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
7 changes: 7 additions & 0 deletions .claude/board/INTEGRATION_PLANS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2026-10-11 (2) — dir-sim-persist-v1 — a dir-sim history that survives a restart (W-3) → `.claude/plans/dir-sim-persist-v1.md`

**Status:** ACTIVE (D-DSP-0..3). Consumer ask: HubSPO-rs handover W-3.

- The persisted form is the store's inputs (root observations, dictionary in id order, versions verbatim, tags, verdicts), never the derived snapshot lanes.
- `lance-graph-dir-sim` stays free of I/O; a separate crate writes the image to Lance.

## 2026-10-11 — concept-labels-hotplug-v1 — labels of a concept, pulled through the plug → `.claude/plans/concept-labels-hotplug-v1.md`

**Status:** ACTIVE (D-LBL-0..5). Operator DECISION 2026-10-11: labels are ontology data in `ogar-vocab`, pulled through `hotplug.rs` only for plugged concepts; every language now, a non-breaking per-consumer spec later; customer binaries activate at build time; `0x0103` German preferred "Zeiterfassung".
Expand Down
11 changes: 11 additions & 0 deletions .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 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-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-LBL — labels of a concept, pulled through the plug (2026-10-11)

Plan: `.claude/plans/concept-labels-hotplug-v1.md`.
Expand Down
60 changes: 60 additions & 0 deletions .claude/plans/dir-sim-persist-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# dir-sim persistence (W-3) — v1

**Status:** ACTIVE (2026-10-11). Consumer ask: HubSPO-rs handover
`.claude/handovers/2026-10-10-1600-hubspo-rs-to-lance-graph.md` W-3; meaning
half OGAR `.claude/handovers/2026-10-10-1600-hubspo-rs-to-ogar-dir-sim.md`
W-3 (`VersionId` maps 1:1 onto a Lance dataset version, `provenance.rs:16`).

## Overview

`VersionStore` is in memory only. HubSPO-rs needs a history that survives a
restart without re-observing AD, keeps provenance and tags, and can restore
an earlier version to undo a directory change.

## What is persisted, and why not the lanes

The persisted form is the store's **inputs**, not its derived planes:

| part | persisted as | why |
|---|---|---|
| each root | the `Observation` it was built from | `Snapshot` lanes are derived and lossy (`alias_key` keeps only the comparison form; `owner` / `mail_owner` are computed); their layout changes as planes are added. The observation is the source's own schema. |
| the dictionary | raw values in `ValueId` order | comparison keys and routing aliases are recomputed by `Dicts::intern`; interning the values in order reproduces every id, so persisted `Change`s stay valid. |
| each version | `Version` (id, parent, origin, delta) verbatim | the delta is what a simulation produced; rules are not re-run. |
| tags, verdicts | as held | a verdict is evidence about a version, kept with it. |

Restore = intern the dictionary in order, rebuild each root with
`Snapshot::build`, push the versions in id order. A restored store answers
every `view` exactly as the original.

Restoring an earlier version to undo a change is a new version, never a
rewrite: `diff(current, earlier)` is the change list that reverts, recorded
as a simulated version whose origin names the restored version.

## Work items

- [x] **P-W3-0 (probe first):** `StoreImage` (owned, in `lance-graph-dir-sim`,
no I/O) + `VersionStore::image()` / `VersionStore::restore(image)`;
test: a store with two roots, simulated versions, tags and verdicts
round-trips, and every version's view, diff and plan are equal before
and after. Done (`tests/persist.rs`); five disable
runs red: dictionary order, verdicts, tags, delta check, dictionary
completeness.
- [x] `VersionStore` keeps each root's `Observation` (needed for the image).
- [x] `VersionStore::revert(current, earlier, evidence)` — the undo as a version, recorded under `REVERT`.
- [ ] Lance writer/reader in a separate crate (`lance-graph-dir-sim-lance`),
so `lance-graph-dir-sim` keeps its no-I/O rule: one Lance dataset per
store, tables `values`, `nodes`, `members`, `proxies`, `versions`,
`changes`, `tags`; one Lance version per dir-sim version written.
- [ ] Board: STATUS_BOARD row; HubSPO handover reply.

