diff --git a/.claude/knowledge/hotplug-consumer-migration.md b/.claude/knowledge/hotplug-consumer-migration.md index 66eb35d..451a024 100644 --- a/.claude/knowledge/hotplug-consumer-migration.md +++ b/.claude/knowledge/hotplug-consumer-migration.md @@ -132,3 +132,67 @@ sibling on every push. `domain_tables()` entry + a `_EXPECTED_EXECUTORS` list arms the thinking-style table for whichever engine executes styles — same recipe as step 1 above, zero new machinery. +5. **Universal substrate vs a particular palette — the Blocks case + (operator-ruled 2026-08-07).** The distinction every future plug needs, + and the one a first attempt got wrong in both directions. + + | | what it is | who owns it | + |---|---|---| + | **`ogar-loco`** | the call ABI + the node SHAPES (`0x1701` function body, `0x1702` inventory) | **global** — thinking orchestration rides the same ABI, so this is global interest | + | **a frontend palette** | which vocabulary resolves a node's call bytes (`ogar-blockly` = `0x1717`) | **particular** — one id, activated only in a build containing that frontend | + + The tell that `0x1701`/`0x1702` are the substrate's: they are described + entirely in `ogar-loco`'s vocabulary — `FunctionBody`, `LaneShape`, the + value slab. An elixir-shaped thinking template and an RO relation body are + the SAME shape with a different palette. A frontend that owns them is + claiming the universal shape as its property. + + Consequently a frontend needs exactly **one** classid, naming its + vocabulary — not one per node shape. `ogar-blockly` is `0x1717`; the 256 + operations are `FnIndex` palette BYTES resolved through its `Vocabulary`, + never codebook rows. + + **What NOT to do (burned 2026-08-07, reverted same day).** Do not mint a + particular palette's concepts into `ogar_vocab::class_ids::ALL` to make + `resolve_hotplug` accept them. `class_ids::ALL` is mirrored into + `lance_graph_contract::ogar_codebook` under a **compile-time** count fuse + (`lance_graph_ogar::parity::COUNT_FUSE`), so anything added there is by + construction a lance-graph change — it turned lance-graph red against + OGAR `main` and dragged in `ogar-class-view`, `all_promoted_classes`, + `domains_agree` and both fuse halves. Registering a frontend's codebook in + lance-graph is an OGAR concern that has leaked; the shared codebook is + **global** surface only. + + **The shape instead:** the shared codebook is *triggered*, not + pre-minted — the classid travels with the plug and is activated by Cargo + presence when the frontend is actually in the build graph (the same + "auto-activation = Cargo presence, no runtime detection" rule + `lance-graph-ogar` documents), with `ogar-vocab` detecting the classid as + the trigger. `ConceptDomain::Blocks` (`0x17`) is already reserved with + ZERO rows precisely so `canonical_concept_domain(0x1717)` routes today + without any mint — the reserved-domain posture was the mechanism all + along. + + **The `0x17` layout (operator, 2026-08-07).** The domain is the + SUBSTRATE's, not one frontend's: `0x1701`/`0x1702` are loco's node shapes, + `0x1703`–`0x1716` is loco's reserved headroom (uplifting, Klickwege, + whatever the ABI needs next), and consumers are seated from `0x1717` + upward — one slot per frontend palette. Consumers sit HIGH on purpose so + the substrate keeps contiguous room beneath them; a frontend that outgrows + its slot gets its **own domain** rather than eating that headroom (the + same "scale = the next cascade level, never field-widening" rule as the + GUID canon). The `ConceptDomain::Blocks` label on `0x17` predates this and + now reads too narrowly — renaming a reserved domain is a canon change, + left to the operator; nothing depends on the label, only the `id >> 8` + route. + + **Status:** the ownership split is shipped (`LocoConcept` in `ogar-loco`, + `BlockConcept::Palette` = `0x1717` in `ogar-blockly`). Whether + `ogar-loco`'s `0x1701`/`0x1702` are additionally promoted into the shared + codebook as the global registration is an **open operator decision**, not + done here — and `ogar-loco` is written so nothing breaks if they never + are. The conditional activation path for a frontend palette + (`resolve_hotplug` accepting a plug-carried classid) is likewise **not yet + built**; a blockly plug at `0x1717` still receives `UnknownClassid` until + it is. Both are named-pending, deliberately, rather than described as + shipped. diff --git a/crates/ogar-blockly/src/lib.rs b/crates/ogar-blockly/src/lib.rs index 49fc605..94f822d 100644 --- a/crates/ogar-blockly/src/lib.rs +++ b/crates/ogar-blockly/src/lib.rs @@ -30,7 +30,9 @@ //! - the palette constants' *meanings* (documented on [`FnIndex`]'s //! associated constants, re-exported from the core where the shared //! computational range is defined once for every vocabulary), -//! - the Blocks concept domain (`0x17XX`) and its two concept ids, +//! - the Blocks concept domain (`0x17XX`) and its ONE concept id (`0x1717`), +//! which names the PALETTE; the node shapes it stores into are +//! [`ogar_loco::LocoConcept`]'s (`0x1701` / `0x1702`), globally owned, //! - the [`SoaSplit`] storage partitioning, //! - [`BlocklyVocabulary`], this palette's [`Vocabulary`] implementation. //! @@ -52,6 +54,10 @@ //! = its own SoA, so every write is owned and no singleton table accumulates //! writers. //! +//! The split belongs to the SUBSTRATE, not to this palette: both partitions +//! resolve to [`ogar_loco::LocoConcept`], because a registry row and a +//! function body are the same two shapes for every vocabulary. +//! //! # Provenance fence (load-bearing, not decorative) //! //! Every palette entry here is derived from **permissively-licensed or @@ -111,28 +117,52 @@ pub const DEVICE_FAMILY_FLOOR: u8 = DOMAIN_FLOOR; // ── Concept ids (authoritative here, NOT in the shared codebook) ──────────── -/// The concepts this crate owns inside `0x17XX`. +/// The **one** concept this crate owns: `0x1717`. +/// +/// # Why exactly one, and why not `0x1701`/`0x1702` +/// +/// The node shapes a block program stores into — the function body and the +/// inventory row — are NOT Blockly's. They are described entirely in +/// `ogar-loco`'s own vocabulary ([`FunctionBody`], [`LaneShape`], the value +/// slab), and an elixir-shaped thinking template or an RO relation body is +/// the same shape with a different palette. So they belong to the substrate +/// and live at [`ogar_loco::LocoConcept`] (`0x1701` / `0x1702`) — **global +/// interest**, because thinking orchestration rides the same call ABI. +/// +/// This crate previously owned those two ids, which read as though a +/// frontend owned the universal shape. It doesn't. What is genuinely +/// Blockly's is ONE thing: *which palette resolves the bytes* — and that +/// needs exactly one classid. +/// +/// # This crate is a CONSUMER inside loco's domain +/// +/// `0x17` belongs to the substrate, not to block programming (operator, +/// 2026-08-07). This palette is **seated at `0x1717`** — deliberately high, +/// so `0x1703`–`0x1716` stays contiguous headroom for `ogar-loco`'s own +/// growth (uplifting, Klickwege, whatever the ABI needs next). One slot is +/// the whole allocation: if a block frontend ever outgrows it, it gets its +/// **own domain** rather than expanding into the substrate's headroom. /// -/// Deliberately small. The operations are palette **bytes**, not concepts — the -/// classid names the schema, and one content schema is all a function body -/// needs. +/// The operations themselves are palette **bytes**, never concepts: 256 +/// `FnIndex` slots resolved through this crate's [`BlocklyVocabulary`], not +/// 256 codebook rows. That is why one id suffices, and why the shared +/// codebook stays at zero `0x17XX` rows — this palette is **plug-and-play**, +/// activated only in a build that actually contains a block frontend. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum BlockConcept { - /// `0x1701` — a **function body**: identity names the function, the value - /// slab carries up to [`LaneShape::calls_per_function`] calls. The one - /// content classid. - Content, - /// `0x1702` — the **inventory** row: the function registry entry (which - /// functions exist, addressed by identity). Reads never touch a body. - Inventory, + /// `0x1717` — **the Blockly/Scratch palette**: the classid that says + /// "resolve this node's call bytes through [`BlocklyVocabulary`]". The + /// node's SHAPE comes from [`ogar_loco::LocoConcept`]; this names the + /// vocabulary, not the shape. + Palette, } impl BlockConcept { /// Every concept, in id order — the enumeration hook a consumer uses to /// inherit the full set instead of hand-maintaining a parallel list. - pub const ALL: [BlockConcept; 2] = [BlockConcept::Content, BlockConcept::Inventory]; + pub const ALL: [BlockConcept; 1] = [BlockConcept::Palette]; /// This concept's canonical id inside the `0x17XX` Blocks domain. /// @@ -141,8 +171,7 @@ impl BlockConcept { #[must_use] pub const fn concept_id(self) -> u16 { match self { - BlockConcept::Content => 0x1701, - BlockConcept::Inventory => 0x1702, + BlockConcept::Palette => 0x1717, } } @@ -177,12 +206,19 @@ pub enum SoaSplit { } impl SoaSplit { - /// The concept whose classid this partition's rows carry. + /// The **`ogar-loco`** concept whose classid this partition's rows carry. + /// + /// The split is the substrate's, not this palette's: a registry row and a + /// function body are the same two shapes for every vocabulary, so they + /// resolve to [`ogar_loco::LocoConcept`], never to [`BlockConcept`]. This + /// crate's own id ([`BlockConcept::Palette`], `0x1717`) says which + /// vocabulary resolves the call bytes — a different question from which + /// shape the row is. #[must_use] - pub const fn concept(self) -> BlockConcept { + pub const fn concept(self) -> ogar_loco::LocoConcept { match self { - SoaSplit::Inventory => BlockConcept::Inventory, - SoaSplit::Content => BlockConcept::Content, + SoaSplit::Inventory => ogar_loco::LocoConcept::Inventory, + SoaSplit::Content => ogar_loco::LocoConcept::FunctionBody, } } } @@ -216,14 +252,15 @@ impl Vocabulary for BlocklyVocabulary { /// Validate this palette and plug it into a consumer's /// [`VocabularyRegistry`] under the Blocks **content** concept -/// ([`BlockConcept::Content`]) — the USB handshake for this device. +/// ([`BlockConcept::Palette`]) — the USB handshake for this device. /// /// A consumer (blockly-rs, lance-graph) builds ONE registry at boot and /// calls each vocabulary crate's `plug_into`; every stored function node /// then resolves through `registry.resolve_classid(node_classid)`, with no /// consumer-side "this node must be Blockly" branch. Only the CONTENT -/// concept is plugged: [`BlockConcept::Inventory`] rows are registry -/// entries, not function bodies, so they carry no call vocabulary. +/// concept is plugged: it names WHICH vocabulary resolves a node's call +/// bytes. The node SHAPES ([`ogar_loco::LocoConcept`]) are the substrate's +/// and are not this palette's to register. /// /// # Errors /// @@ -237,7 +274,7 @@ impl Vocabulary for BlocklyVocabulary { pub fn plug_into(registry: &mut VocabularyRegistry) -> Result<(), RegistryError> { let checked = ogar_loco::vocabulary::conformance::validate(BlocklyVocabulary) .expect("BlocklyVocabulary conforms; pinned by this crate's tests"); - registry.plug(BlockConcept::Content.concept_id(), &checked) + registry.plug(BlockConcept::Palette.concept_id(), &checked) } #[cfg(test)] @@ -270,9 +307,9 @@ mod tests { #[test] fn render_classid_is_canon_high() { // canon concept HIGH, app render prefix LOW (D-CLASSID-CANON-HIGH-FLIP). - let id = BlockConcept::Content.render_classid(0x1000); - assert_eq!(id, 0x1701_1000); - assert_eq!(id >> 16, u32::from(BlockConcept::Content.concept_id())); + let id = BlockConcept::Palette.render_classid(0x1000); + assert_eq!(id, 0x1717_1000); + assert_eq!(id >> 16, u32::from(BlockConcept::Palette.concept_id())); assert_eq!(id & 0xFFFF, 0x1000); } @@ -280,8 +317,14 @@ mod tests { fn soa_split_maps_each_partition_to_its_own_concept() { // Inventory and Content must NOT share a classid — the whole point of // the split is that a registry read never touches a body. - assert_eq!(SoaSplit::Inventory.concept(), BlockConcept::Inventory); - assert_eq!(SoaSplit::Content.concept(), BlockConcept::Content); + assert_eq!( + SoaSplit::Inventory.concept(), + ogar_loco::LocoConcept::Inventory + ); + assert_eq!( + SoaSplit::Content.concept(), + ogar_loco::LocoConcept::FunctionBody + ); assert_ne!( SoaSplit::Inventory.concept().concept_id(), SoaSplit::Content.concept().concept_id() diff --git a/crates/ogar-loco/src/lib.rs b/crates/ogar-loco/src/lib.rs index b4fde03..f843389 100644 --- a/crates/ogar-loco/src/lib.rs +++ b/crates/ogar-loco/src/lib.rs @@ -136,6 +136,86 @@ pub use telemetry::{FunnelTally, RefusalGate}; pub use vocabulary::conformance::CheckedVocabulary; pub use vocabulary::{FnSpec, ValueCodebook, Vocabulary, VocabularyTable}; +// ── The universal node concepts ───────────────────────────────────────────── + +/// The **global** concepts this crate owns: the node shapes EVERY sibling +/// vocabulary stores into, whatever palette it speaks. +/// +/// These are not a frontend's property. A Blockly script body, an +/// elixir-shaped thinking template, and an RO relation body are all the same +/// [`FunctionBody`] in the same 512-byte [`FunctionNode`] — they differ only +/// in which [`Vocabulary`] resolves their bytes. So the classid that names +/// *"a function body"* belongs here, at the substrate, and a frontend +/// references it rather than minting its own. +/// +/// This is the global half of the split: `ogar-loco` is global interest +/// (thinking orchestration rides the same call ABI), whereas a particular +/// palette — Blockly/Scratch opcodes, `ogar-blockly`'s `0x1717` — is +/// activated only in a build that actually contains that frontend. +/// +/// # `0x17` is LOCO's domain, and consumers are seated above +/// +/// The whole `0x17XX` block belongs to this substrate (operator, 2026-08-07). +/// The layout inside it: +/// +/// | range | who | for what | +/// |---|---|---| +/// | `0x1701` / `0x1702` | **loco** | the node shapes — body + inventory | +/// | `0x1703`–`0x1716` | **loco, reserved** | headroom for the substrate's own growth — uplifting, Klickwege, whatever the ABI needs next | +/// | `0x1717`+ | **consumers** | one slot per frontend palette (`ogar-blockly` = `0x1717`) | +/// +/// Consumers were deliberately seated *high* so the substrate keeps +/// contiguous room beneath them. A frontend that outgrows a single slot gets +/// its **own domain** rather than eating into that headroom — the same +/// "scale = the next cascade level, never field-widening" rule the GUID canon +/// uses. +/// +/// The `ogar_vocab::ConceptDomain::Blocks` label on `0x17` predates this and +/// now reads narrowly (the domain is the substrate's, not one frontend's). +/// Renaming a reserved domain is a canon change and is left to the operator; +/// nothing here depends on the label, only on the `id >> 8` route. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub enum LocoConcept { + /// `0x1701` — a **function body**: identity names the function, the value + /// slab carries up to [`LaneShape::calls_per_function`] calls. The one + /// content classid, shared by every vocabulary. + FunctionBody, + /// `0x1702` — the **inventory** row: the function registry entry (which + /// functions exist, addressed by identity). A registry read never touches + /// a body — the V3 mailbox split, and it is the same split regardless of + /// palette. + Inventory, +} + +impl LocoConcept { + /// Every concept, in id order — the enumeration hook a consumer uses + /// instead of hand-maintaining a parallel list. + pub const ALL: [LocoConcept; 2] = [LocoConcept::FunctionBody, LocoConcept::Inventory]; + + /// This concept's canonical id. + /// + /// Authoritative HERE. Whether these are additionally promoted into + /// `ogar_vocab`'s shared codebook is a separate, operator-ruled canon + /// decision — this crate does not assume it, and nothing here breaks if + /// they are not. + #[must_use] + pub const fn concept_id(self) -> u16 { + match self { + LocoConcept::FunctionBody => 0x1701, + LocoConcept::Inventory => 0x1702, + } + } + + /// The full V3 render classid under a consumer's app prefix — canon-high + /// `(concept as u32) << 16 | app_prefix`. + #[must_use] + pub const fn render_classid(self, app_prefix: u16) -> u32 { + ((self.concept_id() as u32) << 16) | (app_prefix as u32) + } +} + // ── The function-body budget ──────────────────────────────────────────────── /// Value-slab facet slots in a 512-byte node: `value(480) / 16` = **30**. diff --git a/crates/ogar-ro/tests/plug_and_play.rs b/crates/ogar-ro/tests/plug_and_play.rs index af5af96..dae0ba9 100644 --- a/crates/ogar-ro/tests/plug_and_play.rs +++ b/crates/ogar-ro/tests/plug_and_play.rs @@ -24,7 +24,7 @@ fn one_hub_routes_two_vocabularies_by_classid_alone() { // Two stored nodes under DIFFERENT app prefixes — routing must ignore // the lo u16 (render skin) and read only the hi u16 (concept). - let blockly_node = ogar_blockly::BlockConcept::Content.render_classid(0x1000); + let blockly_node = ogar_blockly::BlockConcept::Palette.render_classid(0x1000); let relation_node = ogar_ro::relation_body_render_classid(0xBEEF); let blocks = hub.resolve_classid(blockly_node).expect("blocks plugged"); @@ -48,7 +48,7 @@ fn the_shared_core_is_identical_across_every_plugged_device() { // you happened to load. let hub = boot(); let blocks = hub - .resolve_classid(ogar_blockly::BlockConcept::Content.render_classid(0x1000)) + .resolve_classid(ogar_blockly::BlockConcept::Palette.render_classid(0x1000)) .unwrap(); let relations = hub .resolve_classid(ogar_ro::relation_body_render_classid(0x1000)) @@ -74,10 +74,13 @@ fn an_unplugged_concept_resolves_to_nothing_rather_than_a_default() { // answered *something* for every classid would carry no information. let hub = boot(); assert!(hub.resolve_classid(0x0999_1000).is_none()); - // …and the inventory concept is deliberately NOT plugged: registry rows - // are not function bodies, so they carry no call vocabulary. - let inventory = ogar_blockly::BlockConcept::Inventory.render_classid(0x1000); - assert!(hub.resolve_classid(inventory).is_none()); + // …and a node SHAPE concept is deliberately NOT plugged: `LocoConcept` + // says what a row IS (a body, a registry entry); a palette id says which + // vocabulary resolves its bytes. Only the latter carries a table. + let shape = ogar_loco::LocoConcept::Inventory.render_classid(0x1000); + assert!(hub.resolve_classid(shape).is_none()); + let body = ogar_loco::LocoConcept::FunctionBody.render_classid(0x1000); + assert!(hub.resolve_classid(body).is_none()); } #[test]