Skip to content

Commit 2089c1c

Browse files
committed
fix(callcenter): widen OwlIdentity slot u8 -> u16, preserve full registry IDs in audit
Per chatgpt-codex-connector P1 review on PR #364: owl_from_schema_ptr truncated SchemaPtr::entity_type_id (u16) to 8 bits, so distinct authorized entities whose registry IDs differ by 256 collided in the audit log. RegistryState::append allocates IDs globally as rows.len()+1 u16, not per-namespace u8 slots, so the truncation was reachable on otherwise-valid hydrated registries. User decision (sprint-5-roadmap OQ): widen now, not defer to sprint 5. Contract-level change rippling through three call sites: * OwlIdentity: 2-byte packed u16 -> named fields { family: OgitFamily, slot: u16 }. raw() removed; replaced by to_canonical_bytes() -> [u8; 3] returning the deterministic on-wire form [family, slot_lo, slot_hi]. new()/is_slot() now take slot: u16. UNKNOWN literal updated. * owl_from_schema_ptr: drop the & 0xFF truncation; entity_type_id flows through full-width. * UnifiedAuditEvent::canonical_bytes: grows 25 -> 26 bytes. owl now occupies [13..16); op/decision/role_hash offsets shifted by 1. Wire format is breaking for any persisted audit log — none exist outside test fixtures at this commit, so no migration is needed in tree. Cross-language emitters (Rust / C#) get the new layout via OwlIdentity::to_canonical_bytes. * OgitFamilyTable: [Option<FamilyEntry>; 256] dense array -> sparse HashMap<u16, FamilyEntry>. Lookups stay O(1); resident set scales with occupied-slot count, not the u16 keyspace. set()/clear() now take slot: u16. The doc comments' '256-slot' framing is replaced by 'sparse map'. Tests: * slot_keyspace_distinguishes_high_ids — locks the new invariant by asserting slot 7 and slot 7+256 stay distinct (would alias under the old layout). * canonical_bytes_round_trips_field_order — updated to the 26-byte layout and to_canonical_bytes() projection. 97/97 callcenter lib tests pass. Refs: PR #364 review threads (P1 priority); sprint-5 roadmap OQ 'PR-D5 compat shim vs coordinated cutover' — resolved as cutover (no compat shim because no on-disk audit log exists yet outside test fixtures).
1 parent bdbd08d commit 2089c1c

4 files changed

Lines changed: 108 additions & 62 deletions

File tree

crates/lance-graph-callcenter/src/family_table.rs

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
//! Per-family codebook table — inline label + schema + verbs per OGIT slot
22
//! (per `.claude/plans/super-domain-rbac-tenancy-v1.md` §3.3 / D-SDR-3).
33
//!
4-
//! Each OGIT family (basin) carries its own `OgitFamilyTable` — a 256-slot
5-
//! dense array indexed by `OwlIdentity::slot()`. Each occupied slot holds
6-
//! the label URI, schema kind (Entity / Edge / Attribute), OWL property
7-
//! characteristics, DOLCE upper marker, axiom blob, dcterms:source
8-
//! provenance, and outgoing-verb slot list **inline** — no sidecar table,
9-
//! no join, one cache-line per slot.
4+
//! Each OGIT family (basin) carries its own `OgitFamilyTable` — a sparse
5+
//! map indexed by `OwlIdentity::slot()` (u16, full registry width). Each
6+
//! occupied slot holds the label URI, schema kind (Entity / Edge /
7+
//! Attribute), OWL property characteristics, DOLCE upper marker, axiom
8+
//! blob, dcterms:source provenance, and outgoing-verb slot list
9+
//! **inline** — no sidecar table, no join, one cache-line per slot.
1010
//!
1111
//! Per the spec's brutal-honest correction (§16.5 + §17.4): inline storage
1212
//! is the Foundry-parity-shaped surface; the earlier sidecar sketch was
1313
//! Neo4j-shaped and rejected.
1414
//!
15+
//! The original D-SDR-3 sketch used `[Option<FamilyEntry>; 256]`; PR #364
16+
//! review surfaced that registry IDs allocate globally as u16, so a dense
17+
//! 256-slot table would alias slot collisions across distinct entities.
18+
//! The dense array is therefore replaced by a `HashMap<u16, FamilyEntry>`
19+
//! preserving O(1) lookup while honoring the full slot domain.
20+
//!
1521
//! ## Hot path
1622
//!
1723
//! ```text
18-
//! caller's OwlIdentity (u16)
24+
//! caller's OwlIdentity (family u8 + slot u16)
1925
//! │
2026
//! ▼ table.lookup(owl)
21-
//! &FamilyEntry ← one array index (`entries[owl.slot()]`)
27+
//! &FamilyEntry ← one hash probe (`entries.get(&owl.slot())`)
2228
//! │
2329
//! ├─ label_uri: &'static str (display / audit)
2430
//! ├─ kind: SchemaKind (Entity / Edge / Attribute)
@@ -33,6 +39,8 @@
3339
//! hydration is D-SDR-3b (lance-graph-ontology side). `PerFamilyCodebook` is
3440
//! a placeholder for the per-family CAM-PQ centroids (D-SDR-3c).
3541
42+
use std::collections::HashMap;
43+
3644
use crate::super_domain::DolceMarker;
3745
use crate::unified_bridge::{OgitFamily, OwlIdentity};
3846

