diff --git a/spec/ANALYSIS_GENESIS_PERSISTENCE.md b/spec/ANALYSIS_GENESIS_PERSISTENCE.md index d5d581e..0dcdcd2 100644 --- a/spec/ANALYSIS_GENESIS_PERSISTENCE.md +++ b/spec/ANALYSIS_GENESIS_PERSISTENCE.md @@ -1,5 +1,11 @@ # Analysis: canonical graph-state persistence across genesis and pruning +> **Ruled 2026-07-24 — see `spec/RULING_GENESIS_PERSISTENCE.md`.** Disposition +> **B** was taken: the operation set absorbs genesis. This document stays as +> analysis and is not rewritten to match, so the evidence the ruling rests on +> remains readable; the two amendments it carries (the `Measure` correction and +> the promotion of `identity` to blocking) are marked inline where they belong. + **Status: analysis, not a ruling.** This is the field-by-field `Score` table that `spec/PLAN_EDITOR_APP.md` §Ruling B blocker (i) requires *before* the blocker can be resolved, and that the T1b runway names as step (1). It @@ -73,6 +79,7 @@ without a minting operation. |---|---|---|---|---| | 1 | `metadata` | ✅ | `SetMetadata` (LWW) | `reduce.rs:2713`, `:5249` | | 2 | `canvas.regions` | ✅ | `CreateRegion` / `DeleteRegion`, `ChangeRegionTimeModel`, `SetMetricGrid`, `SetTimeSignature`, `SetUserSystemBreak` / `SetUserPageBreak` | 29 sites incl. `:2656` | +| 2a | ↳ `StaffInstance.measures` | ❌ **none** | — see the correction below | `Measure {` built only in `testkit/src/fixtures.rs` | | 3 | `canvas.layout_defaults` | ❌ **none** | — page size and margins | zero hits in `epiphany-ops` | | 4 | `instruments` | ❌ **none** | — no `CreateInstrument` exists | read-only at `:1318` | | 5 | `staves` | ✅ | `CreateStaff` (mint), tombstone removal | `:3850`, `:2560` | @@ -92,11 +99,27 @@ without a minting operation. | 19 | `tombstoned_pitches` | ✅ derived | delete/tombstone paths | `:2526` | | 20 | `tombstoned_events` | ✅ derived | delete/tombstone paths | 3 sites | -**Eight fields have no operation that can produce them** (3, 4, 6, 7, 10, 15, -16, 17, plus `identity` at 18), and one more (14) can only be pruned back, -never authored. Of the covered ones, several are covered *only in the +**Eight top-level fields have no operation that can produce them** (3, 4, 6, 7, +10, 15, 16, 17, plus `identity` at 18), and one more (14) can only be pruned +back, never authored. Of the covered ones, several are covered *only in the graph-aware mode* — they write through `if let Some(score) = self.graph`. +> **Correction (2026-07-24, made while ruling on this analysis).** Row 2 scored +> `canvas.regions` op-covered at *container* granularity — regions, staff +> instances, voices — and that hid a ninth gap one level deeper. **`Measure` is +> authored by nothing**: no operation mints it, no reducer path writes it, and +> `Measure {` is constructed only in `testkit/src/fixtures.rs` (`:137`, `:235`, +> `:389`). `CreateStaffInstance` moreover *refuses* an instance carrying +> measures (`reduce.rs:3715`, container-not-empty), so they cannot enter at +> instance-mint either. A from-empty document therefore cannot have a measure, +> which puts this on the critical path rather than in the margins. Ruled +> **authored, not derived** (2026-07-24): `TimeAnchor::Measure { id, .. }` means +> cross-cutting structures anchor to measure ids, so deriving measures from the +> metric grid would make their identity a function of the meter and every +> time-signature change would orphan the anchors pointing into them. The cost +> accepted with that ruling is that measure/meter consistency becomes an +> authoring obligation backed by a graph invariant, not a model guarantee. + --- ## 3. The three findings @@ -224,6 +247,15 @@ added to `Score` — which argues, again, for C. 2. **Is `identity` document state or session state?** It is on `Score` today with no op coverage. If a document has one `IdentityContext` and each session mints under its own replica id, the field's role needs stating. + **Promoted to blocking (2026-07-24):** under the ruled disposition, + reduction runs onto `Score::empty(identity)`, so whoever opens the document + chooses the value — and the codec *encodes* it (`codec.rs:2766`, `:3223`), + so two replicas reducing an identical log produce Scores differing in an + encoded field while the music is identical. `IdentityContext` is + `{replica_id, next_counter}`, replica-scoped by construction + (`ids.rs:711-717`). This must be dispositioned before the tranche can be + specified; the likely answer is that it belongs in the manifest rather than + the graph. 3. **`decomposition_attachments` and `spelling_precedence`** are consumed by the prepass. Are they derived state that should be rebuilt rather than persisted — in which case they leave this table — or authored state? diff --git a/spec/PLAN_EDITOR_APP.md b/spec/PLAN_EDITOR_APP.md index 5da3ec2..b7d95bb 100644 --- a/spec/PLAN_EDITOR_APP.md +++ b/spec/PLAN_EDITOR_APP.md @@ -229,10 +229,11 @@ after the resolver tranche lands. * **T1b — the document layer (post-resolver; contract after its blockers resolve).** Rulings B and D: `EditorDocument` + single-writer enforcement in `epiphany-bundle`. Its runway, in order: (1) the **graph-state - persistence decision** — how canonical graph state is persisted across - *both* genesis *and* pruning, driven by the field-by-field `Score` table - (§Ruling B, blocker i); a coordinated spec/core/ops/format decision, or an - explicit T1b scope limit to empty/metadata/region documents; (2) the + persistence decision — RESOLVED 2026-07-24** + (`spec/RULING_GENESIS_PERSISTENCE.md`): the operation set absorbs genesis, + and pruning is parked until the canonical base carries graph values. The + lease/save/single-writer machinery does **not** depend on that tranche + landing and may be contracted in parallel with it; (2) the **versioned-decode disposition** — the migrate-on-read API in `epiphany-ops`, preferred and eventually mandatory, or the enforceable current-layout restriction (§Ruling B, blocker ii); (3) the **Ruling-D @@ -433,9 +434,14 @@ consumes them and never blocks on them — and the sequencing currency is the **schema-major budget** ("a major is a budget to spend deliberately", `PLAN_PUSH4B_TUNING.md`), not editor tranche numbers: -* **Genesis persistence (T1b blocker i, §Ruling B):** how genesis-only graph - data — instruments, canvas — is canonically persisted and travels with a - document. The one registry item that *does* gate an editor tranche. +* **Genesis persistence (T1b blocker i, §Ruling B) — RULED 2026-07-24, + tranche not yet dispatched.** `spec/RULING_GENESIS_PERSISTENCE.md`: the + operation set absorbs genesis, so this stops being "how is genesis-only data + persisted" and becomes a normal operation-vocabulary tranche — three LWW + settings setters and six entity mint families, one `OperationEnvelopeBlock` + accept-set raise spent once, Pass-12 K8 reversed. Blocked on the `identity` + disposition. The one registry item that *did* gate an editor tranche; the + gate is now the tranche's execution, not the decision. * **Articulations, dynamics, ornaments are empty wire types.** `ArticulationMark` / `DynamicMark` / `OrnamentMark` are unit structs (`event.rs:49-57`) that already encode — giving them real payloads is a @@ -573,13 +579,28 @@ The T2 ruling the ladder reserved, now drafted: Granting this ruling unblocks T2's W4 packet (copy/paste); W1–W3 do not depend on it. -### Ruling B — the document layer and persistence semantics — **REDRAFTED ×2 — NOT grantable yet** +### Ruling B — the document layer and persistence semantics — **REDRAFTED ×2 — blocker (i) RESOLVED 2026-07-24; blocker (ii) still open** *(First draft withdrawn for violating the grow-only operation set. Second -draft amended by the 2026-07-23 second review. Two blockers stand between +draft amended by the 2026-07-23 second review. Two blockers stood between this ruling and grant; both are outside the resolver-parallel blast radius, which is why T1b sequences after the resolver.)* +> **Blocker (i) is RESOLVED — `spec/RULING_GENESIS_PERSISTENCE.md`, ratified +> 2026-07-24.** The field-by-field table this blocker demanded was produced as +> `spec/ANALYSIS_GENESIS_PERSISTENCE.md` (nine uncovered surfaces, not the +> handful this text guessed at: `canvas.layout_defaults`, `instruments`, +> `staff_groups`, `parts`, `tuning_context`, `spelling_precedence`, +> `analysis_layers`, `views`, and `StaffInstance.measures`, plus `identity`). +> The ruling takes **neither** (a)-as-written nor (b): the operation set +> **absorbs** genesis — Pass-12 K8 is reversed, there is no genesis block, and +> a document is `Score::empty(identity)` plus its envelope log. Execution is a +> Push-4b-class coordinated tranche; the editor track consumes it. Two +> constraints carry forward into T1b: **pruning may not be implemented until +> disposition C lands** (a `MaterializedState` base cannot rebuild a score), and +> `identity`'s disposition blocks specification of that tranche. The text below +> is kept as the record of what the blocker was. + **Blocker (i) — canonical graph-state persistence, across genesis AND pruning.** Two halves of one question. *Genesis:* "every piece of content enters as operations" is false for more than instruments — genesis is diff --git a/spec/RULING_GENESIS_PERSISTENCE.md b/spec/RULING_GENESIS_PERSISTENCE.md new file mode 100644 index 0000000..c070f4f --- /dev/null +++ b/spec/RULING_GENESIS_PERSISTENCE.md @@ -0,0 +1,186 @@ +# Ruling: the operation set absorbs genesis + +**Ratified 2026-07-24.** Resolves `spec/PLAN_EDITOR_APP.md` §Ruling B blocker +(i) — canonical graph-state persistence across genesis and pruning. The +evidence is `spec/ANALYSIS_GENESIS_PERSISTENCE.md`, which stays as analysis; +this document is the decision and its constraints. + +Execution belongs to the **Push-4b-class coordinated spec+core+ops track**, +not to the editor track. The editor track consumes it and does not block on +it (§3.7's standing posture). + +--- + +## 1. The decision + +**Every mutable field of `Score` becomes operation-authored.** The Pass-12 K8 +ratification — "genesis is outside the operation set", `binary_format.tex:2420` +— is **reversed**. A document is `Score::empty(identity)` plus its envelope +log; the instrument → staff → staff instance → voice → event chain is +authorable end to end. + +**There is no genesis block.** No new chunk role, no manifest field, no +write-once payload, no immutability rule, and no merge or fail-closed rule for +a non-CRDT canonical blob. That machinery was the price of leaving genesis +outside the operation set, and this ruling declines to pay it. Genesis becomes +the first few operations of an ordinary log, converging under the rules +already in force. + +**Rationale, in one line:** genesis state is edited — instruments are added, +page geometry is changed, temperaments are chosen — and every alternative made +those edits either single-writer, unmergeable, or impossible. Concurrency is a +first-order product commitment (§3.4), so the exception was not worth +institutionalising in the format. + +### What was considered and rejected + +* **A — a canonical genesis block.** Rejected: it fixes genesis but leaves the + *music* unreconstructable after a prune, and a non-op canonical payload has + no merge rule, forcing either single-writer genesis or a fail-closed sync + rule. Considered in a staged form (immutable block + settings ops) and + rejected once it became clear the block buys nothing that B does not. +* **C — the canonical base carries graph values.** Not rejected — **deferred**, + and still required. See §4. +* **D — scope-limit T1b.** Rejected as a destination; unnecessary as a + sequencing device once B is committed to. + +--- + +## 2. Scope + +Two existing reduction patterns cover the whole tranche. Neither is new +semantics; both are templates already proven in `reduce.rs`. + +**Settings — LWW field-overwrite, the `SetMetadata` pattern** (`reduce.rs:2713`, +seeded for value-restoring undo at `:1357`): + +| Field | Operation | +|---|---| +| `canvas.layout_defaults` | `SetCanvasLayoutDefaults` | +| `tuning_context` | `SetTuningContext` | +| `spelling_precedence` | `SetSpellingPrecedence` | + +**Entities — set-union mint with byte-identical re-carry idempotence, the +`CreateStaff` pattern** (`reduce.rs:3850`, graph-aware preconditions at +`:3824`): + +| Field | Family | +|---|---| +| `instruments` | `CreateInstrument` … | +| `staff_groups` | `CreateStaffGroup` … | +| `parts` | `CreatePartDefinition` … | +| `analysis_layers` | `CreateAnalysisLayer` … | +| `views` | `CreateView` … | +| `StaffInstance.measures` | `CreateMeasure` … | + +Delete and modify coverage per family is the tranche contract's design work, +guided by the existing precedent (Group 3's "mint + empty-only delete" for +containers; `CreateStaff` lands today with no `DeleteStaff`, so full CRUD is +not automatically owed). What **is** owed is that every family's referential +preconditions are **graph-aware**, in the shape `CreateStaff` already uses: + +* `CreateStaffGroup.members`, `CreatePartDefinition.staves` → live `Staff`s; +* `CreateView.active_layers` → live `AnalysisLayer`s; +* `CreateMeasure` → a live `StaffInstance`; +* deleting an entity with live dependents → refuse (container-not-empty). + +### Measures: authored, not derived + +Ruled 2026-07-24. `TimeAnchor::Measure { id, .. }` means cross-cutting +structures anchor to measure ids; deriving measures from the metric grid would +make their identity a function of the meter, so every time-signature change +would re-derive a different measure set and orphan the anchors pointing into +it. Authored ids survive a meter change. + +**Accepted cost:** measure/meter consistency becomes an authoring obligation +backed by a graph invariant, not a model guarantee. The invariant belongs in +the tranche. + +### Out of scope, with reasons + +* **`identity`** — not an authored field; see §3, which must be settled first. +* **`decomposition_attachments`** — **derived, not authored.** The prepass + creates it (`core/src/prepass.rs:382`); reduction only ever *retains* + (`reduce.rs:2342`). It leaves the table rather than gaining operations. + Flagged for ratification with the tranche. + +--- + +## 3. The one open sub-decision, and it blocks specification + +**`identity` must be dispositioned before the tranche can be specified.** + +`IdentityContext { replica_id, next_counter }` is replica-scoped by +construction (`ids.rs:711-717`) yet lives on `Score`, which the codec +**encodes** (`codec.rs:2766`, `:3223`). Today the value is inherited from a +shared base, which masks the tension. Under this ruling reduction runs onto +`Score::empty(identity)`, so whoever opens the document chooses it — and two +replicas reducing an identical log then produce Scores differing in an encoded +field while the music is identical. + +Options: move it to the manifest (document-scoped, recommended); keep it on +`Score` but exclude it from the canonical encoding; or accept +replica-dependent `Score` bytes and confine byte-equality claims to +`MaterializedState`. **Not ruled here.** + +--- + +## 4. Standing constraints + +**Pruning MUST NOT be implemented until disposition C lands.** This ruling +makes documents openable, editable, and collaborative; it does **not** make +them prunable. A prune deletes the covered envelopes and installs a +`MaterializedState` base carrying no graph values, so the score is +unreconstructable afterward — and `MaterializedState.effects` are outcomes, +not payloads, so nothing rebuilds it. Pruning is unimplemented today (no +`fn prune`; `opset.rs:7` calls it out-of-scope-for-v0), which is why this costs +nothing now and would cost everything later. C — the canonical base carrying +graph values, the same checkpoint T4b needs for incremental materialization — +remains **required before any pruning implementation**. + +**The from-empty path must reduce with a graph, not base-free.** Reduction has +two modes, and graph-aware preconditions are skipped in the base-free one +*because it has no universe to check against* (`reduce.rs:3822`, `:3721`). +`Score::empty(identity)` **is** a graph, so `new_onto` with an empty score +enforces every precondition from the first operation; the base-free mode does +not. A from-empty document reduced through the wrong entry point silently +loses referential enforcement. Name this in the tranche and test it. + +**One accept-set raise, spent once.** `OperationEnvelopeBlock` is capped at +major 2 (`bundle.rs:69`) because no operation payload embeds these types. The +first new kind that does raises it to 3; every later kind in that major is +free. The new kinds therefore land as **one batch**, not dribbled out per +need. Note this is a *different* major from Push-4b's schema major 3, which is +the Score/Snapshot-role wire that tranche 3b-i opened and permanently froze — +there is no free ride between them. + +**Additive discriminants.** New kinds extend past `TransposeInterval` under +the existing convention (`req:binfmt:kind-discriminants`); canonical bytes for +existing types do not move. + +--- + +## 5. Acceptance + +* A document created empty and given **only operations** materializes a + note-bearing `Score` — the full chain, no fixture, no base. +* Two replicas applying concurrent genesis-era operations in any delivery + order converge to byte-identical `MaterializedState`. +* Opening such a document from a bundle reaches a note, which is what + unblocks T1b. +* Existing canonical bytes, goldens, and conformance gates unmoved except + where the accept-set raise is the deliberate change. + +--- + +## 6. What this unblocks + +`spec/PLAN_EDITOR_APP.md` §Ruling B blocker (i) is **resolved**. Blocker (ii) +— the version-aware envelope decoder — is unaffected and still open; its +residual is one bounded ops packet. T1b's remaining runway is therefore +blocker (ii) plus the Ruling-D ownership API, and the lease/save/single-writer +machinery can be contracted in parallel with this tranche, since it does not +depend on genesis being authorable. + +*Related: `spec/ANALYSIS_GENESIS_PERSISTENCE.md`, `spec/PLAN_EDITOR_APP.md` +§Ruling B / §3.7, `spec/PLAN_PUSH4B_TUNING.md` (the tranche mold).*