Skip to content

Version-aware preflight without breaking v2

Adityo Guni Waluyo

Preflight v3 without breaking the frozen v2 validator.

TL;DR

V2 preflight blocked v3 configs, so a new pagesync preflight now validates v3 decisions and Pages while leaving the frozen v2 engine alone. Duplicate validators stay aligned via a shared adversarial test table that requires matching accept/reject outcomes and surfaces traversal errors first. Page sync verifies what Plane stored via retrieve-after-write name and hash checks instead of trusting hidden markers.

Configuration versions need separate contracts

After the v2-to-v3 migration, I found a configuration that never reached the Pages engine. The file .pm/plane.json was not malformed. The old preflight stopped too early: sync.py still owned the v2 validation contract and rejected a v3 configuration. Commit 122997e fixes the entry path without changing the deliberately frozen v2 engine.

The problem was not that the old validator had become bad. It was being asked to judge a configuration outside its contract. The v2 work-item engine still has to protect v2 repositories, while the v3 configuration has a Pages block and a different shape for its decisions half. Treating one preflight as a universal gate makes the v3 path unreachable.

The fix is intentionally not a v3 rewrite of sync.py. The commit adds pagesync.validate_decisions_block. This helper validates the decisions half with the same shape rules and message style as the frozen validator, but without v2's meetings requirements. The version-aware pagesync.py preflight then validates both decisions and Pages.

The CLI boundary is now explicit. A v2 repository continues with python3 scripts/sync.py preflight <repo-root>. A v3 repository uses python3 scripts/pagesync.py preflight <repo-root>. Calling the Pages preflight on a v2 configuration still stops with the old initialization contract. That is not an accidental limitation; it preserves the meaning of the v2 check for repositories that have not migrated.

The path part deserves the same discipline. Python's pathlib provides path objects with operating-system-appropriate semantics. [2] It does not replace the safety rule, though: resolve_repo_path(root, rel) must reject absolute paths and traversal before the value is treated as a Markdown candidate.

Duplicated validators need a contract test

The Pages rules live in two skills: plane-init and plane-doc-sync. Runtime imports across those skills are forbidden, so some duplication is intentional. Different behavior is not acceptable.

Commit 037d718 tests the shared contract instead of pretending that both copies must produce identical wording. A table of adversarial configurations runs through both validators. It covers traversal, absolute paths, non-Markdown files, directories outside .docs, empty directory lists, non-hex last_sync digests, empty page_id mappings, and _template.md. The assertion is deliberately about the outcome: both validators accept the case or both reject it.

One ordering detail matters more than matching error strings. The resolver runs before the Markdown-shape check. Therefore ../escape.md fails as traversal, not as a file that merely fails the .docs rule. Error precedence should expose the most specific safety failure available. Otherwise a broad format check hides the actual configuration mistake.

This is exactly the kind of matrix that pytest's @pytest.mark.parametrize supports: one test function can run with multiple argument and fixture sets. [3] The value is not only fewer test functions. The table makes the contract's boundary visible, and it makes drift between duplicated validators cheap to detect.

Verify what the server stored

Commit 67f50e0 applies the same principle to page synchronization. The original v3 design used a hidden HTML marker as page identity. A real probe found that Plane had stored a canonicalized HTML form and removed that marker. The v3.1 design moves identity to the configured page_id and a page-name convention. The name helps humans search; an existing mapping always joins by ID.

A write is no longer treated as proof that the server retained the request byte for byte. After an update, the page is retrieved immediately. Its name must match the convention, and its body is compared through the normalized hash pipeline. Only editor transformations observed in the fixture are tolerated. Unsupported forms remain fail-loud.

That rule follows the API boundary. Plane's page-content documentation says that HTML is sanitized, converted to the editor document format, and that the complete page body is replaced during an update. [4] Retrieve-after-write verification is therefore stronger than checking only the outgoing request. Python's HTMLParser provides handlers for tags, text, comments, and other markup, but a parser is not a reason to accept an editor form that has not been observed. [5]

I prefer this boundary because every change has a clear owner: v2 stays frozen, v3 gets its own preflight entry, and the two Pages validators must agree on hostile inputs. A preflight should read the configuration version before choosing its rules, not swing an old contract at every configuration shape.

Sources

Related articles