epiphany/spec/CONTRACT_FORMAT_EPOCH_MAJOR...

42 KiB
Raw Blame History

Contract — Format epoch: container major 1

Status: RATIFIED after four adversarial review rounds; AMENDED 2026-08-07 in a ratified forward amendment of its own — pin 3c, touch rows 10 and 11, gate 8 — closing a gap found by reconnaissance before dispatch: pin 3a's refusals reach a conformance-suite criterion through a file the touch table did not carry. Not yet implemented. The pins are otherwise frozen — they may be executed, not edited. A defect found during execution is reported, not patched in place; that is how pin 3c came to exist rather than being absorbed silently into the work.

Track: format epoch. Not a Pass 13 rung. P13-S28 is its dependency record in spec/PASS13_CANDIDATES.md and points here; this contract is where the work lives. It is sequenced ahead of P13-S27, which is ahead of P13-S16.

Rung type: container format-major boundary. A new reader must deliberately decode major 0 as legacy; old readers reject major 1 by the mechanism that already exists.

Rulings taken 2026-07-31, not re-opened here:

  1. The carrier is the format major. No generation-scoped attestation in this epoch — a superblock attestation cannot make pre-boundary readers fail closed, and once a major-1 container is the boundary, every writer able to open one is necessarily epoch-aware, so P13-S27's capabilities already validate every base replacement. The extra field would add wire and re-derivation complexity while solving no additional case.
  2. Legacy resolves to hard rejection, not read-only. A pre-authority base is not safe materialized state.
  3. Its own track, per above.

§0. What was verified before drafting

Read out of the working tree at 818a16f.

0.1 The boundary has the right polarity already, in one direction only

FixedHeader::decode (header.rs:119) rejects on format_major != FORMAT_MAJOR with BundleError::UnsupportedFormatVersion. FORMAT_MAJOR = 0, FORMAT_MINOR = 1 (:39, :42).

So old readers already fail closed on a major they do not know — that half needs no new mechanism and is why the major is the correct carrier. The half that must be built is the other one: today's decoder is exact-major-only, so a new reader would reject major 0 outright. It must instead decode major 0 deliberately, as legacy.

0.2 The surface is unusually small

FORMAT_MAJOR appears in exactly four places workspace-wide: header.rs:39 (definition), :68 (stamped into every new header), :119 (the accept check), and lib.rs:84 (re-export). There is no second parallel table.

0.3 No committed byte artifact pins a header — verified, not assumed

spec/vectors/decode_vectors.txt covers bundle.block, bundle.manifest and bundle.operation_index; it has no header or superblock surface. The only other committed byte artifact is spec/vectors/textproj_document_vectors.txt, which is text-projection documents. A repository-wide search for *.bin, *.bundle, *.epi* outside target/ and the out-of-bounds trees finds nothing.

Consequence, and its limit. No binary frozen file pins a header, so the major bump itself regenerates nothing there, and "repack" describes what a user's bundle needs rather than an in-tree artifact.

That conclusion does NOT extend to the text companion, and an earlier draft of this contract wrongly generalized it into a blanket "no vector file may change" gate. spec/vectors/textproj_document_vectors.txt carries base-bearing documents (textproj/src/vectors.rs:353, :363), and pin 3b makes projecting, parsing and serializing such a document an error.

That file WILL change, and how it changes is specified — not left as an unknown deliverable. A second earlier draft downgraded the question to "report whether it moved"; that is not ratifiable either. The corpus was decoded (it is hex-encoded; see pin 3b) and the exact required edits are pinned in pin 3b and touch row 5c.

0.4 The header cannot carry per-generation state, which is why this works

core_spec.tex:10799:10800: "The header never changes after the file is created." commit_versioned (bundle.rs:791) publishes only a superblock. That immutability is exactly what disqualified FORMAT_MINOR as a provenance carrier (P13-S27 pin 2a, rejected option iv) and is exactly what makes the major sound as an epoch carrier: an epoch is a property of the file's creation, and must not drift with later commits.


§1. Pins

Pin 1 — FORMAT_MAJOR becomes 1; FORMAT_MINOR resets to 0. A new major restarts minor numbering. Both constants get doc comments naming this contract and stating what the epoch means: a major-1 container is one whose every base-bearing commit was validated against a supplied reduction authority.

Pin 2 — the decoder becomes deliberately legacy-aware. header.rs:119's exact-major test is replaced by an explicit three-way classification, and the retained major must be carried on FixedHeader so callers can act on it:

  • 0legacy, decoded and marked as such;
  • 1current;
  • anything else → UnsupportedFormatVersion, unchanged.

A boolean is not sufficient — introduce a named enum (e.g. FormatEpoch) so the legacy case is a value the type system carries, not a comparison re-derived at each use site. Every consumer of the epoch reads that value.

Pin 3 — the epoch matrix is normative and is implemented exactly.

# Container Canonical base Behaviour
1 major 0 none may open — nothing unverifiable is exposed
2 major 0 present hard reject at open
3 major 0 commit attempts to add or replace one reject; require repack into a fresh major-1 bundle
4 major 1 none may open; may commit non-base-bearing history
5i major 1 present at open INTERIM (this rung → P13-S27): refuse, third error
5 major 1 present at open post-S27: opens; base validated against the supplied current authority
6i major 1 commit attempts to add or replace one INTERIM (this rung → P13-S27): refuse, third error
6 major 1 commit attempts to add or replace one post-S27: validates against the supplied current authority

Rows 5i and 6i are what this rung actually implements; rows 5 and 6 are what P13-S27 replaces them with. An earlier draft's matrix stated only the post-S27 behaviour while pin 3a implemented the interim one — the contract described a container this rung does not build. Both states are now written, and the interim rows are the normative ones for this rung's tests.

Row 3 is the one that would be missed: a legacy bundle that opens cleanly under row 1 must not become base-bearing in place, because its header can never say it was validated. This is the rule that makes the epoch non-inheritable, and it is why the boundary works where FORMAT_MINOR did not.

Corruption precedence is preserved, and it is not automatic. It binds in BOTH epochs. A container whose base's version disagrees with its superblock's is corrupt, and MUST still fail with the existing malformed-bundle DecodeError (bundle.rs:396:401) before any of pin 4's three epoch errors is considered — row 2's legacy error in a major-0 container, and equally row 5i's ReductionAuthorityUnavailable in a major-1 one.

The major-1 half is the one an earlier draft missed. It pinned precedence only for legacy containers, so a corrupt major-1 base could be reported as "authority unavailable" — a temporary condition a user would reasonably retry after P13-S27 — if the new epoch check were placed ahead of the existing malformed check at bundle.rs:396. Test 11 cannot catch this: it uses a self-consistent base by construction, so the malformed branch never runs in it. Ordering the new checks first would collapse tampering into staleness and silently erase the very distinction P13-S27 rests on (its §0.1 and pin 6). Test 7 pins the order in both epochs.

Pin 3a — the epoch may not assert what is not yet enforced. SEQUENCING. This contract's first draft stamped major 1 while §6 forbade implementing P13-S27's capability — so between this rung and S27, Bundle::create would mint major-1 containers and commit_versioned would still copy any carried base version unchecked. That is precisely the false provenance the epoch exists to exclude, minted by the mechanism meant to prevent it, and test 4 could not truthfully claim a major-1 bundle "validates its base."

A write-side refusal alone is insufficient, and an earlier draft of this pin stopped there. open (bundle.rs:393ff) accepts a matching base/superblock pair, and S28 adds no authority capability — so a major-1 bundle that already carries a self-consistent base would still open during the interval, its epoch asserting a validation that never ran.

Resolution: until P13-S27 lands, BOTH boundaries are temporarily closed:

  • open a major-1 container that already carries a canonical base → refuse;
  • commit a base into a major-1 container → refuse.

This needs a THIRD error, distinct from both legacy errors — carried as a first-class member of pin 4's inventory, not as a footnote to this pin. A major-1 container does not need repacking — it is the right epoch; what is missing is S27's authority check. Name the condition for what it is (ReductionAuthorityUnavailable), and assert in tests that it is neither legacy error. Reusing a repack error would tell a user to repack a container that is already correct.

Both branches are temporary and must be marked as such in code, naming P13-S27 as what replaces them. S27 converts both into capability validation — not one of them.

