Skip to main content

frame_conv/
action_dispatch.rs

1//! The ONE shared action-dispatch entry point (F-6a R3) — and the whole of
2//! F-6a's sanctioned frame-conv edit.
3//!
4//! Direct callers and the `frame-mcp` tool adapter enter HERE, so a denied
5//! tool call and the byte-identical direct call return byte-identical typed
6//! verdicts and produce exactly ONE denial event each — the event published
7//! by the shared [`CapabilityChecker`], never a second adapter-side event.
8//! Neither adapter may pre-authorize, cache a verdict, or emit a denial
9//! itself; this module is the only door to a declared action.
10//!
11//! The dispatch address is derived, never configured: one Running
12//! incarnation's action listens on the conversation this module derives from
13//! `(ActionKey, ActionIncarnation)`. A retained handle from a previous
14//! incarnation therefore dispatches into a conversation no replacement
15//! incarnation ever attaches, and the call ends at the caller's deadline
16//! wall — S2's landed no-responder bound (gate fill, 2026-07-23).
17
18use std::str::FromStr;
19use std::time::Duration;
20
21use frame_core::action::ActionRow;
22use frame_core::capability::{
23    CapabilityCheckError, CapabilityChecker, CapabilityDenied, CheckVerdict,
24};
25use frame_core::component::ComponentId;
26use serde::Serialize;
27use serde::de::DeserializeOwned;
28use thiserror::Error;
29
30use crate::error::CallError;
31use crate::handle::ConversationHandle;
32use crate::id::ConversationId;
33use crate::outcome::RequestOutcome;
34use crate::store::ResumeStore;
35
36/// The conversation one Running incarnation's action dispatches on:
37/// frame-core's opaque [`ActionRow::dispatch_ordinal`] interpreted as the
38/// 64-bit conversation identity (frame-core derives, frame-conv interprets —
39/// frame-core stays conversation-ignorant as its architecture requires).
40#[must_use]
41pub fn conversation_for_action(row: &ActionRow) -> ConversationId {
42    ConversationId::from_str(&row.dispatch_ordinal().to_string())
43        .unwrap_or_else(|_| unreachable!("a decimal u64 always parses as a conversation id"))
44}
45
46/// Typed outcome of one dispatched action call.
47#[derive(Debug)]
48pub enum ActionDispatchOutcome<R> {
49    /// The first non-Allowed verdict from the declaration's canonical
50    /// requirement sequence. The shared checker already published the ONE
51    /// denial event; the caller receives the same serialized verdict bytes on
52    /// every path.
53    Denied(CapabilityDenied),
54    /// Every requirement was allowed and one S1 request-response call ran to
55    /// its typed S2 outcome (reply, deadline, or responder failure) — passed
56    /// through untranslated; the MCP boundary translates exactly once.
57    Completed(RequestOutcome<R>),
58}
59
60/// Typed refusal or failure BEFORE any consuming act or dispatch happened.
61#[derive(Debug, Error)]
62pub enum ActionDispatchError {
63    /// The checker's component identity does not match the action key — a
64    /// binding refusal, NOT a capability denial: no requirement was checked
65    /// and no denial event exists (F-6a R3).
66    #[error(
67        "capability checker for component {checker} cannot dispatch action of component {action}"
68    )]
69    CheckerBinding {
70        /// Identity the checker evaluates.
71        checker: ComponentId,
72        /// Identity the action key names.
73        action: ComponentId,
74    },
75    /// The handle is attached to a different conversation than the one this
76    /// action's `(key, incarnation)` derives — dispatching there could reach
77    /// a different action's responder; refused typed, no denial event.
78    #[error("handle is attached to conversation {attached} but action dispatch derives {derived}")]
79    ConversationBinding {
80        /// The handle's enrolled conversation.
81        attached: ConversationId,
82        /// The conversation derived from the action row.
83        derived: ConversationId,
84    },
85    /// The shared checker's synchronization failed; no verdict exists.
86    #[error(transparent)]
87    Check(#[from] CapabilityCheckError),
88    /// The S1 call's admission or connection leg failed typed (distinct from
89    /// every conversation outcome, which arrives as
90    /// [`ActionDispatchOutcome::Completed`]).
91    #[error(transparent)]
92    Call(#[from] CallError),
93}
94
95/// Dispatches one action call through the one shared permission path.
96///
97/// Order is fixed and identical for every caller: checker/action binding,
98/// handle/conversation binding, then the declaration's canonical capability
99/// sequence through the action owner's [`CapabilityChecker`] — stopping at
100/// the first non-Allowed verdict — then serde validation and one F-3a
101/// request-response call with the caller-supplied deadline. Nothing here
102/// retries, caches, or re-emits; every failure is typed.
103///
104/// # Errors
105///
106/// Returns a typed binding refusal, checker synchronization failure, or S1
107/// admission/connection failure. A capability denial is NOT an error — it is
108/// the typed [`ActionDispatchOutcome::Denied`] verdict.
109pub fn dispatch_action<Q, R, S>(
110    checker: &CapabilityChecker,
111    row: &ActionRow,
112    handle: &mut ConversationHandle<S>,
113    request: &Q,
114    deadline: Duration,
115) -> Result<ActionDispatchOutcome<R>, ActionDispatchError>
116where
117    Q: Serialize,
118    R: DeserializeOwned,
119    S: ResumeStore,
120{
121    if checker.component_id() != row.key.component_id {
122        return Err(ActionDispatchError::CheckerBinding {
123            checker: checker.component_id(),
124            action: row.key.component_id,
125        });
126    }
127    let derived = conversation_for_action(row);
128    if handle.conversation() != derived {
129        return Err(ActionDispatchError::ConversationBinding {
130            attached: handle.conversation(),
131            derived,
132        });
133    }
134    for requirement in &row.requirements {
135        match checker.check(requirement)? {
136            CheckVerdict::Allowed => {}
137            CheckVerdict::Denied(denial) => {
138                return Ok(ActionDispatchOutcome::Denied(denial));
139            }
140        }
141    }
142    let outcome = handle.request(request, deadline)?;
143    Ok(ActionDispatchOutcome::Completed(outcome))
144}