# Contract: Editor T4-pre — the layout-IR readiness tranche Repo root `/home/jeans/Repos/active/epiphany`. Governed by `spec/PLAN_EDITOR_APP.md` §3.7 ("the layout-IR readiness tranche (T4 prerequisite)") and named as a prerequisite by Ruling A: *"IR per-system primitive ownership; the shared typed glyph-asset seam; the text-run primitive decision. These are IR/render tranches this ruling depends on, not work it smuggles in."* T3 is complete at `85d8af6`. Execution model as T1a/T2/T3: Sonnet subagents per packet, coordinator line-level review with independent mutation re-runs, user deep-dives at contract sign-off, any new golden baseline, and the final report. Mutation discipline throughout: anchor-assert before substituting, restore by reversing, never `git checkout`. **Parallel safety.** The Push-4b track owns `crates/epiphany-core/**`, its `DECISIONS.md`, and all `.tex` (`CONTRACT_PUSH4B_RESOLVER.md` blast radius); it is live in the working tree. This tranche touches **no `epiphany-core` file, no `.tex`, and adds no requirement label** — counts stay at whatever the parallel track has them at; report observed actuals, never assert stale numbers. **The literal census, both structs.** W1 adds a field to `ResolvedSystem` as well as to `ResolvedLayoutIR`, and Rust struct literals are exhaustive, so every literal of both must be updated or the workspace does not compile. Verified: * `ResolvedLayoutIR {` — `layout-ir/src/{resolved.rs:405, solver.rs:518}`, `engrave/src/lib.rs:407`, `render-svg/src/svg.rs:1049`, `testkit/src/layout_stub.rs:436`. * `ResolvedSystem {` — `layout-ir/src/solver.rs:501`, `engrave/src/casting.rs:1950`, `testkit/src/layout_stub.rs:461`, **and `editor-core/src/lib.rs:4718`** — the two-system hit-test fixture in editor-core's test module, a full four-field literal with no `..` spread, and `ResolvedSystem` cannot implement `Default` (it carries a `Provenance`). So this tranche **does** reach one editor-crate file: that test literal, a **mechanical field-add only**. `epiphany-editor-core` otherwise borrows `&ResolvedLayoutIR` and mutates `.pages`; it **must not consume ownership** — adoption stays at T4 per pin 8. Parallel safety is unaffected either way: Push-4b owns `epiphany-core`, not the editor crate. --- ## W1 — per-system primitive ownership (dispatchable now) ### The verified starting point Read these before designing; they change the shape of the work: * **The partition already exists and is discarded.** Casting-off computes `system_of_slot`, `stroke_system`, `curve_system`, and `region_of_system` and returns them on `CastLayout` (`engrave/src/casting.rs:186-206`); `engrave/src/lib.rs:386-391` folds `cast.glyphs/strokes/curves/pages` into `ResolvedLayoutIR` and **drops every ownership vector on the floor**. W1 is "stop discarding it", not "infer it". * **Glyph ownership is derivable exactly, and one consumer already derives it**: the quality census maps a glyph to a system through `system_of_slot[constrained_glyph.horizontal_slot]` (`engrave/src/quality.rs:232`), skipping slots no region claimed. * **Cross-system primitives are already resolved at this stage.** A system-spanning stroke is replaced by its first segment plus synthesized continuation segments, and a system-spanning curve is split by de Casteljau subdivision, the continuations carrying `SYSTEM_CONTINUATION_SYNTHESIS` provenance (strokes `casting.rs:1051-1075`, curves `casting.rs:1128-1165`). So **every resolved primitive belongs to at most one system already**. Fact 2's warning that "cross-system curves and boundary-straddling primitives make inference ambiguous" is an argument against *spatial inference by the consumer*, not against publishing what casting-off knows. * **`SystemId(pub u128)` (`layout-ir/src/cache.rs:10`) has no production constructor** — the single construction in the tree is testkit's random cache generator (`layout_stub.rs:630`, property-test fodder for the cache codec). Nothing maps a real system into `LayoutCache.resolved: BTreeMap`; it is inert scaffolding for T4b. W1 does **not** wire it; see the identity pin. ### Design pins 1. **Ownership lives on the system, not in a parallel table.** `ResolvedSystem` gains one field holding index lists into the layout's flat `glyphs`/`strokes`/`curves` arrays (`u32` indices — no layout nears 4 G primitives, and it matches the crate's existing count width), and `ResolvedLayoutIR` gains one **unowned** bucket of the same shape. Structural ownership removes the "table order must match page order" invariant a top-level parallel table would create, and each system already carries its own identity in `provenance`. 2. **No primitive is split, merged, reordered, or renumbered.** The flat arrays are exactly what they are today, in exactly today's order. W1 publishes indices into them and nothing else. 3. **Unowned is a first-class bucket, never coerced.** `None` — "claimed by no region" — goes to the unowned bucket. It has a real producer: the stub solver (`layout-ir/src/solver.rs:490-520`) builds a degenerate page tree of default-rect systems and resolves no per-system geometry, so **it publishes every primitive as unowned**. Fabricating an attribution there would be a lie about what that path computed. 4. **The partition is total and disjoint.** For each of the three arrays, the union of every system's indices plus the unowned bucket is exactly `0..len`, each index appearing exactly once. This is the load-bearing invariant; it is a test, not a comment. 5. **Identity is available, position is primary.** A consumer keys a system by its `provenance.stable_id` (`LayoutObjectId`); systems remain addressed positionally in page order. **Verified caution to record in DECISIONS.md, because it will bite T4b:** a region's *first* system reuses the region's own provenance verbatim (`casting.rs:1871`), and page 1 reuses the first region's provenance too (`casting.rs:1192`) — so a system's `stable_id` can equal a page's and a region's. On the **stub solver path the aliasing is total, not just first-system**: every stub system reuses its region's provenance (`solver.rs:501-502`), so the rule below has a producer on both solver paths. Uniqueness *among systems* is structural, not incidental — synthesized ids are domain-tagged hashes over (source, kind discriminant, namespaced instance key), and `KEY_NS_SYSTEM` and `KEY_NS_PAGE` occupy distinct namespaces — which is all this packet needs; **any future cross-kind map keyed on raw `LayoutObjectId`/`SystemId` u128s must disambiguate by kind.** 6. **Byte-neutral: ownership is NOT canonically encoded.** Precedent is in the same module: `vertical_band` is carried through but excluded, because "band ownership tells a vertical solver which staff owns a primitive; it draws nothing, so two layouts differing only in it are the same rendered layout and hash alike" (`resolved.rs:110-118`). System ownership draws nothing either. `encode_canonical` is therefore **unchanged**, and the module's canonical-serialization note gains ownership to its stated exclusions. If a future normative requirement wants ownership pinned on the wire, that is a spec-side schema-major decision — not this packet's budget to spend. Two consequences the **DECISIONS.md entry must state outright**, because they are what stop a later reader from "fixing" this: encoding ownership would make the fingerprint *more fragile than the rendering it fingerprints* — a casting-off refactor that re-partitions without moving a pixel would become a byte-level break and force a schema major for something no renderer and no conformance claim can observe; and because two conformant implementations may legitimately partition differently, **any future cross-implementation test of incremental relayout compares final bytes, never intermediate partitions.** 7. **One derivation, not two.** Casting-off publishes `glyph_system` on `CastLayout` (computed once, from `system_of_slot`) and the quality census **consumes it** instead of re-deriving. Two copies of an attribution rule drift; this is the packet that makes that impossible. 8. **One accessor.** `ResolvedLayoutIR` gains a `systems()` walk in page order (pages → systems), which the partition tests use. Editor-core hand-rolls this same flatten today (`editor-core/src/lib.rs:989`); it adopts the accessor at T4, not here. ### Tests + minimum mutations Value-asserting throughout; the multi-system fixtures already exist (`ten_measure_single_staff` casts to 2 systems at the demo's scale; `ten_measure_with_slurs` is the casting fixture behind golden G4). * **(m1) totality/disjointness** — the partition test of pin 4 on a real multi-system engrave. *Mutation:* drop the last glyph index from a system's list → dies. * **(m2) no coercion** — the stub-solver path publishes everything unowned. *Mutation:* map `None` to system 0 → dies. * **(m3) attribution correctness** — assert the *actual* per-system glyph / stroke / curve counts of a two-system fixture (real numbers, reported in the packet report). *Mutation:* off-by-one on the system index → dies. * **(m4) the census really consumes the published vector** — *mutation:* publish `Some(0)` for every glyph while leaving `system_of_slot` correct → the existing quality-metric assertions die. If they survive, the census is still deriving its own answer and pin 7 is not done. * **(m5) continuation segments follow the later system** — a slur or stroke crossing a system break: its continuation is owned by the system it was split *into*. *Mutation:* attribute continuations to the source segment's system → dies. * **(m6) byte-neutrality is locked** — build a layout, clone it, perturb **only** the ownership lists, assert `canonical_bytes()` equal while `PartialEq` differs. *Mutation:* encode the ownership lists in `encode_canonical` → dies. ### Blast radius `crates/epiphany-layout-ir/src/{resolved.rs, solver.rs, lib.rs}`, `crates/epiphany-engrave/src/{casting.rs, lib.rs, quality.rs}`, the test-only literals at `render-svg/src/svg.rs:1049`, `testkit/src/layout_stub.rs:{436, 461}` and `editor-core/src/lib.rs:4718` (mechanical field-add only — editor-core consumes no ownership in this packet), and the `DECISIONS.md` of `layout-ir` and `engrave`. Nothing else. No new crate, no new dependency, no public API removed or renamed. --- ## W2 — the shared typed glyph-asset seam (contracted after W1 review) Named now so the tranche's shape is visible; **not dispatched with W1**. The Bravura table is private to `render-svg` (`outline()` is `pub(crate)`, `outline.rs:7`) and stores SVG path `d` strings generated by `tools/extract_bravura_outlines.py` (`outlines_generated.rs:1-20`). A canvas tessellator needs typed vector paths from a crate both the renderer and the app can depend on. **The pin that makes it safe**, to be detailed when W1 lands: the generator emits a **typed path representation alongside the existing `d` string**, and `render-svg` keeps emitting the `d` string byte-for-byte — so every SVG golden and every conformance byte stays identical while the canvas gets typed geometry. The open decision W2's contract resolves is the home (a new `epiphany-glyphs` crate vs. a `layout-ir` module), judged on dependency weight and the MSRV/CI job structure, plus whether the typed form is generated or parsed once at load. ## W3 — the text-run primitive decision (analysis packet, after W2) Ruling A's criterion 3 makes the text pipeline a hard spike criterion: shaping, fallback, bidi, and metrics consistent across canvas, SVG/PDF export, hit testing, and the accessibility tree. W3 is a **decision document**, not an implementation — what a text-run primitive is in the resolved IR, and which of shaping/metrics the IR owns versus the renderer — because getting this wrong forecloses the toolkit choice T4's spike is supposed to make freely. ## Coordinator deliverable, alongside W1 `spec/ANALYSIS_GENESIS_PERSISTENCE.md` — the **field-by-field `Score` table** Ruling B blocker (i) demands before it can be resolved, per the plan's T1b runway. Analysis only: no code, no `.tex`, no core edits, therefore no collision with the parallel track. Fable writes it while W1 runs; the user deep-dives it as a coordination point. --- ## Gate (every packet, actual output) The standard six; conformance **9/9 with `--features golden-gate`, 8/8 without** (both run); requirement labels reported as observed. Plus two locks specific to this tranche, run as before/after comparisons and reported with their actual digests: * **Layout canonical bytes are byte-identical.** Capture `ResolvedLayoutIR::canonical_bytes()` for the reference-suite fixtures at the base commit, then after the change; they must be equal. * **All five GUI goldens are byte-identical** (`ten_measure_open.png` 53638, `ten_measure_insert.png` 54590, `ten_measure_slurs_castoff.png` 57891, `ten_measure_caret_entry.png` 57379, plus G3's reuse of G1). No golden is re-blessed in this tranche — nothing here may change a pixel. Blast radius per packet as stated. Do not commit. ## Report Per packet, as every tranche: files + summary, exact asserted values (including the real per-system primitive counts), every mutation with kill evidence, gate output, deviations flagged explicitly.