# Plan — G-minor: the chunk schema minor

**Filed as** P13-S14. **Ruled** 2026-07-28: its own rung, sequenced **after
G2a and before G2b** (`spec/PLAN_GENESIS_OPS.md` §4).

**Status:** policy ratified 2026-07-28 (§4); vocabulary audit landed (§5.1,
`spec/AUDIT_GMINOR_VOCABULARIES.md`, c63258d); **epoch ladder ratified
2026-07-28** (§4, minors 2–9). **Every gate in §5 is now discharged — the
implementation contract may be drafted.** Two things it must carry that are
not yet designed: the manifest derivation seam across bundle-opaque barrier
bytes (§4), and the `binary_format.tex:2373` correction (§5.1).

> **Revision 2026-07-28.** The first draft of this plan got three things wrong
> and recommended a policy that cannot work. Corrections are marked inline
> rather than deleted, because two of them are the kind of mistake that
> regenerates: an under-scoped vocabulary, and a corpus claim asserted from a
> `grep -c` over *lines* rather than occurrences.

---

## 1. The requirement, and the evidence it has never been met

`binary_format.tex:2330` (§"Schema Versioning", the Minor bullet):

> **Minor** = additive. v0 readers verify the major only; the minor is a
> *record*, not a gate — but it is a mandatory record: a writer **MUST** raise
> the chunk schema minor when it emits any discriminant appended after the
> minor it otherwise declares, so that a decode failure on an unknown appended
> discriminant is attributable to a version skew rather than corruption.

Three facts, each read from the tree:

1. `SchemaVersion::for_major` (`bundle/src/ids.rs:204`) maps a major to a
   **fixed constant** and **accepts only a major**. There is no parameter a
   minor could travel through. (`V0` is `{0, 1}`; `V1`/`V2`/`V3` are `{n, 0}`.
   The baselines are already inconsistent — §4 preserves that rather than
   normalising it.)
2. Both writer-side staging paths derive only the major
   (`testkit/src/bundle_harness.rs:32`, `textproj/src/serialize.rs:189`).
3. So every post-baseline append ships with no additive record. The
   requirement's own failure mode is what the gap produces: a reader meeting an
   unknown discriminant cannot distinguish a stale vocabulary from damaged
   bytes.

## 2. The minor is content-addressed

`chunk_content_hash` (`bundle/src/chunk.rs:177`) pushes
`schema.canonical_bytes()` into the preimage, and that is "major then minor,
little-endian" (`ids.rs:222`). `chunk_id` dispatches to the same function
(`chunk.rs:202`).

**So raising a chunk's minor changes its `ChunkId`**, which changes the
manifest body naming it, and therefore the `ManifestId`. This is real **address
churn** — a migration and deduplication cost — and it is the main reason the
rung is not a patch.

> **Correction 1 (was P2).** The first draft framed this as tension with
> `req:format:manifest-id`. It is not. That requirement
> (`core_spec.tex:11213`) promises that *"two conforming writers committing
> **the same manifest body** at the same generation of the same document derive
> identical `ManifestId`s"*. Once a `ChunkRef` changes, the bodies are not the
> same, so the promise does not apply. Two conforming writers both following
> one normative minor derivation still agree — and the *historical* writer was
> already violating the minor MUST. Churn, not a broken guarantee.

> **Correction 2 (was P1).** The first draft claimed op-block minor changes
> reach the text projection and therefore force another `COMPANION_VERSION`
> bump. **They do not.** Operation blocks are decoded into envelopes and the
> block's physical schema is discarded during projection
> (`textproj/src/project.rs:424`); schemas are carried only for preserved
> extension chunks and the canonical base (`:454`). The committed corpus holds
> **seven** decoded `(schema 0 1)` forms in its *accepted* vectors — not the
> six the draft claimed, an error from counting matching lines instead of
> occurrences — and every one belongs to an extension chunk or a canonical
> base. **No companion bump is required for op-block stamping.** A separate
> decision to move canonical-base or extension schemas would change this;
> the op-block sweep alone does not.
>
> **Still true, but no longer the whole answer (2026-07-28).** The manifest
> seam ruling (§4) makes the manifest's aggregate `SchemaVersion` a *carried*
> `TextDocument` attribute, so **G-minor does bump the companion** — 0.9.0 →
> 0.10.0, with corpus regeneration. Op-block stamping remains
> projection-invisible exactly as this correction says; the bump comes from the
> manifest, not from it.

