diff --git a/crates/lance-graph-contract/src/rbac.rs b/crates/lance-graph-contract/src/rbac.rs index 639968c4a..17ae580cb 100644 --- a/crates/lance-graph-contract/src/rbac.rs +++ b/crates/lance-graph-contract/src/rbac.rs @@ -86,6 +86,114 @@ pub trait ClassRbac { fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool; } +// ───────────────────────────────────────────────────────────────────────────── +// §6 — the typed `granted` value-tenant (first-class replacement for +// `project_role.permissions: text`). +// ───────────────────────────────────────────────────────────────────────────── + +/// The verb bitmask of a class-grant — the §3 axis-1 "verb × class" gate, one +/// `u8`, palette-native (#511 `SoaMemberSpec`: a role's grants are low-tens, one +/// column). Shaped after Odoo `ir.model.access`'s `perm_{read,write,create,unlink}`. +/// +/// This is the **coarse verb gate** (§5 stage 1). It answers "may this role +/// *read / write / act on* this class at all", not the finer "at what depth / +/// which predicate / which action name" — those are the field-projection (axis 4) +/// and row-scope (axis 3) refinements that layer *above* a passed verb gate. So +/// [`OpMask::permits`] maps [`Operation::Read`] → the `READ` bit regardless of +/// depth; a depth/predicate/action-name check is a separate, finer stage. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd, Ord, Hash)] +pub struct OpMask(pub u8); + +impl OpMask { + /// May read the class (any depth). + pub const READ: OpMask = OpMask(1 << 0); + /// May write a predicate on the class. + pub const WRITE: OpMask = OpMask(1 << 1); + /// May create an instance (Odoo `perm_create`). + pub const CREATE: OpMask = OpMask(1 << 2); + /// May delete an instance (Odoo `perm_unlink`). + pub const DELETE: OpMask = OpMask(1 << 3); + /// May trigger a named action (the DO arm — `ActionDef` fire). + pub const ACT: OpMask = OpMask(1 << 4); + + /// The empty mask — grants nothing (restrictive default-deny). + pub const NONE: OpMask = OpMask(0); + + /// Union of two masks (grant composition; e.g. role-hierarchy fold). + #[inline] + #[must_use] + pub const fn union(self, other: OpMask) -> OpMask { + OpMask(self.0 | other.0) + } + + /// Whether `self` carries every bit of `bits`. + #[inline] + #[must_use] + pub const fn contains(self, bits: OpMask) -> bool { + self.0 & bits.0 == bits.0 + } + + /// Whether this mask permits `op` — the verb gate. `Read` → `READ`, + /// `Write` → `WRITE`, `Act` → `ACT` (depth / predicate / action-name are + /// finer stages, not decided here). + #[inline] + #[must_use] + pub fn permits(self, op: &Operation<'_>) -> bool { + let bit = match op { + Operation::Read { .. } => OpMask::READ, + Operation::Write { .. } => OpMask::WRITE, + Operation::Act { .. } => OpMask::ACT, + }; + self.contains(bit) + } +} + +/// One typed class-grant tuple — `(target_classid: u16, op_mask: u8)`. The +/// first-class, palette-native replacement for the `project_role.permissions: +/// text` blob (keystone §6 / I-K0 registry axiom: "decisions key on `classid`, +/// not on text"). A role's `granted` value-tenant is a `&[ClassGrant]`. +/// +/// `target_classid` is the **low `u16` codebook id** (the shared-concept half of +/// a [`NodeGuid`](crate::NodeGuid)'s `classid`) — the RBAC + ontology identity, +/// app-render-skin-independent (the hi `u16` chooses render, never grants). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd, Ord, Hash)] +pub struct ClassGrant { + /// The class this grant targets (low-`u16` codebook id). + pub target_classid: u16, + /// The verbs this grant permits on that class. + pub op_mask: OpMask, +} + +impl ClassGrant { + /// Construct a grant. + #[inline] + #[must_use] + pub const fn new(target_classid: u16, op_mask: OpMask) -> Self { + Self { + target_classid, + op_mask, + } + } + + /// Whether this grant permits `op` on `class`. Matches on the **low `u16`** + /// of `class` (the codebook id), so a grant authored against the shared + /// concept applies regardless of which app's render-skin (hi `u16`) the + /// `ClassId` carries. + #[inline] + #[must_use] + pub fn permits(&self, class: ClassId, op: &Operation<'_>) -> bool { + self.target_classid == (class as u16) && self.op_mask.permits(op) + } +} + +/// Does any grant in a role's `granted` set permit `op` on `class`? The slice +/// form of the §5 stage-1 positive op-gate — the body a typed [`ClassRbac`] impl +/// uses for `grant_permits` (restrictive default-deny: empty ⇒ `false`). +#[must_use] +pub fn grants_permit(granted: &[ClassGrant], class: ClassId, op: &Operation<'_>) -> bool { + granted.iter().any(|g| g.permits(class, op)) +} + #[cfg(test)] mod tests { use super::*; @@ -125,4 +233,84 @@ mod tests { )); assert!(!rbac.grant_permits("reader", 0x0901, &Operation::Act { action: "x" })); } + + // ── §6 typed `granted` value-tenant ── + + const PATIENT: ClassId = 0x0000_0901; + + #[test] + fn opmask_permits_the_matching_verb_only() { + let rw = OpMask::READ.union(OpMask::WRITE); + assert!(rw.permits(&Operation::Read { + depth: PrefetchDepth::Full + })); + assert!(rw.permits(&Operation::Write { predicate: "x" })); + assert!(!rw.permits(&Operation::Act { action: "approve" })); + // contains is bit-subset + assert!(rw.contains(OpMask::READ)); + assert!(!rw.contains(OpMask::ACT)); + assert_eq!(OpMask::NONE, OpMask::default()); + } + + #[test] + fn class_grant_matches_on_low_u16_codebook_id() { + let grant = ClassGrant::new(0x0901, OpMask::READ.union(OpMask::ACT)); + // Same concept, different app render-skin (hi u16) → still permitted: + // the grant keys on the shared-concept low u16, never the render half. + let app_a: ClassId = 0x0000_0901; + let app_b: ClassId = 0xAB12_0901; + let read = Operation::Read { + depth: PrefetchDepth::Identity, + }; + assert!(grant.permits(app_a, &read)); + assert!(grant.permits(app_b, &read)); + // Wrong concept → denied even with the verb. + assert!(!grant.permits(0x0000_0902, &read)); + // Right concept, ungranted verb → denied. + assert!(!grant.permits(app_a, &Operation::Write { predicate: "due" })); + } + + /// A typed [`ClassRbac`] impl whose `grant_permits` body IS [`grants_permit`] + /// over a role's `granted` value-tenant — the §6 shape end-to-end, proving the + /// typed tenant replaces `permissions: text` with contract-only types. + struct TypedRoleGrants { + // physician → {READ+ACT on PATIENT}; cashier → {READ on PATIENT} + physician: [ClassGrant; 1], + cashier: [ClassGrant; 1], + } + impl ClassRbac for TypedRoleGrants { + fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId] { + match actor { + "dr-house" => &["physician"], + "betty" => &["cashier"], + _ => &[], + } + } + fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool { + let granted: &[ClassGrant] = match role { + "physician" => &self.physician, + "cashier" => &self.cashier, + _ => &[], + }; + grants_permit(granted, class, op) + } + } + + #[test] + fn typed_granted_drives_grant_permits() { + let rbac = TypedRoleGrants { + physician: [ClassGrant::new(0x0901, OpMask::READ.union(OpMask::ACT))], + cashier: [ClassGrant::new(0x0901, OpMask::READ)], + }; + let act = Operation::Act { action: "approve" }; + // physician may act; cashier may not — restrictive default-deny. + assert!(rbac.grant_permits("physician", PATIENT, &act)); + assert!(!rbac.grant_permits("cashier", PATIENT, &act)); + // both may read + let read = Operation::Read { + depth: PrefetchDepth::Identity, + }; + assert!(rbac.grant_permits("physician", PATIENT, &read)); + assert!(rbac.grant_permits("cashier", PATIENT, &read)); + } } diff --git a/crates/lance-graph-ogar/src/actions.rs b/crates/lance-graph-ogar/src/actions.rs new file mode 100644 index 000000000..daef168c7 --- /dev/null +++ b/crates/lance-graph-ogar/src/actions.rs @@ -0,0 +1,279 @@ +//! `actions` — the OGAR **DO-arm provider**: per-class [`ActionDef`] manifests +//! keyed by classid, with **RBAC hardcoded into the class** (the Türsteher). +//! +//! # Why the provider, and why RBAC is `const` here +//! +//! OGAR is the Active-Record Core: a `Class` carries its own behaviour +//! ([`HIRO-IN-CLASSES.md`](../../../../OGAR/docs/HIRO-IN-CLASSES.md)). The DO arm +//! of that behaviour is a `const` table of [`ActionDef`]s per class — the +//! action-axis sibling of the THINK-arm `ClassView`. [`OgarActionProvider`] is +//! the lance-graph-side registry of those tables, resolved exactly like the +//! field-set: `classid → actions`, inheritance-aware via +//! [`contract::action::effective_actions`]. +//! +//! The pin (operator, "OGAR der Türsteher mit Köpfchen"): each action's +//! [`required_role`](ActionDef::required_role) is a **compile-time `const` on the +//! class manifest**, not a runtime policy row. RBAC is *baked into the structure* +//! — a compliance reviewer reads the class's grant surface off the source, and an +//! action with no role is structurally `None` (audited), never an accidental open +//! door. Containment is by **structure**, not by trust: whatever cognition sits +//! *above* (the Rung-1-9 Flughöhe in the hot path) cannot widen a class's DO +//! surface, because the surface is fixed in the Core and the +//! [`commit`](contract::action::ActionInvocation::commit) gate reads +//! `required_role` from *here*, not from the caller. "Ogar kriegt sie alle." +//! +//! # The cold-path gate this feeds +//! +//! These manifests are the `def` half of the cold path: a consumer resolves +//! `actions_for(classid)`, matches an [`ActionInvocation`] to its [`ActionDef`] by +//! `predicate`, then [`commit`](contract::action::ActionInvocation::commit)s — +//! which enforces `required_role` (RBAC) → the [`StateGuard`] (Libet do/don't) → +//! the MUL [`GateDecision`](contract::mul::GateDecision) (the Rubicon crossing). +//! The kgV executor that runs the committed action is `graph-flow-action`'s +//! `ActionHandler` in rs-graph-llm; the outer cycle envelope is +//! `graph-flow-kanban`. This crate supplies the *authorized DO surface* both sit +//! over. + +use contract::action::{actions_for, effective_actions, ActionDef, ClassActions, StateGuard}; +use contract::kanban::ExecTarget; +use lance_graph_contract as contract; + +// ── Role identities, hardcoded into the manifests below (the Türsteher's keys). +// These are the `required_role` literals the cold-path `commit` checks against an +// `ActorContext`; an OIDC `auth_store` profile (§7) resolves token claims to them. +const ROLE_AUTH_USER: &str = "auth_user"; +const ROLE_AUTH_ADMIN: &str = "auth_admin"; + +/// `auth_store` (`0x0B01`) DO surface — the base auth membrane's actions, RBAC +/// baked in. The most on-point "RBAC hardcoded in OGAR" example: the class that +/// *does* authorization carries its own authorization on every mutating action. +const AUTH_STORE_ACTIONS: &[ActionDef] = &[ + // Mint a session token — any authenticated principal may. + ActionDef { + predicate: "issue_token", + object_class: AUTH_STORE_CID, + exec: ExecTarget::Native, + guard: None, + required_role: Some(ROLE_AUTH_USER), + overrides: None, + }, + // Revoke a token — admin only. + ActionDef { + predicate: "revoke_token", + object_class: AUTH_STORE_CID, + exec: ExecTarget::Native, + guard: None, + required_role: Some(ROLE_AUTH_ADMIN), + overrides: None, + }, + // Rotate the signing secret — admin only, and only while the store is active + // (Libet do/don't state guard: never rotate a suspended store). + ActionDef { + predicate: "rotate_secret", + object_class: AUTH_STORE_CID, + exec: ExecTarget::Native, + guard: Some(StateGuard { + field: "status", + value: "active", + }), + required_role: Some(ROLE_AUTH_ADMIN), + overrides: None, + }, +]; + +/// `auth_zitadel` (`0x0B02`) net-new DO surface. `auth_zitadel` **is-a** +/// `auth_store` (§7 provider profile), so its *effective* actions are the base's +/// plus these — and `issue_token` is **overridden** to run the Zitadel +/// org-role-aware path (an Elixir low-code template; the JITson ↔ Elixir ↔ +/// SurrealQL exec seam). Same `required_role` is restated to keep the override's +/// grant surface explicit (an override must not silently widen access). +const AUTH_ZITADEL_ACTIONS: &[ActionDef] = &[ + ActionDef { + predicate: "issue_token", + object_class: AUTH_ZITADEL_CID, + exec: ExecTarget::Elixir, + guard: None, + required_role: Some(ROLE_AUTH_USER), + overrides: Some("auth_store::issue_token"), + }, + // Sync org→role grants from Zitadel — admin only, low-code template. + ActionDef { + predicate: "sync_org_roles", + object_class: AUTH_ZITADEL_CID, + exec: ExecTarget::Elixir, + guard: None, + required_role: Some(ROLE_AUTH_ADMIN), + overrides: None, + }, +]; + +// The auth-family codebook ids (keystone §7 `0x0B` core domain). Written as +// literals — NOT `ogar_vocab::class_ids::AUTH_STORE` — so this DO-arm provider is +// strictly `lance_graph_contract`-dependent and does not couple to whichever +// `ogar-vocab` git ref this crate pins (the action manifest is a contract-shaped +// artifact, exactly as `contract::action::ClassActions` documents — "generated +// downstream; the Core provides the type"). They MUST equal +// `ogar_vocab::class_ids::{AUTH_STORE, AUTH_ZITADEL}`; the lib's `parity` guard is +// what binds the codebook itself. +const AUTH_STORE_CID: u32 = 0x0000_0B01; +const AUTH_ZITADEL_CID: u32 = 0x0000_0B02; + +/// The registry: one [`ClassActions`] row per class with a DO surface. Seeded +/// with the auth family (the worked hardcoded-RBAC example); other domains append +/// their `const` tables here as their harvests land. +const REGISTRY: &[ClassActions] = &[ + ClassActions { + classid: AUTH_STORE_CID, + actions: AUTH_STORE_ACTIONS, + }, + ClassActions { + classid: AUTH_ZITADEL_CID, + actions: AUTH_ZITADEL_ACTIONS, + }, +]; + +/// The lance-graph-side OGAR DO-arm provider — resolves a classid to its +/// authorized action manifest. Zero-state: it wraps the `const` [`REGISTRY`]; the +/// "provider" name marks it as the lookup surface a consumer holds (mirrors how +/// `OgarClassView` is the THINK-arm projection surface). +#[derive(Debug, Clone, Copy, Default)] +pub struct OgarActionProvider; + +impl OgarActionProvider { + /// Construct the provider (the registry is `const`, so this is zero-cost). + #[must_use] + pub const fn new() -> Self { + Self + } + + /// The full action registry. + #[must_use] + pub const fn registry(&self) -> &'static [ClassActions] { + REGISTRY + } + + /// A class's **own** actions (not its parents'). Zero-fallback: an + /// unregistered classid resolves to `&[]`, never a panic. + #[must_use] + pub fn actions_for(&self, classid: u32) -> &'static [ActionDef] { + actions_for(REGISTRY, classid) + } + + /// A class's **effective** DO surface — its own actions composed with its + /// OGAR parent's (`is-a`), child overrides applied. For `auth_zitadel` this + /// is the `auth_store` base + the Zitadel net-new, with `issue_token` + /// overridden. A root class (no parent) returns its own actions unchanged. + #[must_use] + pub fn effective_actions(&self, classid: u32) -> Vec { + match self.parent_of(classid) { + Some(parent) => { + let mut actions = + effective_actions(self.actions_for(parent), self.actions_for(classid)); + // Rebind every effective action to THIS class. Inherited parent + // actions carry the parent's `object_class`; left unchanged, the + // `commit` def-match (`def.object_class == inv.object_class`, + // contract::action) would reject them for a child instance BEFORE + // RBAC — the inherited action would be advertised but uncommittable + // for the child. The child's own actions already carry `classid`, + // so the rebind is idempotent for them. + for a in &mut actions { + a.object_class = classid; + } + actions + } + None => self.actions_for(classid).to_vec(), + } + } + + /// The OGAR `is-a` parent of a class for DO-arm inheritance. The auth provider + /// profiles (§7) are-a `auth_store`; everything else is a root here until its + /// `class_ancestors` edge is wired. (Kept local + explicit so the inheritance + /// the DO arm uses is auditable, not implicit.) + #[must_use] + fn parent_of(&self, classid: u32) -> Option { + match classid { + AUTH_ZITADEL_CID => Some(AUTH_STORE_CID), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_store_surface_has_hardcoded_rbac_on_every_mutating_action() { + let p = OgarActionProvider::new(); + let acts = p.actions_for(AUTH_STORE_CID); + assert_eq!(acts.len(), 3); + // Every action carries a required_role — RBAC is baked into the class, + // there is no roleless mutating action (the Türsteher invariant). + assert!(acts.iter().all(|a| a.required_role.is_some())); + // issue_token is auth_user; revoke/rotate are admin. + let by = |name: &str| acts.iter().find(|a| a.predicate == name).unwrap(); + assert_eq!(by("issue_token").required_role, Some(ROLE_AUTH_USER)); + assert_eq!(by("revoke_token").required_role, Some(ROLE_AUTH_ADMIN)); + assert_eq!(by("rotate_secret").required_role, Some(ROLE_AUTH_ADMIN)); + // rotate_secret guards on status==active (Libet do/don't). + assert_eq!( + by("rotate_secret").guard, + Some(StateGuard { + field: "status", + value: "active" + }) + ); + } + + #[test] + fn zitadel_inherits_auth_store_and_overrides_issue_token() { + let p = OgarActionProvider::new(); + let eff = p.effective_actions(AUTH_ZITADEL_CID); + // base(3) ∪ net-new(sync_org_roles); issue_token overridden, not doubled. + let names: Vec<&str> = eff.iter().map(|a| a.predicate).collect(); + assert!(names.contains(&"issue_token")); + assert!(names.contains(&"revoke_token")); // inherited, unchanged + assert!(names.contains(&"rotate_secret")); // inherited, unchanged + assert!(names.contains(&"sync_org_roles")); // net-new + assert_eq!( + names.iter().filter(|n| **n == "issue_token").count(), + 1, + "override must replace, not duplicate" + ); + // The override is the Zitadel Elixir-low-code path, and it did NOT widen + // the grant (still auth_user, never silently elevated). + let issue = eff.iter().find(|a| a.predicate == "issue_token").unwrap(); + assert_eq!(issue.exec, ExecTarget::Elixir); + assert_eq!(issue.required_role, Some(ROLE_AUTH_USER)); + assert!(issue.is_override()); + // Every effective action — inherited ones included — must carry the + // CHILD classid, else commit's def-match rejects it before RBAC. + assert!( + eff.iter().all(|a| a.object_class == AUTH_ZITADEL_CID), + "effective child actions must commit against the child classid" + ); + } + + #[test] + fn unregistered_classid_is_zero_fallback_empty() { + let p = OgarActionProvider::new(); + assert!(p.actions_for(0xDEAD_BEEF).is_empty()); + assert!(p.effective_actions(0xDEAD_BEEF).is_empty()); + } + + #[test] + fn exec_targets_span_the_jitson_elixir_seam() { + // The DO surface carries the exec target through to the kanban/handler + // layer untouched: native for the base store, Elixir low-code for the + // Zitadel provider profile. + let p = OgarActionProvider::new(); + assert!(p + .actions_for(AUTH_STORE_CID) + .iter() + .all(|a| a.exec == ExecTarget::Native)); + assert!(p + .actions_for(AUTH_ZITADEL_CID) + .iter() + .all(|a| a.exec == ExecTarget::Elixir)); + } +} diff --git a/crates/lance-graph-ogar/src/lib.rs b/crates/lance-graph-ogar/src/lib.rs index 0a3f3735b..87be866bd 100644 --- a/crates/lance-graph-ogar/src/lib.rs +++ b/crates/lance-graph-ogar/src/lib.rs @@ -82,6 +82,11 @@ pub use ogar_vocab::Class; // which is OGIT and must not depend on ogar-vocab) ── pub mod bridges; +// ── OGAR DO-arm provider: per-class ActionDef manifests with RBAC hardcoded +// into the class (the Türsteher). The action-axis sibling of OgarClassView. ── +pub mod actions; +pub use actions::OgarActionProvider; + // Per-port bridge aliases (`MedcareBridge` / `OpenProjectBridge` / // `RedmineBridge` / `OdooBridge` / `SmbBridge` / `WoaBridge`) are // `#[deprecated]` (2026-06-22) — pull the classid via the corresponding diff --git a/crates/lance-graph-planner/src/elevation/mod.rs b/crates/lance-graph-planner/src/elevation/mod.rs index a6673cafe..74450d3c0 100644 --- a/crates/lance-graph-planner/src/elevation/mod.rs +++ b/crates/lance-graph-planner/src/elevation/mod.rs @@ -21,6 +21,7 @@ pub mod homeostasis; pub mod learning; pub mod operator; +use lance_graph_contract::cognitive_shader::RungLevel; use std::time::{Duration, Instant}; /// Execution level in the elevation stack. @@ -87,6 +88,41 @@ impl ElevationLevel { Self::IvfBatch, Self::Async, ]; + + /// The **baseline** elevation a semantic [`RungLevel`] (the cognition-depth + /// "Flughöhe", 0..9) implies — the calibration between the two ladders. + /// + /// Two distinct axes meet here: the **Rung** is *how deep the thinking goes* + /// (`Surface` … `Transcendent`, the hot-path cognition altitude); the + /// **ElevationLevel** is *how much execution machinery the search spends* + /// (`Point` … `Async`). Deeper cognition needs more execution headroom, so the + /// map is **monotone non-decreasing** — but it is a *floor of ambition*, not a + /// fixed cost. The Csíkszentmihályi **Flow channel** tunes the actual spend + /// around this floor: [`homeostasis`] raises the patience budget under + /// `Boredom` (skill ≫ challenge → go deeper than the rung's floor) and cuts it + /// under `Anxiety` (challenge ≫ skill → cap below it). So `from_rung` sets + /// where the search *starts*; `should_elevate` + the flow-modulated budget + /// decide where it *ends*. + /// + /// | Rung (depth) | → Elevation (cost floor) | why | + /// |---|---|---| + /// | `Surface` / `Shallow` (0–1) | `Point` | literal / hot-path lookup | + /// | `Contextual` / `Analogical` (2–3) | `Scan` | local neighborhood | + /// | `Abstract` / `Structural` (4–5) | `Cascade` | CAM-PQ multi-stroke | + /// | `Counterfactual` (6) | `Batch` | scenario forks need a morsel pipeline | + /// | `Meta` (7) | `IvfBatch` | meta-search partitions the space | + /// | `Recursive` / `Transcendent` (8–9) | `Async` | unbounded depth → fire-and-forget | + #[must_use] + pub fn from_rung(rung: RungLevel) -> Self { + match rung { + RungLevel::Surface | RungLevel::Shallow => Self::Point, + RungLevel::Contextual | RungLevel::Analogical => Self::Scan, + RungLevel::Abstract | RungLevel::Structural => Self::Cascade, + RungLevel::Counterfactual => Self::Batch, + RungLevel::Meta => Self::IvfBatch, + RungLevel::Recursive | RungLevel::Transcendent => Self::Async, + } + } } impl std::fmt::Display for ElevationLevel { @@ -154,6 +190,44 @@ mod tests { assert_eq!(ElevationLevel::Batch.prev(), ElevationLevel::Cascade); } + #[test] + fn from_rung_is_monotone_and_spans_the_ladder() { + // The Rung→Elevation calibration: deeper cognition ⇒ never-lower cost + // floor, and the two endpoints map to the two ladder ends. + let rungs = [ + RungLevel::Surface, + RungLevel::Shallow, + RungLevel::Contextual, + RungLevel::Analogical, + RungLevel::Abstract, + RungLevel::Structural, + RungLevel::Counterfactual, + RungLevel::Meta, + RungLevel::Recursive, + RungLevel::Transcendent, + ]; + let mut prev = ElevationLevel::Point; + for r in rungs { + let e = ElevationLevel::from_rung(r); + assert!(e >= prev, "from_rung must be monotone non-decreasing"); + prev = e; + } + // Endpoints: Surface floors at the hot path, Transcendent at fire-and-forget. + assert_eq!( + ElevationLevel::from_rung(RungLevel::Surface), + ElevationLevel::Point + ); + assert_eq!( + ElevationLevel::from_rung(RungLevel::Transcendent), + ElevationLevel::Async + ); + // The counterfactual rung is the scenario-fork threshold → Batch. + assert_eq!( + ElevationLevel::from_rung(RungLevel::Counterfactual), + ElevationLevel::Batch + ); + } + #[test] fn test_should_elevate_latency() { let budget = budget::PatienceBudget { diff --git a/crates/lance-graph-rbac/src/lib.rs b/crates/lance-graph-rbac/src/lib.rs index f9d6b606c..d2fd2c2e5 100644 --- a/crates/lance-graph-rbac/src/lib.rs +++ b/crates/lance-graph-rbac/src/lib.rs @@ -13,3 +13,12 @@ pub mod authorize; pub mod permission; pub mod policy; pub mod role; + +/// The §6 typed `granted` value-tenant surface, re-exported from the zero-dep +/// contract so kernel consumers reach it through `lance_graph_rbac` without an +/// extra import: [`ClassGrant`] `(target_classid, op_mask)` is the first-class +/// replacement for `project_role.permissions: text`, [`OpMask`] is its verb gate, +/// and [`grants_permit`] is the §5 stage-1 positive op-gate over a role's grant +/// slice. The richer [`permission::PermissionSpec`] (depth/predicate/action-name) +/// is the finer stage that layers above a passed verb gate. +pub use lance_graph_contract::rbac::{grants_permit, ClassGrant, OpMask};