Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ members = [
"crates/ogar-dir-core",
"crates/ogar-ad",
"crates/ogar-az",
"crates/ogar-dir-sim",
]

[workspace.package]
Expand Down
12 changes: 12 additions & 0 deletions crates/ogar-dir-sim/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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" }
75 changes: 75 additions & 0 deletions crates/ogar-dir-sim/src/change.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// New value (raw).
to: Option<String>,
},
}

/// 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"));
}
}
26 changes: 26 additions & 0 deletions crates/ogar-dir-sim/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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};
180 changes: 180 additions & 0 deletions crates/ogar-dir-sim/src/plan.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
},
}

/// 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<String>),
}

/// 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<Change> 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<PlannedOp>,
}

impl ExecutionPlan {
/// Lower a semantic diff `basis → target` into a plan.
pub fn from_diff(basis: VersionId, target: VersionId, diff: Vec<Change>) -> Self {
let mut ops: Vec<PlannedOp> = 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()))
);
}
}
75 changes: 75 additions & 0 deletions crates/ogar-dir-sim/src/provenance.rs
Original file line number Diff line number Diff line change
@@ -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<EvidenceRef>,
},
}

/// 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<VersionId>,
/// What produced it.
pub origin: Origin,
/// The changes it introduced relative to `parent` (empty for an observation).
pub delta: Vec<Change>,
}
Loading
Loading