**Sixty-six** occurrences of `SchemaVersion::{V0, new, for_major}` across
sixteen files (corrected from 62), concentrated in `epiphany-bundle`,
`epiphany-testkit`, and `epiphany-textproj`. Most are fixtures stamping `V0`
and are unaffected; the load-bearing ones are the two staging paths.

## 3. The vocabulary inventory — the part the first draft missed

> **Correction 3 (was P1), and the reason the recommended policy died.** The
> draft reduced the problem to a per-`OperationKind` minor. An envelope also
> emits the **independent outer `OperationPayload` discriminant**, and
> `ResolveEquivocation` — appended at 3 — contains no `OperationKind` at all
> (`ops/src/payload.rs:60`). So the ambiguity lives **inside one role and one
> block**, not merely between roles, and no per-kind method can discharge the
> MUST.

`binary_format.tex:2369` states that the *only* minor-additive mechanism in
schema major 0 is appending discriminants to open vocabularies, and enumerates
them:

| Vocabulary | Appends at | Note |
|---|---|---|
| `OperationKind` | ≥ 30 | 24–27, 28–29 also took this mechanism |
| `OperationKindTag` | ≥ 30 | independent space from `OperationKind` |
| `OperationPayload` | ≥ 4 | **outer**; `ResolveEquivocation` carries no kind |
| value-layer unions closed under `req:binfmt:frozen-layout` | — | appendable only via a ratified revision of that document; also minor-additive |

Two boundaries that keep the audit finite:

* **`ChunkKind` is closed.** It has no `Registered` variant and its
  discriminant enters every chunk's hash preimage, so a new chunk kind is a
  format-**major** event, not a minor one.
* **`Registered` escape variants are not schema changes at all** — the wire
  form is already defined. `binary_format.tex` lists fifteen carriers
  (`RepairKind`, `ReanchorReason`, `PreconditionFailureReason`,
  `IntegrityAnomalyKind`, `TypedObjectId`, …). Extension through an escape is
  out of scope; **appending a native variant to one of those same enums is
  not**, and that distinction is the audit's sharpest edge.

**The rung must inventory every append-only discriminant reachable from each
affected chunk payload**, not just `OperationKind`. For the canonical base that
means the vocabularies `MaterializedState` actually emits — `OperationEffect`,
`NoOpReason`, `PreconditionFailureReason`, `ConflictKind`,
`IntegrityAnomalyKind`, `PendingReason`, `ObjectState`, `TypedObjectId` —
which notably do **not** include `OperationKind`.

## 4. Policy — RATIFIED 2026-07-28

**A global additive epoch with content-minimal stamping.** The first draft
recommended "minor = highest discriminant emitted"; that is rejected. It cannot
represent multiple independent vocabularies in one block, and generalising it
to "highest from any vocabulary" is worse than useless — an old `OperationKind`
23 would numerically mask a newly appended `OperationPayload` 3.

The ratified rule:

1. Each additive format revision receives **one globally meaningful minor
   epoch**.
2. Every appended variant is annotated with the epoch that introduced it.
3. An **envelope's** required minor is the maximum across its outer payload
   variant, its primitive kind, and every nested additive variant **actually
   emitted**.
4. A **block** takes the maximum required minor over its envelopes. The major
   remains the independent maximum of `schema_major()`.
5. Old content keeps its major's baseline — `V0`'s existing `1`, the others'
   `0`. Baselines are not normalised.

**Per-major counters are also rejected:** a mixed block can carry a new major-0
kind beside a major-2 payload, so independently numbered minor namespaces do
not compose after the block takes `max_major`.

**Maintenance risk, and how it is controlled.** The obvious objection to an
epoch table is the one this track keeps proving — hand-maintained parallel
lists go stale (four at Push 4a, six found during G2a). The control is to
**co-locate `introduced_minor` with each discriminant in an exhaustive macro or
match with no wildcard arm**, so a newly appended variant *cannot compile*
without being assigned an epoch. That assignment is an unavoidable schema
decision, not a fallible parallel list — the same reasoning that made
`operation_kind_tag_vocabulary!` safe.

### The epoch mapping — RATIFIED 2026-07-28

**These are schema-minor epochs, not companion semver numbers.** The two
numbering spaces are unrelated and must not be cross-read.

| 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 |
| 10 | Genesis G2b | `OperationKind`/`OperationKindTag` 34 (`SetTuningContext`) |
| 11 | Genesis G3a | `OperationKind`/`OperationKindTag` 35–38 (`CreateStaffGroup`, `CreatePartDefinition`, `CreateAnalysisLayer`, `CreateView`) |
| 12 | Genesis G3b | `OperationKind`/`OperationKindTag` 39 (`CreateMeasure`); `PreconditionFailureReason` 16–18 (`MeasureMeterMismatch`, `MeasureOutOfOrder`, `MeasureOrderUnverifiable`) |

