diff --git a/crates/ogar-blockly/Cargo.toml b/crates/ogar-blockly/Cargo.toml index 35dc9bc..3d503a6 100644 --- a/crates/ogar-blockly/Cargo.toml +++ b/crates/ogar-blockly/Cargo.toml @@ -14,5 +14,8 @@ serde = ["dep:serde", "ogar-vocab/serde", "ogar-loco/serde"] [dependencies] ogar-loco = { path = "../ogar-loco" } -ogar-vocab = { path = "../ogar-vocab" } +# `features = ["blocks"]` IS the activation: any build graph containing this +# crate activates the Blocks codebook in ogar-vocab, and no other build does. +# Cargo presence, not runtime detection. +ogar-vocab = { path = "../ogar-vocab", features = ["blocks"] } serde = { workspace = true, optional = true } diff --git a/crates/ogar-blockly/src/lib.rs b/crates/ogar-blockly/src/lib.rs index 94f822d..935dd9f 100644 --- a/crates/ogar-blockly/src/lib.rs +++ b/crates/ogar-blockly/src/lib.rs @@ -166,12 +166,22 @@ impl BlockConcept { /// This concept's canonical id inside the `0x17XX` Blocks domain. /// - /// Authoritative HERE; `ogar_vocab`'s shared CODEBOOK deliberately carries - /// zero `0x17XX` rows (plug-and-play, mirroring `ogar_obo::Namespace`). + /// **Read from `ogar_vocab`, never re-declared here.** The id is declared + /// once in [`ogar_vocab::blocks_actions::BLOCK_PALETTE`] — the crate that + /// also declares the capability table keyed by it — because this crate + /// depends on `ogar-vocab` and the reverse is impossible. Two constants + /// for one id is exactly the drift the classid join exists to catch. + /// + /// That row is **activated, not canon**: it lives behind `ogar-vocab`'s + /// `blocks` feature, which this crate's dependency turns on, so it exists + /// only in a build graph that actually contains a block editor. The shared + /// `class_ids::ALL` keeps zero `0x17XX` rows — that surface is mirrored + /// into lance-graph under a compile-time fuse, and a frontend's palette is + /// not lance-graph's concern. #[must_use] pub const fn concept_id(self) -> u16 { match self { - BlockConcept::Palette => 0x1717, + BlockConcept::Palette => ogar_vocab::blocks_actions::BLOCK_PALETTE, } } diff --git a/crates/ogar-vocab/Cargo.toml b/crates/ogar-vocab/Cargo.toml index 16274f0..4e6d3aa 100644 --- a/crates/ogar-vocab/Cargo.toml +++ b/crates/ogar-vocab/Cargo.toml @@ -11,6 +11,16 @@ description = "Open Graph of Active Record — canonical IR types for the AR-sha [features] default = [] serde = ["dep:serde"] +# The Blocks (visual block-programming) codebook — ACTIVATED, not canon. +# Off by default, so a consumer that never touches a block editor carries +# zero 0x17XX rows. `ogar-blockly` turns it on for its own dependents, which +# makes activation Cargo presence rather than runtime detection (the same +# rule `lance-graph-ogar` documents). The rows it adds are consulted by +# `capability_registry::resolve_hotplug` ALONGSIDE `class_ids::ALL` and are +# deliberately never merged into it — that surface is mirrored into +# lance-graph under a compile-time count fuse, and a frontend's palette is +# not lance-graph's concern. +blocks = [] [dependencies] serde = { workspace = true, optional = true } diff --git a/crates/ogar-vocab/src/blocks_actions.rs b/crates/ogar-vocab/src/blocks_actions.rs new file mode 100644 index 0000000..1da0698 --- /dev/null +++ b/crates/ogar-vocab/src/blocks_actions.rs @@ -0,0 +1,208 @@ +//! Blocks capability surface — **feature-activated, never shared canon**. +//! +//! Compiled ONLY under `--features blocks`, which `ogar-blockly` turns on for +//! its own dependents. A consumer that never touches a block editor never +//! compiles this module, never carries its rows, and sees `0x17XX` exactly as +//! it did before: a reserved domain with zero concepts. +//! +//! # Why this is not in `class_ids::ALL` +//! +//! `class_ids::ALL` is mirrored into `lance_graph_contract::ogar_codebook` +//! under a **compile-time** count fuse (`lance_graph_ogar::parity::COUNT_FUSE`). +//! Anything minted there is, by construction, a lance-graph change — and a +//! block editor's palette is not lance-graph's concern. Minting these rows +//! there once already turned lance-graph red against `main` and dragged in +//! `ogar-class-view`, `all_promoted_classes` and both fuse halves. +//! +//! So the activated rows live HERE, in [`ACTIVATED_CONCEPTS`], which +//! [`resolve_hotplug`](crate::capability_registry::resolve_hotplug) consults +//! **in addition to** `class_ids::ALL`. The global codebook keeps its exact +//! contents; a particular frontend's codebook rides its own feature. +//! +//! # Why the table is here and not in `ogar-blockly` +//! +//! [`domain_tables`](crate::capability_registry::domain_tables) resolves its +//! entries at compile time from modules inside THIS crate, so a table declared +//! in the producer crate is a **parallel registry that verifies itself against +//! itself and passes** while the real port answers `NoCapabilitiesFor` — the +//! `ogar-osm` defect `geo_actions` was written to correct. `ogar-blockly` +//! cannot host it for a second reason: `ogar-blockly` depends on +//! `ogar-vocab`, so `ogar-vocab` could never read the ids back. +//! +//! That dependency direction is also why the id is declared here and *read* by +//! `ogar-blockly`, not the reverse — one source of truth for `0x1717`. +//! +//! # The subject +//! +//! Only the PALETTE concept binds capabilities. `ogar-loco`'s node shapes +//! (`0x1701` / `0x1702`) are deliberately absent: they are the substrate's, +//! shared by every vocabulary, and a consumer plugging them would claim +//! ownership of the shape every sibling rides. + +use crate::{ActionDef, ActionSubject, KausalSpec}; + +/// The Blocks **palette** concept — `0x1717`, canon-high. +/// +/// Seated at `0x1717` rather than low in the domain because `0x1701`–`0x1716` +/// is `ogar-loco`'s: `0x1701`/`0x1702` are the node shapes and `0x1703`– +/// `0x1716` is the substrate's reserved headroom. Consumers are seated high so +/// the substrate keeps contiguous room beneath them (OGAR #255). +/// +/// Read by `ogar_blockly::BlockConcept::Palette` — declared once, here, since +/// `ogar-blockly` deps this crate and the reverse is impossible. +pub const BLOCK_PALETTE: u16 = 0x1717; + +/// The concept rows this feature ACTIVATES — consulted by +/// [`resolve_hotplug`](crate::capability_registry::resolve_hotplug) alongside +/// `class_ids::ALL`, and deliberately never merged into it. +/// +/// This is the whole "codebook triggered by plug-and-play" mechanism: with the +/// feature off the slice does not exist, `canonical_concept_domain(0x1717)` +/// still routes to [`Blocks`](crate::ConceptDomain::Blocks) on the reserved +/// domain byte alone, and a plug of `0x1717` correctly reports +/// `UnknownClassid`. With the feature on — i.e. when a block editor is +/// actually in the build graph — the same plug resolves. +pub const ACTIVATED_CONCEPTS: &[(&str, u16)] = &[("block_palette", BLOCK_PALETTE)]; + +/// Every Blocks capability name, in table order — the `const`-evaluable +/// fingerprint of [`blocks_actions`]. +pub const BLOCKS_ACTION_NAMES: &[&str] = &[ + "lower_script", + "raise_calls", + "render_text", + "parse_text", + "klickweg_address", +]; + +/// One Blocks [`ActionDef`], keyed by the palette concept. +/// +/// `object_class` carries the concept name so `derive_action_rows` recovers it +/// from the last `/` segment — the same fuse shape as `geo_actions` and +/// `ocr_actions`. Resolution goes through the feature-activated rows, so this +/// resolves iff the feature that declares the concept is the one compiling it. +fn blocks_action_def(capability: &'static str) -> ActionDef { + let object_class = "ogit-blocks/block_palette".to_owned(); + let identity = format!("{object_class}::action_def::{capability}"); + ActionDef { + identity, + predicate: capability.to_owned(), + object_class, + // Lowering a workspace or raising a body is a pure transform the + // editor invokes on its own content — the caller is the substrate + // (an editor cast, a render pass), not an authenticated User. + default_subject: ActionSubject::System, + // Invoked directly by a same-process caller with no OGAR-side + // precondition to guard on — `KausalSpec::External`'s documented case. + kausal: Some(KausalSpec::External), + ..ActionDef::default() + } +} + +/// The Blocks capability surface — one [`ActionDef`] per capability, in +/// [`BLOCKS_ACTION_NAMES`] order. +/// +/// Every entry is a real `blockly-abi` public function. `resolve_hotplug` +/// checks coverage in BOTH directions, so an aspirational entry fails the +/// consumer's own activation rather than quietly describing work nobody did. +#[must_use] +pub fn blocks_actions() -> Vec { + BLOCKS_ACTION_NAMES + .iter() + .map(|&capability| blocks_action_def(capability)) + .collect() +} + +/// The executors the authority EXPECTS to register against this table. +pub const BLOCKS_EXPECTED_EXECUTORS: &[&str] = &["blockly-abi"]; + +/// The distinct subject classids this table binds. A registering consumer must +/// activate exactly this set — the substrate's `0x1701`/`0x1702` are +/// deliberately absent. +pub const BLOCKS_SUBJECT_CLASSIDS: &[u16] = &[BLOCK_PALETTE]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::capability_registry::{HotplugDrift, resolve_hotplug}; + + #[test] + fn the_activated_concept_resolves_only_because_this_feature_is_on() { + // The whole point of the mechanism: 0x1717 is NOT in class_ids::ALL — + // it resolves through the feature-activated rows. Assert BOTH halves, + // or "it resolved" proves nothing about where it resolved from. + assert!( + !crate::class_ids::ALL + .iter() + .any(|&(_, id)| id == BLOCK_PALETTE), + "0x1717 must NEVER enter the globally-mirrored codebook" + ); + assert!( + ACTIVATED_CONCEPTS + .iter() + .any(|&(_, id)| id == BLOCK_PALETTE) + ); + + let (concepts, capabilities) = + resolve_hotplug("blockly-abi", BLOCKS_SUBJECT_CLASSIDS, BLOCKS_ACTION_NAMES) + .expect("the blocks domain must activate under its own feature"); + assert_eq!(capabilities.len(), BLOCKS_ACTION_NAMES.len()); + assert_eq!( + concepts.iter().map(|&(n, _)| n).collect::>(), + vec!["block_palette"] + ); + } + + #[test] + fn the_palette_never_claims_the_substrates_node_shapes() { + // The ownership line, asserted rather than trusted. 0x1701/0x1702 are + // ogar-loco's; a plug of either must NOT resolve through this table. + assert!(!BLOCKS_SUBJECT_CLASSIDS.contains(&0x1701)); + assert!(!BLOCKS_SUBJECT_CLASSIDS.contains(&0x1702)); + assert!( + !ACTIVATED_CONCEPTS + .iter() + .any(|&(_, id)| id == 0x1701 || id == 0x1702) + ); + for shape in [0x1701u16, 0x1702] { + assert!( + matches!( + resolve_hotplug("blockly-abi", &[shape], BLOCKS_ACTION_NAMES), + Err(HotplugDrift::UnknownClassid(id)) if id == shape + ), + "plugging {shape:#06x} must not resolve — it is the substrate's" + ); + } + } + + #[test] + fn the_port_rejects_a_wrong_consumer_and_coverage_gaps_both_ways() { + // Can-fire halves, so the activation above is not "yes to everything". + assert!(matches!( + resolve_hotplug( + "some-other-crate", + BLOCKS_SUBJECT_CLASSIDS, + BLOCKS_ACTION_NAMES + ), + Err(HotplugDrift::UnexpectedConsumer(_)) + )); + assert!(matches!( + resolve_hotplug("blockly-abi", BLOCKS_SUBJECT_CLASSIDS, &["lower_script"]), + Err(HotplugDrift::Uncovered(_)) + )); + let mut over = BLOCKS_ACTION_NAMES.to_vec(); + over.push("compile_to_wasm"); + assert!(matches!( + resolve_hotplug("blockly-abi", BLOCKS_SUBJECT_CLASSIDS, &over), + Err(HotplugDrift::Undeclared(_)) + )); + } + + #[test] + fn the_fingerprint_matches_the_table_in_order() { + let defs = blocks_actions(); + assert_eq!(defs.len(), BLOCKS_ACTION_NAMES.len()); + for (def, name) in defs.iter().zip(BLOCKS_ACTION_NAMES) { + assert_eq!(&def.predicate, name, "fingerprint drifted from the table"); + } + } +} diff --git a/crates/ogar-vocab/src/capability_registry.rs b/crates/ogar-vocab/src/capability_registry.rs index 7f11593..33aee3e 100644 --- a/crates/ogar-vocab/src/capability_registry.rs +++ b/crates/ogar-vocab/src/capability_registry.rs @@ -186,6 +186,14 @@ pub fn domain_tables() -> Vec { expected_executors: crate::healthcare_actions::HEALTHCARE_EXPECTED_EXECUTORS, entries: healthcare_entries, }, + // Feature-activated, not canon: present only when a block editor is + // actually in the build graph. Every table above ships in every build. + #[cfg(feature = "blocks")] + DomainTable { + domain: "blocks", + expected_executors: crate::blocks_actions::BLOCKS_EXPECTED_EXECUTORS, + entries: blocks_entries, + }, ] } @@ -229,7 +237,14 @@ fn derive_action_rows(actions: &[crate::ActionDef]) -> Vec { .iter() .map(|def| { let concept = def.object_class.rsplit('/').next().unwrap_or_default(); - match crate::canonical_concept_id(concept) { + // Canon first, then feature-activated rows — the SAME order as + // `resolve_concept_row`. Both sides of the join must consult the + // same set, or an activated concept resolves as a plugged classid + // (so the plug is accepted) while its capabilities silently land + // in the slag ledger, and the port answers `NoCapabilitiesFor` for + // a table that is right there. Measured, not hypothetical: that is + // exactly what this returned before the `or_else` arm. + match crate::canonical_concept_id(concept).or_else(|| activated_concept_id(concept)) { Some(id) => ActionRow::Resolved(def.predicate.clone(), id), None => ActionRow::Unminted(UnmintedRow { capability: def.predicate.clone(), @@ -326,6 +341,65 @@ fn geo_entries() -> Vec<(String, u16)> { entries_from_actions(&crate::geo_actions::geo_actions()) } +/// Blocks domain rows ([`crate::blocks_actions`], the blockly-abi table) — +/// compiled ONLY under the `blocks` feature. +#[cfg(feature = "blocks")] +fn blocks_entries() -> Vec<(String, u16)> { + entries_from_actions(&crate::blocks_actions::blocks_actions()) +} + +/// Resolve one plugged classid to its `(concept, id)` row. +/// +/// **The global codebook first, then whatever a feature ACTIVATED.** This is +/// the "codebook triggered by plug-and-play" seam: `class_ids::ALL` is the +/// canon every build carries (and the surface mirrored into lance-graph under +/// a compile-time count fuse), while a particular frontend's codebook rides +/// its own Cargo feature and is consulted only when that feature is compiled +/// in — i.e. only when the consumer that owns it is actually in the build +/// graph. +/// +/// Auto-activation is **Cargo presence, not runtime detection** — the same +/// rule `lance-graph-ogar` already documents. With the feature off these rows +/// do not exist and a plug reports `UnknownClassid`, which is the honest +/// answer: that vocabulary is not in this binary. +/// +/// Order is deliberate: **canon wins.** A feature can ADD a concept the global +/// codebook does not carry; it can never SHADOW one it does. +fn resolve_concept_row(id: u16) -> Option<(&'static str, u16)> { + if let Some(&(name, cid)) = crate::class_ids::ALL.iter().find(|&&(_, cid)| cid == id) { + return Some((name, cid)); + } + activated_concepts() + .iter() + .find(|&&(_, cid)| cid == id) + .copied() +} + +/// Every concept row a compiled-in feature activates, beyond `class_ids::ALL`. +/// +/// Empty in a default build — which is what keeps a consumer that never +/// touches a block editor free of a block editor's codebook. +/// The id of an activated concept BY NAME — the `derive_action_rows` half of +/// the same lookup [`resolve_concept_row`] does by id. Both halves must +/// consult the same set (see the call site). +fn activated_concept_id(concept: &str) -> Option { + activated_concepts() + .iter() + .find(|&&(name, _)| name == concept) + .map(|&(_, id)| id) +} + +fn activated_concepts() -> &'static [(&'static str, u16)] { + #[cfg(feature = "blocks")] + { + crate::blocks_actions::ACTIVATED_CONCEPTS + } + #[cfg(not(feature = "blocks"))] + { + &[] + } +} + /// Healthcare domain rows ([`crate::healthcare_actions`], the medcare-rs /// table — parity-plan P3), derived through the same generic /// [`entries_from_actions`] path as OCR. @@ -353,8 +427,8 @@ pub fn resolve_hotplug( ) -> Result { let mut concepts = Vec::new(); for &id in classids { - match crate::class_ids::ALL.iter().find(|&&(_, cid)| cid == id) { - Some(&(name, cid)) => concepts.push((name, cid)), + match resolve_concept_row(id) { + Some((name, cid)) => concepts.push((name, cid)), None => return Err(HotplugDrift::UnknownClassid(id)), } } diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index f69aea7..1ef4dbc 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -34,6 +34,13 @@ use serde::{Deserialize, Serialize}; /// `::()` grammar (`E-GRAMMAR-IS-THE-RECIPE-SHAPE`). pub mod recipe; +/// Healthcare capability surface — the medcare-rs authoritative action +/// table (parity-plan P3; hand-authored, harvest-informed — see the +/// module doc for why the mechanical lift was falsified by the corpus). +/// The Blocks capability surface — **feature-activated**, never shared canon. +/// Compiled only under `--features blocks`, which `ogar-blockly` turns on. +#[cfg(feature = "blocks")] +pub mod blocks_actions; /// The tesseract-rs OCR capability surface — a hand-authored, non-`lift_*` /// [`ActionDef`] table (tesseract-rs has no source AST to extract from; see /// the module doc for why this is the authoritative action table rather @@ -42,9 +49,6 @@ pub mod recipe; /// `render_tsv` / `render_hocr` / `render_searchable_pdf`, each targeting a /// minted `0x08XX` [`class_ids`] concept. pub mod capability_registry; -/// Healthcare capability surface — the medcare-rs authoritative action -/// table (parity-plan P3; hand-authored, harvest-informed — see the -/// module doc for why the mechanical lift was falsified by the corpus). pub mod geo_actions; pub mod healthcare_actions; pub mod ocr_actions;