diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index e4b7b803..80cab398 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -97,7 +97,10 @@ pub const fn effective_active(ad: Option, entra: Option) -> Option, }, + /// Compare-and-set of the enabled flag, all three values: `Some(true)` + /// enabled, `Some(false)` disabled, `None` unknown. Users only (a group + /// has no flag). A diff reports `→ None` when a later observation stops + /// reporting the flag; no plan can actuate it (an actuator cannot write + /// "unknown"). + SetActive { + /// Object. + node: Guid128, + /// Flag the change expects to replace (as observed). + from: Option, + /// New flag. + to: Option, + }, + /// Compare-and-set of the hierarchy location (numeric; never a DN). + /// `None` = unknown location. A move to `None` is reportable by a diff + /// but not actuatable. + SetLocation { + /// Object. + node: Guid128, + /// Location the change expects to replace (as observed). + from: Option, + /// New location. + to: Option, + }, /// A node that did not exist comes into existence. CreateNode { /// Identity. @@ -167,6 +212,73 @@ pub enum Change { }, } +/// Why [`NodeState::apply`] refused a change. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Refusal { + /// The change is not a property change of one existing node + /// (memberships, creates and deletes act on the directory, not on a + /// node's state). + NotAPropertyChange, + /// The node no longer holds the value the change expects to replace. + Stale, + /// [`Change::SetActive`] on a group: groups carry no enabled flag. + NoEnabledFlag, +} + +impl NodeState { + /// The reference semantics of a compare-and-set property change on one + /// existing node: the state after it, or why it is refused. Every + /// executor must agree with it. + /// + /// The change applies only if the node still holds its `from`; + /// otherwise it is [`Refusal::Stale`], whatever `to` is. `kind` is never + /// changed (no change can express it). + /// + /// # Errors + /// + /// [`Refusal`]. + pub fn apply(&self, c: &Change) -> Result { + let mut next = self.clone(); + match c { + Change::SetAttribute { + attribute, + from, + to, + .. + } => { + let slot = match attribute { + Attribute::Upn => &mut next.upn, + Attribute::PrimarySmtp => &mut next.primary_smtp, + }; + if slot != from { + return Err(Refusal::Stale); + } + *slot = *to; + } + Change::SetActive { from, to, .. } => { + if self.kind == NodeKind::Group { + return Err(Refusal::NoEnabledFlag); + } + if self.active != *from { + return Err(Refusal::Stale); + } + next.active = *to; + } + Change::SetLocation { from, to, .. } => { + if self.dn != *from { + return Err(Refusal::Stale); + } + next.dn = *to; + } + Change::RemoveMembership { .. } + | Change::DeleteNode { .. } + | Change::CreateNode { .. } + | Change::AddMembership { .. } => return Err(Refusal::NotAPropertyChange), + } + Ok(next) + } +} + /// Comparison form of a UPN / SMTP address, computed once at ingress (the /// store maps each [`ValueId`] to its [`KeyId`]); never during execution: trimmed and lowercased with /// Unicode case mapping (Exchange and Entra compare these @@ -214,6 +326,179 @@ mod tests { } } + use super::{Attribute, Change, NodeKind, NodeState, Refusal, ValueId}; + use ogar_dir_core::{Dn128, Guid128}; + + const N: Guid128 = Guid128([7; 16]); + const STATES: [Option; 3] = [Some(true), Some(false), None]; + + fn user(active: Option, dn: Option) -> NodeState { + NodeState { + kind: NodeKind::User, + active, + upn: Some(ValueId(1)), + primary_smtp: Some(ValueId(2)), + dn, + } + } + fn dn(l: &[u8]) -> Option { + Some(Dn128::new(l).unwrap()) + } + fn set_active(from: Option, to: Option) -> Change { + Change::SetActive { node: N, from, to } + } + fn set_location(from: Option, to: Option) -> Change { + Change::SetLocation { node: N, from, to } + } + + // Every one of the nine (from, to) pairs over the three flag values: + // applies when `from` is current, and lands exactly on `to` — `None` + // stays `None`, never collapsing to either known value. A change whose + // `from` is any OTHER value is stale, whatever its `to`. + #[test] + fn set_active_is_compare_and_set_over_all_three_values() { + for current in STATES { + for from in STATES { + for to in STATES { + let got = user(current, None).apply(&set_active(from, to)); + if from == current { + assert_eq!(got, Ok(user(to, None)), "{current:?}: {from:?}->{to:?}"); + } else { + assert_eq!(got, Err(Refusal::Stale), "{current:?}: {from:?}->{to:?}"); + } + } + } + } + } + + // A group has no flag: even a "no-op" `Some(true) -> Some(true)` is + // refused, so no executor can grow a group flag lane. + #[test] + fn a_group_has_no_enabled_flag_to_set() { + let group = NodeState { + kind: NodeKind::Group, + active: Some(true), + upn: None, + primary_smtp: None, + dn: None, + }; + for (from, to) in [(Some(true), Some(false)), (Some(true), Some(true))] { + assert_eq!( + group.apply(&set_active(from, to)), + Err(Refusal::NoEnabledFlag) + ); + } + // Its location is a property like any other. + assert_eq!( + group.apply(&set_location(None, dn(&[3]))).map(|s| s.dn), + Ok(dn(&[3])) + ); + } + + #[test] + fn set_location_moves_between_known_and_unknown() { + let (a, b) = (dn(&[0, 1]), dn(&[2])); + for (from, to) in [(None, a), (a, None), (a, b)] { + assert_eq!( + user(None, from).apply(&set_location(from, to)), + Ok(user(None, to)) + ); + } + // Stale: expected A while the node is at B, or expected a known + // location while it is unknown (and the other way round). + for (current, from) in [(b, a), (None, a), (a, None)] { + assert_eq!( + user(None, current).apply(&set_location(from, b)), + Err(Refusal::Stale) + ); + } + } + + #[test] + fn set_attribute_is_compare_and_set() { + let ok = Change::SetAttribute { + node: N, + attribute: Attribute::Upn, + from: Some(ValueId(1)), + to: Some(ValueId(9)), + }; + let mut want = user(None, None); + want.upn = Some(ValueId(9)); + assert_eq!(user(None, None).apply(&ok), Ok(want)); + let stale = Change::SetAttribute { + node: N, + attribute: Attribute::PrimarySmtp, + from: Some(ValueId(1)), + to: None, + }; + assert_eq!(user(None, None).apply(&stale), Err(Refusal::Stale)); + } + + // `kind` is identity: no property change alters it, from either kind, + // and the changes that are not property changes are refused here. + #[test] + fn no_change_alters_kind() { + for kind in [NodeKind::User, NodeKind::Group] { + let s = NodeState { + kind, + ..user(Some(true), dn(&[1])) + }; + for c in [ + set_active(Some(true), Some(false)), + set_location(dn(&[1]), dn(&[2])), + Change::SetAttribute { + node: N, + attribute: Attribute::Upn, + from: Some(ValueId(1)), + to: None, + }, + ] { + if let Ok(next) = s.apply(&c) { + assert_eq!(next.kind, kind); + } + } + for c in [ + Change::CreateNode { + node: N, + state: s.clone(), + }, + Change::DeleteNode { + node: N, + state: s.clone(), + }, + Change::AddMembership { user: N, group: N }, + Change::RemoveMembership { user: N, group: N }, + ] { + assert_eq!(s.apply(&c), Err(Refusal::NotAPropertyChange)); + } + } + } + + // The sorted order places the two new property changes after renames + // and before creates. + #[test] + fn property_changes_sort_between_renames_and_creates() { + let mut cs = [ + Change::CreateNode { + node: N, + state: user(None, None), + }, + set_location(None, dn(&[1])), + set_active(None, Some(true)), + Change::SetAttribute { + node: N, + attribute: Attribute::Upn, + from: None, + to: None, + }, + ]; + cs.sort(); + assert!(matches!(cs[0], Change::SetAttribute { .. })); + assert!(matches!(cs[1], Change::SetActive { .. })); + assert!(matches!(cs[2], Change::SetLocation { .. })); + assert!(matches!(cs[3], Change::CreateNode { .. })); + } + use super::normalize; #[test] diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index af65aaff..68049a51 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -21,7 +21,7 @@ pub mod provenance; pub mod violation; pub use change::{ - Attribute, Change, KeyId, NodeKind, NodeState, ValueId, effective_active, normalize, + Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize, }; pub use plan::{ExecutionPlan, Operation, PlanError, PlannedOp, Precondition}; pub use provenance::{EvidenceRef, Origin, RuleId, TAG_DESIRED, TAG_OBSERVED, Version, VersionId}; diff --git a/crates/ogar-dir-sim/src/plan.rs b/crates/ogar-dir-sim/src/plan.rs index 64b94d2d..cc83d09d 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -8,13 +8,13 @@ use crate::change::{Attribute, Change, KeyId, NodeState, ValueId}; use crate::provenance::VersionId; -use ogar_dir_core::Guid128; +use ogar_dir_core::{Dn128, Guid128}; use std::collections::{BTreeMap, BTreeSet}; /// A technology-neutral directory operation. /// /// Variant order is load-bearing and mirrors [`Change`]: removals, deletes, -/// attribute sets, creates, adds. A sorted plan is therefore a safe +/// attribute sets, enable/disable, moves, creates, adds. A sorted plan is therefore a safe /// execution order (an address is freed before it is claimed; a node's /// edges are gone before it is, and it exists before it gains one). #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -40,6 +40,23 @@ pub enum Operation { /// New value. value: Option, }, + /// Enable or disable `object`. A `bool`, not an `Option`: an actuator + /// cannot write "unknown", so a change towards an unknown flag has no + /// operation. + SetEnabled { + /// Object. + object: Guid128, + /// Enabled. + enabled: bool, + }, + /// Move `object` to a known location (numeric; the actuator resolves + /// it to its own addressing at the boundary). + MoveObject { + /// Object. + object: Guid128, + /// Destination. + to: Dn128, + }, /// Create `object` with `state`. CreateObject { /// Object. @@ -65,6 +82,10 @@ pub enum Precondition { IsMember, /// The attribute must still hold this value. AttributeEquals(Option), + /// The enabled flag must still read this (`None`: still unreported). + EnabledEquals(Option), + /// The object must still be at this location (`None`: still unknown). + LocationEquals(Option), /// No object with this identity may exist yet. ObjectAbsent, /// The object must still exist in exactly this state and have no @@ -82,9 +103,29 @@ pub struct PlannedOp { pub precondition: Precondition, } -impl From for PlannedOp { - fn from(c: Change) -> Self { - match c { +impl PlannedOp { + /// Lower one change. `Err(node)` when the change has no operation: its + /// target is an unknown flag or location, which no actuator can write. + /// + /// # Errors + /// + /// The node of a non-actuatable change. + pub fn lower(c: Change) -> Result { + Ok(match c { + Change::SetActive { node, from, to } => Self { + op: Operation::SetEnabled { + object: node, + enabled: to.ok_or(node)?, + }, + precondition: Precondition::EnabledEquals(from), + }, + Change::SetLocation { node, from, to } => Self { + op: Operation::MoveObject { + object: node, + to: to.ok_or(node)?, + }, + precondition: Precondition::LocationEquals(from), + }, Change::CreateNode { node, state } => Self { op: Operation::CreateObject { object: node, @@ -123,7 +164,7 @@ impl From for PlannedOp { }, precondition: Precondition::AttributeEquals(from), }, - } + }) } } @@ -142,17 +183,30 @@ impl ExecutionPlan { /// Lower a semantic diff `basis → target` into a plan. `key` maps a /// value to its comparison key (the store's ingress mapping), so rename /// dependencies follow the directory's notion of equal addresses. + /// + /// # Errors + /// + /// [`PlanError::NotActuatable`] if a change targets an unknown flag or + /// location; no partial plan is returned. pub fn from_diff( basis: VersionId, target: VersionId, diff: Vec, key: impl Fn(ValueId) -> KeyId, - ) -> Self { - let mut ops: Vec = diff.into_iter().map(PlannedOp::from).collect(); + ) -> Result { + let mut ops = diff + .into_iter() + .map(PlannedOp::lower) + .collect::, _>>() + .map_err(|node| PlanError::NotActuatable { + basis, + target, + node, + })?; ops.sort(); ops.dedup(); order_value_transfers(&mut ops, &key); - Self { basis, target, ops } + Ok(Self { basis, target, ops }) } } @@ -236,10 +290,10 @@ pub enum PlanError { /// The target is unknown. UnknownVersion(VersionId), /// The latest observation (`basis`) already holds `node`, which the - /// desired version (`target`) creates, but with a kind, enabled flag or - /// OU that no change can converge yet (the change algebra sets only UPN - /// and primary SMTP). Rather than claim the create is done, no plan is - /// derived; simulate again from the new observation. + /// desired version (`target`) creates, but with another kind. Kind is + /// identity and no change alters it, so the create is neither done nor + /// doable; simulate again from the new observation. (Every other field + /// of an existing node converges through a compare-and-set change.) Unconvergeable { /// The latest observation. basis: VersionId, @@ -248,6 +302,17 @@ pub enum PlanError { /// The node whose observed state cannot reach the desired one. node: Guid128, }, + /// The desired version changes `node` towards an unknown enabled flag or + /// location. "Unknown" is an observation, not an intention: no actuator + /// can write it, so no plan is derived. + NotActuatable { + /// The observation. + basis: VersionId, + /// The desired version. + target: VersionId, + /// The node. + node: Guid128, + }, /// The latest observation and the desired version describe different /// directories (`DirectoryScope`). Hierarchy codes are only meaningful /// within one scope, so the two are not compared and no plan is derived. @@ -279,7 +344,8 @@ mod tests { user: Guid128([1; 16]), group: Guid128([2; 16]), }; - let p = ExecutionPlan::from_diff(VersionId(0), VersionId(1), vec![c.clone(), c], key); + let p = + ExecutionPlan::from_diff(VersionId(0), VersionId(1), vec![c.clone(), c], key).unwrap(); assert_eq!(p.ops.len(), 1); } @@ -330,7 +396,8 @@ mod tests { }, ], key, - ); + ) + .unwrap(); let ops: Vec<_> = plan.ops.iter().map(|p| &p.op).collect(); assert!(matches!(ops[0], Operation::RemoveGroupMember { member, .. } if *member == g(5))); // The delete frees value 70 before the create claims it. @@ -374,7 +441,8 @@ mod tests { VersionId(1), vec![rename(1, 10, 21), rename(2, 20, 30), rename(3, 31, 40)], key, - ); + ) + .unwrap(); assert_eq!(renamed(&plan), vec![3, 2, 1]); } @@ -385,7 +453,8 @@ mod tests { VersionId(1), vec![rename(2, 50, 60), rename(1, 70, 80)], key, - ); + ) + .unwrap(); assert_eq!(renamed(&plan), vec![1, 2]); // A swap cannot be ordered: both stay, in canonical order. let swap = ExecutionPlan::from_diff( @@ -393,10 +462,107 @@ mod tests { VersionId(1), vec![rename(1, 10, 20), rename(2, 20, 10)], key, - ); + ) + .unwrap(); assert_eq!(renamed(&swap), vec![1, 2]); } + fn dn(l: &[u8]) -> Dn128 { + Dn128::new(l).unwrap() + } + + // SetActive / SetLocation lower to typed operations carrying the + // observed value as their precondition (including an observed + // `None`), and the result is the same whatever the input order. + #[test] + fn property_changes_lower_with_their_observed_precondition() { + let g = |n| Guid128([n; 16]); + let cs = vec![ + Change::SetLocation { + node: g(2), + from: None, + to: Some(dn(&[4, 1])), + }, + Change::SetActive { + node: g(1), + from: Some(true), + to: Some(false), + }, + Change::SetActive { + node: g(3), + from: None, + to: Some(true), + }, + ]; + let plan = ExecutionPlan::from_diff(VersionId(0), VersionId(1), cs.clone(), key).unwrap(); + let mut rev = cs; + rev.reverse(); + assert_eq!( + ExecutionPlan::from_diff(VersionId(0), VersionId(1), rev, key).unwrap(), + plan, + "deterministic" + ); + assert_eq!( + plan.ops, + vec![ + PlannedOp { + op: Operation::SetEnabled { + object: g(1), + enabled: false + }, + precondition: Precondition::EnabledEquals(Some(true)), + }, + PlannedOp { + op: Operation::SetEnabled { + object: g(3), + enabled: true + }, + precondition: Precondition::EnabledEquals(None), + }, + PlannedOp { + op: Operation::MoveObject { + object: g(2), + to: dn(&[4, 1]) + }, + precondition: Precondition::LocationEquals(None), + }, + ] + ); + } + + // "Unknown" is never an operation: a change towards an unknown flag or + // location refuses the whole plan instead of being dropped or guessed. + #[test] + fn a_change_towards_unknown_is_not_actuatable() { + let g = Guid128([5; 16]); + for c in [ + Change::SetActive { + node: g, + from: Some(true), + to: None, + }, + Change::SetLocation { + node: g, + from: Some(dn(&[1])), + to: None, + }, + ] { + assert_eq!( + ExecutionPlan::from_diff( + VersionId(0), + VersionId(1), + vec![rename(1, 10, 20), c], + key + ), + Err(PlanError::NotActuatable { + basis: VersionId(0), + target: VersionId(1), + node: g + }) + ); + } + } + #[test] fn lowering_carries_the_basis_precondition_and_no_transport() { let g = |n| Guid128([n; 16]); @@ -416,7 +582,8 @@ mod tests { }, ], key, - ); + ) + .unwrap(); assert_eq!( plan.ops[1].op, Operation::AddGroupMember { diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index fafb355d..a203def1 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -171,8 +171,23 @@ no-population-intermediate rule. address) is not orderable without a temporary value and stays open. The plan is the desired version's net intent rebased onto the latest observation: work reality already shows is dropped, and drift outside the - intent is neither planned nor reverted. Not representable yet: a change of - `active`, of location or of kind on a node that exists in both versions. + intent is neither planned nor reverted. +- **V2b — properties of an existing node (closed, 2026-10-07).** Every + property of a node present in both versions now changes by compare-and-set: + `SetAttribute` (UPN, primary SMTP, as `ValueId`), `SetActive` (the + three-valued flag, all nine `from → to` pairs) and `SetLocation` + (`Option`, never a DN string). `NodeState::apply` is the one + reference semantics: a change applies only where the node still holds its + `from` (else `Stale`), and `SetActive` on a group is refused (groups carry + no flag). Plans lower them to `SetEnabled { enabled: bool }` and + `MoveObject { to: Dn128 }` with `EnabledEquals` / `LocationEquals` + preconditions read from the observation. A change *towards* an unknown flag + or location is reportable by a diff but refused by the planner + (`PlanError::NotActuatable`): "unknown" is an observation, not an intention. + **`kind` is immutable by design**: it is identity (separate user and group + populations and ordinal spaces, typed membership endpoints), no change can + express it, and the planner's `Unconvergeable` now covers exactly a kind + mismatch. - **V3 — hierarchy coordinate (closed by ruling).** The hot location is `Dn128` (`ogar-dir-core::dn128`): 16 levels × `u8`, at most 256 distinct child codes per parent, depth as explicit side metadata (code 0 is a real