From 108bd2f6181c993dc07e2c5e9a069a433908a184 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:44:50 +0000 Subject: [PATCH 1/4] ogar-dir: Dn128 hierarchy coordinate and numeric ValueId vocabulary Operator ruling: a 16 x u8 hierarchy coordinate with explicit depth, the domain as external scope, and no strings in population execution. ogar-dir-core: - dn128: Dn128 (16 levels x u8, depth stored beside the codes, code 0 is a real level) and DirectoryScope. from_ou_hhtl converts the ingress OuHhtl and fails closed past 256 children per parent (ChildCodeOverflow); it never hashes or truncates. OuHhtl stays the record-wire and ingress format. ogar-dir-sim: - ValueId / KeyId: stable numeric ids issued by the executor's cold label/value store. NodeState, Change::SetAttribute, Operation, Precondition and the duplicate violations carry ids, not strings. NodeState locates a node by Dn128. - ExecutionPlan::from_diff takes the value -> key mapping, so ordering renames compares comparison keys. docs: DIRECTORY-SIMULATION-POC closes V3 (Dn128 ruling), records the missing-userAccountControl-means-enabled finding under V4 without changing it, and updates the measured allocation figures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-core/src/dn128.rs | 202 +++++++++++++++++++++++++++ crates/ogar-dir-core/src/lib.rs | 4 +- crates/ogar-dir-sim/src/change.rs | 52 +++++-- crates/ogar-dir-sim/src/lib.rs | 2 +- crates/ogar-dir-sim/src/plan.rs | 121 ++++++++-------- crates/ogar-dir-sim/src/violation.rs | 11 +- docs/DIRECTORY-SIMULATION-POC.md | 64 ++++++--- 7 files changed, 357 insertions(+), 99 deletions(-) create mode 100644 crates/ogar-dir-core/src/dn128.rs diff --git a/crates/ogar-dir-core/src/dn128.rs b/crates/ogar-dir-core/src/dn128.rs new file mode 100644 index 00000000..0abffa01 --- /dev/null +++ b/crates/ogar-dir-core/src/dn128.rs @@ -0,0 +1,202 @@ +//! The hot hierarchy coordinate: up to 16 levels, one byte per level. +//! +//! ```text +//! DirectoryScope × Dn128 +//! (domain/tenant) codes[0..depth] depth (side metadata) +//! ``` +//! +//! * A level's code is a byte, `0..=255`: up to **256 distinct children per +//! parent**. Every byte value is a real code — `0` included — so a byte is +//! never stolen as a "no level" marker. How many levels are populated is the +//! explicit [`Dn128::depth`], stored beside the codes. +//! * Bytes past `depth` are zero. That is a canonical form (so equality and +//! ordering are byte comparisons), not a meaning: presence comes from +//! `depth` alone. +//! * The domain or tenant is not a level. It is the [`DirectoryScope`] the +//! codes are interpreted in; a code is only meaningful under its scope and +//! its parent path. +//! +//! Subtree selection is a prefix match on the first `d` bytes plus +//! `depth >= d`: one ternary compare on the 16 bytes and one on the depth. +//! +//! [`OuHhtl`] (`[u16; 8]`) remains the ingress and wire format of the +//! directory records. [`Dn128::from_ou_hhtl`] converts it and fails closed +//! when a parent has more than 256 children; it never hashes or truncates. + +use crate::guid::Guid128; +use crate::hhtl::OuHhtl; + +/// Maximum hierarchy depth. +pub const DN_LEVELS: usize = 16; + +/// Maximum distinct child codes under one parent. +pub const DN_CHILDREN: usize = 256; + +/// The directory a [`Dn128`] is interpreted in: an AD domain or an Entra +/// tenant, by its identifier. Codes from two scopes are never comparable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct DirectoryScope(pub Guid128); + +/// 16 one-byte hierarchy levels, root first, plus an explicit depth. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Dn128 { + codes: [u8; DN_LEVELS], + depth: u8, +} + +/// Why a [`Dn128`] could not be formed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dn128Error { + /// More than [`DN_LEVELS`] levels. + TooDeep(usize), + /// A parent has more than [`DN_CHILDREN`] children: the source segment + /// id (1-based, as `OuDictionary` allocates it) does not fit a byte. + ChildCodeOverflow { + /// Level (0 = root). + level: usize, + /// The source segment id. + segment: u16, + }, + /// The source path has a zero segment before its last level (malformed). + Gap(usize), +} + +impl std::fmt::Display for Dn128Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "dn128: {self:?}") + } +} +impl std::error::Error for Dn128Error {} + +impl Dn128 { + /// The root (depth 0): an object directly under the naming context. + pub const ROOT: Self = Self { + codes: [0; DN_LEVELS], + depth: 0, + }; + + /// From root-first level codes. + pub fn new(codes: &[u8]) -> Result { + if codes.len() > DN_LEVELS { + return Err(Dn128Error::TooDeep(codes.len())); + } + let mut d = Self::ROOT; + d.codes[..codes.len()].copy_from_slice(codes); + d.depth = codes.len() as u8; + Ok(d) + } + + /// Convert an ingress [`OuHhtl`]. Segment `s` (1..=65535, allocated per + /// parent from 1) becomes code `s - 1`; `s > 256` means the parent has + /// more than 256 children and is refused. + pub fn from_ou_hhtl(h: &OuHhtl) -> Result { + let depth = h.depth(); + if let Some(i) = (depth..h.0.len()).find(|&i| h.0[i] != 0) { + return Err(Dn128Error::Gap(i)); + } + let mut codes = [0u8; DN_LEVELS]; + for (level, &segment) in h.0[..depth].iter().enumerate() { + codes[level] = u8::try_from(segment - 1) + .map_err(|_| Dn128Error::ChildCodeOverflow { level, segment })?; + } + Self::new(&codes[..depth]) + } + + /// Number of populated levels. + pub fn depth(&self) -> usize { + usize::from(self.depth) + } + + /// The populated codes, root first. + pub fn codes(&self) -> &[u8] { + &self.codes[..self.depth()] + } + + /// All 16 code bytes (zero past [`Self::depth`]) — the lane field. + pub fn bytes(&self) -> [u8; DN_LEVELS] { + self.codes + } + + /// The first `depth` levels. + pub fn prefix(&self, depth: usize) -> Self { + let d = depth.min(self.depth()); + let mut p = Self::ROOT; + p.codes[..d].copy_from_slice(&self.codes[..d]); + p.depth = d as u8; + p + } + + /// True if `self` is a (non-strict) ancestor of `other`. + pub fn is_ancestor_of(&self, other: &Self) -> bool { + let d = self.depth(); + d <= other.depth() && self.codes[..d] == other.codes[..d] + } + + /// The ternary match selecting the subtree of `self`: compare the first + /// `depth` bytes exactly, ignore the rest. Combined with + /// `node.depth >= self.depth` it selects exactly ancestor-or-self. + pub fn subtree_mask(&self) -> ([u8; DN_LEVELS], [u8; DN_LEVELS]) { + let mut care = [0u8; DN_LEVELS]; + care[..self.depth()].fill(0xFF); + (self.codes, care) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ou(levels: &[u16]) -> OuHhtl { + let mut h = OuHhtl::ROOT; + h.0[..levels.len()].copy_from_slice(levels); + h + } + + #[test] + fn code_zero_is_a_level_and_depth_is_explicit() { + let a = Dn128::new(&[0]).unwrap(); + assert_eq!(a.depth(), 1); + assert_ne!(a, Dn128::ROOT, "code 0 is a real level, not absence"); + let deep = Dn128::new(&[0; 16]).unwrap(); + assert_eq!(deep.depth(), 16); + assert!(a.is_ancestor_of(&deep)); + assert!(!deep.is_ancestor_of(&a)); + } + + #[test] + fn deeper_than_sixteen_is_refused() { + assert_eq!(Dn128::new(&[1; 17]), Err(Dn128Error::TooDeep(17))); + } + + #[test] + fn ou_hhtl_converts_and_fails_closed_past_256_children() { + let d = Dn128::from_ou_hhtl(&ou(&[1, 256, 7])).unwrap(); + assert_eq!(d.codes(), &[0, 255, 6]); + assert_eq!( + Dn128::from_ou_hhtl(&ou(&[1, 257])), + Err(Dn128Error::ChildCodeOverflow { + level: 1, + segment: 257 + }) + ); + assert_eq!(Dn128::from_ou_hhtl(&OuHhtl::ROOT), Ok(Dn128::ROOT)); + } + + #[test] + fn subtree_mask_compares_only_the_prefix() { + let p = Dn128::new(&[3, 9]).unwrap(); + let (pattern, care) = p.subtree_mask(); + let matches = + |n: &Dn128| (0..DN_LEVELS).all(|k| (n.bytes()[k] ^ pattern[k]) & care[k] == 0); + assert!(matches(&Dn128::new(&[3, 9, 200, 1]).unwrap())); + assert!(!matches(&Dn128::new(&[3, 8]).unwrap())); + // The byte compare alone also accepts a shallower node whose + // zero-filled bytes happen to equal the prefix; the depth gate is + // what excludes it. + let shallow = Dn128::new(&[3]).unwrap(); + let p0 = Dn128::new(&[3, 0]).unwrap(); + let (pat0, care0) = p0.subtree_mask(); + assert!((0..DN_LEVELS).all(|k| (shallow.bytes()[k] ^ pat0[k]) & care0[k] == 0)); + assert!(!p0.is_ancestor_of(&shallow)); + } +} diff --git a/crates/ogar-dir-core/src/lib.rs b/crates/ogar-dir-core/src/lib.rs index d02ad2b6..a960fba5 100644 --- a/crates/ogar-dir-core/src/lib.rs +++ b/crates/ogar-dir-core/src/lib.rs @@ -10,7 +10,7 @@ //! | axis | carrier | module | //! |-------|-------------------------------------------|-------------| //! | WHO | [`Guid128`] source object id | [`guid`] | -//! | WHERE | scope [`Guid128`] + [`OuHhtl`] | [`hhtl`] | +//! | WHERE | scope [`Guid128`] + [`OuHhtl`] (ingress); [`DirectoryScope`] × [`Dn128`] (execution) | [`hhtl`], [`dn128`] | //! | WHAT | schema-defined attribute slots + [`ValuePool`] | [`record`], [`pool`], [`schema`] | //! | LINKS | [`DirEdge`] records, never inline lists | [`edge`] | //! | WHEN | `observed_at_ms` only (provenance hook) | [`record`] | @@ -28,6 +28,7 @@ pub mod base64; pub mod dn; +pub mod dn128; pub mod edge; pub mod guid; pub mod hhtl; @@ -36,6 +37,7 @@ pub mod record; pub mod schema; pub use dn::{Dn, DnError, Rdn}; +pub use dn128::{DN_CHILDREN, DN_LEVELS, DirectoryScope, Dn128, Dn128Error}; pub use edge::{DirEdge, EdgeEvidence, EdgeKind}; pub use guid::{Guid128, GuidParseError}; pub use hhtl::{HhtlError, OU_LEVELS, OuDictionary, OuHhtl}; diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index 366cb06c..724b69da 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -2,8 +2,34 @@ //! diff *reports* them. An attribute change carries the value it expects to //! replace (`from`): applying it where `from` no longer holds is refused, and //! the same expectation becomes a plan operation's [`Precondition`](crate::Precondition). +//! +//! ## No strings past ingress +//! +//! ```text +//! external String ──► ingress (normalize) ──► cold label/value store +//! │ stable ValueId / KeyId +//! ════════════════════ numeric substrate ════════╧═══════════════════════ +//! Change · NodeState · Violation · ExecutionPlan carry ids only +//! ═══════════════════════════════════════════════════════════════════════ +//! actuator / UI / evidence formatting ──► resolve id ──► String +//! ``` +//! +//! A [`ValueId`] names one exact raw value; a [`KeyId`] names its comparison +//! form ([`normalize`]). Both are issued by the store that owns the +//! observations, append-only, so an id keeps its meaning from observation +//! through simulation, the desired version, reconciliation and the plan. They +//! are not snapshot ordinals. -use ogar_dir_core::{Guid128, OuHhtl}; +use ogar_dir_core::{Dn128, Guid128}; + +/// Stable identity of one exact raw attribute value (case and spacing kept). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ValueId(pub u32); + +/// Stable identity of a value's comparison form ([`normalize`]): two values +/// the directory treats as equal share one key. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KeyId(pub u32); /// Kind of a directory node. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -29,12 +55,13 @@ pub struct NodeState { pub kind: NodeKind, /// Enabled. Users only: groups have no enabled flag and carry `true`. pub active: bool, - /// Raw UPN. - pub upn: Option, - /// Raw primary SMTP. - pub primary_smtp: Option, - /// OU location (numeric HHTL, never a DN), if known. - pub ou: Option, + /// UPN. + pub upn: Option, + /// Primary SMTP. + pub primary_smtp: Option, + /// Hierarchy location (numeric, within the store's directory scope; + /// never a DN), if known. + pub dn: Option, } /// Attribute a change can set. @@ -86,10 +113,10 @@ pub enum Change { node: Guid128, /// Which attribute. attribute: Attribute, - /// Value the change expects to replace (raw, as observed). - from: Option, - /// New value (raw). - to: Option, + /// Value the change expects to replace (as observed). + from: Option, + /// New value. + to: Option, }, /// A node that did not exist comes into existence. CreateNode { @@ -107,7 +134,8 @@ pub enum Change { }, } -/// Comparison form of a UPN / SMTP address: trimmed and lowercased with +/// Comparison form of a UPN / SMTP address, computed once at ingress (the +/// store maps each [`ValueId`] to its [`KeyId`]); never during execution: trimmed and lowercased with /// Unicode case mapping (Exchange and Entra compare these /// case-insensitively, and neither restricts them to ASCII). /// diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index d4f7360f..513680c6 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -20,7 +20,7 @@ pub mod plan; pub mod provenance; pub mod violation; -pub use change::{Attribute, Change, NodeKind, NodeState, normalize}; +pub use change::{Attribute, Change, KeyId, NodeKind, NodeState, ValueId, normalize}; pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; pub use provenance::{EvidenceRef, Origin, RuleId, TAG_DESIRED, TAG_OBSERVED, Version, VersionId}; pub use violation::{Endpoint, Violation}; diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs index 643c6f77..b272d3c5 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -6,7 +6,7 @@ //! actuator can re-read that one fact from reality before acting (still //! true → execute; changed → re-observe and re-plan). -use crate::change::{Attribute, Change, NodeState, normalize}; +use crate::change::{Attribute, Change, KeyId, NodeState, ValueId}; use crate::provenance::VersionId; use ogar_dir_core::Guid128; use std::collections::{BTreeMap, BTreeSet}; @@ -38,7 +38,7 @@ pub enum Operation { /// Attribute. attribute: Attribute, /// New value. - value: Option, + value: Option, }, /// Create `object` with `state`. CreateObject { @@ -64,7 +64,7 @@ pub enum Precondition { /// The membership must still be present. IsMember, /// The attribute must still hold this value. - AttributeEquals(Option), + AttributeEquals(Option), /// No object with this identity may exist yet. ObjectAbsent, /// The object must still exist in exactly this state and have no @@ -139,35 +139,35 @@ pub struct ExecutionPlan { } impl ExecutionPlan { - /// Lower a semantic diff `basis → target` into a plan. - pub fn from_diff(basis: VersionId, target: VersionId, diff: Vec) -> Self { + /// Lower a semantic diff `basis → target` into a plan. `key` maps a + /// value to its comparison key (the store's ingress mapping), so rename + /// dependencies follow the directory's notion of equal addresses. + pub fn from_diff( + basis: VersionId, + target: VersionId, + diff: Vec, + key: impl Fn(ValueId) -> KeyId, + ) -> Self { let mut ops: Vec = diff.into_iter().map(PlannedOp::from).collect(); ops.sort(); ops.dedup(); - order_value_transfers(&mut ops); + order_value_transfers(&mut ops, &key); Self { basis, target, ops } } } -/// The value an attribute op claims, and the value it releases (by -/// comparison form: uniqueness is case-insensitive). -fn transfer(p: &PlannedOp) -> Option<((Attribute, String), (Attribute, String))> { +/// A claimed or released value, by comparison key (`None` = no value). +type Transfer = (Attribute, Option); + +/// The value an attribute op claims, and the value it releases. +fn transfer(p: &PlannedOp, key: &impl Fn(ValueId) -> KeyId) -> Option<(Transfer, Transfer)> { match (&p.op, &p.precondition) { ( Operation::SetAttribute { attribute, value, .. }, Precondition::AttributeEquals(from), - ) => Some(( - ( - *attribute, - value.as_deref().map(normalize).unwrap_or_default(), - ), - ( - *attribute, - from.as_deref().map(normalize).unwrap_or_default(), - ), - )), + ) => Some(((*attribute, value.map(key)), (*attribute, from.map(key)))), _ => None, } } @@ -178,24 +178,24 @@ fn transfer(p: &PlannedOp) -> Option<((Attribute, String), (Attribute, String))> /// broken by the canonical order, so the result is deterministic. Sets in a /// rename cycle (two objects swapping a value) cannot be ordered without a /// temporary value; they keep their canonical order at the end of the block. -fn order_value_transfers(ops: &mut [PlannedOp]) { - let Some(start) = ops.iter().position(|p| transfer(p).is_some()) else { +fn order_value_transfers(ops: &mut [PlannedOp], key: &impl Fn(ValueId) -> KeyId) { + let Some(start) = ops.iter().position(|p| transfer(p, key).is_some()) else { return; }; let len = ops[start..] .iter() - .take_while(|p| transfer(p).is_some()) + .take_while(|p| transfer(p, key).is_some()) .count(); let block = &mut ops[start..start + len]; - let keys: Vec<_> = block.iter().filter_map(transfer).collect(); + let keys: Vec<_> = block.iter().filter_map(|p| transfer(p, key)).collect(); let object = |p: &PlannedOp| match &p.op { Operation::SetAttribute { object, .. } => Some(*object), _ => None, }; - // releaser[v] = the ops releasing value v; an empty value releases nothing. - let mut releasers: BTreeMap<&(Attribute, String), Vec> = BTreeMap::new(); + // releaser[v] = the ops releasing value v; no value releases nothing. + let mut releasers: BTreeMap<&Transfer, Vec> = BTreeMap::new(); for (i, (_, released)) in keys.iter().enumerate() { - if !released.1.is_empty() { + if released.1.is_some() { releasers.entry(released).or_default().push(i); } } @@ -255,13 +255,22 @@ mod tests { use super::*; use crate::change::NodeKind; + /// Test values: `ValueId(k * 10 + variant)`, all variants of one `k` + /// sharing comparison key `k` (like `b@x` and `B@x`). + fn key(v: ValueId) -> KeyId { + KeyId(v.0 / 10) + } + fn v(n: u32) -> Option { + Some(ValueId(n)) + } + #[test] fn a_repeated_change_is_planned_once() { let c = Change::RemoveMembership { user: Guid128([1; 16]), group: Guid128([2; 16]), }; - let p = ExecutionPlan::from_diff(VersionId(0), VersionId(1), vec![c.clone(), c]); + let p = ExecutionPlan::from_diff(VersionId(0), VersionId(1), vec![c.clone(), c], key); assert_eq!(p.ops.len(), 1); } @@ -271,15 +280,15 @@ mod tests { active: true, upn: None, primary_smtp: None, - ou: None, + dn: None, } } #[test] fn a_sorted_plan_frees_before_it_claims() { let g = |n| Guid128([n; 16]); - let with_smtp = |smtp: &str| NodeState { - primary_smtp: Some(smtp.into()), + let with_smtp = |smtp: u32| NodeState { + primary_smtp: v(smtp), ..state(NodeKind::User) }; // Given in the worst order: user 5 is deleted and user 1 created @@ -294,44 +303,45 @@ mod tests { }, Change::CreateNode { node: g(1), - state: with_smtp("shared@example.test"), + state: with_smtp(70), }, Change::SetAttribute { node: g(3), attribute: Attribute::PrimarySmtp, - from: Some("c@example.test".into()), - to: Some("c2@example.test".into()), + from: v(30), + to: v(40), }, Change::DeleteNode { node: g(5), - state: with_smtp("shared@example.test"), + state: with_smtp(70), }, Change::RemoveMembership { user: g(5), group: g(9), }, ], + key, ); let ops: Vec<_> = plan.ops.iter().map(|p| &p.op).collect(); assert!(matches!(ops[0], Operation::RemoveGroupMember { member, .. } if *member == g(5))); - // The delete frees "shared@" before the create claims it. + // The delete frees value 70 before the create claims it. assert_eq!(ops[1], &Operation::DeleteObject { object: g(5) }); assert!(matches!(ops[2], Operation::SetAttribute { object, .. } if *object == g(3))); assert!(matches!(ops[3], Operation::CreateObject { object, .. } if *object == g(1))); assert!(matches!(ops[4], Operation::AddGroupMember { member, .. } if *member == g(1))); assert_eq!( plan.ops[1].precondition, - Precondition::ObjectRemovable(with_smtp("shared@example.test")) + Precondition::ObjectRemovable(with_smtp(70)) ); assert_eq!(plan.ops[3].precondition, Precondition::ObjectAbsent); } - fn rename(n: u8, from: &str, to: &str) -> Change { + fn rename(n: u8, from: u32, to: u32) -> Change { Change::SetAttribute { node: Guid128([n; 16]), attribute: Attribute::PrimarySmtp, - from: Some(from.into()), - to: Some(to.into()), + from: v(from), + to: v(to), } } fn renamed(plan: &ExecutionPlan) -> Vec { @@ -346,17 +356,15 @@ mod tests { #[test] fn a_value_is_released_before_another_object_claims_it() { - // 1: a → b, 2: b → c, 3: c → d. Canonical order is 1, 2, 3; the safe - // order is 3, 2, 1 (each frees what the previous one takes). Case - // differences do not hide the dependency. + // 1: a → B, 2: b → c, 3: C → d, where b/B and c/C are case variants + // (distinct values, one key). Canonical order is 1, 2, 3; the safe + // order is 3, 2, 1. The dependency is found through the key, so + // case differences do not hide it. let plan = ExecutionPlan::from_diff( VersionId(0), VersionId(1), - vec![ - rename(1, "a@x.test", "B@x.test"), - rename(2, "b@x.test", "c@x.test"), - rename(3, "C@x.test", "d@x.test"), - ], + vec![rename(1, 10, 21), rename(2, 20, 30), rename(3, 31, 40)], + key, ); assert_eq!(renamed(&plan), vec![3, 2, 1]); } @@ -366,20 +374,16 @@ mod tests { let plan = ExecutionPlan::from_diff( VersionId(0), VersionId(1), - vec![ - rename(2, "p@x.test", "q@x.test"), - rename(1, "r@x.test", "s@x.test"), - ], + vec![rename(2, 50, 60), rename(1, 70, 80)], + key, ); assert_eq!(renamed(&plan), vec![1, 2]); // A swap cannot be ordered: both stay, in canonical order. let swap = ExecutionPlan::from_diff( VersionId(0), VersionId(1), - vec![ - rename(1, "a@x.test", "b@x.test"), - rename(2, "b@x.test", "a@x.test"), - ], + vec![rename(1, 10, 20), rename(2, 20, 10)], + key, ); assert_eq!(renamed(&swap), vec![1, 2]); } @@ -394,14 +398,15 @@ mod tests { Change::SetAttribute { node: g(2), attribute: Attribute::PrimarySmtp, - from: Some("bob@example.test".into()), - to: Some("robert@example.test".into()), + from: v(20), + to: v(30), }, Change::AddMembership { user: g(1), group: g(9), }, ], + key, ); assert_eq!( plan.ops[1].op, @@ -413,7 +418,7 @@ mod tests { assert_eq!(plan.ops[1].precondition, Precondition::NotMember); assert_eq!( plan.ops[0].precondition, - Precondition::AttributeEquals(Some("bob@example.test".into())) + Precondition::AttributeEquals(v(20)) ); } } diff --git a/crates/ogar-dir-sim/src/violation.rs b/crates/ogar-dir-sim/src/violation.rs index 3d232459..5bc0ebf9 100644 --- a/crates/ogar-dir-sim/src/violation.rs +++ b/crates/ogar-dir-sim/src/violation.rs @@ -1,7 +1,8 @@ //! Invariant violations as structured evidence — identities and the -//! offending normalized value, never a message string. Resolution to names +//! offending comparison key, never a message string. Resolution to names //! is an output projection done by whoever renders the violation. +use crate::change::KeyId; use ogar_dir_core::Guid128; /// Which end of a membership edge is missing or of the wrong kind. @@ -18,15 +19,15 @@ pub enum Endpoint { pub enum Violation { /// Two or more active users own the same normalized primary SMTP. DuplicateSmtp { - /// Normalized address. - address: String, + /// Comparison key of the address. + key: KeyId, /// Every owner, sorted. owners: Vec, }, /// Two or more active users own the same normalized UPN. DuplicateUpn { - /// Normalized UPN. - upn: String, + /// Comparison key of the UPN. + key: KeyId, /// Every owner, sorted. owners: Vec, }, diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 302b51c9..31dca9f2 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -51,7 +51,14 @@ never the reverse. ## 3. Versions and structural sharing -An observation becomes an immutable `Snapshot` behind an `Arc`. +An observation becomes an immutable `Snapshot` behind an `Arc`: two +populations (users, groups), each at most **65,536** nodes with its own dense +`u16` ordinal space (`UserOrdinal`, `GroupOrdinal`; no value is reserved), and +a sparse membership relation of `(UserOrdinal, GroupOrdinal)` rows — never a +dense matrix. The row lanes are 32-bit because mask-risc has no 16-bit lane; +the values are `u16`. A membership whose endpoint does not resolve is kept by +identity in a side table, never as a lane value, so no ordinal means +"missing". A simulated version stores only `parent`, `origin` (rule and evidence) and `delta`. A read folds the lineage's deltas into an `Overlay`: @@ -64,9 +71,13 @@ Queries run over the base lanes, gated by "still live" planes, and over the overlay rows; the two results are combined by summing the sinks or OR-ing the masks. The base is never copied. -**Measured** (`tests/alloc.rs`, using a counting allocator): one membership -mutation allocates **853 B at 1,000 users and 853 B at 100,000 users**. The -same-root diff allocates 1,208 B at both sizes. +**Measured** (`tests/alloc.rs`, counting allocator; 1,000 / 16,384 / 65,536 +users, the last being the bound): one mutation allocates the same at every +size — membership add 517 B, remove 205 B, attribute set 277 B, node create +357 B. A node delete allocates 711 B at 1,000 and 2,631 B at 16,384 and at +65,536: its membership guard runs one `Count` program whose scratch is one +tile, and a tile is capped at 16,384 rows. The same-root diff is 458–1,056 B, +flat in the same way. The lifecycle stages are not a workflow enum: @@ -122,9 +133,20 @@ no-population-intermediate rule. order, so the semantic output does not depend on input order (test t17). A duplicate node in one observation is refused rather than resolved by ingestion order. -- **Strings.** Strings live in the store's append-only dictionaries; lanes - carry ids. A string is resolved only for compare-and-set checks, evidence and - plans. +- **Strings.** External string → ingress (`VersionStore::intern`, the + observation) → the store's append-only label/value table → `ValueId` (raw + value) and `KeyId` (comparison form, `normalize`). `Change`, `NodeState`, + `Violation`, `Operation` and `Precondition` carry ids; compare-and-set, + uniqueness and rename ordering compare ids. A string comes back only at + egress (`value`, `key_label`). The table lives as long as the store, so an + id means the same value from observation through simulation, the desired + version, re-observation, reconciliation and the plan. Ordinals are never + used as value ids. +- **Value identity, audited.** `ogar-dir-core::ValuePool` (`StrRef` offsets) + is per record batch and not deduplicated, so its offsets are not a stable + identity. lance-graph's `Dicts` is append-only for the store's lifetime and + is reused as the cold store; `ValueId` / `KeyId` (in `ogar-dir-sim`) are its + ids. - **Remaining materialisations, all at the evidence or reconcile boundary:** - offending membership rows and duplicate owners, bounded by the number of violations; @@ -150,25 +172,23 @@ no-population-intermediate rule. The plan is the desired version's net intent rebased onto the latest observation: work reality already shows is dropped, and drift outside the intent is neither planned nor reverted. Not representable yet: a change of - `active`, of OU or of kind on a node that exists in both versions. -- **V3 — OU-HHTL depth 8 (blocked, needs a decision).** `OuHhtl` is - `[u16; 8]`: 128 bits, per-parent segment ids up to 65,535. One `u64` holds - 4 such levels. The "HHTL64 (8 × u8)" candidate fits 8 levels only by - narrowing a segment to 255 children per parent (zero ends the path), which changes the - meaning of every persisted id and dictionary, and it leaves no bit for a - domain. Domain context today is not per node at all: it is the scope of - the `OuDictionary` (one per AD domain / Entra tenant), so two domains in - one snapshot already have ambiguous segment ids. Options: (a) two `u64` - lanes, levels 0..3 and 4..7, matched by two `MatchU64` in one program; - exact, no ABI change, but not one compare; (b) narrow to 8 × u8 with a - refusal past 255 children; (c) a scope id lane plus (a). The snapshot now - also keeps the exact `OuHhtl` per node, so no option loses data. + `active`, of location or of kind on a node that exists in both versions. +- **V3 — hierarchy coordinate (closed by ruling).** The hot location is + `Dn128` (`ogar-dir-core::dn128`): 16 levels × `u8`, at most 256 distinct + child codes per parent, depth as explicit side metadata (code 0 is a real + level, no byte is stolen). The domain or tenant is not a level: it is the + `DirectoryScope` of the observation, and a subtree query in another scope is + refused. Subtree selection is one program: `located ∧ depth ≥ d ∧ + MatchFacet16Strided(prefix, care)`, the 16 bytes read in place. `OuHhtl` + (`[u16; 8]`) stays the ingress and record-wire format; `Dn128::from_ou_hhtl` + converts it and **fails closed** at a 257th child (`ChildCodeOverflow`) — + `from_ad` then refuses the whole observation, never hashing or truncating. - **V4 — "active" across AD and Entra (blocked, needs a decision).** Graph `accountEnabled` is ingested by `ogar-az` (null = unknown), but an Entra user is a different node from its AD source: `sync_edges` links them as evidence and keeps both. There is therefore no node on which "AD active - and Entra active" is a pair. Separately, `from_ad` treats a missing - `userAccountControl` as enabled: unknown currently collapses into true. + and Entra active" is a pair. **Recorded, not fixed:** `from_ad` treats a + missing `userAccountControl` as enabled — unknown collapses into true. Candidate policies: AD authoritative for synced users; Entra authoritative; active only if every known source says enabled (unknown ignored); active only if every source is known and enabled. From 432fbd75fbff2323ae96752947e0ed14ded1ce4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:01:12 +0000 Subject: [PATCH 2/4] ogar-adapter-surrealql: fill new parser Config fields from Default The surrealdb fork's main (a floating git dependency; no lockfile is tracked) added quirk_redefine, quirk_block_first_no_semi and quirk_delete_permission_field to surrealdb_parser::Config, so the exhaustive initializer stopped compiling and `cargo test -p ogar-adapter-surrealql --features surrealdb-parser` went red on any fresh resolve, main included. The explicit settings are kept; the rest comes from Config::default() (all quirks off). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-adapter-surrealql/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ogar-adapter-surrealql/src/lib.rs b/crates/ogar-adapter-surrealql/src/lib.rs index 807e86fa..39df23bf 100644 --- a/crates/ogar-adapter-surrealql/src/lib.rs +++ b/crates/ogar-adapter-surrealql/src/lib.rs @@ -203,6 +203,9 @@ pub fn parse_surrealql_ddl(_input: &str) -> Result, ParseError> { generate_warnings: false, feature_bearer_access: false, feature_surrealism: false, + // The fork's `main` grows quirk flags; the rest stays at the + // parser's defaults so a new field does not break this build. + ..Config::default() }; let (query, ast) = Parser::enter_parse::(_input, cfg) .map_err(|e| ParseError::Parse(format!("{e:?}")))?; From 1d574a86d462a1aee5bca94a54a96e05f050a8e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:30:37 +0000 Subject: [PATCH 3/4] ogar-dir-sim: PlanError::ScopeMismatch A Dn128 carries no scope (the domain / tenant is external context), so a plan must not compare a desired version with an observation of another directory. The executor reports that as ScopeMismatch instead of deriving creates, deletes or Unconvergeable from incomparable codes. Addresses the review finding that identical OU paths from two scopes yield identical Dn128 values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/plan.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs index b272d3c5..df13f33e 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -248,6 +248,15 @@ pub enum PlanError { /// The node whose observed state cannot reach the desired one. node: Guid128, }, + /// The latest observation and the desired version describe different + /// directories (`DirectoryScope`). Hierarchy codes are only meaningful + /// within one scope, so the two are not compared and no plan is derived. + ScopeMismatch { + /// The latest observation. + basis: VersionId, + /// The desired version. + target: VersionId, + }, } #[cfg(test)] From 1545025b07dfc134f39315dabbfce36ff34c8bdb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:31:37 +0000 Subject: [PATCH 4/4] docs: subtree row and NodeState description follow Dn128 / ValueId Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- docs/DIRECTORY-SIMULATION-POC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 31dca9f2..c7aed76f 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -34,7 +34,7 @@ never the reverse. | dangling edge | Quack `negate(Semijoin)` lowered to `MaskOp::Gather` (anti-join) over the kind planes | membership lanes plus kind planes | offending rows only | a bitmap of membership-row size | yes | yes | | graph diff (same root) | overlay touched-key comparison | delta-sized | no | delta-sized | yes | yes | | graph diff (different roots, i.e. reconcile) | merge of two effective relations | O(n+m) set | effective pairs | O(n+m) | no (documented) | yes | -| subtree selection | Quack `Cmp::MatchU64` over a packed OU-HHTL lane | `u64` lane plus a presence plane | no | a node bitmap | yes | yes | +| subtree selection | Quack `located ∧ GeI32(depth) ∧ Cmp::MatchFacet16Strided` over the `Dn128` lane, read in place | `[[u8;16]]` + depth lanes, presence plane | no | a node bitmap | yes | yes | | version snapshot read | `Arc` plus the folded lineage overlay | shared base | no | delta-sized | yes | yes | **Considered and not used.** @@ -159,8 +159,8 @@ no-population-intermediate rule. additions-only diff. Persisting this store needs 128-bit keys and removal- and attribute-aware diffs upstream, or a dedicated directory dataset. - **V2 — node creation and deletion (closed).** `Change::CreateNode` / - `Change::DeleteNode` carry a `NodeState` (kind, active, UPN, primary SMTP, - `OuHhtl`): the content to create, or the compare-and-set expectation of a + `Change::DeleteNode` carry a `NodeState` (kind, active, UPN and primary + SMTP as `ValueId`, location as `Option`): the content to create, or the compare-and-set expectation of a delete. A delete is refused while the node still has a membership on either side, so an edge cannot be stranded; its plan precondition (`ObjectRemovable`) re-checks that against reality. `Change` and