From be40eb2d7dbc7ce0648bcab6a78037c93273ea85 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:18:49 -0400 Subject: [PATCH] Contract G2a: the split, and the two setters that move no wire bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G2 was scoped as one packet of three LWW setters. Reading the frozen codec walks rather than the type labels shows they do not sit at the same major: SpellingPrecedence has never been versioned, CanvasLayoutDefaults is versioned in the containing Canvas walk and not the leaf, and only ScoreTuningContext is born at v3. So the accept-set raise — a one-way door — is charged to one surface, not amortised across nine as the ruling's framing implied. G2 splits: G2a is the two major-0 setters and touches epiphany-bundle not at all; G2b is SetTuningContext alone, carrying the raise and the S13 close. Withdraws plan trap 5. SetMetadata already answers it: Score::empty seeds metadata exactly as it seeds tuning_context, the base ingest seeds the LWW chain from it, and restoring that seed is correct for both never-authored and authored-to-default. These are always-valued fields, not map keys, so the Predecessor::Base/::Write distinction that matters for spellings and breaks does not apply here. Records the G1 lesson as a trap in its own right: an OperationKind variant is not containable to core+ops, and the G2a contract budgets all three downstream literal sites up front instead of discovering them mid-dispatch. Corrects S13's amortisation claim, and states the closure argument properly — the canonical base is a MaterializedState that embeds no graph values for any field, so S13 closes on the metadata precedent, not on the base. Notes the consequence: after G2b, pruning would discard authored state. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV --- spec/CONTRACT_GENESIS_G2A_SETTINGS.md | 288 ++++++++++++++++++++++++++ spec/PASS13_CANDIDATES.md | 2 +- spec/PLAN_GENESIS_OPS.md | 112 ++++++++-- 3 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 spec/CONTRACT_GENESIS_G2A_SETTINGS.md diff --git a/spec/CONTRACT_GENESIS_G2A_SETTINGS.md b/spec/CONTRACT_GENESIS_G2A_SETTINGS.md new file mode 100644 index 0000000..6c1d8db --- /dev/null +++ b/spec/CONTRACT_GENESIS_G2A_SETTINGS.md @@ -0,0 +1,288 @@ +# Contract: genesis tranche G2a — the two major-0 settings setters + +Repo root `/home/jeans/Repos/active/epiphany`. Governed by +`spec/RULING_GENESIS_PERSISTENCE.md` (011c68a) and `spec/PLAN_GENESIS_OPS.md` §4 +(ladder ratified 2026-07-24, split into G2a/G2b 2026-07-28). Predecessor: +`spec/CONTRACT_GENESIS_G1_INSTRUMENT.md`, landed at 3b09595. + +Execution model as every tranche on this track: Sonnet subagent, coordinator +line-level review with **independent mutation re-runs**, user deep-dive at +contract sign-off and final report. Mutation discipline throughout: anchor-assert +before substituting, restore by reversing, never `git checkout`. + +**Parallel safety.** The editor track owns `epiphany-editor-gui`, +`epiphany-render-svg`, `epiphany-glyphs`, every +`crates/epiphany-editor-gui/goldens/*.png`, `spec/PLAN_EDITOR_APP.md`, +`spec/CONTRACT_EDITOR_*.md`, and `spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` +(currently untracked — do not stage it). **This packet does cross into +`epiphany-editor-core` and `epiphany-layout-ir`**, by necessity, at the two +sites named in §"The boundary crossing". That crossing is bounded to those two +files and is authorized for this packet only. Stage files explicitly; never +`git add -A`. + +--- + +## What this packet does, in one sentence + +Adds two LWW settings operations — `SetCanvasLayoutDefaults` and +`SetSpellingPrecedence` — on the `SetMetadata` pattern, so that two more of the +ruling's nine `Score` surfaces become operation-authored, **without touching any +wire bound**. + +## Why these two, and why not the third + +G2's three setters do not sit at the same schema major. Verified by reading the +frozen codec walks, not by inference: + +| Op | Carried type | Major | Evidence | +|---|---|---|---| +| `SetSpellingPrecedence` | `SpellingPrecedence` | **0** | plain `Codec::dec` at v0/v1/v2 and live (`core/src/codec.rs:2673`, `:3249`, `:3372`) — never versioned | +| `SetCanvasLayoutDefaults` | `CanvasLayoutDefaults` | **0** | versioning lives in the containing `Canvas` walk, not the leaf: `dec_canvas_v0` default-fills the field (`codec.rs:2775`), `enc_canvas_v1` writes it through the live `Codec` (`:3158`) | +| `SetTuningContext` | `ScoreTuningContext` | **3** | `enc_tuning_context_v2` writes 3 fields (`codec.rs:3293`), the live `Codec` writes 5; a default `smufl` + empty `overrides` still append bytes | + +`SetTuningContext` alone drags `max_supported_major(OperationEnvelopeBlock)` +from 2 to 3, and that raise is a one-way door — an older reader meeting a v3 +block preserves the bundle read-only. It is **G2b**, a separate packet, and this +one must not anticipate it in any way. + +## Design pins + +1. **No wire layout is designed.** Both carried types already have `Codec` impls + and already ship inside `Score`. Add `CanvasLayoutDefaults` and + `SpellingPrecedence` to `canonical_value!` (`core/src/codec.rs:3455`) and + encode each payload as `push_lp_bytes(out, &self..canonical_bytes())` + — the `SetMetadataOp` template verbatim (`ops/src/payload.rs:1360`). The + generated `decode_canonical` already does decode → `finish()` → re-encode → + reject-on-mismatch, so strict-form enforcement is inherited, not written. +2. **Discriminants: kind 32/33, tag 32/33.** Both spaces currently top out at 31 + (`CreateInstrument`). Assign `SetCanvasLayoutDefaults` = 32, + `SetSpellingPrecedence` = 33 in **both** spaces. They coincide here by + accident, not by rule — the spaces are independent and misaligned elsewhere + (`RespellPitch` is kind 2 / tag 3). Append-only under + `req:binfmt:kind-discriminants`. +3. **`schema_major()` gains NO arm.** Both fall into the existing catch-all + `_ => 0` (`payload.rs:262`). **Adding them to the `=> 2` arm alongside + `SetMetadata` would be the bug** — `SetMetadata` is there because + `ScoreMetadata` has six mandatory major-2 appends, which is a property of + *that* type and nothing else. Test i5 exists to catch exactly this. +4. **No `epiphany-bundle` change of any kind.** + `max_supported_major(OperationEnvelopeBlock)` is 2 (`bundle.rs:69`) and + **stays 2**. Do not edit `bundle.rs`, and do not touch its cap comment at + `:58` — that prose belongs to G2b and moving it early leaves a different + falsehood behind. +5. **Reduction copies `set_metadata` structurally** (`reduce.rs:2814`): advisory + LWW, **no conflict, no idempotence short-circuit**, no `AlreadyApplied`. The + write chain records unconditionally; the graph field is overwritten when a + graph is present. Do **not** import the `create_staff` mint discipline + (`AlreadyApplied` / `RecreateContentMismatch`) — these are field overwrites, + not mints, and a re-write of an identical value is a legitimate new write. +6. **Both chains must be seeded from the base**, beside + `metadata_chain.seed(...)` (`reduce.rs:1385`). This is what makes + value-restoring undo of the *first* operational write restore the + pre-operational value rather than nothing. Under from-empty the base is + `Score::empty`, so the seed is the type's `Default` — and restoring the + default is correct for both the never-authored and the authored-to-default + case. There is no "never authored" state to distinguish: these are + always-valued `Score` fields, not map keys, so the `Predecessor::Base` vs + `::Write` distinction that matters for `spellings`/`breaks` + (`reduce.rs:707`) does **not** apply. Do not invent an `Option` wrapper. +7. **Undo plumbing is three sites per setter, and one is easy to miss.** Mirror + `metadata_chain` at: the `undo_verdict` walk (`reduce.rs:5184`), the + `ValueRestoration` enum variant (`:785`), and the restoration *application* + (`:5404`). Missing the application site fails silently — the verdict is + computed and discarded. +8. **The snapshot/restore pair.** Both chains must appear in the + snapshot/restore pair alongside `metadata_chain` (`reduce.rs:7386`, `:7423`). + **This is the G1 silent-failure site**: omitting it surfaces only under + undo/replay, never in a straight-line test. +9. **No deletes.** These are field overwrites; there is nothing to tombstone. + +## The boundary crossing + +Per `PLAN_GENESIS_OPS.md` §5 trap 6 — the G1 lesson, budgeted up front rather +than discovered mid-dispatch. Adding an `OperationKind` variant is **not** +containable to core + ops: + +* **`crates/epiphany-editor-core/src/barriers.rs:468`** — `subjects_of` is + exhaustive; without arms the workspace does not compile, and because + `epiphany-testkit` depends on editor-core, **the entire gate is blocked** — + conformance and `requirement_labels` included. Both new kinds are score-level + field overwrites with no resolvable region or object, exactly like + `SetMetadata`: **join the existing `OperationKind::SetMetadata(_) | + OperationKind::DeclareTransaction(_)` arm** rather than writing new ones. + That file's module doc (`:22`) already names `SetMetadata` as the + score-level exemplar; extend that sentence. +* **`crates/epiphany-layout-ir/src/barrier.rs:1105`** — the "one past the + vocabulary" literal, currently `32`, becomes `34`. It is deliberately a + literal rather than `PAYLOAD_FREE.len()` so the bump is a conscious act; keep + it that way and update the comment's kind list. +* **`crates/epiphany-testkit/tests/text_projection_grammar.rs:307`** — a + hardcoded kind *count*, currently `32`, becomes `34`. Its own comment explains + why it stays a literal; do not "fix" it into a derivation. + +## The companion version bump + +Two new **kind** productions are a document-surface grammar change, so +`COMPANION_VERSION` moves **0.8.0 → 0.9.0** (`textproj/src/lib.rs:29`). This is +the G1 precedent and it is not optional: holding the version while extending the +grammar leaves two incompatible grammars claiming `(0 8 0)`, and +`req:textproj:header-version` requires a parser to accept exactly the version it +implements and reject all others. Cached projections do not migrate — +`TextProjection` is a non-canonical accelerator, so stale ones regenerate. + +Sites: `textproj/src/lib.rs:29` (the constant and its doc block), and in +`spec/text_projection.tex` the five live sites at `:237`, `:470`, `:521`, +`:1121`, `:1309` plus a **new** changelog row. + +**Do not bulk-replace `0.8.0` / `(0 8 0)` across the `.tex`.** The 0.8.0 +changelog row is history and must keep its version; a blind sweep falsified +exactly that row during G1 and had to be reverted. Edit the five live sites +individually and append the new row. + +Also flip `textproj/src/vectors.rs`'s negative +`superseded_companion_version` vector: its "wrong version" must become the newly +superseded `(0 8 0)`, since `(0 9 0)` is now the accepted one. + +## Touch points + +Derived by enumerating every `SetMetadata` site. Each row is **per setter** +unless noted. + +| # | File | Site (SetMetadata analogue) | +|---|---|---| +| 1 | `core/src/codec.rs` | `canonical_value!` — add both carried types (`:3455`) | +| 2 | `ops/src/payload.rs` | op struct + `CanonicalEncode` (`:1356`, `:1360`) | +| 3 | `ops/src/payload.rs` | `OperationKind` variant (`:167`) | +| 4 | `ops/src/payload.rs` | `discriminant()` → 32 / 33 (`:289`) | +| 5 | `ops/src/payload.rs` | `tag()` (`:336`) + encode dispatch (`:385`) | +| 6 | `ops/src/payload.rs` | `OperationKindTag` variant (`:427`) + `operation_kind_tag_vocabulary!` entry (`:533`) — **compile-enforced** | +| 7 | `ops/src/payload.rs` | `schema_major()` — **no edit** (pin 3) | +| 8 | `ops/src/envdecode.rs` | discriminant decode (`:530`) + tag→kind (`:819`) | +| 9 | `ops/src/v0.rs` | `V0OperationKind` variant (`:92`) | +| 10 | `ops/src/migrate.rs` | both directions (`:167`, `:323`) | +| 11 | `ops/src/reduce.rs` | dispatch (`:2736`) + the setter fn beside `set_metadata` (`:2814`) | +| 12 | `ops/src/reduce.rs` | chain decls (`:901`, `:1012`), init (`:1292`), base seed (`:1385`) | +| 13 | `ops/src/reduce.rs` | undo verdict (`:5184`), `ValueRestoration` variant (`:785`), restoration apply (`:5404`) | +| 14 | `ops/src/reduce.rs` | snapshot / restore (`:7386`, `:7423`) | +| 15 | `ops/src/textproj_kind.rs` | production (`:174`) + parse (`:443`) | +| 16 | `ops/src/fuzz.rs` | generator arm (`:197`) | +| 17 | `ops/src/valuegen.rs` | LWW generator (`:325`) | +| 18 | `ops/src/vectors.rs` | a decode vector per new payload | +| 19 | `editor-core/src/barriers.rs` | join the score-level arm (`:468`) + module doc (`:22`) | +| 20 | `layout-ir/src/barrier.rs` | literal 32 → 34 (`:1105`) | +| 21 | `testkit/tests/text_projection_grammar.rs` | count 32 → 34 (`:307`) | +| 22 | `textproj/src/lib.rs` | `COMPANION_VERSION` → `(0, 9, 0)` (`:29`) | +| 23 | `textproj/src/vectors.rs` | flip the superseded-version negative vector | +| 24 | `spec/text_projection.tex` | `kind` production + five version sites + changelog row | +| 25 | `spec/operation_catalog.tex` | §`SetCanvasLayoutDefaults`, §`SetSpellingPrecedence` | + +The tag vocabulary macro (#6) is a **single source of truth**: the decoder, fuzz +corpus, conformance vectors, and edit-barrier round-trip all read +`PAYLOAD_FREE`, and a variant missing an entry **fails to compile**. It exists +because Push 4a added `TransposeInterval` to a hand-written match and nothing +else, and four hand-maintained lists stayed green while the decoder rejected its +own encoding. `OperationKind::discriminant()` (#4) has **no such guard**. + +## Tests + minimum mutations + +Every test must be mutation-verified: re-introduce the bug it exists to catch and +show it dies. A test that cannot see its own bug is not a test. + +* **(s1) from-empty authoring.** From `Score::empty(identity)` through + `reduce_operation_set_onto`, each setter produces the authored value in the + materialized `Score`. **Mutation:** drop the graph write in the setter fn → + the field stays at its `Default` while the effect still reads `Applied`. +* **(s2) LWW, last write wins, no conflict.** Two concurrent differing writes + resolve to the later in canonical order and record **no** conflict — matching + `SetMetadata` (`ops/tests/graph_reduction.rs:1408`). **Mutation:** reverse the + canonical comparison → the earlier value wins. +* **(s3) re-write of an identical value is a new write, not a no-op.** Assert + the effect is `Applied` and the chain grew. **Mutation:** add an + `AlreadyApplied` short-circuit → dies. *This test exists because pin 5 is the + most likely thing for a subagent to get wrong by pattern-matching on + `create_staff`.* +* **(s4) value-restoring undo reaches the seeded base.** Author once inside a + transaction, undo it, and assert the field returns to the **base** value — + run this both from-empty (base = `Default`) and onto a loaded base with a + non-default value, so the test distinguishes "restored the base" from + "restored the type default". **Mutation:** remove the `.seed(...)` call → + the undo produces `Restore(None)` and the field does not move. +* **(s5) minimal stamping stays 0.** Both kinds report `schema_major() == 0` + for every value, including a non-default one. Extend + `schema_majors_follow_the_minimal_stamping_rule` (`reduce.rs:10546`). + **Mutation:** move either kind into the `=> 2` arm → dies. +* **(s6) the accept-set did not move.** Assert + `max_supported_major(ChunkKind::OperationEnvelopeBlock) == 2`, and assert a + block carrying either new kind stamps at major 0. **Mutation:** stamp a + payload at 3 → the assertion fires. *This is the packet's boundary against + G2b.* +* **(s7) undo survives snapshot/restore** — the pin-8 site. Snapshot the + reducer mid-run, restore, then undo. **Mutation:** omit either chain from the + snapshot/restore pair → the restored reducer loses the write history and the + undo silently no-ops. +* **(s8) decode vectors pinned to literal bytes**, not round-trip. Round-trip + locking cannot see a self-consistent encoder/decoder reorder — the 3b-i + lesson, where a swap applied to both halves passed 1283 tests and 8/8 + conformance. +* **(s9) text-projection round-trip** for both kinds, matching the existing + per-kind coverage, plus a negative test that a `(0 8 0)` header is now + **rejected**. + +## Blast radius + +`crates/epiphany-core/src/codec.rs` + its `DECISIONS.md`; +`crates/epiphany-ops/src/{payload,envdecode,v0,migrate,reduce,textproj_kind,fuzz,valuegen,vectors}.rs` ++ its `DECISIONS.md`; `crates/epiphany-textproj/src/{lib,vectors}.rs`; +`crates/epiphany-editor-core/src/barriers.rs` and +`crates/epiphany-layout-ir/src/barrier.rs` (the two authorized crossings only); +`crates/epiphany-testkit/tests/text_projection_grammar.rs`; +`spec/{operation_catalog,text_projection}.tex` **and their rebuilt PDFs**; +`spec/vectors/*.txt` (regenerated, not hand-edited). + +**Nothing else.** No `epiphany-bundle`, no `binary_format.tex`, no +`epiphany-editor-gui`, no golden re-blessing. + +Expect `the_canonical_base_is_byte_identical_across_data_model_majors` +(`reduce.rs:10715`) to require a **conscious re-pin**: the seeded corpus's +`gen_payload` gains discriminants 32/33, shifting the RNG stream, exactly as at +Phase D and at G1. That is a corpus shift, not a value leak. **State in the +report which it is, and how you distinguished them** — a genuine leak of a +settings value into the canonical base would be a ruling violation, and the +easy mistake is to re-pin without checking. + +## Gate (report actual output, never stale numbers) + +`cargo fmt --check`; `cargo clippy --workspace --all-targets -D warnings` at +**0** warnings; `cargo test --workspace` at **0** failed; `cargo test --doc` at +0 failed; conformance **8/8**, and **9/9** with `--features golden-gate`; +`requirement_labels` **6/6** with its three observed counts reported as seen +(212/282/282 at 439e1e2; they move with the catalog sections). Plus: + +* `max_supported_major(OperationEnvelopeBlock)` is **still 2** — assert it in + code and state it in the report. +* Vector corpora **regenerated** via + `cargo run -q -p epiphany-testkit --example generate_vectors`, never + hand-edited, with the decode-vector count reported before and after. +* Both `.tex` PDFs rebuilt (`latexmk -xelatex`) with **0 undefined references** + reported. A `.tex` commit without its PDF has been a repeat lapse. +* No `crates/epiphany-editor-gui/goldens/*.png` byte changes. + +## What I will verify independently before committing + +Build to survive this. I re-run every mutation myself, and I check specifically: +that #4's hand-written discriminant match and #6's macro entry agree; that pin 3 +really added no `schema_major` arm; that pin 8's snapshot/restore pair was not +missed; that s3 fails for the stated reason rather than incidentally; that the +canonical-base re-pin is a corpus shift and not a value leak; that the `.tex` +0.8.0 changelog row was **not** rewritten; and that no claim in the report is +copied forward from this contract rather than observed — the recurring failure +mode on this project is a plausible claim propagating because nobody re-derived +it. + +## Report + +Files + summary, exact asserted values, every mutation with kill evidence, gate +output verbatim, deviations flagged explicitly. If any pin here turns out to be +wrong, say so rather than working around it silently. In particular: if the +boundary crossing turns out to be wider than the three sites named above, that +is a finding about the contract, not a nuisance — report it. diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index 421bad5..4e817c7 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -90,4 +90,4 @@ visible — a value with a wire form and no canonical carrier to reach it.) | P13-S10 | **`PitchSpaceModification::Cents(f64)` puts a raw `f64` in canonical state, which the byte layer cannot encode.** `core_spec.tex:3112` declares `Cents(f64)`; `PitchSpaceModification` is reached from canonical score state through `AccidentalDefinition` → `ScoreAccidentalExtensions` → `ScoreTuningContext`. `req:determinism:canonical-floating-point` requires canonical stored floats to be finite IEEE 754 binary64, and the byte layer enforces it with no escape hatch: `serialize.rs:110` decodes floats *only* through `CanonicalF64::from_le_bytes → NonFiniteFloat`, so there is no `Codec for f64`. A raw `f64` is therefore not merely risky in canonical state — it is unencodable without inventing a new unvalidated codec. The same chapter already resolved this exact tension forty lines later: `EngravingBoundingBox` (`:3150`) carries `SpaceUnit` edges with a rationale (`:3191`) invoking the same requirement | this file (verified 2026-07-23 against `core_spec.tex:3112`, `:3150-3197`, and `crates/epiphany-determinism/src/serialize.rs:110`) | **resolved** (ratified: `Cents(CanonicalF64)`. A one-line spec correction, the only shape consistent with the existing byte layer — the identical maneuver Ruling D applied to the bounding box. Freezes in tranche 3b) | | 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 2–4 (`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:** the ruling's "one accept-set raise, spent once" lands every new kind as a single batch at `OperationEnvelopeBlock` major 3, amortising the raise across nine surfaces instead of charging it to this one. 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-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 2–4 (`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) | diff --git a/spec/PLAN_GENESIS_OPS.md b/spec/PLAN_GENESIS_OPS.md index 6f6162c..54c679a 100644 --- a/spec/PLAN_GENESIS_OPS.md +++ b/spec/PLAN_GENESIS_OPS.md @@ -6,7 +6,10 @@ reverses Pass-12 K8 and makes every mutable field of `Score` operation-authored. This plan is the execution scope: what the tranche touches, in what order, and which questions must be answered before a dispatch contract can be written. -**Status:** scoped, not dispatched. §6 lists what needs ratification first. +**Status:** **G1 landed** (3b09595, CI green) via +`spec/CONTRACT_GENESIS_G1_INSTRUMENT.md`. **G2a contracted** +(`spec/CONTRACT_GENESIS_G2A_SETTINGS.md`); G2b and G3 scoped, not contracted. +§6 lists what still needs ratification. --- @@ -122,20 +125,68 @@ G1 also carries the two constraints that are not per-surface: No accept-set raise. No wire change. Highest value per unit of risk in the whole tranche. -### G2 — the three settings setters +### G2 — the settings setters, split in two -`SetCanvasLayoutDefaults`, `SetSpellingPrecedence`, `SetTuningContext` on the -`SetMetadata` LWW pattern (`reduce.rs:2713`), seeded for value-restoring undo -(`:1357`). This is where the **one accept-set raise is spent**: `bundle.rs`'s -`max_supported_major(OperationEnvelopeBlock)` moves 2 → 3. +All three ride the `SetMetadata` LWW pattern (`reduce.rs:2814`), seeded for +value-restoring undo (`:1385`). But they do **not** sit at the same major, and +only one of them is a compatibility event. Verified 2026-07-28 against the +working tree: -Note `bundle.rs:58` documents the current cap **with the tuning-context -rationale in prose** — "no operation payload embeds the tuning context, so no op -block is ever born at v3". That comment becomes false the moment this lands and -must move with the cap. Same for `DECISIONS.md`'s superseded prohibition, which -is already marked. +* `SetSpellingPrecedence` → `SpellingPrecedence`. Major **0**: the frozen + `decode_v0_score`/`decode_v1_score`/`decode_v2_score` walks and the live + `Codec` all read this field through plain `Codec::dec` (`codec.rs:2673`, + `:3249`, `:3372`) — it has never been versioned. +* `SetCanvasLayoutDefaults` → `CanvasLayoutDefaults`. Major **0**. The type is + labelled "schema major 1" (`graph.rs:836`), but the versioning lives in the + *containing* `Canvas` walk, not the leaf: `dec_canvas_v0` (`codec.rs:2775`) + default-fills the whole field while `enc_canvas_v1` (`:3158`) writes it + through the live `Codec`. As a standalone payload it has exactly one layout. +* `SetTuningContext` → `ScoreTuningContext`. Major **3**, and + **unconditionally** so. `enc_tuning_context_v2` (`codec.rs:3293`) writes three + fields; the live `Codec` writes five. A default `smufl` and an empty + `overrides` still append bytes, so there is no value for which a lower-major + layout exists — this is a `CreateInstrument`-shaped arm, not a + `CreateRegion`-shaped one. -**P13-S13 closes here.** +So the split is not tidiness. Two of the three move no wire bound at all, and +the accept-set raise is a **one-way door**: once a block can be born at v3, an +older reader meeting one preserves the bundle read-only (`bundle/src/error.rs:257`). +Spending that in the same packet as two major-0 leaf setters buries it. + +**G2a — `SetCanvasLayoutDefaults` + `SetSpellingPrecedence`.** Both land in +`schema_major()`'s catch-all `_ => 0` arm (`payload.rs:262`) with **no arm +added**; adding them to the `=> 2` arm would be the bug. No `epiphany-bundle` +change of any kind. + +**G2b — `SetTuningContext` alone**, carrying the raise, the `bundle.rs` prose, +and the S13 close. `bundle.rs:58` documents the current cap **with the +tuning-context rationale in prose** — "no operation payload embeds the tuning +context, so no op block is ever born at v3". G2b is precisely what falsifies +that sentence, so the comment must move with the number. Same for +`DECISIONS.md`'s superseded prohibition, which is already marked. + +**The cost of splitting, stated honestly:** each packet appends *kind* +productions to the text-projection grammar, and a kind append is a +document-surface change (the G1 precedent). So the companion bumps twice — +0.8.0 → 0.9.0 → 0.10.0 — and each bump re-sweeps five live version sites in +`text_projection.tex` plus a changelog row, re-flips the negative +`superseded_companion_version` vector, and regenerates the vector corpora. +That is mechanical and pre-1.0; it is the cheaper of the two risks. + +**P13-S13 closes at G2b — and on the metadata precedent, not on the canonical +base.** The base cannot carry a v3 tuning context: it is role-bound to major 0 +(`mis_stamped_canonical_base`, `bundle.rs:866`). It does not need to. The +canonical base is a `MaterializedState` (`reduce.rs:504`) — effects, conflicts, +anomalies, objects, spellings, breaks, page-breaks, pending — which embeds **no +graph values for any field**, including `metadata`, op-authored since M2d and +durable purely through its operations. S13's claim was "no canonical carrier +embeds it at all: no operation authors it". G2b makes an operation author it, +and the op log is canonical. + +**What that sharpens.** The standing prohibition on pruning (blocked on +disposition C) stops being a performance concern the moment G2b lands: pruning +would then discard authored genesis state, not merely re-derivable state. G2b's +contract must state this as an explicit non-goal. ### G3 — the remaining entity families @@ -168,9 +219,37 @@ Consider splitting it out if G3 runs long. 3b-i: a swap applied to both codec halves passed 1283 tests and 8/8 conformance. New payloads want decode-vector entries pinned to literal bytes, not just round-trip tests. -5. **`Score::empty` seeds `tuning_context` with a default**, so +5. ~~**`Score::empty` seeds `tuning_context` with a default**, so `SetTuningContext`'s reduction must distinguish "never authored" from - "authored to the default value" if undo is to restore correctly. + "authored to the default value" if undo is to restore correctly.~~ + **Withdrawn 2026-07-28 — this is not a trap, and `SetMetadata` already + proves it.** `Score::empty` seeds `metadata` with a default exactly as it + seeds `tuning_context` (`graph.rs:1749`, `:1757`), and the base ingest then + runs `metadata_chain.seed(score.metadata.clone())` (`reduce.rs:1385`) under + a comment stating the purpose outright: the score-level LWW chains seed with + the base values so a value-restoring undo of the *first* operational write + restores the pre-operational state. Since from-empty reduces **onto** + `Score::empty` (trap-free only through `reduce_operation_set_onto` — G1 pin + 10), the seed runs, and undoing the first write yields + `Restore(Some(Predecessor::Base(default)))`. Restoring the default is + correct in both the never-authored and the authored-to-default case, so the + distinction is unobservable **and must stay so**. The `Predecessor::Base` + vs `::Write` distinction earns its keep only for the canonical bookkeeping + families (`spellings`, `breaks`, `page_breaks`, `reduce.rs:707`), where a + base predecessor returns a *map key* to absence. `ScoreTuningContext` is an + always-valued `Score` field, like metadata: there is no absent state to + return to. All three G2 setters copy `set_metadata` structurally. +6. **Adding an `OperationKind` variant is NOT containable to core + ops** — + the G1 lesson, and the one claim this plan previously got wrong. Rust + exhaustiveness forces an arm in `epiphany-editor-core`'s `subjects_of` + (`barriers.rs:437`), and because `epiphany-testkit` depends on editor-core, + a missing arm blocks conformance *and* `requirement_labels` — the gate + cannot run at all. Three further sites bake in a literal that only surfaces + once the workspace compiles: `layout-ir/src/barrier.rs:1105` (a tag + "one past the vocabulary"), `testkit/tests/text_projection_grammar.rs:307` + (a hardcoded kind *count*), and `textproj/src/vectors.rs` (a negative vector + whose "wrong version" is the one each bump moves to). Every G2/G3 contract + MUST enumerate these and budget the boundary crossing up front. ## 6. Open rulings — needed before a dispatch contract @@ -186,7 +265,10 @@ Consider splitting it out if G3 runs long. 3. **The measure/meter invariant.** Measures are authored, not derived (ruling §2), so measure/meter consistency becomes an authoring obligation backed by a graph invariant. That invariant needs specifying — it belongs in G3. -4. **Ladder shape.** G1/G2/G3 as above, or a different cut. +4. ~~**Ladder shape.** G1/G2/G3 as above, or a different cut.~~ **Ratified + 2026-07-24**, and amended 2026-07-28: G2 splits into **G2a** (the two + major-0 setters) and **G2b** (`SetTuningContext` alone, carrying the + accept-set raise and the S13 close). See §4. *Related: `spec/RULING_GENESIS_PERSISTENCE.md`, `spec/ANALYSIS_GENESIS_PERSISTENCE.md`, `spec/PLAN_EDITOR_APP.md` §Ruling B / §3.7, `spec/PLAN_PUSH4B_TUNING.md` (the