# 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<u16>` 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.
