Skip to content

fix(engine): prevent older binaries from rewriting newer cluster state - #313

Merged
xerj-org merged 1 commit into
xerj-org:mainfrom
probelabs:contrib/cluster-state-format1-fence
Aug 11, 2026
Merged

fix(engine): prevent older binaries from rewriting newer cluster state#313
xerj-org merged 1 commit into
xerj-org:mainfrom
probelabs:contrib/cluster-state-format1-fence

Conversation

@buger

@buger buger commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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.json bytes 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.json remains 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:

  • the process remains live for diagnosis while readiness reports unavailable;
  • authenticated and authorized HTTP storage requests receive typed 503 cluster_state_unavailable, while existing body-size, authentication, and authorization precedence remains unchanged;
  • authenticated gRPC calls receive UNAVAILABLE;
  • no user/system index, WAL, durable auxiliary registry, Console bootstrap, cluster transport, storage-mutating background task, or shutdown index flush is activated;
  • the rejected live document and cluster_state.tmp remain untouched, with no salvage rewrite.

Supported format-1 cluster-state and managed-storage behavior remains equivalent under the audited comparison boundary.

Implementation

  • Classify cluster_state.json once, before directory discovery and any Index::open.
  • Accept only the exact seven-field format-1 persistence envelope emitted by the shipped writer, with recursive duplicate-key detection and strict typed shapes for index templates and data streams.
  • Construct a diagnostic in-memory Engine shell for unsupported, malformed, incompatible, or unreadable state.
  • Skip durable audit/API-key/alias/ISM/ML registries, Console bootstrap/routes, cluster transport, lifecycle work, periodic flushing, resource sampling, ingest tracing, context sweepers, and final index flush while blocked.
  • Apply the HTTP fence after the existing decompression, body-size, authentication, and authorization gates.
  • Apply the gRPC fence centrally after authentication across all six current service methods.
  • Guard direct durable Engine, lifecycle, snapshot, alias, API-key, mapping, ISM/ILM, data-stream, create/delete, retry, and CLI entry points before side effects.

Recovery

Restore a cluster_state.json supported 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.lock acquisition/PID diagnostics, and credential/TLS preparation that precedes Engine construction remain outside this storage fence.

Verified

Verified on exact parent/head
18f85755b2f25d2a4eefb6b7539a129f017ef6b9 -> d8b243023f3c325c3c433cd384ad74ca4e12af51:

  • the final branch is one commit, its worktree is clean, and both committed-range and worktree diff checks pass;
  • nine focused fence commands executed 24 passing tests and 0 failures;
  • complete xerj-common, xerj-engine, xerj-api, xerj-console-api, xerj-server, and xerj-autoindex package 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 current ES-YAML suite passed 1,366 cases, failed 0, and skipped 3 (1,369 total);
  • the fresh causal A/B established the parent/candidate failure difference described above, stable candidate storage inventories across both blocked boots, supported format-1 equivalence under the audited boundary-C comparison, and typed refusal without target-index creation through the direct non-HTTP CLI path.

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

  • This is a format-1 compatibility fence, not a format-2 implementation. The tests exercise detection, typed refusal, exact byte preservation, two blocked boots, and operator-directed recovery for a representative synthetic future-format document. No format-2 writer, reader that interprets format 2 as supported state, downgrade, or migration path exists in this PR; adding one requires a separate versioned data-format design.
  • Strict power-loss durability was not tested because this change adds no persistence algorithm. Supported-state writes retain the existing temporary-file write, file sync, atomic rename, and best-effort parent-directory sync behavior. The tested rejected-state contract is no storage activation or publication while live, after mutation attempts and shutdown, and across two restarts; hardware/filesystem power-loss guarantees require a separate fault-injection environment.
  • No performance claim is made. The A/B harness was designed for causal correctness and storage-integrity comparison, not controlled performance measurement, so its build and wrapper times are provenance only. Production binary size was not compared because the qualification binaries use profiling/thin LTO rather than the release fat-LTO profile.
  • Repository policy requires scoped local builds, so no workspace-wide or local fat-LTO build was run. Complete tests for the six relevant packages, strict all-target Clippy for those packages, scoped thin-LTO builds, and the full ES-YAML conformance gate were run instead; release fat LTO remains a CI/release concern.
  • GitHub-hosted checks remain external merge gates and are not counted as local validation.

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.

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)
@cla-bot cla-bot Bot added the cla-signed label Aug 11, 2026
@buger buger changed the title fix(engine): fail closed on unsupported cluster state fix(engine): prevent older binaries from rewriting newer cluster state Aug 11, 2026
@xerj-org
xerj-org merged commit aa142d6 into xerj-org:main Aug 11, 2026
14 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants