frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! The ONE shared action-dispatch entry point (F-6a R3) — and the whole of
//! F-6a's sanctioned frame-conv edit.
//!
//! Direct callers and the `frame-mcp` tool adapter enter HERE, so a denied
//! tool call and the byte-identical direct call return byte-identical typed
//! verdicts and produce exactly ONE denial event each — the event published
//! by the shared [`CapabilityChecker`], never a second adapter-side event.
//! Neither adapter may pre-authorize, cache a verdict, or emit a denial
//! itself; this module is the only door to a declared action.
//!
//! The dispatch address is derived, never configured: one Running
//! incarnation's action listens on the conversation this module derives from
//! `(ActionKey, ActionIncarnation)`. A retained handle from a previous
//! incarnation therefore dispatches into a conversation no replacement
//! incarnation ever attaches, and the call ends at the caller's deadline
//! wall — S2's landed no-responder bound (gate fill, 2026-07-23).

use std::str::FromStr;
use std::time::Duration;

use frame_core::action::ActionRow;
use frame_core::capability::{
    CapabilityCheckError, CapabilityChecker, CapabilityDenied, CheckVerdict,
};
use frame_core::component::ComponentId;
use serde::Serialize;
use serde::de::DeserializeOwned;
use thiserror::Error;

use crate::error::CallError;
use crate::handle::ConversationHandle;
use crate::id::ConversationId;
use crate::outcome::RequestOutcome;
use crate::store::ResumeStore;

/// The conversation one Running incarnation's action dispatches on:
/// frame-core's opaque [`ActionRow::dispatch_ordinal`] interpreted as the
/// 64-bit conversation identity (frame-core derives, frame-conv interprets —
/// frame-core stays conversation-ignorant as its architecture requires).
#[must_use]
pub fn conversation_for_action(row: &ActionRow) -> ConversationId {
    ConversationId::from_str(&row.dispatch_ordinal().to_string())
        .unwrap_or_else(|_| unreachable!("a decimal u64 always parses as a conversation id"))
}

/// Typed outcome of one dispatched action call.
#[derive(Debug)]
pub enum ActionDispatchOutcome<R> {
    /// The first non-Allowed verdict from the declaration's canonical
    /// requirement sequence. The shared checker already published the ONE
    /// denial event; the caller receives the same serialized verdict bytes on
    /// every path.
    Denied(CapabilityDenied),
    /// Every requirement was allowed and one S1 request-response call ran to
    /// its typed S2 outcome (reply, deadline, or responder failure) — passed
    /// through untranslated; the MCP boundary translates exactly once.
    Completed(RequestOutcome<R>),
}

/// Typed refusal or failure BEFORE any consuming act or dispatch happened.
#[derive(Debug, Error)]
pub enum ActionDispatchError {
    /// The checker's component identity does not match the action key — a
    /// binding refusal, NOT a capability denial: no requirement was checked
    /// and no denial event exists (F-6a R3).
    #[error(
        "capability checker for component {checker} cannot dispatch action of component {action}"
    )]
    CheckerBinding {
        /// Identity the checker evaluates.
        checker: ComponentId,
        /// Identity the action key names.
        action: ComponentId,
    },
    /// The handle is attached to a different conversation than the one this
    /// action's `(key, incarnation)` derives — dispatching there could reach
    /// a different action's responder; refused typed, no denial event.
    #[error("handle is attached to conversation {attached} but action dispatch derives {derived}")]
    ConversationBinding {
        /// The handle's enrolled conversation.
        attached: ConversationId,
        /// The conversation derived from the action row.
        derived: ConversationId,
    },
    /// The shared checker's synchronization failed; no verdict exists.
    #[error(transparent)]
    Check(#[from] CapabilityCheckError),
    /// The S1 call's admission or connection leg failed typed (distinct from
    /// every conversation outcome, which arrives as
    /// [`ActionDispatchOutcome::Completed`]).
    #[error(transparent)]
    Call(#[from] CallError),
}

/// Dispatches one action call through the one shared permission path.
///
/// Order is fixed and identical for every caller: checker/action binding,
/// handle/conversation binding, then the declaration's canonical capability
/// sequence through the action owner's [`CapabilityChecker`] — stopping at
/// the first non-Allowed verdict — then serde validation and one F-3a
/// request-response call with the caller-supplied deadline. Nothing here
/// retries, caches, or re-emits; every failure is typed.
///
/// # Errors
///
/// Returns a typed binding refusal, checker synchronization failure, or S1
/// admission/connection failure. A capability denial is NOT an error — it is
/// the typed [`ActionDispatchOutcome::Denied`] verdict.
pub fn dispatch_action<Q, R, S>(
    checker: &CapabilityChecker,
    row: &ActionRow,
    handle: &mut ConversationHandle<S>,
    request: &Q,
    deadline: Duration,
) -> Result<ActionDispatchOutcome<R>, ActionDispatchError>
where
    Q: Serialize,
    R: DeserializeOwned,
    S: ResumeStore,
{
    if checker.component_id() != row.key.component_id {
        return Err(ActionDispatchError::CheckerBinding {
            checker: checker.component_id(),
            action: row.key.component_id,
        });
    }
    let derived = conversation_for_action(row);
    if handle.conversation() != derived {
        return Err(ActionDispatchError::ConversationBinding {
            attached: handle.conversation(),
            derived,
        });
    }
    for requirement in &row.requirements {
        match checker.check(requirement)? {
            CheckVerdict::Allowed => {}
            CheckVerdict::Denied(denial) => {
                return Ok(ActionDispatchOutcome::Denied(denial));
            }
        }
    }
    let outcome = handle.request(request, deadline)?;
    Ok(ActionDispatchOutcome::Completed(outcome))
}