> **Epoch 10 ratified 2026-07-28**, with G2b as the event. This is the **first
> exercise of the ladder's own growth path**: G-minor's `introduced_minor()` is
> exhaustive with no wildcard arm, so kind/tag 34 *cannot compile* without an
> epoch — the control working exactly as designed. The ladder stays monotonic
> (G2b follows G2a) and prefix-closed. Epoch assignment remains a ratified
> schema decision, never an implementer's choice.

> **Epoch 11 ratified 2026-07-29**, with G3a as the event (introducing
> `6c5e69f`, per the same discipline as the G2a correction above). One epoch for
> the whole rung's four kinds, the G2a precedent (two kinds at epoch 9) rather
> than one epoch per kind. The ladder stays monotonic (G3a follows G2b) and
> prefix-closed.

> **Epoch 12 ratified 2026-07-30**, with G3b as the event — the final rung of
> the genesis ladder (`spec/PLAN_GENESIS_OPS.md`), closing it. One epoch for
> the rung's one kind and its three `PreconditionFailureReason` appends
> together, the Push 4a precedent (epoch 7 bundled kind 30 with reasons 14–15)
> rather than a kind epoch and a reason epoch separately. The introducing
> commit is **`e64a4b7`** (2026-07-30), where kind/tag 39 enters
> `ops/src/payload.rs` and reasons 16–18 enter `ops/src/effect.rs` — G3b landed
> across six commits, and the four that follow packet 1 introduce no
> discriminant, exactly the distinction the 2026-07-28 correction below draws
> between `7df5ca1` and `55eff00`. The rung *completes* at **`d58eee8`**
> (2026-07-30), the documentation half; the hash was filled in by the pre-push
> repair that follows it, because a commit cannot cite itself and G3b closed
> the ladder, leaving no later rung to do it.
> The ladder stays monotonic (G3b follows G3a) and prefix-closed.

**The ladder is complete against the audit** — every post-baseline variant in
`AUDIT_GMINOR_VOCABULARIES.md` appears exactly once: all fifteen kind/tag pairs
through G3a, plus G3b's kind/tag 39 and its three reason appends,
`OperationPayload` 3, `ReanchorReason` 6, and all nine
`PreconditionFailureReason` appends.

**And it is monotonic in real time**, which is what makes a minor prefix-closed
(a reader declaring minor *n* supports every epoch ≤ *n*). Verified against the
introducing commits rather than assumed: M2c `a207077` (2026-06-25) → Push 3
`92aaccf` (07-02) → Phase-3 `0316160` (07-02) → G-pass `e4edea6` (07-07) →
repeat pair `9b5339f` (07-07) → Push 4a `2740a6c` (07-09) → G1 `3b09595`
(07-24) → **G2a `7df5ca1`** (07-28) → **G2b `13c3d2f`** (07-29) → **G3a
`6c5e69f`** (07-29) → **G3b `e64a4b7`** (07-30, the packet-1 commit where
kind/tag 39 and reasons 16–18 enter the vocabularies; the rung completes at
`d58eee8`, which introduces no discriminant). The two
events sharing 2026-07-07 are ordered correctly: the G-pass precedes the
repeat revision. The two events sharing 2026-07-29 are ordered **by ancestry,
not timestamp**: `13c3d2f` is an ancestor of `6c5e69f` (verified with
`git merge-base --is-ancestor 13c3d2f 6c5e69f`), so G2b precedes G3a. **Only
these three additive events — G2b, G3a, G3b — extend the chain past G2a**;
G-minor (`ff9bd0f`) built the epoch machinery itself and introduced no
additive variant, and P13-S17's restoration commit (`6170015`) repaired a
document's history rather than appending vocabulary, so neither belongs in
this chain.

> **Correction 2026-07-28.** G2a's introducing commit is **`7df5ca1`**, where
> kinds/tags 32–33 enter `ops/src/payload.rs`. `55eff00` is the *subsequent
> review-fix* commit and introduces no discriminant. The distinction matters
> because these hashes are the evidence for monotonicity: citing a follow-up
> commit would make the ladder unverifiable at the one rung a future reader is
> most likely to re-derive.

**Existing major baselines are unchanged**: `V0` uses minor 1; `V1`–`V3` use
minor 0. **Baseline variants impose no additive override** — M2c's *operation
kinds* stay baseline (inside the golden-locked 0..=23) while M2c's
`PreconditionFailureReason` append requires epoch 2. Same tranche, two
vocabularies, two answers.

