diff --git a/crates/ogar-vocab/src/ports.rs b/crates/ogar-vocab/src/ports.rs index 98745afa..9686e7b6 100644 --- a/crates/ogar-vocab/src/ports.rs +++ b/crates/ogar-vocab/src/ports.rs @@ -712,6 +712,45 @@ pub const HUBSPO_ALIASES: &[(&str, u16)] = &[ ("Team", class_ids::PROJECT_ACTOR), ]; +// ── HIRO (Bardioc-rs, the IT-automation stack) port ────────────────── + +/// HIRO's `PortSpec`: the `ogit/_type` strings HIRO puts on the wire onto the +/// `0x0CXX` automation concepts, so a HIRO node dispatches on a `u16` instead +/// of a string match (`docs/HIRO-IN-CLASSES.md` §3.1). `0x000C` is HIRO's +/// render skin. Names are port-scoped: nothing here feeds the global alias +/// normalizer. `ogit/Automation/MAID` has no alias on purpose: MAID has no +/// minted concept, and no workload needs one yet. +pub struct HiroPort; + +impl PortSpec for HiroPort { + const NAMESPACE: &'static str = "HIRO"; + const BRIDGE_ID: &'static str = "hiro"; + const APP_PREFIX: u16 = 0x000C; + fn aliases() -> &'static [(&'static str, u16)] { + HIRO_ALIASES + } +} + +/// HIRO's wire names, exactly as its `ogit/_type` field spells them, for the +/// automation concepts. +pub const HIRO_ALIASES: &[(&str, u16)] = &[ + ("ogit/MARS/Application", class_ids::MARS_APPLICATION), + ("ogit/MARS/Resource", class_ids::MARS_RESOURCE), + ("ogit/MARS/Software", class_ids::MARS_SOFTWARE), + ("ogit/MARS/Machine", class_ids::MARS_MACHINE), + ("ogit/Automation/KnowledgeItem", class_ids::KNOWLEDGE_ITEM), + ( + "ogit/Automation/MARSNodeTemplate", + class_ids::MARS_NODE_TEMPLATE, + ), + ("ogit/Automation/ActionHandler", class_ids::ACTION_HANDLER), + ( + "ogit/Automation/ActionApplicability", + class_ids::ACTION_APPLICABILITY, + ), + ("ogit/Automation/Trigger", class_ids::AUTOMATION_TRIGGER), +]; + #[cfg(test)] mod tests { use super::*; @@ -759,6 +798,49 @@ mod tests { assert_eq!(HubSpoPort::class_id("Team"), Some(class_ids::PROJECT_ACTOR)); } + #[test] + fn hiro_classview_composes_canon_high_custom_low() { + assert_eq!(HiroPort::NAMESPACE, "HIRO"); + assert_eq!(HiroPort::BRIDGE_ID, "hiro"); + assert_eq!(HiroPort::classview(), 0x000C); + assert_eq!(HiroPort::class_id("ogit/MARS/Machine"), Some(0x0C04)); + assert_eq!(HiroPort::class_id("ogit/Automation/Trigger"), Some(0x0C09)); + let machine = render_classid(HiroPort::APP_PREFIX, class_ids::MARS_MACHINE); + assert_eq!(machine, 0x0C04_000C); + assert_eq!(concept_of(machine), 0x0C04); + assert_eq!(app_of(machine), 0x000C); + // The wire name is exact: no case folding, no OGIT CURIE spelling. + assert_eq!(HiroPort::class_id("ogit/mars/machine"), None); + assert_eq!(HiroPort::class_id("ogit.MARS:Machine"), None); + // MAID stays unminted, so HIRO's MAID type does not resolve. + assert_eq!(HiroPort::class_id("ogit/Automation/MAID"), None); + } + + /// The port covers exactly the automation domain: every `0x0CXX` concept + /// has its HIRO wire name, and no alias leaves the domain. A tenth + /// automation concept fails this until it gets a wire name or a reason + /// not to have one. + #[test] + fn hiro_covers_exactly_the_automation_domain() { + use crate::{ConceptDomain, canonical_concept_domain, concepts_in_domain}; + let domain: Vec<(&str, u16)> = concepts_in_domain(ConceptDomain::Automation).collect(); + assert_eq!(domain.len(), 9); + for (concept, id) in &domain { + assert!( + HIRO_ALIASES.iter().any(|(_, a)| a == id), + "{concept} (0x{id:04X}) has no HIRO wire name", + ); + } + for (name, id) in HIRO_ALIASES { + assert_eq!( + canonical_concept_domain(*id), + ConceptDomain::Automation, + "{name} resolves outside the automation domain", + ); + } + assert_eq!(HIRO_ALIASES.len(), domain.len()); + } + #[test] fn spear_classview_composes_canon_high_custom_low() { assert_eq!(SpearPort::class_id("Email"), Some(0x0B05)); @@ -1390,6 +1472,7 @@ mod tests { WeatherNextPort::APP_PREFIX, SpearPort::APP_PREFIX, HubSpoPort::APP_PREFIX, + HiroPort::APP_PREFIX, ] { assert_ne!( prefix, 0x1000, @@ -1476,6 +1559,7 @@ mod tests { WeatherNextPort::APP_PREFIX, SpearPort::APP_PREFIX, HubSpoPort::APP_PREFIX, + HiroPort::APP_PREFIX, ]; let mut sorted = prefixes; sorted.sort_unstable(); diff --git a/docs/APP-CLASS-CODEBOOK-LAYOUT.md b/docs/APP-CLASS-CODEBOOK-LAYOUT.md index 618089c0..b8bdf756 100644 --- a/docs/APP-CLASS-CODEBOOK-LAYOUT.md +++ b/docs/APP-CLASS-CODEBOOK-LAYOUT.md @@ -183,6 +183,7 @@ materialised until the app mints its first private class). | `0x0009` | WeatherNext / weathernext-rs | `0x04` weather / atmosphere | **no** — maps onto core | | `0x000A` | Spear (mail hub) | `0x0B` IAM (`email` `0x0B05`) + `0x08` (`document`) | **no** — maps onto core | | `0x000B` | HubSPO / hubspo-rs (CRM) | `0x05` crm (15 concepts) + aliases onto `0x0B05` email, `0x0207` product, `0x0201` line item, `0x0202` commercial document, `0x0205` payment record, `0x0203` tax policy, `0x0104` project actor | **no** — maps onto core | +| `0x000C` | HIRO / Bardioc-rs (IT automation) | `0x0C` automation: HIRO's nine `ogit/_type` wire names (`ogit/MARS/Application` … `ogit/Automation/Trigger`) onto `0x0C01`–`0x0C09`; `ogit/Automation/MAID` unaliased, no concept minted | **no** — maps onto core | | `0x00A0` | (reserved) future app block | — | — | > **OpenProject (`0x0001`) and Redmine (`0x0007`) are the showcase:** diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index c01054c2..1eea8803 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -268,6 +268,12 @@ two halves of a cell. ADR‑026 names the cascade that ties them. |---|---|:--:|:--:|---|---| | D‑CRM‑DOMAIN | class domain byte `0x05` = **CRM**: 15 concepts `crm_contact 0x0501` … `crm_custom_record 0x050F` (contact, company, deal, ticket, lead, pipeline, pipeline stage, call, meeting, task, note, list, campaign, sequence, custom record). Each passed METHOD ∧ STORAGE ∧ STRUCTURE (STRUCTURE = rendered as a distinct item type on some screen; [H] until the consumer's screen‑graph oracle runs). Ten HubSpot objects **converge** onto existing core concepts instead of minting (email, product, line item, quote/invoice/order → commercial document, payment, tax, user/team → project actor). Custom objects and custom properties are tenant data: they share the one `crm_custom_record` concept and are never minted. **Not the recipe byte:** `RecipeConceptId` family `0x05` (Scope) is a separate typed address space and stays reserved mint‑on‑emit (E‑RECIPE‑FAMILIES‑MINT‑ON‑EMIT). `crm_actions` is **deferred**: no capability rows are declared until the consumer implements them (the `geo_actions` rule — declared surface nobody implements is fiction). | G (codebook + port + tests) / H (STRUCTURE axis) | CODED | `crates/ogar-vocab` (`CODEBOOK`, `ConceptDomain::Crm`, `class_ids::CRM_*`, `ports::HubSpoPort`); mirror in lance‑graph `ogar_codebook.rs` | APP‑CLASS‑CODEBOOK‑LAYOUT §2; HOTPLUG‑MIGRATION‑GUIDE | +### 2.13 HIRO port (2026‑10‑10) — consumer Bardioc-rs, port `0x000C` + +| ID | Shape | Grade | Status | Home | Deps | +|---|---|:--:|:--:|---|---| +| D‑HIRO‑PORT | `HiroPort` at app prefix `0x000C` maps the `ogit/_type` strings HIRO puts on the wire (`ogit/MARS/Application`, `/Resource`, `/Software`, `/Machine`; `ogit/Automation/KnowledgeItem`, `/MARSNodeTemplate`, `/ActionHandler`, `/ActionApplicability`, `/Trigger`) onto the nine `0x0CXX` automation concepts, so a HIRO node dispatches on a `u16` instead of a string match (`HIRO-IN-CLASSES.md` §3.1). **Nothing is minted.** `ogit/Automation/MAID` deliberately has no alias: MAID has no concept and no workload needs one, and a row minted before its falsifier passes is enum explosion (`ogar-r2il`). **DECISION (2026‑10‑10):** Bardioc-rs meets spear through ports, not code — spear's only public contract is `SpearPort` (`0x000A`), so both sides share classids. SCOPE: the port only; no HIRO capability rows. REVISIT WHEN: a workload needs MAID, or a tenth automation concept is minted (the coverage test fails until it gets a wire name). | G (port + tests) | CODED | `crates/ogar-vocab` (`ports::HiroPort`, `HIRO_ALIASES`); lance‑graph mirror `AppPrefix::Hiro` in `ogar_codebook.rs` | HIRO‑IN‑CLASSES §3.1; APP‑CLASS‑CODEBOOK‑LAYOUT §2 | + --- ## 3. The materialization pipeline — what's ready, what's blocked