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
14 changes: 14 additions & 0 deletions .claude/board/INTEGRATION_PLANS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
## 2026-06-23 — integration-actionhandler-rbac-orchestration-v1 (PLAN; shipped)

Plan: `.claude/plans/integration-actionhandler-rbac-orchestration-v1.md`. The
ActionHandler⟷RBAC⟷orchestration spine, 5+3-hardened then built one-Sonnet-agent-
per-file (commented drafts → Opus uncomment+reconcile+central compile). Six files:
F1 `contract::rbac` §4 trait (`roles_reaching`/`row_scope`/`field_mask` defaults +
`ScopeSpec`), F2 `rbac::authorize_scoped`+`ScopedDecision` (§5 two-stage), F3
`ActionInvocation::commit_via` (ClassRbac convergence of the inline gate), F4
`lance-graph-ogar::OgarRbac<S: GrantSource>` (Q5 as a local newtype — orphan-safe +
§6 evaporation seam), F5 `graph-flow-kanban::run_cycle` (end-to-end spine), F6
`graph-flow-action::dispatch_via` (executor-side convergence). DEFERRED (OGAR repo):
MARS class mint + `gen_statem→ActionDef` lift (the three `ogar-from-elixir` todo!()s).
contract 735 / rbac 23 / ogar 3 / kanban 12 / action 11 green; clippy+fmt clean.

## 2026-06-21 — capstone-out-leg-wiring-v1 (PLAN)