## Open

- Whether a Lance version per dir-sim version, or per write batch, is the
right mapping (OGAR says 1:1 "by design"; a batch import would write many
versions at once).
- Encoding of `Change` / `Origin` rows: they are OGAR types; their columnar
form belongs next to them in OGAR or here.
- `RuleId::name` is `&'static str` (OGAR `provenance.rs:29`). An image held in
memory restores fine, but one read back from bytes has no `'static` name to
put there. D-DSP-2 needs either a rule registry the reader maps names
through (unknown name = refusal), or an owned name in OGAR's `RuleId`.
36 changes: 30 additions & 6 deletions crates/lance-graph-dir-sim/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# lance-graph-dir-sim — simulate a directory's desired state over the SoA
# substrate. EXCLUDED from the lance-graph workspace (own [workspace] root)
# because it path-deps the OGAR sibling — the same shape as
# lance-graph-report-ogar / lance-graph-ogar.
# because it depends on OGAR — the same shape as lance-graph-ogar.
#
# OGAR is a git dependency, so a consumer in another repository (HubSPO-rs)
# can depend on this crate by git (ISS-LG-DIRSIM-NOT-GIT-CONSUMABLE). The
# [patch] below redirects that git source onto the sibling checkout for this
# crate's own build and CI, so a paired OGAR change is tested before it
# merges. `[patch]` only applies in the workspace root: a consumer that also
# names OGAR by path (Spear, Stalwart) must repeat it in its root, or it
# builds two `ogar-dir-core` crates and the types do not unify.
#
# Direction: this crate depends on BOTH sides; neither depends on it. OGAR
# owns the semantic vocabulary (ogar-dir-sim: Change, provenance, Violation,
Expand All @@ -24,17 +31,34 @@ description = "Versioned directory graph over SoA lanes: observed snapshots shar
[dependencies]
lance-graph-quack = { path = "../lance-graph-quack" }
lance-graph-mask-risc = { path = "../lance-graph-mask-risc" }
ogar-dir-core = { path = "../../../OGAR/crates/ogar-dir-core" }
ogar-dir-sim = { path = "../../../OGAR/crates/ogar-dir-sim" }
ogar-ad = { path = "../../../OGAR/crates/ogar-ad" }
ogar-az = { path = "../../../OGAR/crates/ogar-az" }
ogar-dir-core = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" }
ogar-dir-sim = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" }
ogar-ad = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" }
ogar-az = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" }
# The actor source over a view (`actors`, feature `rbac`): the contract's
# zero-dep `ActorSource` socket.
lance-graph-contract = { path = "../lance-graph-contract", optional = true }
# RFC 4518 string preparation (NFKC) for case-ignore LDAP matching.
unicode-normalization = "0.1"
# Full Unicode case folding (RFC 3454 B.2) for the same.
unicase = "2"

[features]
rbac = ["dep:lance-graph-contract"]

# mask-risc names ndarray by its canonical git coordinate; point it at the
# sibling checkout so this workspace has one ndarray identity and needs no
# network for it (same patch as the lance-graph root).
[patch."https://github.com/AdaWorldAPI/ndarray"]
ndarray = { path = "../../../ndarray" }

# This crate's own build uses the OGAR sibling checkout (see the header).
[patch."https://github.com/AdaWorldAPI/OGAR"]
ogar-dir-core = { path = "../../../OGAR/crates/ogar-dir-core" }
ogar-dir-sim = { path = "../../../OGAR/crates/ogar-dir-sim" }
ogar-ad = { path = "../../../OGAR/crates/ogar-ad" }
ogar-az = { path = "../../../OGAR/crates/ogar-az" }

