frame-core 0.3.0

Component model, lifecycle, process isolation — hosts components as supervised BEAM process trees
Documentation
//! Component action declarations and the authoritative action snapshot
//! vocabulary (F-6a R1).
//!
//! Shared declaration types live here, beside [`ComponentMeta`]'s `provides`
//! and `needs`, so every consumer of the action surface — direct dispatch and
//! generated tooling alike — reads ONE vocabulary owned by frame-core.
//! Registration is the only door: every declaration is validated there,
//! atomically per component, and the registry's
//! [`action_snapshot`](crate::registry::ComponentRegistry::action_snapshot)
//! is the sole authority for which actions exist right now.
//!
//! [`ComponentMeta`]: crate::component::ComponentMeta

use serde::{Deserialize, Serialize};

use crate::capability::Capability;
use crate::component::ComponentId;

/// Component-local action identity.
///
/// Uniqueness is scoped by [`ActionKey`]; two components may intentionally
/// declare the same local id. Registration refuses an empty id — the id's
/// UTF-8 bytes participate in generated tool names, and nonemptiness is part
/// of the by-construction collision-freedom argument (F-6a R2).
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct ActionId(String);

impl ActionId {
    /// Wraps a raw id without validating; registration validates every id it
    /// admits, so an unregistered invalid id can never enter the authority.
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    /// Returns the id text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns the canonical UTF-8 bytes used for ordering and tool naming.
    #[must_use]
    pub fn as_bytes(&self) -> &[u8] {
        self.0.as_bytes()
    }
}

/// Globally unique action identity: component identity plus local id.
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct ActionKey {
    /// The declaring component.
    pub component_id: ComponentId,
    /// The component-local action id.
    pub action_id: ActionId,
}

impl ActionKey {
    /// Canonical ordering bytes: the 32 fixed-width component identity bytes,
    /// then the action id's UTF-8 bytes. The fixed component width makes the
    /// concatenation injective (F-6a R2's ordering and naming argument).
    #[must_use]
    pub fn canonical_bytes(&self) -> Vec<u8> {
        let mut bytes = Vec::with_capacity(32 + self.action_id.as_bytes().len());
        bytes.extend_from_slice(self.component_id.as_bytes());
        bytes.extend_from_slice(self.action_id.as_bytes());
        bytes
    }
}

impl Ord for ActionKey {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.component_id
            .as_bytes()
            .cmp(other.component_id.as_bytes())
            .then_with(|| self.action_id.as_bytes().cmp(other.action_id.as_bytes()))
    }
}

impl PartialOrd for ActionKey {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

/// Typed reference to an S3 schema source: the canonical Rust path of the
/// serde message type that IS the schema (F-3a's pinned ruling — schema is
/// the typed message, validation is serde). Under the F-6a constraint-5 (c)
/// ruling this reference is what generated minimal schemas cite; no schema
/// bytes are derived from it.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct SchemaSource(String);

impl SchemaSource {
    /// Wraps a raw type path without validating; registration refuses an
    /// empty path.
    #[must_use]
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    /// Returns the referenced type path.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// One declared component action.
///
/// Declared inside [`ComponentMeta::actions`] and validated at registration:
/// nonempty id, description, and schema sources; no duplicate local ids; no
/// duplicate requirements; every requirement covered by a declared need (an
/// action requiring an undeclarable capability would be a permanently dead
/// tool — refused at the door, F-6a R1's tear condition).
///
/// [`ComponentMeta::actions`]: crate::component::ComponentMeta::actions
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ActionDeclaration {
    /// Component-local identity.
    pub id: ActionId,
    /// Human-readable description; carried into generated tool descriptions
    /// verbatim (mandatory readable identity, F-6a R2's naming decision).
    pub description: String,
    /// The typed request message this action validates against (S3).
    pub request_schema: SchemaSource,
    /// The typed response message this action replies with (S3).
    pub response_schema: SchemaSource,
    /// Capability requirements checked, in this exact order, by the shared
    /// dispatch entry point's consuming act — never authority mutation.
    pub requirements: Vec<Capability>,
}

/// Opaque monotonic token for one entry into Running.
///
/// Minted by the registry each time a component transition into Running
/// commits; a retained handle carrying an older incarnation can never reach a
/// replacement incarnation's dispatch surface (F-2a's held-vs-current rule,
/// applied to actions).
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct ActionIncarnation(u64);

impl ActionIncarnation {
    /// Wraps a raw incarnation ordinal (registry-minted; test fixtures only).
    #[must_use]
    pub const fn new(value: u64) -> Self {
        Self(value)
    }

    /// Returns the ordinal for deterministic derivation (dispatch naming).
    #[must_use]
    pub const fn ordinal(self) -> u64 {
        self.0
    }
}

/// One row of the registry's authoritative action snapshot.
///
/// Rows exist only for Running component incarnations and carry everything
/// S1 dispatch and R2 generation need — never host-facade authority. The
/// dispatch identity S1 needs is `(key, incarnation)`: frame-conv derives
/// the conversation address from those two values mechanically, so a stale
/// incarnation's derived address is one no replacement incarnation ever
/// attaches.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ActionRow {
    /// Globally unique action identity.
    pub key: ActionKey,
    /// Human-readable component name (tool description material).
    pub component_name: String,
    /// Declared description, passed through without rewriting.
    pub description: String,
    /// The typed request message reference (S3).
    pub request_schema: SchemaSource,
    /// The typed response message reference (S3).
    pub response_schema: SchemaSource,
    /// Canonical requirement sequence for the consuming act.
    pub requirements: Vec<Capability>,
    /// The Running incarnation these rows belong to.
    pub incarnation: ActionIncarnation,
}

impl ActionRow {
    /// The dispatch ordinal S1 addressing derives from: BLAKE3 over a domain
    /// tag, the key's canonical bytes (injective — fixed-width component
    /// identity then action id UTF-8), and the little-endian incarnation
    /// ordinal, truncated to the first eight digest bytes.
    ///
    /// Pure and mechanical: byte-identical rows derive the byte-identical
    /// ordinal in any process. frame-core stays ignorant of conversations —
    /// this is an opaque address ordinal; frame-conv interprets it. A stale
    /// incarnation's ordinal is one no replacement incarnation ever derives,
    /// so a retained handle can never reach the replacement (F-6a R5's
    /// stopped-target bound).
    #[must_use]
    pub fn dispatch_ordinal(&self) -> u64 {
        let mut hasher = blake3::Hasher::new();
        hasher.update(b"frame/action-dispatch/v1");
        hasher.update(&self.key.canonical_bytes());
        hasher.update(&self.incarnation.ordinal().to_le_bytes());
        let digest = hasher.finalize();
        let mut raw = [0u8; 8];
        raw.copy_from_slice(&digest.as_bytes()[..8]);
        u64::from_le_bytes(raw)
    }
}