Plan: `.claude/plans/capstone-out-leg-wiring-v1.md`. The file-level execution
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`) — ActionHandler⟷RBAC⟷orchestration spine

`contract::rbac`: `ScopeSpec` (axis-3 Copy token) + `ClassRbac` §4 default methods (`roles_reaching`/`row_scope`/`field_mask`; backward-compat, probe green). `contract::class_view::FieldMask::union`. `contract::action::ActionInvocation::commit_via<R: ClassRbac>` (no-admin-bypass convergence of the inline gate). `lance-graph-rbac::{authorize_scoped, ScopedDecision}` (§5 two-stage). `lance-graph-ogar::{OgarRbac<S: GrantSource>, GrantSource}` (Q5 local newtype, §6 evaporation seam). rs-graph-llm: `graph-flow-kanban::{run_cycle, CycleOutcome}` + `graph-flow-action::dispatch_via`. Plan: integration-actionhandler-rbac-orchestration-v1.

## 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.
Expand Down
257 changes: 257 additions & 0 deletions .claude/plans/integration-actionhandler-rbac-orchestration-v1.md

Large diffs are not rendered by default.

267 changes: 267 additions & 0 deletions crates/lance-graph-contract/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,273 @@ impl ActionInvocation {
}
}

use crate::rbac::{ActorId, ClassRbac, Operation};

impl ActionInvocation {
/// ClassRbac convergence of [`ActionInvocation::commit`]'s inline RBAC gate.
///
/// Identical lifecycle order to `commit` (sticky-terminal → def-match → RBAC →
/// state guard → MUL impact), but RBAC is resolved through the [`ClassRbac`]
/// trait instead of an [`crate::auth::ActorContext`] value.
///
/// **No `is_admin` bypass**: unlike `commit`, this method does NOT grant
/// unconditional access to admins. An actor claiming administrative privilege
/// must hold a role whose grant explicitly permits
/// `Operation::Act { action: def.predicate }` on `def.object_class`. This is
/// deliberate: the `ClassRbac` surface is the authoritative grant registry;
/// out-of-band bypass is not modelled here.
pub fn commit_via<R: ClassRbac>(
&mut self,
def: &ActionDef,
rbac: &R,
actor_id: ActorId<'_>,
impact: &GateDecision,
guard_field_value: Option<&str>,
now_millis: u64,
) -> ActionState {
// Sticky terminal — already adjudicated, not re-adjudicated.
if self.state.is_terminal() {
return self.state;
}
// Def-match: the def MUST identify THIS invocation before any policy check.
// Applying an unrelated def's required_role would authorize via the wrong policy.
if def.object_class != self.object_class || def.predicate != self.predicate {
self.state = ActionState::Failed;
return self.state;
}
// RBAC — resolve `ok` to bool BEFORE mutating `self.state` (borrow hygiene).
if let Some(required_role) = def.required_role {
let ok = rbac.actor_roles(actor_id).iter().any(|&r| {
r == required_role
&& rbac.grant_permits(
r,
def.object_class,
&Operation::Act {
action: def.predicate,
},
)
});
if !ok {
self.state = ActionState::Failed;
return self.state;
}
}
// State guard — fire only when `field == value` (P2).
// An unsatisfied or unknown guarded state refuses the fire (Cancelled, not Failed).
if let Some(g) = def.guard {
if guard_field_value != Some(g.value) {
self.state = ActionState::Cancelled;
return self.state;
}
}
// MUL impact assessment — mirrors `commit` exactly.
self.state = match impact {
GateDecision::Flow => {
self.emitted_at_millis = Some(now_millis);
ActionState::Committed
}
GateDecision::Hold { .. } => ActionState::Pending,
GateDecision::Block { .. } => ActionState::Cancelled,
};
self.state
}
}

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

// Minimal ClassRbac test double: a fixed grant table.
struct TestRbac {
// triples of (role, object_class, action_predicate) that are permitted
grants: Vec<(&'static str, u32, &'static str)>,
// pairs of actor_id -> roles
actor_roles_map: Vec<(&'static str, Vec<&'static str>)>,
}

impl ClassRbac for TestRbac {
fn actor_roles<'a>(&'a self, actor_id: ActorId<'_>) -> &'a [&'static str] {
for (id, roles) in &self.actor_roles_map {
if *id == actor_id {
return roles.as_slice();
}
}
&[]
}
fn grant_permits(&self, role: &'static str, object_class: u32, op: &Operation) -> bool {
match op {
Operation::Act { action } => self
.grants
.iter()
.any(|&(r, c, a)| r == role && c == object_class && a == *action),
_ => false,
}
}
}

fn inst(identity: u32) -> NodeGuid {
NodeGuid::new(0x0A1E_0001, 0, 0, 0, 0, identity)
}

const DEF_WITH_ROLE: ActionDef = ActionDef {
predicate: "action_confirm",
object_class: 0x0A1E_0001,
exec: ExecTarget::SurrealQl,
guard: Some(StateGuard {
field: "state",
value: "draft",
}),
required_role: Some("sales_manager"),
overrides: None,
};

const DEF_NO_ROLE: ActionDef = ActionDef {
predicate: "action_cancel",
object_class: 0x0A1E_0001,
exec: ExecTarget::SurrealQl,
guard: None,
required_role: None,
overrides: None,
};

fn rbac_granting(role: &'static str) -> TestRbac {
TestRbac {
grants: vec![(role, 0x0A1E_0001, "action_confirm")],
actor_roles_map: vec![("u1", vec![role])],
}
}

fn rbac_denying() -> TestRbac {
TestRbac {
grants: vec![("sales_manager", 0x0A1E_0001, "action_confirm")],
actor_roles_map: vec![("u1", vec!["viewer"])],
}
}

fn actor(id: &'static str) -> ActorId<'static> {
id
}

/// Authorized actor (holds a role whose grant permits Act) + guard satisfied + Flow
/// → Committed, emitted_at_millis stamped. Terminal sticky on re-call.
#[test]
fn commit_via_authorized_flow_commits() {
let rbac = rbac_granting("sales_manager");
let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(1), 5, 1, 1);
let state = inv.commit_via(
&DEF_WITH_ROLE,
&rbac,
actor("u1"),
&GateDecision::Flow,
Some("draft"),
2000,
);
assert_eq!(state, ActionState::Committed);
assert_eq!(inv.emitted_at_millis, Some(2000));
// sticky: re-adjudication is a no-op even with Block
let state2 = inv.commit_via(
&DEF_WITH_ROLE,
&rbac,
actor("u1"),
&GateDecision::Block {
reason: "x".to_string(),
},
Some("draft"),
3000,
);
assert_eq!(state2, ActionState::Committed);
assert_eq!(
inv.emitted_at_millis,
Some(2000),
"stamp must not be overwritten on sticky re-call"
);
}

/// Ungranted actor (role present but grant does not permit Act) → Failed before guard/MUL.
#[test]
fn commit_via_ungranted_actor_fails() {
let rbac = rbac_denying();
let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(2), 5, 2, 2);
let state = inv.commit_via(
&DEF_WITH_ROLE,
&rbac,
actor("u1"),
&GateDecision::Flow,
Some("draft"),
2000,
);
assert_eq!(state, ActionState::Failed);
assert_eq!(inv.emitted_at_millis, None, "failed action must not emit");
}

/// required_role: None → proceeds regardless of rbac content (parity with commit).
/// No role check means even an actor with no roles can proceed to MUL/guard.
#[test]
fn commit_via_no_required_role_proceeds_regardless_of_rbac() {
let rbac = rbac_denying(); // rbac grants nothing useful for this actor
let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_cancel", inst(3), 5, 3, 3);
// DEF_NO_ROLE has no guard and no required_role → reaches MUL directly
let state = inv.commit_via(
&DEF_NO_ROLE,
&rbac,
actor("u1"),
&GateDecision::Flow,
None,
1000,
);
assert_eq!(
state,
ActionState::Committed,
"no required_role means rbac is not consulted"
);
}

/// Admin-as-role: an actor explicitly granted "admin" role whose grant permits Act passes.
/// Bare `is_admin` does NOT bypass — there is no `ActorContext` here, no backdoor.
#[test]
fn commit_via_admin_must_be_granted_role_not_bypass() {
// Actor "admin_user" is explicitly granted "admin" role with the required permission.
let rbac = TestRbac {
grants: vec![("admin", 0x0A1E_0001, "action_confirm")],
actor_roles_map: vec![("admin_user", vec!["admin"])],
};
let admin_def = ActionDef {
required_role: Some("admin"),
..DEF_WITH_ROLE
};
let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(4), 5, 4, 4);
let state = inv.commit_via(
&admin_def,
&rbac,
actor("admin_user"),
&GateDecision::Flow,
Some("draft"),
1000,
);
assert_eq!(state, ActionState::Committed, "admin-as-granted-role works");

// Actor whose id is literally "admin" but holds a role with no grant → fails (no bypass).
let rbac_no_grants = TestRbac {
grants: vec![],
actor_roles_map: vec![("admin", vec!["admin"])],
};
let mut inv2 = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(5), 5, 5, 5);
let state2 = inv2.commit_via(
&DEF_WITH_ROLE,
&rbac_no_grants,
actor("admin"),
&GateDecision::Flow,
Some("draft"),
1000,
);
assert_eq!(
state2,
ActionState::Failed,
"no grant → fails; bare is_admin does not bypass"
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
9 changes: 9 additions & 0 deletions crates/lance-graph-contract/src/class_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ impl FieldMask {
Self(self.0 & other.0)
}

/// Bitwise union — the field positions present in EITHER mask. The fold an
/// RBAC kernel uses to combine the projections a user's several granting
/// roles each permit (a user sees the union of the columns any of their
/// roles may see).
#[inline]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}

/// Do the two masks share NO field position? RBAC uses this to assert
/// two roles project **distinct** views of the same class — e.g. a
/// research projection must be disjoint from the identifier fields
Expand Down
Loading
Loading