[[test]]
name = "view_actors"
required-features = ["rbac"]
143 changes: 143 additions & 0 deletions crates/lance-graph-dir-sim/src/actors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! The roles a principal holds, read from a [`View`]: the contract's
//! [`ActorSource`] over the directory.
//!
//! A role is granted to a group, never to an address: [`GroupRoles`] keys
//! the grants by the group's GUID. A principal holds the roles of every
//! security group whose SID it carries ([`View::security_identifiers`]),
//! directly or through nesting. Only a security group has a SID, so a
//! distribution group passes on nothing, and nesting reaches a role only
//! through a chain of security groups.
//!
//! Fails closed in three places:
//!
//! * a group whose grants were never read grants nothing. It is not the same
//! as a group read with no grants, so [`ViewActors::unread_groups`] names
//! it, and a caller that needs the full picture can refuse;
//! * a role the consumer's binding does not declare is dropped, so group
//! data can never hand out a role the plug did not ask for;
//! * any actor other than the principal holds nothing.
//!
//! Roles are unscoped here; a consumer that binds a role to rows (a mailbox,
//! a practice) supplies its own `memberships_of`.

use std::collections::HashMap;

use lance_graph_contract::rbac::{ActorId, RoleId};
use lance_graph_contract::rbac_plug::{ActorSource, RbacBinding};
use ogar_dir_core::Guid128;

use crate::View;

/// The roles granted to each group, keyed by the group's GUID.
///
/// A group is either read (with its roles, possibly none) or absent, which
/// means its grants were never read.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GroupRoles {
read: HashMap<Guid128, Vec<RoleId>>,
}

impl GroupRoles {
/// No group read yet.
#[must_use]
pub fn new() -> Self {
Self::default()
}

/// Record the grants of `group` as read: `roles`, which may be empty.
/// Reading a group again replaces what was recorded.
pub fn read(&mut self, group: Guid128, roles: &[RoleId]) -> &mut Self {
self.read.insert(group, roles.to_vec());
self
}

/// The roles of `group`, or `None` when its grants were never read.
#[must_use]
pub fn of(&self, group: &Guid128) -> Option<&[RoleId]> {
self.read.get(group).map(Vec::as_slice)
}
}

/// One principal's roles, read from a [`View`] once.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewActors {
subject: String,
principal: Guid128,
roles: Vec<RoleId>,
unread: Vec<Guid128>,
}

impl ViewActors {
/// The roles `principal` holds in `view`: the grants of every security
/// group whose SID it carries, kept only where `binding` declares the
/// role. Decisions are asked under the principal's GUID as text
/// ([`subject`](Self::subject)).
///
/// A principal that is not a user in `view` holds nothing.
#[must_use]
pub fn for_principal(
view: &View<'_>,
principal: &Guid128,
grants: &GroupRoles,
binding: &RbacBinding,
) -> Self {
let declared: Vec<RoleId> = binding.declared_grants().iter().map(|(r, _)| *r).collect();
let mut roles: Vec<RoleId> = Vec::new();
let mut unread = Vec::new();
for group in view.security_identifiers(principal) {
match grants.of(&group) {
Some(granted) => {
for role in granted {
if declared.contains(role) && !roles.contains(role) {
roles.push(*role);
}
}
}
None => unread.push(group),
}
}
roles.sort_unstable();
Self {
subject: principal.to_string(),
principal: *principal,
roles,
unread,
}
}

/// The principal's GUID as text, the id decisions are asked under.
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}

/// The principal's directory object.
#[must_use]
pub fn principal(&self) -> &Guid128 {
&self.principal
}

/// The roles held, sorted.
#[must_use]
pub fn roles(&self) -> &[RoleId] {
&self.roles
}

/// The security groups of the principal whose grants were never read.
/// They granted nothing; a caller that needs every grant can refuse when
/// this is not empty.
#[must_use]
pub fn unread_groups(&self) -> &[Guid128] {
&self.unread
}
}