**Baseline boundaries are per vocabulary, even though the epoch space is
global.** The epoch answers *which revision introduced this variant*; the
boundary answers *was this variant present at that vocabulary's own lock*.
Only the second varies. M2c is the proof: it is baseline for `OperationKind`
(absorbed into the golden-locked 0..=23) and a genuine append for
`PreconditionFailureReason` (`ContainerNotEmpty` = 10, whose 0..=9 lock
predates it). So `introduced_minor` is assigned **per variant, per
vocabulary**.

### Consequent calls, all ruled 2026-07-28

* **Keep the MUST.** Its cost is lower than §2 first claimed.
* **No text-companion bump** for op-block stamping.
* **The canonical base does not move merely because its source operations are
  newer.** It never emits the op-kind discriminant. It retains its current
  minor while its payload uses only baseline vocabulary, and rises only when
  the `MaterializedState` bytes themselves emit a later-added discriminant —
  a newly introduced effect or reason variant, say. *(This replaces the first
  draft's "is the base exempt?", which was the wrong binary question: the base
  is neither exempt nor automatically dragged, it is content-minimally
  stamped like everything else.)*
* ~~**`Manifest::SCHEMA` stays unchanged.**~~ **NARROWLY SUPERSEDED
  2026-07-28 by the audit.** The original call was right about its own case and
  wrong as an unconditional rule:

  * **Still true** — a changed child `ChunkRef` is changed manifest *data*, not
    a new manifest-layout discriminant. Its body, id, and chunk hash move
    naturally, and **that alone never raises the manifest minor.**
  * **Newly true** — **emitted barrier tags do.** The manifest reaches
    `OperationKindTag` through `ExtensionDeclaration::edit_barriers` →
    `EditBarrier::prohibited_operation_kinds` (audit §5.1, Part 2). **A
    manifest containing an edit barrier naming a tag in 24–33 takes that tag's
    epoch**; a manifest naming only baseline tags retains its baseline. This is
    content-minimal stamping applied to the manifest like any other role — the
    original call had simply not seen that the manifest emits an additive
    vocabulary at all.
  * **The manifest major remains 0.**

  **The implementation contract must design the derivation seam across the
  bundle-opaque barrier bytes**, and this is the hard part, not a detail.
  `epiphany-bundle`'s entire dependency list is `epiphany-determinism` and
  `zstd` — **not `epiphany-ops`, not `epiphany-layout-ir`, not
  `epiphany-core`**. It holds `edit_barriers` as `Vec<u8>` by design
  (`manifest.rs:283`: the barrier family "is owned by Agents C and E"), so the
  layer that stamps the manifest is structurally incapable of reading the tags
  that determine its minor. Three shapes are available — a new dependency edge,
  a producer-supplied minor travelling beside the blob, or a
  decode-and-inspect step at a higher layer that already depends on both —
  and choosing among them is contract work.

  **The genuinely hard case:** barrier bytes are *preserved verbatim* across
  writes, including for extensions the writer does not understand. A repack can
  carry a barrier blob it cannot decode, whose tags it cannot enumerate, and
  whose epoch it cannot derive. **"Decode and inspect" silently does not cover
  this** — it looks complete until a foreign extension is present.

### The seam — RULED 2026-07-28

**`epiphany-bundle` stays opaque. No `epiphany-ops` or `epiphany-layout-ir`
dependency is added.** The producer supplies the aggregate manifest
`SchemaVersion` explicitly; the bundle records it and never derives it.

1. **Producers supply the aggregate.** The write paths take the manifest's
   `SchemaVersion` as an input instead of selecting a constant.
2. **`CommitContext` must expose the previous manifest version**, so unchanged
   barrier content can preserve it. **This is new plumbing, not a read-through:**
   `CommitContext::previous_manifest` is a `&Manifest`, and **`Manifest` has no
   schema-version field at all** — the version lives in the *superblock*
   (`superblock.rs:174`, bytes 64..68). It must therefore travel as its own
   `CommitContext` field.
3. **It must NOT become a `Manifest` body field.** Adding one is a schema
   **major** change regardless of type (`binary_format.tex:2360`), which would
   defeat the entire rung. The wire slot already exists in the superblock; no
   new one is needed.
4. **Changed barrier bytes require an aware producer.** One that cannot
   establish the exact new epoch **must refuse**. Blindly retaining the previous
   aggregate is specifically wrong: **removing the sole barrier that contributed
   the maximum would leave the manifest over-stamped**, claiming a vocabulary
   level its content no longer needs.
5. **Ordinary repacks preserving the complete barrier content preserve the
   carried version exactly.** This is the common path and it stays cheap.