@@ -191,33 +199,35 @@ pub struct PerFamilyCodebook;
191199
// ═══════════════════════════════════════════════════════════════════════════
192200

193201
/// One table per OGIT family. Lives in static memory after hydration —
194-
/// 256-slot dense array, slot index IS `OwlIdentity::slot()`. Each slot
195-
/// holds a `FamilyEntry` (label + schema + verbs inline) or `None`.
202+
/// sparse `HashMap<u16, FamilyEntry>` keyed by `OwlIdentity::slot()`.
203+
/// Each occupied slot holds a `FamilyEntry` (label + schema + verbs
204+
/// inline).
196205
///
197-
/// Typical size: ~50-200 KB per family depending on slot occupancy + axiom
198-
/// blob sizes. With ~75 active basins on a hydrated registry, the resident
199-
/// set is ~5-15 MB. Lookups are O(1) array index (sub-microsecond).
206+
/// Typical size scales with occupied-slot count, not the u16 keyspace:
207+
/// ~50-200 KB per family depending on entry count + axiom blob sizes.
208+
/// With ~75 active basins on a hydrated registry, the resident set is
209+
/// ~5-15 MB. Lookups are O(1) hash probes (sub-microsecond).
200210
pub struct OgitFamilyTable
201211
{
202212
pub family: OgitFamily,
203-
pub entries: [Option<FamilyEntry>; 256],
213+
pub entries: HashMap<u16, FamilyEntry>,
204214
pub codebook: PerFamilyCodebook,
205215
}
206216

207217
impl OgitFamilyTable
208218
{
209219
/// Construct an empty table for the given family. Hydration populates
210-
/// `entries[i]` as TTL classes / properties are discovered.
220+
/// `entries` as TTL classes / properties are discovered.
211221
pub fn empty(family: OgitFamily) -> Self
212222
{
213223
Self {
214224
family,
215-
entries: [None; 256],
225+
entries: HashMap::new(),
216226
codebook: PerFamilyCodebook,
217227
}
218228
}
219229

220-
/// Hot-path lookup: O(1) array index. Sub-microsecond.
230+
/// Hot-path lookup: O(1) hash probe. Sub-microsecond.
221231
///
222232
/// `debug_assert`s that the `OwlIdentity` belongs to this family — in
223233
/// release builds the assertion is elided, so callers MUST ensure the
@@ -232,7 +242,7 @@ impl OgitFamilyTable
232242
owl.family().raw(),
233243
self.family.raw(),
234244
);
235-
self.entries[owl.slot() as usize].as_ref()
245+
self.entries.get(&owl.slot())
236246
}
237247

