21 KiB
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, orOperationPayloadvariant. The four-document append ritual (binary_format.tex+operation_catalog.tex+core_spec.tex+text_projection.texpayload/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
OperationPayloadvariant, its primitiveOperationKind(whenPrimitive), 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:V0keeps minor 1,V1–V3keep 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 (ReanchorReason6 orPreconditionFailureReason10–15). It never emitsOperationKind,OperationKindTag, orOperationPayload, 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.CompressionAlgorithmis excluded by ruling (audit §1.8):ChunkReftransport 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.
- Producers supply the aggregate manifest
SchemaVersionexplicitly. The write paths take it as an input. CommitContextgains a previous-manifest-version field. This is new plumbing, not a read-through:CommitContext::previous_manifest(bundle.rs:156) is a&Manifest, andManifestcarries no schema-version field at all — it lives in the superblock (superblock.rs:174, bytes 64..68).- It must NOT become a
Manifestbody 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. - A manifest naming a barrier tag in 24–33 takes that tag's epoch;
otherwise it retains its baseline. Changed child
ChunkRefs never raise the manifest minor. The manifest major stays 0. - 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.
- 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_VERSION0.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.
- 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. - 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).
- 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.
- Describe the gate honestly in its own output and docs: it validates
known, decodable, in-tree artifacts. It is not evidence that
textprojvalidates 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 --checkclean;cargo clippy --workspace --all-targets0 warnings;cargo test --workspacegreen 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.pdfandtext_projection.pdf; report whethercore_spec.pdforoperation_catalog.pdfchanged 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 --shortbefore 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'sM Cargo.tomland untrackedspikes/— 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.