From fce9840fe33aa4f298d7b0ae625c04f56d4bc5b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 12:08:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(contract):=20promote=20ClassRbac=20trait?= =?UTF-8?q?=20+=20Operation=20to=20contract::rbac=20(keystone=20=C2=A711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads the contract <-> rbac <-> ogar <-> callcenter chain: ClassRbac lived in lance-graph-rbac, which lance-graph-ogar does not dep (contract only), so OgarClassView could not impl the keystone's Q5 'impl ClassRbac for OgarClassView'. §11 places the trait in contract. - NEW lance_graph_contract::rbac: ClassId/ActorId/RoleId, Operation<'a> (reads contract::property::PrefetchDepth, no rbac dep), trait ClassRbac. A contract-only impl test proves ogar can satisfy it. - lance-graph-rbac re-exports them (policy::Operation, authorize::{ClassRbac, ClassId, ActorId, RoleId} unchanged); authorize()+ClassGrants+Policy+ AccessDecision+auth stay in rbac. - Zero breakage: callcenter builds against the re-exports; sibling smb-realtime/medcare-realtime gates use AccessDecision (unmoved), untouched. - contract::rbac 2 tests + 723 contract; rbac 21; clippy -D warnings + fmt clean. Follow-on (not forced): converge rbac::auth::ResolvedIdentity onto the existing contract::auth::ActorContext; OgarClassView impl needs the §6 granted tenant. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- .claude/board/EPIPHANIES.md | 30 ++++++ .claude/board/LATEST_STATE.md | 4 + crates/lance-graph-contract/src/lib.rs | 1 + crates/lance-graph-contract/src/rbac.rs | 128 +++++++++++++++++++++++ crates/lance-graph-rbac/src/authorize.rs | 38 ++----- crates/lance-graph-rbac/src/policy.rs | 13 +-- 6 files changed, 175 insertions(+), 39 deletions(-) create mode 100644 crates/lance-graph-contract/src/rbac.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 0a0ad93c..18fb6696 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -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 diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index b4d50f05..12f284dd 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -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. diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index 0d0d0b3c..bad59293 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -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, diff --git a/crates/lance-graph-contract/src/rbac.rs b/crates/lance-graph-contract/src/rbac.rs new file mode 100644 index 00000000..639968c4 --- /dev/null +++ b/crates/lance-graph-contract/src/rbac.rs @@ -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" })); + } +} diff --git a/crates/lance-graph-rbac/src/authorize.rs b/crates/lance-graph-rbac/src/authorize.rs index 32dbd69d..ca71c972 100644 --- a/crates/lance-graph-rbac/src/authorize.rs +++ b/crates/lance-graph-rbac/src/authorize.rs @@ -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 diff --git a/crates/lance-graph-rbac/src/policy.rs b/crates/lance-graph-rbac/src/policy.rs index 5a8579a8..4fe8b6b0 100644 --- a/crates/lance-graph-rbac/src/policy.rs +++ b/crates/lance-graph-rbac/src/policy.rs @@ -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. @@ -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 {