frame-host 0.4.0

Frame host server and embedding seam — boots an application's frame-core component tree with an embedded liminal bus, announces the host's real application events on the bus, and serves the built frame page
Documentation
//! The `frame:dev-management@v1` vocabulary: the S1/S2 schema `frame dev`
//! and the generated host SHARE (F-7b R5 — frame-cli adapts this
//! vocabulary; it does not fork it).
//!
//! S1 is one typed request-response operation riding frame-conv's
//! `frame:conv-request@v1` pattern: the dev client asks the running node
//! to stage and activate candidate component bytes, and receives exactly
//! one typed outcome under the caller's declared deadline. S2 is the
//! typed status event stream riding `frame:conv-subscription@v1`:
//! readiness, lifecycle, reload generation, and failure, observed by
//! content.
//!
//! ## The authority boundary (tear condition, ruling C3)
//!
//! Pushing candidate bytecode is the most privileged operation a node can
//! expose. The vocabulary exists in every host build — types are not the
//! door — but the DOOR is dev-wiring only: a host started in
//! [`NodeMode::Production`] carries no path from this request to an
//! activation, answers [`StageRefusal::NotADevNode`] typed, and that
//! refusal is pinned by a red-first test BEFORE any door works. Embedded
//! initial bytes remain the SOLE boot source in every mode; remote
//! management stays the named not-here seam whose price of admission is
//! authentication, designed there, never defaulted here.

use serde::{Deserialize, Serialize};

mod adapter;
mod conversation;
mod door;
mod engine;
mod node;

pub use adapter::{InboundManagement, ManagementConversation, ServeExit, serve};
pub use conversation::{
    DevManagementConversation, DevResumeStore, MANAGEMENT_LINE_PREFIX, format_management_line,
    parse_management_line,
};

/// Everything the generated application hands the dev door at start (the
/// template supplies these VISIBLY — the teaching half of constraint 1;
/// the machinery stays library-shaped here).
pub struct DevWiring {
    /// The component's manifest — the same object the spec installs.
    pub meta: frame_core::component::ComponentMeta,
    /// The boot bytes as generation zero's last-known-good.
    pub boot: CandidateBytes,
    /// The application's mailbox liveness proof.
    pub mailbox_proof: Witness,
    /// The application's stored-content round-trip proof.
    pub content_proof: Witness,
}
pub use door::require_dev_node;
pub use engine::{BarrierEngine, NodeControl, StartFailure};
pub use node::{DevNodeControl, Witness};

/// How the host was started. The dev door exists only in [`Self::Dev`];
/// production is not "dev minus a flag" but a host with NO path from a
/// stage request to an activation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeMode {
    /// The normal start: no bytecode-push door, no management surface.
    Production,
    /// Started by `frame dev`: the management conversation is opened,
    /// bound to the local participant scope.
    Dev,
}

/// One candidate: the compiled component and FFI BEAM files of one build
/// generation, with the content digest that identifies the snapshot they
/// were built from (constraint 8 — stale completions are recognized by
/// identity, never by timing).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CandidateBytes {
    /// The dev client's build generation for this candidate.
    pub build_generation: u64,
    /// BLAKE3 digest of the content snapshot the candidate was built from.
    pub content_digest: [u8; 32],
    /// The compiled component module bytes.
    pub component_beam: Vec<u8>,
    /// The compiled FFI module bytes.
    pub ffi_beam: Vec<u8>,
}

/// S1: the one management request. Riding `frame:conv-request@v1`, so
/// correlation, the caller-declared deadline, and the closed outcome set
/// are frame-conv's proven machinery — this vocabulary adds only the
/// operation itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DevRequest {
    /// Stage these candidate bytes and run the full activation barrier:
    /// close admission, drain admitted old work, stop the old tree, purge
    /// old module code, load the candidate, start the fresh tree, prove
    /// mailbox and by-content liveness, reopen. All-or-rollback.
    StageAndActivate(CandidateBytes),
}

/// S1: the exactly-one typed answer to a [`DevRequest`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DevReply {
    /// The candidate is live: the visible current generation incremented
    /// exactly once and both liveness witnesses passed.
    Activated(GenerationReport),
    /// The request was refused before any barrier closed — nothing about
    /// the running node changed.
    Refused(StageRefusal),
    /// Activation failed at the named stage; the previous bytes were
    /// restored and re-proven before the barrier reopened.
    RolledBack {
        /// The stage that failed.
        failed: ReloadStage,
        /// Exact typed detail of the failure.
        detail: String,
        /// The re-proven last-good state now serving.
        serving: GenerationReport,
    },
    /// Activation failed AND restoring last good failed: the node is not
    /// serving. This is never dressed up as running.
    NodeFailed {
        /// The stage that failed first.
        failed: ReloadStage,
        /// Exact typed detail, restore failure included.
        detail: String,
    },
}

/// Why a stage request was refused outright.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StageRefusal {
    /// The host was not started in dev mode: there is no door. The
    /// red-first pinned refusal (ruling C3).
    NotADevNode,
    /// A previous barrier is still in flight; one activation at a time.
    ActivationInFlight,
    /// The candidate's build generation is not newer than the last one
    /// this node saw: a stale candidate never activates.
    StaleGeneration {
        /// The candidate's generation.
        offered: u64,
        /// The newest generation this node has already seen.
        newest_seen: u64,
    },
    /// The request's payload failed typed validation at the node —
    /// version skew between `frame dev` and the generated host surfaces
    /// loudly as this refusal, never as a silent drop (R5: one shared
    /// schema, adapted not forked).
    SchemaInvalid {
        /// Exact validation refusal.
        detail: String,
    },
}