impl ActorSource for ViewActors {
fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId] {
if actor == self.subject {
&self.roles
} else {
&[]
}
}
}
68 changes: 68 additions & 0 deletions crates/lance-graph-dir-sim/src/image.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! A version history as owned data: what persisting a [`VersionStore`]
//! writes, and what restoring one reads.
//!
//! The image holds the store's inputs, never its derived planes:
//!
//! * each root as the [`Observation`] it was built from. Snapshot lanes are
//! derived and lossy (a nickname keeps only its comparison form there), and
//! their layout grows as planes are added;
//! * the dictionary as raw values in [`ValueId`](crate::ValueId) order.
//! Comparison keys and routing aliases are recomputed by interning, so the
//! same order reproduces every id the changes refer to;
//! * every [`Version`] verbatim: a simulated version's delta is what the
//! rule produced, so restoring never re-runs a rule;
//! * tags and recorded verdicts.
//!
//! No I/O here: a storage crate writes and reads the image.
//!
//! [`VersionStore`]: crate::VersionStore

use crate::snapshot::{BuildError, Observation};
use crate::view::ApplyError;
use ogar_dir_sim::{Version, VersionId, Violation};

/// A version history as owned data ([`crate::VersionStore::image`],
/// [`crate::VersionStore::restore`]).
#[derive(Clone, Debug, Default)]
pub struct StoreImage {
/// The dictionary: raw values in id order.
pub values: Vec<String>,
/// Each root and the observation it was built from, by version id.
pub roots: Vec<(VersionId, Observation)>,
/// Every version, in id order.
pub versions: Vec<Version>,
/// Tags, sorted by name.
pub tags: Vec<(String, VersionId)>,
/// Recorded verdicts, by version id.
pub verdicts: Vec<(VersionId, Vec<Violation>)>,
}

/// Why an image could not be restored. Nothing is half-restored: the error
/// is returned instead of a store.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RestoreError {
/// Value `index` appears twice in the dictionary, so ids would shift.
DuplicateValue(usize),
/// Versions must be listed densely in id order; `index` holds another id.
VersionOrder {
/// Position in the list.
index: usize,
/// The id found there.
found: VersionId,
},
/// A root version without its observation.
MissingRoot(VersionId),
/// An observation for a version that is not a root.
NotARoot(VersionId),
/// A version derived from a version listed after it, or not at all.
UnknownParent(VersionId),
/// A root's observation does not build.
Build(VersionId, BuildError),
/// A version's delta does not apply to its parent.
Apply(VersionId, ApplyError),
/// Building the roots interned a value the dictionary did not list, so
/// the image's ids are not the ones its changes were made with.
IncompleteDictionary,
/// A tag or verdict names a version the image does not hold.
UnknownVersion(VersionId),
}
6 changes: 6 additions & 0 deletions crates/lance-graph-dir-sim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
//! * [`validate`] — invariants as Quack programs (`Semijoin` anti-joins,
//! `GroupReduce` counts).
//! * [`store`] — append-only versions, tags, diff, plan, audit.
//! * [`image`] — a history as owned data, to persist and restore.
//! * [`observe`] — `ogar-ad` records → observation (`OuHhtl` → `Dn128`,
//! failing closed).
//! * [`ad`] — a version as Active Directory entries (observed, mirrored,
//! synthetic or simulated) and as LDIF.
//! * [`ldap`] — a read-only LDAP v3 protocol handler over a projection;
//! access decided by the host's [`ldap::Authority`]. Bytes in, bytes out.
//! * `actors` (feature `rbac`) — the roles a principal holds through the
//! security groups whose SID it carries, as the contract's `ActorSource`.
//! * [`cloud`] — which AD objects have their mailbox in Exchange Online,
//! read from OGAR's GUID-keyed hybrid correspondence fold.
//!
Expand All @@ -31,11 +34,14 @@

#![forbid(unsafe_code)]

#[cfg(feature = "rbac")]
pub mod actors;
pub mod ad;
pub mod bind;
pub mod cloud;
mod exec;
pub mod groups;
pub mod image;
pub mod ldap;
pub mod observe;
pub mod proxy;
Expand Down
Loading
Loading