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}