Revise the G-minor scope: the recommended policy could not work

All five findings verified against the tree; the recommended policy is
rejected and replaced with the ratified one.

Policy (b), minor = highest discriminant emitted, cannot represent an
operation block. An envelope also emits the outer OperationPayload
discriminant, and ResolveEquivocation is appended at 3 while carrying no
OperationKind at all -- so the ambiguity is inside one role and one block, not
between roles. Generalizing to "highest from any vocabulary" is worse: an old
kind 23 would numerically mask a new payload 3. binary_format itself enumerates
four independent minor-additive vocabularies. The rung's gating work is
therefore an audit of every append-only discriminant reachable from each
affected payload, which the first draft never scoped.

Ratified instead: a global additive epoch with content-minimal stamping, an
envelope's minor being the max across outer payload, primitive kind, and every
nested additive variant actually emitted. The maintenance objection is
answered by co-locating introduced_minor with each discriminant in an
exhaustive match with no wildcard, so an unassigned variant cannot compile --
the operation_kind_tag_vocabulary! reasoning. Per-major counters are rejected
too: mixed blocks do not compose after max_major.

Two of my conclusions were wrong. Op-block minors do not reach the text
projection -- block schemas are discarded there, and all seven accepted-corpus
schema forms belong to extension chunks or canonical bases -- so no companion
bump. I had also miscounted them as six by grepping lines rather than
occurrences. And the manifest-ID promise is not threatened: it is conditional
on the same manifest body, and a changed ChunkRef is a different body. Real
address churn, not a broken guarantee.

"Is the canonical base exempt" was the wrong binary question. It never emits
the op-kind discriminant, so it holds its minor until MaterializedState's own
bytes emit a later-added variant. Manifest::SCHEMA stays put; existing bundles
need no migration. Construction-site count corrected 62 -> 66.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV
This commit is contained in:
Levi Neuwirth 2026-07-28 17:59:56 -04:00
parent 42a8cc6dc5
commit 82ac636982
2 changed files with 151 additions and 116 deletions

View File

