From c0975e8bbd042b9612680862c71b862281cc2871 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:27:33 +0000 Subject: [PATCH] ogar-dir-sim: three-valued active and the one effective_active definition (V4) NodeState::active becomes Option: Some(true) enabled, Some(false) disabled, None unknown. An unknown flag is never read as enabled. effective_active(ad, entra) is the single cross-source definition, decided 2026-10-07: active iff at least one source is known and no known source says disabled; an unknown source abstains. The full 3x3 table is pinned, both source orders. Disable runs: reading unknown/unknown as enabled, and requiring both sources to say disabled, each fail the table. Docs: V4 closed by ruling; the AD<->Entra merge is still not wired. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-dir-sim/src/change.rs | 76 ++++++++++++++++++++++++++++--- crates/ogar-dir-sim/src/lib.rs | 4 +- crates/ogar-dir-sim/src/plan.rs | 2 +- docs/DIRECTORY-SIMULATION-POC.md | 22 +++++---- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/ogar-dir-sim/src/change.rs b/crates/ogar-dir-sim/src/change.rs index 724b69da..e4b7b803 100644 --- a/crates/ogar-dir-sim/src/change.rs +++ b/crates/ogar-dir-sim/src/change.rs @@ -45,16 +45,16 @@ pub enum NodeKind { /// node carries it as the compare-and-set expectation (delete what was /// read, never whatever is there now). /// -/// `active` is the single enabled flag as observed by the one source the -/// node came from (AD: `userAccountControl` ACCOUNTDISABLE clear). How AD -/// and Entra enabled state combine is not settled; see -/// `docs/DIRECTORY-SIMULATION-POC.md` V4. +/// `active` is three-valued: `Some(true)` enabled, `Some(false)` disabled, +/// `None` unknown (no source reported a flag). An unknown flag is never read +/// as enabled. Several sources combine through [`effective_active`]. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct NodeState { /// Kind. pub kind: NodeKind, - /// Enabled. Users only: groups have no enabled flag and carry `true`. - pub active: bool, + /// Enabled; `None` = unknown. Users only: groups have no enabled flag + /// and carry `Some(true)`. + pub active: Option, /// UPN. pub upn: Option, /// Primary SMTP. @@ -64,6 +64,39 @@ pub struct NodeState { pub dn: Option, } +/// The one definition of "active" across sources (V4, decided 2026-10-07): +/// **active iff at least one source is known and no known source says +/// disabled. An unknown source abstains.** +/// +/// ```text +/// known_any ∧ ¬known_disabled +/// = (ad_known ∨ entra_known) ∧ (¬ad_known ∨ ad_enabled) ∧ (¬entra_known ∨ entra_enabled) +/// ``` +/// +/// | AD | Entra | effective | +/// |---|---|---| +/// | enabled | enabled | active | +/// | enabled | unknown | active | +/// | unknown | enabled | active | +/// | disabled | enabled | inactive | +/// | enabled | disabled | inactive | +/// | disabled | unknown | inactive | +/// | unknown | disabled | inactive | +/// | unknown | unknown | unknown | +/// +/// Absence of evidence never disables; negative evidence always does; two +/// unknowns stay unknown rather than collapsing to either value. Every +/// executor that derives "active" from more than one source must agree with +/// this function row for row. +#[must_use] +pub const fn effective_active(ad: Option, entra: Option) -> Option { + match (ad, entra) { + (None, None) => None, + (Some(false), _) | (_, Some(false)) => Some(false), + _ => Some(true), + } +} + /// Attribute a change can set. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Attribute { @@ -150,6 +183,37 @@ pub fn normalize(s: &str) -> String { #[cfg(test)] mod tests { + use super::effective_active; + + /// The full 3×3 table, every row. A combinator that read unknown as + /// enabled (the old `from_ad` default) fails the `(None, None)` row; one + /// that read it as disabled fails the four rows with a single known + /// `true`. + #[test] + fn effective_active_is_the_v4_table() { + let (e, d, u) = (Some(true), Some(false), None); + let rows = [ + (e, e, e), + (e, u, e), + (u, e, e), + (d, e, d), + (e, d, d), + (d, u, d), + (u, d, d), + (d, d, d), + (u, u, u), + ]; + for (ad, entra, want) in rows { + assert_eq!( + effective_active(ad, entra), + want, + "ad={ad:?} entra={entra:?}" + ); + // Source order does not matter. + assert_eq!(effective_active(entra, ad), want, "swapped"); + } + } + use super::normalize; #[test] diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index 513680c6..af65aaff 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -20,7 +20,9 @@ pub mod plan; pub mod provenance; pub mod violation; -pub use change::{Attribute, Change, KeyId, NodeKind, NodeState, ValueId, normalize}; +pub use change::{ + Attribute, Change, KeyId, NodeKind, NodeState, ValueId, effective_active, 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 index df13f33e..64b94d2d 100644 --- a/crates/ogar-dir-sim/src/plan.rs +++ b/crates/ogar-dir-sim/src/plan.rs @@ -286,7 +286,7 @@ mod tests { fn state(kind: NodeKind) -> NodeState { NodeState { kind, - active: true, + active: Some(true), upn: None, primary_smtp: None, dn: None, diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index c7aed76f..fafb355d 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -183,14 +183,18 @@ no-population-intermediate rule. (`[u16; 8]`) stays the ingress and record-wire format; `Dn128::from_ou_hhtl` converts it and **fails closed** at a 257th child (`ChildCodeOverflow`) — `from_ad` then refuses the whole observation, never hashing or truncating. -- **V4 — "active" across AD and Entra (blocked, needs a decision).** Graph - `accountEnabled` is ingested by `ogar-az` (null = unknown), but an Entra - user is a different node from its AD source: `sync_edges` links them as - evidence and keeps both. There is therefore no node on which "AD active - and Entra active" is a pair. **Recorded, not fixed:** `from_ad` treats a - missing `userAccountControl` as enabled — unknown collapses into true. - Candidate policies: AD authoritative for synced users; Entra - authoritative; active only if every known source says enabled (unknown - ignored); active only if every source is known and enabled. +- **V4 — "active" across AD and Entra (closed by ruling, 2026-10-07).** + `NodeState::active` is three-valued (`Some(true)` / `Some(false)` / + `None` = unknown). Across sources, **active iff at least one source is + known and no known source says disabled; an unknown source abstains**: + `(ad_known ∨ entra_known) ∧ (¬ad_known ∨ ad_enabled) ∧ (¬entra_known ∨ + entra_enabled)`. Absence of evidence never disables, negative evidence + always does, and unknown/unknown stays unknown. The one definition is + `ogar_dir_sim::effective_active`, pinned row for row; every executor must + agree with it. `from_ad` no longer reads a missing `userAccountControl` as + enabled: it is unknown. **Not yet wired:** an Entra user is still a + separate node from its AD source (`sync_edges` links them as evidence), and + no observation path merges the two, so today each node carries its one + source's flag. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first.