Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
66 changes: 60 additions & 6 deletions crates/lance-graph-dir-sim/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,25 +539,36 @@ 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<Violation>) -> 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<Violation>,
) -> 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() {
let Some(g) = v.guid_in(NodeKind::User, i) else {
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<u32> = Vec::new();
Expand Down Expand Up @@ -635,3 +646,46 @@ pub fn validate(v: &View<'_>) -> Vec<Violation> {
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<ProvisioningConflict> {
let present = v.existing(NodeKind::User);
let rows = rows_over(v, &present, &present, &mut Vec::new());
Comment thread
AdaWorldAPI marked this conversation as resolved.
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()
}
130 changes: 130 additions & 0 deletions crates/lance-graph-dir-sim/tests/provisioning.rs
Original file line number Diff line number Diff line change
@@ -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![]);
}
Loading