@ -91,4 +91,4 @@ visible — a value with a wire form and no canonical carrier to reach it.)
| P13-S11 | **`AnchorPoint`, referenced by `AccidentalEngraving.anchor`, is defined nowhere.** `core_spec.tex:3166` names `pub anchor: AnchorPoint`; no struct or enum of that name exists in the spec, and `epiphany-core` (whose `Cargo.toml` has no `epiphany-layout-ir` dependency) cannot borrow any layout-ir type even if one shared the name — the same core-native requirement that forced `EngravingBoundingBox`. An undefined leaf frozen onto the wire is the `KeyContext`-shaped gap. Compounding it: the bounding box is documented "relative to the glyph's anchor point" (`:3160`), so freezing an anchor with no defined coordinate frame freezes a point with an undefined origin | this file (verified 2026-07-23; `AnchorPoint` appears once in `core_spec.tex`, defined nowhere; `epiphany-core/Cargo.toml` lists no layout-ir) | **resolved** (ratified: core-native `AnchorPoint { x: SpaceUnit, y: SpaceUnit }`, over the same `SpaceUnit` as `advance_width` and `EngravingBoundingBox`. **Plus one normative sentence pinning the frame**: x/y in canonical space units, y-up, relative to the glyph's coordinate origin — matching the repo's existing Bravura outline convention — so the anchor and the box it anchors share an unambiguous origin. Freezes in tranche 3b) |
| P13-S12 | **`SmuflVersion` is undefined, and its obvious representation orders SMuFL's real history backwards.** `SmuflVersionRequirement` (`core_spec.tex:3269`) carries `minimum`/`authored_against` of type `SmuflVersion`, which Chapter 4 references but does not define (it exists only as a **Chapter 7 / layout-ir** type, `glyph.rs:29`, with literal-minor encoding — see the resolution). Ordering is load-bearing (`SmuflVersionRequirement.minimum`, `:3271`, gates the fallback of `req:tuning:smufl-version-fallback`), and the type is dual-purpose — it also anchors Chapter 9's `GlyphCatalog::smufl_version()` / `GlyphCatalogIdentity` (`:10420`, `:10460`), so this freeze touches layout-conformance identity, not just tuning. The trap: SMuFL versions are decimal fractions — 1.12 (2015), 1.18, 1.20 (2016), 1.3 (2019), 1.4 (2021) — that succeed by fraction (0.12 < 0.18 < 0.20 < 0.30 < 0.40). A `{ major: u16, minor: u16 }` storing literal digits with derived `Ord` orders the minors 3 < 4 < 12 < 18 < 20, placing 1.3 and 1.4 **before** 1.12; old fonts declaring 1.18-era versions exist forever | this file (verified 2026-07-23 against `core_spec.tex:3269-3274` and SMuFL's published release history) | **resolved** (ratified shape `SmuflVersion { major: u16, minor_centi: u16 }`, **literal-minor storage rejected**: the minor is stored **fraction-normalized to hundredths** 1.12→(1,12), 1.18→(1,18), 1.20→(1,20), 1.3→(1,30), 1.4→(1,40), rule normative, release table a note. Derived `Ord` is then correct across the whole real history and collapses the 1.2/1.20 ambiguity (both (1,20)). **Correction on filing: the leaf is NOT undefined — it exists in `epiphany-layout-ir/src/glyph.rs:29` as `SmuflVersion { major, minor }` with LITERAL minor (`{1,4}`) and derived `Ord`, so the backwards-ordering bug is LIVE there today (1.3 < 1.12), and it is a direct field of `GlyphCatalogIdentity` — conformance identity.** `epiphany-core` cannot depend on layout-ir, and `SmuflVersionRequirement` is core, so the type MUST be defined in core and layout-ir must reuse it a **unification**, not a fresh definition, which moves `GlyphCatalogIdentity` (`{1,4}`→`minor_centi 40`). Tranche 3a defines `core::SmuflVersion` for the tuning use and leaves layout-ir's alone (a bounded, core-invisible homonym since core can't import layout-ir's); tranche 3b performs the unification and the deliberate `GlyphCatalogIdentity` move. **Correction (2026-07-23, tranche 3b-ii): "with golden/vector regen" above was verified false before that tranche's dispatch.** No golden, baseline, or vector is pinned to the catalog identity every assertion on `ResolvedLayoutIR::canonical_bytes()` is relative (stability, determinism, and a `metrics_hash[0] ^= 1` sensitivity check that never touches `smufl_version`), and the committed SVG/PNG goldens do not embed it. The move changes the catalog's emitted bytes (`encode_catalog`'s minor field: `04 00` `28 00`) in *value*, with nothing to regenerate. The hundredths scale is blocking for 3b, free to adjust in 3a) |
| P13-S13 | **The score tuning context has no canonical persistence path: it can be saved, but never authored, replicated, or merged.** Schema major 3 (Push 4b tranche 3b-i) put `smufl` and `overrides` on the wire, but the only *persisted* carrier that embeds a `ScoreTuningContext` is the **acceleration snapshot**, which Chapter 8 makes explicitly non-canonical and regenerable and which the bundle may discard and rebuild at will. No **canonical** carrier embeds it at all: no operation authors it (`epiphany-ops` has no tuning-context payload anywhere — the vocabulary's only `tuning` references are the per-pitch `TuningReference::Inherit`), and `MaterializedState` carries effects, conflicts, anomalies, objects, spellings, breaks, page-breaks, and pending, but no tuning context. So a user who selects a tuning system or sets a per-voice override has authored something the format cannot durably represent, and `req:tuning:tuning-resolution-order`'s scopes 24 (`overrides`) are in practice unreachable from any document a replica could exchange | this file (found 2026-07-24 while scoping text-projection parity; verified by searching the whole `epiphany-ops` operation vocabulary and `MaterializedState`'s field list) | **resolved by the genesis tranche**`spec/RULING_GENESIS_PERSISTENCE.md` (ratified 2026-07-24, 011c68a) reverses Pass-12 K8 and absorbs genesis into the operation set, naming `SetTuningContext` in its §2 settings table. **This was not a tuning-specific defect and did not get a tuning-specific fix:** the operation arrives as one of nine surfaces in a single coordinated tranche, and the wire layout is untouched. **The disposition, and the evidence that produced it:** `spec/ANALYSIS_GENESIS_PERSISTENCE.md` maps the same gap across the whole `Score`: **eight fields have no operation that can produce them**`canvas.layout_defaults`, `instruments`, `staff_groups`, `parts`, `tuning_context` (this entry, its field 10), `spelling_precedence`, `analysis_layers`, `views`, plus `identity` — and one more (`decomposition_attachments`) can only be pruned back, never authored. Independently re-verified against the working tree: each field's sole `reduce.rs` mention is a read-only base-seed read. Of the four dispositions weighed there (a canonical genesis block; closing the op-coverage gap; promoting the canonical base to carry graph values; scope-limiting), **the second was taken** — every mutable `Score` field becomes operation-authored — so S13 closes when that tranche lands and is tracked *there*, not here. **One cost this entry contributes as evidence:** the per-field operation route is not the free schema-minor it appears. Blocks stamp *minimally*, at the lowest major whose layouts decode them, and `bundle.rs`'s `max_supported_major` currently caps `OperationEnvelopeBlock` at **2** on the explicit ground that no operation payload embeds the tuning context. A `SetTuningContext`-style operation would make its blocks stamp v3, dragging a role accept-set raise along with the kind append — for one field of eight. That is a concrete instance of the analysis's closing constraint, that any option enumerating fields must be re-audited against its table whenever `Score` gains a field. **This cost is now paid deliberately rather than avoided — but NOT amortised, as first written.** The ruling's "one accept-set raise, spent once" implied a single batch landing every new kind at `OperationEnvelopeBlock` major 3, spreading the raise across nine surfaces. Re-derived 2026-07-28 against the working tree, that is wrong: minimal stamping is a pure function of each payload's value, so the other eight surfaces stamp at major 0, 2, or 2 and never reach the raised bound at all. `SetTuningContext` is the sole payload born at v3, so the raise is charged to **exactly this surface** after all — which is why `spec/PLAN_GENESIS_OPS.md` §4 splits G2 and isolates it in **G2b**. **And the closure argument is not the canonical base:** the base is role-bound to major 0 (`mis_stamped_canonical_base`) and is a `MaterializedState`, which embeds no graph values for *any* field — including `metadata`, op-authored since M2d and durable purely through its operations. S13 closes on that precedent: the op log is canonical, and G2b makes an operation author the tuning context. Consequence to carry forward: once G2b lands, pruning would discard *authored* genesis state rather than merely re-derivable state, so the standing prohibition on pruning (blocked on disposition C) gains real teeth. Note `bundle.rs` documents the cap of 2 *with the tuning-context rationale in prose*, so that comment must move with the cap. Note the wire layouts themselves are already frozen and correct under every disposition: this was never about how the tuning context encodes, only about which carrier embeds it. Not a regression — nothing ever worked; major 3 made the gap visible by giving the value a wire form and no way to reach it) |
| P13-S14 | **The chunk schema *minor* is a normative MUST that no writer has ever honoured, so every appended operation discriminant is unattributable to version skew.** `binary_format.tex:2330` requires a writer to raise the chunk schema minor when it emits any discriminant appended after the minor it otherwise declares, with a stated rationale: so that a decode failure on an unknown appended discriminant is attributable to a version skew rather than to corruption. `SchemaVersion::for_major` (`bundle/src/ids.rs:204`) maps a major to a fixed constant — `V0` is `{0, 1}`, not `{0, 0}` (`ids.rs:173`), and `V1`/`V2`/`V3` carry minor 0 — and, decisively, **takes only a major**, so no per-kind additive minor can reach it — and **both** writer-side staging paths derive nothing but the major (`testkit/src/bundle_harness.rs:25`, `textproj/src/serialize.rs:183`, each mapping `max(OperationEnvelope::schema_major)` through `for_major`). So every kind appended after the initial vocabulary has no additive-version record: **2427** (Phase-3 first tranche), **2829** (the schema-major-2 repeat revision), **30** (Push 4a `TransposeInterval`), and **31** (genesis G1 `CreateInstrument`). The requirement's own failure mode is exactly what the gap produces: a reader meeting kind 31 from a newer writer cannot distinguish "my vocabulary is stale" from "these bytes are damaged" | this file (found 2026-07-28 during G2a contract review; verified against the working tree — `for_major`'s signature admits no minor, and neither staging path computes one) | **open, and deliberately extended.** Ruled 2026-07-28: the fix is its own rung, sequenced **after G2a and before G2b** — G2b appends kind 34, so running it first would either grow this sweep mid-flight or ship 34 with the very defect the rung retires. G2a appends kinds 32/33 and takes the violation from eight kinds to ten, knowingly — the debt is already eight deep, the +2 is immaterial against it, and one retroactive sweep over 2433 is strictly less work than 2431 now and 3233 later. **What the rung owes:** a minor-assignment policy (which minor each past append belongs to — this is a retroactive judgement, not a derivation), a per-kind `schema_minor()` or equivalent, block minor = max over payloads, a `for_major` replacement that accepts one, and both staging paths. **Check before scoping it** whether the chunk header's minor reaches any pinned corpus, since that decides whether this is a vector-moving change. Note this is orthogonal to the *major* accept-set: `OperationEnvelopeBlock` stays at 2 through G2a and rises to 3 only at G2b |
| P13-S14 | **The chunk schema *minor* is a normative MUST that no writer has ever honoured, so every appended operation discriminant is unattributable to version skew.** `binary_format.tex:2330` requires a writer to raise the chunk schema minor when it emits any discriminant appended after the minor it otherwise declares, with a stated rationale: so that a decode failure on an unknown appended discriminant is attributable to a version skew rather than to corruption. `SchemaVersion::for_major` (`bundle/src/ids.rs:204`) maps a major to a fixed constant — `V0` is `{0, 1}`, not `{0, 0}` (`ids.rs:173`), and `V1`/`V2`/`V3` carry minor 0 — and, decisively, **takes only a major**, so no per-kind additive minor can reach it — and **both** writer-side staging paths derive nothing but the major (`testkit/src/bundle_harness.rs:25`, `textproj/src/serialize.rs:183`, each mapping `max(OperationEnvelope::schema_major)` through `for_major`). So every kind appended after the initial vocabulary has no additive-version record: **2427** (Phase-3 first tranche), **2829** (the schema-major-2 repeat revision), **30** (Push 4a `TransposeInterval`), and **31** (genesis G1 `CreateInstrument`). The requirement's own failure mode is exactly what the gap produces: a reader meeting kind 31 from a newer writer cannot distinguish "my vocabulary is stale" from "these bytes are damaged" | this file (found 2026-07-28 during G2a contract review; verified against the working tree — `for_major`'s signature admits no minor, and neither staging path computes one) | **open, and deliberately extended.** Ruled 2026-07-28: policy ratified 2026-07-28 as a **global additive epoch with content-minimal stamping** (`spec/PLAN_GMINOR_SCHEMA_MINOR.md` §4); the fix is its own rung, sequenced **after G2a and before G2b** — G2b appends kind 34, so running it first would either grow this sweep mid-flight or ship 34 with the very defect the rung retires. G2a appends kinds 32/33 and takes the violation from eight kinds to ten, knowingly — the debt is already eight deep, the +2 is immaterial against it, and one retroactive sweep over 2433 is strictly less work than 2431 now and 3233 later. **What the rung owes:** a minor-assignment policy (which minor each past append belongs to — this is a retroactive judgement, not a derivation), a per-kind `schema_minor()` or equivalent, block minor = max over payloads, a `for_major` replacement that accepts one, and both staging paths. **Scoped 2026-07-28:** the minor IS in the chunk content-address preimage (`chunk_content_hash`, `chunk.rs:177`), so raising it moves every affected `ChunkId` and the manifest body naming it — real address churn, though **not** a `req:format:manifest-id` problem, since that promise is conditional on the same manifest body. It does **not** reach the text projection: op-block schemas are discarded during projection (`textproj/src/project.rs:424`), so no companion bump. And the problem is **not** per-`OperationKind`: an envelope also emits the outer `OperationPayload` discriminant (`ResolveEquivocation` = 3 carries no kind at all), so the gating work is an audit of every append-only vocabulary reachable from an affected payload. Note this is orthogonal to the *major* accept-set: `OperationEnvelopeBlock` stays at 2 through G2a and rises to 3 only at G2b |

View File

@ -1,10 +1,16 @@
# Plan — G-minor: the chunk schema minor, and why it is not a small rung
# 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 — the sweep is scoped to
kinds 2433, and G2b appends 34).
G2a and before G2b** (`spec/PLAN_GENESIS_OPS.md` §4).
**Status:** scoped, not contracted. §5 lists what needs ratification first.
**Status:** scoped; policy ratified 2026-07-28 (§4). Not contracted — §5 lists
the audit that must complete first.
> **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.
---
@ -22,135 +28,164 @@ 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
per-kind minor could travel through. (Note `V0` is `{0, 1}``ids.rs:173`
— while `V1`/`V2`/`V3` are `{n, 0}`. The baselines are already inconsistent,
which matters for §4.)
2. Both writer-side staging paths derive only the major:
`testkit/src/bundle_harness.rs:32` and `textproj/src/serialize.rs:189`, each
computing `max(schema_major)` and handing it to `for_major`.
3. Therefore kinds **2427** (Phase 3), **2829** (major-2 repeats), **30**
(Push 4a), **31** (G1), and **3233** (G2a) all ship with no additive
record. The requirement's own failure mode is exactly what the gap
produces: a reader meeting kind 33 from a newer writer cannot distinguish
"my vocabulary is stale" from "these bytes are damaged".
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 decisive scoping finding: the minor is content-addressed
## 2. The minor is content-addressed
**This is what makes G-minor a real tranche rather than a one-line fix, and it
must be settled before any contract is written.**
`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`).
`chunk_content_hash` (`bundle/src/chunk.rs:177`) builds the preimage as
**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.
```rust
p.push_bytes(&kind.canonical_bytes());
p.push_bytes(&schema.canonical_bytes()); // <-- major AND minor
p.push_u64_le(payload.len() as u64);
p.push_bytes(payload);
```
> **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.
and `SchemaVersion::canonical_bytes` (`ids.rs:222`) is "major then minor,
little-endian" — the minor is *in* the preimage. `chunk_id` dispatches through
`content_hash_for` to the same function (`chunk.rs:202`).
> **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.
**So raising a chunk's minor changes its `ChunkId`.** Every affected chunk gets
a new content address, which propagates to the manifest that names it. This is
not a semantic break — readers gate on the major only, exactly as the spec says
— but it is a **content-address-moving change**, and it lands on the one
structure `req:format:manifest-id` promises two conforming writers derive
identically.
**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.
**Two corpora move with it:**
## 3. The vocabulary inventory — the part the first draft missed
* `spec/vectors/textproj_document_vectors.txt` — the text projection projects
the minor as a document surface: `project_schema` (`textproj/src/project.rs:270`)
emits `(schema <major> <minor>)`, and the committed corpus contains six
literal `(schema 0 1)` occurrences. If op-block minors rise, those texts
change — which makes this **another `COMPANION_VERSION` bump** on the G1/G2a
precedent.
* `spec/vectors/decode_vectors.txt` — value-level, so it moves only if the
packet touches value codecs. It should not, and that is a check, not an
assumption.
> **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.
**Sixty-two sites across sixteen files construct a `SchemaVersion`**
(`SchemaVersion::{V0,new,for_major}`), concentrated in `epiphany-bundle`
(`chunk.rs`, `manifest.rs`, `superblock.rs`, `opindex.rs`, `bundle.rs`,
`vectors.rs`, `fuzz.rs`), `epiphany-testkit` (`bundle_harness.rs`,
`generators.rs`, `roundtrip.rs`, `benches/bundle.rs`), and `epiphany-textproj`
(`serialize.rs`, `parse.rs`, `project.rs`, `vectors.rs`). Most are fixtures
stamping `V0` and are unaffected; the ones that matter are the two staging
paths and anything asserting a literal id.
`binary_format.tex:2369` states that the *only* minor-additive mechanism in
schema major 0 is appending discriminants to open vocabularies, and enumerates
them:
## 3. What the rung owes
| Vocabulary | Appends at | Note |
|---|---|---|
| `OperationKind` | ≥ 30 | 2427, 2829 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 |
1. A **minor-assignment policy** — see §4. This is a judgement, not a
derivation, and it is the reason this is a rung and not a patch.
2. A per-kind minor (or an equivalent watermark) reachable from a payload.
3. Block minor = **max over payloads**, mirroring how the major is derived.
4. A `for_major` replacement that accepts a minor — `SchemaVersion::new`
already exists (`ids.rs:200`), so this is a call-site change, not a new API.
5. Both staging paths, and a check that no third path has appeared.
6. Regenerated corpora, a companion bump if §2's projection finding holds, and
the `binary_format.tex` accounting for whichever policy §4 ratifies.
Two boundaries that keep the audit finite:
## 4. The policy question — needs ratification before contracting
* **`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 spec says a writer must raise the minor "when it emits any discriminant
appended **after the minor it otherwise declares**". That phrasing presumes a
correspondence between each minor and a vocabulary watermark, but no such
correspondence has ever been written down. Three candidate policies:
**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`.
**(a) One minor per tranche, monotonic.** Phase 3 → 2, repeats → 3, Push 4a →
4, G1 → 5, G2a → 6. Matches the intuitive reading of "minor" as a format
revision counter. **Cost:** the assignment is a retroactive judgement with no
derivation behind it, so it must be written into the spec as a table and
maintained by hand forever — a seventh hand-maintained site, on a track whose
defining lesson is that those go stale.
## 4. Policy — RATIFIED 2026-07-28
**(b) Minor = the highest kind discriminant the chunk emits.** A block whose
largest kind is 33 stamps minor 33. **Derivable, self-describing, and it
satisfies the rationale exactly** — a reader seeing minor 33 knows precisely
which vocabulary it needs. Nothing to remember and nothing to maintain.
**Cost:** it redefines "minor" from *format revision* to *vocabulary
watermark*, it collides with `V0`'s existing minor of `1`, and it is
per-vocabulary — the layout cache and the operation index have their own
discriminant spaces, so "the minor" would mean different things per role.
**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.
**(c) Per-major append counter.** A hybrid: minor counts vocabulary appends
within a major. Inherits (a)'s bookkeeping without (b)'s redefinition.
The ratified rule:
My recommendation is **(b)**, on the strength of the track's own history: every
hand-maintained parallel list on this project has gone stale (four at Push 4a,
six found during G2a), and (b) is the only option with nothing to maintain. But
it is a genuine redefinition of a spec term and the per-role ambiguity is real,
so it is a ruling, not a default.
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.
**A fourth option worth pricing before choosing:** amend the MUST. It was
declined at P13-S14 filing on the ground that its rationale — distinguishing
skew from corruption — is sound and unchallenged. If §2's content-address cost
is judged too high for the benefit, that trade deserves an explicit re-look
rather than a silent deferral.
**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`.
## 5. Open questions
**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.
1. **The policy** (§4). Blocks everything.
2. **Does the canonical base move?** Its major is pinned to 0 by role
(`mis_stamped_canonical_base`, `bundle.rs:866`), but its *minor* is
unconstrained, and `the_canonical_base_is_byte_identical_across_data_model_majors`
pins the `MaterializedState` **payload** bytes, not the chunk header. A base
whose minor rises keeps its payload and changes its `ChunkId`. Decide
deliberately whether the base is exempt.
3. **Per-role or global?** Op blocks, layout cache, and operation index have
independent discriminant spaces. Policy (b) forces this question; (a) and
(c) can dodge it.
4. **Does the manifest's own `manifest_schema_version`
(`superblock.rs:174`) participate?** The manifest is carried opaquely and
never grows a versioned layout, so probably not — but it is a
`SchemaVersion` and should be ruled in or out explicitly.
5. **Migration.** Existing bundles carry minor 0/1 with appended kinds inside.
After this rung they are, by the new rule, mis-stamped. No production corpus
exists (local repo and test bundles only — the standing
`epiphany-ops/DECISIONS.md` position), so the answer is probably "nothing to
do", but it should be *stated* rather than assumed.
**Tentative epoch mapping** — Phase 3 → 2, repeats → 3, Push 4a → 4, G1 → 5,
G2a → 6. Reasonable, but **not ratified**: it must wait until every
post-baseline additive vocabulary is audited (§3), not just `OperationKind`.
*Related: `spec/PASS13_CANDIDATES.md` (P13-S14), `spec/PLAN_GENESIS_OPS.md` §4
(the ladder), `spec/binary_format.tex` §"Schema Versioning".*
### 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.** A changed child `ChunkRef` is changed
manifest *data*, not a new manifest-layout discriminant; its body, id, and
chunk hash move naturally without raising the manifest's own schema.
* **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 2433".
## 5. What must complete before a contract
1. **The vocabulary audit** (§3). This is now the gating work, and it is the
only thing standing between here and a contract. Its output is the epoch
annotation for every post-baseline additive variant in every vocabulary
reachable from an affected payload.
2. Ratify the epoch mapping once that audit lands.
3. Confirm no third staging path has appeared beside the two in §1.
4. Decide whether `decode_vectors.txt` moves — it is value-level, so it should
not, but that is a check rather than an assumption.
*Related: `spec/PASS13_CANDIDATES.md` (P13-S14), `spec/PLAN_GENESIS_OPS.md` §4,
`spec/binary_format.tex` §"Schema Versioning" / §"What ``Additive'' Means
Here".*