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:?}")))?; 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..df13f33e 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); } } @@ -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)] @@ -255,13 +264,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 +289,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 +312,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 +365,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 +383,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 +407,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 +427,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..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.** @@ -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; @@ -137,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 @@ -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.