From 40d1f283bbec28da265494fd64e0fcd2c2c43351 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:54:20 +0000 Subject: [PATCH 1/5] ogar-dir-sim: versioned directory graph simulator (observe -> simulate -> validate -> diff -> plan) Pure, in-memory: observed root versions with snapshots, simulated versions as parent + delta with rule/evidence provenance, tags 'observed'/'desired' instead of a workflow state machine. Rules are &GraphState -> Vec over populations (bitset and/minus), never per-user loops. Invariants (unique SMTP, unique UPN, edge integrity) return structured violations; a failed promotion leaves 'desired' and every ancestor untouched and keeps the rejected version. Semantic diff lowers to an ExecutionPlan whose ops carry the precondition read from the observed basis (optimistic reality check). No AD/Graph/Exchange/LDAP/ PowerShell I/O. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- Cargo.toml | 1 + crates/ogar-dir-sim/Cargo.toml | 13 + crates/ogar-dir-sim/src/graph.rs | 265 +++++++++++++++++ crates/ogar-dir-sim/src/lib.rs | 34 +++ crates/ogar-dir-sim/src/observe.rs | 58 ++++ crates/ogar-dir-sim/src/plan.rs | 143 +++++++++ crates/ogar-dir-sim/src/population.rs | 96 ++++++ crates/ogar-dir-sim/src/rule.rs | 125 ++++++++ crates/ogar-dir-sim/src/store.rs | 254 ++++++++++++++++ crates/ogar-dir-sim/src/validate.rs | 88 ++++++ crates/ogar-dir-sim/tests/main.rs | 410 ++++++++++++++++++++++++++ docs/DIRECTORY-SIMULATION-POC.md | 139 +++++++++ 12 files changed, 1626 insertions(+) create mode 100644 crates/ogar-dir-sim/Cargo.toml create mode 100644 crates/ogar-dir-sim/src/graph.rs create mode 100644 crates/ogar-dir-sim/src/lib.rs create mode 100644 crates/ogar-dir-sim/src/observe.rs create mode 100644 crates/ogar-dir-sim/src/plan.rs create mode 100644 crates/ogar-dir-sim/src/population.rs create mode 100644 crates/ogar-dir-sim/src/rule.rs create mode 100644 crates/ogar-dir-sim/src/store.rs create mode 100644 crates/ogar-dir-sim/src/validate.rs create mode 100644 crates/ogar-dir-sim/tests/main.rs create mode 100644 docs/DIRECTORY-SIMULATION-POC.md diff --git a/Cargo.toml b/Cargo.toml index b66fbf7..27d173b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates/ogar-dir-core", "crates/ogar-ad", "crates/ogar-az", + "crates/ogar-dir-sim", ] [workspace.package] diff --git a/crates/ogar-dir-sim/Cargo.toml b/crates/ogar-dir-sim/Cargo.toml new file mode 100644 index 0000000..076469b --- /dev/null +++ b/crates/ogar-dir-sim/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ogar-dir-sim" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Versioned directory graph simulator: observed state G0, pure rules producing hypothetical versions, invariant validation, semantic diff, and a side-effect-free ExecutionPlan boundary. No AD / Graph / Exchange / LDAP / PowerShell writes." + +[dependencies] +ogar-dir-core = { path = "../ogar-dir-core" } +ogar-ad = { path = "../ogar-ad" } diff --git a/crates/ogar-dir-sim/src/graph.rs b/crates/ogar-dir-sim/src/graph.rs new file mode 100644 index 0000000..77edb76 --- /dev/null +++ b/crates/ogar-dir-sim/src/graph.rs @@ -0,0 +1,265 @@ +//! The semantic directory graph of ONE version, and the change algebra. +//! +//! This is directory meaning (users, groups, UPN, primary SMTP, membership), +//! not storage geometry. The 512-byte `DirRecord` is how an observation is +//! stored; this is what a rule reasons about and what a diff reports. +//! +//! [`Change`] is used twice on purpose: a rule *proposes* changes, and +//! [`GraphState::diff`] *reports* changes. Every attribute change carries the +//! value it expects to replace (`from`), so a change is a compare-and-set: +//! applying it to a state where `from` no longer holds is refused. That same +//! expectation is what a future actuator checks against reality before acting. + +use ogar_dir_core::Guid128; +use std::collections::{BTreeMap, BTreeSet}; + +/// Kind of directory object this graph models. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeKind { + /// A user (a recipient when it has a primary SMTP address). + User, + /// A group. + Group, +} + +/// One directory object. Values are stored as observed (raw); comparisons +/// for uniqueness use [`normalize`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Node { + /// User or group. + pub kind: NodeKind, + /// Display name (explanatory only; never identity). + pub name: String, + /// False for disabled accounts; inactive nodes do not own addresses. + pub active: bool, + /// userPrincipalName. + pub upn: Option, + /// Primary SMTP address (the `SMTP:` proxy). + pub primary_smtp: Option, +} + +impl Node { + /// Active user with UPN and primary SMTP. + pub fn user(name: &str, upn: &str, smtp: &str) -> Self { + Self { + kind: NodeKind::User, + name: name.into(), + active: true, + upn: Some(upn.into()), + primary_smtp: Some(smtp.into()), + } + } + /// Group without addresses. + pub fn group(name: &str) -> Self { + Self { + kind: NodeKind::Group, + name: name.into(), + active: true, + upn: None, + primary_smtp: None, + } + } +} + +/// Comparison form of a UPN / SMTP address: ASCII-lowercased and trimmed. +/// (Exchange and Entra compare these case-insensitively.) +pub fn normalize(s: &str) -> String { + s.trim().to_ascii_lowercase() +} + +/// Attribute a change can set. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Attribute { + /// userPrincipalName. + Upn, + /// Primary SMTP address. + PrimarySmtp, +} + +/// One semantic change. Ordering is total so diffs are deterministic. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Change { + /// `user` becomes a member of `group`. + AddMembership { + /// Member. + user: Guid128, + /// Group. + group: Guid128, + }, + /// `user` stops being a member of `group`. + RemoveMembership { + /// Member. + user: Guid128, + /// Group. + group: Guid128, + }, + /// Compare-and-set of one attribute. + SetAttribute { + /// Object. + node: Guid128, + /// Which attribute. + attribute: Attribute, + /// Value the change expects to replace. + from: Option, + /// New value. + to: Option, + }, +} + +/// Why a change could not be applied to a state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApplyError { + /// A `SetAttribute` named a node that does not exist. + UnknownNode(Guid128), + /// A `SetAttribute`'s `from` does not match the current value (stale). + Stale { + /// Node. + node: Guid128, + /// Attribute. + attribute: Attribute, + /// What the change expected. + expected: Option, + /// What the state holds. + actual: Option, + }, + /// The membership already holds (add) / does not hold (remove). + NoOp(Change), +} + +/// Full semantic state of one version. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GraphState { + nodes: BTreeMap, + /// `(user, group)` pairs. Endpoints are NOT required to exist here — a + /// dangling edge is representable so validation can report it. + members: BTreeSet<(Guid128, Guid128)>, +} + +impl GraphState { + /// Empty graph. + pub fn new() -> Self { + Self::default() + } + /// Insert or replace a node (observation building only). + pub fn put_node(&mut self, id: Guid128, node: Node) { + self.nodes.insert(id, node); + } + /// Insert a membership (observation building only). + pub fn put_membership(&mut self, user: Guid128, group: Guid128) { + self.members.insert((user, group)); + } + /// Node by id. + pub fn node(&self, id: &Guid128) -> Option<&Node> { + self.nodes.get(id) + } + /// All nodes, ordered by id. + pub fn nodes(&self) -> impl Iterator { + self.nodes.iter() + } + /// All `(user, group)` memberships, ordered. + pub fn memberships(&self) -> impl Iterator { + self.members.iter() + } + /// Membership test. + pub fn is_member(&self, user: Guid128, group: Guid128) -> bool { + self.members.contains(&(user, group)) + } + + fn attr(&self, id: &Guid128, a: Attribute) -> Result, ApplyError> { + let n = self.nodes.get(id).ok_or(ApplyError::UnknownNode(*id))?; + Ok(match a { + Attribute::Upn => n.upn.clone(), + Attribute::PrimarySmtp => n.primary_smtp.clone(), + }) + } + + /// Apply one change. Pure: returns a new state, `self` untouched. + pub fn apply(&self, c: &Change) -> Result { + let mut next = self.clone(); + match c { + Change::AddMembership { user, group } => { + if !next.members.insert((*user, *group)) { + return Err(ApplyError::NoOp(c.clone())); + } + } + Change::RemoveMembership { user, group } => { + if !next.members.remove(&(*user, *group)) { + return Err(ApplyError::NoOp(c.clone())); + } + } + Change::SetAttribute { + node, + attribute, + from, + to, + } => { + let actual = self.attr(node, *attribute)?; + if &actual != from { + return Err(ApplyError::Stale { + node: *node, + attribute: *attribute, + expected: from.clone(), + actual, + }); + } + let n = next.nodes.get_mut(node).expect("checked by attr"); + match attribute { + Attribute::Upn => n.upn = to.clone(), + Attribute::PrimarySmtp => n.primary_smtp = to.clone(), + } + } + } + Ok(next) + } + + /// Apply a change set in order (all or nothing). + pub fn apply_all(&self, cs: &[Change]) -> Result { + cs.iter().try_fold(self.clone(), |s, c| s.apply(c)) + } + + /// Net semantic difference `self → other`, deterministic order. + /// Applying the result to `self` yields `other` (for the modelled + /// attributes and memberships; node creation/deletion is out of scope + /// for this slice and reported by [`GraphState::node_set_differs`]). + pub fn diff(&self, other: &Self) -> Vec { + let mut out = Vec::new(); + for (user, group) in other.members.difference(&self.members) { + out.push(Change::AddMembership { + user: *user, + group: *group, + }); + } + for (user, group) in self.members.difference(&other.members) { + out.push(Change::RemoveMembership { + user: *user, + group: *group, + }); + } + for (id, a) in &self.nodes { + let Some(b) = other.nodes.get(id) else { + continue; + }; + for (attr, x, y) in [ + (Attribute::Upn, &a.upn, &b.upn), + (Attribute::PrimarySmtp, &a.primary_smtp, &b.primary_smtp), + ] { + if x != y { + out.push(Change::SetAttribute { + node: *id, + attribute: attr, + from: x.clone(), + to: y.clone(), + }); + } + } + } + out.sort(); + out + } + + /// True if the two states disagree on which nodes exist (not expressible + /// as a [`Change`] in this slice). + pub fn node_set_differs(&self, other: &Self) -> bool { + !self.nodes.keys().eq(other.nodes.keys()) + } +} diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs new file mode 100644 index 0000000..113768f --- /dev/null +++ b/crates/ogar-dir-sim/src/lib.rs @@ -0,0 +1,34 @@ +//! # ogar-dir-sim — explore a directory future without touching reality +//! +//! ```text +//! observed G0 ──Rule──► G1 ──Rule──► G2 ──validate──► desired? ──diff(G0,G2)──► ExecutionPlan ──X +//! ``` +//! +//! * [`graph`] — the semantic state of one version and the [`Change`] algebra. +//! * [`population`] — node sets as bitsets; rules select populations, not loops. +//! * [`rule`] — pure rules: `&GraphState -> Vec`, no I/O handle. +//! * [`store`] — append-only versions; provenance and tags ARE the audit trail. +//! * [`validate`] — invariants returning structured [`Violation`]s. +//! * [`plan`] — semantic [`ExecutionPlan`] with per-op preconditions. Never executed here. +//! * [`observe`] — `ogar-ad` records → observed state. +//! +//! Nothing in this crate writes to AD, Entra, Exchange, LDAP or PowerShell, +//! and nothing in it can: there is no network, process or file I/O. +//! Design notes: `docs/DIRECTORY-SIMULATION-POC.md`. + +pub mod graph; +pub mod observe; +pub mod plan; +pub mod population; +pub mod rule; +pub mod store; +pub mod validate; + +pub use graph::{Attribute, Change, GraphState, Node, NodeKind}; +pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; +pub use population::Population; +pub use rule::{EvidenceRef, Rule, RuleId}; +pub use store::{ + Origin, Rejection, SimError, TAG_DESIRED, TAG_OBSERVED, Version, VersionId, VersionStore, +}; +pub use validate::{Endpoint, Violation, validate}; diff --git a/crates/ogar-dir-sim/src/observe.rs b/crates/ogar-dir-sim/src/observe.rs new file mode 100644 index 0000000..2ddcc48 --- /dev/null +++ b/crates/ogar-dir-sim/src/observe.rs @@ -0,0 +1,58 @@ +//! Observed state from `ogar-ad` records (PR #313) into a [`GraphState`]. +//! +//! Read-only and derived: "active" comes from `userAccountControl` bit +//! `0x2` (ACCOUNTDISABLE), the primary SMTP from the `SMTP:` proxy. Group +//! membership is not carried by `ogar-ad` records (it is a relation); the +//! caller adds observed memberships with [`GraphState::put_membership`]. + +use crate::graph::{GraphState, Node, NodeKind}; +use ogar_ad::{AdKind, SCHEMA_V1}; +use ogar_dir_core::{DirRecord, ValuePool}; + +const UAC_ACCOUNTDISABLE: u32 = 0x2; + +fn slot(name: &str) -> usize { + SCHEMA_V1 + .iter() + .find(|d| d.name == name) + .map(|d| d.slot as usize) + .expect("ogar-ad schema v1") +} + +fn text<'p>(r: &DirRecord, p: &'p ValuePool, name: &str) -> Option<&'p str> { + std::str::from_utf8(p.get(r.str_ref(slot(name))?)?).ok() +} + +/// Users and groups of `records` (other kinds are skipped). +pub fn from_ad(records: &[DirRecord], pool: &ValuePool) -> GraphState { + let mut g = GraphState::new(); + for r in records { + let kind = match r.object_kind() { + k if k == AdKind::User as u16 => NodeKind::User, + k if k == AdKind::Group as u16 => NodeKind::Group, + _ => continue, + }; + let primary_smtp = r + .str_ref(slot("proxyAddresses")) + .and_then(|s| pool.get_multi(s)) + .and_then(|vs| { + vs.into_iter() + .filter_map(|v| std::str::from_utf8(v).ok()) + .find_map(|v| v.strip_prefix("SMTP:").map(str::to_string)) + }); + g.put_node( + r.node_guid(), + Node { + kind, + name: text(r, pool, "displayName") + .or_else(|| text(r, pool, "sAMAccountName")) + .unwrap_or("") + .into(), + active: r.num(0).is_none_or(|uac| uac & UAC_ACCOUNTDISABLE == 0), + upn: text(r, pool, "userPrincipalName").map(str::to_string), + primary_smtp, + }, + ); + } + g +} diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs new file mode 100644 index 0000000..f2cd9fd --- /dev/null +++ b/crates/ogar-dir-sim/src/plan.rs @@ -0,0 +1,143 @@ +//! The execution boundary — derived, never executed here. +//! +//! ```text +//! desired version ──diff from its observed basis──► ExecutionPlan ──X── actuator (later) +//! ``` +//! +//! A plan holds semantic operations only: no shell text, no cmdlet names, +//! no endpoints, no credentials. Each operation carries the +//! [`Precondition`] that held in the **observed basis** the plan was derived +//! from. A future actuator re-reads that one fact from reality before acting: +//! still true → execute; changed → re-observe and re-plan (optimistic +//! concurrency). Nothing in this module performs that check or any I/O. + +use crate::graph::{Attribute, Change}; +use crate::store::{Origin, SimError, VersionId, VersionStore}; +use ogar_dir_core::Guid128; + +/// A technology-neutral directory operation. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Operation { + /// Add `member` to `group`. + AddGroupMember { + /// Group. + group: Guid128, + /// Member. + member: Guid128, + }, + /// Remove `member` from `group`. + RemoveGroupMember { + /// Group. + group: Guid128, + /// Member. + member: Guid128, + }, + /// Set an attribute (`None` clears it). + SetAttribute { + /// Object. + object: Guid128, + /// Attribute. + attribute: Attribute, + /// New value. + value: Option, + }, +} + +/// What reality must still look like for the operation to be safe. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Precondition { + /// The membership must still be absent. + NotMember, + /// The membership must still be present. + IsMember, + /// The attribute must still hold this value. + AttributeEquals(Option), +} + +/// One planned step. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PlannedOp { + /// What to do. + pub op: Operation, + /// What must still hold first. + pub precondition: Precondition, +} + +/// Semantic plan from an observed basis to a desired target. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExecutionPlan { + /// The observation the preconditions were read from. + pub basis: VersionId, + /// The desired version the plan reaches. + pub target: VersionId, + /// Operations, deterministic order. + pub ops: Vec, +} + +/// Why no plan was derived. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PlanError { + /// The target is not the current `"desired"` version. + NotDesired(VersionId), + /// Version lookup failed. + Sim(SimError), + /// The target creates or deletes nodes (not plannable in this slice). + NodeSetChanged, +} + +fn lower(c: Change) -> PlannedOp { + match c { + Change::AddMembership { user, group } => PlannedOp { + op: Operation::AddGroupMember { + group, + member: user, + }, + precondition: Precondition::NotMember, + }, + Change::RemoveMembership { user, group } => PlannedOp { + op: Operation::RemoveGroupMember { + group, + member: user, + }, + precondition: Precondition::IsMember, + }, + Change::SetAttribute { + node, + attribute, + from, + to, + } => PlannedOp { + op: Operation::SetAttribute { + object: node, + attribute, + value: to, + }, + precondition: Precondition::AttributeEquals(from), + }, + } +} + +impl ExecutionPlan { + /// Derive the plan for the current `"desired"` version `target`, from + /// the observed root of its lineage. + pub fn derive(store: &VersionStore, target: VersionId) -> Result { + if store.tag(crate::store::TAG_DESIRED) != Some(target) { + return Err(PlanError::NotDesired(target)); + } + let basis = store.lineage(target).map_err(PlanError::Sim)?[0]; + debug_assert!(matches!( + store.version(basis).map(|v| &v.origin), + Some(Origin::Observed { .. }) + )); + let (b, t) = ( + store.state(basis).map_err(PlanError::Sim)?, + store.state(target).map_err(PlanError::Sim)?, + ); + if b.node_set_differs(&t) { + return Err(PlanError::NodeSetChanged); + } + let mut ops: Vec = b.diff(&t).into_iter().map(lower).collect(); + ops.sort(); + Ok(Self { basis, target, ops }) + } +} diff --git a/crates/ogar-dir-sim/src/population.rs b/crates/ogar-dir-sim/src/population.rs new file mode 100644 index 0000000..61244b8 --- /dev/null +++ b/crates/ogar-dir-sim/src/population.rs @@ -0,0 +1,96 @@ +//! A set of nodes of one version, as a dense bitset over that version's +//! node order — the shape rules select with, so they never have to be +//! written as `for user in users { run_workflow(user) }`. +//! +//! Identity stays [`Guid128`]; the bit index is an execution ordinal valid +//! only for the [`GraphState`] it was taken from (the version's sorted node +//! order). [`Population::guids`] resolves it back. +//! +//! The contract crate has no row-population mask today (`FieldMask` / +//! `WideFieldMask` are column masks, `RowFocusMask` holds address subtrees). +//! This is deliberately the same shape as lance-graph-java's `Mask` +//! (`Box<[u64]>` over rows, `and` / `minus`), so it can be replaced by a +//! shared row mask without changing any rule. + +use crate::graph::{GraphState, Node, NodeKind}; +use ogar_dir_core::Guid128; + +/// Node set over one version's ordinals. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Population { + words: Vec, + order: Vec, +} + +impl Population { + fn empty_for(g: &GraphState) -> Self { + let order: Vec = g.nodes().map(|(id, _)| *id).collect(); + Self { + words: vec![0; order.len().div_ceil(64)], + order, + } + } + fn set(&mut self, i: usize) { + self.words[i / 64] |= 1 << (i % 64); + } + + /// All nodes matching `pred`. + pub fn select(g: &GraphState, pred: impl Fn(&Guid128, &Node) -> bool) -> Self { + let mut p = Self::empty_for(g); + for (i, (id, n)) in g.nodes().enumerate() { + if pred(id, n) { + p.set(i); + } + } + p + } + + /// Active users. + pub fn active_users(g: &GraphState) -> Self { + Self::select(g, |_, n| n.kind == NodeKind::User && n.active) + } + + /// Members of `group`. + pub fn members_of(g: &GraphState, group: Guid128) -> Self { + Self::select(g, |id, _| g.is_member(*id, group)) + } + + /// Exactly the given ids (unknown ids are ignored). + pub fn of(g: &GraphState, ids: &[Guid128]) -> Self { + Self::select(g, |id, _| ids.contains(id)) + } + + fn zip(&self, o: &Self, f: impl Fn(u64, u64) -> u64) -> Self { + assert_eq!(self.order, o.order, "populations from different versions"); + Self { + words: self + .words + .iter() + .zip(&o.words) + .map(|(a, b)| f(*a, *b)) + .collect(), + order: self.order.clone(), + } + } + /// Intersection. + pub fn and(&self, o: &Self) -> Self { + self.zip(o, |a, b| a & b) + } + /// Set difference. + pub fn minus(&self, o: &Self) -> Self { + self.zip(o, |a, b| a & !b) + } + /// Cardinality. + pub fn count(&self) -> u32 { + self.words.iter().map(|w| w.count_ones()).sum() + } + /// Resolve to identities, in version order. + pub fn guids(&self) -> Vec { + self.order + .iter() + .enumerate() + .filter(|(i, _)| self.words[i / 64] >> (i % 64) & 1 == 1) + .map(|(_, g)| *g) + .collect() + } +} diff --git a/crates/ogar-dir-sim/src/rule.rs b/crates/ogar-dir-sim/src/rule.rs new file mode 100644 index 0000000..4c18b8a --- /dev/null +++ b/crates/ogar-dir-sim/src/rule.rs @@ -0,0 +1,125 @@ +//! Pure rules: `G(n+1) = R(G(n), evidence)`. +//! +//! A rule reads one version and returns the changes it proposes. It cannot +//! write: it receives `&GraphState`, returns `Vec`, and has no I/O +//! handle. The store turns the proposal into a new version. + +use crate::graph::{Attribute, Change, GraphState}; +use crate::population::Population; +use ogar_dir_core::Guid128; + +/// Rule identity recorded in every version a rule produces. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RuleId { + /// Stable name, e.g. `"ExchangeAccess"`. + pub name: &'static str, + /// Rule version; a behaviour change is a new version, never an edit. + pub version: u16, +} + +impl std::fmt::Display for RuleId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/v{}", self.name, self.version) + } +} + +/// Opaque reference to the input that justified running a rule (a request +/// id, ticket, HR record…). Stored in the version's provenance. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EvidenceRef(pub String); + +/// A pure graph transformation. +pub trait Rule { + /// Identity recorded in provenance. + fn id(&self) -> RuleId; + /// Proposed changes against `g`. Must be deterministic in `(g, evidence)`. + fn propose(&self, g: &GraphState, evidence: &[EvidenceRef]) -> Vec; +} + +/// Grant `group` to an explicit population (e.g. the users named in a +/// request). Members already in the group are skipped, so the proposal is +/// exactly the net change. +pub struct GrantGroup { + /// Rule identity (lets one implementation serve several named rules). + pub rule: RuleId, + /// The group to grant. + pub group: Guid128, + /// Who should receive it. + pub to: Vec, +} + +impl Rule for GrantGroup { + fn id(&self) -> RuleId { + self.rule + } + fn propose(&self, g: &GraphState, _: &[EvidenceRef]) -> Vec { + let wanted = Population::of(g, &self.to); + let missing = wanted.minus(&Population::members_of(g, self.group)); + missing + .guids() + .into_iter() + .map(|user| Change::AddMembership { + user, + group: self.group, + }) + .collect() + } +} + +/// Population rule: every active member of `source` is also a member of +/// `target`. Evaluated as `active ∩ members(source) − members(target)`. +pub struct ImplyGroup { + /// Rule identity. + pub rule: RuleId, + /// Membership that implies… + pub source: Guid128, + /// …membership here. + pub target: Guid128, +} + +impl Rule for ImplyGroup { + fn id(&self) -> RuleId { + self.rule + } + fn propose(&self, g: &GraphState, _: &[EvidenceRef]) -> Vec { + Population::active_users(g) + .and(&Population::members_of(g, self.source)) + .minus(&Population::members_of(g, self.target)) + .guids() + .into_iter() + .map(|user| Change::AddMembership { + user, + group: self.target, + }) + .collect() + } +} + +/// Set one user's primary SMTP address (compare-and-set against the value +/// in the version the rule reads). +pub struct SetPrimarySmtp { + /// Rule identity. + pub rule: RuleId, + /// User. + pub user: Guid128, + /// New address. + pub to: String, +} + +impl Rule for SetPrimarySmtp { + fn id(&self) -> RuleId { + self.rule + } + fn propose(&self, g: &GraphState, _: &[EvidenceRef]) -> Vec { + let from = g.node(&self.user).and_then(|n| n.primary_smtp.clone()); + if from.as_deref() == Some(self.to.as_str()) { + return Vec::new(); + } + vec![Change::SetAttribute { + node: self.user, + attribute: Attribute::PrimarySmtp, + from, + to: Some(self.to.clone()), + }] + } +} diff --git a/crates/ogar-dir-sim/src/store.rs b/crates/ogar-dir-sim/src/store.rs new file mode 100644 index 0000000..2fa9274 --- /dev/null +++ b/crates/ogar-dir-sim/src/store.rs @@ -0,0 +1,254 @@ +//! The version history. It is the audit trail: there is no separate log. +//! +//! * An **observed** version is a root holding a full snapshot of what was +//! read from reality. +//! * A **simulated** version holds only its parent and the changes a rule +//! proposed (structural sharing by delta). Its state is the parent's state +//! with the delta applied. +//! * Versions are append-only and never mutated. Simulating, validating or +//! rejecting a version cannot change any other version. +//! +//! The lifecycle stages are not a workflow enum. They fall out of data the +//! store already keeps: +//! +//! | stage | expressed as | +//! |--------------------------|---------------------------------------------------| +//! | OBSERVED | `Origin::Observed` (+ tag `"observed"`) | +//! | SIMULATED | `Origin::Simulated` | +//! | APPROVED / DESIRED | tag `"desired"`, settable only on a valid version | +//! | OBSERVED AFTER EXECUTION | a newer `Origin::Observed`; converged when its diff to `"desired"` is empty | +//! +//! Tags mirror lance-graph `VersionedGraph::tag_version`; `VersionId` is +//! meant to map 1:1 onto a Lance dataset version once persisted. + +use crate::graph::{ApplyError, Change, GraphState}; +use crate::rule::{EvidenceRef, Rule, RuleId}; +use crate::validate::{Violation, validate}; +use ogar_dir_core::Guid128; +use std::collections::BTreeMap; + +/// Version identifier (monotonic logical clock of this store). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VersionId(pub u64); + +/// Tag set on every new observation. +pub const TAG_OBSERVED: &str = "observed"; +/// Tag naming the current desired state. +pub const TAG_DESIRED: &str = "desired"; + +/// Where a version came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Origin { + /// Read from reality. + Observed { + /// Which observer (e.g. `"ogar-ad:ldif"`). + source: String, + /// When it was read (unix ms). + observed_at_ms: i64, + }, + /// Produced by a pure rule. + Simulated { + /// The rule. + rule: RuleId, + /// The input it ran on. + evidence: Vec, + }, +} + +/// One version's provenance record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Version { + /// This version. + pub id: VersionId, + /// The version it was derived from (`None` for an observation). + pub parent: Option, + /// What produced it. + pub origin: Origin, + /// The changes it introduced relative to `parent` (empty for an observation). + pub delta: Vec, +} + +/// Simulation failure. No version is created. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SimError { + /// No such version. + UnknownVersion(VersionId), + /// The rule proposed nothing (no new version — nothing to explore). + EmptyProposal(RuleId), + /// The proposal does not apply to the parent. + Apply(ApplyError), +} + +/// Refusal to make a version desired. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rejection { + /// The rejected version (it stays in the store as evidence). + pub version: VersionId, + /// Why. + pub violations: Vec, +} + +/// Append-only version store. +#[derive(Debug, Default)] +pub struct VersionStore { + versions: Vec, + snapshots: BTreeMap, + tags: BTreeMap, + verdicts: BTreeMap>, +} + +impl VersionStore { + /// Empty store. + pub fn new() -> Self { + Self::default() + } + + fn next_id(&self) -> VersionId { + VersionId(self.versions.len() as u64) + } + + /// Record an observation as a new root version; tags it `"observed"`. + pub fn observe(&mut self, source: &str, observed_at_ms: i64, state: GraphState) -> VersionId { + let id = self.next_id(); + self.versions.push(Version { + id, + parent: None, + origin: Origin::Observed { + source: source.into(), + observed_at_ms, + }, + delta: Vec::new(), + }); + self.snapshots.insert(id, state); + self.tags.insert(TAG_OBSERVED.into(), id); + id + } + + /// Provenance record of a version. + pub fn version(&self, v: VersionId) -> Option<&Version> { + self.versions.get(v.0 as usize) + } + + /// Path from the observed root to `v`, root first. + pub fn lineage(&self, v: VersionId) -> Result, SimError> { + let mut path = Vec::new(); + let mut cur = Some(v); + while let Some(c) = cur { + path.push(c); + cur = self.version(c).ok_or(SimError::UnknownVersion(c))?.parent; + } + path.reverse(); + Ok(path) + } + + /// Materialize the full state of `v` (root snapshot + deltas). + pub fn state(&self, v: VersionId) -> Result { + let path = self.lineage(v)?; + let mut s = self + .snapshots + .get(&path[0]) + .cloned() + .ok_or(SimError::UnknownVersion(path[0]))?; + for id in &path[1..] { + s = s + .apply_all(&self.versions[id.0 as usize].delta) + .map_err(SimError::Apply)?; + } + Ok(s) + } + + /// Run a pure rule against `parent`, recording the result as a new + /// hypothetical version. Never touches anything outside the store. + pub fn simulate( + &mut self, + parent: VersionId, + rule: &dyn Rule, + evidence: &[EvidenceRef], + ) -> Result { + let base = self.state(parent)?; + let delta = rule.propose(&base, evidence); + if delta.is_empty() { + return Err(SimError::EmptyProposal(rule.id())); + } + base.apply_all(&delta).map_err(SimError::Apply)?; + let id = self.next_id(); + self.versions.push(Version { + id, + parent: Some(parent), + origin: Origin::Simulated { + rule: rule.id(), + evidence: evidence.to_vec(), + }, + delta, + }); + Ok(id) + } + + /// Validate a version and record the verdict. Empty = valid. + pub fn validate(&mut self, v: VersionId) -> Result, SimError> { + let violations = validate(&self.state(v)?); + self.verdicts.insert(v, violations.clone()); + Ok(violations) + } + + /// Recorded verdict, if `validate` ran. + pub fn verdict(&self, v: VersionId) -> Option<&[Violation]> { + self.verdicts.get(&v).map(Vec::as_slice) + } + + /// Make `v` the desired state. Validates first; on any violation the + /// `"desired"` tag is left exactly as it was and the version is kept. + pub fn promote_desired(&mut self, v: VersionId) -> Result<(), Rejection> { + let violations = self.validate(v).map_err(|_| Rejection { + version: v, + violations: Vec::new(), + })?; + if !violations.is_empty() { + return Err(Rejection { + version: v, + violations, + }); + } + self.tags.insert(TAG_DESIRED.into(), v); + Ok(()) + } + + /// Version a tag points to. + pub fn tag(&self, name: &str) -> Option { + self.tags.get(name).copied() + } + + /// Semantic difference `a → b`. + pub fn diff(&self, a: VersionId, b: VersionId) -> Result, SimError> { + Ok(self.state(a)?.diff(&self.state(b)?)) + } + + /// Why does `v` contain the membership `(user, group)`? Returns the + /// lineage from the observed root to the version whose delta introduced + /// it, or `None` if `v` does not contain it. A chain of length 1 means + /// it was observed, not simulated. + pub fn explain_membership( + &self, + v: VersionId, + user: Guid128, + group: Guid128, + ) -> Option> { + if !self.state(v).ok()?.is_member(user, group) { + return None; + } + let path = self.lineage(v).ok()?; + let add = Change::AddMembership { user, group }; + // The LAST delta on the path that added it (a later re-add after a + // removal is the one that explains the current state). + let at = path + .iter() + .rposition(|id| self.versions[id.0 as usize].delta.contains(&add)) + .unwrap_or(0); + Some( + path[..=at] + .iter() + .map(|id| &self.versions[id.0 as usize]) + .collect(), + ) + } +} diff --git a/crates/ogar-dir-sim/src/validate.rs b/crates/ogar-dir-sim/src/validate.rs new file mode 100644 index 0000000..c5cbbbe --- /dev/null +++ b/crates/ogar-dir-sim/src/validate.rs @@ -0,0 +1,88 @@ +//! Invariants over one version. Each failure is structured evidence (which +//! objects, which value), never a message string. + +use crate::graph::{GraphState, NodeKind, normalize}; +use ogar_dir_core::Guid128; +use std::collections::BTreeMap; + +/// Which end of a membership edge is missing or of the wrong kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Endpoint { + /// The member side. + User, + /// The group side. + Group, +} + +/// One invariant violation. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Violation { + /// Two or more active recipients own the same normalized primary SMTP. + DuplicateSmtp { + /// Normalized address. + address: String, + /// Every owner, ordered. + owners: Vec, + }, + /// Two or more active users own the same normalized UPN. + DuplicateUpn { + /// Normalized UPN. + upn: String, + /// Every owner, ordered. + owners: Vec, + }, + /// A membership edge references a missing or wrong-kind endpoint. + DanglingMembership { + /// Member side as recorded. + user: Guid128, + /// Group side as recorded. + group: Guid128, + /// Which side is broken. + missing: Endpoint, + }, +} + +fn duplicates(entries: impl Iterator) -> Vec<(String, Vec)> { + let mut by: BTreeMap> = BTreeMap::new(); + for (k, id) in entries { + by.entry(k).or_default().push(id); + } + by.into_iter().filter(|(_, v)| v.len() > 1).collect() +} + +/// All violations of `g`, deterministic order. Empty = valid. +pub fn validate(g: &GraphState) -> Vec { + let active_users = || { + g.nodes() + .filter(|(_, n)| n.kind == NodeKind::User && n.active) + }; + let mut out = Vec::new(); + for (address, owners) in duplicates( + active_users().filter_map(|(id, n)| Some((normalize(n.primary_smtp.as_ref()?), *id))), + ) { + out.push(Violation::DuplicateSmtp { address, owners }); + } + for (upn, owners) in + duplicates(active_users().filter_map(|(id, n)| Some((normalize(n.upn.as_ref()?), *id)))) + { + out.push(Violation::DuplicateUpn { upn, owners }); + } + for &(user, group) in g.memberships() { + let is = |id: &Guid128, k| g.node(id).is_some_and(|n| n.kind == k); + if !is(&user, NodeKind::User) { + out.push(Violation::DanglingMembership { + user, + group, + missing: Endpoint::User, + }); + } else if !is(&group, NodeKind::Group) { + out.push(Violation::DanglingMembership { + user, + group, + missing: Endpoint::Group, + }); + } + } + out.sort(); + out +} diff --git a/crates/ogar-dir-sim/tests/main.rs b/crates/ogar-dir-sim/tests/main.rs new file mode 100644 index 0000000..089a79b --- /dev/null +++ b/crates/ogar-dir-sim/tests/main.rs @@ -0,0 +1,410 @@ +//! The simulation slice: observe → simulate → validate → diff → plan. + +use ogar_dir_core::Guid128; +use ogar_dir_sim::rule::{GrantGroup, ImplyGroup, SetPrimarySmtp}; +use ogar_dir_sim::*; + +fn g(n: u8) -> Guid128 { + Guid128([n; 16]) +} +const ALICE: u8 = 0xA1; +const BOB: u8 = 0xB0; +const EMPLOYEES: u8 = 0xE0; +const EXCHANGE: u8 = 0xEC; + +const EXCHANGE_ACCESS: RuleId = RuleId { + name: "ExchangeAccess", + version: 1, +}; +const EMPLOYEES_GET_EXCHANGE: RuleId = RuleId { + name: "EmployeesGetExchange", + version: 1, +}; +const RENAME_MAIL: RuleId = RuleId { + name: "RenameMail", + version: 1, +}; + +/// Observed G0: Alice and Bob in Employees; ExchangeUsers exists, empty. +fn observed() -> GraphState { + let mut s = GraphState::new(); + s.put_node( + g(ALICE), + Node::user("Alice", "alice@example.test", "alice@example.test"), + ); + s.put_node( + g(BOB), + Node::user("Bob", "bob@example.test", "bob@example.test"), + ); + s.put_node(g(EMPLOYEES), Node::group("Employees")); + s.put_node(g(EXCHANGE), Node::group("ExchangeUsers")); + s.put_membership(g(ALICE), g(EMPLOYEES)); + s.put_membership(g(BOB), g(EMPLOYEES)); + s +} + +fn grant_alice() -> GrantGroup { + GrantGroup { + rule: EXCHANGE_ACCESS, + group: g(EXCHANGE), + to: vec![g(ALICE)], + } +} +fn imply() -> ImplyGroup { + ImplyGroup { + rule: EMPLOYEES_GET_EXCHANGE, + source: g(EMPLOYEES), + target: g(EXCHANGE), + } +} +fn ev(s: &str) -> Vec { + vec![EvidenceRef(s.into())] +} + +/// G0 → G1 (ExchangeAccess for Alice) → G2 (all employees get Exchange). +fn chain() -> (VersionStore, VersionId, VersionId, VersionId) { + let mut st = VersionStore::new(); + let g0 = st.observe("lab", 1_000, observed()); + let g1 = st.simulate(g0, &grant_alice(), &ev("REQ-1")).unwrap(); + let g2 = st.simulate(g1, &imply(), &ev("POLICY-7")).unwrap(); + (st, g0, g1, g2) +} + +// 1. G0 remains immutable after simulation. +#[test] +fn t01_observed_version_is_immutable() { + let (st, g0, ..) = chain(); + assert_eq!(st.state(g0).unwrap(), observed()); + assert!(st.version(g0).unwrap().delta.is_empty()); + assert!(!st.state(g0).unwrap().is_member(g(ALICE), g(EXCHANGE))); +} + +// 2. A rule produces G1 with G0 as parent; 10. provenance names the rule. +#[test] +fn t02_t10_rule_produces_child_with_provenance() { + let (st, g0, g1, _) = chain(); + let v1 = st.version(g1).unwrap(); + assert_eq!(v1.parent, Some(g0)); + assert_eq!( + v1.origin, + Origin::Simulated { + rule: EXCHANGE_ACCESS, + evidence: ev("REQ-1") + } + ); + assert_eq!( + v1.delta, + vec![Change::AddMembership { + user: g(ALICE), + group: g(EXCHANGE) + }] + ); + assert!(st.state(g1).unwrap().is_member(g(ALICE), g(EXCHANGE))); +} + +// 3. A second rule produces G2 from G1 — and it is population-shaped: +// it adds only Bob, because Alice already holds the target. +#[test] +fn t03_chained_rule_from_g1() { + let (st, g0, g1, g2) = chain(); + let v2 = st.version(g2).unwrap(); + assert_eq!(v2.parent, Some(g1)); + assert_eq!( + v2.delta, + vec![Change::AddMembership { + user: g(BOB), + group: g(EXCHANGE) + }] + ); + assert_eq!(st.lineage(g2).unwrap(), vec![g0, g1, g2]); + // running the population rule on G2 again has nothing left to do + let mut st = st; + assert_eq!( + st.simulate(g2, &imply(), &[]), + Err(SimError::EmptyProposal(EMPLOYEES_GET_EXCHANGE)) + ); +} + +// 4. diff(G0, G1) is exactly one semantic change. +#[test] +fn t04_diff_is_one_semantic_change() { + let (st, g0, g1, g2) = chain(); + assert_eq!( + st.diff(g0, g1).unwrap(), + vec![Change::AddMembership { + user: g(ALICE), + group: g(EXCHANGE) + }] + ); + assert_eq!(st.diff(g0, g2).unwrap().len(), 2); + assert!(st.diff(g1, g1).unwrap().is_empty()); +} + +// 5. Valid group membership passes invariants. +#[test] +fn t05_valid_version_passes() { + let (mut st, g0, _, g2) = chain(); + assert!(st.validate(g0).unwrap().is_empty()); + assert!(st.validate(g2).unwrap().is_empty()); + st.promote_desired(g2).unwrap(); + assert_eq!(st.tag(TAG_DESIRED), Some(g2)); +} + +// 6. A dangling membership fails, with structured evidence. +#[test] +fn t06_dangling_membership_fails() { + let ghost = g(0x66); + let mut st = VersionStore::new(); + let g0 = st.observe("lab", 0, observed()); + let bad = st + .simulate( + g0, + &GrantGroup { + rule: EXCHANGE_ACCESS, + group: ghost, + to: vec![g(ALICE)], + }, + &[], + ) + .unwrap(); + assert_eq!( + st.validate(bad).unwrap(), + vec![Violation::DanglingMembership { + user: g(ALICE), + group: ghost, + missing: Endpoint::Group + }] + ); + // a user-side dangling edge, and a group used as a member, both fail too + let mut s = observed(); + s.put_membership(ghost, g(EXCHANGE)); + s.put_membership(g(EMPLOYEES), g(EXCHANGE)); + let v = validate(&s); + assert!(v.contains(&Violation::DanglingMembership { + user: ghost, + group: g(EXCHANGE), + missing: Endpoint::User + })); + assert!(v.contains(&Violation::DanglingMembership { + user: g(EMPLOYEES), + group: g(EXCHANGE), + missing: Endpoint::User + })); +} + +// 7. Duplicate UPN fails (normalized), but an inactive owner does not count. +#[test] +fn t07_duplicate_upn_fails() { + let mut s = observed(); + s.put_node( + g(0x77), + Node::user("Imposter", " ALICE@example.test", "other@example.test"), + ); + assert_eq!( + validate(&s), + vec![Violation::DuplicateUpn { + upn: "alice@example.test".into(), + owners: vec![g(0x77), g(ALICE)] + }] + ); + let mut disabled = Node::user("Imposter", "alice@example.test", "other@example.test"); + disabled.active = false; + s.put_node(g(0x77), disabled); + assert!(validate(&s).is_empty()); +} + +// 8 + 9 + rejected future. Bob.primarySMTP = alice@… is constructible, +// detected, refused for promotion, kept as evidence — and nothing else moved. +#[test] +fn t08_t09_smtp_collision_is_rejected_without_touching_ancestors() { + let (mut st, g0, g1, g2) = chain(); + st.promote_desired(g2).unwrap(); + let before: Vec<_> = [g0, g1, g2].iter().map(|v| st.state(*v).unwrap()).collect(); + + let rule = SetPrimarySmtp { + rule: RENAME_MAIL, + user: g(BOB), + to: "Alice@Example.test".into(), + }; + let g3 = st + .simulate(g2, &rule, &ev("REQ-2")) + .expect("the hypothetical future is constructible"); + let rej = st.promote_desired(g3).unwrap_err(); + assert_eq!(rej.version, g3); + assert_eq!( + rej.violations, + vec![Violation::DuplicateSmtp { + address: "alice@example.test".into(), + owners: vec![g(ALICE), g(BOB)] + }] + ); + // 9: desired tag unchanged; every ancestor identical; G3 kept as evidence + assert_eq!(st.tag(TAG_DESIRED), Some(g2)); + let after: Vec<_> = [g0, g1, g2].iter().map(|v| st.state(*v).unwrap()).collect(); + assert_eq!(before, after); + assert_eq!(st.verdict(g3), Some(rej.violations.as_slice())); + assert_eq!( + st.state(g3) + .unwrap() + .node(&g(BOB)) + .unwrap() + .primary_smtp + .as_deref(), + Some("Alice@Example.test") + ); +} + +// A stale compare-and-set is refused and creates no version. +#[test] +fn stale_change_creates_no_version() { + struct Stale; + impl Rule for Stale { + fn id(&self) -> RuleId { + RENAME_MAIL + } + fn propose(&self, _: &GraphState, _: &[EvidenceRef]) -> Vec { + vec![Change::SetAttribute { + node: g(BOB), + attribute: Attribute::PrimarySmtp, + from: Some("not-what-it-is@example.test".into()), + to: Some("x@example.test".into()), + }] + } + } + let mut st = VersionStore::new(); + let g0 = st.observe("lab", 0, observed()); + assert!(matches!( + st.simulate(g0, &Stale, &[]), + Err(SimError::Apply(_)) + )); + assert!(st.version(VersionId(1)).is_none()); +} + +// 11. The semantic diff becomes an ExecutionPlan with no shell information, +// and every op carries the precondition from the observed basis. +#[test] +fn t11_plan_is_semantic_with_preconditions() { + let (mut st, g0, g1, g2) = chain(); + assert_eq!( + ExecutionPlan::derive(&st, g2), + Err(PlanError::NotDesired(g2)), + "only desired versions plan" + ); + st.promote_desired(g2).unwrap(); + let plan = ExecutionPlan::derive(&st, g2).unwrap(); + assert_eq!((plan.basis, plan.target), (g0, g2)); + assert_eq!( + plan.ops, + vec![ + PlannedOp { + op: Operation::AddGroupMember { + group: g(EXCHANGE), + member: g(ALICE) + }, + precondition: Precondition::NotMember + }, + PlannedOp { + op: Operation::AddGroupMember { + group: g(EXCHANGE), + member: g(BOB) + }, + precondition: Precondition::NotMember + }, + ] + ); + let _ = g1; + // An attribute op carries the value it expects reality to still hold. + let mut st2 = VersionStore::new(); + let b0 = st2.observe("lab", 0, observed()); + let rule = SetPrimarySmtp { + rule: RENAME_MAIL, + user: g(BOB), + to: "robert@example.test".into(), + }; + let b1 = st2.simulate(b0, &rule, &[]).unwrap(); + st2.promote_desired(b1).unwrap(); + assert_eq!( + ExecutionPlan::derive(&st2, b1).unwrap().ops, + vec![PlannedOp { + op: Operation::SetAttribute { + object: g(BOB), + attribute: Attribute::PrimarySmtp, + value: Some("robert@example.test".into()) + }, + precondition: Precondition::AttributeEquals(Some("bob@example.test".into())), + }] + ); +} + +// 12. Same observed input + same rules ⇒ same desired state and same plan. +#[test] +fn t12_deterministic() { + let run = || { + let (mut st, _, _, g2) = chain(); + st.promote_desired(g2).unwrap(); + ( + st.state(g2).unwrap(), + ExecutionPlan::derive(&st, g2).unwrap(), + ) + }; + assert_eq!(run(), run()); +} + +// Audit: why is Alice in ExchangeUsers? observed G0 → ExchangeAccess/v1 → G1. +#[test] +fn audit_chain_reconstructs_the_cause() { + let (st, g0, g1, g2) = chain(); + let why = st.explain_membership(g2, g(ALICE), g(EXCHANGE)).unwrap(); + assert_eq!(why.iter().map(|v| v.id).collect::>(), vec![g0, g1]); + assert!(matches!(why[0].origin, Origin::Observed { .. })); + match &why[1].origin { + Origin::Simulated { rule, evidence } => { + assert_eq!(rule.to_string(), "ExchangeAccess/v1"); + assert_eq!(evidence, &ev("REQ-1")); + } + o => panic!("unexpected origin {o:?}"), + } + // Bob's membership has a different cause; Employees was observed. + let bob = st.explain_membership(g2, g(BOB), g(EXCHANGE)).unwrap(); + assert_eq!(bob.last().unwrap().id, g2); + assert_eq!( + st.explain_membership(g2, g(ALICE), g(EMPLOYEES)) + .unwrap() + .len(), + 1 + ); + assert!(st.explain_membership(g0, g(ALICE), g(EXCHANGE)).is_none()); +} + +// Convergence check shape: a later observation equal to desired has an empty diff. +#[test] +fn observing_the_desired_state_converges() { + let (mut st, _, _, g2) = chain(); + st.promote_desired(g2).unwrap(); + let after = st.state(g2).unwrap(); + let o = st.observe("lab", 2_000, after); + assert!(st.diff(o, st.tag(TAG_DESIRED).unwrap()).unwrap().is_empty()); + assert_eq!(st.tag(TAG_OBSERVED), Some(o)); +} + +// Observed state from PR #313 records. +#[test] +fn observe_from_ogar_ad_records() { + use ogar_dir_core::{OuDictionary, ValuePool}; + let ldif = "dn: CN=Alice,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nuserPrincipalName: alice@example.test\nproxyAddresses: smtp:a@legacy.test\nproxyAddresses: SMTP:alice@example.test\nuserAccountControl: 512\n\ndn: CN=Employees,OU=Groups,DC=example,DC=test\nobjectGUID:: 1MOyoQAAAECAAAAAAAC+7w==\nobjectClass: group\n"; + let (mut d, mut p) = (OuDictionary::new(), ValuePool::new()); + let recs: Vec<_> = ogar_ad::ldif::parse(ldif) + .unwrap() + .iter() + .map(|e| { + ogar_ad::encode(e, Guid128::NIL, &mut d, &mut p, 0) + .unwrap() + .record + }) + .collect(); + let s = observe::from_ad(&recs, &p); + let a = s.node(&recs[0].node_guid()).unwrap(); + assert_eq!((a.kind, a.active), (NodeKind::User, true)); + assert_eq!(a.primary_smtp.as_deref(), Some("alice@example.test")); + assert_eq!(s.node(&recs[1].node_guid()).unwrap().kind, NodeKind::Group); +} diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md new file mode 100644 index 0000000..3b25057 --- /dev/null +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -0,0 +1,139 @@ +# Directory simulation PoC — `ogar-dir-sim` + +Status: **PoC, 2026-10-03.** Builds on PR #313 (`ogar-dir-core` / `ogar-ad` / +`ogar-az`). Pure and in-memory. Nothing here writes to AD, Entra, Exchange, +LDAP or PowerShell. There is no network, process or file I/O in the crate. + +``` +observed G0 ──rule──► G1 ──rule──► G2 ──validate──► "desired" ──diff(G0,G2)──► ExecutionPlan ──X (actuator: later) +``` + +## 1. What already existed, and what was reused + +| Existing | Where | Decision | +|---|---|---| +| `ScenarioBranch` / `ScenarioDiff` / `ScenarioWorld` | lance-graph-contract `scenario.rs` | **Not adopted. Its shape is mirrored.** It is the named write-divergent branch over a `forked_from` Lance version, which is the concept used here. Its fields are NARS/archetype-specific (`inference_mode`, `archetype_prior`, `fork_seed`, interventions as opaque `u64`), and `ScenarioDiff` is a set of counts, not semantics. | +| `VersionedGraph` (`tag_version`, `at_version`, `diff`) | lance-graph `graph/versioned.rs` | **Not adopted (conflict V1).** Node ids are `u32`, which cannot hold a 128-bit directory GUID. History is linear, each write overwrites the whole state, and the diff reports additions only (no removals, no attribute changes). Its *tag* model is mirrored: `"observed"` / `"desired"` are tags, and `VersionId` is designed to map 1:1 onto a Lance version once persisted. | +| `LanceVersion = u64`, `TemporalPov` | contract `temporal_pov.rs` | Same scalar shape as `VersionId`. Not imported, so the crate stays free of the git dependency. | +| `CausalEdge64` | `causal-edge` | Does not fit. It is 8 bytes and cannot carry two 128-bit endpoints. | +| `ActionDef` / `ActionInvocation` | `ogar-vocab` | `ActionInvocation` is the **runtime** record: string ids plus a Pending/Committed/Failed lifecycle. A `PlannedOp` is what an actuator would later lower into one. It is not an invocation itself. | +| `ogar-action-handler` executors | OGAR | This is the side that does I/O (shell, SSH). It is exactly what the plan boundary keeps out. | +| `ogar-loco` / `ogar-r2il` | OGAR | These are the program/call ABI. They are a natural future encoding for compiled rules, but they are not a graph-rule engine. Not used. | +| `FieldMask` / `WideFieldMask` / `standing_mask` | contract | These are **column** masks. `dirty ∩ interest` is the right future shape for "which observation fields would invalidate a plan". They are not used for rows. | +| `RowFocusMask` | contract `attention_facet.rs` | It holds address subtrees, not row bitsets. | +| lance-graph-java `Mask` | lgj | It is the row-bitset algebra (`and` / `minus` over `u64` words). `Population` has the identical shape so it can be swapped in later. | +| `Guid128`, `ogar-ad` records | PR #313 | Reused. Identity is `Guid128`, and `observe::from_ad` reads the `DirRecord` slots. | + +## 2. Boundary + +There is one new crate, `ogar-dir-sim`. It depends on `ogar-dir-core` and +`ogar-ad` (for the observation bridge only). Its modules are `graph`, +`population`, `rule`, `store`, `validate`, `plan` and `observe`. + +## 3. Versions and provenance + +`VersionStore` is append-only. + +- An **observed** version is a root. It holds a full snapshot and its origin is + `Origin::Observed { source, observed_at_ms }`. +- A **simulated** version holds `parent` plus `delta: Vec`. Its origin + is `Origin::Simulated { rule: RuleId, evidence }`. Its state is the parent's + state with the delta applied. This is structural sharing by delta: no full + copy is stored. +- `VersionId` is the logical clock. The `lineage(v)`, `version(v)` and + `explain_membership` functions read provenance directly from the history. + There is no separate log. + +The lifecycle stages are expressed as data, not as a workflow enum: + +- **OBSERVED** is `Origin::Observed`, plus the tag `"observed"`. +- **SIMULATED** is `Origin::Simulated`. +- **DESIRED** is the tag `"desired"`. Only `promote_desired` sets it, and only + after validation passes. +- **OBSERVED AFTER EXECUTION** is a newer observation. The system has converged + when `diff(observed, desired)` is empty. + +## 4. Rule + +```rust +trait Rule { + fn id(&self) -> RuleId; // e.g. ExchangeAccess/v1 + fn propose(&self, g: &GraphState, evidence: &[EvidenceRef]) -> Vec; +} +``` + +A rule has no I/O handle and cannot mutate anything. The slice ships three +example rules: + +- `GrantGroup`: an explicit population is granted a group. +- `ImplyGroup`: computes `active ∩ members(source) − members(target)`. +- `SetPrimarySmtp`: a compare-and-set of one attribute. + +## 5. Invariants + +`validate(&GraphState) -> Vec`, which is structured and sorted. It +checks three things: + +- `DuplicateSmtp { address, owners }`: no two active users may share the + normalized primary SMTP. +- `DuplicateUpn { upn, owners }`: no two active users may share the + normalized UPN. +- `DanglingMembership { user, group, missing }`: a membership must point to an + existing user and an existing group, each of the right kind. + +`promote_desired` validates first. If validation fails, the `"desired"` tag is +left untouched and the version stays in the store with its recorded verdict, so +a rejected future remains available as evidence. + +## 6. Semantic diff and execution plan + +`Change` is both what a rule proposes and what a diff reports: + +- `AddMembership` +- `RemoveMembership` +- `SetAttribute { from, to }` + +A change carries the value it expects to replace, so applying a stale change is +refused (compare-and-set). + +`ExecutionPlan { basis, target, ops: Vec }`: + +- `basis` is the observed root. +- `target` must currently carry the `"desired"` tag. +- `op` is one of `AddGroupMember`, `RemoveGroupMember`, `SetAttribute`. +- `precondition` is `NotMember`, `IsMember` or `AttributeEquals(value)`, taken + from the basis. + +This is the optimistic reality check a future actuator runs before each +operation: if the precondition still holds it executes; otherwise it +re-observes and re-plans. The plan contains no shell text, endpoints or +credentials. + +## 7. Population compatibility + +Rules receive the whole version and select **populations**: a `Population` is a +bitset over the version's sorted node order, with `and`, `minus` and `count`. +For example, `ImplyGroup` is one expression over sets, not a loop of per-user +workflows. The bit index is an execution ordinal valid only within one version. +Identity stays `Guid128`. + +The suitable lance-graph primitives for scaling this up are: + +- the lgj row `Mask` algebra, for the population expressions; +- `WideFieldMask` with `standing_mask::fires`, for "which observed fields + invalidate this plan"; +- Lance versions, as `VersionId`. + +## 8. Conflicts and open points + +- **V1 — VersionedGraph.** lance-graph `VersionedGraph` uses `u32` node ids and + has an additions-only diff. Persisting this store on Lance needs 128-bit node + keys and a removal- and attribute-aware diff upstream, or a separate + directory dataset that mirrors this store's semantics. +- **V2 — no row-population mask in the contract.** `Population` is a local copy + of the lgj `Mask` shape and should be retired when a shared row mask exists. +- **V3 — node creation and deletion.** These are not part of the `Change` + algebra in this slice. A plan whose target changes the set of nodes is + refused with `PlanError::NodeSetChanged`. +- **V4 — the "active" signal.** It comes only from AD's `userAccountControl`. + Entra `accountEnabled` is not yet mapped by `observe`. From 805433d9289c0bdaa51cc62591ff4f6289cb9c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:09:16 +0000 Subject: [PATCH 2/5] ogar-dir-sim: reduce to the semantic vocabulary; execution moves to lance-graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first slice simulated over an owned BTreeMap graph with String fields and cloned the whole state per change — an AoS object graph, the shape the substrate exists to avoid. OGAR keeps the meaning (Change, provenance, Violation, ExecutionPlan); the SoA snapshot, delta-overlay versions, rules, invariants and diff now run in lance-graph (crates/lance-graph-dir-sim) over Quack: Semijoin anti-joins, GroupReduce uniqueness, structural sharing measured at 853 B per one-edge mutation at both 1k and 100k users. Design + recon table: docs/DIRECTORY-SIMULATION-POC.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/Cargo.toml | 3 +- crates/ogar-dir-sim/src/change.rs | 52 ++++ crates/ogar-dir-sim/src/graph.rs | 265 ----------------- crates/ogar-dir-sim/src/lib.rs | 42 ++- crates/ogar-dir-sim/src/observe.rs | 58 ---- crates/ogar-dir-sim/src/plan.rs | 163 +++++----- crates/ogar-dir-sim/src/population.rs | 96 ------ crates/ogar-dir-sim/src/provenance.rs | 75 +++++ crates/ogar-dir-sim/src/store.rs | 254 ---------------- crates/ogar-dir-sim/src/validate.rs | 88 ------ crates/ogar-dir-sim/src/violation.rs | 42 +++ crates/ogar-dir-sim/tests/main.rs | 410 -------------------------- docs/DIRECTORY-SIMULATION-POC.md | 248 ++++++++-------- 13 files changed, 404 insertions(+), 1392 deletions(-) create mode 100644 crates/ogar-dir-sim/src/change.rs delete mode 100644 crates/ogar-dir-sim/src/graph.rs delete mode 100644 crates/ogar-dir-sim/src/observe.rs delete mode 100644 crates/ogar-dir-sim/src/population.rs create mode 100644 crates/ogar-dir-sim/src/provenance.rs delete mode 100644 crates/ogar-dir-sim/src/store.rs delete mode 100644 crates/ogar-dir-sim/src/validate.rs create mode 100644 crates/ogar-dir-sim/src/violation.rs delete mode 100644 crates/ogar-dir-sim/tests/main.rs diff --git a/crates/ogar-dir-sim/Cargo.toml b/crates/ogar-dir-sim/Cargo.toml index 076469b..667a765 100644 --- a/crates/ogar-dir-sim/Cargo.toml +++ b/crates/ogar-dir-sim/Cargo.toml @@ -6,8 +6,7 @@ license.workspace = true repository.workspace = true authors.workspace = true rust-version.workspace = true -description = "Versioned directory graph simulator: observed state G0, pure rules producing hypothetical versions, invariant validation, semantic diff, and a side-effect-free ExecutionPlan boundary. No AD / Graph / Exchange / LDAP / PowerShell writes." +description = "Semantic vocabulary for simulating directory desired state: the Change algebra, version provenance (observed / simulated, rule + evidence), structured invariant Violations and the side-effect-free ExecutionPlan boundary. Types only — execution lives in lance-graph (lance-graph-dir-sim) over Quack. No AD / Graph / Exchange / LDAP / PowerShell." [dependencies] ogar-dir-core = { path = "../ogar-dir-core" } -ogar-ad = { path = "../ogar-ad" } diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs new file mode 100644 index 0000000..19c0436 --- /dev/null +++ b/crates/ogar-dir-sim/src/change.rs @@ -0,0 +1,52 @@ +//! The change algebra. One type serves twice: a rule *proposes* changes, a +//! 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). + +use ogar_dir_core::Guid128; + +/// Attribute a change can set. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Attribute { + /// userPrincipalName. + Upn, + /// Primary SMTP address. + PrimarySmtp, +} + +/// One semantic change. `Ord` is total, so a sorted change list is a +/// canonical form (used for determinism). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Change { + /// `user` becomes a member of `group`. + AddMembership { + /// Member. + user: Guid128, + /// Group. + group: Guid128, + }, + /// `user` stops being a member of `group`. + RemoveMembership { + /// Member. + user: Guid128, + /// Group. + group: Guid128, + }, + /// Compare-and-set of one attribute. + SetAttribute { + /// Object. + node: Guid128, + /// Which attribute. + attribute: Attribute, + /// Value the change expects to replace (raw, as observed). + from: Option, + /// New value (raw). + to: Option, + }, +} + +/// Comparison form of a UPN / SMTP address: trimmed and ASCII-lowercased +/// (Exchange and Entra compare these case-insensitively). +pub fn normalize(s: &str) -> String { + s.trim().to_ascii_lowercase() +} diff --git a/crates/ogar-dir-sim/src/graph.rs b/crates/ogar-dir-sim/src/graph.rs deleted file mode 100644 index 77edb76..0000000 --- a/crates/ogar-dir-sim/src/graph.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! The semantic directory graph of ONE version, and the change algebra. -//! -//! This is directory meaning (users, groups, UPN, primary SMTP, membership), -//! not storage geometry. The 512-byte `DirRecord` is how an observation is -//! stored; this is what a rule reasons about and what a diff reports. -//! -//! [`Change`] is used twice on purpose: a rule *proposes* changes, and -//! [`GraphState::diff`] *reports* changes. Every attribute change carries the -//! value it expects to replace (`from`), so a change is a compare-and-set: -//! applying it to a state where `from` no longer holds is refused. That same -//! expectation is what a future actuator checks against reality before acting. - -use ogar_dir_core::Guid128; -use std::collections::{BTreeMap, BTreeSet}; - -/// Kind of directory object this graph models. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum NodeKind { - /// A user (a recipient when it has a primary SMTP address). - User, - /// A group. - Group, -} - -/// One directory object. Values are stored as observed (raw); comparisons -/// for uniqueness use [`normalize`]. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Node { - /// User or group. - pub kind: NodeKind, - /// Display name (explanatory only; never identity). - pub name: String, - /// False for disabled accounts; inactive nodes do not own addresses. - pub active: bool, - /// userPrincipalName. - pub upn: Option, - /// Primary SMTP address (the `SMTP:` proxy). - pub primary_smtp: Option, -} - -impl Node { - /// Active user with UPN and primary SMTP. - pub fn user(name: &str, upn: &str, smtp: &str) -> Self { - Self { - kind: NodeKind::User, - name: name.into(), - active: true, - upn: Some(upn.into()), - primary_smtp: Some(smtp.into()), - } - } - /// Group without addresses. - pub fn group(name: &str) -> Self { - Self { - kind: NodeKind::Group, - name: name.into(), - active: true, - upn: None, - primary_smtp: None, - } - } -} - -/// Comparison form of a UPN / SMTP address: ASCII-lowercased and trimmed. -/// (Exchange and Entra compare these case-insensitively.) -pub fn normalize(s: &str) -> String { - s.trim().to_ascii_lowercase() -} - -/// Attribute a change can set. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Attribute { - /// userPrincipalName. - Upn, - /// Primary SMTP address. - PrimarySmtp, -} - -/// One semantic change. Ordering is total so diffs are deterministic. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Change { - /// `user` becomes a member of `group`. - AddMembership { - /// Member. - user: Guid128, - /// Group. - group: Guid128, - }, - /// `user` stops being a member of `group`. - RemoveMembership { - /// Member. - user: Guid128, - /// Group. - group: Guid128, - }, - /// Compare-and-set of one attribute. - SetAttribute { - /// Object. - node: Guid128, - /// Which attribute. - attribute: Attribute, - /// Value the change expects to replace. - from: Option, - /// New value. - to: Option, - }, -} - -/// Why a change could not be applied to a state. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ApplyError { - /// A `SetAttribute` named a node that does not exist. - UnknownNode(Guid128), - /// A `SetAttribute`'s `from` does not match the current value (stale). - Stale { - /// Node. - node: Guid128, - /// Attribute. - attribute: Attribute, - /// What the change expected. - expected: Option, - /// What the state holds. - actual: Option, - }, - /// The membership already holds (add) / does not hold (remove). - NoOp(Change), -} - -/// Full semantic state of one version. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct GraphState { - nodes: BTreeMap, - /// `(user, group)` pairs. Endpoints are NOT required to exist here — a - /// dangling edge is representable so validation can report it. - members: BTreeSet<(Guid128, Guid128)>, -} - -impl GraphState { - /// Empty graph. - pub fn new() -> Self { - Self::default() - } - /// Insert or replace a node (observation building only). - pub fn put_node(&mut self, id: Guid128, node: Node) { - self.nodes.insert(id, node); - } - /// Insert a membership (observation building only). - pub fn put_membership(&mut self, user: Guid128, group: Guid128) { - self.members.insert((user, group)); - } - /// Node by id. - pub fn node(&self, id: &Guid128) -> Option<&Node> { - self.nodes.get(id) - } - /// All nodes, ordered by id. - pub fn nodes(&self) -> impl Iterator { - self.nodes.iter() - } - /// All `(user, group)` memberships, ordered. - pub fn memberships(&self) -> impl Iterator { - self.members.iter() - } - /// Membership test. - pub fn is_member(&self, user: Guid128, group: Guid128) -> bool { - self.members.contains(&(user, group)) - } - - fn attr(&self, id: &Guid128, a: Attribute) -> Result, ApplyError> { - let n = self.nodes.get(id).ok_or(ApplyError::UnknownNode(*id))?; - Ok(match a { - Attribute::Upn => n.upn.clone(), - Attribute::PrimarySmtp => n.primary_smtp.clone(), - }) - } - - /// Apply one change. Pure: returns a new state, `self` untouched. - pub fn apply(&self, c: &Change) -> Result { - let mut next = self.clone(); - match c { - Change::AddMembership { user, group } => { - if !next.members.insert((*user, *group)) { - return Err(ApplyError::NoOp(c.clone())); - } - } - Change::RemoveMembership { user, group } => { - if !next.members.remove(&(*user, *group)) { - return Err(ApplyError::NoOp(c.clone())); - } - } - Change::SetAttribute { - node, - attribute, - from, - to, - } => { - let actual = self.attr(node, *attribute)?; - if &actual != from { - return Err(ApplyError::Stale { - node: *node, - attribute: *attribute, - expected: from.clone(), - actual, - }); - } - let n = next.nodes.get_mut(node).expect("checked by attr"); - match attribute { - Attribute::Upn => n.upn = to.clone(), - Attribute::PrimarySmtp => n.primary_smtp = to.clone(), - } - } - } - Ok(next) - } - - /// Apply a change set in order (all or nothing). - pub fn apply_all(&self, cs: &[Change]) -> Result { - cs.iter().try_fold(self.clone(), |s, c| s.apply(c)) - } - - /// Net semantic difference `self → other`, deterministic order. - /// Applying the result to `self` yields `other` (for the modelled - /// attributes and memberships; node creation/deletion is out of scope - /// for this slice and reported by [`GraphState::node_set_differs`]). - pub fn diff(&self, other: &Self) -> Vec { - let mut out = Vec::new(); - for (user, group) in other.members.difference(&self.members) { - out.push(Change::AddMembership { - user: *user, - group: *group, - }); - } - for (user, group) in self.members.difference(&other.members) { - out.push(Change::RemoveMembership { - user: *user, - group: *group, - }); - } - for (id, a) in &self.nodes { - let Some(b) = other.nodes.get(id) else { - continue; - }; - for (attr, x, y) in [ - (Attribute::Upn, &a.upn, &b.upn), - (Attribute::PrimarySmtp, &a.primary_smtp, &b.primary_smtp), - ] { - if x != y { - out.push(Change::SetAttribute { - node: *id, - attribute: attr, - from: x.clone(), - to: y.clone(), - }); - } - } - } - out.sort(); - out - } - - /// True if the two states disagree on which nodes exist (not expressible - /// as a [`Change`] in this slice). - pub fn node_set_differs(&self, other: &Self) -> bool { - !self.nodes.keys().eq(other.nodes.keys()) - } -} diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index 113768f..1fbc8af 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -1,34 +1,26 @@ -//! # ogar-dir-sim — explore a directory future without touching reality +//! # ogar-dir-sim — the vocabulary of a simulated directory future //! //! ```text -//! observed G0 ──Rule──► G1 ──Rule──► G2 ──validate──► desired? ──diff(G0,G2)──► ExecutionPlan ──X +//! observed G0 ──rule──► G1 ──rule──► G2 ──validate──► "desired" ──diff──► ExecutionPlan ──X //! ``` //! -//! * [`graph`] — the semantic state of one version and the [`Change`] algebra. -//! * [`population`] — node sets as bitsets; rules select populations, not loops. -//! * [`rule`] — pure rules: `&GraphState -> Vec`, no I/O handle. -//! * [`store`] — append-only versions; provenance and tags ARE the audit trail. -//! * [`validate`] — invariants returning structured [`Violation`]s. -//! * [`plan`] — semantic [`ExecutionPlan`] with per-op preconditions. Never executed here. -//! * [`observe`] — `ogar-ad` records → observed state. +//! This crate holds only the **meaning** of that pipeline: what a change is, +//! where a version came from, what a violation says, and what a plan asks an +//! actuator to do. It executes nothing. Snapshots, versions, rules, +//! invariant evaluation and diffs run in lance-graph +//! (`crates/lance-graph-dir-sim`) over the SoA store and Quack's masking +//! operators — OGAR is the IR, lance-graph the execution (OGAR-AS-IR). //! -//! Nothing in this crate writes to AD, Entra, Exchange, LDAP or PowerShell, -//! and nothing in it can: there is no network, process or file I/O. -//! Design notes: `docs/DIRECTORY-SIMULATION-POC.md`. +//! Identity is always [`Guid128`](ogar_dir_core::Guid128). Dense ordinals, +//! dictionary ids and mask bits are execution detail and never appear here. +//! Design: `docs/DIRECTORY-SIMULATION-POC.md`. -pub mod graph; -pub mod observe; +pub mod change; pub mod plan; -pub mod population; -pub mod rule; -pub mod store; -pub mod validate; +pub mod provenance; +pub mod violation; -pub use graph::{Attribute, Change, GraphState, Node, NodeKind}; +pub use change::{Attribute, Change, normalize}; pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; -pub use population::Population; -pub use rule::{EvidenceRef, Rule, RuleId}; -pub use store::{ - Origin, Rejection, SimError, TAG_DESIRED, TAG_OBSERVED, Version, VersionId, VersionStore, -}; -pub use validate::{Endpoint, Violation, validate}; +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/observe.rs b/crates/ogar-dir-sim/src/observe.rs deleted file mode 100644 index 2ddcc48..0000000 --- a/crates/ogar-dir-sim/src/observe.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Observed state from `ogar-ad` records (PR #313) into a [`GraphState`]. -//! -//! Read-only and derived: "active" comes from `userAccountControl` bit -//! `0x2` (ACCOUNTDISABLE), the primary SMTP from the `SMTP:` proxy. Group -//! membership is not carried by `ogar-ad` records (it is a relation); the -//! caller adds observed memberships with [`GraphState::put_membership`]. - -use crate::graph::{GraphState, Node, NodeKind}; -use ogar_ad::{AdKind, SCHEMA_V1}; -use ogar_dir_core::{DirRecord, ValuePool}; - -const UAC_ACCOUNTDISABLE: u32 = 0x2; - -fn slot(name: &str) -> usize { - SCHEMA_V1 - .iter() - .find(|d| d.name == name) - .map(|d| d.slot as usize) - .expect("ogar-ad schema v1") -} - -fn text<'p>(r: &DirRecord, p: &'p ValuePool, name: &str) -> Option<&'p str> { - std::str::from_utf8(p.get(r.str_ref(slot(name))?)?).ok() -} - -/// Users and groups of `records` (other kinds are skipped). -pub fn from_ad(records: &[DirRecord], pool: &ValuePool) -> GraphState { - let mut g = GraphState::new(); - for r in records { - let kind = match r.object_kind() { - k if k == AdKind::User as u16 => NodeKind::User, - k if k == AdKind::Group as u16 => NodeKind::Group, - _ => continue, - }; - let primary_smtp = r - .str_ref(slot("proxyAddresses")) - .and_then(|s| pool.get_multi(s)) - .and_then(|vs| { - vs.into_iter() - .filter_map(|v| std::str::from_utf8(v).ok()) - .find_map(|v| v.strip_prefix("SMTP:").map(str::to_string)) - }); - g.put_node( - r.node_guid(), - Node { - kind, - name: text(r, pool, "displayName") - .or_else(|| text(r, pool, "sAMAccountName")) - .unwrap_or("") - .into(), - active: r.num(0).is_none_or(|uac| uac & UAC_ACCOUNTDISABLE == 0), - upn: text(r, pool, "userPrincipalName").map(str::to_string), - primary_smtp, - }, - ); - } - g -} diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs index f2cd9fd..e44cd88 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -1,22 +1,17 @@ -//! The execution boundary — derived, never executed here. +//! The execution boundary — described, never executed. //! -//! ```text -//! desired version ──diff from its observed basis──► ExecutionPlan ──X── actuator (later) -//! ``` -//! -//! A plan holds semantic operations only: no shell text, no cmdlet names, -//! no endpoints, no credentials. Each operation carries the -//! [`Precondition`] that held in the **observed basis** the plan was derived -//! from. A future actuator re-reads that one fact from reality before acting: -//! still true → execute; changed → re-observe and re-plan (optimistic -//! concurrency). Nothing in this module performs that check or any I/O. +//! A plan holds semantic operations only: no shell text, cmdlet names, +//! endpoints or credentials. Every operation carries the [`Precondition`] +//! that held in the **observed basis** it was derived from, so a future +//! actuator can re-read that one fact from reality before acting (still +//! true → execute; changed → re-observe and re-plan). -use crate::graph::{Attribute, Change}; -use crate::store::{Origin, SimError, VersionId, VersionStore}; +use crate::change::{Attribute, Change}; +use crate::provenance::VersionId; use ogar_dir_core::Guid128; /// A technology-neutral directory operation. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Operation { /// Add `member` to `group`. AddGroupMember { @@ -44,7 +39,7 @@ pub enum Operation { } /// What reality must still look like for the operation to be safe. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Precondition { /// The membership must still be absent. NotMember, @@ -55,7 +50,7 @@ pub enum Precondition { } /// One planned step. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PlannedOp { /// What to do. pub op: Operation, @@ -63,6 +58,40 @@ pub struct PlannedOp { pub precondition: Precondition, } +impl From for PlannedOp { + fn from(c: Change) -> Self { + match c { + Change::AddMembership { user, group } => Self { + op: Operation::AddGroupMember { + group, + member: user, + }, + precondition: Precondition::NotMember, + }, + Change::RemoveMembership { user, group } => Self { + op: Operation::RemoveGroupMember { + group, + member: user, + }, + precondition: Precondition::IsMember, + }, + Change::SetAttribute { + node, + attribute, + from, + to, + } => Self { + op: Operation::SetAttribute { + object: node, + attribute, + value: to, + }, + precondition: Precondition::AttributeEquals(from), + }, + } + } +} + /// Semantic plan from an observed basis to a desired target. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExecutionPlan { @@ -70,74 +99,62 @@ pub struct ExecutionPlan { pub basis: VersionId, /// The desired version the plan reaches. pub target: VersionId, - /// Operations, deterministic order. + /// Operations, sorted (canonical order). pub ops: Vec, } +impl ExecutionPlan { + /// Lower a semantic diff `basis → target` into a plan. + pub fn from_diff(basis: VersionId, target: VersionId, diff: Vec) -> Self { + let mut ops: Vec = diff.into_iter().map(PlannedOp::from).collect(); + ops.sort(); + Self { basis, target, ops } + } +} + /// Why no plan was derived. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PlanError { /// The target is not the current `"desired"` version. NotDesired(VersionId), - /// Version lookup failed. - Sim(SimError), - /// The target creates or deletes nodes (not plannable in this slice). - NodeSetChanged, + /// The target is unknown. + UnknownVersion(VersionId), } -fn lower(c: Change) -> PlannedOp { - match c { - Change::AddMembership { user, group } => PlannedOp { - op: Operation::AddGroupMember { - group, - member: user, - }, - precondition: Precondition::NotMember, - }, - Change::RemoveMembership { user, group } => PlannedOp { - op: Operation::RemoveGroupMember { - group, - member: user, - }, - precondition: Precondition::IsMember, - }, - Change::SetAttribute { - node, - attribute, - from, - to, - } => PlannedOp { - op: Operation::SetAttribute { - object: node, - attribute, - value: to, - }, - precondition: Precondition::AttributeEquals(from), - }, - } -} +#[cfg(test)] +mod tests { + use super::*; -impl ExecutionPlan { - /// Derive the plan for the current `"desired"` version `target`, from - /// the observed root of its lineage. - pub fn derive(store: &VersionStore, target: VersionId) -> Result { - if store.tag(crate::store::TAG_DESIRED) != Some(target) { - return Err(PlanError::NotDesired(target)); - } - let basis = store.lineage(target).map_err(PlanError::Sim)?[0]; - debug_assert!(matches!( - store.version(basis).map(|v| &v.origin), - Some(Origin::Observed { .. }) - )); - let (b, t) = ( - store.state(basis).map_err(PlanError::Sim)?, - store.state(target).map_err(PlanError::Sim)?, + #[test] + fn lowering_carries_the_basis_precondition_and_no_transport() { + let g = |n| Guid128([n; 16]); + let plan = ExecutionPlan::from_diff( + VersionId(0), + VersionId(2), + vec![ + Change::SetAttribute { + node: g(2), + attribute: Attribute::PrimarySmtp, + from: Some("bob@example.test".into()), + to: Some("robert@example.test".into()), + }, + Change::AddMembership { + user: g(1), + group: g(9), + }, + ], + ); + assert_eq!( + plan.ops[0].op, + Operation::AddGroupMember { + group: g(9), + member: g(1) + } + ); + assert_eq!(plan.ops[0].precondition, Precondition::NotMember); + assert_eq!( + plan.ops[1].precondition, + Precondition::AttributeEquals(Some("bob@example.test".into())) ); - if b.node_set_differs(&t) { - return Err(PlanError::NodeSetChanged); - } - let mut ops: Vec = b.diff(&t).into_iter().map(lower).collect(); - ops.sort(); - Ok(Self { basis, target, ops }) } } diff --git a/crates/ogar-dir-sim/src/population.rs b/crates/ogar-dir-sim/src/population.rs deleted file mode 100644 index 61244b8..0000000 --- a/crates/ogar-dir-sim/src/population.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! A set of nodes of one version, as a dense bitset over that version's -//! node order — the shape rules select with, so they never have to be -//! written as `for user in users { run_workflow(user) }`. -//! -//! Identity stays [`Guid128`]; the bit index is an execution ordinal valid -//! only for the [`GraphState`] it was taken from (the version's sorted node -//! order). [`Population::guids`] resolves it back. -//! -//! The contract crate has no row-population mask today (`FieldMask` / -//! `WideFieldMask` are column masks, `RowFocusMask` holds address subtrees). -//! This is deliberately the same shape as lance-graph-java's `Mask` -//! (`Box<[u64]>` over rows, `and` / `minus`), so it can be replaced by a -//! shared row mask without changing any rule. - -use crate::graph::{GraphState, Node, NodeKind}; -use ogar_dir_core::Guid128; - -/// Node set over one version's ordinals. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Population { - words: Vec, - order: Vec, -} - -impl Population { - fn empty_for(g: &GraphState) -> Self { - let order: Vec = g.nodes().map(|(id, _)| *id).collect(); - Self { - words: vec![0; order.len().div_ceil(64)], - order, - } - } - fn set(&mut self, i: usize) { - self.words[i / 64] |= 1 << (i % 64); - } - - /// All nodes matching `pred`. - pub fn select(g: &GraphState, pred: impl Fn(&Guid128, &Node) -> bool) -> Self { - let mut p = Self::empty_for(g); - for (i, (id, n)) in g.nodes().enumerate() { - if pred(id, n) { - p.set(i); - } - } - p - } - - /// Active users. - pub fn active_users(g: &GraphState) -> Self { - Self::select(g, |_, n| n.kind == NodeKind::User && n.active) - } - - /// Members of `group`. - pub fn members_of(g: &GraphState, group: Guid128) -> Self { - Self::select(g, |id, _| g.is_member(*id, group)) - } - - /// Exactly the given ids (unknown ids are ignored). - pub fn of(g: &GraphState, ids: &[Guid128]) -> Self { - Self::select(g, |id, _| ids.contains(id)) - } - - fn zip(&self, o: &Self, f: impl Fn(u64, u64) -> u64) -> Self { - assert_eq!(self.order, o.order, "populations from different versions"); - Self { - words: self - .words - .iter() - .zip(&o.words) - .map(|(a, b)| f(*a, *b)) - .collect(), - order: self.order.clone(), - } - } - /// Intersection. - pub fn and(&self, o: &Self) -> Self { - self.zip(o, |a, b| a & b) - } - /// Set difference. - pub fn minus(&self, o: &Self) -> Self { - self.zip(o, |a, b| a & !b) - } - /// Cardinality. - pub fn count(&self) -> u32 { - self.words.iter().map(|w| w.count_ones()).sum() - } - /// Resolve to identities, in version order. - pub fn guids(&self) -> Vec { - self.order - .iter() - .enumerate() - .filter(|(i, _)| self.words[i / 64] >> (i % 64) & 1 == 1) - .map(|(_, g)| *g) - .collect() - } -} diff --git a/crates/ogar-dir-sim/src/provenance.rs b/crates/ogar-dir-sim/src/provenance.rs new file mode 100644 index 0000000..db1b0ac --- /dev/null +++ b/crates/ogar-dir-sim/src/provenance.rs @@ -0,0 +1,75 @@ +//! Where a version came from. The version history is the audit trail. +//! +//! The lifecycle stages are not a workflow enum; they fall out of origin and +//! tags (mirroring lance-graph `VersionedGraph::tag_version`): +//! +//! | stage | expressed as | +//! |--------------------------|------------------------------------------------------| +//! | OBSERVED | [`Origin::Observed`], tag [`TAG_OBSERVED`] | +//! | SIMULATED | [`Origin::Simulated`] | +//! | APPROVED / DESIRED | tag [`TAG_DESIRED`], set only on a valid version | +//! | OBSERVED AFTER EXECUTION | a newer `Origin::Observed`; converged when its diff to `"desired"` is empty | + +use crate::change::Change; + +/// Version identifier — the store's monotonic logical clock. Designed to +/// map 1:1 onto a Lance dataset version once persisted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VersionId(pub u64); + +/// Tag set on every new observation. +pub const TAG_OBSERVED: &str = "observed"; +/// Tag naming the current desired state. +pub const TAG_DESIRED: &str = "desired"; + +/// Rule identity recorded in every version a rule produces. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RuleId { + /// Stable name, e.g. `"ExchangeAccess"`. + pub name: &'static str, + /// Rule version; a behaviour change is a new version, never an edit. + pub version: u16, +} + +impl std::fmt::Display for RuleId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/v{}", self.name, self.version) + } +} + +/// Opaque reference to the input that justified a rule run (request id, +/// ticket, HR record…). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EvidenceRef(pub String); + +/// What produced a version. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Origin { + /// Read from reality. + Observed { + /// Which observer (e.g. `"ogar-ad:ldif"`). + source: String, + /// When it was read (unix ms). + observed_at_ms: i64, + }, + /// Produced by a pure rule. + Simulated { + /// The rule. + rule: RuleId, + /// The input it ran on. + evidence: Vec, + }, +} + +/// One version's provenance record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Version { + /// This version. + pub id: VersionId, + /// The version it was derived from (`None` for an observation). + pub parent: Option, + /// What produced it. + pub origin: Origin, + /// The changes it introduced relative to `parent` (empty for an observation). + pub delta: Vec, +} diff --git a/crates/ogar-dir-sim/src/store.rs b/crates/ogar-dir-sim/src/store.rs deleted file mode 100644 index 2fa9274..0000000 --- a/crates/ogar-dir-sim/src/store.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! The version history. It is the audit trail: there is no separate log. -//! -//! * An **observed** version is a root holding a full snapshot of what was -//! read from reality. -//! * A **simulated** version holds only its parent and the changes a rule -//! proposed (structural sharing by delta). Its state is the parent's state -//! with the delta applied. -//! * Versions are append-only and never mutated. Simulating, validating or -//! rejecting a version cannot change any other version. -//! -//! The lifecycle stages are not a workflow enum. They fall out of data the -//! store already keeps: -//! -//! | stage | expressed as | -//! |--------------------------|---------------------------------------------------| -//! | OBSERVED | `Origin::Observed` (+ tag `"observed"`) | -//! | SIMULATED | `Origin::Simulated` | -//! | APPROVED / DESIRED | tag `"desired"`, settable only on a valid version | -//! | OBSERVED AFTER EXECUTION | a newer `Origin::Observed`; converged when its diff to `"desired"` is empty | -//! -//! Tags mirror lance-graph `VersionedGraph::tag_version`; `VersionId` is -//! meant to map 1:1 onto a Lance dataset version once persisted. - -use crate::graph::{ApplyError, Change, GraphState}; -use crate::rule::{EvidenceRef, Rule, RuleId}; -use crate::validate::{Violation, validate}; -use ogar_dir_core::Guid128; -use std::collections::BTreeMap; - -/// Version identifier (monotonic logical clock of this store). -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct VersionId(pub u64); - -/// Tag set on every new observation. -pub const TAG_OBSERVED: &str = "observed"; -/// Tag naming the current desired state. -pub const TAG_DESIRED: &str = "desired"; - -/// Where a version came from. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Origin { - /// Read from reality. - Observed { - /// Which observer (e.g. `"ogar-ad:ldif"`). - source: String, - /// When it was read (unix ms). - observed_at_ms: i64, - }, - /// Produced by a pure rule. - Simulated { - /// The rule. - rule: RuleId, - /// The input it ran on. - evidence: Vec, - }, -} - -/// One version's provenance record. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Version { - /// This version. - pub id: VersionId, - /// The version it was derived from (`None` for an observation). - pub parent: Option, - /// What produced it. - pub origin: Origin, - /// The changes it introduced relative to `parent` (empty for an observation). - pub delta: Vec, -} - -/// Simulation failure. No version is created. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum SimError { - /// No such version. - UnknownVersion(VersionId), - /// The rule proposed nothing (no new version — nothing to explore). - EmptyProposal(RuleId), - /// The proposal does not apply to the parent. - Apply(ApplyError), -} - -/// Refusal to make a version desired. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Rejection { - /// The rejected version (it stays in the store as evidence). - pub version: VersionId, - /// Why. - pub violations: Vec, -} - -/// Append-only version store. -#[derive(Debug, Default)] -pub struct VersionStore { - versions: Vec, - snapshots: BTreeMap, - tags: BTreeMap, - verdicts: BTreeMap>, -} - -impl VersionStore { - /// Empty store. - pub fn new() -> Self { - Self::default() - } - - fn next_id(&self) -> VersionId { - VersionId(self.versions.len() as u64) - } - - /// Record an observation as a new root version; tags it `"observed"`. - pub fn observe(&mut self, source: &str, observed_at_ms: i64, state: GraphState) -> VersionId { - let id = self.next_id(); - self.versions.push(Version { - id, - parent: None, - origin: Origin::Observed { - source: source.into(), - observed_at_ms, - }, - delta: Vec::new(), - }); - self.snapshots.insert(id, state); - self.tags.insert(TAG_OBSERVED.into(), id); - id - } - - /// Provenance record of a version. - pub fn version(&self, v: VersionId) -> Option<&Version> { - self.versions.get(v.0 as usize) - } - - /// Path from the observed root to `v`, root first. - pub fn lineage(&self, v: VersionId) -> Result, SimError> { - let mut path = Vec::new(); - let mut cur = Some(v); - while let Some(c) = cur { - path.push(c); - cur = self.version(c).ok_or(SimError::UnknownVersion(c))?.parent; - } - path.reverse(); - Ok(path) - } - - /// Materialize the full state of `v` (root snapshot + deltas). - pub fn state(&self, v: VersionId) -> Result { - let path = self.lineage(v)?; - let mut s = self - .snapshots - .get(&path[0]) - .cloned() - .ok_or(SimError::UnknownVersion(path[0]))?; - for id in &path[1..] { - s = s - .apply_all(&self.versions[id.0 as usize].delta) - .map_err(SimError::Apply)?; - } - Ok(s) - } - - /// Run a pure rule against `parent`, recording the result as a new - /// hypothetical version. Never touches anything outside the store. - pub fn simulate( - &mut self, - parent: VersionId, - rule: &dyn Rule, - evidence: &[EvidenceRef], - ) -> Result { - let base = self.state(parent)?; - let delta = rule.propose(&base, evidence); - if delta.is_empty() { - return Err(SimError::EmptyProposal(rule.id())); - } - base.apply_all(&delta).map_err(SimError::Apply)?; - let id = self.next_id(); - self.versions.push(Version { - id, - parent: Some(parent), - origin: Origin::Simulated { - rule: rule.id(), - evidence: evidence.to_vec(), - }, - delta, - }); - Ok(id) - } - - /// Validate a version and record the verdict. Empty = valid. - pub fn validate(&mut self, v: VersionId) -> Result, SimError> { - let violations = validate(&self.state(v)?); - self.verdicts.insert(v, violations.clone()); - Ok(violations) - } - - /// Recorded verdict, if `validate` ran. - pub fn verdict(&self, v: VersionId) -> Option<&[Violation]> { - self.verdicts.get(&v).map(Vec::as_slice) - } - - /// Make `v` the desired state. Validates first; on any violation the - /// `"desired"` tag is left exactly as it was and the version is kept. - pub fn promote_desired(&mut self, v: VersionId) -> Result<(), Rejection> { - let violations = self.validate(v).map_err(|_| Rejection { - version: v, - violations: Vec::new(), - })?; - if !violations.is_empty() { - return Err(Rejection { - version: v, - violations, - }); - } - self.tags.insert(TAG_DESIRED.into(), v); - Ok(()) - } - - /// Version a tag points to. - pub fn tag(&self, name: &str) -> Option { - self.tags.get(name).copied() - } - - /// Semantic difference `a → b`. - pub fn diff(&self, a: VersionId, b: VersionId) -> Result, SimError> { - Ok(self.state(a)?.diff(&self.state(b)?)) - } - - /// Why does `v` contain the membership `(user, group)`? Returns the - /// lineage from the observed root to the version whose delta introduced - /// it, or `None` if `v` does not contain it. A chain of length 1 means - /// it was observed, not simulated. - pub fn explain_membership( - &self, - v: VersionId, - user: Guid128, - group: Guid128, - ) -> Option> { - if !self.state(v).ok()?.is_member(user, group) { - return None; - } - let path = self.lineage(v).ok()?; - let add = Change::AddMembership { user, group }; - // The LAST delta on the path that added it (a later re-add after a - // removal is the one that explains the current state). - let at = path - .iter() - .rposition(|id| self.versions[id.0 as usize].delta.contains(&add)) - .unwrap_or(0); - Some( - path[..=at] - .iter() - .map(|id| &self.versions[id.0 as usize]) - .collect(), - ) - } -} diff --git a/crates/ogar-dir-sim/src/validate.rs b/crates/ogar-dir-sim/src/validate.rs deleted file mode 100644 index c5cbbbe..0000000 --- a/crates/ogar-dir-sim/src/validate.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Invariants over one version. Each failure is structured evidence (which -//! objects, which value), never a message string. - -use crate::graph::{GraphState, NodeKind, normalize}; -use ogar_dir_core::Guid128; -use std::collections::BTreeMap; - -/// Which end of a membership edge is missing or of the wrong kind. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Endpoint { - /// The member side. - User, - /// The group side. - Group, -} - -/// One invariant violation. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Violation { - /// Two or more active recipients own the same normalized primary SMTP. - DuplicateSmtp { - /// Normalized address. - address: String, - /// Every owner, ordered. - owners: Vec, - }, - /// Two or more active users own the same normalized UPN. - DuplicateUpn { - /// Normalized UPN. - upn: String, - /// Every owner, ordered. - owners: Vec, - }, - /// A membership edge references a missing or wrong-kind endpoint. - DanglingMembership { - /// Member side as recorded. - user: Guid128, - /// Group side as recorded. - group: Guid128, - /// Which side is broken. - missing: Endpoint, - }, -} - -fn duplicates(entries: impl Iterator) -> Vec<(String, Vec)> { - let mut by: BTreeMap> = BTreeMap::new(); - for (k, id) in entries { - by.entry(k).or_default().push(id); - } - by.into_iter().filter(|(_, v)| v.len() > 1).collect() -} - -/// All violations of `g`, deterministic order. Empty = valid. -pub fn validate(g: &GraphState) -> Vec { - let active_users = || { - g.nodes() - .filter(|(_, n)| n.kind == NodeKind::User && n.active) - }; - let mut out = Vec::new(); - for (address, owners) in duplicates( - active_users().filter_map(|(id, n)| Some((normalize(n.primary_smtp.as_ref()?), *id))), - ) { - out.push(Violation::DuplicateSmtp { address, owners }); - } - for (upn, owners) in - duplicates(active_users().filter_map(|(id, n)| Some((normalize(n.upn.as_ref()?), *id)))) - { - out.push(Violation::DuplicateUpn { upn, owners }); - } - for &(user, group) in g.memberships() { - let is = |id: &Guid128, k| g.node(id).is_some_and(|n| n.kind == k); - if !is(&user, NodeKind::User) { - out.push(Violation::DanglingMembership { - user, - group, - missing: Endpoint::User, - }); - } else if !is(&group, NodeKind::Group) { - out.push(Violation::DanglingMembership { - user, - group, - missing: Endpoint::Group, - }); - } - } - out.sort(); - out -} diff --git a/crates/ogar-dir-sim/src/violation.rs b/crates/ogar-dir-sim/src/violation.rs new file mode 100644 index 0000000..3d23245 --- /dev/null +++ b/crates/ogar-dir-sim/src/violation.rs @@ -0,0 +1,42 @@ +//! Invariant violations as structured evidence — identities and the +//! offending normalized value, never a message string. Resolution to names +//! is an output projection done by whoever renders the violation. + +use ogar_dir_core::Guid128; + +/// Which end of a membership edge is missing or of the wrong kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Endpoint { + /// The member side. + User, + /// The group side. + Group, +} + +/// One invariant violation. `Ord` is total; a validator returns a sorted list. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Violation { + /// Two or more active users own the same normalized primary SMTP. + DuplicateSmtp { + /// Normalized address. + address: String, + /// Every owner, sorted. + owners: Vec, + }, + /// Two or more active users own the same normalized UPN. + DuplicateUpn { + /// Normalized UPN. + upn: String, + /// Every owner, sorted. + owners: Vec, + }, + /// A membership edge references a missing or wrong-kind endpoint. + DanglingMembership { + /// Member side as recorded. + user: Guid128, + /// Group side as recorded. + group: Guid128, + /// Which side is broken (the user side is reported first). + missing: Endpoint, + }, +} diff --git a/crates/ogar-dir-sim/tests/main.rs b/crates/ogar-dir-sim/tests/main.rs deleted file mode 100644 index 089a79b..0000000 --- a/crates/ogar-dir-sim/tests/main.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! The simulation slice: observe → simulate → validate → diff → plan. - -use ogar_dir_core::Guid128; -use ogar_dir_sim::rule::{GrantGroup, ImplyGroup, SetPrimarySmtp}; -use ogar_dir_sim::*; - -fn g(n: u8) -> Guid128 { - Guid128([n; 16]) -} -const ALICE: u8 = 0xA1; -const BOB: u8 = 0xB0; -const EMPLOYEES: u8 = 0xE0; -const EXCHANGE: u8 = 0xEC; - -const EXCHANGE_ACCESS: RuleId = RuleId { - name: "ExchangeAccess", - version: 1, -}; -const EMPLOYEES_GET_EXCHANGE: RuleId = RuleId { - name: "EmployeesGetExchange", - version: 1, -}; -const RENAME_MAIL: RuleId = RuleId { - name: "RenameMail", - version: 1, -}; - -/// Observed G0: Alice and Bob in Employees; ExchangeUsers exists, empty. -fn observed() -> GraphState { - let mut s = GraphState::new(); - s.put_node( - g(ALICE), - Node::user("Alice", "alice@example.test", "alice@example.test"), - ); - s.put_node( - g(BOB), - Node::user("Bob", "bob@example.test", "bob@example.test"), - ); - s.put_node(g(EMPLOYEES), Node::group("Employees")); - s.put_node(g(EXCHANGE), Node::group("ExchangeUsers")); - s.put_membership(g(ALICE), g(EMPLOYEES)); - s.put_membership(g(BOB), g(EMPLOYEES)); - s -} - -fn grant_alice() -> GrantGroup { - GrantGroup { - rule: EXCHANGE_ACCESS, - group: g(EXCHANGE), - to: vec![g(ALICE)], - } -} -fn imply() -> ImplyGroup { - ImplyGroup { - rule: EMPLOYEES_GET_EXCHANGE, - source: g(EMPLOYEES), - target: g(EXCHANGE), - } -} -fn ev(s: &str) -> Vec { - vec![EvidenceRef(s.into())] -} - -/// G0 → G1 (ExchangeAccess for Alice) → G2 (all employees get Exchange). -fn chain() -> (VersionStore, VersionId, VersionId, VersionId) { - let mut st = VersionStore::new(); - let g0 = st.observe("lab", 1_000, observed()); - let g1 = st.simulate(g0, &grant_alice(), &ev("REQ-1")).unwrap(); - let g2 = st.simulate(g1, &imply(), &ev("POLICY-7")).unwrap(); - (st, g0, g1, g2) -} - -// 1. G0 remains immutable after simulation. -#[test] -fn t01_observed_version_is_immutable() { - let (st, g0, ..) = chain(); - assert_eq!(st.state(g0).unwrap(), observed()); - assert!(st.version(g0).unwrap().delta.is_empty()); - assert!(!st.state(g0).unwrap().is_member(g(ALICE), g(EXCHANGE))); -} - -// 2. A rule produces G1 with G0 as parent; 10. provenance names the rule. -#[test] -fn t02_t10_rule_produces_child_with_provenance() { - let (st, g0, g1, _) = chain(); - let v1 = st.version(g1).unwrap(); - assert_eq!(v1.parent, Some(g0)); - assert_eq!( - v1.origin, - Origin::Simulated { - rule: EXCHANGE_ACCESS, - evidence: ev("REQ-1") - } - ); - assert_eq!( - v1.delta, - vec![Change::AddMembership { - user: g(ALICE), - group: g(EXCHANGE) - }] - ); - assert!(st.state(g1).unwrap().is_member(g(ALICE), g(EXCHANGE))); -} - -// 3. A second rule produces G2 from G1 — and it is population-shaped: -// it adds only Bob, because Alice already holds the target. -#[test] -fn t03_chained_rule_from_g1() { - let (st, g0, g1, g2) = chain(); - let v2 = st.version(g2).unwrap(); - assert_eq!(v2.parent, Some(g1)); - assert_eq!( - v2.delta, - vec![Change::AddMembership { - user: g(BOB), - group: g(EXCHANGE) - }] - ); - assert_eq!(st.lineage(g2).unwrap(), vec![g0, g1, g2]); - // running the population rule on G2 again has nothing left to do - let mut st = st; - assert_eq!( - st.simulate(g2, &imply(), &[]), - Err(SimError::EmptyProposal(EMPLOYEES_GET_EXCHANGE)) - ); -} - -// 4. diff(G0, G1) is exactly one semantic change. -#[test] -fn t04_diff_is_one_semantic_change() { - let (st, g0, g1, g2) = chain(); - assert_eq!( - st.diff(g0, g1).unwrap(), - vec![Change::AddMembership { - user: g(ALICE), - group: g(EXCHANGE) - }] - ); - assert_eq!(st.diff(g0, g2).unwrap().len(), 2); - assert!(st.diff(g1, g1).unwrap().is_empty()); -} - -// 5. Valid group membership passes invariants. -#[test] -fn t05_valid_version_passes() { - let (mut st, g0, _, g2) = chain(); - assert!(st.validate(g0).unwrap().is_empty()); - assert!(st.validate(g2).unwrap().is_empty()); - st.promote_desired(g2).unwrap(); - assert_eq!(st.tag(TAG_DESIRED), Some(g2)); -} - -// 6. A dangling membership fails, with structured evidence. -#[test] -fn t06_dangling_membership_fails() { - let ghost = g(0x66); - let mut st = VersionStore::new(); - let g0 = st.observe("lab", 0, observed()); - let bad = st - .simulate( - g0, - &GrantGroup { - rule: EXCHANGE_ACCESS, - group: ghost, - to: vec![g(ALICE)], - }, - &[], - ) - .unwrap(); - assert_eq!( - st.validate(bad).unwrap(), - vec![Violation::DanglingMembership { - user: g(ALICE), - group: ghost, - missing: Endpoint::Group - }] - ); - // a user-side dangling edge, and a group used as a member, both fail too - let mut s = observed(); - s.put_membership(ghost, g(EXCHANGE)); - s.put_membership(g(EMPLOYEES), g(EXCHANGE)); - let v = validate(&s); - assert!(v.contains(&Violation::DanglingMembership { - user: ghost, - group: g(EXCHANGE), - missing: Endpoint::User - })); - assert!(v.contains(&Violation::DanglingMembership { - user: g(EMPLOYEES), - group: g(EXCHANGE), - missing: Endpoint::User - })); -} - -// 7. Duplicate UPN fails (normalized), but an inactive owner does not count. -#[test] -fn t07_duplicate_upn_fails() { - let mut s = observed(); - s.put_node( - g(0x77), - Node::user("Imposter", " ALICE@example.test", "other@example.test"), - ); - assert_eq!( - validate(&s), - vec![Violation::DuplicateUpn { - upn: "alice@example.test".into(), - owners: vec![g(0x77), g(ALICE)] - }] - ); - let mut disabled = Node::user("Imposter", "alice@example.test", "other@example.test"); - disabled.active = false; - s.put_node(g(0x77), disabled); - assert!(validate(&s).is_empty()); -} - -// 8 + 9 + rejected future. Bob.primarySMTP = alice@… is constructible, -// detected, refused for promotion, kept as evidence — and nothing else moved. -#[test] -fn t08_t09_smtp_collision_is_rejected_without_touching_ancestors() { - let (mut st, g0, g1, g2) = chain(); - st.promote_desired(g2).unwrap(); - let before: Vec<_> = [g0, g1, g2].iter().map(|v| st.state(*v).unwrap()).collect(); - - let rule = SetPrimarySmtp { - rule: RENAME_MAIL, - user: g(BOB), - to: "Alice@Example.test".into(), - }; - let g3 = st - .simulate(g2, &rule, &ev("REQ-2")) - .expect("the hypothetical future is constructible"); - let rej = st.promote_desired(g3).unwrap_err(); - assert_eq!(rej.version, g3); - assert_eq!( - rej.violations, - vec![Violation::DuplicateSmtp { - address: "alice@example.test".into(), - owners: vec![g(ALICE), g(BOB)] - }] - ); - // 9: desired tag unchanged; every ancestor identical; G3 kept as evidence - assert_eq!(st.tag(TAG_DESIRED), Some(g2)); - let after: Vec<_> = [g0, g1, g2].iter().map(|v| st.state(*v).unwrap()).collect(); - assert_eq!(before, after); - assert_eq!(st.verdict(g3), Some(rej.violations.as_slice())); - assert_eq!( - st.state(g3) - .unwrap() - .node(&g(BOB)) - .unwrap() - .primary_smtp - .as_deref(), - Some("Alice@Example.test") - ); -} - -// A stale compare-and-set is refused and creates no version. -#[test] -fn stale_change_creates_no_version() { - struct Stale; - impl Rule for Stale { - fn id(&self) -> RuleId { - RENAME_MAIL - } - fn propose(&self, _: &GraphState, _: &[EvidenceRef]) -> Vec { - vec![Change::SetAttribute { - node: g(BOB), - attribute: Attribute::PrimarySmtp, - from: Some("not-what-it-is@example.test".into()), - to: Some("x@example.test".into()), - }] - } - } - let mut st = VersionStore::new(); - let g0 = st.observe("lab", 0, observed()); - assert!(matches!( - st.simulate(g0, &Stale, &[]), - Err(SimError::Apply(_)) - )); - assert!(st.version(VersionId(1)).is_none()); -} - -// 11. The semantic diff becomes an ExecutionPlan with no shell information, -// and every op carries the precondition from the observed basis. -#[test] -fn t11_plan_is_semantic_with_preconditions() { - let (mut st, g0, g1, g2) = chain(); - assert_eq!( - ExecutionPlan::derive(&st, g2), - Err(PlanError::NotDesired(g2)), - "only desired versions plan" - ); - st.promote_desired(g2).unwrap(); - let plan = ExecutionPlan::derive(&st, g2).unwrap(); - assert_eq!((plan.basis, plan.target), (g0, g2)); - assert_eq!( - plan.ops, - vec![ - PlannedOp { - op: Operation::AddGroupMember { - group: g(EXCHANGE), - member: g(ALICE) - }, - precondition: Precondition::NotMember - }, - PlannedOp { - op: Operation::AddGroupMember { - group: g(EXCHANGE), - member: g(BOB) - }, - precondition: Precondition::NotMember - }, - ] - ); - let _ = g1; - // An attribute op carries the value it expects reality to still hold. - let mut st2 = VersionStore::new(); - let b0 = st2.observe("lab", 0, observed()); - let rule = SetPrimarySmtp { - rule: RENAME_MAIL, - user: g(BOB), - to: "robert@example.test".into(), - }; - let b1 = st2.simulate(b0, &rule, &[]).unwrap(); - st2.promote_desired(b1).unwrap(); - assert_eq!( - ExecutionPlan::derive(&st2, b1).unwrap().ops, - vec![PlannedOp { - op: Operation::SetAttribute { - object: g(BOB), - attribute: Attribute::PrimarySmtp, - value: Some("robert@example.test".into()) - }, - precondition: Precondition::AttributeEquals(Some("bob@example.test".into())), - }] - ); -} - -// 12. Same observed input + same rules ⇒ same desired state and same plan. -#[test] -fn t12_deterministic() { - let run = || { - let (mut st, _, _, g2) = chain(); - st.promote_desired(g2).unwrap(); - ( - st.state(g2).unwrap(), - ExecutionPlan::derive(&st, g2).unwrap(), - ) - }; - assert_eq!(run(), run()); -} - -// Audit: why is Alice in ExchangeUsers? observed G0 → ExchangeAccess/v1 → G1. -#[test] -fn audit_chain_reconstructs_the_cause() { - let (st, g0, g1, g2) = chain(); - let why = st.explain_membership(g2, g(ALICE), g(EXCHANGE)).unwrap(); - assert_eq!(why.iter().map(|v| v.id).collect::>(), vec![g0, g1]); - assert!(matches!(why[0].origin, Origin::Observed { .. })); - match &why[1].origin { - Origin::Simulated { rule, evidence } => { - assert_eq!(rule.to_string(), "ExchangeAccess/v1"); - assert_eq!(evidence, &ev("REQ-1")); - } - o => panic!("unexpected origin {o:?}"), - } - // Bob's membership has a different cause; Employees was observed. - let bob = st.explain_membership(g2, g(BOB), g(EXCHANGE)).unwrap(); - assert_eq!(bob.last().unwrap().id, g2); - assert_eq!( - st.explain_membership(g2, g(ALICE), g(EMPLOYEES)) - .unwrap() - .len(), - 1 - ); - assert!(st.explain_membership(g0, g(ALICE), g(EXCHANGE)).is_none()); -} - -// Convergence check shape: a later observation equal to desired has an empty diff. -#[test] -fn observing_the_desired_state_converges() { - let (mut st, _, _, g2) = chain(); - st.promote_desired(g2).unwrap(); - let after = st.state(g2).unwrap(); - let o = st.observe("lab", 2_000, after); - assert!(st.diff(o, st.tag(TAG_DESIRED).unwrap()).unwrap().is_empty()); - assert_eq!(st.tag(TAG_OBSERVED), Some(o)); -} - -// Observed state from PR #313 records. -#[test] -fn observe_from_ogar_ad_records() { - use ogar_dir_core::{OuDictionary, ValuePool}; - let ldif = "dn: CN=Alice,OU=Staff,DC=example,DC=test\nobjectGUID:: 4AQlP4lP0xGaDAMF6CwzAQ==\nobjectClass: user\nuserPrincipalName: alice@example.test\nproxyAddresses: smtp:a@legacy.test\nproxyAddresses: SMTP:alice@example.test\nuserAccountControl: 512\n\ndn: CN=Employees,OU=Groups,DC=example,DC=test\nobjectGUID:: 1MOyoQAAAECAAAAAAAC+7w==\nobjectClass: group\n"; - let (mut d, mut p) = (OuDictionary::new(), ValuePool::new()); - let recs: Vec<_> = ogar_ad::ldif::parse(ldif) - .unwrap() - .iter() - .map(|e| { - ogar_ad::encode(e, Guid128::NIL, &mut d, &mut p, 0) - .unwrap() - .record - }) - .collect(); - let s = observe::from_ad(&recs, &p); - let a = s.node(&recs[0].node_guid()).unwrap(); - assert_eq!((a.kind, a.active), (NodeKind::User, true)); - assert_eq!(a.primary_smtp.as_deref(), Some("alice@example.test")); - assert_eq!(s.node(&recs[1].node_guid()).unwrap().kind, NodeKind::Group); -} diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 3b25057..de1d71b 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -1,139 +1,145 @@ -# Directory simulation PoC — `ogar-dir-sim` +# Directory simulation PoC -Status: **PoC, 2026-10-03.** Builds on PR #313 (`ogar-dir-core` / `ogar-ad` / -`ogar-az`). Pure and in-memory. Nothing here writes to AD, Entra, Exchange, -LDAP or PowerShell. There is no network, process or file I/O in the crate. +Status: **PoC, 2026-10-03.** This builds on PR #313 (`ogar-dir-core`, `ogar-ad` +and `ogar-az`). Nothing here writes to AD, Entra, Exchange, LDAP or PowerShell, +and no network, process or file I/O exists in either crate. ``` -observed G0 ──rule──► G1 ──rule──► G2 ──validate──► "desired" ──diff(G0,G2)──► ExecutionPlan ──X (actuator: later) +observed G0 ──rule──► G1 ──rule──► G2 ──validate──► "desired" ──diff(G0,G2)──► ExecutionPlan ──X ``` -## 1. What already existed, and what was reused +## 1. Boundary (OGAR-AS-IR) -| Existing | Where | Decision | +| crate | repo | holds | |---|---|---| -| `ScenarioBranch` / `ScenarioDiff` / `ScenarioWorld` | lance-graph-contract `scenario.rs` | **Not adopted. Its shape is mirrored.** It is the named write-divergent branch over a `forked_from` Lance version, which is the concept used here. Its fields are NARS/archetype-specific (`inference_mode`, `archetype_prior`, `fork_seed`, interventions as opaque `u64`), and `ScenarioDiff` is a set of counts, not semantics. | -| `VersionedGraph` (`tag_version`, `at_version`, `diff`) | lance-graph `graph/versioned.rs` | **Not adopted (conflict V1).** Node ids are `u32`, which cannot hold a 128-bit directory GUID. History is linear, each write overwrites the whole state, and the diff reports additions only (no removals, no attribute changes). Its *tag* model is mirrored: `"observed"` / `"desired"` are tags, and `VersionId` is designed to map 1:1 onto a Lance version once persisted. | -| `LanceVersion = u64`, `TemporalPov` | contract `temporal_pov.rs` | Same scalar shape as `VersionId`. Not imported, so the crate stays free of the git dependency. | -| `CausalEdge64` | `causal-edge` | Does not fit. It is 8 bytes and cannot carry two 128-bit endpoints. | -| `ActionDef` / `ActionInvocation` | `ogar-vocab` | `ActionInvocation` is the **runtime** record: string ids plus a Pending/Committed/Failed lifecycle. A `PlannedOp` is what an actuator would later lower into one. It is not an invocation itself. | -| `ogar-action-handler` executors | OGAR | This is the side that does I/O (shell, SSH). It is exactly what the plan boundary keeps out. | -| `ogar-loco` / `ogar-r2il` | OGAR | These are the program/call ABI. They are a natural future encoding for compiled rules, but they are not a graph-rule engine. Not used. | -| `FieldMask` / `WideFieldMask` / `standing_mask` | contract | These are **column** masks. `dirty ∩ interest` is the right future shape for "which observation fields would invalidate a plan". They are not used for rows. | -| `RowFocusMask` | contract `attention_facet.rs` | It holds address subtrees, not row bitsets. | -| lance-graph-java `Mask` | lgj | It is the row-bitset algebra (`and` / `minus` over `u64` words). `Population` has the identical shape so it can be swapped in later. | -| `Guid128`, `ogar-ad` records | PR #313 | Reused. Identity is `Guid128`, and `observe::from_ad` reads the `DirRecord` slots. | - -## 2. Boundary - -There is one new crate, `ogar-dir-sim`. It depends on `ogar-dir-core` and -`ogar-ad` (for the observation bridge only). Its modules are `graph`, -`population`, `rule`, `store`, `validate`, `plan` and `observe`. - -## 3. Versions and provenance - -`VersionStore` is append-only. - -- An **observed** version is a root. It holds a full snapshot and its origin is - `Origin::Observed { source, observed_at_ms }`. -- A **simulated** version holds `parent` plus `delta: Vec`. Its origin - is `Origin::Simulated { rule: RuleId, evidence }`. Its state is the parent's - state with the delta applied. This is structural sharing by delta: no full - copy is stored. -- `VersionId` is the logical clock. The `lineage(v)`, `version(v)` and - `explain_membership` functions read provenance directly from the history. - There is no separate log. - -The lifecycle stages are expressed as data, not as a workflow enum: - -- **OBSERVED** is `Origin::Observed`, plus the tag `"observed"`. +| `ogar-dir-sim` | OGAR | Meaning only: `Change`, provenance (`VersionId`, `Origin`, `RuleId`, `EvidenceRef`, `Version`, tags), `Violation`, and `ExecutionPlan` / `Operation` / `Precondition`. | +| `lance-graph-dir-sim` | lance-graph | Execution: the SoA snapshot, versions as snapshot + overlay, rules, invariants, diff, plan and audit, all lowered through Quack onto mask-risc. | + +Execution has to live in lance-graph. Quack and mask-risc path-depend on +`../../../ndarray`, so they cannot be git dependencies of OGAR. All existing +Quack consumers live in lance-graph (`lance-graph-sap`, `lance-graph-report`), +and `lance-graph-report-ogar` is the precedent for an excluded crate that +path-depends on OGAR. The arrow points one way: lance-graph depends on OGAR, +never the reverse. + +## 2. Reconnaissance: operation → existing primitive + +| operation | existing primitive | representation | rows materialised? | allocates | zero-copy? | used | +|---|---|---|---|---|---|---| +| node lookup | sorted `Guid128` id lane | `&[Guid128]`, ordinal = index | no | no | yes (binary search) | yes | +| membership traversal ("members of g") | Quack `GroupReduce Count` keyed on the user FK | sorted `(user, group)` `u32` lanes plus a live plane | no | a K = \|nodes\| sink | lanes borrowed | yes | +| membership add / remove | delta overlay (added rows, removed-row bitmap) | delta-sized `BTreeMap`, plus a bitmap only once something is removed | no | delta-sized | base borrowed | yes | +| UPN uniqueness | Quack `GroupReduce Count` on the normalized-key id (`GROUP BY … HAVING > 1`) | key-id lane plus the active-user plane | owners only, per colliding key | a K = \|keys\| sink | lanes borrowed | yes | +| SMTP uniqueness | same, with the overlay folded in via `Semijoin` against the active plane | same | owners only | same | yes | yes | +| 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 | +| version snapshot read | `Arc` plus the folded lineage overlay | shared base | no | delta-sized | yes | yes | + +**Considered and not used.** + +- **`ScenarioBranch` and lance-graph `VersionedGraph`.** Node ids are `u32`, + history is linear, each version overwrites the whole state, and the diff + reports additions only. Its tag model is kept, and `VersionId` is shaped to + map onto a Lance version. +- **`CausalEdge64`.** It is 8 bytes and cannot hold two 128-bit endpoints. +- **`ogar-loco` / `ogar-r2il`.** They are the program-call ABI, not a rule + engine. +- **`ActionInvocation`.** It is the runtime record an actuator would write, + which comes after a plan. + +## 3. Versions and structural sharing + +An observation becomes an immutable `Snapshot` behind an `Arc`. + +A simulated version stores only `parent`, `origin` (rule and evidence) and +`delta`. A read folds the lineage's deltas into an `Overlay`: + +- added membership rows, +- a removed-rows bitmap, allocated only after the first removal, +- per-attribute override maps. + +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. + +The lifecycle stages are not a workflow enum: + +- **OBSERVED** is `Origin::Observed` plus the tag `"observed"`. - **SIMULATED** is `Origin::Simulated`. -- **DESIRED** is the tag `"desired"`. Only `promote_desired` sets it, and only +- **DESIRED** is the tag `"desired"`, which only `promote_desired` sets and only after validation passes. - **OBSERVED AFTER EXECUTION** is a newer observation. The system has converged - when `diff(observed, desired)` is empty. + when its diff to `"desired"` (the reconcile path) is empty. ## 4. Rule ```rust -trait Rule { - fn id(&self) -> RuleId; // e.g. ExchangeAccess/v1 - fn propose(&self, g: &GraphState, evidence: &[EvidenceRef]) -> Vec; -} +trait Rule { fn id(&self) -> RuleId; fn propose(&self, v: &View<'_>, evidence: &[EvidenceRef]) -> Vec; } ``` -A rule has no I/O handle and cannot mutate anything. The slice ships three +A rule receives a borrowed `View` and has no I/O handle. There are three example rules: -- `GrantGroup`: an explicit population is granted a group. -- `ImplyGroup`: computes `active ∩ members(source) − members(target)`. -- `SetPrimarySmtp`: a compare-and-set of one attribute. - -## 5. Invariants - -`validate(&GraphState) -> Vec`, which is structured and sorted. It -checks three things: - -- `DuplicateSmtp { address, owners }`: no two active users may share the - normalized primary SMTP. -- `DuplicateUpn { upn, owners }`: no two active users may share the - normalized UPN. -- `DanglingMembership { user, group, missing }`: a membership must point to an - existing user and an existing group, each of the right kind. - -`promote_desired` validates first. If validation fails, the `"desired"` tag is -left untouched and the version stays in the store with its recorded verdict, so -a rejected future remains available as evidence. - -## 6. Semantic diff and execution plan - -`Change` is both what a rule proposes and what a diff reports: - -- `AddMembership` -- `RemoveMembership` -- `SetAttribute { from, to }` - -A change carries the value it expects to replace, so applying a stale change is -refused (compare-and-set). - -`ExecutionPlan { basis, target, ops: Vec }`: - -- `basis` is the observed root. -- `target` must currently carry the `"desired"` tag. -- `op` is one of `AddGroupMember`, `RemoveGroupMember`, `SetAttribute`. -- `precondition` is `NotMember`, `IsMember` or `AttributeEquals(value)`, taken - from the basis. - -This is the optimistic reality check a future actuator runs before each -operation: if the precondition still holds it executes; otherwise it -re-observes and re-plans. The plan contains no shell text, endpoints or -credentials. - -## 7. Population compatibility - -Rules receive the whole version and select **populations**: a `Population` is a -bitset over the version's sorted node order, with `and`, `minus` and `count`. -For example, `ImplyGroup` is one expression over sets, not a loop of per-user -workflows. The bit index is an execution ordinal valid only within one version. -Identity stays `Guid128`. - -The suitable lance-graph primitives for scaling this up are: - -- the lgj row `Mask` algebra, for the population expressions; -- `WideFieldMask` with `standing_mask::fires`, for "which observed fields - invalidate this plan"; -- Lance versions, as `VersionId`. - -## 8. Conflicts and open points - -- **V1 — VersionedGraph.** lance-graph `VersionedGraph` uses `u32` node ids and - has an additions-only diff. Persisting this store on Lance needs 128-bit node - keys and a removal- and attribute-aware diff upstream, or a separate - directory dataset that mirrors this store's semantics. -- **V2 — no row-population mask in the contract.** `Population` is a local copy - of the lgj `Mask` shape and should be retired when a shared row mask exists. -- **V3 — node creation and deletion.** These are not part of the `Change` - algebra in this slice. A plan whose target changes the set of nodes is - refused with `PlanError::NodeSetChanged`. -- **V4 — the "active" signal.** It comes only from AD's `userAccountControl`. - Entra `accountEnabled` is not yet mapped by `observe`. +- **`GrantGroup`** handles a request-sized list of users. Its cost is + proportional to the request. +- **`ImplyGroup`** is a population rule: active ∧ count(source) > 0 ∧ + count(target) = 0. It reads two folded `GROUP BY` sinks and a resident plane. +- **`SetPrimarySmtp`** is a compare-and-set on one attribute. + +The two sinks are combined at the consumer, never by feeding one program's +mask into another program's `Semijoin`. That respects Quack's +no-population-intermediate rule. + +## 5. Invariants, diff, plan, rejection, audit + +- **Invariants.** `Violation` is structured: identities plus the normalized + value, sorted. Strings are resolved only for keys that actually collide. +- **Diff.** `Change` is shared by rule proposals and diffs. Attribute changes + carry `from`, and a stale `from` is refused. +- **Plan.** `ExecutionPlan` holds `AddGroupMember`, `RemoveGroupMember` and + `SetAttribute`. Each operation carries a precondition read from the observed + basis, which is the hook for an optimistic reality check before execution. + The plan has no shell text, endpoints or credentials. +- **Rejected futures.** A rejected version is an ordinary version that keeps + its recorded verdict. The `"desired"` tag does not move. +- **Audit.** `explain_membership` reads the lineage directly. For example: + observed G0 → `ExchangeAccess/v1` (evidence `REQ-1`) → G1. + +## 6. Identity, ordinals, zero-copy, determinism + +- **Identity versus ordinals.** `Guid128` is the only identity in provenance, + diffs, violations and plans. An ordinal is an index into one snapshot's + `Guid128`-sorted id lane, valid only in that snapshot. +- **Determinism.** Snapshot ordinals and dictionary ids are assigned in `Guid128` + 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. +- **Remaining materialisations, all at the evidence or reconcile boundary:** + - offending membership rows and duplicate owners, bounded by the number of + violations; + - `K`-slot `GroupReduce` sinks, bounded by the population or dictionary size; + - the reconcile diff across snapshots, O(n + m). + +## 7. Conflicts and open points + +- **V1 — Lance persistence.** `VersionedGraph` uses `u32` ids and an + 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.** These are not in the `Change` algebra + yet. A diff across different node sets returns `NodeSetChanged`. +- **V3 — OU-HHTL lane width.** The packed lane addresses prefixes up to + depth 4, which is exact. Deeper prefixes are refused (`SubtreeTooDeep`). The + candidate HHTL64 (8 × u8) would make all 8 levels one `MatchU64`. +- **V4 — "active" in Entra.** Active is derived only from AD + `userAccountControl`. Entra `accountEnabled` is not mapped yet. +- **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so + it needs this OGAR PR merged first. From 07944191e716aad116db3fff2e2b856b5a6ce741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:33:30 +0000 Subject: [PATCH 3/5] ogar-dir-sim: normalize UPN/SMTP with Unicode case mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to_ascii_lowercase kept non-ASCII case variants (Ä vs ä) distinct, so addresses a case-insensitive directory treats as equal could both pass the uniqueness invariant. normalize now uses to_lowercase; documented as lowercase mapping, not full case folding. Test: normalize_folds_non_ascii_case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/change.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index 19c0436..6eaabe3 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -45,8 +45,30 @@ pub enum Change { }, } -/// Comparison form of a UPN / SMTP address: trimmed and ASCII-lowercased -/// (Exchange and Entra compare these case-insensitively). +/// Comparison form of a UPN / SMTP address: trimmed and lowercased with +/// Unicode case mapping (Exchange and Entra compare these +/// case-insensitively, and neither restricts them to ASCII). +/// +/// ASCII-only lowercasing would keep `Ä` and `ä` apart, so two addresses the +/// directory treats as equal could both pass the uniqueness invariant. +/// This is lowercase mapping, not full case folding: `ß` and `ss` stay +/// distinct. pub fn normalize(s: &str) -> String { - s.trim().to_ascii_lowercase() + s.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::normalize; + + #[test] + fn normalize_folds_non_ascii_case() { + assert_eq!( + normalize(" Änne@Example.Test "), + normalize("änne@example.test") + ); + assert_eq!(normalize("ÉLODIE@x.test"), "élodie@x.test"); + // Still distinguishes genuinely different addresses. + assert_ne!(normalize("anne@x.test"), normalize("änne@x.test")); + } } From fca06f9010c4f547335d4a7b47c01e8412091a62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:40:14 +0000 Subject: [PATCH 4/5] ogar-dir-sim: PlanError::NodeSetChanged for a re-observation with a new node set 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 e44cd88..36ecc41 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -119,6 +119,15 @@ pub enum PlanError { NotDesired(VersionId), /// The target is unknown. UnknownVersion(VersionId), + /// The latest observation (`basis`) no longer has the node set the + /// desired version (`target`) was built on: users or groups were + /// created or deleted since. Simulate again from the new observation. + NodeSetChanged { + /// The latest observation. + basis: VersionId, + /// The desired version. + target: VersionId, + }, } #[cfg(test)] From c5683c4e7ef4a28f46ba4df6f6f6250cec30db1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:41:15 +0000 Subject: [PATCH 5/5] ogar-dir-sim: remove orphaned rule.rs; dedup planned ops; doc limits rule.rs was left over from the first version: undeclared in lib.rs and importing modules that no longer exist. Rules live in lance-graph; the doc now says so. from_diff drops repeated ops (test). normalize's doc states that no Unicode normalization is applied. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/change.rs | 3 +- crates/ogar-dir-sim/src/plan.rs | 11 +++ crates/ogar-dir-sim/src/rule.rs | 125 ------------------------------ docs/DIRECTORY-SIMULATION-POC.md | 6 +- 4 files changed, 17 insertions(+), 128 deletions(-) delete mode 100644 crates/ogar-dir-sim/src/rule.rs diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index 6eaabe3..5cde5e8 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -52,7 +52,8 @@ pub enum Change { /// ASCII-only lowercasing would keep `Ä` and `ä` apart, so two addresses the /// directory treats as equal could both pass the uniqueness invariant. /// This is lowercase mapping, not full case folding: `ß` and `ss` stay -/// distinct. +/// distinct. No Unicode normalization is applied either: a precomposed `ä` +/// and `a` + U+0308 stay distinct. pub fn normalize(s: &str) -> String { s.trim().to_lowercase() } diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs index 36ecc41..4f89bc3 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -108,6 +108,7 @@ impl ExecutionPlan { pub fn from_diff(basis: VersionId, target: VersionId, diff: Vec) -> Self { let mut ops: Vec = diff.into_iter().map(PlannedOp::from).collect(); ops.sort(); + ops.dedup(); Self { basis, target, ops } } } @@ -134,6 +135,16 @@ pub enum PlanError { mod tests { use super::*; + #[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]); + assert_eq!(p.ops.len(), 1); + } + #[test] fn lowering_carries_the_basis_precondition_and_no_transport() { let g = |n| Guid128([n; 16]); diff --git a/crates/ogar-dir-sim/src/rule.rs b/crates/ogar-dir-sim/src/rule.rs deleted file mode 100644 index 4c18b8a..0000000 --- a/crates/ogar-dir-sim/src/rule.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Pure rules: `G(n+1) = R(G(n), evidence)`. -//! -//! A rule reads one version and returns the changes it proposes. It cannot -//! write: it receives `&GraphState`, returns `Vec`, and has no I/O -//! handle. The store turns the proposal into a new version. - -use crate::graph::{Attribute, Change, GraphState}; -use crate::population::Population; -use ogar_dir_core::Guid128; - -/// Rule identity recorded in every version a rule produces. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RuleId { - /// Stable name, e.g. `"ExchangeAccess"`. - pub name: &'static str, - /// Rule version; a behaviour change is a new version, never an edit. - pub version: u16, -} - -impl std::fmt::Display for RuleId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}/v{}", self.name, self.version) - } -} - -/// Opaque reference to the input that justified running a rule (a request -/// id, ticket, HR record…). Stored in the version's provenance. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct EvidenceRef(pub String); - -/// A pure graph transformation. -pub trait Rule { - /// Identity recorded in provenance. - fn id(&self) -> RuleId; - /// Proposed changes against `g`. Must be deterministic in `(g, evidence)`. - fn propose(&self, g: &GraphState, evidence: &[EvidenceRef]) -> Vec; -} - -/// Grant `group` to an explicit population (e.g. the users named in a -/// request). Members already in the group are skipped, so the proposal is -/// exactly the net change. -pub struct GrantGroup { - /// Rule identity (lets one implementation serve several named rules). - pub rule: RuleId, - /// The group to grant. - pub group: Guid128, - /// Who should receive it. - pub to: Vec, -} - -impl Rule for GrantGroup { - fn id(&self) -> RuleId { - self.rule - } - fn propose(&self, g: &GraphState, _: &[EvidenceRef]) -> Vec { - let wanted = Population::of(g, &self.to); - let missing = wanted.minus(&Population::members_of(g, self.group)); - missing - .guids() - .into_iter() - .map(|user| Change::AddMembership { - user, - group: self.group, - }) - .collect() - } -} - -/// Population rule: every active member of `source` is also a member of -/// `target`. Evaluated as `active ∩ members(source) − members(target)`. -pub struct ImplyGroup { - /// Rule identity. - pub rule: RuleId, - /// Membership that implies… - pub source: Guid128, - /// …membership here. - pub target: Guid128, -} - -impl Rule for ImplyGroup { - fn id(&self) -> RuleId { - self.rule - } - fn propose(&self, g: &GraphState, _: &[EvidenceRef]) -> Vec { - Population::active_users(g) - .and(&Population::members_of(g, self.source)) - .minus(&Population::members_of(g, self.target)) - .guids() - .into_iter() - .map(|user| Change::AddMembership { - user, - group: self.target, - }) - .collect() - } -} - -/// Set one user's primary SMTP address (compare-and-set against the value -/// in the version the rule reads). -pub struct SetPrimarySmtp { - /// Rule identity. - pub rule: RuleId, - /// User. - pub user: Guid128, - /// New address. - pub to: String, -} - -impl Rule for SetPrimarySmtp { - fn id(&self) -> RuleId { - self.rule - } - fn propose(&self, g: &GraphState, _: &[EvidenceRef]) -> Vec { - let from = g.node(&self.user).and_then(|n| n.primary_smtp.clone()); - if from.as_deref() == Some(self.to.as_str()) { - return Vec::new(); - } - vec![Change::SetAttribute { - node: self.user, - attribute: Attribute::PrimarySmtp, - from, - to: Some(self.to.clone()), - }] - } -} diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index de1d71b..b1d3661 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -83,8 +83,10 @@ The lifecycle stages are not a workflow enum: trait Rule { fn id(&self) -> RuleId; fn propose(&self, v: &View<'_>, evidence: &[EvidenceRef]) -> Vec; } ``` -A rule receives a borrowed `View` and has no I/O handle. There are three -example rules: +A rule receives a borrowed `View` and has no I/O handle. The trait and the +rules live in lance-graph (`crates/lance-graph-dir-sim/src/rule.rs`), because a +`View` is execution state; OGAR holds only the `RuleId` and `Change` they +speak. There are three example rules: - **`GrantGroup`** handles a request-sized list of users. Its cost is proportional to the request.