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..667a765 --- /dev/null +++ b/crates/ogar-dir-sim/Cargo.toml @@ -0,0 +1,12 @@ +[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 = "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" } diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs new file mode 100644 index 0000000..5cde5e8 --- /dev/null +++ b/crates/ogar-dir-sim/src/change.rs @@ -0,0 +1,75 @@ +//! 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 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. No Unicode normalization is applied either: a precomposed `ä` +/// and `a` + U+0308 stay distinct. +pub fn normalize(s: &str) -> String { + 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")); + } +} diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs new file mode 100644 index 0000000..1fbc8af --- /dev/null +++ b/crates/ogar-dir-sim/src/lib.rs @@ -0,0 +1,26 @@ +//! # ogar-dir-sim — the vocabulary of a simulated directory future +//! +//! ```text +//! observed G0 ──rule──► G1 ──rule──► G2 ──validate──► "desired" ──diff──► ExecutionPlan ──X +//! ``` +//! +//! 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). +//! +//! 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 change; +pub mod plan; +pub mod provenance; +pub mod violation; + +pub use change::{Attribute, Change, normalize}; +pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; +pub use provenance::{EvidenceRef, Origin, RuleId, TAG_DESIRED, TAG_OBSERVED, Version, VersionId}; +pub use violation::{Endpoint, Violation}; diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs new file mode 100644 index 0000000..4f89bc3 --- /dev/null +++ b/crates/ogar-dir-sim/src/plan.rs @@ -0,0 +1,180 @@ +//! The execution boundary — described, never executed. +//! +//! 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::change::{Attribute, Change}; +use crate::provenance::VersionId; +use ogar_dir_core::Guid128; + +/// A technology-neutral directory operation. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +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, Hash)] +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, Hash)] +pub struct PlannedOp { + /// What to do. + pub op: Operation, + /// What must still hold first. + 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 { + /// The observation the preconditions were read from. + pub basis: VersionId, + /// The desired version the plan reaches. + pub target: VersionId, + /// 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(); + ops.dedup(); + 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), + /// 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)] +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]); + 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())) + ); + } +} 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/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/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md new file mode 100644 index 0000000..b1d3661 --- /dev/null +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -0,0 +1,147 @@ +# Directory simulation PoC + +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 +``` + +## 1. Boundary (OGAR-AS-IR) + +| crate | repo | holds | +|---|---|---| +| `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"`, which only `promote_desired` sets and only + after validation passes. +- **OBSERVED AFTER EXECUTION** is a newer observation. The system has converged + when its diff to `"desired"` (the reconcile path) is empty. + +## 4. Rule + +```rust +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. 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. +- **`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.