From 0dc90b9db79109cb67624ad4052737db7bc44efb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 15:44:15 +0000 Subject: [PATCH] feat(orchestrate): run_cycle spine (F5) + dispatch_via (F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rs-graph-llm half of the ActionHandler<>RBAC<>orchestration spine (depends on lance-graph commit_via/ClassRbac, F1-F3). F5 graph-flow-kanban::run_cycle + CycleOutcome — the end-to-end spine: resolve an ActionDef by (classid,predicate) from a provided manifest, drive the existing KanbanPlanEnvelope Planning->CognitiveWork->Evaluation (deterministic Flow progression), commit_via at the decision point (the real MUL gate), map ActionState onto the terminal column (Committed->Commit, Pending->Plan, Cancelled/Failed->Prune). Generic over a provided manifest + ClassRbac (kgV: no thinking-style/provider/SoA import). Unknown predicate -> Prune (no unwrap). F6 graph-flow-action::dispatch_via — the executor-side convergence: mirrors dispatch but routes the cold floor through commit_via (ClassRbac) instead of commit (ActorContext), closing the 'executor still on the old RBAC surface' half of the gap. dispatch untouched (siblings, not nested). Tests: kanban 12 (incl 4 run_cycle), action 11 (incl 5 dispatch_via). clippy+fmt clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- graph-flow-action/src/lib.rs | 198 +++++++++++++++++++++++++++ graph-flow-kanban/src/lib.rs | 2 + graph-flow-kanban/src/orchestrate.rs | 197 ++++++++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 graph-flow-kanban/src/orchestrate.rs diff --git a/graph-flow-action/src/lib.rs b/graph-flow-action/src/lib.rs index af8710f..a42848d 100644 --- a/graph-flow-action/src/lib.rs +++ b/graph-flow-action/src/lib.rs @@ -293,3 +293,201 @@ mod tests { assert_eq!(i.state, ActionState::Pending); // untouched — never committed } } + +use lance_graph_contract::rbac::{ActorId, ClassRbac}; + +/// Run an action through **routing → cold floor (ClassRbac path) → hot path**. +/// +/// The ClassRbac-driven sibling of [`dispatch`]: identical three-layer structure but +/// the cold floor is [`ActionInvocation::commit_via`] — which resolves RBAC through +/// the grant table (`ClassRbac::actor_roles` → `ClassRbac::grant_permits`) rather +/// than the `ActorContext` role-set. The admin must hold an explicit granted role on +/// the action's class; no `ActorContext` fallback here. +/// +/// 1. **Routing**: `capability` ∧ `applicability`. False ⇒ [`HandlerOutcome::NotApplicable`]. +/// 2. **Cold floor** ([`ActionInvocation::commit_via`]): def-match → ClassRbac grant → +/// state guard → MUL impact. Returns [`ActionState`]. +/// 3. **Hot path**: only on `Committed` do we call [`ActionHandler::handle`]. +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn dispatch_via( + handler: &H, + rbac: &R, + actor_id: ActorId<'_>, + gate: &GateDecision, + action: &ActionDef, + inv: &mut ActionInvocation, + guard_field_value: Option<&str>, + now_millis: u64, +) -> HandlerOutcome { + if !handler.capability(action) || !handler.applicability(inv) { + return HandlerOutcome::NotApplicable; + } + match inv.commit_via(action, rbac, actor_id, gate, guard_field_value, now_millis) { + ActionState::Committed => handler.handle(action, inv), + ActionState::Pending => HandlerOutcome::Postponed, + ActionState::Cancelled => HandlerOutcome::Escalated, + ActionState::Failed => HandlerOutcome::Denied, + } +} + +#[cfg(test)] +mod dispatch_via_tests { + use super::*; + use lance_graph_contract::action::StateGuard; + use lance_graph_contract::canonical_node::NodeGuid; + use lance_graph_contract::kanban::ExecTarget; + use lance_graph_contract::rbac::{ActorId, ClassId, ClassRbac, Operation, RoleId}; + + const PATIENT: u32 = 0x0901; + + struct EchoHandler { + can: bool, + } + impl ActionHandler for EchoHandler { + fn configuration(&self) -> u32 { + 0x0B01 + } + fn capability(&self, _a: &ActionDef) -> bool { + self.can + } + fn applicability(&self, _inv: &ActionInvocation) -> bool { + true + } + fn handle(&self, _a: &ActionDef, _inv: &mut ActionInvocation) -> HandlerOutcome { + HandlerOutcome::Done + } + } + + /// Minimal ClassRbac fixture: "dr-house" → physician → ACT on PATIENT. + struct FixtureRbac; + impl ClassRbac for FixtureRbac { + fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId] { + match actor { + "dr-house" => &["physician"], + _ => &[], + } + } + fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool { + role == "physician" + && class as u16 == PATIENT as u16 + && matches!(op, Operation::Act { .. }) + } + } + + fn def(required_role: Option<&'static str>, guard: Option) -> ActionDef { + ActionDef { + predicate: "approve", + object_class: PATIENT, + exec: ExecTarget::Native, + guard, + required_role, + overrides: None, + } + } + + fn inv() -> ActionInvocation { + ActionInvocation::pending( + PATIENT, + "approve", + NodeGuid::new(PATIENT, 0, 0, 0, 0, 0), + 1, + 0, + 0, + ) + } + + #[test] + fn via_authorized_flow_commits_and_handles() { + let h = EchoHandler { can: true }; + let mut i = inv(); + let out = dispatch_via( + &h, + &FixtureRbac, + "dr-house", + &GateDecision::Flow, + &def(Some("physician"), None), + &mut i, + None, + 1000, + ); + assert_eq!(out, HandlerOutcome::Done); + assert_eq!(i.state, ActionState::Committed); + } + + #[test] + fn via_ungranted_actor_denied() { + let h = EchoHandler { can: true }; + let mut i = inv(); + let out = dispatch_via( + &h, + &FixtureRbac, + "unknown-user", // not in fixture → no roles → no grant + &GateDecision::Flow, + &def(Some("physician"), None), + &mut i, + None, + 1000, + ); + assert_eq!(out, HandlerOutcome::Denied); + assert_eq!(i.state, ActionState::Failed); + } + + #[test] + fn via_mul_hold_postpones() { + let h = EchoHandler { can: true }; + let mut i = inv(); + let out = dispatch_via( + &h, + &FixtureRbac, + "dr-house", + &GateDecision::Hold { + reason: "uncertain".into(), + }, + &def(None, None), + &mut i, + None, + 1000, + ); + assert_eq!(out, HandlerOutcome::Postponed); + assert_eq!(i.state, ActionState::Pending); + } + + #[test] + fn via_mul_block_escalates() { + let h = EchoHandler { can: true }; + let mut i = inv(); + let out = dispatch_via( + &h, + &FixtureRbac, + "dr-house", + &GateDecision::Block { + reason: "veto".into(), + }, + &def(None, None), + &mut i, + None, + 1000, + ); + assert_eq!(out, HandlerOutcome::Escalated); + assert_eq!(i.state, ActionState::Cancelled); + } + + #[test] + fn via_not_applicable_never_consults_cold_path() { + let h = EchoHandler { can: false }; + let mut i = inv(); + let out = dispatch_via( + &h, + &FixtureRbac, + "dr-house", + &GateDecision::Flow, + &def(None, None), + &mut i, + None, + 1000, + ); + assert_eq!(out, HandlerOutcome::NotApplicable); + assert_eq!(i.state, ActionState::Pending); // untouched + } +} diff --git a/graph-flow-kanban/src/lib.rs b/graph-flow-kanban/src/lib.rs index af11df8..9ea1935 100644 --- a/graph-flow-kanban/src/lib.rs +++ b/graph-flow-kanban/src/lib.rs @@ -48,6 +48,8 @@ #![forbid(unsafe_code)] +pub mod orchestrate; + use lance_graph_contract::collapse_gate::MailboxId; use lance_graph_contract::kanban::{ExecTarget, KanbanColumn, KanbanMove, RubiconTransitionError}; use lance_graph_contract::mul::GateDecision; diff --git a/graph-flow-kanban/src/orchestrate.rs b/graph-flow-kanban/src/orchestrate.rs new file mode 100644 index 0000000..6befce6 --- /dev/null +++ b/graph-flow-kanban/src/orchestrate.rs @@ -0,0 +1,197 @@ +//! `orchestrate` — the end-to-end spine: resolve an OGAR `ActionDef`, drive the +//! Rubicon [`KanbanPlanEnvelope`], authorize+commit through `commit_via`, and +//! map the resulting [`ActionState`] onto a terminal Kanban column. +//! +//! Generic over a **provided** action manifest (`&[ActionDef]`) and a **provided** +//! [`ClassRbac`] — it imports no thinking-style, no provider, no SoA column (the +//! kgV invariant). The lifecycle advances are deterministic (`Flow` progression +//! `Planning → CognitiveWork → Evaluation`); only the **commit** consults the MUL +//! `gate` to decide the Evaluation terminal. + +use crate::KanbanPlanEnvelope; +use lance_graph_contract::action::ActionDef; +use lance_graph_contract::action::{ActionInvocation, ActionState}; +use lance_graph_contract::canonical_node::NodeGuid; +use lance_graph_contract::collapse_gate::MailboxId; +use lance_graph_contract::kanban::{ExecTarget, KanbanColumn}; +use lance_graph_contract::mul::GateDecision; +use lance_graph_contract::rbac::{ActorId, ClassRbac}; + +/// The result of one [`run_cycle`] — the terminal column reached plus the +/// envelope (its move-log is the audit trail). +#[derive(Debug, Clone)] +pub struct CycleOutcome { + /// The terminal Kanban column: `Commit` / `Plan` / `Prune`. + pub outcome: KanbanColumn, + /// The driven envelope (carries the `KanbanMove` log + exec target). + pub envelope: KanbanPlanEnvelope, +} + +/// Run one cognitive cycle for `(classid, predicate)` against the provided +/// manifest + RBAC. +/// +/// 1. Resolve the [`ActionDef`] by `(object_class, predicate)`. Unknown ⇒ the +/// envelope is vetoed straight to `Prune` (no action to run). +/// 2. Drive the envelope `Planning → CognitiveWork → Evaluation` (deterministic +/// `Flow` progression — entering the work is not the MUL decision). +/// 3. At Evaluation, `commit_via` adjudicates the action (def-match → RBAC → +/// guard → MUL `gate`); the resulting [`ActionState`] maps to the terminal: +/// `Committed → Commit`, `Pending → Plan` (re-deliberate), `Cancelled`/`Failed +/// → Prune`. +/// +/// `object_instance` is the target node (its `classid()` must equal `classid`). +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn run_cycle( + actions: &[ActionDef], + rbac: &impl ClassRbac, + actor_id: ActorId<'_>, + classid: u32, + predicate: &'static str, + gate: &GateDecision, + object_instance: NodeGuid, + guard_value: Option<&str>, + now_millis: u64, +) -> CycleOutcome { + let mailbox: MailboxId = classid; + let Some(def) = actions + .iter() + .find(|a| a.object_class == classid && a.predicate == predicate) + else { + // No such action — veto the cycle (Planning → Prune is a legal Libet veto). + let mut envelope = KanbanPlanEnvelope::new(mailbox, ExecTarget::Native); + envelope.try_transition(KanbanColumn::Prune).ok(); + return CycleOutcome { + outcome: KanbanColumn::Prune, + envelope, + }; + }; + + let mut envelope = KanbanPlanEnvelope::new(mailbox, def.exec); + // Deterministic lifecycle progression to the Evaluation decision point. + envelope.advance(&GateDecision::Flow); // Planning → CognitiveWork + envelope.advance(&GateDecision::Flow); // CognitiveWork → Evaluation + + // The cold floor: commit_via IS { def-match · RBAC · Libet guard · Rubikon@MUL }. + let mut inv = + ActionInvocation::pending(classid, predicate, object_instance, envelope.cycle, 0, 0); + let state = inv.commit_via(def, rbac, actor_id, gate, guard_value, now_millis); + + let outcome = match state { + ActionState::Committed => KanbanColumn::Commit, + ActionState::Pending => KanbanColumn::Plan, + ActionState::Cancelled | ActionState::Failed => KanbanColumn::Prune, + }; + envelope.try_transition(outcome).ok(); + CycleOutcome { outcome, envelope } +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_graph_contract::rbac::{ActorId as Aid, ClassId, Operation, RoleId}; + + const PATIENT: u32 = 0x0000_0901; + + fn manifest() -> Vec { + vec![ActionDef { + predicate: "approve", + object_class: PATIENT, + exec: ExecTarget::Native, + guard: None, + required_role: Some("physician"), + overrides: None, + }] + } + + // A ClassRbac fixture: "dr-house" holds "physician" which is granted Act. + struct Rbac; + impl ClassRbac for Rbac { + fn actor_roles(&self, actor: Aid<'_>) -> &[RoleId] { + if actor == "dr-house" { + const R: &[RoleId] = &["physician"]; + R + } else { + &[] + } + } + fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool { + role == "physician" && class == PATIENT && matches!(op, Operation::Act { .. }) + } + } + + fn guid() -> NodeGuid { + NodeGuid::new(PATIENT, 0, 0, 0, 0, 1) + } + + #[test] + fn authorized_flow_reaches_commit() { + let m = manifest(); + let out = run_cycle( + &m, + &Rbac, + "dr-house", + PATIENT, + "approve", + &GateDecision::Flow, + guid(), + None, + 1000, + ); + assert_eq!(out.outcome, KanbanColumn::Commit); + assert_eq!(out.envelope.column, KanbanColumn::Commit); + } + + #[test] + fn unauthorized_actor_reaches_prune() { + let m = manifest(); + let out = run_cycle( + &m, + &Rbac, + "betty", // not a physician + PATIENT, + "approve", + &GateDecision::Flow, + guid(), + None, + 1000, + ); + assert_eq!(out.outcome, KanbanColumn::Prune); + } + + #[test] + fn mul_hold_reaches_plan() { + let m = manifest(); + let out = run_cycle( + &m, + &Rbac, + "dr-house", + PATIENT, + "approve", + &GateDecision::Hold { + reason: "uncertain".into(), + }, + guid(), + None, + 1000, + ); + assert_eq!(out.outcome, KanbanColumn::Plan); + } + + #[test] + fn unknown_predicate_reaches_prune() { + let m = manifest(); + let out = run_cycle( + &m, + &Rbac, + "dr-house", + PATIENT, + "nonexistent", + &GateDecision::Flow, + guid(), + None, + 1000, + ); + assert_eq!(out.outcome, KanbanColumn::Prune); + } +}