epiphany/spec/CONTRACT_GMINOR_IMPLEMENTAT...

21 KiB
Raw Blame History

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 2427; PreconditionFailureReason::TempoMapMalformed = 11
5 Pass-12 G-pass ReanchorReason::SameCanvasNearer = 6; PreconditionFailureReason 1213
6 Schema-major-2 repeat revision OperationKind/OperationKindTag 2829
7 Push 4a OperationKind/OperationKindTag 30; PreconditionFailureReason 1415
8 Genesis G1 OperationKind/OperationKindTag 31
9 Genesis G2a OperationKind/OperationKindTag 3233

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, V1V3 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 V1V3, 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 1015). 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, IntegrityIndexno 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 2433 takes that tag's epoch; otherwise it retains its baseline. Changed child ChunkRefs 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 itepiphany-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 2427 and 28/29. Kinds 3033 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.