From c6a77975d1d84cf55061324a7b34f155d6ece64c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:37:18 +0000 Subject: [PATCH] Mint the CRM domain (0x05XX) and HubSpoPort (0x000B) Adds ConceptDomain::Crm at high byte 0x05 with 15 concepts, crm_contact 0x0501 through crm_custom_record 0x050F, each with a class_ids constant, an identity-only builder, and a row in all_canonical_classes. HubSpoPort (app prefix 0x000B) aliases the 15 CRM concepts and converges ten more HubSpot objects onto existing core concepts (email, product, line item, commercial document, payment record, tax policy, project actor) instead of minting. Custom objects and custom properties stay tenant data: they share the one crm_custom_record concept. No crm_actions rows yet; capabilities are declared once the consumer implements them, as with geo_actions. The recipe family bytes 0x05/0x06 (RecipeConceptId) are a separate typed address space and stay reserved mint-on-emit; the board row that mixed the two spaces is corrected, and Weather (0x04) is listed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPcakjk7ioQsnasmNDtc5z --- .claude/board/LATEST_STATE.md | 5 +- crates/ogar-class-view/src/lib.rs | 31 +++ crates/ogar-vocab/src/lib.rs | 341 +++++++++++++++++++++++++++++- crates/ogar-vocab/src/ports.rs | 98 +++++++++ docs/APP-CLASS-CODEBOOK-LAYOUT.md | 7 +- docs/DISCOVERY-MAP.md | 6 + 6 files changed, 478 insertions(+), 10 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 11d97ce7..994eebf1 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -78,7 +78,10 @@ table drops for brevity. | `0x01` | ProjectMgmt | OP ↔ Redmine. | | `0x02` | Commerce | Billing/ERP, OSB ↔ Odoo. | | `0x03` | Ontology | OBO biomedical reference (MONDO/HPO/Uberon/PATO/RO). Carries zero shared-vocab rows — concept ids live in `ogar-obo` itself, not the shared codebook. See `TECH_DEBT.md` for the collision history inside this domain. | -| `0x04`–`0x06` | **Unassigned** | `0x05` = Scope-kind, `0x06` = Concern-kind — RESERVED but mint-on-emit only (EPIPHANIES 2026-07-05 E-RECIPE-FAMILIES-MINT-ON-EMIT); no variant, no concept, no codebook row until the emit seam exists. | +| `0x04` | Weather | Forecast + atmospheric reference cells (consumer weathernext-rs). | +| `0x05` | Crm | Customer relationship management, 15 concepts `0x0501`–`0x050F` (consumer hubspo-rs, port `0x000B`). | +| `0x06` | **Unassigned** | — | +| _recipe space_ | — | `RecipeConceptId` is a separate, typed address space: its family bytes `0x05` (Scope) / `0x06` (Concern) stay RESERVED mint-on-emit (E-RECIPE-FAMILIES-MINT-ON-EMIT) and do not collide with class domains `0x05`/`0x06`. This table previously mixed the two. | | `0x07` | Osint | Open-source intelligence. Zero shared-vocab rows (reserved posture). | | `0x08` | Ocr | Optical character recognition / document extraction. | | `0x09` | Health | Clinical/patient/care (PHI). | diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index 31ca56d9..cfda05bd 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -84,6 +84,21 @@ use ogar_vocab::{ charset, commercial_document, commercial_line_item, + crm_call, + crm_campaign, + crm_company, + crm_contact, + crm_custom_record, + crm_deal, + crm_lead, + crm_list, + crm_meeting, + crm_note, + crm_pipeline, + crm_pipeline_stage, + crm_sequence, + crm_task, + crm_ticket, currency_policy, diagnosis, document, @@ -233,6 +248,22 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> { // manifest, not promoted into the shared OGAR schema. ("weather_cell", weather_cell()), ("weather_static_cell", weather_static_cell()), + // ── 0x05XX — CRM (identity only; properties are tenant ClassView data) ── + ("crm_contact", crm_contact()), + ("crm_company", crm_company()), + ("crm_deal", crm_deal()), + ("crm_ticket", crm_ticket()), + ("crm_lead", crm_lead()), + ("crm_pipeline", crm_pipeline()), + ("crm_pipeline_stage", crm_pipeline_stage()), + ("crm_call", crm_call()), + ("crm_meeting", crm_meeting()), + ("crm_task", crm_task()), + ("crm_note", crm_note()), + ("crm_list", crm_list()), + ("crm_campaign", crm_campaign()), + ("crm_sequence", crm_sequence()), + ("crm_custom_record", crm_custom_record()), // ── 0x08XX — OCR (container kinds; content stays in content stores) ── ("unicharset", unicharset()), ("recoder", recoder()), diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 2c95c523..503451ad 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1106,7 +1106,7 @@ impl Class { /// 0x02XX commerce / ERP (OSB ↔ Odoo cross-curator) /// 0x03XX Ontology (OBO biomedical reference: MONDO/HPO/Uberon/PATO/RO — zero rows here; concepts in ogar-obo) /// 0x04XX Weather / Atmosphere (forecast + atmospheric reference cells) -/// 0x05XX unassigned +/// 0x05XX CRM (customer relationship management; consumer hubspo-rs) /// 0x06XX unassigned /// 0x07XX reserved: OSINT /// 0x08XX OCR (container kinds: unicharset/recoder/charset) @@ -1240,6 +1240,26 @@ const CODEBOOK: &[(&str, u16)] = &[ // ClassView selection remains in the low u16 of the full classid. ("weather_cell", 0x0401), ("weather_static_cell", 0x0402), + // ── 0x05XX — CRM domain (customer relationship management) ── + // Shared CRM meanings, first consumed by hubspo-rs (port `0x000B`). + // `0x0500` is the domain root, never a concept. Inside the business + // band 0x00-0x0F per the altitude ruling (2026-08-18). The recipe-family + // bytes 0x05/0x06 in `recipe.rs` are a different numbering. + ("crm_contact", 0x0501), + ("crm_company", 0x0502), + ("crm_deal", 0x0503), + ("crm_ticket", 0x0504), + ("crm_lead", 0x0505), + ("crm_pipeline", 0x0506), + ("crm_pipeline_stage", 0x0507), + ("crm_call", 0x0508), + ("crm_meeting", 0x0509), + ("crm_task", 0x050A), + ("crm_note", 0x050B), + ("crm_list", 0x050C), + ("crm_campaign", 0x050D), + ("crm_sequence", 0x050E), + ("crm_custom_record", 0x050F), // ── 0x07XX — OSINT domain: ZERO vocabulary rows BY DESIGN (operator // ruling 2026-07-02, corrects PR #145's two hallucinated concept mints // `osint_system@0x0700` / `osint_person@0x0701`). Within the OSINT domain @@ -1485,6 +1505,15 @@ pub enum ConceptDomain { /// `0x04XX` — Weather / Atmosphere. Shared atmospheric and forecast-grid /// concepts; public environmental reference data, not an OSM extension. Weather, + /// `0x05XX` — CRM (customer relationship management): contacts, + /// companies, deals, tickets, leads, pipelines, engagements, lists, + /// campaigns, sequences and tenant custom records. Business ontology, + /// so it sits inside the `0x00`–`0x0F` band (altitude ruling, + /// 2026-08-18). First consumer: hubspo-rs (`HubSpoPort`, `0x000B`). + /// Aliased HubSpot objects (emails, products, line items, commercial + /// documents, payments, tax rates, users, teams) stay on their existing + /// concepts in other domains. + Crm, /// `0x07XX` — OSINT (open-source intelligence). Osint, /// `0x08XX` — OCR (optical character recognition / document @@ -1760,7 +1789,7 @@ pub enum ConceptDomain { /// reads the compartment off the key (`classid >> 24`) instead of looking /// it up. Form, - /// Any high-byte slot not yet assigned a domain (`0x05XX`–`0x06XX`, + /// Any high-byte slot not yet assigned a domain (`0x06XX`, /// `0x10XX`–`0x16XX`, `0x18XX`–`0xBFXX`, `0xC2XX`–`0xC3XX`, /// `0xC5XX`, `0xC7XX`+). Unassigned, @@ -1776,6 +1805,7 @@ pub fn canonical_concept_domain(id: u16) -> ConceptDomain { 0x02 => ConceptDomain::Commerce, 0x03 => ConceptDomain::Ontology, 0x04 => ConceptDomain::Weather, + 0x05 => ConceptDomain::Crm, 0x07 => ConceptDomain::Osint, 0x08 => ConceptDomain::Ocr, 0x09 => ConceptDomain::Health, @@ -1846,6 +1876,7 @@ pub fn source_domain_concept(source_domain: &str) -> Option { match source_domain { "project" => Some(ConceptDomain::ProjectMgmt), "erp" | "german-erp" => Some(ConceptDomain::Commerce), + "crm" => Some(ConceptDomain::Crm), _ => None, } } @@ -2057,6 +2088,39 @@ pub mod class_ids { /// geography-derived weather context, distinct from dynamic fields. pub const WEATHER_STATIC_CELL: u16 = 0x0402; + // ── 0x05XX — CRM domain (customer relationship management) ── + + /// `crm_contact` (`0x0501`) — a person the organization has a relationship with (HubSpot `contacts`, type `0-1`). + pub const CRM_CONTACT: u16 = 0x0501; + /// `crm_company` (`0x0502`) — an organization the CRM tracks (HubSpot `companies`, type `0-2`). Kept apart from `crm_contact`: person and organization are two record types joined by a labelled association. + pub const CRM_COMPANY: u16 = 0x0502; + /// `crm_deal` (`0x0503`) — a revenue opportunity moving through a pipeline (HubSpot deals, type `0-3`). + pub const CRM_DEAL: u16 = 0x0503; + /// `crm_ticket` (`0x0504`) — a customer service request moving through a pipeline (HubSpot `tickets`, type `0-5`; OGIT `CustomerSupport/Ticket`). No project semantics, unlike `project_work_item`. + pub const CRM_TICKET: u16 = 0x0504; + /// `crm_lead` (`0x0505`) — a qualification record associated to a contact and/or company (HubSpot `leads`, type `0-136`). + pub const CRM_LEAD: u16 = 0x0505; + /// `crm_pipeline` (`0x0506`) — an ordered set of stages bound to one object type. + pub const CRM_PIPELINE: u16 = 0x0506; + /// `crm_pipeline_stage` (`0x0507`) — one stage of exactly one pipeline; authorized per stage. Stage values are rows, never attributes of this class. + pub const CRM_PIPELINE_STAGE: u16 = 0x0507; + /// `crm_call` (`0x0508`) — a logged call engagement (HubSpot `calls`, type `0-48`). + pub const CRM_CALL: u16 = 0x0508; + /// `crm_meeting` (`0x0509`) — a meeting engagement (HubSpot `meetings`, type `0-47`). A calendar event backs it; it is not the calendar event. + pub const CRM_MEETING: u16 = 0x0509; + /// `crm_task` (`0x050A`) — a to-do assigned to a user (HubSpot `tasks`, type `0-27`). No project semantics, unlike `project_work_item`. + pub const CRM_TASK: u16 = 0x050A; + /// `crm_note` (`0x050B`) — an owned note associated to one or more records and shown on their timelines (HubSpot `notes`, type `0-46`). Not `project_comment`. + pub const CRM_NOTE: u16 = 0x050B; + /// `crm_list` (`0x050C`) — a list definition (owner, filter, static or active). Membership is a mask, never one row per member. + pub const CRM_LIST: u16 = 0x050C; + /// `crm_campaign` (`0x050D`) — a marketing campaign, the attribution root for marketing assets. + pub const CRM_CAMPAIGN: u16 = 0x050D; + /// `crm_sequence` (`0x050E`) — a shared, permissioned sequence of outreach steps. The steps are `ActionDef`s and data, never attributes of this class. + pub const CRM_SEQUENCE: u16 = 0x050E; + /// `crm_custom_record` (`0x050F`) — a record of a tenant-defined custom object. One concept for every tenant schema; schemas are data and ClassViews. Default deny: access is row scope plus field mask, never a class grant alone. + pub const CRM_CUSTOM_RECORD: u16 = 0x050F; + // ── 0x08XX — OCR domain (document extraction; the Tesseract-rs arc) ── // Class-level container KINDS only: the concept slots name the container // types the Core resolves — never their content. The 112 unichars of a @@ -2378,6 +2442,22 @@ pub mod class_ids { // 0x04XX — Weather / Atmosphere ("weather_cell", WEATHER_CELL), ("weather_static_cell", WEATHER_STATIC_CELL), + // 0x05XX — CRM + ("crm_contact", CRM_CONTACT), + ("crm_company", CRM_COMPANY), + ("crm_deal", CRM_DEAL), + ("crm_ticket", CRM_TICKET), + ("crm_lead", CRM_LEAD), + ("crm_pipeline", CRM_PIPELINE), + ("crm_pipeline_stage", CRM_PIPELINE_STAGE), + ("crm_call", CRM_CALL), + ("crm_meeting", CRM_MEETING), + ("crm_task", CRM_TASK), + ("crm_note", CRM_NOTE), + ("crm_list", CRM_LIST), + ("crm_campaign", CRM_CAMPAIGN), + ("crm_sequence", CRM_SEQUENCE), + ("crm_custom_record", CRM_CUSTOM_RECORD), // 0x07XX — OSINT: ZERO vocabulary rows BY DESIGN (operator ruling // 2026-07-02; see the CODEBOOK 0x07XX section note). No entries // follow — OGAR vocabulary carries no OSINT concept names. @@ -3315,6 +3395,22 @@ pub fn all_promoted_classes() -> Vec { // 0x08XX — OCR arm (9 container kinds), in class_ids::ALL order. weather_cell(), weather_static_cell(), + // 0x05XX — CRM arm (15 concepts), in class_ids::ALL order. + crm_contact(), + crm_company(), + crm_deal(), + crm_ticket(), + crm_lead(), + crm_pipeline(), + crm_pipeline_stage(), + crm_call(), + crm_meeting(), + crm_task(), + crm_note(), + crm_list(), + crm_campaign(), + crm_sequence(), + crm_custom_record(), unicharset(), recoder(), charset(), @@ -5019,6 +5115,147 @@ pub fn weather_static_cell() -> Class { c } +// ───────────────────────────────────────────────────────────────────── +// 0x05XX — CRM canonical builders. Identity only: properties, stages, +// sequence steps and list filters are tenant data and ClassView positions, +// and behaviour is ActionDefs — the address carries none of it. +// ───────────────────────────────────────────────────────────────────── + +/// `crm_contact` (`0x0501`) — a person the organization has a relationship with (HubSpot `contacts`, type `0-1`). +#[must_use] +pub fn crm_contact() -> Class { + let mut c = Class::new("CrmContact"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_contact".to_string()); + c +} + +/// `crm_company` (`0x0502`) — an organization the CRM tracks (HubSpot `companies`, type `0-2`). Kept apart from `crm_contact`: person and organization are two record types joined by a labelled association. +#[must_use] +pub fn crm_company() -> Class { + let mut c = Class::new("CrmCompany"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_company".to_string()); + c +} + +/// `crm_deal` (`0x0503`) — a revenue opportunity moving through a pipeline (HubSpot deals, type `0-3`). +#[must_use] +pub fn crm_deal() -> Class { + let mut c = Class::new("CrmDeal"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_deal".to_string()); + c +} + +/// `crm_ticket` (`0x0504`) — a customer service request moving through a pipeline (HubSpot `tickets`, type `0-5`; OGIT `CustomerSupport/Ticket`). No project semantics, unlike `project_work_item`. +#[must_use] +pub fn crm_ticket() -> Class { + let mut c = Class::new("CrmTicket"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_ticket".to_string()); + c +} + +/// `crm_lead` (`0x0505`) — a qualification record associated to a contact and/or company (HubSpot `leads`, type `0-136`). +#[must_use] +pub fn crm_lead() -> Class { + let mut c = Class::new("CrmLead"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_lead".to_string()); + c +} + +/// `crm_pipeline` (`0x0506`) — an ordered set of stages bound to one object type. +#[must_use] +pub fn crm_pipeline() -> Class { + let mut c = Class::new("CrmPipeline"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_pipeline".to_string()); + c +} + +/// `crm_pipeline_stage` (`0x0507`) — one stage of exactly one pipeline; authorized per stage. Stage values are rows, never attributes of this class. +#[must_use] +pub fn crm_pipeline_stage() -> Class { + let mut c = Class::new("CrmPipelineStage"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_pipeline_stage".to_string()); + c +} + +/// `crm_call` (`0x0508`) — a logged call engagement (HubSpot `calls`, type `0-48`). +#[must_use] +pub fn crm_call() -> Class { + let mut c = Class::new("CrmCall"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_call".to_string()); + c +} + +/// `crm_meeting` (`0x0509`) — a meeting engagement (HubSpot `meetings`, type `0-47`). A calendar event backs it; it is not the calendar event. +#[must_use] +pub fn crm_meeting() -> Class { + let mut c = Class::new("CrmMeeting"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_meeting".to_string()); + c +} + +/// `crm_task` (`0x050A`) — a to-do assigned to a user (HubSpot `tasks`, type `0-27`). No project semantics, unlike `project_work_item`. +#[must_use] +pub fn crm_task() -> Class { + let mut c = Class::new("CrmTask"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_task".to_string()); + c +} + +/// `crm_note` (`0x050B`) — an owned note associated to one or more records and shown on their timelines (HubSpot `notes`, type `0-46`). Not `project_comment`. +#[must_use] +pub fn crm_note() -> Class { + let mut c = Class::new("CrmNote"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_note".to_string()); + c +} + +/// `crm_list` (`0x050C`) — a list definition (owner, filter, static or active). Membership is a mask, never one row per member. +#[must_use] +pub fn crm_list() -> Class { + let mut c = Class::new("CrmList"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_list".to_string()); + c +} + +/// `crm_campaign` (`0x050D`) — a marketing campaign, the attribution root for marketing assets. +#[must_use] +pub fn crm_campaign() -> Class { + let mut c = Class::new("CrmCampaign"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_campaign".to_string()); + c +} + +/// `crm_sequence` (`0x050E`) — a shared, permissioned sequence of outreach steps. The steps are `ActionDef`s and data, never attributes of this class. +#[must_use] +pub fn crm_sequence() -> Class { + let mut c = Class::new("CrmSequence"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_sequence".to_string()); + c +} + +/// `crm_custom_record` (`0x050F`) — a record of a tenant-defined custom object. One concept for every tenant schema; schemas are data and ClassViews. Default deny: access is row scope plus field mask, never a class grant alone. +#[must_use] +pub fn crm_custom_record() -> Class { + let mut c = Class::new("CrmCustomRecord"); + c.language = Language::Unknown; + c.canonical_concept = Some("crm_custom_record".to_string()); + c +} + // ───────────────────────────────────────────────────────────────────── // `email` (`0x0B05`, IAM domain). Identity only; the columns live in the // consumer's ClassView (spear's `messages` Lance schema). @@ -6049,10 +6286,11 @@ mod tests { // lives in ogar-obo; plug-and-play, never pulls into ERP consumers). assert_eq!(canonical_concept_domain(0x0300), ConceptDomain::Ontology); assert_eq!(canonical_concept_domain(0x03AB), ConceptDomain::Ontology); - // Weather / Atmosphere block (0x04), then still-unassigned 0x05-0x06. + // Weather / Atmosphere block (0x04), CRM (0x05), then still-unassigned 0x06. assert_eq!(canonical_concept_domain(0x0400), ConceptDomain::Weather); assert_eq!(canonical_concept_domain(0x0401), ConceptDomain::Weather); - assert_eq!(canonical_concept_domain(0x0500), ConceptDomain::Unassigned); + assert_eq!(canonical_concept_domain(0x0500), ConceptDomain::Crm); + assert_eq!(canonical_concept_domain(0x05FF), ConceptDomain::Crm); assert_eq!(canonical_concept_domain(0x0600), ConceptDomain::Unassigned); // HR block (0x0D). assert_eq!(canonical_concept_domain(0x0D00), ConceptDomain::HR); @@ -6279,6 +6517,7 @@ mod tests { "OCR domain set drift — re-sync the consumer coverage gate", ); assert_eq!(concepts_in_domain(ConceptDomain::HR).count(), 4); + assert_eq!(concepts_in_domain(ConceptDomain::Crm).count(), 15); assert_eq!(concepts_in_domain(ConceptDomain::Commerce).count(), 11); assert_eq!(concepts_in_domain(ConceptDomain::ProjectMgmt).count(), 26); assert_eq!(concepts_in_domain(ConceptDomain::Anatomy).count(), 4); @@ -6613,6 +6852,7 @@ mod tests { Some(ConceptDomain::ProjectMgmt) ); assert_eq!(source_domain_concept("erp"), Some(ConceptDomain::Commerce)); + assert_eq!(source_domain_concept("crm"), Some(ConceptDomain::Crm)); assert_eq!( source_domain_concept("german-erp"), Some(ConceptDomain::Commerce) @@ -7241,7 +7481,96 @@ mod mail_classid_mint_tests { const { assert!(class_ids::EMAIL > class_ids::AUTH_ORY_KETO) }; assert_eq!(canonical_concept_id("email"), Some(0x0B05)); assert_eq!(email().canonical_id(), Some(class_ids::EMAIL)); - // No Mail domain was opened for it. - assert_eq!(canonical_concept_domain(0x0501), ConceptDomain::Unassigned); + // No Mail domain was opened for it: 0x05 went to CRM, and email + // stays in Auth. + assert_eq!(canonical_concept_domain(0x0501), ConceptDomain::Crm); + assert_ne!( + canonical_concept_domain(class_ids::EMAIL), + ConceptDomain::Crm + ); + } +} + +#[cfg(test)] +mod crm_classid_mint_tests { + use super::*; + + #[test] + fn crm_concepts_fill_0x0501_to_0x050f_in_order() { + let crm: Vec<(&str, u16)> = concepts_in_domain(ConceptDomain::Crm).collect(); + let names: Vec<&str> = crm.iter().map(|(n, _)| *n).collect(); + assert_eq!( + names, + [ + "crm_contact", + "crm_company", + "crm_deal", + "crm_ticket", + "crm_lead", + "crm_pipeline", + "crm_pipeline_stage", + "crm_call", + "crm_meeting", + "crm_task", + "crm_note", + "crm_list", + "crm_campaign", + "crm_sequence", + "crm_custom_record", + ], + "CRM domain set drift — re-sync the hubspo-rs coverage gate", + ); + for (i, (_, id)) in crm.iter().enumerate() { + assert_eq!(*id, 0x0501 + i as u16); + } + // The domain root is never a concept. + assert!(crm.iter().all(|(_, id)| *id != 0x0500)); + } + + #[test] + fn crm_sits_in_the_business_band_and_clear_of_the_v3_marker() { + assert_eq!( + canonical_concept_domain(class_ids::CRM_CONTACT), + ConceptDomain::Crm + ); + assert_eq!(class_ids::CRM_CONTACT >> 12, 0, "CRM is business ontology"); + // 0x1000 (the V3 marker slot) stays outside every domain. + assert_eq!(canonical_concept_domain(0x1000), ConceptDomain::Unassigned); + } + + /// The address carries no behaviour: every CRM class is identity only. + /// Stage values, sequence steps, list filters and properties are data. + #[test] + fn crm_classes_are_identity_only() { + for c in [ + crm_contact(), + crm_company(), + crm_deal(), + crm_ticket(), + crm_lead(), + crm_pipeline(), + crm_pipeline_stage(), + crm_call(), + crm_meeting(), + crm_task(), + crm_note(), + crm_list(), + crm_campaign(), + crm_sequence(), + crm_custom_record(), + ] { + let name = c.canonical_concept.clone().expect("canonical concept"); + assert_eq!( + canonical_concept_domain(c.canonical_id().expect(&name)), + ConceptDomain::Crm + ); + // Identical to a bare class with only name, language and concept + // set: no attributes, enums, associations, scopes, callbacks or + // validations — the whole struct, not a hand-picked field list. + let mut bare = Class::new(c.name.clone()); + bare.language = Language::Unknown; + bare.canonical_concept = Some(name.clone()); + assert_eq!(c, bare, "{name} carries more than identity"); + } } } diff --git a/crates/ogar-vocab/src/ports.rs b/crates/ogar-vocab/src/ports.rs index 16f504b3..98745afa 100644 --- a/crates/ogar-vocab/src/ports.rs +++ b/crates/ogar-vocab/src/ports.rs @@ -663,11 +663,102 @@ pub const SPEAR_ALIASES: &[(&str, u16)] = &[ ("DriveItem", class_ids::DOCUMENT), ]; +// ── HubSpot (hubspo-rs, the CRM) port ──────────────────────────────── + +/// hubspo-rs's `PortSpec`: HubSpot's object names onto the `0x05XX` CRM +/// concepts, and the HubSpot objects that converge on existing concepts +/// (emails, products, commercial documents, payments, tax rates, users and +/// teams) onto those. `0x000B` is hubspo-rs's render skin. Names are +/// port-scoped: nothing here feeds the global alias normalizer. +pub struct HubSpoPort; + +impl PortSpec for HubSpoPort { + const NAMESPACE: &'static str = "HubSpo"; + const BRIDGE_ID: &'static str = "hubspo"; + const APP_PREFIX: u16 = 0x000B; + fn aliases() -> &'static [(&'static str, u16)] { + HUBSPO_ALIASES + } +} + +/// hubspo-rs's names for its CRM concepts and the shared concepts it uses. +pub const HUBSPO_ALIASES: &[(&str, u16)] = &[ + // ── CRM concepts (0x05XX) ── + ("Contact", class_ids::CRM_CONTACT), + ("Company", class_ids::CRM_COMPANY), + ("Deal", class_ids::CRM_DEAL), + ("Ticket", class_ids::CRM_TICKET), + ("Lead", class_ids::CRM_LEAD), + ("Pipeline", class_ids::CRM_PIPELINE), + ("PipelineStage", class_ids::CRM_PIPELINE_STAGE), + ("Call", class_ids::CRM_CALL), + ("Meeting", class_ids::CRM_MEETING), + ("Task", class_ids::CRM_TASK), + ("Note", class_ids::CRM_NOTE), + ("List", class_ids::CRM_LIST), + ("Campaign", class_ids::CRM_CAMPAIGN), + ("Sequence", class_ids::CRM_SEQUENCE), + ("CustomObject", class_ids::CRM_CUSTOM_RECORD), + // ── Shared concepts in other domains ── + ("Email", class_ids::EMAIL), + ("Product", class_ids::PRODUCT), + ("LineItem", class_ids::COMMERCIAL_LINE_ITEM), + ("Quote", class_ids::COMMERCIAL_DOCUMENT), + ("Invoice", class_ids::COMMERCIAL_DOCUMENT), + ("Order", class_ids::COMMERCIAL_DOCUMENT), + ("CommercePayment", class_ids::PAYMENT_RECORD), + ("TaxRate", class_ids::TAX_POLICY), + ("User", class_ids::PROJECT_ACTOR), + ("Team", class_ids::PROJECT_ACTOR), +]; + #[cfg(test)] mod tests { use super::*; use crate::app::{app_of, concept_of, render_classid}; + #[test] + fn hubspo_classview_composes_canon_high_custom_low() { + assert_eq!(HubSpoPort::NAMESPACE, "HubSpo"); + assert_eq!(HubSpoPort::BRIDGE_ID, "hubspo"); + assert_eq!(HubSpoPort::classview(), 0x000B); + assert_eq!(HubSpoPort::class_id("Contact"), Some(0x0501)); + assert_eq!(HubSpoPort::class_id("CustomObject"), Some(0x050F)); + let contact = render_classid(HubSpoPort::APP_PREFIX, class_ids::CRM_CONTACT); + assert_eq!(contact, 0x0501_000B); + assert_eq!(concept_of(contact), 0x0501); + assert_eq!(app_of(contact), 0x000B); + // Unknown names do not resolve. + assert_eq!(HubSpoPort::class_id("contacts"), None); + } + + /// Every CRM concept is reachable through the port, and every alias that + /// is not a CRM concept converges on an existing concept in its own + /// domain — the port never re-homes a shared concept into CRM. + #[test] + fn hubspo_covers_every_crm_concept_and_converges_the_rest() { + use crate::{ConceptDomain, canonical_concept_domain, concepts_in_domain}; + for (concept, id) in concepts_in_domain(ConceptDomain::Crm) { + assert!( + HUBSPO_ALIASES.iter().any(|(_, a)| *a == id), + "{concept} (0x{id:04X}) has no HubSpo name", + ); + } + let shared: Vec<(&str, u16)> = HUBSPO_ALIASES + .iter() + .copied() + .filter(|(_, id)| canonical_concept_domain(*id) != ConceptDomain::Crm) + .collect(); + assert_eq!(shared.len(), 10); + assert_eq!(HubSpoPort::class_id("Email"), SpearPort::class_id("Email")); + assert_eq!( + HubSpoPort::class_id("Invoice"), + Some(class_ids::COMMERCIAL_DOCUMENT) + ); + assert_eq!(HubSpoPort::class_id("User"), Some(class_ids::PROJECT_ACTOR)); + assert_eq!(HubSpoPort::class_id("Team"), Some(class_ids::PROJECT_ACTOR)); + } + #[test] fn spear_classview_composes_canon_high_custom_low() { assert_eq!(SpearPort::class_id("Email"), Some(0x0B05)); @@ -1295,6 +1386,10 @@ mod tests { SmbPort::APP_PREFIX, HealthcarePort::APP_PREFIX, RedminePort::APP_PREFIX, + OsmPort::APP_PREFIX, + WeatherNextPort::APP_PREFIX, + SpearPort::APP_PREFIX, + HubSpoPort::APP_PREFIX, ] { assert_ne!( prefix, 0x1000, @@ -1378,6 +1473,9 @@ mod tests { SmbPort::APP_PREFIX, OdooPort::APP_PREFIX, OsmPort::APP_PREFIX, + WeatherNextPort::APP_PREFIX, + SpearPort::APP_PREFIX, + HubSpoPort::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 ecc3821e..618089c0 100644 --- a/docs/APP-CLASS-CODEBOOK-LAYOUT.md +++ b/docs/APP-CLASS-CODEBOOK-LAYOUT.md @@ -54,8 +54,8 @@ classid : u32 = [ hi u16 : in-codebook class ] [ lo u16 : APP / codebook name - **`hi u16` (0xDDCC) — in-codebook class id.** Domain byte `DD` + concept byte `CC`, exactly as the codebook encodes today. Within the core codebook (`lo = 0x0000`) the domain bytes are the canonical map - (`0x01` project, `0x02` commerce, `0x07` osint, `0x08` ocr, `0x09` - health, `0x0A` anatomy, `0x0B` auth, `0x0C` automation). Within an + (`0x01` project, `0x02` commerce, `0x04` weather, `0x05` crm, `0x07` osint, + `0x08` ocr, `0x09` health, `0x0A` anatomy, `0x0B` auth, `0x0C` automation). Within an app-private codebook the app owns its own `DD|CC` layout. **The domain byte carries ALTITUDE** (operator, 2026-08-18 — *"Java is an @@ -171,7 +171,7 @@ materialised until the app mints its first private class). | `lo u16` | App / namespace | Core domain(s) it consumes | Private codebook today? | |---|---|---|---| -| `0x0000` | **Shared canonical core** | all (`0x01/02/04/07/08/09` + `0x0A` anatomy + `0x0B` auth + `0x0C` automation) | n/a (this *is* core) | +| `0x0000` | **Shared canonical core** | all (`0x01/02/04/05/07/08/09` + `0x0A` anatomy + `0x0B` auth + `0x0C` automation; `0x05` = CRM) | n/a (this *is* core) | | `0x0001` | OpenProject (openproject-nexgen-rs) | `0x01` project-mgmt | **no** — maps onto core | | `0x0002` | Odoo | `0x02` commerce | **no** — maps onto core (converge `od-ontology`) | | `0x0003` | WoA / woa-rs | `0x02` commerce (work orders) | **no** — maps onto core | @@ -182,6 +182,7 @@ materialised until the app mints its first private class). | `0x0008` | OpenStreetMap (openstreetmap-website-rs) | `0x0F` geo | **no** — maps entirely onto core | | `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 | | `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 97fa8968..1a09af19 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -262,6 +262,12 @@ two halves of a cell. ADR‑026 names the cascade that ties them. | D‑GENOM‑LINEAGE | ancestry ≠ storage version: two‑parent descent needs interval‑labelled edges (tree sequence / ARG); `LanceVersion` and `ScenarioBranch` are single‑parent — H7 falsified | H | EPIPHANY | capstone §11 | — | | D‑GENOM‑FITCH | Fitch parsimony state sets ARE V4 `BaseSet`s (∩ else ∪); candidate missing‑link reconstruction as lane‑parallel nibble algebra | H | IDEA | capstone §12 | D‑GENOM‑DNA2 | +### 2.12 CRM domain (2026‑10‑10) — consumer hubspo-rs, port `0x000B` + +| ID | Shape | Grade | Status | Home | Deps | +|---|---|:--:|:--:|---|---| +| 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 | + --- ## 3. The materialization pipeline — what's ready, what's blocked