diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index a3e0f03..c79ff79 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -1317,3 +1317,51 @@ live `Codec::dec` killed six tests at once (`v0_score_migrates_*`, `v1_round_trips_non_default_values_for_every_new_field`) — confirming the reroute is load-bearing across the whole frozen-decoder family, not just the two sites the contract named. + +## Text-projection parity: `smufl` and `overrides` project (2026-07-24) + +Schema major 3 (tranche 3b-i) put `smufl` and `overrides` on the binary wire +but deliberately left the text surface alone, deferring it. This closes that +gap: `TextValue for ScoreTuningContext` now projects five fields, in `fn enc` +order, and four new `TextValue` impls carry the leaves +(`SmuflVersion`, `SmuflVersionRequirement`, `TuningScope`, `TuningOverride` — +mirroring the four `Codec` impls 3b-i froze). + +**The rule that moved them is the one this projection always followed** — the +text projection is the same canonical surface the binary codec is. What +changed is not the rule but a premise: the old doc comment justified excluding +all three fields on the grounds that "no schema major 3 has been opened", and +3b-i opened it. `accidental_extensions` was *staged* out of that major and is +still in-memory only, so it is still correctly absent — the staging line now +falls in exactly the same place on both surfaces, which is what the new tests +assert. + +**No header-version bump, and no document vector moved.** This is a +value-projection change, not a document-format change: `epiphany-textproj` has +no reference to a tuning context anywhere, and the canonical base is projected +as one opaque byte atom (`req:textproj:base-snapshot-inline`), not +structurally. So `req:textproj:header-version`'s single accepted version +`(0 7 0)` is untouched and all 13 document vectors are unchanged. +`req:textproj:roundtrip` was never violated — the tuning context is not part +of the projected document — so this is a consistency fix on a value impl, not +a conformance repair. + +**Test renames** (following 3b-i's own treatment of the binary pair, rather +than rewriting the tranche-2/3a entries above): +`score_tuning_context_round_trips_and_overrides_do_not_project` → +`..._and_overrides_project`, with its equality assertion inverted to +`assert_ne!` plus a full round-trip; and +`score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project` +→ `score_tuning_context_projects_smufl_and_overrides_but_not_accidental_extensions`, +now the text analogue of the binary staging-boundary test. Both were +mutation-verified: making `overrides` project as an always-empty vector — the +exact regression this pass fixes — fails both. + +**Found while scoping, not fixed here:** the tuning context has **no canonical +persistence path at all**. No operation authors it (`epiphany-ops` has no +tuning-context payload; the only `tuning` references are per-pitch +`TuningReference::Inherit`), and `MaterializedState` does not carry it. Major 3 +made the *acceleration snapshot* able to carry it, and that snapshot is +explicitly non-canonical and regenerable. So a tuning override survives a +snapshot round-trip but cannot be authored, replicated, or merged. Filed as a +Pass-13 candidate; it is a data-model question, not a codec one. diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 3012b1d..09770ce 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -1952,11 +1952,14 @@ impl Codec for TuningOverride { // drift when this live impl changes. A round-trip test just below // (`score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`) // proves `smufl`/`overrides` now survive `enc`→`dec` while -// `accidental_extensions` still does not. The matching text-projection -// proofs are unchanged by this tranche — `textvalue_graph.rs`'s -// `score_tuning_context_round_trips_and_overrides_do_not_project` and -// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project` -// (text projection is a separate surface; see this tranche's contract). +// `accidental_extensions` still does not. The text projection now draws the +// staging line in the same place, and its proofs are the direct analogues: +// `textvalue_graph.rs`'s `score_tuning_context_round_trips_and_overrides_project` +// and `score_tuning_context_projects_smufl_and_overrides_but_not_accidental_extensions`. +// (3b-i left the text surface untouched, deferring it; the parity pass that +// followed moved `smufl`/`overrides` there for the reason stated in +// `TextValue for ScoreTuningContext`'s doc — the text projection is the same +// canonical surface this codec is.) impl Codec for ScoreTuningContext { fn enc(&self, out: &mut Vec) { self.default_pitch_space.enc(out); diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 1f1d012..9edd439 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -1669,17 +1669,24 @@ pub struct ScoreTuningContext { pub default_tuning_system: TuningSystemId, pub reference: ReferencePitch, /// Score-local accidental-registry extensions (Chapter 4 §"Score-Local - /// Extensions", `:3228`). **In memory only** this tranche (Push 4b - /// tranche 3a) — see the wire note above. + /// Extensions", `:3228`). **In memory only**: staged out of schema major 3 + /// and appended after `overrides` whenever a later major lands it — see + /// the wire note above. Neither the binary codec nor the text projection + /// carries it. pub accidental_extensions: Vec, /// The SMuFL version this score requires (Chapter 4 §"SMuFL Versioning", - /// `req:tuning:smufl-version-fallback`). **In memory only** this - /// tranche — see the wire note above. + /// `req:tuning:smufl-version-fallback`). **On the wire** as of schema + /// major 3 (tranche 3b-i), and projected to text alongside it — see the + /// wire note above. pub smufl: crate::accidental::SmuflVersionRequirement, /// Per-scope overrides consulted by the tuning resolver /// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of - /// `req:tuning:tuning-resolution-order`. **In memory only** this - /// tranche — see the struct doc above. + /// `req:tuning:tuning-resolution-order`. **On the wire** as of schema + /// major 3 (tranche 3b-i), and projected to text alongside it — see the + /// wire note above. Note that reaching the wire is not the same as being + /// canonically persistable: no operation authors this field and the + /// canonical base does not carry it, so today it survives only in the + /// (regenerable) acceleration snapshot — P13-S13. pub overrides: Vec, } diff --git a/crates/epiphany-core/src/textvalue_graph.rs b/crates/epiphany-core/src/textvalue_graph.rs index 1658f67..11d0df0 100644 --- a/crates/epiphany-core/src/textvalue_graph.rs +++ b/crates/epiphany-core/src/textvalue_graph.rs @@ -291,22 +291,133 @@ impl TextValue for TimeSignature { // not a validating constructor. // =========================================================================== -/// `(score-tuning-context -/// )` — exactly the three wire fields, in `fn enc` order -/// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`). +/// `(smufl-version )`. /// -/// `ScoreTuningContext` has gained three fields beyond these three — -/// `overrides` (Push 4b tranche 2), then `accidental_extensions` and `smufl` -/// (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — that are -/// deliberately **not** part of this projection: all three are in-memory only -/// (no schema major 3 has been opened), so none may reach the wire, and the -/// text projection is the same canonical surface the binary codec is — a -/// value that omitted them here would otherwise silently launder a -/// non-empty context into one indistinguishable from an empty one, which is -/// exactly the intended behavior, not an oversight. `parse` always -/// constructs `accidental_extensions: Vec::new()`, `smufl: -/// SmuflVersionRequirement::default()`, and `overrides: Vec::new()`, -/// mirroring `Codec::dec`. +/// `minor_centi` projects as the stored, already-normalized value (1.4 is +/// `40`, 1.12 is `12`), not as the decimal digits a human writes — the text +/// projection carries the canonical value, exactly as `Codec` does. Parsing +/// validates no further than the binary decoder does, for the same reason. +impl TextValue for crate::accidental::SmuflVersion { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("SmuflVersion")), + TextValue::project(&self.major), + TextValue::project(&self.minor_centi), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("SmuflVersion"), 2)?; + Ok(crate::accidental::SmuflVersion { + major: ::parse(&fields[0])?, + minor_centi: ::parse(&fields[1])?, + }) + } +} + +/// `(smufl-version-requirement )` — the two fields +/// in `fn enc` order. +impl TextValue for crate::accidental::SmuflVersionRequirement { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("SmuflVersionRequirement")), + self.minimum.project(), + self.authored_against.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("SmuflVersionRequirement"), 2)?; + Ok(crate::accidental::SmuflVersionRequirement { + minimum: TextValue::parse(&fields[0])?, + authored_against: TextValue::parse(&fields[1])?, + }) + } +} + +/// The tuning-scope union, in the discriminant order `Codec` assigns +/// (`Voice` 0, `Staff` 1, `Region` 2, `Range` 3). +impl TextValue for crate::tuning::TuningScope { + fn project(&self) -> Sexp { + use crate::tuning::TuningScope as S; + match self { + S::Voice(id) => variant("Voice", vec![id.project()]), + S::Staff(id) => variant("Staff", vec![id.project()]), + S::Region(id) => variant("Region", vec![id.project()]), + S::Range { start, end, voices } => variant( + "Range", + vec![start.project(), end.project(), voices.project()], + ), + } + } + fn parse(s: &Sexp) -> Result { + use crate::tuning::TuningScope as S; + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Voice") { + let f = fields_of(fields, "TuningScope", 1)?; + Ok(S::Voice(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Staff") { + let f = fields_of(fields, "TuningScope", 1)?; + Ok(S::Staff(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Region") { + let f = fields_of(fields, "TuningScope", 1)?; + Ok(S::Region(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Range") { + let f = fields_of(fields, "TuningScope", 3)?; + Ok(S::Range { + start: TextValue::parse(&f[0])?, + end: TextValue::parse(&f[1])?, + voices: TextValue::parse(&f[2])?, + }) + } else { + Err(TextError::UnknownConstructor { + type_name: "TuningScope", + found: ctor.to_owned(), + }) + } + } +} + +/// `(tuning-override )` — the +/// four fields in `fn enc` order; the last three are optional and inherit from +/// the next-outer scope when absent. +impl TextValue for crate::tuning::TuningOverride { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("TuningOverride")), + self.scope.project(), + self.pitch_space.project(), + self.tuning_system.project(), + self.reference.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("TuningOverride"), 4)?; + Ok(crate::tuning::TuningOverride { + scope: TextValue::parse(&fields[0])?, + pitch_space: TextValue::parse(&fields[1])?, + tuning_system: TextValue::parse(&fields[2])?, + reference: TextValue::parse(&fields[3])?, + }) + } +} + +/// `(score-tuning-context +/// )` — exactly the five wire fields, in +/// `fn enc` order. +/// +/// **`smufl` and `overrides` project; `accidental_extensions` does not**, and +/// the rule dividing them is the one this projection has always followed: the +/// text projection is the same canonical surface the binary codec is. Schema +/// major 3 (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`) put +/// `smufl` and `overrides` on the wire, so they belong here too — omitting +/// them would silently launder a loaded context into one indistinguishable +/// from an empty one. `accidental_extensions` was deliberately **staged** out +/// of that major and remains in-memory only, so it is still correctly absent; +/// `parse` constructs it as `Vec::new()`, mirroring `Codec::dec`. When a later +/// major puts it on the wire, it joins this projection then and not before. +/// +/// (An earlier revision of this comment justified excluding all three on the +/// grounds that "no schema major 3 has been opened". That premise expired when +/// 3b-i opened it; the rule it appealed to is what moved two of the three in.) impl TextValue for ScoreTuningContext { fn project(&self) -> Sexp { Sexp::List(vec![ @@ -314,20 +425,24 @@ impl TextValue for ScoreTuningContext { self.default_pitch_space.project(), self.default_tuning_system.project(), self.reference.project(), + self.smufl.project(), + self.overrides.project(), ]) } fn parse(s: &Sexp) -> Result { - let fields = s.expect_struct(&kebab("ScoreTuningContext"), 3)?; + let fields = s.expect_struct(&kebab("ScoreTuningContext"), 5)?; let default_pitch_space = TextValue::parse(&fields[0])?; let default_tuning_system = TextValue::parse(&fields[1])?; let reference = TextValue::parse(&fields[2])?; + let smufl = TextValue::parse(&fields[3])?; + let overrides = TextValue::parse(&fields[4])?; Ok(ScoreTuningContext { default_pitch_space, default_tuning_system, reference, accidental_extensions: Vec::new(), - smufl: crate::accidental::SmuflVersionRequirement::default(), - overrides: Vec::new(), + smufl, + overrides, }) } } @@ -935,14 +1050,14 @@ mod tests { } #[test] - fn score_tuning_context_round_trips_and_overrides_do_not_project() { + fn score_tuning_context_round_trips_and_overrides_project() { // Empty overrides: ordinary identity round-trip. round_trip(ScoreTuningContext::default()); - // Non-empty overrides: the text projection is identical to the - // empty-overrides projection (the field is in-memory only, Push 4b - // tranche 2 / Ruling C), and parsing always reconstructs `overrides` - // as empty. + // Non-empty overrides: as of schema major 3 (Push 4b tranche 3b-i) + // `overrides` is on the wire, so it projects and survives the round + // trip. This assertion is the **inversion** of the pre-major-3 one it + // replaces, which required the two projections to be identical. let mut with_overrides = ScoreTuningContext::default(); with_overrides .overrides @@ -952,20 +1067,24 @@ mod tests { tuning_system: None, reference: None, }); - assert_eq!( + assert_ne!( with_overrides.project().render(), ScoreTuningContext::default().project().render(), - "overrides must not appear in the text projection" + "overrides must appear in the text projection as of schema major 3" ); + round_trip(with_overrides.clone()); let parsed = ScoreTuningContext::parse(&with_overrides.project()).unwrap(); - assert!(parsed.overrides.is_empty()); + assert_eq!(parsed.overrides, with_overrides.overrides); } #[test] - fn score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project() { - // The direct analogue of `score_tuning_context_round_trips_and_overrides_do_not_project` - // above, extended to all three in-memory-only fields (Push 4b tranche - // 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`). + fn score_tuning_context_projects_smufl_and_overrides_but_not_accidental_extensions() { + // The text analogue of the binary staging-boundary test + // (`codec::tests::score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`): + // schema major 3 put `smufl` and `overrides` on the wire, so both + // project; `accidental_extensions` was staged out of that major and is + // still in-memory only, so it must still be dropped. This proves the + // staging line falls in exactly the same place on both surfaces. use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement}; let mut loaded = ScoreTuningContext::default(); @@ -985,15 +1104,30 @@ mod tests { tuning_system: None, reference: None, }); - assert_eq!( + // Sanity: the fixture differs from the default in all three fields, so + // neither half of the assertion below is vacuous. + assert!(!loaded.accidental_extensions.is_empty()); + assert_ne!(loaded.smufl, SmuflVersionRequirement::default()); + assert!(!loaded.overrides.is_empty()); + + assert_ne!( loaded.project().render(), ScoreTuningContext::default().project().render(), - "accidental_extensions, smufl, and overrides must not appear in the text projection" + "smufl and overrides must appear in the text projection as of schema major 3" ); let parsed = ScoreTuningContext::parse(&loaded.project()).unwrap(); + // smufl and overrides survive the round trip... + assert_eq!(parsed.smufl, loaded.smufl); + assert_eq!(parsed.overrides, loaded.overrides); + // ...but accidental_extensions is still dropped: staged, never projected. assert!(parsed.accidental_extensions.is_empty()); - assert_eq!(parsed.smufl, SmuflVersionRequirement::default()); - assert!(parsed.overrides.is_empty()); + assert_eq!( + parsed, + ScoreTuningContext { + accidental_extensions: Vec::new(), + ..loaded.clone() + } + ); } #[test] diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index bb25c30..9d35fe4 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -69,8 +69,10 @@ whose obvious representation orders SMuFL's real release history backwards. All three are ratified; each freezes forever once tranche 3b lands, so they were resolved before dispatch, not after. -S4, S8, and S9 remain open; S10–S12 are resolved on ratification. The pass stays -open. +S4, S8, S9, and S13 remain open; S10–S12 are resolved on ratification. The pass +stays open. (S13 was filed 2026-07-24, after S10–S12's wire freeze landed: it is +the gap that freeze made visible — a value with a wire form and no canonical +carrier to reach it.) | Id | One-line statement | Filed in | Status | |---|---|---|---| @@ -86,3 +88,4 @@ open. | 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. The two carriers that *are* canonical do not carry it: 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: it survives a snapshot round-trip, and is lost the moment the snapshot is regenerated from the operation log. This also means `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) | **open** (ratification needed on *where* it belongs: an authoring operation kind — a schema-minor kind append, per the `DeleteRepeatStructure`/`TransposeInterval` precedent — versus a field on the canonical base, which is a data-model major. The operation route is the cheaper mechanism and the one every other authored score property already uses; the base route would make it reduced state rather than authored state, which is likely wrong since a user *chooses* a tuning. Note the wire layouts are already frozen and correct either way — this is about which carrier embeds them, not how they encode. **One cost the operation route must price in:** blocks are stamped *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, so no op block is ever born at v3. An authoring operation carrying a `TuningOverride` would make its blocks stamp v3, so the kind append — a schema *minor* in itself — drags a *role accept-set raise* along with it. That is a small, well-precedented change, but it is not free, and it is the one asymmetry that makes the two routes closer in cost than they first appear. 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) |