fix(engine): prevent older binaries from rewriting newer cluster state - #313
Merged
xerj-org merged 1 commit intoAug 11, 2026
Merged
Conversation
Motivation: A format-1 binary could deserialize a newer cluster_state.json permissively, reconcile it from index directories, and rewrite it as format 1. That downgraded the document, discarded metadata the old binary did not understand, and continued far enough to open indices, replay WALs, and run storage-mutating startup work. A compatibility check that still activates storage is not a compatibility fence. Root cause: Cluster metadata was loaded after storage discovery and its version was not classified through a strict, duplicate-aware format-1 parser before engine activation. Rejected or unreadable metadata also left multiple mutation paths reachable through startup tasks, HTTP, gRPC, lifecycle helpers, and direct Engine methods. Some boot-only registries loaded their own persisted files even when the main cluster-state contract had failed. Change: Classify cluster_state.json before any user or system index is opened. Accept only the exact format-1 envelope, reject duplicate keys and unsupported discriminator shapes, and construct a diagnostic in-memory engine shell when metadata is unavailable. Keep liveness available while readiness reports unavailable. Do not scan or open indices, replay WALs, load durable audit/API-key/alias/ISM/ML state, start storage-mutating background work, bootstrap the console, join cluster transport, or flush on shutdown in the blocked state. Leave cluster_state.json and cluster_state.tmp untouched and do not create a salvage rewrite. Preserve the existing HTTP body-limit, authentication, and authorization precedence before the storage fence. Return typed cluster_state_unavailable only after those gates. Fence all six gRPC methods centrally after authentication. Add pre-mutation typed guards to durable Engine and lifecycle entry points, including the crate-visible data-stream backing-index detach helper, and propagate every result. Zero-storage-activation contract: An unsupported, malformed, or unreadable cluster-state document may start only a diagnostic shell. The old binary must not create, open, delete, replay, repair, downgrade, quarantine, or publish indexed user/system storage. Exact format 1 retains the existing behavior. Recovery is operator-directed: restore supported metadata or run a binary that understands the stored format, then restart. Validation: - parser/preflight unit tests: 10 passed - cluster-state compatibility integration: 7 passed - direct durable-mutator inventory test: passed - lifecycle direct-detach/delete causal test: passed - blocked AppState ML and HTTP 413/401/403/503 ordering test: passed - centralized six-method gRPC authentication/fence test: passed - xerj-engine library: 486 passed - xerj-common, xerj-api, xerj-console-api, and xerj-server package suites: passed - scoped five-package all-target Clippy with -D warnings: passed - cargo fmt and diff checks: passed A broader engine-package run also exposed an unrelated pre-existing stack overflow in the Painless rescore depth test. This commit does not change the Painless evaluator; that result is disclosed rather than counted as a fence failure. Limits: This does not implement format 2, metadata migration, automatic repair, or a new transactional store. Process-control node.lock contents and credential/TLS initialization that precedes Engine construction remain outside the storage fence. Supported-state writes retain the existing temporary-file, file-sync, atomic-rename, and best-effort parent-directory-sync semantics. No throughput claim is made. AI disclosure: Codex assisted with implementation, adversarial review, test construction, and evidence orchestration. Every claim above is tied to inspected source or an executed command; the final candidate is kept as one reviewable commit with no Co-Authored-By trailer. (cherry picked from commit fc05957e7b1a3196c69d7141f8885c8ea303d917)
xerj-org
added a commit
that referenced
this pull request
Aug 11, 2026
PR #313 (the cluster-state version guard: older binaries can no longer rewrite newer cluster state) merged to main at 2026-08-11T21:45Z — after this branch's review snapshot listed it among the in-flight open PRs. Landing the roadmap with a merged PR described as open would reintroduce, on day zero, exactly the staleness class this branch adds tests against. The in-flight list now names only the PRs that are actually open (#258, #274); #313 is recorded as merged, with a pointer to its CHANGELOG.md entry under the rc.16 heading (where PR #313 itself filed it). Docs-only; docs_capability_lists stays 15 passed / 0 failed.
xerj-org
added a commit
that referenced
this pull request
Aug 12, 2026
…build on release staleness (#298) Issue #298 pins ROADMAP.md as the authoritative "where the project is going" document and advertises that parts of it are machine-checked. At HEAD (31d1fd5, the `release: cut 1.0.0-rc.16` commit) the file had drifted from every promise the issue makes: - "Last reviewed: … against v1.0.0-rc.15" while CHANGELOG.md already records 1.0.0-rc.16 as cut. - "## Next release — v1.0.0-rc.16" described the release that had just shipped: five PRs listed "in flight" (#290 #281 #261 #296 #297) had merged; #258 alone remains open. - Closed issues listed as open roadmap items: #293 (fixed by #304), and GA gate entries #270 (PR #300), #269 (PR #308), #207 (PR #261), plus the closed #204 umbrella. - The ES-YAML count read 1,365/1,368 against a measured 1,366/1,369. All 13 existing docs_capability_lists tests were green the whole time: they pin capability counts, but nothing tied the roadmap to the release history, so a cut that forgets to roll the roadmap forward passed CI. Fix, in the same shape as the #211 capability-list guard: - Two new tests in docs_capability_lists.rs pin ROADMAP.md to CHANGELOG.md's latest `## [<version>]` heading: `the_roadmap_next_release_has_not_already_been_cut` (the next-release heading must name a version strictly newer than the latest cut) and `the_roadmap_review_line_names_the_latest_cut_release` (the review line must name the latest cut release). Both FAIL before this ROADMAP.md refresh (verified: 13 passed / 2 failed at HEAD) and pass after it. Cutting rc.18 without re-reviewing ROADMAP.md now fails the build. - ROADMAP.md re-reviewed against v1.0.0-rc.16 and main: rc.16 recorded as cut with the roadmap items it retired; next-release section now points at the v1.0.0-rc.17 milestone with the real open set (#294 carried over; residuals #310 #311 #312; PRs #258 #313 #274 in flight); GA gate list drops the shipped items (#270 #269 #207) and reflects the closed #204 umbrella; conformance count updated. - The same stale 1,365/1,368 count fixed in README.md (badge + prose), landing/llms.txt and landing/llms-full.txt. The fresh number traces to a verified run: CI job 93861837729 on 31d1fd5 — "1366 passed · 0 failed · 3 skipped · 1369 total". GitHub-side, per the issue's "every open issue is triaged onto a milestone": milestone v1.0.0-rc.17 created, #294/#310/#311/#312 moved onto it, milestone v1.0.0-rc.16 closed. Gate: cargo fmt --check clean, clippy clean, docs_capability_lists 15 passed / 0 failed. No engine runtime code is touched (the new file is test-only), so the ES-YAML suite is unchanged from the green run above; PR CI re-runs it. Files: - engine/crates/xerj-engine/tests/docs_capability_lists.rs (release freshness section, end of file) - ROADMAP.md, README.md, landing/llms.txt, landing/llms-full.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this matters
Consider an older or stale XERJ binary started against a data directory already written by a newer binary.
Before this change, the format-1 loader did not reject unfamiliar
cluster_state.jsonbytes before storage discovery. The older binary could scan existing indices, retain only metadata fields it understood, and later replace the live document with its own reduced format-1 representation. That silently discarded newer control-plane and publication metadata while WAL replay, Console bootstrap, background storage work, and request-driven mutations remained reachable. This is metadata loss, even though the file itself is rewritten rather than deleted.A source-isolated causal A/B reproduced the failure with a representative synthetic future-format document; this does not claim that format 2 currently ships. The exact parent rewrote the 653-byte document as a 364-byte format-1 document, discarded the future control-plane/publication metadata, reconciled state from index directories, and accepted index-template management PUT and DELETE. The candidate preserved all 653 bytes exactly across two blocked boots and rejected the same mutations with a typed error.
Compatibility effect
This does not prevent rollback while
cluster_state.jsonremains in the exact shipped format-1 schema. That path is covered by byte-stable restart, supported mutation, and parent/candidate equivalence tests.It intentionally refuses rollback after a newer binary has written persistent state the older binary cannot fully understand, including a higher or alternate discriminator or unknown format-1 envelope and typed-record fields. The previous behavior was not safe compatibility: the older binary could silently discard that metadata and overwrite the newer state. This change chooses explicit storage unavailability requiring operator action over allowing an incompatible binary to overwrite control-plane metadata it cannot represent.
Rolling back across a future schema change therefore requires restoring supported format-1 bytes or running an explicit downgrade/migration using software that understands both schemas; otherwise the operator must continue with a compatible binary. Any downgrade path must define how every persisted field and any affected index/WAL invariant is handled, and may need to refuse conversion when the older schema cannot represent the state. This PR does not implement such a conversion.
Result
XERJ now classifies persisted cluster state before opening any user or system index or replaying a WAL. If the document is newer than this binary, malformed, duplicate-keyed, schema-incompatible, or unreadable:
503 cluster_state_unavailable, while existing body-size, authentication, and authorization precedence remains unchanged;UNAVAILABLE;cluster_state.tmpremain untouched, with no salvage rewrite.Supported format-1 cluster-state and managed-storage behavior remains equivalent under the audited comparison boundary.
Implementation
cluster_state.jsononce, before directory discovery and anyIndex::open.Recovery
Restore a
cluster_state.jsonsupported by this binary or run a binary that understands the stored format, then restart the node. XERJ does not automatically repair, downgrade, quarantine, or migrate rejected state.Data-directory creation,
node.lockacquisition/PID diagnostics, and credential/TLS preparation that precedes Engine construction remain outside this storage fence.Verified
Verified on exact parent/head
18f85755b2f25d2a4eefb6b7539a129f017ef6b9 -> d8b243023f3c325c3c433cd384ad74ca4e12af51:xerj-common,xerj-engine,xerj-api,xerj-console-api,xerj-server, andxerj-autoindexpackage gates executed 1,917 passing tests, 0 failures, and 26 declared ignored tests;cargo fmt --all --check, scoped server check, strict six-package all-target Clippy with-D warnings, and scoped thin-LTO server/runner builds passed;The A/B outputs are machine-local validation records, not checked-in review artifacts. The tracked regression tests and reproducible fixture under
engine/crates/xerj-engine/tests/fixtures/cluster_state/carry the public review contract. No previous-candidate runtime result is reused for this tree.Scope boundaries and validation policy
Reproduction coverage
The tracked tests cover exact format-1 acceptance and rewrite stability; future, malformed, duplicate-key, unknown-field, and unreadable classifications; two blocked boots without WAL/index/audit mutation; staging-file preservation; direct durable mutators; lifecycle delete/detach ordering; HTTP 413/401/403/503 precedence; native and ES-compatible storage routes; AppState ML registry isolation; Console bootstrap side effects; all six gRPC methods; readiness/liveness; snapshot restore; and recovery after installing supported bytes and restarting.