-
Notifications
You must be signed in to change notification settings - Fork 0
ogar-dir-sim: semantic vocabulary for directory desired-state simulation (execution in lance-graph) #314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
ogar-dir-sim: semantic vocabulary for directory desired-state simulation (execution in lance-graph) #314
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
40d1f28
ogar-dir-sim: versioned directory graph simulator (observe -> simulat…
claude 805433d
ogar-dir-sim: reduce to the semantic vocabulary; execution moves to l…
claude 0794419
ogar-dir-sim: normalize UPN/SMTP with Unicode case mapping
claude fca06f9
ogar-dir-sim: PlanError::NodeSetChanged for a re-observation with a n…
claude c5683c4
ogar-dir-sim: remove orphaned rule.rs; dedup planned ops; doc limits
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| 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}; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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())) | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>, | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.