10 KiB
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: 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 -cover 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:
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. (V0is{0, 1};V1/V2/V3are{n, 0}. The baselines are already inconsistent — §4 preserves that rather than normalising it.)- Both writer-side staging paths derive only the major
(
testkit/src/bundle_harness.rs:32,textproj/src/serialize.rs:189). - 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 identicalManifestIds". Once aChunkRefchanges, 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_VERSIONbump. 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.
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-
OperationKindminor. An envelope also emits the independent outerOperationPayloaddiscriminant, andResolveEquivocation— appended at 3 — contains noOperationKindat 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:
ChunkKindis closed. It has noRegisteredvariant and its discriminant enters every chunk's hash preimage, so a new chunk kind is a format-major event, not a minor one.Registeredescape variants are not schema changes at all — the wire form is already defined.binary_format.texlists 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:
- Each additive format revision receives one globally meaningful minor epoch.
- Every appended variant is annotated with the epoch that introduced it.
- An envelope's required minor is the maximum across its outer payload variant, its primitive kind, and every nested additive variant actually emitted.
- A block takes the maximum required minor over its envelopes. The major
remains the independent maximum of
schema_major(). - Old content keeps its major's baseline —
V0's existing1, 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.
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.
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
MaterializedStatebytes 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::SCHEMAstays unchanged. A changed childChunkRefis 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 24–33".
5. What must complete before a contract
- 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.
- Ratify the epoch mapping once that audit lands.
- Confirm no third staging path has appeared beside the two in §1.
- Decide whether
decode_vectors.txtmoves — 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".