Skip to content
Merged
64 changes: 64 additions & 0 deletions .claude/knowledge/hotplug-consumer-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,67 @@ sibling on every push.
`domain_tables()` entry + a `<STYLES>_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.
99 changes: 71 additions & 28 deletions crates/ogar-blockly/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -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
Expand Down Expand Up @@ -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.
///
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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
///
Expand All @@ -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)]
Expand Down Expand Up @@ -270,18 +307,24 @@ 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);
}

#[test]
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()
Expand Down
80 changes: 80 additions & 0 deletions crates/ogar-loco/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
15 changes: 9 additions & 6 deletions crates/ogar-ro/tests/plug_and_play.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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))
Expand All @@ -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]
Expand Down
Loading