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) 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![]); +}