**Consequently `Manifest::SCHEMA` becomes a baseline constant, not the
universally emitted version** (`manifest.rs:591`, currently
`SchemaVersion::V0` = `{0, 1}`). **Three** sites silently select it for
hashing and must stop: `bundle.rs:220` (create), `bundle.rs:724` (commit), and
the re-exported harness helper `manifest_chunk_hash` (`bundle.rs:1298`) —
which is **public API**, so it is a signature change, not an internal edit. The
major check at `bundle.rs:301` stays as-is and stays correct: it compares
`.major` only, which is exactly the v0 rule that the minor is a record, not a
gate.

### The text-projection consequence — G-minor DOES need a companion bump

**This reverses Correction 2 in §2 for the manifest, though not for op
blocks.** The reasoning there still holds where it was aimed: op-block minors
are discarded during projection, so **op-block stamping remains
projection-invisible**. But the seam above makes the manifest's aggregate
`SchemaVersion` a **carried document attribute**, and the projection cannot
derive it — that is the whole point of keeping the bundle opaque, and the bytes
may come from a future or unknown extension.

So `TextDocument` gains the carried manifest `SchemaVersion`
(it currently has no such field), which is a document-surface change:

* **`COMPANION_VERSION` 0.9.0 → 0.10.0** (`textproj/src/lib.rs:34`).
* **Corpus regeneration**, on the same reasoning as G2a's kind-production bump:
  holding the version while changing the surface would leave two incompatible
  grammars both claiming `(0 9 0)`.

**Not** because of op-block stamping — because the manifest schema becomes a
carried attribute. Recording the distinction because §2's "no companion bump"
is otherwise still true and will read as contradictory.
* **No migration.** Existing bundles are accepted as-is; newly emitted or
  repacked blocks are stamped correctly.
* **Scope is "all additive discriminants reachable in affected chunk
  payloads"**, not "kinds 24–33".

## 5. What must complete before a contract

1. ~~**The vocabulary audit** (§3).~~ **DONE** — see §5.1.
2. ~~Ratify the epoch mapping.~~ **DONE** — §4, minors 2–9, complete against
   the audit and monotonic in real time.
3. Confirm no third staging path has appeared beside the two in §1. *(Contract
   work — cheap, but a check rather than an assumption.)*
4. Decide whether `decode_vectors.txt` moves — it is value-level, so it should
   not, but that is a check rather than an assumption. *(Contract work.)*

Items 3 and 4 are checks the contract performs, not gates on drafting it.

### 5.1 The audit landed — and it is the governing inventory

**`spec/AUDIT_GMINOR_VOCABULARIES.md` (c63258d) supersedes §3's table as the
authoritative reachability inventory.** §3 is retained as the reasoning that
led here; where the two differ, the audit governs. It walks each of the nine
`ChunkKind` roles' payload encoders and classifies every variant of every
vocabulary actually emitted.

What it changes about §3:

* **The canonical base reaches more than §3 lists** — add `RepairKind`,
  `ReanchorReason`, and `SpellingNominal`. Not cosmetic: `ReanchorReason` is
  one of only two vocabularies in the tree with a real native append.
* **The manifest reaches `OperationKindTag` with no operation envelope in
  it**, through `ExtensionDeclaration::edit_barriers` →
  `EditBarrier::prohibited_operation_kinds` (`layout-ir/src/barrier.rs:392`).
  §3's table is framed entirely around the operation layer and names neither
  the role nor the path. A manifest declaring a barrier on `CreateInstrument`
  (tag 31) needs the same stamping discipline as a block containing one, and
  the manifest chunk's minor is a separate decision from any op block's.
* **`CompressionAlgorithm` is excluded** (audit §1.8): `ChunkRef` transport
  metadata, not a discriminant emitted by the payload `SchemaVersion` governs,
  and deliberately outside the chunk-identity preimage. Recorded so it is not
  reopened.
* **`binary_format.tex:2373` is stale narrative** — "append at ≥30" with a
  history stopping at 29, while 30–33 are taken. The normative tables
  (`:1443`, `:1526`) are current and authoritative. **The `.tex` correction is
  owed by the G-minor implementation**, which must edit that paragraph
  regardless, since it defines the mechanism the rung implements.

*Related: `spec/AUDIT_GMINOR_VOCABULARIES.md`,
`spec/PASS13_CANDIDATES.md` (P13-S14), `spec/PLAN_GENESIS_OPS.md` §4,
`spec/binary_format.tex` §"Schema Versioning" / §"What ``Additive'' Means
Here".*