238248
/// Resolve to the canonical label URI for a slot, e.g.
@@ -270,27 +280,27 @@ impl OgitFamilyTable
270280

271281
/// Insert / overwrite a slot. Used by hydration; runtime code stays
272282
/// read-only against `&OgitFamilyTable`.
273-
pub fn set(&mut self, slot: u8, entry: FamilyEntry)
283+
pub fn set(&mut self, slot: u16, entry: FamilyEntry)
274284
{
275-
self.entries[slot as usize] = Some(entry);
285+
self.entries.insert(slot, entry);
276286
}
277287

278288
/// Drop a slot's entry. Used by retraction during re-hydration.
279-
pub fn clear(&mut self, slot: u8)
289+
pub fn clear(&mut self, slot: u16)
280290
{
281-
self.entries[slot as usize] = None;
291+
self.entries.remove(&slot);
282292
}
283293

284-
/// Number of occupied slots in this table. O(256) scan; not a hot path.
294+
/// Number of occupied slots in this table. O(1).
285295
pub fn len(&self) -> usize
286296
{
287-
self.entries.iter().filter(|e| e.is_some()).count()
297+
self.entries.len()
288298
}
289299

290300
/// True when no slots are occupied.
291301
pub fn is_empty(&self) -> bool
292302
{
293-
self.entries.iter().all(|e| e.is_none())
303+
self.entries.is_empty()
294304
}
295305
}
296306

@@ -397,6 +407,23 @@ mod tests
397407
assert_eq!(t.len(), 5);
398408
}
399409

410+
#[test]
411+
fn slot_keyspace_distinguishes_high_ids()
412+
{
413+
// PR #364 review: registry IDs allocate globally as u16, so slots
414+
// that differ by 256 used to alias under the old u8 truncation.
415+
// Lock that two slots in the upper half of the keyspace stay
416+
// distinct after the widening.
417+
let mut t = OgitFamilyTable::empty(TEST_FAMILY);
418+
t.set(7, FamilyEntry::plain_entity("ogit.Test:Low"));
419+
t.set(7 + 256, FamilyEntry::plain_entity("ogit.Test:High"));
420+
let low = t.lookup(OwlIdentity::new(TEST_FAMILY, 7));
421+
let high = t.lookup(OwlIdentity::new(TEST_FAMILY, 7 + 256));
422+
assert_eq!(low.unwrap().label_uri, "ogit.Test:Low");
423+
assert_eq!(high.unwrap().label_uri, "ogit.Test:High");
424+
assert_eq!(t.len(), 2);
425+
}
426+
400427
#[test]
401428
#[should_panic(expected = "does not match table family")]
402429
fn lookup_panics_on_wrong_family_in_debug()

crates/lance-graph-callcenter/src/super_domain.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
//! 1 byte; RBAC trust boundary + compliance regime + activation routing
1313
//!
1414
//! Level 2 — OGIT BASIN (per-codebook unit)
15-
//! 1 byte (high byte of OwlIdentity); see `unified_bridge::OgitFamily`
15+
//! 1 byte (`OwlIdentity::family`); see `unified_bridge::OgitFamily`
1616
//!
1717
//! Level 3 — WITHIN-BASIN SLOT (the row identity)
18-
//! 1 byte (low byte of OwlIdentity)
18+
//! 2 bytes (`OwlIdentity::slot`, widened from u8 after PR #364
19+
//! review — registry IDs are globally u16)
1920
//! ```
2021
//!
2122
//! D-SDR-2 scope: type system + `FAMILY_TO_SUPER_DOMAIN` reverse-lookup.

