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
30 changes: 30 additions & 0 deletions .claude/board/EPIPHANIES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
## 2026-06-23 — E-CLASSRBAC-PROMOTED-TO-CONTRACT — the §11 trait-placement that lets ogar join the RBAC chain

**Status:** FINDING (2026-06-23). The four-crate chain `contract ↔ rbac ↔ ogar ↔
callcenter` had one structural gap: the keystone prescribes `impl ClassRbac for
OgarClassView` (Q5), but `ClassRbac` lived in `lance-graph-rbac`, which
`lance-graph-ogar` does NOT depend on (ogar deps contract only). So ogar could
not satisfy the trait. Per keystone §11 ("`ClassRbac` trait in
`lance-graph-contract`") the trait + the `Operation` its methods range over were
promoted into a new `lance_graph_contract::rbac` module (pure types/trait;
`Operation` reads the contract's own `PrefetchDepth`, zero rbac dep). rbac
re-exports them so every existing path is unchanged.

What stayed in rbac (deliberately, Q5 "rbac stays contract-tier"): the concrete
`authorize()` kernel, `ClassGrants`, `Policy`, `AccessDecision`, and the `0x0B`
auth membrane. Only the **trait surface** moved — the contract owns the *shape*,
rbac owns the *impl*.

Two prior-art discoveries the "consult before you guess" rule surfaced (and that
this promotion deliberately did NOT duplicate): `contract::auth::ActorContext`
already is the resolved-identity triple (actor + tenant + roles) that
`rbac::auth::ResolvedIdentity` mirrors — converging them is a tracked follow-on;
and `contract::external_membrane::MembraneGate` is the gate trait that *consults*
`ClassRbac` (gate and grant-resolution compose, they don't duplicate).

Consequence: `lance-graph-ogar` can now `impl ClassRbac for OgarClassView` — but a
*meaningful* impl needs the §6 `project_role.granted` typed tenant (grant data the
ClassView doesn't carry yet), so threading ogar concretely into the chain is the
next, §6-gated step. The trait placement is the unblock. Cross-ref: LATEST_STATE
2026-06-23 `contract::rbac`, OGAR keystone §11/Q5, E-RBAC-AUTHORIZE-PROBE-GREEN.

## 2026-06-23 — E-AUTH-CLASS-WIRED-TO-RBAC — the OGIT-imported 0x0B AuthStore family is now the membrane front-door of authorize()

**Status:** FINDING (2026-06-23). The OGIT `NTO/Auth/Configuration` entity (arago's
Expand Down
4 changes: 4 additions & 0 deletions .claude/board/LATEST_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

---

## 2026-06-23 — IN PR (`claude/medcare-bridge-lance-graph-wmx76z`) — `contract::rbac` — `ClassRbac` trait + `Operation` promoted to contract (keystone §11 trait-placement)

The `ClassRbac` grant-resolution trait (§4) + the `Operation` it ranges over were promoted from `lance-graph-rbac` into the zero-dep contract so `lance-graph-ogar`'s `OgarClassView` (deps contract, NOT rbac) can implement the keystone's `impl ClassRbac for OgarClassView` (Q5) — the missing wire in the `contract ↔ rbac ↔ ogar ↔ callcenter` chain. **NEW** `lance_graph_contract::rbac`: `ClassId` / `ActorId` / `RoleId` / `Operation<'a>` (reads `contract::property::PrefetchDepth`, no rbac dep) / `trait ClassRbac { actor_roles, grant_permits }`. `lance-graph-rbac` **re-exports** them (`policy::Operation`, `authorize::{ClassRbac, ClassId, ActorId, RoleId}` unchanged) — `authorize()` + `ClassGrants` + `Policy` + `AccessDecision` + the `0x0B` auth membrane stay in rbac. Zero breakage: `lance-graph-callcenter` builds against the re-exports (38s); the sibling `smb-realtime` / `medcare-realtime` gates consume `AccessDecision` (unmoved) untouched. **Verified:** contract::rbac 2 tests (incl. a contract-only `impl ClassRbac` proving ogar can satisfy it) + 723 contract tests; rbac 21 tests; callcenter builds; clippy `-D warnings` + fmt clean. Follow-on (not forced here): converge `rbac::auth::ResolvedIdentity` onto the existing `contract::auth::ActorContext`; the `OgarClassView` impl needs the §6 `project_role.granted` tenant. Refs: EPIPHANIES `E-CLASSRBAC-PROMOTED-TO-CONTRACT`, OGAR `CLASSID-RBAC-KEYSTONE-SPEC.md` §11/Q5.

## 2026-06-23 — IN PR (`claude/sync-ogar-codebook-auth-domain`) — `contract::ogar_codebook` synced to OGAR #110 (Auth domain `0x0B`) — fixes the codebook parity drift #42's ogar-vocab bump surfaced

OGAR #110 minted the `0x0B` **AuthStore** class family; the contract's zero-dep mirror lagged (39 vs 43), so `lance-graph-ogar`'s compile-time `COUNT_FUSE` + runtime `assert_codebook_parity()` fired and **broke the q2 Railway build** (`cockpit-server` → `lance-graph-ogar`). Synced the mirror: **NEW** `ConceptDomain::Auth` (`0x0BXX`) + `0x0B => Auth` routing + 4 `CODEBOOK` entries (`auth_store` `0x0B01` / `auth_zitadel` `0x0B02` / `auth_zanzibar` `0x0B03` / `auth_ory_keto` `0x0B04`), and the `lance-graph-ogar::parity::domains_agree` `(O::Auth, C::Auth)` arm. Mirror is now **43** = `ogar_vocab::class_ids::ALL`. **Verified:** `cargo build --manifest-path crates/lance-graph-ogar` (COUNT_FUSE green, 36s); `cargo test --manifest-path crates/lance-graph-ogar` (`mirror_is_a_faithful_copy_of_ogar_codebook` + 53 lib tests green); `cargo test -p lance-graph-contract` (8 ogar_codebook tests green); contract clippy `-D warnings` + fmt clean. The parity guard worked as designed — the `#[non_exhaustive]`-total `domains_agree` match tripped on the new OGAR domain. Refs: q2 #41 (root `/Dockerfile`) + #42 (ogar-vocab lock bump → `302c284`); this is the contract-side completion that unblocks the live Rust deploy.
Expand Down
1 change: 1 addition & 0 deletions crates/lance-graph-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ pub mod plan;
pub mod property;
pub mod proprioception;
pub mod qualia;
pub mod rbac;
pub use qualia::{
axis_index, axis_label, qualia_to_state, QualiaI4_16D, QualiaVector, AXIS_LABELS, MIDPOINT,
QUALIA_DIMS, QUALIA_I4_DIMS, QUALIA_I4_LABELS, ZERO,
Expand Down
128 changes: 128 additions & 0 deletions crates/lance-graph-contract/src/rbac.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! `rbac` — the classid-keyed authorization trait surface (OGAR keystone §4/§11).
//!
//! The keystone §11 build order places the **`ClassRbac` grant-resolution trait**
//! in this zero-dep contract crate so that *both* the concrete kernel
//! (`lance-graph-rbac`, which holds `authorize()` + `Policy` + the `0x0B` auth
//! membrane) *and* the active-record `ClassView` producer (`lance-graph-ogar`'s
//! `OgarClassView`, which deps contract but **not** rbac) can implement / consume
//! one trait. Before this module the trait lived in `lance-graph-rbac`, so ogar —
//! which does not depend on rbac — could not satisfy the keystone's
//! `impl ClassRbac for OgarClassView` (Q5). This is that placement.
//!
//! Only the **trait + the `Operation` it ranges over** live here (pure types, no
//! runtime — `Operation` reads [`PrefetchDepth`](crate::property::PrefetchDepth),
//! already in this crate). The concrete `authorize()` kernel, `ClassGrants`,
//! `Policy`, `AccessDecision`, and the auth membrane stay in `lance-graph-rbac`;
//! it **re-exports** these so existing `lance_graph_rbac::authorize::ClassRbac` /
//! `lance_graph_rbac::policy::Operation` paths are unchanged (callcenter +
//! the sibling `smb-realtime` / `medcare-realtime` gates keep compiling).
//!
//! # Relationship to the rest of the contract auth surface
//!
//! - [`crate::auth::ActorContext`] is the *resolved actor identity* (actor id +
//! tenant + roles). `lance-graph-rbac`'s `auth::ResolvedIdentity` (the `0x0B`
//! membrane output) carries the same triple plus the resolving provider's
//! classid; converging the two onto `ActorContext` is a tracked follow-on, not
//! forced here.
//! - [`crate::external_membrane::MembraneGate`] is the *gate* a consumer impls to
//! admit/deny an external commit; `ClassRbac` is the *grant resolution* a gate
//! consults. They compose: a gate calls `authorize(rbac, actor, class, op)`.

use crate::property::PrefetchDepth;

/// The codebook class identity an authorization targets — the
/// [`NodeGuid`](crate::NodeGuid) `classid` (or its low-`u16` codebook id widened).
/// Opaque to the kernel: it is compared and looked up, never decoded (the kernel
/// "never touches a token" — only resolved keys go inward).
pub type ClassId = u32;

/// An actor identity. In the full keystone this is the OIDC `sub` resolved to a
/// membership-set ([`crate::auth::ActorContext`]); here it is the opaque key a
/// [`ClassRbac`] impl maps to roles.
pub type ActorId<'a> = &'a str;

/// A role identity (a minted role classid in the full keystone; a role *name*
/// where reconciling against a string-keyed policy).
pub type RoleId = &'static str;

/// What a caller wants to do on a class — the op the [`ClassRbac`] grant gate
/// ranges over. Read is depth-graded ([`PrefetchDepth`]); Write names a
/// predicate; Act names an action. (Promoted from `lance-graph-rbac`'s
/// `policy::Operation`, keystone §11; that path re-exports this type.)
#[derive(Clone, Debug)]
pub enum Operation<'a> {
/// Read up to a prefetch depth.
Read {
/// The requested read depth (`Identity` < … < `Full`).
depth: PrefetchDepth,
},
/// Write a specific predicate.
Write {
/// The predicate being written.
predicate: &'a str,
},
/// Trigger a named action.
Act {
/// The action name.
action: &'a str,
},
}

/// The §4 grant-resolution surface, **classid-keyed**. The single trait both the
/// membrane gate and the cognitive loop resolve access through; the impl owns the
/// membership→role folding and the `(role, class)` grant table. `lance-graph-rbac`
/// supplies the reference impl (`ClassGrants`) + the `authorize()` kernel that
/// consumes it; `lance-graph-ogar`'s `OgarClassView` is the keystone's intended
/// active-record impl (Q5).
pub trait ClassRbac {
/// Roles the actor holds, already folded through
/// membership → member_role → role (the §4 `actor_roles`). Empty ⇒ the actor
/// is unknown to the policy.
fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId];

/// Does `role` carry a grant on `class` that permits `op`? The positive
/// `R⁺` op-mask gate (§5 stage 1). No grant, or a grant that does not permit
/// the op, ⇒ `false` (restrictive default-deny).
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool;
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn operation_reads_prefetch_depth() {
// Operation ranges over the contract's own PrefetchDepth — no rbac dep.
let op = Operation::Read {
depth: PrefetchDepth::Full,
};
assert!(matches!(op, Operation::Read { .. }));
}

// A trivial in-contract ClassRbac impl proves the trait is satisfiable with
// contract-only types (the property ogar relies on: deps contract, not rbac).
struct OneRole;
impl ClassRbac for OneRole {
fn actor_roles(&self, _actor: ActorId<'_>) -> &[RoleId] {
const R: &[RoleId] = &["reader"];
R
}
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool {
role == "reader" && class == 0x0901 && matches!(op, Operation::Read { .. })
}
}

#[test]
fn trait_is_satisfiable_with_contract_only_types() {
let rbac = OneRole;
assert_eq!(rbac.actor_roles("anyone"), &["reader"]);
assert!(rbac.grant_permits(
"reader",
0x0901,
&Operation::Read {
depth: PrefetchDepth::Identity
}
));
assert!(!rbac.grant_permits("reader", 0x0901, &Operation::Act { action: "x" }));
}
}
38 changes: 7 additions & 31 deletions crates/lance-graph-rbac/src/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,37 +44,13 @@ use crate::access::AccessDecision;
use crate::permission::PermissionSpec;
use crate::policy::Operation;

/// The codebook class identity an authorization targets — the `NodeGuid.classid`
/// (or its low-`u16` codebook id widened). Opaque to the kernel: `authorize`
/// only compares and looks it up, never decodes it (per the keystone, the kernel
/// "never touches a token" — only resolved keys go inward).
pub type ClassId = u32;

/// An actor identity. In the full keystone this is the OIDC `sub` resolved to a
/// membership-set; here it is the opaque key the [`ClassRbac`] impl maps to
/// roles.
pub type ActorId<'a> = &'a str;

/// A role identity (a minted role classid in the full keystone; the role *name*
/// here, to reconcile against the shipped string-keyed `Policy`).
pub type RoleId = &'static str;

/// The §4 grant-resolution surface, **classid-keyed**. Both the membrane gate
/// and the cognitive loop resolve access through this one trait; the impl owns
/// the membership→role folding and the (role, class) grant table. Kept
/// rbac-crate-local for the probe; the keystone §11 promotes the trait to
/// `lance-graph-contract` once the gate is green (tracked as follow-up).
pub trait ClassRbac {
/// Roles the actor holds, already folded through
/// membership → member_role → role (the §4 `actor_roles`). Empty ⇒ the actor
/// is unknown to the policy.
fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId];

/// Does `role` carry a grant on `class` that permits `op`? The positive
/// `R⁺` op-mask gate (§5 stage 1). No grant, or a grant that does not permit
/// the op, ⇒ `false` (restrictive default-deny).
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool;
}
// `ClassId` / `ActorId` / `RoleId` / `ClassRbac` were promoted to
// `lance_graph_contract::rbac` (keystone §11) so `lance-graph-ogar`'s
// `OgarClassView` (deps contract, NOT rbac) can implement the trait. Re-exported
// here so the `lance_graph_rbac::authorize::{ClassRbac, ClassId, ActorId, RoleId}`
// paths are unchanged; `authorize()` + `ClassGrants` (the kernel + reference impl)
// stay in this crate.
pub use lance_graph_contract::rbac::{ActorId, ClassId, ClassRbac, RoleId};

/// The §5 kernel — positive intersection ∧ op-gate, collapsed to the shipped
/// [`AccessDecision`]. An actor is allowed iff it holds at least one role whose
Expand Down
13 changes: 5 additions & 8 deletions crates/lance-graph-rbac/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

use crate::access::AccessDecision;
use crate::role::Role;
use lance_graph_contract::property::PrefetchDepth;

/// A policy is a named set of roles. Users are assigned roles;
/// the policy resolves access decisions by checking the user's role.
Expand Down Expand Up @@ -77,13 +76,11 @@ impl Policy {
}
}

/// What the caller wants to do.
#[derive(Clone, Debug)]
pub enum Operation<'a> {
Read { depth: PrefetchDepth },
Write { predicate: &'a str },
Act { action: &'a str },
}
/// What the caller wants to do — promoted to `lance_graph_contract::rbac`
/// (keystone §11) so `lance-graph-ogar` can range over it without depending on
/// this crate. Re-exported here so `lance_graph_rbac::policy::Operation` is
/// unchanged for existing consumers (callcenter, the sibling membrane gates).
pub use lance_graph_contract::rbac::Operation;

/// Build the default SMB policy with accountant, auditor, admin roles.
pub fn smb_policy() -> Policy {
Expand Down
Loading