Skip to content

Commit d8a59a4

Browse files
authored
Merge pull request #599 from AdaWorldAPI/claude/medcare-bridge-lance-graph-wmx76z
feat(contract): promote ClassRbac trait + Operation to contract::rbac (keystone §11)
2 parents 927c668 + fce9840 commit d8a59a4

6 files changed

Lines changed: 175 additions & 39 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
## 2026-06-23 — E-CLASSRBAC-PROMOTED-TO-CONTRACT — the §11 trait-placement that lets ogar join the RBAC chain
2+
3+
**Status:** FINDING (2026-06-23). The four-crate chain `contract ↔ rbac ↔ ogar ↔
4+
callcenter` had one structural gap: the keystone prescribes `impl ClassRbac for
5+
OgarClassView` (Q5), but `ClassRbac` lived in `lance-graph-rbac`, which
6+
`lance-graph-ogar` does NOT depend on (ogar deps contract only). So ogar could
7+
not satisfy the trait. Per keystone §11 ("`ClassRbac` trait in
8+
`lance-graph-contract`") the trait + the `Operation` its methods range over were
9+
promoted into a new `lance_graph_contract::rbac` module (pure types/trait;
10+
`Operation` reads the contract's own `PrefetchDepth`, zero rbac dep). rbac
11+
re-exports them so every existing path is unchanged.
12+
13+
What stayed in rbac (deliberately, Q5 "rbac stays contract-tier"): the concrete
14+
`authorize()` kernel, `ClassGrants`, `Policy`, `AccessDecision`, and the `0x0B`
15+
auth membrane. Only the **trait surface** moved — the contract owns the *shape*,
16+
rbac owns the *impl*.
17+
18+
Two prior-art discoveries the "consult before you guess" rule surfaced (and that
19+
this promotion deliberately did NOT duplicate): `contract::auth::ActorContext`
20+
already is the resolved-identity triple (actor + tenant + roles) that
21+
`rbac::auth::ResolvedIdentity` mirrors — converging them is a tracked follow-on;
22+
and `contract::external_membrane::MembraneGate` is the gate trait that *consults*
23+
`ClassRbac` (gate and grant-resolution compose, they don't duplicate).
24+
25+
Consequence: `lance-graph-ogar` can now `impl ClassRbac for OgarClassView` — but a
26+
*meaningful* impl needs the §6 `project_role.granted` typed tenant (grant data the
27+
ClassView doesn't carry yet), so threading ogar concretely into the chain is the
28+
next, §6-gated step. The trait placement is the unblock. Cross-ref: LATEST_STATE
29+
2026-06-23 `contract::rbac`, OGAR keystone §11/Q5, E-RBAC-AUTHORIZE-PROBE-GREEN.
30+
131
## 2026-06-23 — E-AUTH-CLASS-WIRED-TO-RBAC — the OGIT-imported 0x0B AuthStore family is now the membrane front-door of authorize()
232

333
**Status:** FINDING (2026-06-23). The OGIT `NTO/Auth/Configuration` entity (arago's

.claude/board/LATEST_STATE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
1111
---
1212

13+
## 2026-06-23 — IN PR (`claude/medcare-bridge-lance-graph-wmx76z`) — `contract::rbac``ClassRbac` trait + `Operation` promoted to contract (keystone §11 trait-placement)
14+
15+
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.
16+
1317
## 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
1418

1519
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.

crates/lance-graph-contract/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub mod plan;
9898
pub mod property;
9999
pub mod proprioception;
100100
pub mod qualia;
101+
pub mod rbac;
101102
pub use qualia::{
102103
axis_index, axis_label, qualia_to_state, QualiaI4_16D, QualiaVector, AXIS_LABELS, MIDPOINT,
103104
QUALIA_DIMS, QUALIA_I4_DIMS, QUALIA_I4_LABELS, ZERO,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! `rbac` — the classid-keyed authorization trait surface (OGAR keystone §4/§11).
2+
//!
3+
//! The keystone §11 build order places the **`ClassRbac` grant-resolution trait**
4+
//! in this zero-dep contract crate so that *both* the concrete kernel
5+
//! (`lance-graph-rbac`, which holds `authorize()` + `Policy` + the `0x0B` auth
6+
//! membrane) *and* the active-record `ClassView` producer (`lance-graph-ogar`'s
7+
//! `OgarClassView`, which deps contract but **not** rbac) can implement / consume
8+
//! one trait. Before this module the trait lived in `lance-graph-rbac`, so ogar —
9+
//! which does not depend on rbac — could not satisfy the keystone's
10+
//! `impl ClassRbac for OgarClassView` (Q5). This is that placement.
11+
//!
12+
//! Only the **trait + the `Operation` it ranges over** live here (pure types, no
13+
//! runtime — `Operation` reads [`PrefetchDepth`](crate::property::PrefetchDepth),
14+
//! already in this crate). The concrete `authorize()` kernel, `ClassGrants`,
15+
//! `Policy`, `AccessDecision`, and the auth membrane stay in `lance-graph-rbac`;
16+
//! it **re-exports** these so existing `lance_graph_rbac::authorize::ClassRbac` /
17+
//! `lance_graph_rbac::policy::Operation` paths are unchanged (callcenter +
18+
//! the sibling `smb-realtime` / `medcare-realtime` gates keep compiling).
19+
//!
20+
//! # Relationship to the rest of the contract auth surface
21+
//!
22+
//! - [`crate::auth::ActorContext`] is the *resolved actor identity* (actor id +
23+
//! tenant + roles). `lance-graph-rbac`'s `auth::ResolvedIdentity` (the `0x0B`
24+
//! membrane output) carries the same triple plus the resolving provider's
25+
//! classid; converging the two onto `ActorContext` is a tracked follow-on, not
26+
//! forced here.
27+
//! - [`crate::external_membrane::MembraneGate`] is the *gate* a consumer impls to
28+
//! admit/deny an external commit; `ClassRbac` is the *grant resolution* a gate
29+
//! consults. They compose: a gate calls `authorize(rbac, actor, class, op)`.
30+
31+
use crate::property::PrefetchDepth;
32+
33+
/// The codebook class identity an authorization targets — the
34+
/// [`NodeGuid`](crate::NodeGuid) `classid` (or its low-`u16` codebook id widened).
35+
/// Opaque to the kernel: it is compared and looked up, never decoded (the kernel
36+
/// "never touches a token" — only resolved keys go inward).
37+
pub type ClassId = u32;
38+
39+
/// An actor identity. In the full keystone this is the OIDC `sub` resolved to a
40+
/// membership-set ([`crate::auth::ActorContext`]); here it is the opaque key a
41+
/// [`ClassRbac`] impl maps to roles.
42+
pub type ActorId<'a> = &'a str;
43+
44+
/// A role identity (a minted role classid in the full keystone; a role *name*
45+
/// where reconciling against a string-keyed policy).
46+
pub type RoleId = &'static str;
47+
48+
/// What a caller wants to do on a class — the op the [`ClassRbac`] grant gate
49+
/// ranges over. Read is depth-graded ([`PrefetchDepth`]); Write names a
50+
/// predicate; Act names an action. (Promoted from `lance-graph-rbac`'s
51+
/// `policy::Operation`, keystone §11; that path re-exports this type.)
52+
#[derive(Clone, Debug)]
53+
pub enum Operation<'a> {
54+
/// Read up to a prefetch depth.
55+
Read {
56+
/// The requested read depth (`Identity` < … < `Full`).
57+
depth: PrefetchDepth,
58+
},
59+
/// Write a specific predicate.
60+
Write {
61+
/// The predicate being written.
62+
predicate: &'a str,
63+
},
64+
/// Trigger a named action.
65+
Act {
66+
/// The action name.
67+
action: &'a str,
68+
},
69+
}
70+
71+
/// The §4 grant-resolution surface, **classid-keyed**. The single trait both the
72+
/// membrane gate and the cognitive loop resolve access through; the impl owns the
73+
/// membership→role folding and the `(role, class)` grant table. `lance-graph-rbac`
74+
/// supplies the reference impl (`ClassGrants`) + the `authorize()` kernel that
75+
/// consumes it; `lance-graph-ogar`'s `OgarClassView` is the keystone's intended
76+
/// active-record impl (Q5).
77+
pub trait ClassRbac {
78+
/// Roles the actor holds, already folded through
79+
/// membership → member_role → role (the §4 `actor_roles`). Empty ⇒ the actor
80+
/// is unknown to the policy.
81+
fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId];
82+
83+
/// Does `role` carry a grant on `class` that permits `op`? The positive
84+
/// `R⁺` op-mask gate (§5 stage 1). No grant, or a grant that does not permit
85+
/// the op, ⇒ `false` (restrictive default-deny).
86+
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool;
87+
}
88+
89+
#[cfg(test)]
90+
mod tests {
91+
use super::*;
92+
93+
#[test]
94+
fn operation_reads_prefetch_depth() {
95+
// Operation ranges over the contract's own PrefetchDepth — no rbac dep.
96+
let op = Operation::Read {
97+
depth: PrefetchDepth::Full,
98+
};
99+
assert!(matches!(op, Operation::Read { .. }));
100+
}
101+
102+
// A trivial in-contract ClassRbac impl proves the trait is satisfiable with
103+
// contract-only types (the property ogar relies on: deps contract, not rbac).
104+
struct OneRole;
105+
impl ClassRbac for OneRole {
106+
fn actor_roles(&self, _actor: ActorId<'_>) -> &[RoleId] {
107+
const R: &[RoleId] = &["reader"];
108+
R
109+
}
110+
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool {
111+
role == "reader" && class == 0x0901 && matches!(op, Operation::Read { .. })
112+
}
113+
}
114+
115+
#[test]
116+
fn trait_is_satisfiable_with_contract_only_types() {
117+
let rbac = OneRole;
118+
assert_eq!(rbac.actor_roles("anyone"), &["reader"]);
119+
assert!(rbac.grant_permits(
120+
"reader",
121+
0x0901,
122+
&Operation::Read {
123+
depth: PrefetchDepth::Identity
124+
}
125+
));
126+
assert!(!rbac.grant_permits("reader", 0x0901, &Operation::Act { action: "x" }));
127+
}
128+
}

crates/lance-graph-rbac/src/authorize.rs

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,37 +44,13 @@ use crate::access::AccessDecision;
4444
use crate::permission::PermissionSpec;
4545
use crate::policy::Operation;
4646

47-
/// The codebook class identity an authorization targets — the `NodeGuid.classid`
48-
/// (or its low-`u16` codebook id widened). Opaque to the kernel: `authorize`
49-
/// only compares and looks it up, never decodes it (per the keystone, the kernel
50-
/// "never touches a token" — only resolved keys go inward).
51-
pub type ClassId = u32;
52-
53-
/// An actor identity. In the full keystone this is the OIDC `sub` resolved to a
54-
/// membership-set; here it is the opaque key the [`ClassRbac`] impl maps to
55-
/// roles.
56-
pub type ActorId<'a> = &'a str;
57-
58-
/// A role identity (a minted role classid in the full keystone; the role *name*
59-
/// here, to reconcile against the shipped string-keyed `Policy`).
60-
pub type RoleId = &'static str;
61-
62-
/// The §4 grant-resolution surface, **classid-keyed**. Both the membrane gate
63-
/// and the cognitive loop resolve access through this one trait; the impl owns
64-
/// the membership→role folding and the (role, class) grant table. Kept
65-
/// rbac-crate-local for the probe; the keystone §11 promotes the trait to
66-
/// `lance-graph-contract` once the gate is green (tracked as follow-up).
67-
pub trait ClassRbac {
68-
/// Roles the actor holds, already folded through
69-
/// membership → member_role → role (the §4 `actor_roles`). Empty ⇒ the actor
70-
/// is unknown to the policy.
71-
fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId];
72-
73-
/// Does `role` carry a grant on `class` that permits `op`? The positive
74-
/// `R⁺` op-mask gate (§5 stage 1). No grant, or a grant that does not permit
75-
/// the op, ⇒ `false` (restrictive default-deny).
76-
fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool;
77-
}
47+
// `ClassId` / `ActorId` / `RoleId` / `ClassRbac` were promoted to
48+
// `lance_graph_contract::rbac` (keystone §11) so `lance-graph-ogar`'s
49+
// `OgarClassView` (deps contract, NOT rbac) can implement the trait. Re-exported
50+
// here so the `lance_graph_rbac::authorize::{ClassRbac, ClassId, ActorId, RoleId}`
51+
// paths are unchanged; `authorize()` + `ClassGrants` (the kernel + reference impl)
52+
// stay in this crate.
53+
pub use lance_graph_contract::rbac::{ActorId, ClassId, ClassRbac, RoleId};
7854

7955
/// The §5 kernel — positive intersection ∧ op-gate, collapsed to the shipped
8056
/// [`AccessDecision`]. An actor is allowed iff it holds at least one role whose

crates/lance-graph-rbac/src/policy.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
use crate::access::AccessDecision;
44
use crate::role::Role;
5-
use lance_graph_contract::property::PrefetchDepth;
65

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

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

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

0 commit comments

Comments
 (0)