diff --git a/crates/ogar-loco/src/lib.rs b/crates/ogar-loco/src/lib.rs index b165f9e..8cd920d 100644 --- a/crates/ogar-loco/src/lib.rs +++ b/crates/ogar-loco/src/lib.rs @@ -122,13 +122,15 @@ use serde::{Deserialize, Serialize}; pub mod node; pub mod pool; pub mod program; +pub mod statements; pub mod vocabulary; pub use node::FunctionNode; pub use pool::{Constant, ConstantPool, PoolError}; pub use program::{Program, branches_of}; -pub use vocabulary::Vocabulary; +pub use statements::{StatementBounds, StatementError, statement_bounds}; pub use vocabulary::conformance::CheckedVocabulary; +pub use vocabulary::{FnSpec, Vocabulary, VocabularyTable}; // ── The function-body budget ──────────────────────────────────────────────── diff --git a/crates/ogar-loco/src/statements.rs b/crates/ogar-loco/src/statements.rs new file mode 100644 index 0000000..428710b --- /dev/null +++ b/crates/ogar-loco/src/statements.rs @@ -0,0 +1,305 @@ +//! Statement boundaries — the unit a step mask may address. +//! +//! # Why calls are not maskable +//! +//! A body is a stack program: each call pops its operands and (for +//! expressions) pushes a result. Masking one CALL out of that stream +//! desynchronizes every later consumer — the exact defect the two-quantity +//! split exists to prevent, reintroduced at dispatch time. So the maskable +//! unit is the **statement**: the operand-producing post-order run PLUS the +//! consuming statement call, skipped or kept as one piece. +//! +//! This is the R5 ruling made mechanical, and it dissolves the apparent +//! capacity mismatch between a 64-bit step mask and a 180-call body: the +//! mask addresses up to 64 STATEMENTS; the body budget stays 180 CALLS. +//! A template wanting more than 64 statements is a split signal, not a +//! mask-widening use case. +//! +//! # The segmentation rule +//! +//! Walk the calls simulating stack depth (`depth -= arity; depth += 1` if +//! the call pushes). A statement CLOSES where depth returns to zero after a +//! **non-pushing** call. A body ending at depth one closes a final +//! *expression statement* (the script's value). Anything else refuses: +//! +//! - an **uncovered** call (no arity, or no `pushes_result` declaration) — +//! segmentation does not guess; a vocabulary that wants segmentable +//! bodies declares the column; +//! - a stack **underflow** — the body is malformed under this vocabulary; +//! - **dangling operands** (final depth ≥ 2) — no honest grouping exists. +//! +//! The vocabulary arrives as a [`CheckedVocabulary`] and the walk reads its +//! validated table. + +use crate::vocabulary::conformance::CheckedVocabulary; +use crate::{FnIndex, FunctionBody, Vocabulary}; + +/// One statement's extent inside a body, in call indices. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StatementBounds { + /// Index of the statement's first call. + pub first_call: usize, + /// How many calls the statement spans (operands + the statement call). + pub call_count: usize, +} + +/// Why a body could not be segmented into statements. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatementError { + /// A call's function has no arity or no `pushes_result` declaration in + /// this vocabulary — refused rather than guessed, because a wrong guess + /// mis-groups statements silently. + Uncovered { + /// Position of the call in the body. + index: usize, + /// The undeclared function. + f: FnIndex, + }, + /// A call pops more operands than the stack holds — the body is + /// malformed under this vocabulary. + StackUnderflow { + /// Position of the underflowing call. + index: usize, + /// The function that underflowed. + f: FnIndex, + }, + /// The body ends with two or more values on the stack — there is no + /// honest statement grouping for dangling operands. + DanglingOperands { + /// The final stack depth. + depth: usize, + }, +} + +impl core::fmt::Display for StatementError { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + StatementError::Uncovered { index, f } => write!( + fmt, + "call {index} ({f:?}) is not declared for segmentation in this vocabulary" + ), + StatementError::StackUnderflow { index, f } => { + write!(fmt, "call {index} ({f:?}) pops more operands than exist") + } + StatementError::DanglingOperands { depth } => { + write!(fmt, "body ends with {depth} dangling operands") + } + } + } +} + +impl core::error::Error for StatementError {} + +/// Segment a body into statements — the derived metadata a step-mask +/// dispatcher consumes (`statement ordinal → [first_call, call_count]`). +/// +/// # Errors +/// +/// See [`StatementError`]; every arm refuses rather than guesses. +pub fn statement_bounds( + v: &CheckedVocabulary, + body: &FunctionBody, +) -> Result, StatementError> { + let table = v.table(); + let mut out = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + for (index, call) in body.calls().enumerate() { + let f = call.function; + let (Some(arity), Some(pushes)) = (table.stack_arity(f), table.pushes_result(f)) else { + return Err(StatementError::Uncovered { index, f }); + }; + depth = depth + .checked_sub(usize::from(arity)) + .ok_or(StatementError::StackUnderflow { index, f })?; + if pushes { + depth += 1; + } else if depth == 0 { + out.push(StatementBounds { + first_call: start, + call_count: index + 1 - start, + }); + start = index + 1; + } + } + match depth { + 0 => Ok(out), + 1 => { + // A trailing value: the final expression statement (the script's + // own result). `start < len` holds — a boundary resets depth to + // zero, so depth one implies calls after the last boundary. + out.push(StatementBounds { + first_call: start, + call_count: body.len() - start, + }); + Ok(out) + } + depth => Err(StatementError::DanglingOperands { depth }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vocabulary::conformance::validate; + use crate::{Call, LaneShape}; + + struct EmptyVocab; + impl Vocabulary for EmptyVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + } + + const S: LaneShape = LaneShape::Pairs; + + fn body(calls: &[Call]) -> FunctionBody { + FunctionBody::from_calls(S, calls).unwrap() + } + + #[test] + fn an_expression_is_one_trailing_statement() { + // `5 + 3` leaves its value: one expression statement spanning all + // three calls — the operand run is atomic with its consumer. + let v = validate(EmptyVocab).unwrap(); + let b = body(&[ + Call::with_value(FnIndex::NUMBER, 5), + Call::with_value(FnIndex::NUMBER, 3), + Call::new(FnIndex::ADD), + ]); + assert_eq!( + statement_bounds(&v, &b).unwrap(), + vec![StatementBounds { + first_call: 0, + call_count: 3 + }] + ); + } + + #[test] + fn control_calls_close_statements_and_two_statements_split_correctly() { + // `if 5 [→1]; repeat 10 [→1]` — each control call consumes its + // operand and closes at depth zero. THE capacity dissolution: the + // mask addresses these two STATEMENTS, not the four calls. + let v = validate(EmptyVocab).unwrap(); + let b = body(&[ + Call::with_value(FnIndex::NUMBER, 5), + Call::with_value(FnIndex::IF, 1), + Call::with_value(FnIndex::NUMBER, 10), + Call::with_value(FnIndex::REPEAT, 1), + ]); + assert_eq!( + statement_bounds(&v, &b).unwrap(), + vec![ + StatementBounds { + first_call: 0, + call_count: 2 + }, + StatementBounds { + first_call: 2, + call_count: 2 + }, + ] + ); + // …and a closed statement followed by a trailing expression mixes. + let mixed = body(&[ + Call::with_value(FnIndex::NUMBER, 5), + Call::with_value(FnIndex::IF, 1), + Call::with_value(FnIndex::NUMBER, 9), + ]); + assert_eq!( + statement_bounds(&v, &mixed).unwrap(), + vec![ + StatementBounds { + first_call: 0, + call_count: 2 + }, + StatementBounds { + first_call: 2, + call_count: 1 + }, + ] + ); + } + + #[test] + fn masking_hazards_are_refused_not_guessed() { + let v = validate(EmptyVocab).unwrap(); + // Underflow: ADD with an empty stack is malformed, not "arity 0". + assert_eq!( + statement_bounds(&v, &body(&[Call::new(FnIndex::ADD)])), + Err(StatementError::StackUnderflow { + index: 0, + f: FnIndex::ADD + }) + ); + // Dangling operands: two values, no consumer — no honest grouping. + assert_eq!( + statement_bounds( + &v, + &body(&[ + Call::with_value(FnIndex::NUMBER, 1), + Call::with_value(FnIndex::NUMBER, 2), + ]) + ), + Err(StatementError::DanglingOperands { depth: 2 }) + ); + // Uncovered: WAIT has no shared-core tables — refuse, never guess. + assert_eq!( + statement_bounds(&v, &body(&[Call::with_value(FnIndex::WAIT, 3)])), + Err(StatementError::Uncovered { + index: 0, + f: FnIndex::WAIT + }) + ); + } + + #[test] + fn a_domain_verb_without_the_pushes_column_refuses_segmentation() { + // Arity-covered but pushes-undeclared = lowerable but NOT + // segmentable — the honest partial-coverage state the None default + // produces. The silence twin: declaring the column makes the same + // body segment. + struct ArityOnly; + impl Vocabulary for ArityOnly { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + (f.0 == 0x90).then_some(0) + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + } + struct Declared; + impl Vocabulary for Declared { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + (f.0 == 0x90).then_some(0) + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn domain_pushes_result(&self, f: FnIndex) -> Option { + (f.0 == 0x90).then_some(false) + } + } + let b = body(&[Call::new(FnIndex(0x90))]); + let arity_only = validate(ArityOnly).unwrap(); + assert_eq!( + statement_bounds(&arity_only, &b), + Err(StatementError::Uncovered { + index: 0, + f: FnIndex(0x90) + }) + ); + let declared = validate(Declared).unwrap(); + assert_eq!( + statement_bounds(&declared, &b).unwrap(), + vec![StatementBounds { + first_call: 0, + call_count: 1 + }] + ); + } +} diff --git a/crates/ogar-loco/src/vocabulary.rs b/crates/ogar-loco/src/vocabulary.rs index 8df52d5..bd915c8 100644 --- a/crates/ogar-loco/src/vocabulary.rs +++ b/crates/ogar-loco/src/vocabulary.rs @@ -169,6 +169,36 @@ pub mod shared_core { _ => LaneShape::Quads, } } + + /// Whether a covered shared-core call **pushes a result** onto the stack. + /// + /// `Some(true)` for every covered expression (leaves, unary, binary); + /// `Some(false)` for the covered control calls and `BREAK`/`CONTINUE`, + /// which act and push nothing; `None` for anything the core does not + /// cover — the same refuse-don't-guess rule as [`stack_arity`]. + /// + /// This is the column statement segmentation + /// ([`crate::statements::statement_bounds`]) runs on: a statement ends + /// where the stack returns to empty after a non-pushing call. A wrong + /// guess here would mis-group statements silently, which is why the + /// uncovered answer is `None` and never a default. + #[must_use] + pub fn pushes_result(f: FnIndex) -> Option { + stack_arity(f)?; + Some(!matches!( + f, + FnIndex::IF + | FnIndex::IF_ELSE + | FnIndex::REPEAT + | FnIndex::WHILE + | FnIndex::REPEAT_UNTIL + | FnIndex::FOREVER + | FnIndex::FOR_EACH + | FnIndex::FOR_RANGE + | FnIndex::BREAK + | FnIndex::CONTINUE + )) + } } /// A sibling codebook over the shared surface. @@ -230,11 +260,145 @@ pub trait Vocabulary { _ => LaneShape::Quads, } } + + /// Whether a covered domain-range function pushes a result — the column + /// statement segmentation runs on. + /// + /// The default is `None` — "not declared" — which makes segmentation + /// REFUSE bodies using such functions rather than mis-group them. A + /// vocabulary that wants its bodies statement-segmentable (templates + /// masking by `StepMask` position, most importantly) declares the column + /// for its verbs; a vocabulary that never segments pays nothing. + fn domain_pushes_result(&self, _f: FnIndex) -> Option { + None + } + + /// Whether `f` pushes a result — shared core first, domain hook above + /// the floor. + fn pushes_result(&self, f: FnIndex) -> Option { + if f.0 < DOMAIN_FLOOR { + shared_core::pushes_result(f) + } else { + self.domain_pushes_result(f) + } + } +} + +// ── The canonical data form ───────────────────────────────────────────────── + +/// One codebook slot's semantics, as data. +/// +/// The data-first ruling (R4 of the ratified design rulings): the +/// authoritative truth about a vocabulary is a TABLE — inspectable by +/// compilers, renderers, fuzzers, and oracle schemas, validated once as a +/// whole, incapable of the mutual inconsistency separate methods can drift +/// into. The trait's methods are the authoring/access surface; the composed +/// [`VocabularyTable`] inside a +/// [`CheckedVocabulary`](conformance::CheckedVocabulary) is what everything +/// downstream actually reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FnSpec { + /// Operands popped from the stack; `None` = not covered (refused). + pub stack_arity: Option, + /// Value bytes that are function indices this call branches to. + pub body_refs: u8, + /// The narrowest lane carving that can hold this call. + pub min_shape: LaneShape, + /// Whether the call pushes a result; `None` = not declared, so + /// statement segmentation refuses rather than guesses. + pub pushes_result: Option, +} + +impl FnSpec { + /// The uncovered slot — refused everywhere. + pub const REFUSED: FnSpec = FnSpec { + stack_arity: None, + body_refs: 0, + min_shape: LaneShape::Pairs, + pushes_result: None, + }; +} + +/// The composed 256-slot semantic table of one vocabulary. +/// +/// [`compose`](Self::compose) stamps the shared-core half from +/// [`shared_core`]'s own tables — **never** from the vocabulary — so a +/// sibling cannot even EXPRESS a divergent opinion about a shared byte in +/// the table consumers read. The domain half is sampled from the +/// vocabulary's hooks once, at composition time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VocabularyTable { + specs: [FnSpec; 256], +} + +impl VocabularyTable { + /// Compose a vocabulary's full table: shared core below the floor + /// (unforgeable — read from the core, not the vocabulary), the + /// vocabulary's hooks above it. + #[must_use] + pub fn compose(v: &V) -> Self { + let mut specs = [FnSpec::REFUSED; 256]; + for (b, spec) in specs.iter_mut().enumerate() { + let f = FnIndex(b as u8); + *spec = if f.0 < DOMAIN_FLOOR { + FnSpec { + stack_arity: shared_core::stack_arity(f), + body_refs: shared_core::body_refs(f), + min_shape: shared_core::min_shape(f), + pushes_result: shared_core::pushes_result(f), + } + } else { + FnSpec { + stack_arity: v.domain_stack_arity(f), + body_refs: v.domain_body_refs(f), + min_shape: v.min_shape(f), + pushes_result: v.domain_pushes_result(f), + } + }; + } + Self { specs } + } + + /// The spec for one slot. + #[must_use] + pub fn spec(&self, f: FnIndex) -> &FnSpec { + &self.specs[usize::from(f.0)] + } + + /// Operands `f` pops; `None` = refused. + #[must_use] + pub fn stack_arity(&self, f: FnIndex) -> Option { + self.spec(f).stack_arity + } + + /// Function-index value bytes `f` carries. + #[must_use] + pub fn body_refs(&self, f: FnIndex) -> u8 { + self.spec(f).body_refs + } + + /// Whether `f` references a body at all. + #[must_use] + pub fn branches(&self, f: FnIndex) -> bool { + self.spec(f).body_refs > 0 + } + + /// The narrowest shape holding `f`. + #[must_use] + pub fn min_shape(&self, f: FnIndex) -> LaneShape { + self.spec(f).min_shape + } + + /// Whether `f` pushes a result; `None` = not declared. + #[must_use] + pub fn pushes_result(&self, f: FnIndex) -> Option { + self.spec(f).pushes_result + } } /// Mechanical conformance: what every vocabulary crate's tests must run. pub mod conformance { - use super::{DOMAIN_FLOOR, FnIndex, Vocabulary, shared_core}; + use super::{DOMAIN_FLOOR, FnIndex, Vocabulary, VocabularyTable, shared_core}; /// A way a vocabulary violates the sharing discipline. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -244,7 +408,8 @@ pub mod conformance { SharedCoreDrift { /// The byte that drifted. f: FnIndex, - /// Which table drifted: `"stack_arity"` or `"body_refs"`. + /// Which table drifted: `"stack_arity"`, `"body_refs"`, or + /// `"pushes_result"`. what: &'static str, }, /// `min_shape` reports a shape too narrow to hold the function's own @@ -291,49 +456,70 @@ pub mod conformance { /// /// [`Program::references_are_resolvable`]: crate::Program::references_are_resolvable /// [`branches_of`]: crate::branches_of + /// + /// # Table-backed since the data-first ruling (R4) + /// + /// The wrapper stores the [`VocabularyTable`] composed at validation and + /// answers every semantic query FROM that table — not by delegating to + /// the inner vocabulary. Delegation left one pathological gap (a + /// vocabulary whose methods answer differently across calls); the stored + /// table closes it: what was validated is, byte for byte, what is read. #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub struct CheckedVocabulary(V); + pub struct CheckedVocabulary { + vocab: V, + table: VocabularyTable, + } impl CheckedVocabulary { /// Borrow the validated vocabulary. pub fn inner(&self) -> &V { - &self.0 + &self.vocab } /// Unwrap, discarding the proof. pub fn into_inner(self) -> V { - self.0 + self.vocab + } + + /// The composed, validated semantic table — the authoritative data + /// form everything downstream reads. + pub fn table(&self) -> &VocabularyTable { + &self.table } } impl Vocabulary for CheckedVocabulary { fn domain_stack_arity(&self, f: FnIndex) -> Option { - self.0.domain_stack_arity(f) + self.vocab.domain_stack_arity(f) } fn domain_body_refs(&self, f: FnIndex) -> u8 { - self.0.domain_body_refs(f) + self.vocab.domain_body_refs(f) + } + fn domain_pushes_result(&self, f: FnIndex) -> Option { + self.vocab.domain_pushes_result(f) } - // The composed methods delegate too — conformance validated the - // COMPOSED answers, so the wrapper must forward them rather than - // re-derive from the hooks (a vocabulary with overridden composed - // methods would otherwise answer differently through the wrapper - // than it was validated as). + // The composed methods read the VALIDATED TABLE, never the inner + // vocabulary: what was checked is what answers. fn stack_arity(&self, f: FnIndex) -> Option { - self.0.stack_arity(f) + self.table.stack_arity(f) } fn body_refs(&self, f: FnIndex) -> u8 { - self.0.body_refs(f) + self.table.body_refs(f) } fn branches(&self, f: FnIndex) -> bool { - self.0.branches(f) + self.table.branches(f) } fn min_shape(&self, f: FnIndex) -> crate::LaneShape { - self.0.min_shape(f) + self.table.min_shape(f) + } + fn pushes_result(&self, f: FnIndex) -> Option { + self.table.pushes_result(f) } } /// Validate a vocabulary and, on success, return the proof-carrying - /// wrapper program traversal requires. + /// wrapper program traversal requires — carrying the composed + /// [`VocabularyTable`] it was validated as. /// /// # Errors /// @@ -342,7 +528,8 @@ pub mod conformance { /// assertion without taking ownership. pub fn validate(v: V) -> Result, ConformanceError> { check(&v)?; - Ok(CheckedVocabulary(v)) + let table = VocabularyTable::compose(&v); + Ok(CheckedVocabulary { vocab: v, table }) } /// Check a vocabulary against the sharing discipline, over the full @@ -370,6 +557,12 @@ pub mod conformance { what: "body_refs", }); } + if v.pushes_result(f) != shared_core::pushes_result(f) { + return Err(ConformanceError::SharedCoreDrift { + f, + what: "pushes_result", + }); + } } // Everywhere: the reported minimum shape must actually hold the // call's own body references. @@ -621,4 +814,71 @@ mod tests { }) ); } + + #[test] + fn the_composed_tables_shared_half_is_unforgeable() { + // The data-first strengthening: compose() reads the shared half from + // the core, never from the vocabulary — so even the DRIFTING + // vocabulary's table carries the correct ADD. The drift exists only + // in its methods, where check() still rejects it. Both facts + // together: the table cannot express the defect, and the vocabulary + // that tries never gets a table. + let table = VocabularyTable::compose(&DriftingVocab); + assert_eq!(table.stack_arity(FnIndex::ADD), Some(2)); + assert_eq!(DriftingVocab.stack_arity(FnIndex::ADD), Some(1)); + assert!(conformance::validate(DriftingVocab).is_err()); + } + + #[test] + fn the_checked_wrapper_answers_from_its_validated_table() { + let checked = conformance::validate(DomainVocab).expect("DomainVocab conforms"); + // Shared core through the table: expressions push, control does not, + // uncovered stays None. + assert_eq!(checked.pushes_result(FnIndex::ADD), Some(true)); + assert_eq!(checked.pushes_result(FnIndex::REPEAT), Some(false)); + assert_eq!(checked.pushes_result(FnIndex::WAIT), None); + // Domain half sampled from the hooks; the undeclared pushes column + // defaults to None (refused by segmentation, never guessed). + assert_eq!(checked.table().stack_arity(FnIndex(0x90)), Some(1)); + assert_eq!(checked.table().pushes_result(FnIndex(0x90)), None); + // Table and composed methods agree byte-for-byte — the methods READ + // the table, so disagreement is unrepresentable. + for b in 0..=255u8 { + let f = FnIndex(b); + assert_eq!(checked.stack_arity(f), checked.table().stack_arity(f)); + assert_eq!(checked.body_refs(f), checked.table().body_refs(f)); + } + } + + #[test] + fn conformance_fires_on_a_pushes_drift_too() { + // The new column is a new drift channel; it must be guarded like the + // other two. ADD claiming to push nothing would silently break + // statement segmentation for every consumer. + struct PushDriftVocab; + impl Vocabulary for PushDriftVocab { + fn domain_stack_arity(&self, _f: FnIndex) -> Option { + None + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn pushes_result(&self, f: FnIndex) -> Option { + if f == FnIndex::ADD { + Some(false) + } else if f.0 < DOMAIN_FLOOR { + shared_core::pushes_result(f) + } else { + None + } + } + } + assert_eq!( + check(&PushDriftVocab), + Err(ConformanceError::SharedCoreDrift { + f: FnIndex::ADD, + what: "pushes_result", + }) + ); + } } diff --git a/docs/BLOCK-EDITOR-PLAN.md b/docs/BLOCK-EDITOR-PLAN.md index 8c3fb46..57e60bc 100644 --- a/docs/BLOCK-EDITOR-PLAN.md +++ b/docs/BLOCK-EDITOR-PLAN.md @@ -579,3 +579,83 @@ panic edge, and fixing it surfaced a second defect the review had not seen: universal forever, `0x90..=0xFF` vocabulary-local forever — moving the floor would reinterpret persisted programs. Documented on the constant and pinned by a `const` assert whose message says why the "fix" is wrong. + +## Design rulings R1–R9 — ratified via the operator's external-review loop (2026-08-05) + +The nine open questions from the reusable-surface brief came back ruled. +Recorded here as the arc's canon; items marked *(operator)* stay gated. + +1. **R1** — `ogar-loco` is the authoritative shared ABI; vocabulary crates + carry only domain extensions and membranes. +2. **R2** — `0x00..=0x8F` is universal permanent ABI; `0x90..=0xFF` is + sibling-local. (Already pinned in code by the `DOMAIN_FLOOR` const + assert.) A sibling may EXTEND, never REINTERPRET, the low range. +3. **R3** — the **full classid** selects the vocabulary, not merely the + concept domain: multiple semantic skins per domain are permitted; the + payload never becomes self-describing. +4. **R4** — vocabulary semantics are **canonical const data**, validated + into `CheckedVocabulary`. Landed additively (see grounding note below): + `FnSpec` + `VocabularyTable::compose` (shared half stamped from the + core — a sibling cannot even EXPRESS a divergent shared-core opinion in + the table consumers read) + the wrapper now answering from its stored + validated table, never by delegation. +5. **R5** — `StepMask` addresses **statements, not calls**. Landed: + `statements::statement_bounds` (`statement ordinal → [first_call, + call_count]`), refusing uncovered/underflowing/dangling bodies, with the + new `pushes_result` column (`None` = undeclared = segmentation refuses; + a vocabulary that wants maskable bodies declares the column). This + dissolves the 64-mask/180-call mismatch: masks address statements. +6. **R6** — PA `runAfter` semantics lower into **explicit structured + calls** or are refused with a diagnostic naming the unsupported edge; + never substrate edge-annotations. First implementation: linear + `Succeeded` chains + Condition/Switch/Foreach/Until/Scope; refuse real + parallel joins and status-dependent branches until the structured + vocabulary exists. **Proposed mints *(operator)*:** `PARALLEL(refs…)`, + `JOIN(policy)`, `TRY(body)`, `CATCH(status mask, body)`, + `FINALLY(body)` — candidate bytes exist in the unallocated shared-core + control range (`0x0F..0x1F`); the ids are the operator's, none assumed. +7. **R7** — stored bodies are the graph authority; graph-flow executes + them (`GraphBuilder` = disposable lowering/cache, never the persisted + authority). Arbitrary `GoTo` stays a graph-flow facility or is refused + at lowering; stored control is structured (calls + body references). +8. **R8** — sessions are substrate rows + storage versions; replay = load + program version, load session version, resume from statement ordinal, + **re-run gates** (never replay an old verdict), record oracle + consultations (request/result hashes, grading, compiled-template id). +9. **R9** — oracle output is admitted ONLY through the same + parse/cast/conformance path as human-authored programs; grammar + constraint improves yield, validation remains the authority; large + shortcode spaces ship as scope-local codebooks + Inventory minting, + never a flat 4000-entry enum. + +**Deliberately still operator-gated:** the rung-2 144-atom unification — +choosing which catalogue becomes byte-stable canon is semantic +legislation, not implementation cleanup. The template vocabulary mint +waits on it. + +**Grain-of-salt grounding on R4 (measured, not asserted):** the trait +break ChatGPT sketched (`const DOMAIN` table as the only authoring form) +was NOT taken. Blast radius today is tiny (only `BlocklyVocabulary` and +in-crate tests implement the trait), but the soundness property — one +validated table, methods that cannot disagree with it — lands fully +additively via `compose()` + the table-backed wrapper, and `ogar-blockly` +compiles UNTOUCHED as the non-breakage proof. The const-table *authoring* +surface is deferred until the first real domain vocabulary exists to +ground it; deciding an authoring API with zero real authors is the +ungrounded break the grain-of-salt directive forbids. + +**The projection-engine reframe (recorded framing, 2026-08-05):** the +companion review's sharpest observation — this surface has no AST and no +compiler middle: the fixed-size call body IS the semantic object, the +ClassView is the schema, and every syntax (Blockly, elixir-shaped text, +forms, JSON, future Cypher/Mermaid) is an interchangeable **projection +adapter** (`dto_to_X` / `X_to_dto`) over the one canonical DTO. Edits are +field-local (no parser invalidation, no AST rebuild). The blockly-rs +inverse cast (stored calls → Blockly JSON) is the first `dto_to_blocks` +adapter and the template text is "an editable pretty-printer." One +precision correction so the framing doesn't calcify wrong: the DTO is not +`command[256] + payload[256]` — it is 30 lanes × `classid(4)+payload(12)` +carved into ≤180/120/90 `(function:value)` calls; 256 is the codebook +cardinality, not the array length. A possible rename of `ogar-loco` to +reflect the projection-engine role is deferred to the pre-flip window +(the cheapest moment; blockly-rs does not yet dep the crate directly).