Contract G2a: the split, and the two setters that move no wire bound

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV
This commit is contained in:
Levi Neuwirth 2026-07-28 14:18:49 -04:00
parent 3b09595196
commit be40eb2d7d
3 changed files with 386 additions and 16 deletions

View File

@ -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.<field>.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.

View File

@ -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-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-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-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 24 (`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 24 (`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) |

View File

@ -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 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. 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 No accept-set raise. No wire change. Highest value per unit of risk in the whole
tranche. tranche.
### G2 — the three settings setters ### G2 — the settings setters, split in two
`SetCanvasLayoutDefaults`, `SetSpellingPrecedence`, `SetTuningContext` on the All three ride the `SetMetadata` LWW pattern (`reduce.rs:2814`), seeded for
`SetMetadata` LWW pattern (`reduce.rs:2713`), seeded for value-restoring undo value-restoring undo (`:1385`). But they do **not** sit at the same major, and
(`:1357`). This is where the **one accept-set raise is spent**: `bundle.rs`'s only one of them is a compatibility event. Verified 2026-07-28 against the
`max_supported_major(OperationEnvelopeBlock)` moves 2 → 3. working tree:
Note `bundle.rs:58` documents the current cap **with the tuning-context * `SetSpellingPrecedence``SpellingPrecedence`. Major **0**: the frozen
rationale in prose** — "no operation payload embeds the tuning context, so no op `decode_v0_score`/`decode_v1_score`/`decode_v2_score` walks and the live
block is ever born at v3". That comment becomes false the moment this lands and `Codec` all read this field through plain `Codec::dec` (`codec.rs:2673`,
must move with the cap. Same for `DECISIONS.md`'s superseded prohibition, which `:3249`, `:3372`) — it has never been versioned.
is already marked. * `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 ### 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 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, conformance. New payloads want decode-vector entries pinned to literal bytes,
not just round-trip tests. 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 `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 ## 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 3. **The measure/meter invariant.** Measures are authored, not derived (ruling
§2), so measure/meter consistency becomes an authoring obligation backed by a §2), so measure/meter consistency becomes an authoring obligation backed by a
graph invariant. That invariant needs specifying — it belongs in G3. 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`, *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 `spec/PLAN_EDITOR_APP.md` §Ruling B / §3.7, `spec/PLAN_PUSH4B_TUNING.md` (the