/// The stages of one activation, in barrier order. Failure reports name
/// exactly one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReloadStage {
    /// Closing admission and draining admitted old work.
    Drain,
    /// Stopping the old component tree (ordered stop).
    StopOld,
    /// Purging retained old module code (safe purge after drain; a
    /// refusal here is a drain-failure signal, loud).
    PurgeOld,
    /// Loading the candidate bytes.
    LoadCandidate,
    /// Starting the fresh tree.
    StartFresh,
    /// The mailbox liveness round-trip.
    LivenessMailbox,
    /// The stored-entity/content round-trip.
    LivenessContent,
    /// Restoring the previous bytes after a failure.
    Restore,
    /// Reading the freshness witnesses of the now-serving state.
    Report,
}

/// What is serving, named fully (constraint 7: "still serving" without
/// "stale" is a forbidden report).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GenerationReport {
    /// The dev-loop build generation now (or still) serving.
    pub build_generation: u64,
    /// The content digest of the serving generation's snapshot.
    pub content_digest: [u8; 32],
    /// The component's lifecycle incarnation ordinal after this
    /// activation (the F-5a per-identity mint — strictly increasing per
    /// identity, the registry-side freshness witness).
    pub component_incarnation: u64,
    /// The VM module generation of the component module (beamr's
    /// per-name monotonic counter — the VM-side freshness witness;
    /// surfacing it is sanctioned, correlating the two is a design note
    /// first).
    pub module_generation: u64,
}

/// S2: one typed status event on the management subscription. Every
/// state transition of constraint 7 is observable here by content.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DevStatusEvent {
    /// The node is serving this generation and no work is in flight:
    /// `RUNNING CURRENT`.
    RunningCurrent(GenerationReport),
    /// An activation barrier is in flight for this build generation:
    /// `RELOADING`, at the named stage.
    Reloading {
        /// The candidate generation being activated.
        build_generation: u64,
        /// The stage the barrier is executing.
        stage: ReloadStage,
    },
    /// Activation failed and the node serves last good:
    /// `RELOAD FAILED — RUNNING LAST GOOD`. Diagnostics attached, stale
    /// state named.
    ReloadFailed {
        /// The candidate generation that failed.
        failed_generation: u64,
        /// The stage that failed.
        failed: ReloadStage,
        /// Exact typed failure detail.
        detail: String,
        /// The stale-but-live state still serving.
        serving: GenerationReport,
    },
    /// The node cannot serve: `NODE FAILED`. Never reported as anything
    /// softer.
    NodeFailed {
        /// Exact typed detail (exit, tombstone, or connection evidence).
        detail: String,
    },
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used)]

    use super::{
        CandidateBytes, DevReply, DevRequest, DevStatusEvent, GenerationReport, NodeMode,
        ReloadStage, StageRefusal,
    };

    fn report() -> GenerationReport {
        GenerationReport {
            build_generation: 4,
            content_digest: [7; 32],
            component_incarnation: 9,
            module_generation: 5,
        }
    }

    /// The schema IS the typed Rust message and validation IS serde at
    /// both ends (F-3a R1's pinned ruling): every vocabulary type
    /// round-trips.
    #[test]
    fn vocabulary_round_trips_through_serde() {
        let request = DevRequest::StageAndActivate(CandidateBytes {
            build_generation: 4,
            content_digest: [7; 32],
            component_beam: vec![1, 2, 3],
            ffi_beam: vec![4, 5],
        });
        let json = serde_json::to_string(&request).expect("serializes");
        let back: DevRequest = serde_json::from_str(&json).expect("deserializes");
        assert_eq!(request, back);

        for reply in [
            DevReply::Activated(report()),
            DevReply::Refused(StageRefusal::NotADevNode),
            DevReply::Refused(StageRefusal::StaleGeneration {
                offered: 3,
                newest_seen: 4,
            }),
            DevReply::RolledBack {
                failed: ReloadStage::StartFresh,
                detail: "supervisor refused".to_owned(),
                serving: report(),
            },
            DevReply::NodeFailed {
                failed: ReloadStage::Restore,
                detail: "restore load failed".to_owned(),
            },
        ] {
            let json = serde_json::to_string(&reply).expect("serializes");
            let back: DevReply = serde_json::from_str(&json).expect("deserializes");
            assert_eq!(reply, back);
        }

        for event in [
            DevStatusEvent::RunningCurrent(report()),
            DevStatusEvent::Reloading {
                build_generation: 5,
                stage: ReloadStage::PurgeOld,
            },
            DevStatusEvent::ReloadFailed {
                failed_generation: 5,
                failed: ReloadStage::LoadCandidate,
                detail: "invalid BEAM".to_owned(),
                serving: report(),
            },
            DevStatusEvent::NodeFailed {
                detail: "child exited: signal 9".to_owned(),
            },
        ] {
            let json = serde_json::to_string(&event).expect("serializes");
            let back: DevStatusEvent = serde_json::from_str(&json).expect("deserializes");
            assert_eq!(event, back);
        }
    }

    /// A payload that fails typed validation surfaces as the typed serde
    /// refusal, never a silent default (no unknown-variant leniency).
    #[test]
    fn unknown_variants_refuse_typed() {
        assert!(serde_json::from_str::<DevRequest>("{\"InstallPlugin\":{}}").is_err());
        assert!(serde_json::from_str::<NodeMode>("\"Staging\"").is_err());
    }
}