crates/lance-graph-callcenter/src/unified_audit.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,10 @@ pub struct UnifiedAuditEvent
155155
/// Super domain the OGIT basin belongs to (looked up via
156156
/// `FAMILY_TO_SUPER_DOMAIN` at emit time).
157157
pub super_domain: SuperDomain,
158-
/// 2-byte OwlIdentity = `(family, slot)` — the per-row identity that
159-
/// was authorized.
158+
/// 3-byte OwlIdentity = `(family u8, slot u16)` — the per-row
159+
/// identity that was authorized. Slot is full-width since PR #364
160+
/// review surfaced that registry IDs are globally u16 and would
161+
/// alias under the prior u8 truncation.
160162
pub owl: OwlIdentity,
161163
/// Read / Write / Act.
162164
pub op: AuthOp,
@@ -177,16 +179,16 @@ impl UnifiedAuditEvent
177179
/// Field order is fixed; little-endian for all integers; no padding.
178180
/// **`merkle_root` is excluded** — it's the OUTPUT of the chain, not
179181
/// an input.
180-
pub fn canonical_bytes(&self) -> [u8; 8 + 4 + 1 + 2 + 1 + 1 + 8]
182+
pub fn canonical_bytes(&self) -> [u8; 8 + 4 + 1 + 3 + 1 + 1 + 8]
181183
{
182-
let mut out = [0u8; 25];
184+
let mut out = [0u8; 26];
183185
out[0..8].copy_from_slice(&self.ts_unix_ms.to_le_bytes());
184186
out[8..12].copy_from_slice(&self.tenant.raw().to_le_bytes());
185187
out[12] = self.super_domain.raw();
186-
out[13..15].copy_from_slice(&self.owl.raw().to_le_bytes());
187-
out[15] = self.op.as_u8();
188-
out[16] = self.decision.as_u8();
189-
out[17..25].copy_from_slice(&self.actor_role_hash.to_le_bytes());
188+
out[13..16].copy_from_slice(&self.owl.to_canonical_bytes());
189+
out[16] = self.op.as_u8();
190+
out[17] = self.decision.as_u8();
191+
out[18..26].copy_from_slice(&self.actor_role_hash.to_le_bytes());
190192
out
191193
}
192194
}
@@ -455,9 +457,9 @@ mod tests
455457
assert_eq!(&bytes[0..8], &ev.ts_unix_ms.to_le_bytes());
456458
assert_eq!(&bytes[8..12], &ev.tenant.raw().to_le_bytes());
457459
assert_eq!(bytes[12], ev.super_domain.raw());
458-
assert_eq!(&bytes[13..15], &ev.owl.raw().to_le_bytes());
459-
assert_eq!(bytes[15], ev.op.as_u8());
460-
assert_eq!(bytes[16], ev.decision.as_u8());
461-
assert_eq!(&bytes[17..25], &ev.actor_role_hash.to_le_bytes());
460+
assert_eq!(&bytes[13..16], &ev.owl.to_canonical_bytes());
461+
assert_eq!(bytes[16], ev.op.as_u8());
462+
assert_eq!(bytes[17], ev.decision.as_u8());
463+
assert_eq!(&bytes[18..26], &ev.actor_role_hash.to_le_bytes());
462464
}
463465
}

crates/lance-graph-callcenter/src/unified_bridge.rs

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -99,55 +99,73 @@ impl OgitFamily
9999
// OwlIdentity — Level-3 per-row identity (§3.2)
100100
// ═══════════════════════════════════════════════════════════════════════════
101101

102-
/// 2 bytes. BF16-shaped container (interpreted as named bit-fields, not
103-
/// literal floating-point semantics).
104-
/// High 8 bits = `OgitFamily` (the precise heel pointer / "mantissa").
105-
/// Low 8 bits = within-family slot (the OWL/consumer's own identity).
102+
/// 3 bytes when serialized: 1-byte `OgitFamily` (the precise heel
103+
/// pointer / "mantissa") + 2-byte within-family slot (the OWL /
104+
/// consumer's own identity). Widened from the original 2-byte
105+
/// (family u8 + slot u8) layout after PR #364 review surfaced that
106+
/// `RegistryState::append` allocates entity-type IDs globally as `u16`
107+
/// so any registry with ≥256 entries would alias slot collisions
108+
/// across distinct authorized entities.
109+
///
110+
/// In-memory representation is two named fields (4 bytes with
111+
/// alignment). On-wire layout in `UnifiedAuditEvent::canonical_bytes`
112+
/// is the deterministic 3-byte sequence `[family, slot_le_lo,
113+
/// slot_le_hi]`.
114+
///
106115
/// This is what rides on every LanceDB row.
107-
#[repr(transparent)]
108116
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
109-
pub struct OwlIdentity(pub u16);
117+
pub struct OwlIdentity {
118+
family: OgitFamily,
119+
slot: u16,
120+
}
110121

