epiphany/spec/CONTRACT_GMINOR_IMPLEMENTAT...

358 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, `V1``V3` 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 `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 (`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`, `IntegrityIndex`** — **no 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 `ChunkRef`s 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 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_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.