# Contract: G-minor — the chunk schema minor becomes a derived record Repo root `/home/jeans/Repos/active/epiphany`. Governed by `spec/PLAN_GMINOR_SCHEMA_MINOR.md` (policy §4, seam §4, epoch ladder §4, audit fold-in §5.1) and `spec/AUDIT_GMINOR_VOCABULARIES.md` (the governing reachability inventory). Filed as **P13-S14**. Sequenced **after G2a, before G2b** (`spec/PLAN_GENESIS_OPS.md` §4). This rung implements a **MUST that no writer has ever honoured** (`binary_format.tex:2330`): a writer must raise the chunk schema minor when it emits a discriminant appended after the minor it otherwise declares, so an unknown-discriminant decode failure is attributable to version skew rather than corruption. **Parallel safety.** The editor track owns `crates/epiphany-editor-gui/**`, `crates/epiphany-render-svg/**`, `crates/epiphany-glyphs/**`, `crates/epiphany-testkit/benches/editor_pipeline.rs`, `spec/PLAN_EDITOR_APP.md`, every `spec/CONTRACT_EDITOR_*.md`, `spec/DRAFT_T4_FIXTURE_RECIPE.md`, `spec/ANALYSIS_*.md`, `crates/epiphany-editor-gui/goldens/*.png`, the entire `spikes/` tree, and the current unstaged root `Cargo.toml` change. **All of it is out of scope.** **Stage only the files this contract names — never `git add -A`.** **One-time authorization**, not generalising beyond this packet: this contract may edit `crates/epiphany-editor-core/src/barriers.rs` **only if** the compiler requires it. It should not — this rung appends no `OperationKind` variant — and if an edit there turns out to be needed, that is a finding to report, because it would mean the change is wider than scoped. --- ## 0. What this rung is not * **Not an operation-vocabulary append.** No new `OperationKind`, `OperationKindTag`, or `OperationPayload` variant. The four-document append ritual (`binary_format.tex` + `operation_catalog.tex` + `core_spec.tex` + `text_projection.tex` payload/tag tables) does **not** apply. * **Not a schema-major change.** No field is added to any encoded struct. See pin 6 for the one place that constraint bites hardest. * **Not a migration.** Existing bundles are accepted exactly as they are. --- ## 1. Design pins ### Pin 1 — the epoch ladder is normative input, not a derivation `PLAN_GMINOR_SCHEMA_MINOR.md` §4's table is **ratified**. Transcribe it; do not re-derive it, do not renumber it, and do not "improve" it. | Minor | Additive event | Variants introduced | |---|---|---| | 2 | M2c | `PreconditionFailureReason::ContainerNotEmpty` = 10 | | 3 | Push 3 | `OperationPayload::ResolveEquivocation` = 3 | | 4 | Phase-3 first tranche | `OperationKind`/`OperationKindTag` 24–27; `PreconditionFailureReason::TempoMapMalformed` = 11 | | 5 | Pass-12 G-pass | `ReanchorReason::SameCanvasNearer` = 6; `PreconditionFailureReason` 12–13 | | 6 | Schema-major-2 repeat revision | `OperationKind`/`OperationKindTag` 28–29 | | 7 | Push 4a | `OperationKind`/`OperationKindTag` 30; `PreconditionFailureReason` 14–15 | | 8 | Genesis G1 | `OperationKind`/`OperationKindTag` 31 | | 9 | Genesis G2a | `OperationKind`/`OperationKindTag` 32–33 | **Everything else is baseline and contributes no epoch.** The audit's fourteen clean escape-carriers and seven clean closed unions are baseline; recording an epoch for any of them is a defect. ### Pin 2 — `introduced_minor` is co-located, exhaustive, and wildcard-free For each of the **five** vocabularies with post-baseline variants (`OperationKind`, `OperationKindTag`, `OperationPayload`, `ReanchorReason`, `PreconditionFailureReason`), add an `introduced_minor()` that returns the epoch, with the **baseline arm returning the sentinel for "no additive requirement"** (see pin 3 for what that sentinel is). **It must be exhaustive with no wildcard arm**, so a future variant cannot compile without being assigned an epoch. This is the control the whole policy rests on — `PLAN_GMINOR_SCHEMA_MINOR.md` §4 adopts it precisely because this track has now watched hand-maintained parallel lists go stale twice (four sites at Push 4a, six found during G2a). **`OperationKindTag`'s must live inside `operation_kind_tag_vocabulary!`** (`ops/src/payload.rs:487`), not beside it. The macro is the compile-enforced source of truth for that space; a sibling match would reintroduce exactly the parallel list this pin exists to prevent. **`OperationKind::introduced_minor()` must NOT be bolted onto `discriminant()`'s match** (`payload.rs:275`). That hand-written match is *the site Push 4a got wrong*. Adding a second responsibility to it is acceptable only if the arms stay one-per-variant and exhaustive; a `_ =>` anywhere in the new code is a contract violation. ### Pin 3 — the derivation * An **envelope's** required minor = the **max** over every discriminant it **actually emits**: its outer `OperationPayload` variant, its primitive `OperationKind` (when `Primitive`), and every nested additive variant reached by its encoder. * A **block's** = the max over its envelopes. * The **major** is unchanged: still the independent max of `schema_major()`. Major and minor are derived separately and never influence each other. * **The emitted version is `{major, max(baseline_minor(major), epoch_max)}`.** Baselines are **not** normalised: `V0` keeps minor 1, `V1`–`V3` keep minor 0. A major-0 block emitting only baseline vocabulary still stamps `{0, 1}`. **Represent "no additive requirement" as a distinct value, not as `0`.** `0` is a real baseline minor for `V1`–`V3`, and conflating them makes the `max` above read correctly by accident rather than by construction. `Option` with `None` for baseline is the shape to prefer. ### Pin 4 — `for_major` is replaced, not extended `SchemaVersion::for_major` (`bundle/src/ids.rs:204`) accepts only a major and maps it to a fixed constant; there is no parameter a minor could travel through. Add the minor-aware constructor and route both staging paths through it. **Keep `for_major` itself** as the baseline lookup — pin 3 needs `baseline_minor(major)` and that is exactly what `for_major` already computes. Do not delete it; give it a doc comment saying it yields the *baseline*, and that a writer emitting post-baseline vocabulary must use the new constructor. ### Pin 5 — content-minimal stamping for every other role Per the audit's dispositions: * **Canonical base (`MaterializedState`)** — rises only when its **own bytes** emit a later-added discriminant (`ReanchorReason` 6 or `PreconditionFailureReason` 10–15). **It never emits `OperationKind`, `OperationKindTag`, or `OperationPayload`**, so newer source operations alone never move it. * **Acceleration snapshot (full `Score`)** — audited clean; baseline. * **`OperationIndex`** — emits no additive discriminant; baseline. * **`Blob`, `ExtensionData`** — producer-owned opaque bytes. Core derives nothing; the schema is carried from the producer. * **`TextProjection`, `LayoutCache`, `IntegrityIndex`** — **no producer exists in this codebase.** Write no derivation for them. If one is later wired, it inherits this policy then. * **`CompressionAlgorithm` is excluded by ruling** (audit §1.8): `ChunkRef` transport metadata, deliberately outside the chunk-identity preimage. ### Pin 6 — the manifest seam **`epiphany-bundle` stays opaque. Add no `epiphany-ops` or `epiphany-layout-ir` dependency to it.** Its entire dependency list is `epiphany-determinism` + `zstd` and it must stay that way. 1. **Producers supply the aggregate manifest `SchemaVersion` explicitly.** The write paths take it as an input. 2. **`CommitContext` gains a previous-manifest-version field.** This is new plumbing, not a read-through: `CommitContext::previous_manifest` (`bundle.rs:156`) is a `&Manifest`, and **`Manifest` carries no schema-version field at all** — it lives in the superblock (`superblock.rs:174`, bytes 64..68). 3. **It must NOT become a `Manifest` body field.** A field addition is schema- **major** regardless of type (`binary_format.tex:2360`), which would defeat this entire rung. The wire slot already exists in the superblock. 4. **A manifest naming a barrier tag in 24–33 takes that tag's epoch**; otherwise it retains its baseline. **Changed child `ChunkRef`s never raise the manifest minor.** The manifest **major stays 0**. 5. **Changed barrier bytes require an aware producer**, which must compute the exact new epoch or **refuse**. Blindly retaining the previous aggregate is specifically wrong: **removing the sole barrier that contributed the maximum leaves the manifest over-stamped.** 6. **Ordinary repacks preserving the complete barrier content preserve the carried version exactly.** This is the common path; keep it cheap. **Where the epoch is computed.** `epiphany-layout-ir` is the only crate that can both decode `EditBarrier` and reach `OperationKindTag`'s epoch table (it depends on `epiphany-ops`; `barrier.rs:392` is the encoder). The aggregate helper belongs there. `epiphany-bundle` never calls it. ### Pin 7 — `Manifest::SCHEMA` becomes a baseline constant `manifest.rs:591` (`pub const SCHEMA: SchemaVersion = SchemaVersion::V0`) stops being the universally emitted version and becomes the documented **baseline**. **Three** sites silently select it for hashing and must stop: | Site | What it is | |---|---| | `bundle.rs:220` | the create path | | `bundle.rs:724` | the commit path | | `bundle.rs:1298` | `manifest_chunk_hash`, a **re-exported public helper** | The third is **public API**, so this is a signature change with callers outside the crate, not an internal edit. Find and update every caller. **`bundle.rs:301` stays exactly as it is.** It compares `superblock.manifest_schema_version.major != Manifest::SCHEMA.major` — major only — which is precisely the v0 rule that the minor is a *record*, not a gate. **Tightening it to compare the full version would be a conformance regression**, and it is the most tempting wrong edit in this packet. ### Pin 8 — the text-projection consequence `TextDocument` gains the carried manifest `SchemaVersion`. **The projection carries it; it never derives it** — `epiphany-textproj` depends on `epiphany-core`/`bundle`/`ops` and **not on `epiphany-layout-ir`**, so it structurally cannot decode barrier bytes, which is the point. * **`COMPANION_VERSION` 0.9.0 → 0.10.0** (`textproj/src/lib.rs:34`). * **Regenerate `spec/vectors/textproj_document_vectors.txt`.** Holding the version while changing the surface would leave two incompatible grammars both claiming `(0 9 0)` — the same reasoning as G2a's kind-production bump. * **Op-block stamping stays projection-invisible** (block schemas are discarded at `project.rs:424`). The bump is caused by the manifest attribute **only**. State this in the changelog entry so a later reader does not infer that op-block minors reach the text surface. ### Pin 9 — `decode_vectors.txt` must be checked, not assumed The value-level decode corpus should **not** move: chunk schema versions are a chunk-header concern and the decode vectors are value-level. **Verify it and report the check**; a silent "it didn't change" is indistinguishable from "I didn't look." ### Pin 10 — the `binary_format.tex:2373` repair is owed by this packet The bullet list at `:2373-2385` says `OperationKind` and `OperationKindTag` "append at ${\geq}30$" and its parenthetical history names only 24–27 and 28/29. **Kinds 30–33 are absent from both the number and the narrative, and 30 is no longer free.** The normative tables (`:1443-1457`, `:1526-1527`) are current and authoritative. Repair the paragraph: correct the next-free-slot numbers and complete the history. **This paragraph defines the minor-additive mechanism this rung implements**, so it must also state the derivation the rung ships. --- ## Pin 11 — the barrier-change oracle (RULED 2026-07-28: **(b)**, with **(a)** normative) `epiphany-textproj`'s serialize path receives a whole `TextDocument`, has no prior state to diff against, and cannot decode barriers even if it did (no `layout-ir` dependency, pin 8). So a hand-edited document whose barrier bytes changed while its carried `SchemaVersion` did not is undetectable at that layer. **The normative design is (a).** `textproj` **stays a preserving producer**: it carries the declared manifest version verbatim and **never decodes barriers**. **The document author is responsible for updating that version when editing opaque barrier bytes.** Do not add `layout-ir` to `textproj`, and do not add a derivation to its serialize path. **On top of that, ship an independent `testkit` conformance oracle** — testkit *does* depend on `layout-ir`. New gate **`[7f]`**, alongside the existing `[7b]`–`[7e]` in `testkit/examples/conformance_suite.rs`. 1. **When every barrier blob in a fixture decodes**, recompute the exact aggregate manifest version and require **equality — not `>=`**. Equality is the whole point: `>=` catches under-stamping only, while equality also catches **stale over-stamping after a contributing barrier is removed**, which is the failure pin 6.5 exists to prevent. 2. **Negative fixtures, both required:** * a blob naming **tag 31** whose manifest carries the **baseline** version (under-stamped); * **removal of the sole maximum contributor** with the old aggregate retained (over-stamped). 3. **If any blob is undecodable, report it as not-checkable/opaque — never as a pass.** A skipped check recorded as green is the exact failure this whole track keeps finding. Its byte-and-version preservation is covered separately by s10 and s13; the oracle must not claim that ground. 4. **Describe the gate honestly in its own output and docs**: it validates **known, decodable, in-tree artifacts**. It is **not** evidence that `textproj` validates arbitrary edits, and must not be worded as though it were. --- ## 3. Touch table | # | File | Change | |---|---|---| | 1 | `ops/src/payload.rs` | `introduced_minor()` for `OperationKind` (:116) and `OperationPayload` (:59); tag epochs **inside** `operation_kind_tag_vocabulary!` (:487) | | 2 | `ops/src/effect.rs` | `introduced_minor()` for `ReanchorReason` (:317) and `PreconditionFailureReason` (:124) | | 3 | `ops/src/payload.rs` | `OperationEnvelope`'s required-minor derivation (max over emitted) | | 4 | `ops/src/reduce.rs` | `MaterializedState`'s required-minor derivation (pin 5) | | 5 | `bundle/src/ids.rs` | minor-aware constructor; `for_major` documented as baseline (:204) | | 6 | `bundle/src/manifest.rs` | `SCHEMA` documented as baseline (:591) | | 7 | `bundle/src/bundle.rs` | `CommitContext` field (:154); three hash sites (:220, :724, :1298); **:301 unchanged** | | 8 | `layout-ir/src/barrier.rs` | aggregate-epoch helper over `prohibited_operation_kinds` | | 9 | `testkit/src/bundle_harness.rs` | `stage_operation_block` derives the minor (:25) | | 10 | `textproj/src/serialize.rs` | `stage_operation_envelope_block` derives the minor (:183); manifest version supplied on commit | | 11 | `textproj/src/project.rs` | project the manifest `SchemaVersion` into `TextDocument` | | 12 | `textproj/src/parse.rs` | parse it back | | 13 | `textproj/src/lib.rs` | `COMPANION_VERSION` → `(0, 10, 0)` (:34) | | 14 | `spec/vectors/textproj_document_vectors.txt` | regenerate | | 15 | `testkit/examples/conformance_suite.rs` | new gate **`[7f]`** (pin 11); bump `TOTAL_GATES` | | 16 | `testkit/src/` (fixture module, implementer's choice of file) | the `[7f]` fixtures, including **both** negative cases | | 17 | `spec/binary_format.tex` | pin 10 repair; version + Revision History row | | 18 | `spec/binary_format.pdf` | regenerated (tracked artifact) | | 19 | `spec/text_projection.tex` | companion 0.10.0 (:237, :521, :1330) + changelog stating the manifest cause | | 20 | `spec/text_projection.pdf` | regenerated (tracked artifact) | **On the PDFs.** All six `spec/*.pdf` are **tracked**, and the gate rebuilds all four documents. Rows 18 and 20 are the two whose `.tex` sources change and so **must** be committed. `core_spec.pdf` and `operation_catalog.pdf` stay **build-only**: rebuild them for the 0-undefined-references check, but commit them **only if byte-changed** — and if either *is* byte-changed, that is a **finding to report**, since this contract changes neither source. **Anything outside this table is a finding to report, not a fix to apply.** --- ## 4. Tests — each with the mutation that must kill it Mutation discipline (`testing-discipline-mutation-first`): anchor-assert → introduce the bug → **observe the actual failure** → restore by reversing the edit, never `git checkout`. **A test whose mutation you did not run is not signed off.** | # | Test | Required mutation | |---|---|---| | s1 | Every epoch in pin 1's table is returned by `introduced_minor` for its variant | Change one variant's epoch by one; the table assertion must fail | | s2 | Every baseline variant returns "no additive requirement" | Give `InsertEvent` an epoch; must fail | | s3 | A block of only baseline envelopes stamps `{0, 1}` — **not** `{0, 0}` | Normalise the baseline to 0; must fail | | s4 | A block containing kind 31 stamps minor 8 | Return the *count* of additive variants instead of the max; must fail | | s5 | A block mixing an old kind 23 with `ResolveEquivocation` stamps **3**, not 1 | Derive the minor from the highest *discriminant* rather than the epoch — the rejected policy — must fail | | s6 | Major and minor derive independently: a major-2 block with kind 28 stamps `{2, 6}` | Make the minor depend on the major; must fail | | s7 | A canonical base whose effects include `SameCanvasNearer` stamps 5; one without stamps baseline **even when its source operations are kind 33** | Drag the base's minor from the op kinds; must fail | | s8 | A manifest naming barrier tag 31 stamps 8; naming only baseline tags stamps baseline | Ignore `prohibited_operation_kinds`; must fail | | s9 | Removing the sole max-contributing barrier **lowers** the manifest minor | Retain the previous aggregate unconditionally; must fail (this is pin 6.5's over-stamp) | | s10 | A repack with unchanged barrier content preserves the carried version byte-for-byte | Recompute from scratch and drop an undecodable barrier's contribution; must fail | | s11 | `bundle.rs:301` still accepts a bundle whose manifest minor differs but major matches | Tighten it to full-version equality; must fail — **this is the conformance regression pin 7 warns about** | | s12 | A raised minor changes the `ChunkId` and therefore the `ManifestId` | Drop the schema from the hash preimage; must fail | | s13 | `TextDocument` round-trips the carried manifest `SchemaVersion` | Emit the baseline on serialize instead of the carried value; must fail | | s14 | The committed corpus parses at `(0 10 0)` and a `(0 9 0)` document is rejected | Leave `COMPANION_VERSION` at 0.9.0; must fail | | s15 | `[7f]` fails the **under-stamped** fixture (tag-31 blob, baseline version) | Relax the oracle's equality to `>=`; must still fail (this one `>=` catches) | | s16 | `[7f]` fails the **over-stamped** fixture (sole max contributor removed, old aggregate retained) | **Inverse mutation, deliberately.** Relax the oracle's equality to `>=` and the gate **goes green** — the over-stamp is no longer caught. Report the green as the observed result. This is the one place a *passing* run is the evidence: it proves equality is load-bearing and `>=` is not sufficient, which is the whole reason pin 11.1 specifies equality | | s17 | `[7f]` reports an undecodable blob as **not-checkable**, never as a pass | Treat undecodable as pass; must fail | --- ## 5. Gate * `cargo fmt --check` clean; `cargo clippy --workspace --all-targets` **0 warnings**; `cargo test --workspace` green with the count reported. * Conformance suites reported with counts (they were 8/8 + 9/9 at G2a), **plus the new `[7f]` gate's own line** — including how many fixtures were checked and how many were reported not-checkable. * **All four PDFs rebuilt at 0 undefined references.** Commit `binary_format.pdf` and `text_projection.pdf`; report whether `core_spec.pdf` or `operation_catalog.pdf` changed at all (they should not). * Decode vectors: report the count **and** pin 9's verification that the value-level corpus did not move. * Text-projection vectors regenerated; report the count. * `git status --short` **before and after**, in full. The only differences may be the files in §3's touch table. The tree is already dirty with the editor track's `M Cargo.toml` and untracked `spikes/` — **that dirt must appear unchanged in both captures.** ## 6. Report The gate outputs; every mutation with the failure it actually produced; any touch outside §3; and **any place where this contract's own assumptions turned out to be wrong**. That last is not politeness: the plan behind this contract has already been revised twice for scoping errors, and its §3 vocabulary table was found incomplete by the audit that followed it.