111122
impl OwlIdentity
112123
{
113-
pub const UNKNOWN: Self = Self(0);
124+
pub const UNKNOWN: Self = Self {
125+
family: OgitFamily(0),
126+
slot: 0,
127+
};
114128

115129
#[inline]
116-
pub const fn new(family: OgitFamily, slot: u8) -> Self
130+
pub const fn new(family: OgitFamily, slot: u16) -> Self
117131
{
118-
Self(((family.0 as u16) << 8) | slot as u16)
132+
Self { family, slot }
119133
}
120134

121135
#[inline]
122136
pub const fn family(self) -> OgitFamily
123137
{
124-
OgitFamily((self.0 >> 8) as u8)
138+
self.family
125139
}
126140

127141
#[inline]
128-
pub const fn slot(self) -> u8
142+
pub const fn slot(self) -> u16
129143
{
130-
(self.0 & 0xFF) as u8
144+
self.slot
131145
}
132146

147+
/// Deterministic on-wire form: `[family u8, slot_lo u8, slot_hi u8]`.
148+
/// Used by `UnifiedAuditEvent::canonical_bytes` so the merkle chain
149+
/// hashes a byte-stable representation across Rust / C# emitters.
133150
#[inline]
134-
pub const fn raw(self) -> u16
151+
pub const fn to_canonical_bytes(self) -> [u8; 3]
135152
{
136-
self.0
153+
let slot = self.slot.to_le_bytes();
154+
[self.family.0, slot[0], slot[1]]
137155
}
138156

139157
/// Bitmask predicate Cypher MATCH lowers to. No string lookup.
140158
#[inline]
141159
pub const fn is_family(self, f: OgitFamily) -> bool
142160
{
143-
self.family().0 == f.0
161+
self.family.0 == f.0
144162
}
145163

146164
/// Within-family slot predicate.
147165
#[inline]
148-
pub const fn is_slot(self, s: u8) -> bool
166+
pub const fn is_slot(self, s: u16) -> bool
149167
{
150-
self.slot() == s
168+
self.slot == s
151169
}
152170
}
153171

@@ -444,15 +462,13 @@ fn map_decision(
444462
}
445463

446464
/// Project a `SchemaPtr` (canonical OGIT pointer with 8-bit namespace +
447-
/// 16-bit entity-type) onto the 2-byte `OwlIdentity` carried in audit
448-
/// events. The entity-type id is truncated to 8 bits — per the §16
449-
/// reality check, namespaces with >256 entries (SGO meta) are excluded
450-
/// from the runtime addressing layer, so the truncation is lossless for
451-
/// the addressable domain (D-SDR-5 minimum).
465+
/// 16-bit entity-type) onto the 3-byte `OwlIdentity` carried in audit
466+
/// events. `entity_type_id` flows through full-width to `slot` — no
467+
/// truncation, no aliasing across the 256-entity boundary.
452468
#[inline]
453469
fn owl_from_schema_ptr(ptr: &SchemaPtr) -> OwlIdentity {
454470
let family = OgitFamily(ptr.namespace_id().raw());
455-
let slot = (ptr.entity_type_id() & 0xFF) as u8;
471+
let slot = ptr.entity_type_id();
456472
OwlIdentity::new(family, slot)
457473
}
458474

0 commit comments

Comments
 (0)