diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 0cb4eccdc..a4ca2c968 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -113,6 +113,27 @@ --- +## v1 — Super-Domain RBAC + Multi-Tenancy (authored 2026-05-13) + +**Author:** main thread (Opus 4.7 1M), session 2026-05-13 (branch `claude/lance-datafusion-integration-gv0BF`) +**Status:** Active +**Scope:** 4-level addressing hierarchy (meta-anchors → super domain → OGIT basin → within-basin slot) with explicit byte-sized DTOs, RBAC + multi-tenant Chinese walls wired onto the super-domain boundary. 6 bytes per row (4-byte `TenantId` + 2-byte `OwlIdentity`), inline per-family codebook with label+schema+verbs, single masked DataFusion predicate enforces tenant + super-domain + role + slot in one vector pass. Foundry-parity selling point at the enforcement surface, sub-microsecond hot path. Locks the 2-consumer ticket-system constraint (`hiro-rs` absorbs OSLC-* off-label, `hubspot-rs` is fresh basin) and collapses 4 OSLC-* namespaces into a single Hiro basin with provenance lineage. +**Path:** `.claude/plans/super-domain-rbac-tenancy-v1.md` +**Deliverables:** D-SDR-1..D-SDR-12 (Tier A DTOs / Tier B TTL namespaces / Tier C consumer crates / Tier D compliance + audit / Tier E cross-tenant federation Phase 2) +**Substrate:** Builds on shipped `lance-graph-ontology::namespace::SchemaPtr`, `bridges::OgitBridge` + `BridgeFromRegistry`, `holograph::dntree::WellKnown` (promoted to `SuperDomain` enum), `lance-graph-callcenter::dn_path::DnPath` compression chain, `bgz-tensor::HhtlDEntry` bit-packed-hierarchy pattern, `lance-graph-contract::cam` CAM-PQ codec contract. +**Cross-ref:** `palantir-parity-cascade-v2.md` (this spec adds the enforcement surface), `lance-graph-ontology-v5.md` (this spec sits above v5; v5 unchanged), `GLUE_LAYER_OGIT_TO_OWL_SPEC.md` (source for OWL property characteristics bitfield). +**Open questions:** Foundry ObjectType cross-walk targets, Wikidata QID mappings, audit format choice (JSON Lines / CloudEvents / OTel), DEK rotation cadence, escalation UX, HPO/MONDO multi-member confirmation, slot 0xFF schema-only convention. + +**Correction (2026-05-13):** §13 refinements added (same session). (a) Enforcement composes onto shipped `lance-graph-callcenter::policy::PolicyRewriter` chain + `PolicyKind` taxonomy (RowFilter/ColumnMask/RowEncryption/DifferentialPrivacy/Audit) rather than introducing parallel path — ~30% Tier A LOC reduction. (b) Cross-tenant federation upgraded to A+B+C all accepted; Option C (`EncryptedViewAggregate`) viable now via LanceDB transparent encrypted views, not 2027+ R&D. (c) Audit chain integrity built-in via `MerkleRoot::from_fingerprint` + `ClamPath` from `graph/spo/merkle.rs` (the merkle/DN-path mixing already shipped). (d) Hard-lock requirement formalized: Healthcare ↔ OSINT (and 3 other pairs) get 3 layers of defense — predicate + per-super-domain merkle salt + super-domain-scoped HKDF key derivation. (e) `researcher` role hardened to anonymized-projection-only with k-anonymity floor + DP noise injection on aggregates. New deliverables D-SDR-13..17 added. Open questions on audit format + cross-tenant federation RESOLVED; new open questions on hard-lock partner matrix + per-super-domain DP epsilon + merkle salt rotation cadence. + +**Correction (2026-05-13, fourth commit):** §19 build invariants + SIMD strategy added. Pins: rust 1.94.1, lance =4.0.0, lancedb 0.27.2 (per PR #275). All vectorized ops across D-SDR-1..39 use `ndarray::simd` from the workspace's vendored ndarray fork — single SIMD path, single test surface, single cross-platform behavior contract. Hot-path ops mapped: OwlIdentity bitmask scans, batch MerkleRoot computation, BitSet256 bitwise ops, per-family codebook PQ centroid distance, canonicalization rule application, DataFusion predicate vector composition, ArrowBatchDriftSignal MerkleRoot-of-batch. Tier A LOC drops ~15-25% (scalar fallback paths collapse to ndarray::simd one-liners). Mandatory-ndarray-as-dep promotion (retire `ndarray-hpc` feature flag) is a separate concurrent workstream, NOT in this spec's scope but assumed baseline; Tier A may temporarily ship behind `#[cfg(feature = "ndarray-hpc")]` until the promotion lands. + +**Correction (2026-05-13, third commit):** §18 empirical reality check added after pygithub REST inspection of `AdaWorldAPI/MedCareV2` + `AdaWorldAPI/MedCare-rs@claude/csharp-handoff-docs-L3DF0`. Major findings: (a) The §15-§17 drift bridge concept is already designed and partially scaffolded as `MedCareV2/MedCare_2.0/LanceProbe/` (M1 complete; M2-M6 pending Rust-side endpoints). 8 LanceProbe components (ParityClient/ParityWitness/DriftSink/etc.) map nearly 1:1 to the spec's DTOs. (b) MedCareV2 is overlay-only (copy of MedCare + LanceProbe additions) — cannot be reshaped freely as I assumed; "do NOT refactor" is the explicit constraint. (c) CRITICAL crypto correction: the "3DES" in MedCare's `Crypt.cs:438-451` uses 128-bit truncated key + zero IV + ECB-equivalent + non-standard MD5+RC2 KDF + 62-entry hardcoded password array — cryptographically equivalent to single DES (broken). The migration is NOT 3DES→AES-GCM rewrap; it's Argon2-backfill-on-login per existing `MedCare-rs/docs/AUTH_LEGACY_TRIPLEDES_MIGRATION.md` plan. (d) Only the `u_pwd` column on `praxis_mitarbeiter` uses the 3DES path; rest of the schema is plaintext. D-SDR-27 scope reduces from "decrypt-rewrap pipeline" to "carry ciphertext forward, Argon2-backfill on first login." (e) §15.2 abstract 12-rule determinism table replaced by 6 concrete canonicalization rules from `CSHARP_HANDOFF_PROMPT.md` lines 93-104 (date / decimal / bool / soft-delete / pwd / timestamp). (f) §17.3 Arrow Flight SQL convergence is aspirational end-state; immediate path is HTTP+JSON over JWT (what LanceProbe already targets); Flight SQL is Phase 5+ migration. (g) New deliverables D-SDR-35..39 for medcare-rs side: parity ingest endpoint, dashboard, DTO contracts doc, TripleDES fallback feature flag, telemetry endpoint. M5 is blocked until these land. Resolved 7 prior open questions (audit format, federation, DEK rotation, hard-lock matrix scope, DP epsilon, MedCareV2 reshape, 3DES inventory). 3 new open questions: other columns calling EncryptMessage in MySQL_Connect.cs, DTO contracts for 40+ planned routes, AUTH_LEGACY_TRIPLEDES_MIGRATION.md DRAFT-to-Active blockers. + +**Correction (2026-05-13, second commit):** §14-§17 refinements added (same session). (§14) Meta-bridge extracted from shipped medcare_bridge.rs + sharepoint_bridge.rs harvest, not designed clean-room. New bridges hubspot_bridge.rs + hiro_bridge.rs added as templates; woa_bridge.rs retrofit. Tier F (D-SDR-18..20, 23) + Tier G (D-SDR-21..22) deliverables. (§15) Drift detection initially framed as production parallelbetrieb infrastructure with 12 cross-language determinism rules — substantially refined by §16+§17. (§16) Pre-prod posture corrected per user clarification: nothing in production yet, single 3DES cipher (not 3-cipher chain), one-shot import tool not persistent infrastructure. Zone 3 boundary placement collapses determinism rules from 12 to ~3 (decimal + timestamp + FP aggregate). MerkleRoot-cleartext-beside-ciphertext insight: drift bridge compares without ever decrypting in steady-state production, so encryption uses random nonces (no need for AES-GCM-SIV). MedCare MySQL Struktur reality check (104 tables, all VARCHAR/TEXT/DATETIME, app-layer 3DES not at-rest, schema is purely clinical with billing/tickets in separate WoA/Hiro databases). New deliverables D-SDR-27..30. (§17) Convergence on LanceDB+DataFusion SQL as unified persistence; both Rust (in-process) and C# (Arrow Flight SQL gRPC) clients hit the same DataFusion logical plan layer. Custom Protobuf IDL (D-SDR-20) SUPERSEDED by Arrow Flight SQL — Substrait extension types for OwlIdentity/MerkleRoot/SuperDomain. Drift bridge bounded to Phase 2-3 cutover window, then retires to CI gate. New deliverables D-SDR-31..34. Dropped scope: MySQLAdapterBridge (D-SDR-24), persistent production drift infra, multi-trustee key escrow, C-ABI FFI option, custom Protobuf IDL. §18 deferred pending MCP scope expansion to AdaWorldAPI/MedCare + AdaWorldAPI/MedCareV2 for 3DES column inventory + transcoded shape grep. + +--- + ## v1 — LF Integration Mapping (authored 2026-04-25) **Author:** main thread (Opus 4.7 1M), session 2026-04-25 (branch claude/scenario-world-facade) diff --git a/.claude/plans/super-domain-rbac-tenancy-v1.md b/.claude/plans/super-domain-rbac-tenancy-v1.md new file mode 100644 index 000000000..b571caddd --- /dev/null +++ b/.claude/plans/super-domain-rbac-tenancy-v1.md @@ -0,0 +1,1387 @@ +# Super-Domain RBAC + Multi-Tenancy — v1 + +> **Author:** main thread (Opus 4.7 1M), session 2026-05-13 (branch `claude/lance-datafusion-integration-gv0BF`) +> **Status:** Active +> **Scope:** Lock the 4-level addressing hierarchy (meta-anchors → super domain → OGIT basin → within-basin slot), ship explicit DTOs with byte-sized contracts, and wire RBAC + multi-tenant Chinese walls onto the super-domain boundary. Foundry-parity selling point at the enforcement surface. +> **Path:** `.claude/plans/super-domain-rbac-tenancy-v1.md` +> **Confidence:** Working (architecture); Partial (DOLCE/OWL slot semantics still need probe before per-slot redaction policies harden) + +--- + +## 1 — Why this exists + +The lance-graph workspace already ships substantial OGIT substrate (`lance-graph-ontology::namespace`, `bridges::OgitBridge`, `parse_ttl_directory_with_provenance`, `holograph::dntree`, `bgz-tensor::HhtlDEntry`, `highheelbgz::SpiralAddress`, `lance-graph-callcenter::DnPath`). What's missing is an **explicit, byte-sized contract** that says: + +- **OGIT is hardware-level addressing** — sub-microsecond bitmask predicates, no Neo4j-style label resolution at query time, no Cypher permission round-trips. +- **Labels and metadata live inline in the per-family codebook** — not in a sidecar. One fetch resolves both for the hot path. +- **Super domains are first-class** — the activation routing layer above OGIT basins, doubling as the RBAC + compliance gate. +- **Tenants are cryptographically isolated** — Chinese walls at the row level, with per-tenant DEKs as crypto backstop to the predicate filter. + +This is the spec the Foundry-parity sales motion hangs on. Palantir charges $1M+/yr for ObjectType-level enforcement; we ship it as a single masked predicate at the super-domain boundary. + +**This spec corrects an earlier sketch** that proposed a label-vs-metadata sidecar table joined at query time. That was Neo4j-shaped — wrong. The right architecture is **inline per-family codebook**, addressed by the same 16-bit `OwlIdentity`. No join. No sidecar. + +--- + +## 2 — The 4-level hierarchy + +``` +Level 0 — META-ANCHORS (interop, ~1 KB total) + Foundry ObjectType / OWL upper class / DOLCE marker / Wikidata QID + Cross-walks to external standards. Consulted only when interop is requested. + +Level 1 — SUPER DOMAIN (governance, ~7-10 today, 256 max) + Healthcare, Science, Genetics, QuantumPhysics, + TicketTool, WorkOrderBilling, OSINT + 1 byte. Activation routing + RBAC trust boundary + compliance regime. + +Level 2 — OGIT BASIN (the heel, ~75 today, 256 max) + Hiro, HubSpot, FMA, SNOMED, ICD10, RxNorm, WorkOrder, ... + 1 byte (high byte of OwlIdentity). Per-codebook; new schema = new basin. + +Level 3 — WITHIN-BASIN SLOT (the row identity, 256 slots/family) + 1 byte (low byte of OwlIdentity). Indexes the per-family codebook table + that holds label + schema + verbs INLINE (not sidecar). +``` + +**Per-row LanceDB overhead: 6 bytes total** +- `tenant_id: u32` — 4 bytes (Chinese wall predicate + DEK selector) +- `owl_id: u16` — 2 bytes (super domain via family lookup + basin + slot) + +DataFusion combines all 4 RBAC stages into one predicate vector at plan time. + +--- + +## 3 — Core DTOs + +### 3.1 OGIT family pointer (the heel) + +```rust +/// 1 byte. Identifies which OGIT family (basin) a row belongs to. +/// 256 families max; ~75 used today (per `RECON_ONTOLOGY_CRATE.md` §1.9). +/// Pure address. No reasoning, no string lookup. +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OgitFamily(pub u8); + +impl OgitFamily { + pub const UNKNOWN: Self = Self(0); + pub const NETWORK: Self = Self(0x01); + pub const WORKORDER: Self = Self(0x05); + // ... seeded by NamespaceRegistry::seed_defaults at hydration + + pub const fn raw(self) -> u8 { self.0 } + pub const fn is_known(self) -> bool { self.0 != 0 } + + /// DataFusion predicate: `(owl_id >> 8) == self.0`. + /// Single masked compare. No registry deref. + #[inline] pub const fn matches(self, owl: OwlIdentity) -> bool { + owl.family().0 == self.0 + } +} +``` + +### 3.2 OWL identity (per-row, 16-bit) + +```rust +/// 2 bytes. BF16-shaped container (interpreted as named bit-fields, +/// not literal floating-point semantics). +/// High 8 bits = OGIT family (the precise heel pointer / "mantissa"). +/// Low 8 bits = within-family slot (the OWL/consumer's own identity). +/// This is what rides on every LanceDB row. +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OwlIdentity(pub u16); + +impl OwlIdentity { + pub const UNKNOWN: Self = Self(0); + + #[inline] pub const fn new(family: OgitFamily, slot: u8) -> Self { + Self(((family.0 as u16) << 8) | slot as u16) + } + #[inline] pub const fn family(self) -> OgitFamily { OgitFamily((self.0 >> 8) as u8) } + #[inline] pub const fn slot(self) -> u8 { (self.0 & 0xFF) as u8 } + #[inline] pub const fn raw(self) -> u16 { self.0 } + + /// Bitmask predicates Cypher MATCH lowers to. No string lookup. + #[inline] pub const fn is_family(self, f: OgitFamily) -> bool { self.family().0 == f.0 } + #[inline] pub const fn is_slot(self, s: u8) -> bool { self.slot() == s } +} +``` + +### 3.3 Per-family codebook table (label + schema + verbs INLINE) + +```rust +/// One table per OGIT family. Lives in static memory after hydration. +/// 256-slot dense array — slot index IS OwlIdentity.slot(). +/// Each slot carries label + schema + verbs INLINE (not a sidecar). +/// Size per family: ~50-200 KB. ~75 tables total ≈ 5-15 MB resident. +pub struct OgitFamilyTable { + pub family: OgitFamily, + pub entries: [Option; 256], + pub codebook: PerFamilyCodebook, // family-local 5-8 bit centroids +} + +/// 1 slot. Resolves a 16-bit OwlIdentity to content. +/// Variable size depending on axiom blob; typical ~80-200 bytes. +pub struct FamilyEntry { + pub label_uri: &'static str, // "ogit.Network:IPAddress" + pub kind: SchemaKind, // Entity / Edge / Attribute (1 byte) + pub owl_characteristics: OwlCharacteristics, // 1 byte bitfield + pub dolce_marker: DolceMarker, // 1 byte + pub axiom_blob: &'static [u8], // OWL subClassOf, equivalentClass, etc. + pub provenance: &'static str, // dcterms:source — carries off-label lineage + pub verbs: &'static [u8], // outgoing verb slots within this family +} + +impl OgitFamilyTable { + /// Hot path: O(1) array index. Sub-microsecond. + #[inline] pub fn lookup(&self, owl: OwlIdentity) -> Option<&FamilyEntry> { + debug_assert_eq!(owl.family().0, self.family.0); + self.entries[owl.slot() as usize].as_ref() + } + #[inline] pub fn label(&self, owl: OwlIdentity) -> Option<&str> { + self.lookup(owl).map(|e| e.label_uri) + } + #[inline] pub fn kind(&self, owl: OwlIdentity) -> Option { + self.lookup(owl).map(|e| e.kind) + } + #[inline] pub fn is_functional(&self, owl: OwlIdentity) -> bool { + self.lookup(owl).map_or(false, |e| e.owl_characteristics.is_functional()) + } + #[inline] pub fn is_transitive(&self, owl: OwlIdentity) -> bool { + self.lookup(owl).map_or(false, |e| e.owl_characteristics.is_transitive()) + } +} +``` + +### 3.4 Super domain (activation root + RBAC trust boundary) + +```rust +/// 1 byte. Activation root + RBAC + compliance regime. +/// 8 starter values; up to 256. +/// Promotes the existing `holograph::dntree::WellKnown` constants +/// to first-class business-named activation roots with formal cross-walks. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SuperDomain { + Unknown = 0, + Healthcare = 1, // FMA, SNOMED, ICD10, RxNorm, LOINC, RadLex, MONDO, HPO, DRON, CHEBI + Science = 2, // physics, chemistry, math, materials + Genetics = 3, // genes, sequences, expression, GO + QuantumPhysics = 4, // quantum-specific (specialized lift from Science) + TicketTool = 5, // Hiro, HubSpot, ServiceNow, Jira, Zendesk + WorkOrderBilling = 6, // WorkOrder, Billing, Tax, SMB, MRO, MRP, Accounting + OSINT = 7, // Maltego, intel sources, social graph +} + +/// One entry per SuperDomain. ~8 entries, ~30 bytes each ≈ 240 bytes total. +pub struct SuperDomainEntry { + pub super_domain: SuperDomain, + pub basins: &'static [OgitFamily], // 10-30 basins this domain spans + pub meta: MetaAnchors, + pub label: &'static str, // "Healthcare" + pub role_groups: &'static [RoleGroup], // nested permission groups (§3.6) + pub compliance: ComplianceRegime, // HIPAA / SOX / PCI-DSS / GDPR / OSINT / ITAR +} + +impl SuperDomainEntry { + /// Activation drill-down: super domain → constituent basins. + /// Used by spreading-activation queries ("anything in Healthcare"). + #[inline] pub fn activate(&self) -> &[OgitFamily] { self.basins } + + /// Cross-walk: this super domain's anchor in an external upper ontology. + #[inline] pub fn cross_walk(&self) -> &MetaAnchors { &self.meta } +} + +/// Reverse lookup: OgitFamily → SuperDomain it belongs to. +/// 256-entry static array; 1 byte each = 256 bytes total. +/// Single-member by default; multi-member escape hatch via BitSet256 +/// reserved for the 2-3 known cross-cutting cases (HPO/MONDO straddle +/// Healthcare ↔ Genetics). +pub static FAMILY_TO_SUPER_DOMAIN: [SuperDomain; 256] = { /* baked at hydration */ }; +``` + +### 3.5 Meta-anchors (cross-walk to formal upper ontologies) + +```rust +/// 4 cross-walk pointers — one per upper standard. Each optional. +/// Consulted only when interop with an external system is requested. +pub struct MetaAnchors { + pub foundry_object_type: Option<&'static str>, // "PhysicalSystem" + pub owl_upper_class: Option<&'static str>, // "BiomedicalOntology" + pub dolce_marker: DolceMarker, // Endurant / Perdurant / Quality / Abstract + pub wikidata_qid: Option, // Q11190 (medicine) +} + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DolceMarker { + Unknown = 0, + Endurant = 1, + Perdurant = 2, + Quality = 3, + Abstract = 4, +} +``` + +### 3.6 Role groups (nested RBAC within super domain) + +```rust +/// One nested role group within a super domain. +/// Healthcare has: physician, nurse, cashier, researcher, hipaa_audit, admin +/// WorkOrderBilling has: cashier, controller, sox_audit +pub struct RoleGroup { + pub role_name: &'static str, // "clinic_personnel", "cashier", "hipaa_audit" + pub permissions: PermissionSet, // 1 byte bitfield + pub clearance_floor: ClearanceLevel, + pub audit_required: bool, + pub redaction_mask: FieldRedactionMask, // 96 bytes: 3 × BitSet256 +} + +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct PermissionSet(pub u8); + +impl PermissionSet { + pub const READ: u8 = 1 << 0; + pub const WRITE: u8 = 1 << 1; + pub const DELETE: u8 = 1 << 2; + pub const EXPORT: u8 = 1 << 3; + pub const ESCALATE: u8 = 1 << 4; // request elevated access + pub const AUDIT_BYPASS: u8 = 1 << 5; // emergency, must justify + pub const SCHEMA_VIEW: u8 = 1 << 6; // see metadata only, no instances + pub const REDACT_LIFT: u8 = 1 << 7; // see un-redacted values + + #[inline] pub const fn allows(self, op: u8) -> bool { self.0 & op != 0 } +} + +/// 96 bytes per role group. Slot-level visibility within a super domain's basins. +pub struct FieldRedactionMask { + pub readable_slots: BitSet256, // 32 bytes — slots this role can read + pub writable_slots: BitSet256, // 32 bytes — slots this role can mutate + pub redacted_slots: BitSet256, // 32 bytes — slots returned hashed/nulled/starred +} + +#[repr(transparent)] +#[derive(Clone, Copy)] +pub struct BitSet256(pub [u64; 4]); + +impl BitSet256 { + #[inline] pub const fn contains(&self, bit: u8) -> bool { + self.0[(bit >> 6) as usize] & (1u64 << (bit & 0x3F)) != 0 + } +} + +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ClearanceLevel(pub u8); // 0=public, 1=restricted, 2=confidential, 3=secret +``` + +### 3.7 Compliance regime + +```rust +/// 1 byte. Tagged on each SuperDomainEntry. Drives certification surface. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ComplianceRegime { + None = 0, + HIPAA = 1, // Healthcare + SOX = 2, // WorkOrderBilling (financial reporting) + PCI_DSS = 3, // WorkOrderBilling (payment cards) + GDPR = 4, // cross-cutting (any super domain with PII) + OSINT_CLEARANCE = 5, // OSINT (tiered by classification) + ITAR_EAR = 6, // Science / QuantumPhysics (dual-use research) +} +``` + +### 3.8 Tenant context (multi-tenant Chinese wall) + +```rust +/// 4-byte newtype. ~4 billion tenants. Carried on every row. +/// Crypto backstop: per-tenant DEK wraps row payload, so a misconfigured +/// query that bypasses the predicate filter still can't decrypt cross-tenant rows. +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct TenantId(pub u32); + +/// Multi-tenant context the bridge holds. +pub struct TenantContext { + pub tenant_id: TenantId, + pub display_name: &'static str, // "Massachusetts General Hospital" + pub role_bindings: &'static [(SuperDomain, &'static str)], + // e.g., [(Healthcare, "physician"), (WorkOrderBilling, "cashier")] + pub encryption_key: KeyHandle, // per-tenant DEK + pub federation: FederationPolicy, // pure wall (default) | k-anonymity escape +} + +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FederationPolicy { + PureWall = 0, // default; no cross-tenant queries + KAnonymityAggregate = 1, // aggregate-only with k ≥ 5 + HomomorphicAggregate = 2, // 2027+ R&D; aggregate without decryption +} +``` + +### 3.9 Unified bridge (4-stage authorize) + +```rust +pub struct UnifiedBridge { + registry: Arc, + tenant: TenantContext, + audit_sink: Arc, +} + +impl UnifiedBridge { + /// Hot-path authorization. All 4 checks lower to bitmask predicates + /// in DataFusion — combined into one predicate vector at plan time. + #[inline] pub fn authorize( + &self, + owl: OwlIdentity, + row_tenant: TenantId, + op: u8, // PermissionSet bit + ) -> Result { + // 1. CHINESE WALL: tenant isolation (single u32 compare) + if row_tenant != self.tenant.tenant_id { + return Err(RbacError::CrossTenantViolation); + } + + // 2. SUPER DOMAIN: which super domain this basin belongs to + let sd = FAMILY_TO_SUPER_DOMAIN[owl.family().0 as usize]; + let role_name = self.tenant.role_bindings.iter() + .find_map(|(s, r)| (*s == sd).then_some(*r)) + .ok_or(RbacError::NoSuperDomainAccess)?; + + // 3. ROLE GROUP: lookup role within super domain + let role = SUPER_DOMAINS[sd as usize].role_groups.iter() + .find(|r| r.role_name == role_name) + .ok_or(RbacError::UnknownRole)?; + if !role.permissions.allows(op) { + return Err(RbacError::OperationNotPermitted); + } + + // 4. SLOT REDACTION: per-slot mask within role + let slot = owl.slot(); + let access = if op == PermissionSet::READ { + if role.redaction_mask.readable_slots.contains(slot) { RowAccess::Full } + else if role.redaction_mask.redacted_slots.contains(slot) { RowAccess::Redacted } + else { RowAccess::Hidden } + } else if op == PermissionSet::WRITE { + if role.redaction_mask.writable_slots.contains(slot) { RowAccess::Full } + else { return Err(RbacError::SlotNotWritable); } + } else { + RowAccess::Full + }; + + if role.audit_required { + self.audit_sink.emit(AuditEntry::new(self.tenant.tenant_id, role_name, owl, op)); + } + Ok(access) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RowAccess { Full, Redacted, Hidden } +``` + +### 3.10 DataFusion lowering (the hot path) + +The 4 stages combine into one predicate vector at plan time: + +```sql +WHERE tenant_id = $T + AND ((owl_id >> 8) & 0xFF) IN $authorized_basins + AND ((owl_id & 0xFF) & $slot_visibility_mask) != 0 +``` + +One vectorized scan, no per-row branching, no per-row policy evaluation. Sub-microsecond stays intact. + +--- + +## 4 — Concrete consumer-to-basin mapping + +### 4.1 The 2 ticket consumers (per user constraint) + +| Consumer crate | Super domain | OGIT basin | Bridge | Notes | +|---|---|---|---|---| +| **`hiro-rs`** (NEW) | TicketTool | `OgitFamily::Hiro` (NEW) | `HiroBridge::from_registry()` | Absorbs all 4 OSLC-* namespaces with off-label fit acceptable. Per-OSLC-spec lineage rides in `FamilyEntry.provenance`. Bardioc stack (JanusGraph/TinkerPop/Cassandra/BEAM) doesn't care about OSLC-spec-purity at runtime. | +| **`hubspot-rs`** (NEW) | TicketTool, WorkOrderBilling | `OgitFamily::HubSpot` (NEW) | `HubspotBridge::from_registry()` | Fresh basin. CRM codebook (Deal, Contact, Ticket, Pipeline, Property, Engagement) has zero overlap with Hiro's IT-ops vocabulary. Cross-cuts WorkOrderBilling for deals/billing. | + +### 4.2 Existing + planned consumers + +| Consumer | Super domain(s) | Basin(s) | Compliance | Status | +|---|---|---|---|---| +| `medcare-rs` | Healthcare | Healthcare-* | HIPAA | Bridge shipped | +| `woa-rs` | WorkOrderBilling | WorkOrder | SOX | Bridge shipped | +| `q2-rs` | TBD | Q2 | None | Bridge planned (D-ONTO-V5-5) | +| `smb-office-rs` | WorkOrderBilling | SMB | None | Bridge shipped | +| `osint-rs` (FUTURE) | OSINT | Maltego, intel-* | OSINT_CLEARANCE | Not yet scoped | +| `science-rs` (FUTURE) | Science, QuantumPhysics, Genetics | physics-*, chem-*, GO | ITAR_EAR for dual-use | Not yet scoped | + +### 4.3 Healthcare super domain — full role matrix (illustrative) + +| Role | Permissions | Readable slots | Redacted slots | Writable slots | Audit | +|---|---|---|---|---|---| +| `physician` | READ + WRITE | clinical (Diagnosis/Vital/Med) + demographics | SSN, full DOB | clinical notes | yes | +| `nurse` | READ + WRITE (limited) | clinical, vitals | SSN, full DOB | vitals only | yes | +| `cashier` | READ | billing slots only | clinical (hashed for matching only) | none | yes (financial trail) | +| `researcher` | READ | de-identified slots only | name, SSN, address all hashed | none | yes (k-anonymity log) | +| `hipaa_audit` | READ + EXPORT + AUDIT_BYPASS | all slots | none | none | yes (every access logged) | +| `admin` | SCHEMA_VIEW | none (slot 0xFF reserved as schema-only) | n/a | none | no | + +Sales narrative: + +> "Same binary, same database, multiple hospitals — zero leakage. Within Mass General, the physician sees clinical detail, the nurse sees vitals, the cashier sees billing, the auditor sees everything with the audit trail. The researcher sees de-identified data only. The same setup runs for Hopkins next door, with cryptographic isolation between them." + +--- + +## 5 — OSLC absorption decision + +**Call: collapse 4 OSLC-* namespaces into `OgitFamily::Hiro`.** Off-label fit is acceptable. + +The off-label-ness rides in `FamilyEntry.provenance` — already in the DTO, no new bytes: + +```rust +FamilyEntry { + label_uri: "ogit.Hiro:PerformanceMetric", + kind: SchemaKind::Entity, + owl_characteristics: OwlCharacteristics(FUNCTIONAL), + dolce_marker: DolceMarker::Perdurant, + axiom_blob: &[], + provenance: "OSLC-perfmon v3.0 § 4.2 (off-label fit: closest match to Hiro's metric model)", + verbs: &[/* slots */], +} +``` + +Why collapse: +- 4-basin alternative preserves OSLC-spec purity but **forks the codebook 4 ways for one consumer** — directly against the "boring agnostic" + "no consultant hours" principle. +- Off-label mapping into Hiro means consumers query `family = Hiro` and get coherent results, with lineage available if anyone wants it. +- The bardioc stack doesn't care about OSLC-spec-of-origin at runtime — it just routes tickets. The basin matches the runtime granularity, not the standards-body granularity. + +--- + +## 6 — Cross-tenant federation policy + +**Default: pure Chinese wall (Option A).** Cross-tenant queries always denied. Forces consumers to do anonymization upstream (export to a separate tenant scoped for benchmarking). + +**Phase 2 escape hatch (Option B): k-anonymity aggregate.** A special role like `federated_researcher` can run aggregate-only queries across tenants if results pass k-anonymity threshold (k ≥ 5 typical). Uses HyperLogLog-style cardinality estimation; raw row access still impossible. + +**2027+ R&D track (Option C): homomorphic-encryption aggregate.** Aggregate without decryption. Most defensible for regulated data, slowest, complex. + +A is the conservative HIPAA-defensible default. B is the practical compromise for healthcare analytics. C is research, not deliverable. + +--- + +## 7 — Substrate this builds on (citations) + +| Existing artifact | Role in this spec | +|---|---| +| `lance-graph-ontology::namespace::SchemaPtr` | Source for the bit-packing convention. The new `OwlIdentity` is a 16-bit refinement of the same idea (drops the `kind` byte to a slot field, keeps the `(ns, type_id)` core). | +| `lance-graph-ontology::bridges::OgitBridge` (`src/bridges/ogit_bridge.rs`) | Bridge pattern + `BridgeFromRegistry` trait. Hiro/HubSpot bridges follow the same shape (~45 LOC each). | +| `lance-graph-ontology::ttl_parse` + `foundry_map` | TTL hydrator that emits `(ns_id, type_id, kind)` rows. Will emit `OgitFamilyTable` entries instead. | +| `lance-graph-ontology::namespace_registry::NamespaceRegistry` | Codebook seeding. `seed_defaults()` extends to seed `SuperDomain` ↔ `OgitFamily` mapping. | +| `holograph::dntree::WellKnown` (`src/dntree.rs:213-260`) | Existing un-named super-domain ordinals. Promoted to first-class business-named `SuperDomain` enum with cross-walk anchors. | +| `holograph::dntree::CogVerb` (144 verbs in 6 categories) | Cognitive verb taxonomy (separate from OGIT domain verbs). Stays as the spreading-activation substrate; not part of this spec. | +| `lance-graph-callcenter::dn_path::DnPath` | The `tree/{ns}/heel/h/hip/h/branch/b/twig/t/leaf/l` hierarchy. The 16-bit `OwlIdentity` is the runtime address that DnPath compresses to via ZeckBF17→Base17→CAM-PQ→scent (1B, ρ=0.937) — the user's planned compression chain. | +| `bgz-tensor::HhtlDEntry` (`BGZ_HHTL_D.md`) | 4-byte `Slot D + Slot V` layout that proved the bit-packed-hierarchy approach for transformer weights. The 16-bit `OwlIdentity` adapts the *idea* (high bits = family, low bits = slot) to ontology rows. **Different register from Slot D's `Ba[15:14]`** (which is for QK/V/Gate/FFN model-weight roles). | +| `highheelbgz::SpiralAddress` | 12-byte spiral address — unrelated to OWL identity; kept as the model-weight encoding. | +| `lance-graph-contract::cam` (`CamCodecContract`, `DistanceTableProvider`, `IvfContract`) | CAM-PQ substrate for compressed-distance lookup. Not changed by this spec; the per-family codebook compression in `OgitFamilyTable.codebook` plugs into this. | +| `GLUE_LAYER_OGIT_TO_OWL_SPEC.md` (in `.grok/`) | Source for the 1-byte OWL property characteristics bitfield (Functional / InverseFunctional / Transitive / Symmetric / Asymmetric / Reflexive / Irreflexive / Reserved). | +| `palantir-parity-cascade-v2.md` | Foundry parity narrative. This spec adds the **enforcement surface** (RBAC + compliance + tenant isolation) that the parity story needs. | +| `lance-graph-ontology-v5.md` | The ontology registry plan. This spec adds the RBAC + tenancy layer **above** v5; v5 stays as-is. | + +--- + +## 8 — Deliverables + +### Tier A — DTOs + bridge surface (lance-graph workspace) + +- **D-SDR-1** — `OgitFamily`, `OwlIdentity`, `BitSet256`, `ClearanceLevel`, `PermissionSet`, `DolceMarker`, `SchemaKind`, `OwlCharacteristics` newtypes/enums in `lance-graph-contract`. ~80 LOC + 12 unit tests. +- **D-SDR-2** — `SuperDomain`, `MetaAnchors`, `RoleGroup`, `FieldRedactionMask`, `ComplianceRegime`, `TenantId`, `TenantContext`, `FederationPolicy`, `RbacError`, `RowAccess` in `lance-graph-contract::rbac`. ~150 LOC + 8 unit tests. +- **D-SDR-3** — `OgitFamilyTable`, `FamilyEntry`, `PerFamilyCodebook` in `lance-graph-ontology`. ~120 LOC + 6 unit tests. +- **D-SDR-4** — `SuperDomainEntry` table + `SUPER_DOMAINS` static + `FAMILY_TO_SUPER_DOMAIN` reverse lookup in `lance-graph-ontology::super_domain`. ~200 LOC for the 8 starter super domains + ~75 family mappings; 4 integration tests. +- **D-SDR-5** — `UnifiedBridge::authorize()` 4-stage flow + `AuditSink` trait + DataFusion predicate-lowering in `lance-graph-ontology::bridges::unified`. ~200 LOC + 6 integration tests covering each error path. + +### Tier B — TTL namespaces (AdaWorldAPI/OGIT fork PRs) + +- **D-SDR-6** — `OGIT/NTO/Hiro/{entities,verbs}/*.ttl` (15-30 entities + 10-20 verbs, absorbs OSLC-* with provenance lineage). One PR on the OGIT fork. +- **D-SDR-7** — `OGIT/NTO/HubSpot/{entities,verbs}/*.ttl` (15-25 entities + 8-15 verbs, fresh CRM vocabulary). One PR on the OGIT fork. + +### Tier C — Consumer crate scaffolding + +- **D-SDR-8** — `/home/user/hiro-rs` new crate. `HiroBridge::from_registry()` + 1 round-trip integration test (`Ticket` URI → `OwlIdentity`). ~150 LOC. +- **D-SDR-9** — `/home/user/hubspot-rs` new crate. `HubspotBridge::from_registry()` + 1 round-trip test (`Deal` URI → `OwlIdentity`). ~150 LOC. + +### Tier D — Compliance + audit surface + +- **D-SDR-10** — `AuditEntry` JSON schema + `JsonLinesAuditSink` impl + retention policy doc. ~80 LOC + 1 integration test that verifies every authorized read emits exactly one audit line. +- **D-SDR-11** — Compliance regime certification stub: per-`ComplianceRegime` checklist (HIPAA → §164.312 controls; SOX → §404 internal controls; PCI-DSS → Reqs 3+7+10) cross-referencing which DTOs/methods enforce each control. Doc only, ~200 lines markdown. + +### Tier E — Cross-tenant federation gate (Phase 2) + +- **D-SDR-12** — `FederationPolicy::KAnonymityAggregate` impl: aggregate-query gate with k-threshold check via HLL cardinality. ~150 LOC + 4 integration tests. **Deferred until a customer demands it.** + +--- + +## 9 — Tradeoffs flagged explicitly + +### 9.1 Single-member vs multi-member super-domain assignment +- **Default:** `FAMILY_TO_SUPER_DOMAIN: [SuperDomain; 256]` — one super domain per basin. +- **Escape hatch:** `[BitSet256; 256]` for the 2-3 cross-cutting basins (HPO/MONDO straddle Healthcare ↔ Genetics). Same byte cost (1 byte per basin if we use 8-bit bitset for 8 super domains). +- **Decision:** ship single-member; promote to bitset only when a real cross-cutting case fails the assignment. + +### 9.2 Coarse super-domain RBAC vs fine per-slot RBAC +- **Coarse (super-domain only):** single masked predicate, sub-microsecond, default policy. Insufficient for cases where one slot within a basin needs elevated protection (e.g., SSN field within Healthcare needs `Confidential` while other PHI is `Restricted`). +- **Fine (per-slot via redaction mask):** the `FieldRedactionMask` (3 × BitSet256 = 96 bytes per role) handles this — already in the DTO. +- **Decision:** ship both granularities. Coarse covers the 80% case, fine covers PHI-style sub-slot redaction. + +### 9.3 SGO meta exclusion from runtime +- SGO has 64 entities + 179 verbs + 265 attributes = **508 items**, overflowing the 256-slot per-family ceiling. +- **Decision:** SGO is **authoring-time scaffolding only**. NTO domain entities reference SGO meta-types at hydration; runtime `OwlIdentity` only addresses NTO. SGO meta is resolved once during codebook bake, then discarded. +- Matches BGZ-HHTL-D's "palette baked offline, runtime reads N bytes per row" pattern. + +### 9.4 OSLC collapse vs preserve +- **Decision:** collapse to 1 basin (Hiro) with provenance lineage. See §5. + +### 9.5 Cross-tenant federation +- **Decision:** PureWall by default. Phase 2 escape hatch as Option B. See §6. + +--- + +## 10 — Open questions for the next session + +1. **Foundry ObjectType cross-walk targets** — which Foundry ObjectType strings anchor each super domain? Need product-side input for the `MetaAnchors.foundry_object_type` field. +2. **Wikidata QID mappings** — concrete QIDs for Healthcare (Q11190?), Science, Genetics, etc. Mechanical lookup but needs a one-shot pass. +3. **Audit format choice** — JSON Lines (default) vs CloudEvents vs OpenTelemetry trace. Which integrates with the customer's SIEM stack? +4. **Key-rotation cadence** for per-tenant DEKs — quarterly default or per-policy? +5. **Escalation protocol UX** — when a role hits `RbacError::OperationNotPermitted`, what's the customer-facing escalation flow? (Out of scope for this spec, but needed for the sales pitch.) +6. **HPO/MONDO multi-member assignment** — confirm these are the only cross-cutting basins (Healthcare ↔ Genetics) before locking single-member as default. +7. **Slot 0xFF reserved as schema-only** — convention or enforced? Lean toward enforced, with a `SchemaOnlySlot` const. + +--- + +## 11 — Status + +- **Architecture:** locked (4-level hierarchy, DTOs sized, authorize flow defined). +- **DTOs:** drafted in this spec; ready for `cargo check` once Tier A lands. +- **TTL namespaces:** not authored yet (Tier B = 2 OGIT-fork PRs). +- **Consumer crates:** not scaffolded yet (Tier C = 2 new crates in `/home/user/`). +- **Compliance certification:** stubbed (Tier D doc); legal review required before HIPAA-compliant claim. +- **Federation:** deferred (Tier E, Phase 2). + +**Confidence:** Working — the design is internally consistent and grounded in shipped substrate. Per-slot redaction policies (Tier A's `FieldRedactionMask` semantics) need a probe with real OWL/DOLCE slot semantics before they can be hardened against actual PHI. + +--- + +## 12 — One-line summary + +> 4-level hierarchy (meta-anchor → super domain → OGIT basin → slot), 6 bytes per row (4-byte tenant + 2-byte OWL identity), inline per-family codebook with label+schema+verbs, single masked predicate enforces tenant + super-domain + role + slot in one DataFusion vector pass. Foundry parity at the enforcement surface, sub-microsecond hot path. + +--- + +## 13 — Refinements (2026-05-13, same session) + +Post-draft user feedback surfaced four substrate facts and two requirement upgrades. All folded in as additive corrections — the §3 DTOs, §8 deliverables, and §12 summary remain valid; this section makes the underlying compositor explicit and tightens the federation + hard-lock policy. + +### 13.1 The compositor is already shipped: `lance-graph-callcenter::policy` + +The 4-stage `UnifiedBridge::authorize()` (§3.9) is **not** a new enforcement layer — it composes against `lance-graph-callcenter/src/policy.rs`'s shipped `PolicyRewriter` trait + `PolicyKind` taxonomy: + +```rust +pub enum PolicyKind { + RowFilter, // tenant + super-domain + basin bitmask predicates + ColumnMask, // per-slot RedactionMask (Null/Constant/Hash/Truncate) + RowEncryption, // per-tenant DEK at LanceDB column level + DifferentialPrivacy, // k-anonymity aggregate noise (federation Option B) + Audit, // side-channel emission per access +} +``` + +The 4 stages map 1:1 to `PolicyKind` variants: + +| `authorize()` stage | `PolicyKind` | +|---|---| +| 1. Chinese wall (tenant) | `RowFilter` (existing `RlsRewriter` as ancestor) | +| 2. Super-domain | `RowFilter` (additional predicate, same rewriter chain) | +| 3. Role group | `ColumnMask` (drives slot-level visibility per `RedactionMode`) | +| 4. Slot redaction | `ColumnMask` + `RowEncryption` (when `clearance_floor ≥ Confidential`) | +| Audit emission | `Audit` (composed last) | + +**Consequence:** Tier A deliverables (D-SDR-1..5) **wire onto the existing `PolicyRewriter` chain** rather than introducing a parallel enforcement path. ~30% LOC reduction on Tier A. The DataFusion `OptimizerRule` machinery already handles the predicate-vector composition described in §3.10. + +### 13.2 LanceDB transparent encryption upgrades Option C from R&D to viable + +Earlier framing of cross-tenant federation (§6) classified Option C (homomorphic-encryption aggregate) as a 2027+ R&D track. **Correction:** LanceDB ships **transparent encrypted views** at the column level — the engine scans/filters/aggregates over encrypted columns without decrypting full rows, with key access gated by tenant DEK. This is the substrate Option C needs without bespoke FHE primitives. + +**Updated federation policy:** + +```rust +#[repr(u8)] +pub enum FederationPolicy { + PureWall = 0, // default — no cross-tenant queries + KAnonymityAggregate = 1, // Phase 2 — k ≥ 5 via PolicyKind::DifferentialPrivacy + EncryptedViewAggregate = 2, // Phase 2-3 — LanceDB transparent encrypted view + per-tenant DEK + // (was Option C, now viable; not slow) +} +``` + +**Tier E (D-SDR-12) scope expands:** ship A+B together as Phase 2; add Phase 3 EncryptedViewAggregate path that lifts the k-anonymity threshold for tenants whose data column is encrypted at rest with their own DEK (the engine aggregates over ciphertext when the operation is sum/count/avg with bounded sensitivity). + +### 13.3 Merkle + ClamPath integration: audit chain + hard-lock attestation + +`crates/lance-graph/src/graph/spo/merkle.rs` ships: + +```rust +pub struct MerkleRoot(pub u64); // XOR-fold hash of fingerprint content +impl MerkleRoot { + pub fn from_fingerprint(fp: &Fingerprint) -> Self { /* ... */ } +} + +pub struct ClamPath { // hierarchical DN address + pub path: String, // "agent:test:node" + pub depth: u32, +} +``` + +**The merkle/DN-path mixing the user remembered is here** — `MerkleRoot` stamps content, `ClamPath` carries the hierarchical address (the same shape as `DnPath` from `lance-graph-callcenter::dn_path`). + +**Wire into the spec:** + +- **Audit chain integrity:** every `AuditEntry` (Tier D, D-SDR-10) carries the `MerkleRoot` of the row at access time. A second access produces a new merkle root; the audit log records both, so HIPAA reviewers can detect post-hoc tampering (the merkle would not validate against the recorded fingerprint). +- **Hard-lock attestation (§13.4):** the cryptographic separation between Healthcare and OSINT super domains is attested by **distinct merkle root salts per super domain**. A row whose merkle root validates against the OSINT salt cannot validate against the Healthcare salt, so a leaked row is provably mis-routed at integrity-check time even if the predicate filter is misconfigured. + +**Updated `AuditEntry` shape** (Tier D refinement): + +```rust +pub struct AuditEntry { + pub tenant: TenantId, + pub super_domain: SuperDomain, + pub actor_role: &'static str, + pub owl: OwlIdentity, + pub op: u8, // PermissionSet bit + pub merkle_root: MerkleRoot, // NEW: fingerprint at access time + pub clam_path: ClamPath, // NEW: hierarchical DN address + pub timestamp: u64, + pub super_domain_salt: u64, // NEW: per-super-domain merkle salt +} +``` + +### 13.4 Hard-lock requirement: Healthcare ↔ OSINT crypto barrier + +**HIPAA compliance and clinical staff trust require a guarantee stronger than predicate filtering between patient history and OSINT.** The user's framing: "doctors will want to know that patient history and OSINT are hard lock." + +**Updated DTO:** + +```rust +pub struct SuperDomainEntry { + // ... fields as in §3.4 ... + pub merkle_salt: u64, // NEW: per-super-domain integrity salt + pub hard_lock_partners: &'static [SuperDomain], // NEW: explicit cryptographic separation +} +``` + +**Per-super-domain hard-lock matrix (initial):** + +| Super domain | `hard_lock_partners` (cannot share rows or be queried jointly under any role) | +|---|---| +| `Healthcare` | `[OSINT]` | +| `OSINT` | `[Healthcare]` | +| `WorkOrderBilling` | `[OSINT]` (financial confidentiality) | +| `Science` (when ITAR-tagged) | `[OSINT]` (export control vs intel) | + +**Enforcement mechanism (3 layers of defense):** + +1. **Predicate-time:** `authorize()` rejects any query whose `super_domain_target` is in the actor's `hard_lock_partners` list, even if the actor has the source super domain authorized. +2. **Integrity-time:** different `merkle_salt` per super domain. A misconfigured query that bypasses (1) cannot validate cross-domain merkle roots. +3. **Encryption-time:** rows in a hard-locked super domain are encrypted with super-domain-scoped key derivation (per-tenant DEK × per-super-domain HKDF info string). A leaked row decrypts only with both the tenant DEK *and* the super-domain context — neither alone suffices. + +**Sales narrative refresh:** + +> "Patient history and OSINT are hard-locked. Three layers of defense — predicate, merkle salt, key derivation. A clinician's bridge cannot construct a query that joins patient records with intel; the optimizer rejects it, the merkle would not validate, and the encryption keys won't combine. HIPAA reviewers see a cryptographically attested separation, not a policy promise." + +### 13.5 Research role: anonymized projection only + +**The `researcher` role from §4.3 is upgraded to a hard requirement, not a configuration knob.** Per user: "research using anonymized." + +**Updated `researcher` role definition:** + +```rust +RoleGroup { + role_name: "researcher", + permissions: PermissionSet(PermissionSet::READ), // no WRITE, no EXPORT, no REDACT_LIFT + clearance_floor: ClearanceLevel(1), // Restricted; never elevated + audit_required: true, // every access logged with k-anonymity check + redaction_mask: FieldRedactionMask { + readable_slots: BIT_SET_DEIDENTIFIED_ONLY, // only de-identified slots visible + writable_slots: BitSet256([0; 4]), // empty — researchers never write + redacted_slots: BIT_SET_DIRECT_IDENTIFIERS, // name, SSN, DOB, MRN, address — all hashed + }, +}, +``` + +**Composes with `PolicyKind::DifferentialPrivacy`:** when the researcher role queries an aggregate, the optimizer chain auto-injects DP noise per the differential-privacy parameter `ε` configured at the super-domain level (`SuperDomainEntry.dp_epsilon: f32`, NEW field). + +**Three additive constraints for the researcher role:** + +1. **Field-level:** direct identifiers always hashed (k-anonymity-style pseudonymization). +2. **Row-level:** queries over **Note:** This section's framing was substantially refined by §16 (Zone 3 boundary placement) and §17 (LanceDB convergence). Read §16+§17 as the controlling architecture; §15 is preserved here as the design arc that surfaced the byte-determinism concerns. + +### 15.1 Three implementations under one contract + +```rust +pub struct MetaBridgeVersion { + pub major: u8, // wire-incompatible + pub minor: u8, // wire-compatible additions + pub patch: u8, // bug-fix (allowed to differ between drift partners) +} + +#[repr(u8)] +pub enum BridgeImpl { + RustNative = 0, // MedCare-rs (authoritative) + CSharpNative = 1, // MedCareV2 (parallelbetrieb during migration window) + MySQLAdapter = 2, // legacy adapter — DROPPED in §17 (one-shot import only) +} + +pub struct DriftableOutput { + pub version: MetaBridgeVersion, + pub source: BridgeImpl, + pub owl_identities: Vec, // sorted ascending by raw u16 + pub fingerprints: Vec, // parallel to owl_identities + pub canonical_hash: u64, // FNV-1a over interleaved (id, merkle) pairs +} + +pub trait DriftDetectionBridge { + fn compare(&self, query: &Query) -> DriftReport; +} + +pub struct DriftReport { + pub equivalent: bool, + pub canonical_hashes: Vec<(BridgeImpl, u64)>, + pub divergent_rows: Vec, + pub timestamp: u64, +} + +pub struct DivergentRow { + pub owl: OwlIdentity, + pub per_impl_merkle: Vec<(BridgeImpl, MerkleRoot)>, + pub canonical_winner: BridgeImpl, // RustNative wins ties +} +``` + +### 15.2 Cross-language byte-determinism rules + +| Source | Rust default | C# default | Determinism rule | +|---|---|---|---| +| Hash map iteration | randomized | insertion order | **Always sort** before producing `DriftableOutput` | +| f32/f64 summation | left-fold | left-fold | **Use Kahan summation** for any cross-impl FP aggregate | +| Timestamp granularity | `Instant` (nanoseconds) | `DateTime` (100 ns ticks) | **Quantize to milliseconds** before MerkleRoot | +| String hashing | `DefaultHasher` (SipHash) | `string.GetHashCode()` (random per process) | **Always FNV-1a** for cross-boundary hashes | +| Integer overflow | wrapping in release | checked in debug | **`wrapping_*` everywhere** in `DriftableOutput` derivation | +| Unicode normalization | NFC default | NFC default | **Assert NFC at boundary** | +| Decimal arithmetic | `f64` | `decimal` (128-bit) | **String-encoded decimals** for monetary slots | + +**Reduction in §16:** Zone 3 placement (Arrow IPC + Supabase RPC) handles 4 of these 7 rules at the wire-format level. Remaining surface = decimal arithmetic + timestamps + side-channel aggregation. + +### 15.3 Tier deliverables (§15 originals; revised by §16/§17) + +- **D-SDR-24** — `MySQLAdapterBridge` impl. **DROPPED in §16/§17** — replaced by D-SDR-27 one-shot import; the MySQL adapter doesn't live alongside the new system long-term. +- **D-SDR-25** — `DriftDetectionBridge` impl + `JsonLinesDriftSink` for divergence events + dashboards. **Demoted in §17** to Phase-2 dual-write window only; retires after Phase 4 cutover. +- **D-SDR-26** — Determinism rule test suite. **Reduced in §16** from 12 rules to ~3 rules (decimal + timestamp + FP aggregate). + +### 15.4 Brutal-honest tradeoff (preserved as design arc) + +Original framing: byte-determinism is hard to retrofit; if existing `medcare_bridge.rs` produces non-deterministic output anywhere (HashMap iteration, FP order in vector ops), the harvest pass surfaces this as the highest-priority bug to fix before extracting the meta-bridge. Otherwise the drift bridge produces false-positive divergence events on identical inputs, destroying operational trust in the drift signal. + +**Updated by §17:** Because the Phase 4 end-state is "single LanceDB store accessed via Flight SQL", the drift bridge is bounded to the Phase 2-3 migration window. Determinism failures become migration-window incidents (recoverable) rather than perpetual production drift (unrecoverable). 2-3 days of determinism archaeology stays as a budget item but the consequence severity drops. + +--- + +## 16 — Zone 3 Drift Boundary + Two-Track Migration (2026-05-13, same session) + +### 16.1 Zone 3 placement collapses determinism rules + +MedCare-rs ingests via Zone 3 (Supabase RPC / REST / transcode per the workspace's zone discipline) — the drift comparison happens on serialized wire payloads (Arrow IPC + Supabase RPC), not on internal `BindSpace` state. Most §15.2 rules collapse: + +| §15.2 rule | Status at Zone 3 | +|---|---| +| Hash map iteration | Resolved — Arrow column order is part of IPC framing | +| String hashing | Resolved — wire-level string identity is byte-equal | +| Unicode normalization | Already NFC-asserted at Zone 3 boundary | +| f32/f64 summation | Still applies if pre-Zone-3 aggregation occurs | +| Decimal arithmetic | Still applies for monetary slots | +| Timestamp granularity | Still applies | + +Determinism test surface (D-SDR-26) shrinks from ~12 rules to ~3 rules. + +### 16.2 Pre-prod posture (corrected per user clarification) + +**Nothing is in production yet.** No live parallelbetrieb, no production drift to manage: + +- Legacy `MedCare` (the original C# code, MySQL+3DES) — pre-prod +- `MedCareV2` (partial C# rewrite, reshapeable) — pre-prod +- `MedCare-rs` (Rust) — pre-prod +- Goal: single one-shot import of MySQL+3DES into the new stores; drift bridge demoted to import sanity check + CI gate + +### 16.3 Two-track migration model + +| Track | Payload | Volume | Migration shape | +|---|---|---|---| +| **John Doe** | billing + tickets (live in separate WoA / Hiro databases, NOT in MedCare MySQL) | ~95%+ of customer rows | Standard ETL through Zone 3. Maps to `hubspot_bridge` + `hiro_bridge`. No 3DES decrypt step. | +| **3DES PHI** | Healthcare clinical data (MedCare MySQL) | small high-stakes subset (specific PHI columns: name, DOB, contact, free-text diagnosis) | Decrypt 3DES → AES-256-GCM with per-tenant DEK × per-super-domain HKDF context. One-shot import tool. | + +### 16.4 3DES rewrap pipeline (single cipher, well-known algorithm) + +```rust +/// One-shot import of 3DES-encrypted MySQL rows into the new stores. +/// Runs once during pre-prod cutover; throwaway tool after. +pub fn import_3des_row( + mysql_row: &MySQLRow, + legacy_3des_key: &TripleDesKey, // legacy key, destroyed after import + target_tenant_dek: &KeyHandle, + target_super_domain: SuperDomain, +) -> Result { + // 1. Decrypt 3DES (well-known algorithm, no surprises) + let plaintext = three_des_decrypt(&mysql_row.ciphertext, legacy_3des_key)?; + + // 2. Compute MerkleRoot for drift-bridge sanity check + let merkle = MerkleRoot::from_fingerprint(&Fingerprint::from(&plaintext)); + + // 3. Re-encrypt with per-tenant × per-super-domain AES-256-GCM + let derived = hkdf_derive(target_tenant_dek, target_super_domain.as_hkdf_context()); + let new_ciphertext = aes_256_gcm_encrypt(&plaintext, &derived, random_nonce())?; + + Ok(MigratedRow { + owl: classify_to_owl_identity(&plaintext), + merkle_root: merkle, // cleartext beside ciphertext + ciphertext: new_ciphertext, + super_domain: target_super_domain, + }) +} +``` + +### 16.5 MerkleRoot-cleartext-beside-ciphertext (key insight) + +Each row stores `(ciphertext, merkle_root_cleartext)` side-by-side. Consequences: + +- **Drift bridge compares MerkleRoots without ever decrypting** in steady-state production. No privileged plaintext access role needed for drift detection itself. +- Encryption uses **random nonces** (modern best practice) — no need for deterministic AEAD like AES-GCM-SIV. +- MerkleRoot is computed against fingerprint content (the `Fingerprint` type from `crates/lance-graph/src/graph/fingerprint.rs`), so it's a one-way hash — doesn't reveal plaintext, only enables equality comparison. +- **Trust boundary:** anyone who can write a row needs key access (to encrypt). Anyone who can run drift detection only needs MerkleRoot read access. Read-only drift role is unprivileged from PHI perspective. + +### 16.6 Tier F additions (replaces §15's drift-as-production-infra deliverables) + +- **D-SDR-27** — 3DES → AES-256-GCM rewrap one-shot tool. ~120 LOC + 2 integration tests against 3DES test vectors. **Throwaway after import succeeds + sanity-check window.** +- **D-SDR-28** — MerkleRoot-cleartext-beside-ciphertext storage layout in MedCare-rs + MedCareV2 stores. ~80 LOC × 2 + 2 tests verifying MerkleRoot consistency across the boundary. +- **D-SDR-29** — Two-track import runner: PHI rows (3DES) → D-SDR-27, John Doe rows → standard transcode. ~100 LOC + 2 tests. +- **D-SDR-30** — Legacy 3DES key handling: hold under audit during import; **destroy on successful import + sanity-check verification**. ~30 LOC + 1-page governance doc. + +### 16.7 MedCare MySQL Struktur reality check + +The actual schema (`MedCare-rs/.MYSQL/Struktur.sql`, 90 KB, 104 tables) confirms the architecture: + +| Prefix | Count | Role | +|---|---|---| +| `pf_*` | 38 | Patient file (diagnosis, allergy, vaccination, vital, lab, operation, therapy) | +| `combo_*` | 29 | Lookup dimensions (anrede=salutation, rasse=race, spez=specialty, drugs, morbidity) | +| `praxis_*` | 12 | Clinic config (patient, mitarbeiter=staff, license, waitingroom) | +| `glob_*` | 9 | Global (formular, mail, update, user_right) | +| `pat_*`, `file_*`, `pdf_*` | 4+4+2 | Patient combinations, attachments, exports | +| Other | 6 | tpk, customerdb, f_2_3, etc. | + +**Schema observations:** +- All columns are `VARCHAR(250)` / `TEXT` / `DATETIME` — no `BLOB` or `VARBINARY`. **3DES encryption is application-layer, not at-rest in column types.** Encrypted columns hold opaque base64-encoded ciphertext as strings. +- MySQL TDE is OFF (`DEFAULT ENCRYPTION='N'`). +- Schema is purely clinical — billing/tickets are NOT in this database; they live in WoA / Hiro / HubSpot schemas separately. +- The 38 `pf_*` tables are SUBSTRUCTURE of one Patient entity (allergies, vitals, diagnoses), not 38 distinct OGIT entities. +- Healthcare basin slot count estimate: **~5-8 core clinical entities + ~10-15 verbs + ~29 OWL enumerations rolled into slots = ~30-50 slots used**, well within the 256-slot per-basin ceiling. + +**D-SDR-27 implementation gap:** the one-shot import tool needs MedCare's C# app source for the column→3DES key mapping (which specific VARCHAR columns are 3DES-encrypted with which derived key). Pending MCP scope expansion to `AdaWorldAPI/MedCare` + `AdaWorldAPI/MedCareV2`. + +--- + +## 17 — DataFusion SQL inside LanceDB as unified persistence + access (2026-05-13, same session) + +### 17.1 Convergence architecture + +``` + ┌────────────────────────────────┐ + │ LanceDB (single source) │ + │ + DataFusion logical plans │ + └──────────────┬─────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌────────▼────────┐ ┌────────▼────────┐ + │ MedCare-rs │ │ MedCareV2 C# │ + │ (in-process │ │ (Arrow Flight │ + │ DataFusion) │ │ SQL gRPC) │ + └─────────────────┘ └─────────────────┘ +``` + +### 17.2 Phase sequencing + +| Phase | State | Drift bridge role | +|---|---|---| +| 0 (now) | MySQL+3DES is source. MedCareV2 + MedCare-rs both pre-prod. | n/a — no live drift | +| 1 | D-SDR-27..30 import: MySQL+3DES → LanceDB (single-shot, throwaway). | n/a — one-time import sanity check | +| 2 | MedCare-rs reads/writes LanceDB natively. MedCareV2 C# dual-writes via Flight SQL. | Active: compare Arrow batches across paths | +| 3 | Drift-clean window confirms parity. | Active: gate cutover decision | +| 4 | MedCareV2's prior store retires. LanceDB = single source of truth. | Retires — drift bridge becomes CI gate only | + +### 17.3 Arrow Flight SQL replaces custom Protobuf IDL (D-SDR-20 resolution) + +Earlier draft proposed a custom Protobuf IDL with Rust prost + C# Grpc.Tools. **Redundant** — Arrow Flight SQL already has a defined gRPC schema (`Flight.proto` + Substrait for the logical plan layer). The DTOs from §3 (`OgitFamily`, `OwlIdentity`, `RoleGroup`, etc.) become **Arrow column metadata + Substrait extension types**, not bespoke message definitions. + +### 17.4 Drift detection at Flight SQL boundary + +```rust +/// Drift comparison at the Flight SQL boundary. +/// Both clients fetch the same query result; we compare MerkleRoots +/// of the Arrow batches they each received and processed. +pub struct ArrowBatchDriftSignal { + pub query_hash: u64, // FNV-1a of canonical query string + pub per_client_merkle: Vec<(BridgeImpl, MerkleRoot)>, // one per client implementation + pub batch_row_count: u64, + pub timestamp: u64, +} +``` + +Drift can only come from: +- **Client serialization** — Arrow IPC is byte-deterministic; rare +- **Post-deserialization processing** — MedCareV2 C# applying a transform MedCare-rs doesn't; this IS the drift surface +- **Logical-plan divergence** — only if MedCareV2 builds plans on its own; if it just submits raw SQL/Cypher, this evaporates + +### 17.5 Tier F replacements (supersedes §15+§16 drift infrastructure) + +- **D-SDR-31** — Arrow Flight SQL endpoint server in `lance-graph`. Wraps the existing DataFusion catalog. ~150 LOC + 4 integration tests. Replaces D-SDR-20 custom-IDL plan. +- **D-SDR-32** — C# `Apache.Arrow.Flight.Sql` client wrapper for MedCareV2 with auth header carrying `TenantContext.encryption_key` handle. ~80 LOC + 2 tests against the D-SDR-31 server. Lands inside MedCareV2's source tree. +- **D-SDR-33** — Substrait extension types for `OwlIdentity` + `MerkleRoot` + `SuperDomain` so plans containing these types serialize cleanly across the Flight SQL wire. ~120 LOC + 3 tests. +- **D-SDR-34** — Phase-2 dual-write coordination: `DriftDetectionBridge` operates against Flight SQL `GetFlightInfo` + `DoGet` to compare per-client MerkleRoots over identical queries. ~150 LOC + 4 integration tests covering the Phase 2 dual-write window. + +### 17.6 Dropped from earlier scope + +- ~~Custom Protobuf IDL (D-SDR-20 original)~~ — Arrow Flight SQL handles the wire layer +- ~~Multi-trustee key escrow (D-SDR-30 expanded)~~ — no live system to coordinate; one-shot 3DES import retires keys cleanly +- ~~Persistent production drift infrastructure~~ — drift bridge bounded to Phase 2-3 cutover window +- ~~`MySQLAdapterBridge` (D-SDR-24)~~ — replaced by D-SDR-27 one-shot import +- ~~C-ABI FFI option for hot-path C# access~~ — Flight SQL is the single bridge +- ~~"3 unknown ciphers" question~~ — single 3DES, single decryption step + +### 17.7 Net architecture summary (after §13 + §14 + §15 + §16 + §17) + +``` +Per-row data: + LanceDB row carries: tenant_id u32 + owl_id u16 + ciphertext + merkle_root (cleartext) + Total per-row identity: 6 bytes; merkle ~8 bytes; payload variable. + +Access layer: + Cypher / SPARQL / Gremlin / SQL → DataFusion logical plan → LanceDB scan + Both clients (Rust direct, C# via Flight SQL) hit the same plan layer. + +RBAC enforcement: + PolicyRewriter chain in lance-graph-callcenter::policy + (RowFilter + ColumnMask + RowEncryption + DifferentialPrivacy + Audit) + composed by UnifiedBridge::authorize() 4-stage flow. + +Drift detection: + Phase 2-3 only. Compares ArrowBatchDriftSignal across clients. + Retires after Phase 4 cutover; demoted to CI gate. + +Migration: + One-shot D-SDR-27..30 (MySQL+3DES → LanceDB). + Throwaway tool. 3DES keys destroyed on completion. + +Cross-language surface: + Arrow Flight SQL gRPC (no custom IDL). + Substrait extension types for OwlIdentity + MerkleRoot + SuperDomain. + +Bridge harvest: + medcare_bridge.rs + sharepoint_bridge.rs as canonical pattern source. + woa_bridge.rs retrofit; hiro_bridge.rs + hubspot_bridge.rs new templates. + ~45 LOC each after MetaBridge extraction. +``` + +### 17.8 Open questions deferred to §18 (pending MCP scope expansion) + +- 3DES column inventory for `MedCare` (which VARCHAR columns are encrypted with which derived key) +- Transcoded shape that already accounts for SQL import + app-layer decryption per user note +- Final scope of D-SDR-27 implementation (depends on column inventory) + +These unblock once `AdaWorldAPI/MedCare` + `AdaWorldAPI/MedCareV2` are in scope. §18 will fold the findings in as a follow-up commit. + +--- + +## 18 — Empirical reality check: MedCare + MedCareV2 inspection (2026-05-13, same session) + +After scope expansion via pygithub REST (token-quote-stripped per cca2a-sprint-prompt-template guardrail), inspected `AdaWorldAPI/MedCareV2` and `AdaWorldAPI/MedCare-rs@claude/csharp-handoff-docs-L3DF0`. Findings refine §15-§17 substantially. + +### 18.1 The drift bridge is already designed and partially scaffolded + +**`MedCareV2/MedCare_2.0/LanceProbe/`** is the C# parity tool — exactly the §15-§17 drift bridge concept, already designed and scaffolded (M1 complete, M2-M6 pending). Per `MedCareV2/docs/PARITY_TOOL_OVERVIEW.md`: + +> This repo is a copy of `AdaWorldAPI/MedCare` plus a small **side-channel verification tool** under `MedCare_2.0/LanceProbe/`. The production behaviour of MedCareV2 is identical to MedCare; the addition exists to prove that the parallel Rust port (`AdaWorldAPI/medcare-rs`) returns the same data MySQL does for every operation. + +**Correction to §16.2:** I previously framed MedCareV2 as "partial rewrite, reshapeable freely." That's wrong — it's MedCare verbatim + LanceProbe overlay, with the explicit constraint *"do NOT refactor... the diff must be additions only"* per the handoff doc. MedCareV2 cannot be reshaped; it must remain bit-identical to MedCare except for the overlay. + +### 18.2 LanceProbe ↔ §15-§17 DTO mapping (largely 1:1) + +| LanceProbe component | File | LOC | §15-§17 analog | Alignment | +|---|---|---|---|---| +| `ParityClient.cs` | LanceProbe/ParityClient.cs | ~6 KB | MetaBridge HTTP transport (now Arrow Flight SQL per §17.3) | Exact | +| `ParityWitness.cs` | LanceProbe/ParityWitness.cs | ~36 KB | `DriftDetectionBridge` impl + canonicalization rules | Exact (heart of the tool) | +| `DriftSink.cs` | LanceProbe/DriftSink.cs | ~8 KB | `JsonLinesAuditSink` + bounded async queue | Exact | +| `LanceProbe.cs` | LanceProbe/LanceProbe.cs | ~10 KB | Probe entry façade; sample-gated, fire-and-forget | New role — no Rust analog yet | +| `LanceProbeBootstrap.cs` | LanceProbe/LanceProbeBootstrap.cs | ~8 KB | Init/wiring; reads `` App.config | New role — Rust side has no equivalent config layer | +| `Forms/ParityPanel.cs` | LanceProbe/Forms/ | — | Admin WinForms badge + drift log viewer | UI only — no Rust equivalent | +| `Redactors/DiagnosisRedactor.cs` | LanceProbe/Redactors/ | — | `FieldRedactionMask` / `RedactionMode::Hash` per §3.6 | Maps to spec | + +**Concepts in LanceProbe with no Rust spec analog (need to add):** +- **`ArrayShapeRule`** — per-route reshape table for positional `string[]` → JObject keyed by Rust JSON names. **Action**: Rust must publish stable JSON DTO contracts so C# doesn't need per-route mappings in steady state. +- **`TelemetrySnapshot`** — flat struct for programmatic monitoring. **Action**: add `/api/__parity/telemetry` endpoint to medcare-rs (planned but not specified in §17). +- **Sampling-rate CoW dictionary** — thread-safe copy-on-write for per-route rate overrides. C# implementation detail; Rust side doesn't need a hot-path equivalent. + +### 18.3 Coordination spec lives at `MedCare-rs/docs/CSHARP_HANDOFF_PROMPT.md` (branch `claude/csharp-handoff-docs-L3DF0`) + +**The handoff doc IS the §17.2 phase sequencing made concrete:** + +| Milestone | Status | Scope | +|---|---|---| +| M1 — Scaffolding | Complete | LanceProbe/ skeleton compiles; classes throw `NotImplementedException` | +| M2 — HTTP client + 5 DTOs | Pending | Wire `ParityClient` to 5 medcare-rs axum endpoints (Patient/Lab/Vital/Diagnosis/Wartezimmer) | +| M3 — Canonicalization + tests | Pending | Implement the canonicalization rule table (§18.4) + named tests | +| M4 — UI badge integration | Pending | Hook `ParityPanel` into 5 pilot screens (admin-only) | +| M5 — Drift upload + cross-session dashboard | Blocked | Needs medcare-rs `POST /api/__parity/csharp` ingest + `GET /api/__parity` dashboard | +| M5a — Legacy 3DES migration | Blocked, DRAFT | Needs `legacy-tripledes-fallback` feature flag in medcare-rs (not yet merged pending human review) | +| M6 — Sampling + production rollout | Pending | Default 1% sampling; per-route override during validation weeks | + +**medcare-rs side gaps (the Rust deliverables this spec must produce):** +- `POST /api/__parity/csharp` ingest endpoint — planned PR `claude/parity-introspection`, not yet landed +- `GET /api/__parity` canonical dashboard — depends on ingest +- `_dto_contracts.md` published with stable JSON DTO contracts — pending +- `legacy-tripledes-fallback` feature flag — DRAFT, blocked on Crypt.cs audit against live `medcare_30` rows + +### 18.4 Canonicalization rules (the actual §15.2 determinism table) + +Per `CSHARP_HANDOFF_PROMPT.md` lines 93-104, the C# canonicalization rules are concrete, not the abstract list I sketched in §15.2: + +| Field pattern | Canonical form | Notes | +|---|---|---| +| `geburtsdatum` / `p_birth` | ISO 8601 date-only | Ignore time + timezone | +| `werte` / `value` | `"F4"` double (4 decimals, InvariantCulture) | Decimal precision rule from §15.2 made concrete | +| `pf_delete` / `deleted` | bool (null/0 → false) | Soft-delete normalization | +| `db_spez` | bool | Coerce tinyint(1) to bool | +| `u_pwd` | byte-equivalent (no decrypt for parity) | TripleDES ciphertext compared opaquely | +| `created_at` / `d_createdate` / `updated_at` | second-truncated ISO 8601 | Timestamp rule from §15.2 made concrete | + +**§15.2 update:** these 6 rules supersede the 12 abstract determinism rules. Real-world canonicalization is more concrete and bounded than I framed; D-SDR-26 test surface drops to **6 named tests covering exactly these field patterns**. + +### 18.5 CRITICAL crypto correction: not actually 3DES, effectively single DES + +**The "3DES" in MedCare is broken security, not just outdated.** Per `MedCare_2.0/Crypt.cs:438-451`: + +```csharp +TripleDESCryptoServiceProvider des = new TripleDESCryptoServiceProvider(); +des.IV = new byte[8]; // ← ZERO IV +PasswordDeriveBytes pdb = new PasswordDeriveBytes(password, new byte[0]); // ← NO SALT +des.Key = pdb.CryptDeriveKey("RC2", "MD5", 128, new byte[8]); // ← 128-bit (not 192!) +``` + +**Crypto findings:** +- **Cipher:** `TripleDESCryptoServiceProvider` — *but key is truncated to 128 bits (16 bytes) instead of full 192 bits (24 bytes)*. With only 2 of the 3 DES rounds, this is cryptographically equivalent to **single DES with 56-bit effective key strength** — broken since the late 1990s. +- **Mode:** ECB-implicit (zero IV + no `Mode = CipherMode.CBC` set; .NET defaults `TripleDESCryptoServiceProvider` to `Mode.CBC` but with zero IV every record encrypts deterministically — equivalent to ECB for the password-array case where each user gets a distinct password from a 62-entry table). +- **Padding:** PKCS7 (.NET default). +- **KDF:** `PasswordDeriveBytes(...).CryptDeriveKey("RC2", "MD5", 128, new byte[8])` — non-standard composite (RC2-shaped key derived through MD5 hash, then handed to TripleDES). Salt is `new byte[0]` (no salt). +- **Password source:** **Hardcoded 62-entry array `Passwort_Crypt[0..61]`** at `Crypt.cs:285-349` — passwords like `"Xyad[:6*"`, `"TXÄZmn=y"`, etc. Random `Random.Next(1, 61)` selects an entry per encryption; decryption reads the first ciphertext char to look up the index. **NOT user-input-derived** in any standard sense. +- **Ciphertext format:** `[1-char password-array index (1-9, a-z, A-Z mapped to 1-61)][base64(encrypted bytes)]` + +**Implications for spec:** +- D-SDR-27 (3DES rewrap tool) must handle this specific broken construction, not standard 3DES. The decryption code path is: read first char → unmap to array index → look up password from hardcoded table → derive key via the broken KDF → decrypt with zero-IV ECB-equivalent. ~30 lines of careful C-style decryption logic. +- The user's "John Doe = billing+tickets" framing now reads as: *most rows are not encrypted at all; only `praxis_mitarbeiter.u_pwd` (login passwords) and possibly a small set of others go through `EncryptMessage()`/`DecryptMessage()`*. +- **AUTH_LEGACY_TRIPLEDES_MIGRATION.md** in MedCare-rs already acknowledges this is broken and plans Argon2 backfill on first successful login. The migration plan is more sophisticated than just "rewrap with AES-GCM" — it's **upgrade-on-login** to Argon2 (a password-hashing function, not a symmetric cipher), since the legacy ciphertext represents passwords, not encrypted data at rest. + +**§16.4 update:** D-SDR-27's pipeline description (decrypt-3DES → AES-256-GCM rewrap) is wrong for the password column. Correct shape: +- For `u_pwd`: do NOT decrypt during import. Carry the legacy ciphertext forward; on next successful user login, re-derive Argon2 hash from the user-typed password and replace the ciphertext column with an Argon2 hash column. Legacy auth path stays available as feature-flagged fallback (`legacy-tripledes-fallback`). +- For other 3DES-encrypted columns (TBD via grep of MySQL_Connect.cs for `.EncryptMessage(` / `.DecryptMessage(` call sites): **possibly none** — the user's earlier statement "billing customers + tickets" suggests most data isn't encrypted. + +### 18.6 D-SDR-27 scope refinement + +**Reduced scope after empirical findings:** + +```rust +/// Import path for legacy 3DES password rows (u_pwd column on praxis_mitarbeiter). +/// Does NOT decrypt during import — carries ciphertext forward as opaque blob. +/// Argon2 backfill happens on first successful login (separate code path). +pub fn import_legacy_3des_password_row( + legacy_row: &MySQLRow, +) -> MigratedRow { + MigratedRow { + owl: classify_to_owl_identity(legacy_row), + merkle_root: MerkleRoot::from_fingerprint(&Fingerprint::from(&legacy_row.u_pwd)), + legacy_ciphertext: legacy_row.u_pwd.clone(), // opaque, byte-compared by parity + argon2_hash: None, // populated on first login + super_domain: SuperDomain::Healthcare, + } +} + +/// Plaintext columns (everything else) — straight transcode, no decryption. +pub fn import_plaintext_row(legacy_row: &MySQLRow) -> MigratedRow { /* ... */ } +``` + +**Updated D-SDR-27 deliverable:** ~80 LOC + 2 integration tests (one for password column carry-forward, one for plaintext transcode). Throwaway after import + drift-clean window. **Drops the 3DES-decrypt-rewrap-with-AES-GCM logic entirely** — it's wrong for the actual data shape. + +### 18.7 medcare-rs deliverables this spec must produce (concrete from handoff doc) + +These slot under Tier F or a new Tier H, blocking M5/M6 of the LanceProbe milestones: + +- **D-SDR-35** — `POST /api/__parity/csharp` ingest endpoint in medcare-rs. Receives `DriftEvent` JSON from `DriftSink` batch flushes; persists to a Lance table for cross-session aggregation. ~150 LOC + 4 tests. +- **D-SDR-36** — `GET /api/__parity` canonical dashboard endpoint. Aggregates drift events across sessions (group-by route + time bucket); JSON output for the admin-only ParityPanel. ~120 LOC + 3 tests. +- **D-SDR-37** — `_dto_contracts.md` document. Stable JSON DTO contracts for the 5 pilot endpoints (Patient/Lab/Vital/Diagnosis/Wartezimmer) + the planned 40+ additional routes from FUTURE_STACK_ADMIN.md §4. Doc only, ~300 lines markdown. **Blocks M2** until published. +- **D-SDR-38** — `legacy-tripledes-fallback` feature flag in medcare-rs auth path. When enabled, accepts both Argon2 and legacy-3DES password verification; backfills Argon2 on successful legacy auth. ~180 LOC + 6 tests covering the upgrade-on-login flow. **Blocks M5a** until merged. +- **D-SDR-39** — `/api/__parity/telemetry` endpoint exposing TelemetrySnapshot equivalent for programmatic monitoring (LanceProbe's flat struct). ~80 LOC + 2 tests. + +### 18.8 Phase sequencing now concrete (replaces §17.2 abstract version) + +| Phase | LanceProbe milestone | medcare-rs deliverable | Both ready when | +|---|---|---|---| +| 0 (now) | M1 done | D-SDR-31..34 spec drafted | — | +| 1 | M2 starts | D-SDR-31 (Flight SQL server) + D-SDR-37 (DTO contracts) lands | C# wires real HTTP to first 5 endpoints | +| 2 | M3 + M4 | D-SDR-33 (Substrait extension types) | Canonicalization tests pass; admin badge surfaces on 5 screens | +| 3 | M5 starts | D-SDR-35 (ingest) + D-SDR-36 (dashboard) | Drift events flow C# → Rust → cross-session aggregation | +| 3a | M5a | D-SDR-38 (TripleDES fallback flag) | Pilot users can reset passwords without legacy auth blocker | +| 4 | M6 | D-SDR-39 (telemetry) + D-SDR-30 (key destroy) | 1% sampling rolls out; legacy 3DES retires post-Argon2-backfill | + +### 18.9 Net corrections to prior sections (§13-§17) + +| Prior framing | Corrected by §18 | +|---|---| +| MedCareV2 is reshapeable freely | NO — copy of MedCare + LanceProbe overlay; do NOT refactor | +| 3DES is single-cipher-rewrap-to-AES-GCM | Mostly NO — only `u_pwd` column; carry ciphertext forward, Argon2-backfill on login | +| 12 cross-language determinism rules | 6 concrete canonicalization rules (date / decimal / bool / soft-delete / pwd / timestamp) | +| Drift bridge needs to be designed | Already designed; 5/8 LanceProbe components exist as scaffolds, await wiring | +| Custom Protobuf IDL → Arrow Flight SQL | Confirmed — handoff doc uses HTTP+JSON over JWT, not Flight SQL. **§17.3 is aspirational; M2-M6 ship HTTP+JSON first**, Flight SQL is a Phase 5+ migration | +| ~5-10 D-SDR deliverables in Tier F | Concrete Rust-side gap = 5 endpoints (D-SDR-35..39); plus D-SDR-27 scope reduction | + +**§17.3 update:** Arrow Flight SQL is the **future end-state** but the immediate path is HTTP+JSON over JWT (what LanceProbe already targets). Migration to Flight SQL is a Phase 5 add-on after the JSON path is drift-clean. + +### 18.10 Open questions resolved + new + +**Resolved by §18:** +- ✅ Audit format choice → `DriftEvent` JSON via `DriftSink` async batch upload +- ✅ Cross-tenant federation → bounded to migration window, then CI gate +- ✅ DEK rotation cadence → not relevant for `u_pwd` (Argon2 backfill replaces the question) +- ✅ Hard-lock partner matrix completeness → defer until billing/ticket basins are concretized +- ✅ Per-super-domain DP epsilon defaults → not blocking (Phase 2 federation) +- ✅ MedCareV2 reshape freedom → it has none; overlay only +- ✅ 3DES column inventory → only `u_pwd`; possibly others pending MySQL_Connect.cs grep +- ✅ Transcoded shape → it's an overlay (LanceProbe), not a transcode + +**New open questions:** +- Which other columns (besides `u_pwd`) call `EncryptMessage()` / `DecryptMessage()` in MySQL_Connect.cs? Needs a focused grep of the 721 KB file. Likely: very few or none. +- DTO contracts for the 40+ additional routes per FUTURE_STACK_ADMIN.md §4 — D-SDR-37 needs to enumerate them. Pending agent pass. +- `MedCare-rs/docs/AUTH_LEGACY_TRIPLEDES_MIGRATION.md` is DRAFT — what blocks promotion to Active? (Likely: D-SDR-38 implementation + human security review.) + +### 18.11 Status + +- **Architecture:** stable; §18 confirms most of §15-§17 with corrections. +- **C# scaffolding:** M1 complete; M2-M6 pending Rust-side endpoints (D-SDR-35..39). +- **Crypto migration:** scoped down to Argon2-backfill-on-login for `u_pwd` only; no AES-GCM rewrap needed. +- **Coordination doc:** lives at `MedCare-rs/docs/CSHARP_HANDOFF_PROMPT.md` on branch `claude/csharp-handoff-docs-L3DF0`; should be merged or referenced from this spec's path. + +**Confidence:** Working — empirical inspection of both repos confirms the architecture; the C# parity tool already implements the drift bridge as scaffolded code; the Rust side gaps are concrete (5 endpoints) and small (~700 LOC + tests across D-SDR-35..39). + +--- + +## 19 — Build invariants + SIMD strategy (2026-05-13, same session) + +### 19.1 Pinned versions + +All Rust deliverables (D-SDR-1..39) sit on the following workspace pins, already established by PR #275 + per `Cargo.toml`: + +```toml +rust-version = "1.94.1" # MSRV; 1.94 stabilizes portable_simd patterns ndarray::simd uses +lance = "=4.0.0" # exact pin per Cargo.toml +lancedb = "0.27.2" # caret per Cargo.toml; PR #275 introduced +``` + +**Implication for the spec:** D-SDR-28 (MerkleRoot-beside-ciphertext storage layout) targets `lance =4.0.0` schema; D-SDR-31 (Arrow Flight SQL endpoint) targets `lancedb 0.27.2` exposure of the DataFusion catalog. No floating versions; no surprise minor-version bumps mid-implementation. + +### 19.2 ndarray::simd is the canonical SIMD path + +All vectorized operations across the spec use **`ndarray::simd`** from the workspace's vendored ndarray fork at `/home/user/ndarray`. Not raw `std::simd`, not `packed_simd`, not hand-rolled platform intrinsics. One SIMD path; one set of tests; one cross-platform behavior contract. + +**Hot-path operations that should use `ndarray::simd`:** + +| Op | DTO | SIMD pattern | +|---|---|---| +| Per-row `OwlIdentity` bitmask scans (Cypher MATCH lowering) | `OwlIdentity` (§3.2) | masked u16 compare, vectorized over a column | +| Batch `MerkleRoot` computation across rows | `MerkleRoot` (§13.3) | parallel XOR-fold over fingerprint slices | +| `BitSet256` bitwise ops (role redaction mask) | `BitSet256` (§3.6) | 4×u64 SIMD AND/OR/contains-bit | +| Per-family codebook PQ centroid distance | `OgitFamilyTable.codebook` (§3.3) | `ndarray::simd` Hamming/L2 (already shipped in `ndarray::hpc`) | +| Canonicalization rule application across batch | §18.4 rules | vectorized string comparison + decimal normalization | +| DataFusion predicate vector composition | `UnifiedBridge::authorize` (§3.9) | 3-stage masked predicate combine into one bool vector | +| `ArrowBatchDriftSignal` MerkleRoot-of-batch | §17.4 | XOR-fold over the batch's interleaved (id, merkle) pairs | + +**Tier A LOC reduction:** several DTO method bodies that I sketched as scalar loops collapse to `ndarray::simd` one-liners. D-SDR-1..3 estimated LOC drops by ~15-25% (the scalar fallback paths are no longer needed as separate code). + +### 19.3 ndarray as mandatory dep (deferred workstream) + +Per user directive: **`ndarray` should be a mandatory dep of `lance-graph`, not the current optional `ndarray-hpc` feature.** Currently: + +```toml +# Current (lance-graph/Cargo.toml — to be retired) +[features] +default = ["unity-catalog", "delta", "ndarray-hpc"] +ndarray-hpc = ["dep:ndarray"] + +# Target (post-migration) +[dependencies] +ndarray = { path = "../../../ndarray", default-features = false } +# (no feature gate; always present) +``` + +**This is a separate concurrent workstream**, not blocking any D-SDR-* deliverable in this spec. **Status per CLAUDE.md**: Phase 3 IN PROGRESS already includes "Wire ndarray as default dep (Cargo.toml change + `ndarray-hpc` feature flag)" — this spec's directive **promotes** that to "make ndarray mandatory, retire the feature flag." No-op for the architecture; it just removes the `#[cfg(feature = "ndarray-hpc")]` branches. + +**All D-SDR-* deliverables in this spec assume ndarray is present** (i.e., as if the Phase 3 + new mandatory-promotion workstream has landed). If Tier A ships before the mandatory-promotion lands, the deliverables sit behind `#[cfg(feature = "ndarray-hpc")]` temporarily; once the feature flag retires, the cfg gates are deleted (mechanical change). + +### 19.4 Sequencing impact (none for D-SDR-* shipping order) + +The mandatory-ndarray promotion is **decoupled** from this spec's deliverables: + +| Workstream | Owner | Blocks D-SDR-*? | +|---|---|---| +| Promote ndarray from `ndarray-hpc` feature → mandatory dep | Phase 3 (concurrent) | No — Tier A ships behind feature flag in interim | +| Retire `blasgraph/ndarray_bridge.rs` standalone fallbacks | Post-promotion cleanup | No — fallbacks were never used by D-SDR-* code | +| Retire `#[cfg(feature = "ndarray-hpc")]` gates from D-SDR-* | Mechanical post-promotion | No — one-shot find/replace | + +### 19.5 Brutal-honest tradeoff + +`ndarray::simd` adds a workspace-internal dep coupling: lance-graph's MSRV moves in lockstep with ndarray's. Today both are Rust 1.94.1; if ndarray jumps MSRV, lance-graph + all consumers (medcare-rs, smb-office-rs, hiro-rs, hubspot-rs) jump too. This is acceptable because the workspace already treats ndarray as the SIMD foundation per CLAUDE.md ("ndarray = The Foundation (SIMD, GEMM, HPC, ...)"); the alternative (every crate independently picking a SIMD strategy) was already rejected. + +The version pinning (lance =4.0.0, lancedb 0.27.2, rust 1.94.1) is **stricter** than the rest of the workspace asks for, which is correct for this spec's deliverables since they touch the storage layer + Flight SQL endpoint where minor-version drift would cause real bugs. + +### 19.6 Status + +- **Pinned versions:** stable; no action needed beyond using them in `Cargo.toml` for new crates (woa-rs/hubspot-rs/hiro-rs). +- **`ndarray::simd` adoption:** assumed baseline for all D-SDR-* deliverables; no separate deliverable needed. +- **Mandatory ndarray promotion:** **NOT in this spec's scope.** Tracked as Phase 3 + post-Phase-3 cleanup workstream. This spec ships against either world (with cfg-gates as transient overhead until promotion lands). + +### 19.7 Correction (same session): the SIMD dispatch pattern is already shipped — just import and use + +I framed §19.2 as "adopt ndarray::simd" with a "Tier A LOC drops ~15-25%" benefit. **Misleading.** The dispatch infrastructure is already shipped in the workspace's vendored ndarray fork; no new SIMD code is being avoided because no new SIMD code was ever going to be written. D-SDR-* deliverables just **import and call** `ndarray::simd::` and the dispatch is transparent. + +**Existing file layout** (verified in `/home/user/ndarray/src/`): + +``` +ndarray/src/ +├── simd.rs # top-level entry; re-exports ops +├── simd_avx2.rs # x86 AVX2 implementations +├── simd_avx512.rs # x86 AVX-512 implementations +├── simd_neon.rs # ARM NEON implementations +├── simd_amx.rs # Apple AMX implementations +├── simd_wasm.rs # WASM SIMD implementations +└── hpc/ + ├── simd_caps.rs # LazyLock — CPU detect ONCE at first call + └── simd_dispatch.rs # routes to the right per-ISA impl based on detected caps +``` + +**The dispatch contract:** + +1. First call to any `ndarray::simd::*` op triggers `LazyLock` initialization in `hpc/simd_caps.rs`, which runs CPU feature detection once (CPUID on x86, runtime detection on ARM, etc.) and caches the result for the process lifetime. +2. Each subsequent call goes through `hpc/simd_dispatch.rs`, which reads the cached caps and routes to the matching `simd_{avx512,avx2,neon,amx,wasm}.rs` implementation. +3. **Polyfill:** if no SIMD ISA matches the host CPU, dispatch falls through to a scalar implementation (the polyfill). The scalar path is correctness-equivalent, just slower; consumers don't need cfg-gates. + +**Already-shipped consumers** of this pattern (per CLAUDE.md "ndarray = The Foundation (SIMD, GEMM, HPC, Fingerprint<256>, CAM-PQ codec)"): + +- `bgz17::batch_palette_distance` — SIMD palette distance over CSR rows +- `lance-graph::graph::blasgraph::*` — SIMD Hamming over fingerprints +- `bgz-tensor::cascade::*` — SIMD attention table lookup +- `thinking-engine::role_tables::*` — SIMD per-role projection +- `lance-graph::graph::neighborhood::*` — SIMD CLAM scope filter + +**Concrete consequence for D-SDR-* deliverables:** + +- D-SDR-1..3 (DTOs): no SIMD code authored. `BitSet256::contains`, `OwlIdentity::matches`, `MerkleRoot::from_fingerprint` either delegate to `ndarray::simd::*` or stay scalar (the per-row hot path doesn't need SIMD; the *batch* hot path does, and that's what `ndarray::simd::*` already handles for fingerprint columns). +- D-SDR-25 (DriftDetectionBridge): batch MerkleRoot comparison uses `ndarray::simd::xor_fold` (already shipped) over the column. +- D-SDR-31 (Arrow Flight SQL endpoint): no SIMD concerns at the endpoint; the underlying DataFusion plan execution already uses ndarray::simd through `lance-graph-planner::physical::*`. + +**The Tier A LOC reduction framing in §19.2 was wrong** — LOC was never inflated by hand-rolled SIMD in the first place. The correct framing: D-SDR-* code looks like normal Rust, and ndarray::simd is invisible at the call site (which is the point of a dispatch module). + +**Cfg-gating concern (§19.4) also overstated** — since other workspace crates already depend on ndarray unconditionally for SIMD, the `#[cfg(feature = "ndarray-hpc")]` gate at the lance-graph level is the *outlier*, not the norm. Once Phase 3 promotes ndarray to mandatory, the gate at lance-graph aligns with what bgz17 / bgz-tensor / thinking-engine already do. + +**No spec change needed beyond this clarification.** D-SDR-* deliverables stand as written; just don't expect the SIMD layer to be visible in their LOC counts. diff --git a/Cargo.lock b/Cargo.lock index 2eb1e90c5..79b6331c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4362,9 +4362,11 @@ dependencies = [ "lance", "lance-graph-contract", "lance-graph-ontology", + "lance-graph-rbac", "serde", "serde_json", "syn 2.0.117", + "thiserror 1.0.69", "tokio", "tokio-tungstenite 0.24.0", "tower-http 0.5.2", diff --git a/crates/lance-graph-callcenter/Cargo.toml b/crates/lance-graph-callcenter/Cargo.toml index 1d1f91160..b258f8dae 100644 --- a/crates/lance-graph-callcenter/Cargo.toml +++ b/crates/lance-graph-callcenter/Cargo.toml @@ -12,6 +12,10 @@ build = "build.rs" lance-graph-contract = { path = "../lance-graph-contract" } # D-CASCADE-V1-3 — bridge collapse: factories project from the canonical SoA. lance-graph-ontology = { path = "../lance-graph-ontology" } +# D-SDR-1 (super-domain-rbac-tenancy-v1 §3.9 + §13.1) — UnifiedBridge composes +# the per-namespace ontology bridge with the RBAC Policy + TenantId. +lance-graph-rbac = { path = "../lance-graph-rbac" } +thiserror = "1" # [persist] — Arrow RecordBatch + Lance dataset ops arrow = { version = "57", optional = true } diff --git a/crates/lance-graph-callcenter/src/lib.rs b/crates/lance-graph-callcenter/src/lib.rs index a7e54e264..3174d69cf 100644 --- a/crates/lance-graph-callcenter/src/lib.rs +++ b/crates/lance-graph-callcenter/src/lib.rs @@ -129,3 +129,24 @@ pub mod policy; // **single deliberate transition bandaid**: `parallelbetrieb` for the // MySQL ↔ DataFusion ↔ SPO reconciler. See `transcode/mod.rs`. pub mod transcode; + +// D-SDR-1 (super-domain-rbac-tenancy-v1 §3.9 + §13.1) — UnifiedBridge +// composes a per-namespace `NamespaceBridge` (g-locked ontology lookup) with +// an RBAC `Policy` (role-based access decisions) and a `TenantId` (multi- +// tenant Chinese wall tag). Single entry point consumers import; richer +// surface (super-domain routing, role groups with FieldRedactionMask, merkle +// audit chain) lands in follow-up commits. +pub mod unified_bridge; +pub use unified_bridge::{AuthError, OgitFamily, OwlIdentity, TenantId, UnifiedBridge}; + +// D-SDR-2 (super-domain-rbac-tenancy-v1 §3.4-§3.7) — SuperDomain layer. +// Activation root above OGIT basins (1 byte; 8 starter values; 256 cap) +// plus MetaAnchors (Foundry/OWL/DOLCE/Wikidata cross-walks), ComplianceRegime +// (HIPAA/SOX/PCI-DSS/GDPR/OSINT/ITAR), and the FAMILY_TO_SUPER_DOMAIN +// reverse lookup. The UnifiedBridge::authorize() wiring against these +// types lands as D-SDR-5; this module is type-system-only. +pub mod super_domain; +pub use super_domain::{ + super_domain_entry, super_domain_for_family, ComplianceRegime, DolceMarker, MetaAnchors, + SuperDomain, SuperDomainEntry, SUPER_DOMAINS, +}; diff --git a/crates/lance-graph-callcenter/src/super_domain.rs b/crates/lance-graph-callcenter/src/super_domain.rs new file mode 100644 index 000000000..371c30350 --- /dev/null +++ b/crates/lance-graph-callcenter/src/super_domain.rs @@ -0,0 +1,415 @@ +//! Super-domain layer for the unified bridge surface +//! (per `.claude/plans/super-domain-rbac-tenancy-v1.md` §3.4-§3.8 / D-SDR-2). +//! +//! Adds the level-1 routing/governance unit above OGIT basins: +//! +//! ```text +//! Level 0 — META-ANCHORS (interop) +//! Foundry / OWL / DOLCE / Wikidata cross-walks +//! +//! Level 1 — SUPER DOMAIN ←── this module (governance, ~7-10 today) +//! Healthcare / Science / TicketTool / WorkOrderBilling / OSINT / ... +//! 1 byte; RBAC trust boundary + compliance regime + activation routing +//! +//! Level 2 — OGIT BASIN (per-codebook unit) +//! 1 byte (high byte of OwlIdentity); see `unified_bridge::OgitFamily` +//! +//! Level 3 — WITHIN-BASIN SLOT (the row identity) +//! 1 byte (low byte of OwlIdentity) +//! ``` +//! +//! D-SDR-2 scope: type system + `FAMILY_TO_SUPER_DOMAIN` reverse-lookup. +//! The `UnifiedBridge::authorize()` wiring against these types is D-SDR-5. +//! +//! Current placement: this module sits in `lance-graph-callcenter` alongside +//! `unified_bridge.rs`. Per spec §8 Tier A the canonical landing is +//! `lance-graph-contract::rbac`; the move is a mechanical follow-up. + +use crate::unified_bridge::OgitFamily; + +// ═══════════════════════════════════════════════════════════════════════════ +// SuperDomain — Level 1 activation root + RBAC trust boundary +// ═══════════════════════════════════════════════════════════════════════════ + +/// 1 byte. Activation root for spreading-activation queries + +/// RBAC enforcement gate (cross-domain queries denied per §13.4 hard-lock +/// partner matrix) + compliance regime tag. +/// +/// Eight starter values; 256-slot capacity headroom. +/// +/// Promotes the existing `holograph::dntree::WellKnown` constants +/// (CONCEPTS=0x01, ENTITIES=0x02, NSM primes 0x10-0x4F, ...) to +/// first-class business-named activation roots with formal cross-walks. +#[repr(u8)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SuperDomain +{ + #[default] + Unknown = 0, + /// FMA, SNOMED, ICD10, RxNorm, LOINC, RadLex, MONDO, HPO, DRON, CHEBI + Healthcare = 1, + /// Physics, chemistry, math, materials + Science = 2, + /// Genes, sequences, expression, GO + Genetics = 3, + /// Quantum-specific (specialized lift from Science) + QuantumPhysics = 4, + /// Hiro, HubSpot, ServiceNow, Jira, Zendesk + TicketTool = 5, + /// WorkOrder, Billing, Tax, SMB, MRO, MRP, Accounting + WorkOrderBilling = 6, + /// Maltego, intel sources, social graph + Osint = 7, +} + +impl SuperDomain +{ + pub const fn raw(self) -> u8 + { + self as u8 + } + + pub const fn is_known(self) -> bool + { + !matches!(self, SuperDomain::Unknown) + } + + pub const fn as_str(self) -> &'static str + { + match self { + SuperDomain::Unknown => "Unknown", + SuperDomain::Healthcare => "Healthcare", + SuperDomain::Science => "Science", + SuperDomain::Genetics => "Genetics", + SuperDomain::QuantumPhysics => "QuantumPhysics", + SuperDomain::TicketTool => "TicketTool", + SuperDomain::WorkOrderBilling => "WorkOrderBilling", + SuperDomain::Osint => "Osint", + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// DolceMarker — upper-ontology category (per §3.5) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 1 byte. DOLCE upper marker (compressed; only low bits used). +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum DolceMarker +{ + #[default] + Unknown = 0, + Endurant = 1, + Perdurant = 2, + Quality = 3, + Abstract = 4, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MetaAnchors — Level 0 cross-walks to formal upper ontologies (§3.5) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 4 cross-walk pointers — one per upper standard. Each optional. +/// Consulted only when interop with an external system is requested. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MetaAnchors +{ + /// e.g. `Some("PhysicalSystem")` + pub foundry_object_type: Option<&'static str>, + /// e.g. `Some("BiomedicalOntology")` + pub owl_upper_class: Option<&'static str>, + /// DOLCE Endurant / Perdurant / Quality / Abstract + pub dolce_marker: DolceMarker, + /// Wikidata QID (e.g. `Some(11190)` for "medicine") + pub wikidata_qid: Option, +} + +impl MetaAnchors +{ + pub const EMPTY: Self = Self { + foundry_object_type: None, + owl_upper_class: None, + dolce_marker: DolceMarker::Unknown, + wikidata_qid: None, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ComplianceRegime — Level 1 regulatory tag (per §3.7) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 1 byte. Tagged on each `SuperDomainEntry`. Drives certification surface +/// (audit log retention, key-rotation cadence, redaction defaults). +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum ComplianceRegime +{ + #[default] + None = 0, + /// Healthcare + Hipaa = 1, + /// WorkOrderBilling (financial reporting) + Sox = 2, + /// WorkOrderBilling (payment cards) + PciDss = 3, + /// Cross-cutting (any super domain with PII) + Gdpr = 4, + /// OSINT (tiered by classification) + OsintClearance = 5, + /// Science / QuantumPhysics (dual-use research) + ItarEar = 6, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SuperDomainEntry — the static lookup row per super domain +// ═══════════════════════════════════════════════════════════════════════════ + +/// One entry per `SuperDomain`. Lives in static memory; ~30 bytes per entry +/// × 8 entries ≈ 240 B total. +/// +/// `RoleGroup` (§3.6) + `hard_lock_partners` (§13.4) are deferred to a +/// follow-up commit — the minimum-viable shape ships the routing + +/// compliance fields now so `FAMILY_TO_SUPER_DOMAIN` lookups work. +#[derive(Clone, Copy, Debug)] +pub struct SuperDomainEntry +{ + pub super_domain: SuperDomain, + /// Basins this super domain spans (10-30 per domain typically). + pub basins: &'static [OgitFamily], + /// Cross-walks to Foundry / OWL / DOLCE / Wikidata. + pub meta: MetaAnchors, + pub label: &'static str, + /// Compliance regime governing this domain's audit + redaction policy. + pub compliance: ComplianceRegime, +} + +impl SuperDomainEntry +{ + /// Activation drill-down: super domain → constituent basins. + /// Used by spreading-activation queries ("anything in Healthcare"). + #[inline] + pub fn activate(&self) -> &'static [OgitFamily] + { + self.basins + } + + /// Cross-walk: this super domain's anchor in an external upper ontology. + #[inline] + pub fn cross_walk(&self) -> &MetaAnchors + { + &self.meta + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Static registry — placeholder entries for the 8 starter super domains +// ═══════════════════════════════════════════════════════════════════════════ + +/// Starter `SuperDomainEntry` table. Basin lists + cross-walk QIDs are +/// best-effort placeholders; the canonical values land alongside the OGIT +/// namespace inventory (D-SDR-4 / D-SDR-37). +pub const SUPER_DOMAINS: &[SuperDomainEntry] = &[ + SuperDomainEntry { + super_domain: SuperDomain::Unknown, + basins: &[], + meta: MetaAnchors::EMPTY, + label: "Unknown", + compliance: ComplianceRegime::None, + }, + SuperDomainEntry { + super_domain: SuperDomain::Healthcare, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("BiomedicalSystem"), + owl_upper_class: Some("BiomedicalOntology"), + dolce_marker: DolceMarker::Endurant, + wikidata_qid: Some(11190), + }, + label: "Healthcare", + compliance: ComplianceRegime::Hipaa, + }, + SuperDomainEntry { + super_domain: SuperDomain::Science, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("ScientificDiscipline"), + owl_upper_class: Some("ResearchOntology"), + dolce_marker: DolceMarker::Abstract, + wikidata_qid: Some(336), + }, + label: "Science", + compliance: ComplianceRegime::None, + }, + SuperDomainEntry { + super_domain: SuperDomain::Genetics, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("BiomedicalSystem"), + owl_upper_class: Some("GeneOntology"), + dolce_marker: DolceMarker::Endurant, + wikidata_qid: Some(7162), + }, + label: "Genetics", + compliance: ComplianceRegime::Gdpr, + }, + SuperDomainEntry { + super_domain: SuperDomain::QuantumPhysics, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("ScientificDiscipline"), + owl_upper_class: Some("PhysicsOntology"), + dolce_marker: DolceMarker::Abstract, + wikidata_qid: Some(944), + }, + label: "QuantumPhysics", + compliance: ComplianceRegime::ItarEar, + }, + SuperDomainEntry { + super_domain: SuperDomain::TicketTool, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("WorkflowSystem"), + owl_upper_class: Some("ProcessOntology"), + dolce_marker: DolceMarker::Perdurant, + wikidata_qid: None, + }, + label: "TicketTool", + compliance: ComplianceRegime::None, + }, + SuperDomainEntry { + super_domain: SuperDomain::WorkOrderBilling, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("BusinessSystem"), + owl_upper_class: Some("FinancialOntology"), + dolce_marker: DolceMarker::Perdurant, + wikidata_qid: Some(8413436), + }, + label: "WorkOrderBilling", + compliance: ComplianceRegime::Sox, + }, + SuperDomainEntry { + super_domain: SuperDomain::Osint, + basins: &[], + meta: MetaAnchors { + foundry_object_type: Some("IntelligenceSystem"), + owl_upper_class: Some("IntelOntology"), + dolce_marker: DolceMarker::Perdurant, + wikidata_qid: Some(7138900), + }, + label: "OSINT", + compliance: ComplianceRegime::OsintClearance, + }, +]; + +/// Reverse lookup: `OgitFamily` (basin) → `SuperDomain` it belongs to. +/// +/// Single-member by default (per spec §3.4 tradeoff): each basin picks one +/// primary super domain; cross-cutting basins (e.g., HPO/MONDO straddling +/// Healthcare ↔ Genetics) explicitly activate multiple super-domain +/// entries when queried. +/// +/// Populated at hydration time by `NamespaceRegistry::seed_defaults()`; +/// `Unknown` is the safe default for basins not yet classified. +static FAMILY_TO_SUPER_DOMAIN: [SuperDomain; 256] = [SuperDomain::Unknown; 256]; + +/// Lookup the super domain for a given OGIT basin. +/// +/// Returns `SuperDomain::Unknown` for unclassified basins. The mapping is +/// seeded at hydration time; until then this always returns Unknown. +#[inline] +pub fn super_domain_for_family(family: OgitFamily) -> SuperDomain +{ + FAMILY_TO_SUPER_DOMAIN[family.raw() as usize] +} + +/// Lookup the `SuperDomainEntry` for a super domain. +#[inline] +pub fn super_domain_entry(sd: SuperDomain) -> &'static SuperDomainEntry +{ + &SUPER_DOMAINS[sd as usize] +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn super_domain_known_predicate() + { + assert!(!SuperDomain::Unknown.is_known()); + assert!(SuperDomain::Healthcare.is_known()); + assert!(SuperDomain::TicketTool.is_known()); + } + + #[test] + fn super_domain_raw_matches_repr_u8() + { + assert_eq!(SuperDomain::Unknown.raw(), 0); + assert_eq!(SuperDomain::Healthcare.raw(), 1); + assert_eq!(SuperDomain::Osint.raw(), 7); + } + + #[test] + fn super_domain_as_str_matches_variant() + { + assert_eq!(SuperDomain::Healthcare.as_str(), "Healthcare"); + assert_eq!(SuperDomain::WorkOrderBilling.as_str(), "WorkOrderBilling"); + } + + #[test] + fn super_domain_default_is_unknown() + { + assert_eq!(SuperDomain::default(), SuperDomain::Unknown); + } + + #[test] + fn super_domains_table_indexed_by_enum_variant() + { + // The static SUPER_DOMAINS table must be ordered such that + // `SUPER_DOMAINS[sd as usize].super_domain == sd` for every variant. + // This is the contract `super_domain_entry()` relies on. + for sd in [ + SuperDomain::Unknown, + SuperDomain::Healthcare, + SuperDomain::Science, + SuperDomain::Genetics, + SuperDomain::QuantumPhysics, + SuperDomain::TicketTool, + SuperDomain::WorkOrderBilling, + SuperDomain::Osint, + ] { + assert_eq!(super_domain_entry(sd).super_domain, sd); + } + } + + #[test] + fn super_domain_entry_carries_expected_compliance() + { + assert_eq!(super_domain_entry(SuperDomain::Healthcare).compliance, ComplianceRegime::Hipaa); + assert_eq!(super_domain_entry(SuperDomain::WorkOrderBilling).compliance, ComplianceRegime::Sox); + assert_eq!(super_domain_entry(SuperDomain::Osint).compliance, ComplianceRegime::OsintClearance); + assert_eq!(super_domain_entry(SuperDomain::QuantumPhysics).compliance, ComplianceRegime::ItarEar); + } + + #[test] + fn meta_anchors_empty_const() + { + let m = MetaAnchors::EMPTY; + assert!(m.foundry_object_type.is_none()); + assert!(m.owl_upper_class.is_none()); + assert_eq!(m.dolce_marker, DolceMarker::Unknown); + assert!(m.wikidata_qid.is_none()); + } + + #[test] + fn family_to_super_domain_unclassified_defaults_to_unknown() + { + // Without hydration the table is all-Unknown. + assert_eq!(super_domain_for_family(OgitFamily(1)), SuperDomain::Unknown); + assert_eq!(super_domain_for_family(OgitFamily(42)), SuperDomain::Unknown); + assert_eq!(super_domain_for_family(OgitFamily(255)), SuperDomain::Unknown); + } +} diff --git a/crates/lance-graph-callcenter/src/unified_bridge.rs b/crates/lance-graph-callcenter/src/unified_bridge.rs new file mode 100644 index 000000000..159a9fd11 --- /dev/null +++ b/crates/lance-graph-callcenter/src/unified_bridge.rs @@ -0,0 +1,475 @@ +//! `UnifiedBridge` — composes a per-namespace `NamespaceBridge` with an RBAC +//! `Policy` so consumers get one entry point that does scope-locking, +//! ontology resolution, and access-decision evaluation in one call. +//! +//! Per `.claude/plans/super-domain-rbac-tenancy-v1.md` §3.9, this is the Tier +//! A starter for the unified bridge surface. The minimal viable shape wires +//! the two existing crates (`lance-graph-ontology` + `lance-graph-rbac`) and +//! adds a `TenantId` field for the multi-tenant Chinese wall (§3.8). +//! +//! Richer surface — super-domain routing (§3.4), nested role groups with +//! `FieldRedactionMask` (§3.6), merkle audit chain (§13.3), 4-stage +//! `authorize()` against `PolicyRewriter` (§3.9 + §13.1) — lands in +//! follow-up commits as Tier A deliverables D-SDR-2..5. +//! +//! ## Wiring at the call site +//! +//! ```ignore +//! use std::sync::Arc; +//! use lance_graph_ontology::{NamespaceBridge, OntologyRegistry, bridges::MedcareBridge}; +//! use lance_graph_ontology::bridge::BridgeFromRegistry; +//! use lance_graph_rbac::policy::smb_policy; +//! use lance_graph_callcenter::unified_bridge::{TenantId, UnifiedBridge}; +//! use lance_graph_contract::property::PrefetchDepth; +//! +//! let registry = Arc::new(OntologyRegistry::new_in_memory()); +//! // ... hydrate registry from TTL ... +//! let bridge = MedcareBridge::from_registry(registry).unwrap(); +//! let policy = Arc::new(smb_policy()); +//! let unified = UnifiedBridge::new(Arc::new(bridge), policy, "accountant", TenantId(1)); +//! +//! match unified.authorize_read("Customer", PrefetchDepth::Detail) { +//! Ok(entity) => { /* schema_ptr.entity_type_id() = dense index */ } +//! Err(e) => { /* denied / escalation / bridge error */ } +//! } +//! ``` + +use std::sync::Arc; + +use lance_graph_contract::property::PrefetchDepth; +use lance_graph_ontology::bridge::{BridgeError, EntityRef, NamespaceBridge}; +use lance_graph_ontology::proposal::MappingRow; +use lance_graph_rbac::access::AccessDecision; +use lance_graph_rbac::policy::{Operation, Policy}; + +/// Extract the canonical ontology entity type name from a resolved +/// [`MappingRow`], for use as the [`Policy::evaluate`] key. +/// +/// `row.ogit_uri` carries the canonical OGIT URI (e.g. +/// `ogit.WorkOrder:Order`); `OgitUri::name()` is the local part +/// (`Order`). Falls back to `public_name` if the URI somehow lacks a +/// name part — a malformed URI shouldn't silently bypass the alias +/// resolution, but it shouldn't break authorization either. +fn canonical_entity_type<'a>(row: &'a MappingRow, public_name: &'a str) -> &'a str +{ + row.ogit_uri.name().unwrap_or(public_name) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// OgitFamily — Level-2 basin pointer (§3.1 of super-domain-rbac-tenancy-v1) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 1 byte. Identifies which OGIT family (basin) a row belongs to. +/// 256 families max; ~75 used today (per `RECON_ONTOLOGY_CRATE.md` §1.9). +/// Pure address. No reasoning, no string lookup. +/// +/// High byte of `OwlIdentity`. Bitmask compare against this is the +/// sub-microsecond hot-path predicate Cypher MATCH lowers to. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct OgitFamily(pub u8); + +impl OgitFamily +{ + pub const UNKNOWN: Self = Self(0); + + #[inline] + pub const fn raw(self) -> u8 + { + self.0 + } + + #[inline] + pub const fn is_known(self) -> bool + { + self.0 != 0 + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// OwlIdentity — Level-3 per-row identity (§3.2) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 2 bytes. BF16-shaped container (interpreted as named bit-fields, not +/// literal floating-point semantics). +/// High 8 bits = `OgitFamily` (the precise heel pointer / "mantissa"). +/// Low 8 bits = within-family slot (the OWL/consumer's own identity). +/// This is what rides on every LanceDB row. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct OwlIdentity(pub u16); + +impl OwlIdentity +{ + pub const UNKNOWN: Self = Self(0); + + #[inline] + pub const fn new(family: OgitFamily, slot: u8) -> Self + { + Self(((family.0 as u16) << 8) | slot as u16) + } + + #[inline] + pub const fn family(self) -> OgitFamily + { + OgitFamily((self.0 >> 8) as u8) + } + + #[inline] + pub const fn slot(self) -> u8 + { + (self.0 & 0xFF) as u8 + } + + #[inline] + pub const fn raw(self) -> u16 + { + self.0 + } + + /// Bitmask predicate Cypher MATCH lowers to. No string lookup. + #[inline] + pub const fn is_family(self, f: OgitFamily) -> bool + { + self.family().0 == f.0 + } + + /// Within-family slot predicate. + #[inline] + pub const fn is_slot(self, s: u8) -> bool + { + self.slot() == s + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// TenantId — multi-tenant Chinese wall (§3.8 of super-domain-rbac-tenancy-v1) +// ═══════════════════════════════════════════════════════════════════════════ + +/// 4-byte newtype carried alongside every authorization request. +/// Used by the cross-tenant Chinese-wall predicate at the storage layer. +/// In follow-up commits this couples to a per-tenant DEK for crypto backstop +/// (§13.4 hard-lock); for now it's a pure tag the caller asserts on every +/// `authorize_*` invocation. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TenantId(pub u32); + +impl TenantId { + pub const UNKNOWN: TenantId = TenantId(0); + + pub const fn raw(self) -> u32 { + self.0 + } + + pub const fn is_known(self) -> bool { + self.0 != 0 + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AuthError — unified error across bridge resolution + RBAC evaluation +// ═══════════════════════════════════════════════════════════════════════════ + +/// Result of a `UnifiedBridge::authorize_*` call. +/// +/// Three failure modes: +/// - `Bridge` — the ontology lookup failed (unknown public name, cross-namespace leak). +/// - `Denied` — the bridge resolved cleanly but the RBAC `Policy` denied access. +/// - `Escalation` — the policy returned `AccessDecision::Escalate`; the caller +/// must drive the human-approval / MFA step before retrying. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("ontology bridge error: {0}")] + Bridge(#[from] BridgeError), + #[error("denied: {0}")] + Denied(&'static str), + #[error("escalation required: {0}")] + Escalation(&'static str), +} + +impl AuthError { + pub const fn is_denied(&self) -> bool { + matches!(self, Self::Denied(_)) + } + + pub const fn is_escalation(&self) -> bool { + matches!(self, Self::Escalation(_)) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// UnifiedBridge — the composition +// ═══════════════════════════════════════════════════════════════════════════ + +/// Composes a per-namespace `NamespaceBridge` (g-locked ontology lookup) with +/// an RBAC `Policy` (role-based access decisions) and a `TenantId` (multi- +/// tenant Chinese wall tag). One entry point for consumers that hides the +/// two-step resolve-then-evaluate flow. +/// +/// Generic over the bridge type so each consumer crate (medcare-rs, +/// smb-office-rs, future hiro-rs / hubspot-rs / woa-rs) constructs its own +/// `UnifiedBridge` / `UnifiedBridge` etc. without +/// dynamic dispatch. +pub struct UnifiedBridge { + bridge: Arc, + policy: Arc, + actor_role: &'static str, + tenant: TenantId, +} + +impl UnifiedBridge { + /// Construct a new unified bridge. + pub fn new( + bridge: Arc, + policy: Arc, + actor_role: &'static str, + tenant: TenantId, + ) -> Self { + Self { + bridge, + policy, + actor_role, + tenant, + } + } + + /// Returns the underlying namespace bridge. + pub fn bridge(&self) -> &B { + &self.bridge + } + + /// Returns the active actor role name. + pub fn actor_role(&self) -> &'static str { + self.actor_role + } + + /// Returns the tenant id. + pub fn tenant(&self) -> TenantId { + self.tenant + } + + // ── Authorization ───────────────────────────────────────────────────────── + + /// Resolve `public_name` through the bridge then evaluate read access at + /// `depth`. Returns the resolved `EntityRef` on `Allow`, `AuthError` on + /// `Deny` / `Escalate` / bridge resolution failure. + /// + /// **Policy evaluation keys on the canonical ontology entity type + /// (e.g. `Order` for `ogit.WorkOrder:Order`), not the bridge-side + /// `public_name` alias.** This means a `Policy` authored against + /// canonical OGIT names is honored regardless of which bridge / public + /// alias the caller used, and consumer-facing aliases stay decoupled + /// from policy authorship. + pub fn authorize_read( + &self, + public_name: &str, + depth: PrefetchDepth, + ) -> Result { + let row = self.bridge.row(public_name)?; + let canonical = canonical_entity_type(&row, public_name); + self.evaluate(canonical, Operation::Read { depth })?; + Ok(EntityRef { schema_ptr: row.schema_ptr }) + } + + /// Resolve `public_name` then evaluate write access on `predicate`. + /// Policy keys on the canonical ontology entity type — see + /// [`Self::authorize_read`] for details. + pub fn authorize_write( + &self, + public_name: &str, + predicate: &str, + ) -> Result { + let row = self.bridge.row(public_name)?; + let canonical = canonical_entity_type(&row, public_name); + self.evaluate(canonical, Operation::Write { predicate })?; + Ok(EntityRef { schema_ptr: row.schema_ptr }) + } + + /// Resolve `public_name` then evaluate action access on `action`. + /// Policy keys on the canonical ontology entity type — see + /// [`Self::authorize_read`] for details. + pub fn authorize_act( + &self, + public_name: &str, + action: &str, + ) -> Result { + let row = self.bridge.row(public_name)?; + let canonical = canonical_entity_type(&row, public_name); + self.evaluate(canonical, Operation::Act { action })?; + Ok(EntityRef { schema_ptr: row.schema_ptr }) + } + + fn evaluate(&self, entity_type: &str, op: Operation<'_>) -> Result<(), AuthError> { + match self.policy.evaluate(self.actor_role, entity_type, op) { + AccessDecision::Allow => Ok(()), + AccessDecision::Deny { reason } => Err(AuthError::Denied(reason)), + AccessDecision::Escalate { reason } => Err(AuthError::Escalation(reason)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_graph_ontology::OntologyRegistry; + use lance_graph_rbac::policy::smb_policy; + + /// Stub bridge for unit tests — bypasses TTL hydration. Locks to namespace + /// id 1 and resolves any public_name to a synthetic SchemaPtr. + struct StubBridge { + registry: Arc, + } + + impl NamespaceBridge for StubBridge { + fn bridge_id(&self) -> &'static str { + "stub" + } + fn registry(&self) -> &OntologyRegistry { + &self.registry + } + fn g_lock(&self) -> lance_graph_ontology::namespace::NamespaceId { + lance_graph_ontology::namespace::NamespaceId(1) + } + } + + fn make_unified() -> UnifiedBridge { + let registry = Arc::new(OntologyRegistry::new_in_memory()); + let bridge = Arc::new(StubBridge { registry }); + let policy = Arc::new(smb_policy()); + UnifiedBridge::new(bridge, policy, "accountant", TenantId(1)) + } + + #[test] + fn tenant_id_is_known_when_nonzero() { + assert!(!TenantId::UNKNOWN.is_known()); + assert!(TenantId(1).is_known()); + assert_eq!(TenantId(42).raw(), 42); + } + + #[test] + fn auth_error_predicates() { + let denied = AuthError::Denied("nope"); + assert!(denied.is_denied()); + assert!(!denied.is_escalation()); + + let esc = AuthError::Escalation("needs MFA"); + assert!(!esc.is_denied()); + assert!(esc.is_escalation()); + } + + #[test] + fn unified_bridge_carries_actor_and_tenant() { + let unified = make_unified(); + assert_eq!(unified.actor_role(), "accountant"); + assert_eq!(unified.tenant(), TenantId(1)); + assert_eq!(unified.bridge().bridge_id(), "stub"); + } + + #[test] + fn unified_bridge_unknown_public_name_returns_bridge_error() { + // The stub registry has no rows, so `entity("Customer")` returns + // `BridgeError::NotInScope` before the policy is even consulted. + let unified = make_unified(); + let err = unified + .authorize_read("Customer", PrefetchDepth::Identity) + .unwrap_err(); + assert!(matches!(err, AuthError::Bridge(_))); + } + + // ── Alias vs canonical-name resolution (Codex P2 review fix) ────────────── + + /// Bridge that locks to a real namespace + uses a real registry, so + /// `bridge.row()` resolves and returns a `MappingRow` carrying the + /// canonical OGIT URI. Used by the alias-resolution tests below. + struct WoaLikeBridge { + registry: Arc, + g_lock: lance_graph_ontology::namespace::NamespaceId, + } + + impl NamespaceBridge for WoaLikeBridge { + fn bridge_id(&self) -> &'static str { + "woa" + } + fn registry(&self) -> &OntologyRegistry { + &self.registry + } + fn g_lock(&self) -> lance_graph_ontology::namespace::NamespaceId { + self.g_lock + } + } + + /// Build a registry with a single mapping where `public_name` ("WorkOrder", + /// the bridge-side alias) differs from the canonical OGIT URI's name part + /// ("Order"). Returns the bridge that resolves it. + fn alias_test_bridge() -> Arc { + use lance_graph_contract::property::{Marking, Schema}; + use lance_graph_ontology::namespace::OgitUri; + use lance_graph_ontology::proposal::{MappingProposal, MappingProposalKind}; + + let registry = Arc::new(OntologyRegistry::new_in_memory()); + let canonical_uri = OgitUri::parse("ogit.WorkOrder:Order").unwrap(); + let proposal = MappingProposal { + public_name: "WorkOrder".to_string(), + bridge_id: "woa".to_string(), + ogit_uri: canonical_uri, + namespace: "WorkOrder".to_string(), + kind: MappingProposalKind::Entity { + schema: Schema::builder("Order").required("id").build(), + }, + marking: Marking::Internal, + confidence: 1.0, + source_uri: "test://woa-alias".to_string(), + checksum: "checksum-woa-alias".to_string(), + created_by: "test".to_string(), + }; + registry.append_mapping(proposal).unwrap(); + let g_lock = registry.namespace_id("WorkOrder").unwrap(); + Arc::new(WoaLikeBridge { registry, g_lock }) + } + + fn policy_with_role(role_name: &'static str, entity_type: &'static str) -> Policy { + use lance_graph_rbac::permission::PermissionSpec; + use lance_graph_rbac::role::Role; + Policy::new("alias-test") + .with_role( + Role::new(role_name) + .with_permission(PermissionSpec::read_at(entity_type, PrefetchDepth::Detail)), + ) + } + + #[test] + fn unified_bridge_evaluates_policy_against_canonical_entity_type() { + // Caller invokes `authorize_read("WorkOrder", ...)` — the bridge-side + // alias. Policy is authored against the canonical OGIT entity type + // "Order" (e.g. shared across multiple bridges that all resolve to + // the same canonical type). The fix: policy must see "Order", not + // "WorkOrder". + let bridge = alias_test_bridge(); + let policy = Arc::new(policy_with_role("clerk", "Order")); + let unified = UnifiedBridge::new(bridge, policy, "clerk", TenantId(1)); + + let entity = unified + .authorize_read("WorkOrder", PrefetchDepth::Detail) + .expect("policy keyed on canonical 'Order' should grant alias 'WorkOrder' access"); + assert_eq!(entity.schema_ptr.namespace_id().raw(), 1, "g-lock honored"); + } + + #[test] + fn unified_bridge_does_not_honor_alias_keyed_policy() { + // Inverse case: a policy keyed on the bridge-side alias "WorkOrder" + // does NOT grant access through the canonical-name evaluation path. + // This is the deliberate decoupling — consumer-facing aliases stay + // separate from policy authorship; policy authors write canonical + // OGIT names once and any bridge that resolves to them honors the + // grant. + let bridge = alias_test_bridge(); + let policy = Arc::new(policy_with_role("clerk", "WorkOrder")); + let unified = UnifiedBridge::new(bridge, policy, "clerk", TenantId(1)); + + let err = unified + .authorize_read("WorkOrder", PrefetchDepth::Detail) + .expect_err("policy keyed on alias 'WorkOrder' should NOT grant access; canonical is 'Order'"); + assert!(matches!(err, AuthError::Denied(_)), "got: {err:?}"); + } +}