(The alternative — co-landing this rung with S27's capability and every write-path validation — was considered and not taken: it merges two large rungs and loses the separate ratification each has already had. If you prefer it, this pin is where it changes.)

Pin 3b — text projection cannot mint a canonical base. TEXT RULE. TextDocument (textproj/src/lib.rs:74ff) has no container-major or epoch field, and the projector deliberately drops physical layout (project.rs:29ff, req:textproj:derive-or-carry). Meanwhile parse.rs:591 accepts an unbounded u32 reduction version and serialize_document (serialize.rs:119) creates a fresh bundle while build_manifest (:216) copies the carried base verbatim. So an old or hand-authored text document can be serialized into a brand-new major-1 container, and once its raw version matches the current authority nothing downstream can tell it from a validated base. Text import is a laundering path straight through the boundary.

Ruled: symmetric document-level refusal. A one-sided serialize refusal would leave the companion incoherent — project_bundle emits canonical-base text (project.rs:479, :537) and parse accepts it, so text carrying a base could be produced and read but never serialized, while req:textproj:roundtrip (text_projection.tex:903ff) quantifies over every bundle and every valid text. All three sides move together:

  • projection of a base-bearing bundle → error;
  • parsing of base-bearing text → error;
  • serialization → a new, dedicated SerializeError variant, as defence for a directly constructed TextDocument.

All three are additions. Serialization has no refusal to "retain" today — an earlier draft said it did, and that was simply false: serialize_document (serialize.rs:119) stages the carried base as a Snapshot chunk (serialize.rs:131:141) and build_manifest (:216) writes it into the new manifest. Its documented error set is NonEmptyBlobs and Bundle (serialize.rs:116:118); neither covers this. The refusal must be built, and it must be its own variant rather than a SerializeError::Bundle passthrough — see M8 for why that distinction is load-bearing.

Scope of that rule — text only. Text projection may introduce a canonical base only through an explicit rebuild/repack flow (pin 5). It does not say a canonical base may only ever be created that way: matrix rows 5/6 admit validated base introduction in major-1 containers once P13-S27 lands, and ordinary snapshot producers remain governed by S27, not by this pin. An earlier draft stated the rule unscoped, which contradicted both.

req:textproj:roundtrip MUST be amended to state the exclusion in its own terms — "round trips excepted" is not sufficient: the requirement quantifies universally and must say what is now outside its domain and why.

Why not the alternative — carrying provenance through the text companion, with absent/old classified legacy: manifest_schema_version is the existing precedent for a carried-verbatim field, and its own doc concedes "the document author is responsible for updating it." A text format cannot carry unforgeable provenance; any field it defines can be typed by hand. Carrying a provenance marker would therefore reduce to trusting the author, which is exactly what the epoch was built not to do — it would relocate the laundering one level up rather than close it. Refusal is the only rule the medium can actually enforce.

This is a real capability loss and must be stated, not softened: base-bearing documents stop round-tripping through text until a repack flow exists.

COMPANION_VERSION MUST bump 0.13.0 → 0.14.0. Not "determine whether it moves" — an earlier draft left this open and it is not a ratifiable instruction. Refusing a document the companion previously serialized is a semantic change, and parse_header (parse.rs:397) rejects every version but the exact COMPANION_VERSION (lib.rs:59), so the bump is load-bearing rather than cosmetic.

Consequence, verified by decoding the corpus (it is hex-encoded, so a plaintext grep proves nothing — an earlier draft's grep returned zero and proved nothing at all): spec/vectors/textproj_document_vectors.txt holds 19 vectors — 10 accept and 9 reject. Eighteen carry header (0 13 0); one carries (0 12 0). Six carry canonical-base. So lib.rs, parse.rs, and the corpus file are mandatory touch rows.

The header bump reaches 18 rows, not 10. All 10 accepts move (0 13 0)(0 14 0). So do the 8 rejection vectors that also carry (0 13 0) (unreferenced_blob, canonical_base_before_extension, lineage_repeated, envelopes_reversed, profiles_reversed, extensions_reversed, extension_chunks_reversed, final_lf_missing). If they are left at (0 13 0) they still reject — at the header, not at the predicate each was written to exercise. They would pass their declared verdict while testing nothing, which is precisely the silently-degrading corpus this rung must not create.

superseded_companion_version moves (0 12 0)(0 13 0). Its purpose (vectors.rs:559:565) is to reject the immediately superseded companion; after the bump that is 0.13.0. Leaving it at 0.12.0 would make it assert the rejection of a two-generation-old version and stop exercising the deferred migrate-on-read posture it was written for.

The corpus takes ONE complete shape, specified here in full. An earlier draft left rich_document as "reject or re-derived" and the new class count as n; neither is executable, and the choice is not free — the only two accepted documents carrying extensions are the two base-bearing ones (extension_base_multi at vectors.rs:344, rich at :357). Converting one to reject and freeing the other drops extensions and multi_envelope reach from 2 to 1; converting both drops them to 0. The disposition therefore decides coverage, not just row count.

Ruled shape: 20 vectors — 10 accepts, 10 rejects, ten rejection classes.

Accepts (10, all base-free):

Vector Change
extension_base_two_envelopes — the extension_base_multi document (:344, exported :461) base removed; extensions, two envelopes, non-baseline schema version all retained. Its exported name must stop claiming a base it no longer carries — and the rename reaches by_name at :551
rich_document — the rich document (:357, exported :464) base removed; two extensions, lineage, custom profiles, envelopes all retained
the other 8 header only

Rejects (10):

Vector Change
superseded_companion_version (0 12 0)(0 13 0), per above
canonical_base_before_extension re-expressed with a non-base section pair. The order is header document lineage? profile* extension* canonical-base? blob* envelope* (parse.rs:45), so a lineage/profile or profile/extension inversion reaches out-of-order-sections without a base
envelopes_reversed, extensions_reversed, extension_chunks_reversed nothing beyond the header — they are derived from the two accepts above (:598, :614, :630, :633), so freeing those accepts frees these automatically. Confirm it rather than assume it
unreferenced_blob, lineage_repeated, profiles_reversed, final_lf_missing header only
NEW: canonical_base_present class canonical-base-unsupported — a base-bearing text, refused by pin 3b's parse side. Build it from the pre-change base-bearing spelling, so the corpus keeps a base-bearing text as a negative rather than losing the spelling entirely

Why this shape rather than converting the two accepts to rejects: it preserves extensions: 2 and multi_envelope: 2 exactly, confines the reach loss to the one capability pin 3b actually removes, and makes the new class carry a purpose- built vector instead of a demoted accept that also happened to test three other things.

expected_reach() — every count, stated:

Field Before After
extensions 2 2
canonical_bases 2 0
custom_profiles 2 2
lineages 2 2
multi_envelope 2 2
reject_classes nine classes × 1 ten classes × 1 — the nine existing plus canonical-base-unsupported

canonical_bases: 0 is a real reach loss and its doc comment (vectors.rs:124:126) must record the cause — canonical bases are no longer reachable through text at all — and the "nine distinct rejection classes" wording moves to ten. Silently lowering a non-vacuity count without recording why converts a stated capability loss into an unexplained weakened assertion.

Three further count sites move with it, each verified present:

  • vectors.rs:889 asserts the corpus has exactly 19 rows ("the corpus has unexpectedly thinned") → 20;
  • t12_g3b_kinds_round_trip_and_companion_is_0_13_0_rejecting_0_12_0 (vectors.rs:969, asserting COMPANION_VERSION == (0, 13, 0) at :971) — both its body and its name move to 0.14.0/0.13.0;
  • parse.rs:658's test HEADER constant, and text_projection.tex:486 and :1146, which spell (0 13 0) literally.

The four base-bearing reject rows were the dangerous ones: they would still reject after pin 3b while their declared class is informative only (vectors.rs:67), so reject_classes would keep counting them long after the predicate each names went untested.

Pin 3c — the interval's conformance cost is bounded, marked, and owed back. AMENDED 2026-08-07, after ratification, on a finding from execution reconnaissance.

Pin 3a's refusals close every path to a base-bearing container, which the pins did not say out loud: create already rejects a base-bearing manifest (bundle.rs:233:240), row 3 refuses committing one into major 0 and row 6i into major 1, and rows 2 and 5i refuse opening one in either epoch. So for the S28 → P13-S27 interval no bundle anywhere may carry a canonical base — in production, in tests, or in the conformance suite.

That reaches one file outside the original touch table and one criterion: roundtrip::assert_reduction_serialization_stable (roundtrip.rs:241, base declared at :270) is criterion 4's bookkeeping-projection counterpart (testkit/src/lib.rs:86), driven from tests/acceptance.rs:135 and examples/conformance_suite.rs:60. It commits the canonical state as the canonical base and reopens the image; pin 3a refuses both halves. Gate 1 could not have passed, and §6 would have forbidden staging the fix.

Only the canonical-base wiring is suspended. Criterion 4's cycle keeps running. The finding was first reported as "the assertion can only be suspended" — too strong, and verified false before this pin was written. The serialize → load → decode → reserialize cycle does not depend on the snapshot being the canonical base: read_chunk (bundle.rs:509:511) hash-verifies any ChunkRef through read_and_verify_chunk (:1002). The harness therefore keeps committing the snapshot chunk and reads it back by its ref; the criterion survives intact.

Exactly two assertions lapse, both canonical-base-specific:

  1. verify_canonical_chunks's base branch (bundle.rs:613:621), including the base.hash != base.root.hash cross-check;
  2. the reopened manifest actually carrying the base (roundtrip.rs:293:297).

Do NOT re-home the snapshot to acceleration_snapshots. That field appears nowhere in bundle.rs — not in open, not in verify_canonical_chunks — so the reference would verify nothing while looking like preserved coverage. This is the single most tempting wrong repair here, and it is forbidden.

The lapse is marked, never absorbed:

  • at the point the base declaration is removed, a comment naming P13-S27 and this pin;
  • the harness's doc paragraph (roundtrip.rs:228:240) describing the canonical base as the snapshot's "correct semantic home" is amended to state the suspension, not deleted — a deleted paragraph leaves S27 nothing to restore against;
  • both lapsed assertions are recorded as owed in S27's contract (touch row 9), beside pin 8 and M8's deferred demonstration.

testkit/benches/bundle.rs:104 (build_fixture, base at :133) takes the same treatment. It is the [[bench]] name = "bundle" target and not the out-of-bounds editor_pipeline.rs.

One trap, named because it will be met. bundle.rs:1487 (a_canonical_base_stamped_above_major_0_opens_read_only) both commits a base — so it breaks — and asserts read-only + anomaly for a base fault. It is on a different axis (data-model schema major, not container format major) and does not contradict pin 4. Do not "harmonize" pin 4's errors toward it; pin 4 forbids read-only for all three of its errors.

Pin 4 — THREE distinct errors, and none is read-only. An earlier draft specified two, then pin 3a introduced a third without amending this pin — leaving the error inventory, gate 6 and M11 all describing a two-error design. The full set:

Error Raised by Lifetime Message
legacy-base matrix row 2 permanent names repack
legacy-base-introduction matrix row 3 permanent names repack
authority-unavailable (ReductionAuthorityUnavailable) matrix rows 5i, 6i temporary — P13-S27 removes it names P13-S27; MUST NOT mention repack
  • row 2 → the document already contains unverifiable canonical state;
  • row 3 → the document is fine, but the operation requested cannot be performed in this container;
  • rows 5i/6i → the container is the right epoch; the reader has not yet been given the authority to validate its base. Repacking would be wrong advice, so the message must not offer it.

All three are BundleError variants (that type has no discriminant and no encoder — verified — so this is a pure API change). None degrades to read-only: a pre-authority base is not a restricted-but-correct view, and exposing it read-only would serve unverifiable canonical state confidently.

The three must be mutually distinguishable in tests, not merely distinct in source: every test that expects one asserts the other two are not produced.

Pin 5 — repack is named, not built. This rung provides no repack implementation. It MUST leave the door open for one and MUST NOT foreclose it: a higher-level, explicitly non-materializing recovery/repack flow may later offer rebuilding when complete history is available. That flow is not a read-only Bundle::open mode, and nothing in this rung may introduce one.

Record this in the errors' doc comments so a later reader does not "helpfully" add the read-only path.

Pin 6 — every writer path stamps and is checked, including text projection. Bundle::create stamps major 1. The production writer that reaches a canonical base is one, not two: textproj::serialize_document (serialize.rs:119, via build_manifest :212). project.rs:936 is inside #[cfg(test)] (the module opens at project.rs:560) and is a fixture writer; an earlier draft of this pin listed it as production, taken from a census without checking its enclosing module. It stays in the test surface. The committed .txt document vectors can declare any reduction_algorithm_version (parse.rs:591 parses an unbounded u32), so text projection is a writer path in the full sense and is enumerated here rather than left to be discovered, as P13-S27's first draft did.

Pin 7 — specification updates. binary_format.tex:1808's header table states format_major is 0 and must carry the epoch and its meaning. core_spec.tex's Fixed Header subsection (:10796ff) gains the legacy-decode rule and the epoch matrix (rows 14 and the post-S27 rows 5/6 only — the interim rows 5i/6i are implementation state, not normative wire semantics, and must not be written into the spec); the major-version semantics near :12467 gain what a major boundary now means beyond wire layout. Revision History rows and version bumps in both.

Pin 8 — P13-S27's precondition is stated where S27 can rely on it. Reduction-version authority is meaningful only in major-1 containers. Record that here and in S27's contract, so S27's pin 2a resolves to: legacy bases are refused by container epoch, never by version arithmetic.

spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md is therefore an edited file of this rung, and touch row 9 carries it. Two things land there, not one:

  1. this pin's major-1 precondition, resolving S27's open pin 2a;
  2. M8's deferred laundering demonstration, which S27 inherits as owed work.

It is still a DRAFT and so may be edited; it is not among the ratified contracts this session may not touch. An earlier draft left it off the touch table while two pins required writing to it — and since §6 stages only the touch table by explicit path, that omission would have silently dropped both.

Pin 9 — the ledger. spec/PASS13_CANDIDATES.md: P13-S28's row points here and records the ruling set. P13-S28 does not execute as a Pass 13 rung. S27 and S16 stay blocked until this contract lands.


§2. Touch table

# File Change
1 crates/epiphany-bundle/src/header.rs pins 1, 2
2 crates/epiphany-bundle/src/bundle.rs pin 3 (open + commit paths)
3 crates/epiphany-bundle/src/error.rs pins 4, 5
4 crates/epiphany-bundle/src/lib.rs re-exports
5 crates/epiphany-textproj/src/serialize.rs pins 6, 3b — the refusal, and the round-trip laws that must now except base-bearing documents
5b crates/epiphany-textproj/src/project.rs pin 6
5c crates/epiphany-textproj/src/vectors.rs and spec/vectors/textproj_document_vectors.txt mandatory, pin 3b's ruled corpus shape: 18 rows carrying (0 13 0)(0 14 0); superseded_companion_version (0 12 0)(0 13 0); both base-bearing accepts re-derived base-free; canonical_base_before_extension re-expressed on a non-base pair; new canonical_base_present reject; corpus 19 → 20 (:889); expected_reach() canonical_bases 2 → 0 with cause, reject_classes nine → ten; t12_…_0_13_0_rejecting_0_12_0 renamed and rebased (:969)
5d crates/epiphany-textproj/src/lib.rs COMPANION_VERSION 0.13.0 → 0.14.0 (:59) — mandatory, not conditional
5e crates/epiphany-textproj/src/parse.rs pin 3b's parse-side refusal; parse_header (:397) accepting only the new version; the test HEADER constant at :658
5f (same file as row 5) SerializeError at serialize.rs:66 pin 3b's new dedicated variant — an addition, not a retained refusal; the doc comment at :116:118 enumerates the error set and must gain it
5g spec/text_projection.tex (+ .pdf) req:textproj:roundtrip (:903ff) amended to state the base-bearing exclusion in its own terms; companion version (:486, :1146 spell (0 13 0) literally) and Revision History rows
6 spec/binary_format.tex (+ .pdf) pin 7
7 spec/core_spec.tex (+ .pdf) pin 7
8 spec/PASS13_CANDIDATES.md pin 9
9 spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md mandatory — pin 8's major-1 precondition (resolving S27's open pin 2a), M8's deferred laundering demonstration, and pin 3c's two owed-back conformance assertions. Still a DRAFT, so editable; not one of the ratified contracts that may not be touched
10 crates/epiphany-testkit/src/roundtrip.rs pin 3c — criterion 4's canonical-base wiring suspended and marked; the cycle itself preserved via a direct ChunkRef read
11 crates/epiphany-testkit/benches/bundle.rs pin 3cbuild_fixture (:104) stops declaring a canonical base at :133. Not benches/editor_pipeline.rs, which is out of bounds

spec/vectors/decode_vectors.txt is NOT touched (§0.3). If a change appears to require regenerating that file, stop and report — it would mean something reads a header where §0.3 found nothing. spec/vectors/textproj_document_vectors.txt IS touched, mandatorily, by pin 3b's companion bump.


§3. Required tests

  1. a_legacy_major_0_bundle_without_a_base_opens
  2. a_legacy_major_0_bundle_with_a_base_is_rejected — asserting the row-2 error specifically.
  3. adding_a_base_to_a_legacy_bundle_is_rejected_and_names_repack — the row-3 error, distinct from row 2's, asserted by variant and message.
  4. a_major_1_bundle_round_trips_and_refuses_to_introduce_a_base — renamed from …_validates_its_base, which pin 3a forbids claiming until P13-S27 lands. The name must not promise validation this rung does not perform. It MUST assert matrix row 6i's authority-unavailable error specifically, and that neither legacy error is produced. An earlier draft left this test asserting only "some failure", which is what made M11 unfalsifiable.
  5. an_unknown_major_is_still_unsupported_format_version — the third arm of pin 2, which a two-way test would silently drop.
  6. text_projection_serialize_produces_a_major_1_container — pin 6.
  7. a_corrupt_base_fails_as_malformed_before_any_epoch_error — pin 3's precedence rule, covering both epochs in one test or two, but covering both. Construct a container whose base version disagrees with its superblock's, once at major 0 and once at major 1, and assert the malformed error each time — not row 2's legacy error, and not row 5i's ReductionAuthorityUnavailable. Renamed from …_corrupt_legacy_base…: the earlier name scoped the guarantee to legacy containers, which is exactly the half that was missing. Without this, S28 silently erases P13-S27's tamper/staleness distinction and every other test still passes — including test 11, whose base is self-consistent by construction.
  8. serializing_a_text_document_with_a_canonical_base_is_refused — pin 3b, asserted against a document built from the existing base-bearing fixture, and asserting the dedicated SerializeError variant specifically — not merely "an error", and not SerializeError::Bundle. M8 depends on that distinction being asserted.
  9. projecting_a_base_bearing_bundle_is_refused — pin 3b's projection side.
  10. parsing_base_bearing_text_is_refused — pin 3b's parse side. Without 9 and 10 both, the companion is left able to produce text it cannot consume.
  11. opening_a_major_1_bundle_that_already_carries_a_base_is_refused — pin 3a's read-side branch (matrix row 5i), the one an earlier draft omitted entirely. Asserted to return the authority-unavailable error and to be neither legacy error.

Tests 2 and 3 must each assert the other's error is not produced; they are the pair pin 4 exists to separate. Test 7 stands in the same relation to test 2. Tests 4 and 11 stand in that relation to both legacy errors.


§4. Mutation plan

Applied, run, output verbatim, restored by hand-editing back.

M1 — the legacy classification is real. Restore the exact-major test; test 1 must fail (a legacy bundle stops opening at all).

M2 — row 2 fires. Remove the base check for legacy containers; test 2 fails.

M3 — row 3 is not row 2. Make the commit path return row 2's error; test 3 fails. Signs that the two situations are separately diagnosable.

M4 — the epoch is non-inheritable. Permit a legacy container to gain a base; test 3 fails. This is the signing mutation of the whole rung — it reintroduces exactly the counterexample that killed FORMAT_MINOR, and the contract's central claim is that a major boundary does not admit it.

M5 — the unknown-major arm survives. Make the classifier treat any non-1 major as legacy; test 5 fails.

M6 — the writer stamps the epoch. Make create stamp major 0; test 6 fails.

M7 — corruption precedence holds in both epochs. Reorder pin 3's checks so the epoch classification runs before the malformed-base check at bundle.rs:396; test 7 fails on both its major-0 and major-1 halves. Run it as a single reorder — one placement decision governs both — but report both failures, because a reorder that only moves the legacy branch would show one failure and look like a pass of the other. Signs that the ordering is deliberate rather than incidental to how the code happens to be written, and that a corrupt major-1 base is never reported as the temporary ReductionAuthorityUnavailable — which a user would reasonably retry after P13-S27 lands, on a container that is in fact tampered with.

M8 — the text boundary is closed. In serialize_document, remove or bypass the base-bearing early-return branch that raises pin 3b's dedicated SerializeError variant. Test 8 must then fail by falling through to pin 3a's interim bundle error (ReductionAuthorityUnavailable, surfacing as SerializeError::Bundle) instead of the dedicated variant. That fall-through is the signature: it shows the text layer's own refusal is what test 8 was asserting, not the container guard standing behind it.

Mutate the branch, NOT the variant declaration. Deleting the variant makes test 8 — which pin 3b requires to name it — fail to compile, and a mutation that does not compile observes nothing at all: no fall-through, no error identity, no evidence. An earlier draft said "remove the variant," which would have produced a compile error and invited the executing agent to report it as the mutation's "failure." A compile error is not a test failure, and this contract does not accept one as mutation evidence anywhere.

Do NOT expect a successful serialization here, and an earlier draft did. That draft required observing that a base-bearing document "does serialize into a major-1 container" with the refusal removed — impossible under pin 3a, which refuses every major-1 base commit outright. Removing the text refusal reaches that guard; it cannot reach success. The mutation as written could not have produced its promised observation.

The laundering demonstration is deferred to P13-S27, where generic authority validation exists and a base commit can succeed or fail on its version rather than being refused categorically. Record that deferral in S27's contract when this rung lands — the demonstration is still owed, just not performable yet.

M9 — the interim write refusal is not vacuous. Remove pin 3a's temporary base-introduction refusal for major-1 containers; test 4 fails.

M10 — the interim READ refusal is not vacuous. Remove pin 3a's open-side branch; test 11 fails. Run this separately from M9 — a single mutation covering both would not show that the two branches are independently present, which is exactly the gap that made the previous draft unsound.

M11 — the third error is genuinely distinct. Make both of pin 3a's major-1 branches return the row-3 repack error instead of ReductionAuthorityUnavailable. Tests 4 and 11 must both fail; test 3 is a control and must stay green.

An earlier draft said "tests 10 and 3 must both fail" — impossible as written. M11 touches only the major-1 branches; test 3 exercises the untouched legacy commit path and passes regardless. That draft's M11 could not have produced the failure it predicted, and would have been reported as an anomaly or quietly re-specified by the executing agent. The corrected form is what makes it signing: test 3 staying green proves the mutation was confined to the major-1 branches, and tests 4 and 11 failing proves both of them assert the third error rather than any repack error.


§5. Gate

  1. cargo test --workspace — full pass; report the new total and its delta.
  2. cargo clippy --workspace --all-targets -- -D warnings → clean.
  3. cargo fmt -p epiphany-bundle -p epiphany-textproj --check → clean. cargo fmt --all is forbidden.
  4. git diff --cached --check clean; staged list exactly §2.
  5. spec/vectors/decode_vectors.txt unmodified — by git status, not inspection (§0.3). spec/vectors/textproj_document_vectors.txt MUST have changed, and the diff must show every edit from touch row 5c: 18 rows to (0 14 0), superseded_companion_version to (0 13 0), both accepts re-derived base-free, canonical_base_before_extension re-expressed, the new canonical_base_present reject, 20 rows total, and expected_reach() at its pinned counts. An unchanged corpus is a failure of this gate, not a clean result — it would mean pin 3b's text boundary was never exercised. A corpus that changed to 20 rows while canonical_bases stayed at 2 is also a failure — it would mean a base survived on the accept side.
  6. No read-only path was added for any of pin 4's three errors: grep -rn "read_only" crates/epiphany-bundle/src/bundle.rs reviewed, and all three new errors shown not to appear in any branch that sets it.
  7. FORMAT_MAJOR == 1 and FORMAT_MINOR == 0, asserted in a test, not only by reading the constants.
  8. No surviving base-declaring site outside a crafted-image fixture or a refusal test (pin 3c): grep -rn "canonical_base = Some\|canonical_base: Some" crates/ --include='*.rs' reviewed hit by hit, each remaining one classified as (a) a hand-built image fixture, (b) a test asserting a refusal, or (c) the text corpus under pin 3b. Any hit that is a live commit path is a failure — it means a base-bearing container is still being minted.

§6. Staging and boundary

Stage only §2's files, by explicit path. Never git add -A.

A concurrent session commits here. Re-check HEAD before staging and before commit. Never git reset, git restore --staged, git checkout, git stash.

Out of bounds — MUST NOT be read, written, or staged: the entire spikes/ tree, spec/PLAN_EDITOR_APP.md, spec/CONTRACT_EDITOR_*.md, spec/ANALYSIS_GENESIS_PERSISTENCE.md, spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md, spec/DRAFT_T4_FIXTURE_RECIPE.md, crates/epiphany-render-svg/**, crates/epiphany-glyphs/**, crates/epiphany-editor-gui/**, crates/epiphany-testkit/benches/editor_pipeline.rs, the root Cargo.toml, .claude/worktrees/.

Do not implement P13-S27 or P13-S16. No BundleCapabilities, no CURRENT_REDUCTION_ALGORITHM_VERSION, no create_staff change. This rung establishes the container epoch those depend on; it does not begin them.

Editing S27's contract is in scope and is required (touch row 9) — pins 8 and M8 both write to it. That is a document edit, not an implementation of S27. S16's contract is not touched by this rung.

Pin 3a's interim refusal is the one exception and is explicitly in scope: a major-1 container refuses base introduction outright until S27 replaces that with validation. It MUST be marked in code as temporary and MUST NOT be built as a partial capability check.

Do not build a repack flow (pin 5) and do not add a read-only mode for any of pin 4's three errors.

The executing agent MUST NOT commit. Leave the work staged.


§7. Report requirements

  1. The eleven mutations, each with verbatim failure output — M4 identified as the signing mutation of the epoch's non-inheritability; M11's control result (test 3 green) reported alongside its two failures; M7's two failures (major-0 and major-1 halves) reported separately; and M8's fall-through error named, confirming it reached pin 3a's interim guard rather than a successful serialization.
  2. The eight gate results, each with its command — gate 8's hits classified one by one, not summarized.
  3. The staged file list and the test-count delta with its cause.
  4. The eleven tests by name, with 2/3, 2/7, and 4/11-vs-both-legacy-errors each shown to produce different errors.
  5. decode_vectors.txt unchanged. textproj_document_vectors.txt changed to 20 rows, with its diff summarized against touch row 5c. COMPANION_VERSION at 0.14.0. Every expected_reach() count against pin 3b's table, with canonical_bases: 0's recorded cause. Which round-trip laws now except base-bearing documents. These are reported as performed, not as open questions.
  6. Confirmation that no repack flow and no read-only path were added, and that pin 3a's refusal is marked temporary in code with P13-S27 named. 6b. The three additions to spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md (touch row 9), quoted: pin 8's major-1 precondition, M8's deferred laundering demonstration, and pin 3c's two owed-back conformance assertions. 6c. Pin 3c's suspension, shown in the diff: the marker naming P13-S27, the amended (not deleted) doc paragraph, and confirmation that criterion 4's serialize → load → decode → reserialize cycle still runs via a direct ChunkRef read — and that acceleration_snapshots was not used.
  7. Anything contradicting this contract.