From 6ab453d1f6970a9f1e8e33dc3a226dea09a3407f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:17:29 +0000 Subject: [PATCH 1/2] dir-sim: a provisioning plane separate from delivery validate::provisioning_conflicts reports every key claimed by more than one present directory object: each present user's UPN, primary and secondary SMTP and routing address, and each live group's primary SMTP, whatever the object's enabled or recipient state. Entra keeps UPN and proxyAddresses unique across the whole tenant, so a disabled account or a mailbox-less account's leftover SMTP claims its value too. It is a separate plane: address_owner, address_recipient and validate read the owner planes as before, so a provisioning conflict changes neither who holds an address nor where mail to it is delivered. address_rows becomes rows_over(owners, mail_owners); the delivery call passes the same planes it computed before. The claimants are listed, not judged: Entra quarantines the duplicate on the object whose sync brought it, and the simulator observes no sync order. Contacts, public folders and other object kinds also claim proxy addresses in a real tenant and are not observed; recorded as a fidelity limit in the function's docs. tests/provisioning.rs: a leftover address conflicts in provisioning and leaves owner/recipient and validate unchanged; a disabled account's UPN is still claimed; distinct claims and one object under two roles are silent. Red against an empty stub, green after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/lance-graph-dir-sim/src/validate.rs | 66 ++++++++- .../lance-graph-dir-sim/tests/provisioning.rs | 130 ++++++++++++++++++ 2 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 crates/lance-graph-dir-sim/tests/provisioning.rs diff --git a/crates/lance-graph-dir-sim/src/validate.rs b/crates/lance-graph-dir-sim/src/validate.rs index 3b4c38346..802b86fb4 100644 --- a/crates/lance-graph-dir-sim/src/validate.rs +++ b/crates/lance-graph-dir-sim/src/validate.rs @@ -539,12 +539,23 @@ pub fn address_recipient_in( /// deduplicated, with the routing violations (`RoutingNotInProxies`, /// `RoutingMismatch`) pushed to `out` on the way. fn address_rows(v: &View<'_>, out: &mut Vec) -> Vec<(u32, Guid128, AddressRole)> { - let p = &v.snap.users; - let rel = &v.snap.proxies; // The UPN is held by an enabled account or a live recipient; the mail // addresses are provisioned by the recipient type alone. - let owners = v.owner_users(); - let mail_owners = v.mail_owner_users(); + rows_over(v, &v.owner_users(), &v.mail_owner_users(), out) +} + +/// The `(key, holder, role)` rows of the users in `owners` (their UPN) +/// and `mail_owners` (their primary and secondary SMTP and routing +/// address), and of every live group (its primary SMTP), sorted and +/// deduplicated. Routing violations of `mail_owners` go to `out`. +fn rows_over( + v: &View<'_>, + owners: &[u64], + mail_owners: &[u64], + out: &mut Vec, +) -> Vec<(u32, Guid128, AddressRole)> { + let p = &v.snap.users; + let rel = &v.snap.proxies; let n = p.len(); let mut rows: Vec<(u32, Guid128, AddressRole)> = Vec::new(); for i in 0..v.users_len() { @@ -552,12 +563,12 @@ fn address_rows(v: &View<'_>, out: &mut Vec) -> Vec<(u32, Guid128, Ad continue; }; let slot = (NodeKind::User, i); - if bit(&owners, i) { + if bit(owners, i) { if let Some((_, k)) = v.attr_ids(slot, Attribute::Upn).filter(|&(_, k)| k != NONE) { rows.push((k, g, AddressRole::Upn)); } } - if !bit(&mail_owners, i) { + if !bit(mail_owners, i) { continue; } let mut smtp: Vec = Vec::new(); @@ -635,3 +646,46 @@ pub fn validate(v: &View<'_>) -> Vec { out.sort(); out } + +/// Two or more present directory objects claiming one comparison key. +/// +/// The claimants are listed, not judged: Entra quarantines the duplicate +/// value on the object whose sync brought it, and the simulator observes +/// no sync order, so it does not pick the object that keeps the value. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ProvisioningConflict { + /// The key claimed. + pub key: KeyId, + /// Every `(claimant, role)`, sorted by claimant. + pub claims: Vec<(Guid128, AddressRole)>, +} + +/// The provisioning plane: every key claimed by more than one present +/// directory object, sorted by key. +/// +/// Entra ID keeps `userPrincipalName` and `proxyAddresses` unique across +/// the tenant, over every object whatever its state: a disabled account, a +/// mailbox-less account's leftover SMTP and proxies, and a group all claim +/// their values. The claims read here are each present user's UPN, primary +/// and secondary SMTP and routing address, and each live group's primary +/// SMTP, in one address space as [`address_rules`] reads it. `mail` claims +/// nothing. +/// +/// This is a separate plane from the Exchange one. It does not feed +/// [`address_owner`], [`address_recipient`] or [`validate`], so a conflict +/// here changes neither who holds an address nor where mail to it goes. +/// +/// Fidelity limit: the simulator observes users and groups only. Contacts, +/// public folders and other object kinds also claim proxy addresses in a +/// real tenant and are not in this plane. +pub fn provisioning_conflicts(v: &View<'_>) -> Vec { + let present = v.existing(NodeKind::User); + let rows = rows_over(v, &present, &present, &mut Vec::new()); + rows.chunk_by(|a, b| a.0 == b.0) + .filter(|run| run.iter().any(|r| r.1 != run[0].1)) + .map(|run| ProvisioningConflict { + key: KeyId(run[0].0), + claims: run.iter().map(|r| (r.1, r.2)).collect(), + }) + .collect() +} diff --git a/crates/lance-graph-dir-sim/tests/provisioning.rs b/crates/lance-graph-dir-sim/tests/provisioning.rs new file mode 100644 index 000000000..cf554eaaa --- /dev/null +++ b/crates/lance-graph-dir-sim/tests/provisioning.rs @@ -0,0 +1,130 @@ +//! The provisioning plane: an address claimed by more than one directory +//! object, whatever state the objects are in. +//! +//! Entra ID keeps `userPrincipalName` and `proxyAddresses` unique across +//! every object of the tenant; a duplicate value from a sync is quarantined +//! on the object that brought it, enabled or not, mail-enabled or not. That +//! is a separate question from who holds an address in Exchange (only +//! address owners: [`validate::address_owner`]) and from who receives mail +//! at it ([`validate::address_recipient`]), and it must not change either. + +use lance_graph_dir_sim::validate::{ + address_owner, address_recipient, provisioning_conflicts, ProvisioningConflict, +}; +use lance_graph_dir_sim::*; +use ogar_dir_core::{DirectoryScope, Guid128}; +use ogar_dir_sim::*; + +const SCOPE: DirectoryScope = DirectoryScope(Guid128([0x5C; 16])); + +fn g(n: u8) -> Guid128 { + Guid128([n; 16]) +} + +fn store(nodes: Vec<(Guid128, ObservedNode)>) -> (VersionStore, VersionId) { + let mut st = VersionStore::new(); + let v = st + .observe( + "lab", + 0, + Observation { + scope: SCOPE, + nodes, + members: vec![], + }, + ) + .unwrap(); + (st, v) +} + +fn group(smtp: &str) -> ObservedNode { + let mut grp = ObservedNode::group(); + grp.primary_smtp = Some(smtp.into()); + grp +} + +/// An enabled account whose recipient type was read and is empty: not +/// mail-enabled. Its SMTP and proxies are leftovers in Exchange terms. +fn not_mail_enabled(name: &str, smtp: &str) -> ObservedNode { + let mut u = ObservedNode::user(&format!("{name}.upn@x.test"), smtp); + u.recipient = Some(ObservedRecipient::default()); + u +} + +// A non-mail-enabled account still carrying a group's address: Exchange +// holds and delivers the address to the group, and validation raises no +// address conflict, but provisioning reports the key as claimed twice. +#[test] +fn a_leftover_address_is_a_provisioning_conflict_not_a_delivery_change() { + let (mut st, v) = store(vec![ + (g(0xB0), group("team@x.test")), + (g(0xDA), not_mail_enabled("dave", "team@x.test")), + ]); + let key = { + let id = st.intern("team@x.test"); + st.key_of(id).unwrap() + }; + let out = st.validate(v).unwrap(); + assert!( + !out.iter() + .any(|x| matches!(x, Violation::AddressConflict { .. })), + "{out:?}" + ); + let view = st.view(v).unwrap(); + assert_eq!(address_owner(&view, key), Ok(Some(g(0xB0)))); + assert_eq!(address_recipient(&view, key), Ok(Some(g(0xB0)))); + assert_eq!( + provisioning_conflicts(&view), + vec![ProvisioningConflict { + key, + claims: vec![ + (g(0xB0), AddressRole::PrimarySmtp), + (g(0xDA), AddressRole::PrimarySmtp), + ], + }] + ); +} + +// A disabled account's UPN is still claimed in the tenant. +#[test] +fn a_disabled_accounts_upn_is_still_claimed() { + let mut disabled = ObservedNode::user("shared@x.test", "old@x.test"); + disabled.active = Some(false); + disabled.recipient = Some(ObservedRecipient::default()); + let (mut st, v) = store(vec![ + (g(0xD1), disabled), + ( + g(0xA1), + ObservedNode::user("alice.upn@x.test", "shared@x.test"), + ), + ]); + let key = { + let id = st.intern("shared@x.test"); + st.key_of(id).unwrap() + }; + let view = st.view(v).unwrap(); + assert_eq!(address_owner(&view, key), Ok(Some(g(0xA1)))); + assert_eq!( + provisioning_conflicts(&view), + vec![ProvisioningConflict { + key, + claims: vec![ + (g(0xA1), AddressRole::PrimarySmtp), + (g(0xD1), AddressRole::Upn), + ], + }] + ); +} + +// The silence half: distinct addresses, and one object claiming one key +// under two roles (its UPN is its primary SMTP), conflict with nothing. +#[test] +fn distinct_claims_and_one_object_under_two_roles_are_not_conflicts() { + let (st, v) = store(vec![ + (g(0xA1), ObservedNode::user("alice@x.test", "alice@x.test")), + (g(0xB0), group("team@x.test")), + (g(0xDA), not_mail_enabled("dave", "dave@x.test")), + ]); + let view = st.view(v).unwrap(); + assert_eq!(provisioning_conflicts(&view), vec![]); +} From a42379a2c87fb45f92b91cc3c36c7e108dc8504b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:17:45 +0000 Subject: [PATCH 2/2] board: D-DSV-1 provisioning plane; D-DSP-0/1/3 shipped in #1473 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/board/STATUS_BOARD.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index b4af614f5..284dc5867 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -11,16 +11,24 @@ Plan: `.claude/plans/key16-contiguous-match-v1.md`. | **D-KEY16-4** | AVX-512 gather for strides > 16, behind the facade | Queued, gated on D-KEY16-2 (≥ 1.2× at strides 32 and 512) | other backends still take the old path, asserted | | **D-KEY16-5** | Consumer verification: mask-risc, quack, dir-sim tests + Identity CI names; dir-sim `subtree` timing before/after | Queued (after the ndarray merge) | no consumer source change; timing recorded with method | +## D-DSV — dir-sim address planes kept apart (2026-10-11) + +Decision 1(c): observed claims, provisioning conflicts and delivery are three questions. + +| D-id | Deliverable | Status | Gate | +|---|---|---|---| +| **D-DSV-1** | `validate::provisioning_conflicts`: every present object's claims, separate from `address_owner` / `address_recipient` / `validate` | In PR | `tests/provisioning.rs`: delivery unchanged, conflict reported, silence half; two disable runs red | + ## D-DSP — dir-sim history that survives a restart (2026-10-11) Plan: `.claude/plans/dir-sim-persist-v1.md`. | D-id | Deliverable | Status | Gate | |---|---|---|---| -| **D-DSP-0** | `StoreImage` + `VersionStore::image` / `restore` (dir-sim, no I/O) | In PR | P-W3-0: round trip answers every read the same; damaged image refused | -| **D-DSP-1** | `VersionStore::revert(current, earlier, evidence)` — undo as a version | In PR | the revert's view equals `earlier`'s | +| **D-DSP-0** | `StoreImage` + `VersionStore::image` / `restore` (dir-sim, no I/O) | Shipped (#1473) | P-W3-0: round trip answers every read the same; damaged image refused | +| **D-DSP-1** | `VersionStore::revert(current, earlier, evidence)` — undo as a version | Shipped (#1473) | the revert's view equals `earlier`'s | | **D-DSP-2** | Lance writer/reader crate for `StoreImage` | Queued | write → read → restore equals the in-memory image | -| **D-DSP-3** | `ViewActors` (W-2) + `from_graph` groups (wishlist #19) in dir-sim | In PR | disable runs red | +| **D-DSP-3** | `ViewActors` (W-2) + `from_graph` groups (wishlist #19) in dir-sim | Shipped (#1473) | disable runs red | ## D-LBL — labels of a concept, pulled through the plug (2026-10-11)