diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 2abaa464f..6210a297c 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -24,8 +24,8 @@ Plan: `.claude/plans/v3-mandatory-hotplug-reading-v1.md`. | **D-HPR-4** | `0x1000` / render prefixes documented as deprecated, kept (no eviction) | Shipped for `0x1000` (#1464; doc notes on `CLASSID_*_V3` + `classid_read_mode`); render prefixes, `NodeGuid::read_mode` and OGAR/q2 sites still carry no note | — | | **D-HPR-5** | Stored legacy keys keep resolving; a `SlabDeclaration` may override for its own data; no forced removal | Holds today (status quo). Caveat: a `SlabDeclaration` overrides `value_schema` only, never the tail, so it cannot protect a V1/V2-tail slab (`hotplug.rs:231-257, 392`) | — | | **D-HPR-6** | No V1 default for an unknown classid; `guid-v3-tail` not a gate | Queued — split: **6a** un-gate `guid-v3-tail` (default-on, and no fleet consumer disables it, so only `--no-default-features` builds change; there the 5 V3 fossils read `DEFAULT` today); **6b** unknown classid → error. Decided 2026-10-11: go, staged after 6a, D-HPR-3 and a stored-classid check | unknown classid → `NoReadingFor` | -| **D-HPR-7** | Storage readings leave the concept key: out of `concept_override`, into the slab's `SlabDeclaration` or the owning consumer's plug (O3, decided (a) 2026-10-11) | Queued | a non-Odoo plug of `0x0202` reads `PLUG_AND_PLAY_V3`; Odoo's slab still reads `ERP_V3` | -| **D-HPR-8** | A plug that asks only for a reading succeeds (reading is not a capability; decided 2026-10-11) | Queued | `[0x0103]` with `covered: &[]` → `Ok`, `PLUG_AND_PLAY_V3`; with a covered capability → refused as today | +| **D-HPR-7** | Storage readings leave the concept key: out of `concept_override`, into the slab's `SlabDeclaration` or the owning consumer's plug (O3, decided (a) 2026-10-11) | In PR (#1474; the five domain arms removed; the domain models differed from plug-and-play only in `value_schema`, which `SlabDeclaration` overrides; tests red before, green after) | a non-Odoo plug of `0x0202` reads `PLUG_AND_PLAY_V3`; Odoo's slab still reads `ERP_V3` | +| **D-HPR-8** | A plug that asks only for a reading succeeds (reading is not a capability; decided 2026-10-11) | In PR (#1474; `OgarAuthority::activate` skips the capability join when `covered` is empty; ids still must be minted; red before the fix with `NoCapabilitiesFor(0x0103)`; disable run turns the twin red) | `[0x0103]` with `covered: &[]` → `Ok`, `PLUG_AND_PLAY_V3`; with a covered capability → refused as today | ## D-XGP — Cross-glove business parity: Odoo × SAP over one Quack algebra (2026-10-10) diff --git a/.claude/plans/v3-mandatory-hotplug-reading-v1.md b/.claude/plans/v3-mandatory-hotplug-reading-v1.md index 50898b1de..3c767763b 100644 --- a/.claude/plans/v3-mandatory-hotplug-reading-v1.md +++ b/.claude/plans/v3-mandatory-hotplug-reading-v1.md @@ -63,8 +63,8 @@ What it means, in rules: - [ ] **W6 — D-HPR-6: V1 cannot be reached by default.** `ReadMode::DEFAULT` stops being the fallback for an unknown classid (it becomes `NoReadingFor`, as on the plug path), and `guid-v3-tail` stops being a feature gate. - Split (2026-10-11). **W6a**: un-gate `guid-v3-tail`. It is default-on and no fleet consumer disables it, so only `--no-default-features` builds change; there the 5 V3 fossils read `DEFAULT` today (coresearch probe P-X3). **W6b**: an unknown classid errors. This is a behaviour change, and both q2 and MedCare have already hit the V1 default once, so it needs operator approval. - **Decided (operator, 2026-10-11: "Recommended accepted"): go, staged.** 1. W6a. 2. W3 moves the remaining mint paths onto plug readings. 3. A check that every stored artifact's classid resolves. 4. Only then W6b. -- [ ] **W7 — D-HPR-7: storage readings leave the concept key** (O3, decided (a)). The five domain storage readings move out of `concept_override` (keyed by concept) into the slab's `SlabDeclaration` or the owning consumer's plug. A consumer plugging a shared concept then reads `PLUG_AND_PLAY_V3`. Falsifier: a plug of `0x0202` by a non-Odoo consumer reads `PLUG_AND_PLAY_V3`, and Odoo's own slab still reads `ERP_V3`. -- [ ] **W8 — D-HPR-8: a plug that asks only for a reading succeeds** (decided). Reading is not a capability. A plug with `covered` empty gets readings only, also for a concept that has a capability table. A plug that names a capability still needs the table. Falsifier: `[0x0103]` with `covered: &[]` → `Ok` and `read_mode_for(0x0103) == PLUG_AND_PLAY_V3`; with `covered: &["anything"]` → refused as today. +- [x] **W7 — D-HPR-7: storage readings leave the concept key** (O3, decided (a)). **Shipped (#1474):** the five domain arms are removed from `concept_override`. The domain models differed from `PLUG_AND_PLAY_V3` only in `value_schema`, which a `SlabDeclaration` overrides, so no new mechanism was needed. Test `canon_domains_read_plug_and_play_and_their_slab_restores_the_domain_model`. The five domain storage readings move out of `concept_override` (keyed by concept) into the slab's `SlabDeclaration` or the owning consumer's plug. A consumer plugging a shared concept then reads `PLUG_AND_PLAY_V3`. Falsifier: a plug of `0x0202` by a non-Odoo consumer reads `PLUG_AND_PLAY_V3`, and Odoo's own slab still reads `ERP_V3`. +- [x] **W8 — D-HPR-8: a plug that asks only for a reading succeeds** (decided). **Shipped:** `lance-graph-ogar` `OgarAuthority::activate`, tests `a_plug_covering_nothing_activates_for_its_reading_only` (SAP glove on `0x0103`, a non-executor on `0x0901`) and `a_plug_that_covers_a_capability_is_still_refused_without_its_table`. The glove-parity pin now says both glove plugs activate for their reading; claiming a capability on `0x0103` is still `NoCapabilitiesFor`. Reading is not a capability. A plug with `covered` empty gets readings only, also for a concept that has a capability table. A plug that names a capability still needs the table. Falsifier: `[0x0103]` with `covered: &[]` → `Ok` and `read_mode_for(0x0103) == PLUG_AND_PLAY_V3`; with `covered: &["anything"]` → refused as today. ## Open questions (operator) diff --git a/crates/lance-graph-glove-parity/src/basis.rs b/crates/lance-graph-glove-parity/src/basis.rs index e3b9aeee9..a40bcb219 100644 --- a/crates/lance-graph-glove-parity/src/basis.rs +++ b/crates/lance-graph-glove-parity/src/basis.rs @@ -28,7 +28,8 @@ use lance_graph_ogar::OgarClassView; pub const CONCEPTS: &[u16] = &[BILLABLE_WORK_ENTRY]; /// SAP CATS. No capabilities are covered: the authority declares none on -/// `0x0103` yet, so activation refuses with `NoCapabilitiesFor`. +/// `0x0103` yet, so the plug asks for a reading only and activates with it +/// (D-HPR-8: reading is not a capability). pub const SAP_HOT_PLUG: HotPlug = HotPlug { consumer: "sap-glove", classids: CONCEPTS, diff --git a/crates/lance-graph-glove-parity/tests/authority.rs b/crates/lance-graph-glove-parity/tests/authority.rs index 4efa2a225..1bf0d2476 100644 --- a/crates/lance-graph-glove-parity/tests/authority.rs +++ b/crates/lance-graph-glove-parity/tests/authority.rs @@ -1,5 +1,6 @@ //! D-XGP-2: the hot-plugs and the canonical basis, against the authority. -use lance_graph_contract::hotplug::{ActivationDrift, CapabilityAuthority}; +use lance_graph_contract::canonical_node::ReadMode; +use lance_graph_contract::hotplug::{ActivationDrift, CapabilityAuthority, HotPlug}; use lance_graph_glove_parity::basis::{ canonical_fields, Grade, ANCHORS, ODOO_FIELDS, ODOO_HOT_PLUG, SAP_FIELDS, SAP_HOT_PLUG, WOA_FIELDS, WOA_HOURS_TABLE, WOA_PINNED_TABLE, @@ -8,11 +9,29 @@ use lance_graph_ogar::ogar_vocab::class_ids::BILLABLE_WORK_ENTRY; use lance_graph_ogar::OgarAuthority; use lance_graph_sap::schema::FIELDS; +/// D-HPR-8: both gloves cover no capability, so they plug `0x0103` for its +/// reading alone and activate, although OGAR declares no capabilities on it. #[test] -fn both_plugs_refuse_until_the_authority_declares_capabilities_on_0x0103() { +fn both_plugs_activate_for_their_reading_while_0x0103_has_no_capabilities() { for plug in [SAP_HOT_PLUG, ODOO_HOT_PLUG] { + let act = OgarAuthority + .activate(&plug) + .unwrap_or_else(|d| panic!("{}: {d:?}", plug.consumer)); assert_eq!( - OgarAuthority.activate(&plug).map(|_| ()), + act.read_mode_for(BILLABLE_WORK_ENTRY), + Ok(ReadMode::PLUG_AND_PLAY_V3), + "{}", + plug.consumer + ); + assert!(act.capabilities.is_empty(), "{}", plug.consumer); + // Twin: claiming a capability still goes through the join, which has + // no table for 0x0103. When OGAR declares one, write `covered`. + let claiming = HotPlug { + covered: &["log_time"], + ..plug + }; + assert_eq!( + OgarAuthority.activate(&claiming).map(|_| ()), Err(ActivationDrift::NoCapabilitiesFor(BILLABLE_WORK_ENTRY)), "{}: OGAR now declares capabilities on 0x0103; write `covered`", plug.consumer diff --git a/crates/lance-graph-ogar/src/lib.rs b/crates/lance-graph-ogar/src/lib.rs index 43bb61cb2..34ae31f29 100644 --- a/crates/lance-graph-ogar/src/lib.rs +++ b/crates/lance-graph-ogar/src/lib.rs @@ -480,16 +480,13 @@ const fn concept_override(classid: u16) -> Option { value_schema: ValueSchema::Bootstrap, edge_codec: EdgeCodecFlavor::CoarseOnly, }), - // The canon domains' value models, keyed by concept (D-HPR-2, plan - // `v3-mandatory-hotplug-reading-v1`). The same readings - // `classid_read_mode` gives their `CLASSID_*_V3` keys, so a consumer - // plugging one of these concepts reads what the classid table reads, - // with no classview involved. - 0x0701 => Some(ReadMode::OSINT_V3), - 0x0A01 => Some(ReadMode::FMA_V3), - 0x0E01 => Some(ReadMode::CPIC_V3), - 0x0101 => Some(ReadMode::PROJECT_V3), - 0x0202 => Some(ReadMode::ERP_V3), + // The five canon domains (0x0701, 0x0A01, 0x0E01, 0x0101, 0x0202) + // carried their storage models here under D-HPR-2. They were removed + // by D-HPR-7 (decided 2026-10-11): three of those u16s are also + // consumer appid heads, so any consumer plugging the shared concept + // got one app's storage model. A domain's model now comes from the + // slab holding its data (`SlabDeclaration`, which overrides exactly + // the value schema in which these readings differed). _ => None, } } @@ -505,9 +502,11 @@ const fn is_palette_seat(classid: u16) -> bool { classid >= 0x1717 && (classid >> 8) == 0x17 } -/// `true` for the five canon storage domains whose reading lives in -/// [`concept_override`] (D-HPR-2): OSINT `0x0701`, FMA `0x0A01`, CPIC -/// `0x0E01`, PROJECT `0x0101`, ERP `0x0202`. +/// `true` for the five canon storage domains: OSINT `0x0701`, FMA `0x0A01`, +/// CPIC `0x0E01`, PROJECT `0x0101`, ERP `0x0202`. Only their ROUTING is special +/// (reading-only when the join refuses them); since D-HPR-7 they read +/// plug-and-play, and a domain's storage model comes from its slab's +/// `SlabDeclaration`. const fn is_canon_storage_domain(classid: u16) -> bool { matches!(classid, 0x0701 | 0x0A01 | 0x0E01 | 0x0101 | 0x0202) } @@ -617,6 +616,32 @@ impl lance_graph_contract::hotplug::CapabilityAuthority for OgarAuthority { return Err(ActivationDrift::UnexpectedConsumer(plug.consumer.into())); } + // D-HPR-8 (decided 2026-10-11): a plug that covers no capability asks + // for a reading only, and reading is not a capability. So the join is + // not consulted: a concept with no action table, or a table whose + // executor list does not name this consumer, cannot refuse it. The + // ids still have to be real. Palette seats and canon storage domains + // keep their routing above (no concept row, as before); every other + // id must be a minted concept, and its row goes through the mirror + // check. A plug that names any capability falls through to the join + // unchanged. + if plug.covered.is_empty() { + let mut concepts = Vec::with_capacity(capability.len()); + for &id in &capability { + match ogar_vocab::class_ids::ALL + .iter() + .find(|&&(_, cid)| cid == id) + { + Some(&(name, cid)) => concepts.push((name.to_string(), cid)), + None => return Err(ActivationDrift::UnknownClassid(id)), + } + } + if let Some(drift) = lance_graph_contract::hotplug::verify_against_mirror(&concepts) { + return Err(drift); + } + return Ok(Activation::new(concepts, Vec::new(), plug_readings(plug))); + } + // Pure-palette plug: no capability ids at all, so the join is not // consulted (it is pinned to refuse `0x17XX`). if !palette.is_empty() && capability.is_empty() { @@ -820,11 +845,18 @@ mod plug_and_play_reading { assert!(super::OgarAuthority.activate(&BLOCKLY).is_ok()); } - /// D-HPR-2: the canon domains' readings come from the plug, keyed by - /// concept, and equal what `classid_read_mode` gives their `*_V3` keys. + /// D-HPR-7 (decided 2026-10-11): plugging a canon domain's concept reads + /// plug-and-play, like any other plugged concept. The domain's own storage + /// model belongs to the slab that holds its data, so the slab's + /// declaration restores it, and then it equals what `classid_read_mode` + /// gives the domain's `*_V3` key. Reading-only routing (D-HPR-2's codex P1 + /// fix) is unchanged: the plug still activates although the join refuses + /// every one of these ids. #[test] - fn canon_domains_read_from_the_plug_as_the_classid_table_reads_them() { - use lance_graph_contract::canonical_node::NodeGuid; + fn canon_domains_read_plug_and_play_and_their_slab_restores_the_domain_model() { + use lance_graph_contract::canonical_node::{NodeGuid, ValueSchema}; + use lance_graph_contract::hotplug::{SlabDeclaration, SlabReading}; + use lance_graph_contract::soa_envelope::ENVELOPE_LAYOUT_VERSION; let domains = [ (0x0701u16, NodeGuid::CLASSID_OSINT_V3), (0x0A01, NodeGuid::CLASSID_FMA_V3), @@ -837,38 +869,39 @@ mod plug_and_play_reading { classids: &[0x0701, 0x0A01, 0x0E01, 0x0101, 0x0202], covered: &[], }; - // Through the public path, not the private helper (codex P1 on - // #1465): every one of these ids is refused by the capability join, - // so a reading reachable only via `plug_readings` would be dead. let act = super::OgarAuthority .activate(&plug) .expect("a canon-domain plug activates for its reading"); - let readings = act.declared_readings().to_vec(); for (concept, classid) in domains { + assert_eq!(classid >> 16, u32::from(concept), "fixture pairs"); assert_eq!( - classid >> 16, - u32::from(concept), - "fixture pairs concept and classid" + act.read_mode_for(concept), + Ok(ReadMode::PLUG_AND_PLAY_V3), + "0x{concept:04X}: the concept alone carries no storage model" ); - let from_plug = readings - .iter() - .find_map(|(c, m)| (*c == concept).then_some(*m)) - .expect("every plugged concept reads"); - assert_eq!(from_plug, classid_read_mode(classid), "0x{concept:04X}"); - assert_eq!(from_plug.tail_variant, TailVariant::V3); + let stored = classid_read_mode(classid); + let slab = SlabDeclaration { + reading: SlabReading::Facet96, + value_schema: stored.value_schema, + layout_version: ENVELOPE_LAYOUT_VERSION, + }; + let resolved = act + .resolve_for_context(concept, Some(&slab)) + .expect("a declared slab resolves"); + assert_eq!(resolved.read_mode, stored, "0x{concept:04X}"); } - // Anti-vacuity: the domains do not all read the plug-and-play default, - // so the overrides are what this test checks. - assert!(domains.iter().any(|(c, _)| readings + // Anti-vacuity: the stored domain models are not all plug-and-play, + // so the slab declaration is what restores them. + assert!(domains .iter() - .any(|(rc, m)| rc == c && *m != ReadMode::PLUG_AND_PLAY_V3))); + .any(|(_, c)| classid_read_mode(*c).value_schema != ValueSchema::Full)); } /// A canon storage domain plugged alongside capability ids: the domain /// gets its reading without reaching the join, and the capability half /// still resolves (codex P1 on #1465). #[test] - fn a_canon_domain_next_to_capability_ids_activates_and_reads_its_domain() { + fn a_canon_domain_next_to_capability_ids_activates_and_reads_plug_and_play() { let mixed = HotPlug { consumer: "medcare-rs", classids: &[0x0A01, 0x0901, 0x0902], @@ -886,7 +919,9 @@ mod plug_and_play_reading { let act = super::OgarAuthority .activate(&mixed) .expect("the canon domain must not make the plug refuse"); - assert_eq!(act.read_mode_for(0x0A01), Ok(ReadMode::FMA_V3)); + // D-HPR-7: the concept alone reads plug-and-play; FMA's Compressed + // model comes from its slab's declaration. + assert_eq!(act.read_mode_for(0x0A01), Ok(ReadMode::PLUG_AND_PLAY_V3)); assert_eq!(act.read_mode_for(0x0901), Ok(ReadMode::PLUG_AND_PLAY_V3)); assert!(!act.capabilities.is_empty()); assert!(act.concepts.iter().all(|(_, id)| *id != 0x0A01)); @@ -1001,6 +1036,71 @@ mod plug_and_play_reading { )); } + /// D-HPR-8 CAN FIRE: a plug that covers no capability asks for a reading + /// only, and gets one — for a concept that has no capability table + /// (`0x0103`, the SAP/Odoo glove plugs) and for one that has a table it + /// does not serve (`0x0901`, whose healthcare table expects only + /// `medcare-rs`). Reading is not a capability, so neither the missing table + /// nor the executor list stands in the way. + #[test] + fn a_plug_covering_nothing_activates_for_its_reading_only() { + let plugs = [ + ( + HotPlug { + consumer: "sap-glove", + classids: &[0x0103], + covered: &[], + }, + 0x0103u16, + "billable_work_entry", + ), + ( + HotPlug { + consumer: "some-reader", + classids: &[0x0901], + covered: &[], + }, + 0x0901, + "patient", + ), + ]; + for (plug, id, name) in plugs { + let act = super::OgarAuthority.activate(&plug).unwrap_or_else(|d| { + panic!("{} reading-only plug of {id:#06x}: {d:?}", plug.consumer) + }); + assert_eq!(act.read_mode_for(id), Ok(ReadMode::PLUG_AND_PLAY_V3)); + // Concept rows still come from the codebook, so the mirror check + // keeps running; capabilities are empty because none were asked. + assert_eq!(act.concepts, vec![(name.to_string(), id)]); + assert!(act.capabilities.is_empty()); + } + } + + /// D-HPR-8 CAN STAY SILENT: the moment a plug names a capability it goes + /// through the join exactly as before. `0x0103` still has no table, and a + /// non-executor covering a healthcare capability is still an impostor. + #[test] + fn a_plug_that_covers_a_capability_is_still_refused_without_its_table() { + let sap_claiming = HotPlug { + consumer: "sap-glove", + classids: &[0x0103], + covered: &["anything"], + }; + assert_eq!( + super::OgarAuthority.activate(&sap_claiming), + Err(ActivationDrift::NoCapabilitiesFor(0x0103)) + ); + let impostor = HotPlug { + consumer: "some-reader", + classids: &[0x0901], + covered: &["register_patient"], + }; + assert!(matches!( + super::OgarAuthority.activate(&impostor), + Err(ActivationDrift::UnexpectedConsumer(c)) if c == "some-reader" + )); + } + /// CAN STAY SILENT: an UNPLUGGED concept still bangs. /// /// "V3 for all appids in plug-and-play" must not become "V3 for