Binding Mirrored Pages by Content, Not Names
Name-based bindings fork silently under renames; pagesync moves page identity into a hidden HTML marker plus a normalized sha256 drift hash.
TL;DR
The doc-sync tool mirrors markdown files from the docs folder to Plane Pages using content-shaped identity rather than names. A hidden HTML comment holding the source path sits as the first line and Plane preserves it verbatim as the sync authority. Strict tiers, sha256 drift checks on normalized markdown, and hard config validation keep sync reliable through renames and edits.
The Hidden Marker
I was about to bind mirrored pages by their page name while writing the pagesync module for the doc-sync tool. Then I stopped. Names belong to humans; a renaming editor would fork a name-based binding silently. I needed a mechanism that survives renames and ID churn without breaking the synchronization link, and that is why identity in v3 is content-shaped. The tool mirrors markdown files from .docs to self-hosted Plane Pages; this commit adds the pages engine primitives: identity, naming, tier, config validation, and drift hashes.
The identity is a hidden HTML-comment marker as the first line of the page body, holding the source file's relative path. It looks exactly like this:
<!-- plane-doc-sync:v3 path=.docs/MEETING/2026-09-27-weekly.md -->
# Meeting Notes
Actual content starts here
Per MDN, an HTML comment adds explanatory notes or prevents the browser from interpreting parts of the document, and the browser never renders it [1]. The live probe the day and night before had shown that Plane Community Edition round-trips page HTML verbatim, comments included. The official docs warn more broadly that Plane sanitizes HTML server-side [2], so the marker design leans on measured CE behavior, treating the docs as the conservative ceiling. Missing or malformed marker? The page classifies as blocked immediately. The marker path must agree with the config item path; page_id may sit in the config as the API handle, but it never binds a page to a file.
Why hidden? In v2 the marker lived on work items whose HTML is sanitized server-side, so comments got stripped. Pages do not strip them. Storage shape dictates identity shape.
Naming for Humans, Strict Tiers
Naming goes like this: MEETING: YYYY-MM-DD — for two-way files, DOC: for one-way library docs, so a meeting file at .docs/MEETING/2026-09-27-weekly.md becomes a page named MEETING: 2026-09-27 — Weekly. The title comes from frontmatter when present, otherwise it is rebuilt from date plus file slug. Names are pleasant to read, and their role stops there. Plane pages are flat (create ignores the parent), so structure lives in the name, not in a folder hierarchy.
Tier resolution is deliberately unambiguous. A file under both a twoway and an oneway directory resolves twoway, period; one file never gets two treatments. Outside both, error, not a guess. One-way pages are publish-only: pull is refused by design, because the direction is repo-to-page only. With content-shaped identity as the authority, these rules stay easy to run: a page's status comes from the marker plus the hash, not from vibes.
Drift and Config Validation
Drift authority is sha256 over the normalized markdown on both sides. The repo side strips YAML frontmatter before hashing, so local metadata never influences drift. The plane side hashes the markdown pulled back through the converter; pagesync normalizes before hashing, so harmless formatting changes do not trigger false drift alerts. Python ships sha256 among hashlib's always-present constructors, though the docs carry a FIPS caveat that some hash functions provide less collision resistance than expected [3]. That is why the config validator pins the algorithm explicitly instead of trusting ambient defaults.
Config validation has hard edges too. Every item path must resolve inside the repo; a traversal path is a config bug and hard-stops, the OWASP path traversal class of problem [4]. The last_sync field must hold two 64-character hex digests plus timestamps. A legacy v2 config touching pages actions dies with one exact hard-stop message, every time.
The lesson from this small module: content-shaped identity survives renames, editor rewrites, and ID churn. Name-shaped or title-shaped identity breaks the first time someone renames a page.