Skip to main content

frame_host/dev/
mod.rs

1//! The `frame:dev-management@v1` vocabulary: the S1/S2 schema `frame dev`
2//! and the generated host SHARE (F-7b R5 — frame-cli adapts this
3//! vocabulary; it does not fork it).
4//!
5//! S1 is one typed request-response operation riding frame-conv's
6//! `frame:conv-request@v1` pattern: the dev client asks the running node
7//! to stage and activate candidate component bytes, and receives exactly
8//! one typed outcome under the caller's declared deadline. S2 is the
9//! typed status event stream riding `frame:conv-subscription@v1`:
10//! readiness, lifecycle, reload generation, and failure, observed by
11//! content.
12//!
13//! ## The authority boundary (tear condition, ruling C3)
14//!
15//! Pushing candidate bytecode is the most privileged operation a node can
16//! expose. The vocabulary exists in every host build — types are not the
17//! door — but the DOOR is dev-wiring only: a host started in
18//! [`NodeMode::Production`] carries no path from this request to an
19//! activation, answers [`StageRefusal::NotADevNode`] typed, and that
20//! refusal is pinned by a red-first test BEFORE any door works. Embedded
21//! initial bytes remain the SOLE boot source in every mode; remote
22//! management stays the named not-here seam whose price of admission is
23//! authentication, designed there, never defaulted here.
24
25use serde::{Deserialize, Serialize};
26
27mod adapter;
28mod conversation;
29mod door;
30mod engine;
31mod node;
32
33pub use adapter::{InboundManagement, ManagementConversation, ServeExit, serve};
34pub use conversation::{
35    DevManagementConversation, DevResumeStore, MANAGEMENT_LINE_PREFIX, format_management_line,
36    parse_management_line,
37};
38
39/// Everything the generated application hands the dev door at start (the
40/// template supplies these VISIBLY — the teaching half of constraint 1;
41/// the machinery stays library-shaped here).
42pub struct DevWiring {
43    /// The component's manifest — the same object the spec installs.
44    pub meta: frame_core::component::ComponentMeta,
45    /// The boot bytes as generation zero's last-known-good.
46    pub boot: CandidateBytes,
47    /// The application's mailbox liveness proof.
48    pub mailbox_proof: Witness,
49    /// The application's stored-content round-trip proof.
50    pub content_proof: Witness,
51}
52pub use door::require_dev_node;
53pub use engine::{BarrierEngine, NodeControl, StartFailure};
54pub use node::{DevNodeControl, Witness};
55
56/// How the host was started. The dev door exists only in [`Self::Dev`];
57/// production is not "dev minus a flag" but a host with NO path from a
58/// stage request to an activation.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub enum NodeMode {
61    /// The normal start: no bytecode-push door, no management surface.
62    Production,
63    /// Started by `frame dev`: the management conversation is opened,
64    /// bound to the local participant scope.
65    Dev,
66}
67
68/// One candidate: the compiled component and FFI BEAM files of one build
69/// generation, with the content digest that identifies the snapshot they
70/// were built from (constraint 8 — stale completions are recognized by
71/// identity, never by timing).
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct CandidateBytes {
74    /// The dev client's build generation for this candidate.
75    pub build_generation: u64,
76    /// BLAKE3 digest of the content snapshot the candidate was built from.
77    pub content_digest: [u8; 32],
78    /// The compiled component module bytes.
79    pub component_beam: Vec<u8>,
80    /// The compiled FFI module bytes.
81    pub ffi_beam: Vec<u8>,
82}
83
84/// S1: the one management request. Riding `frame:conv-request@v1`, so
85/// correlation, the caller-declared deadline, and the closed outcome set
86/// are frame-conv's proven machinery — this vocabulary adds only the
87/// operation itself.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub enum DevRequest {
90    /// Stage these candidate bytes and run the full activation barrier:
91    /// close admission, drain admitted old work, stop the old tree, purge
92    /// old module code, load the candidate, start the fresh tree, prove
93    /// mailbox and by-content liveness, reopen. All-or-rollback.
94    StageAndActivate(CandidateBytes),
95}
96
97/// S1: the exactly-one typed answer to a [`DevRequest`].
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub enum DevReply {
100    /// The candidate is live: the visible current generation incremented
101    /// exactly once and both liveness witnesses passed.
102    Activated(GenerationReport),
103    /// The request was refused before any barrier closed — nothing about
104    /// the running node changed.
105    Refused(StageRefusal),
106    /// Activation failed at the named stage; the previous bytes were
107    /// restored and re-proven before the barrier reopened.
108    RolledBack {
109        /// The stage that failed.
110        failed: ReloadStage,
111        /// Exact typed detail of the failure.
112        detail: String,
113        /// The re-proven last-good state now serving.
114        serving: GenerationReport,
115    },
116    /// Activation failed AND restoring last good failed: the node is not
117    /// serving. This is never dressed up as running.
118    NodeFailed {
119        /// The stage that failed first.
120        failed: ReloadStage,
121        /// Exact typed detail, restore failure included.
122        detail: String,
123    },
124}
125
126/// Why a stage request was refused outright.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub enum StageRefusal {
129    /// The host was not started in dev mode: there is no door. The
130    /// red-first pinned refusal (ruling C3).
131    NotADevNode,
132    /// A previous barrier is still in flight; one activation at a time.
133    ActivationInFlight,
134    /// The candidate's build generation is not newer than the last one
135    /// this node saw: a stale candidate never activates.
136    StaleGeneration {
137        /// The candidate's generation.
138        offered: u64,
139        /// The newest generation this node has already seen.
140        newest_seen: u64,
141    },
142    /// The request's payload failed typed validation at the node —
143    /// version skew between `frame dev` and the generated host surfaces
144    /// loudly as this refusal, never as a silent drop (R5: one shared
145    /// schema, adapted not forked).
146    SchemaInvalid {
147        /// Exact validation refusal.
148        detail: String,
149    },
150}
151
152/// The stages of one activation, in barrier order. Failure reports name
153/// exactly one.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155pub enum ReloadStage {
156    /// Closing admission and draining admitted old work.
157    Drain,
158    /// Stopping the old component tree (ordered stop).
159    StopOld,
160    /// Purging retained old module code (safe purge after drain; a
161    /// refusal here is a drain-failure signal, loud).
162    PurgeOld,
163    /// Loading the candidate bytes.
164    LoadCandidate,
165    /// Starting the fresh tree.
166    StartFresh,
167    /// The mailbox liveness round-trip.
168    LivenessMailbox,
169    /// The stored-entity/content round-trip.
170    LivenessContent,
171    /// Restoring the previous bytes after a failure.
172    Restore,
173    /// Reading the freshness witnesses of the now-serving state.
174    Report,
175}
176
177/// What is serving, named fully (constraint 7: "still serving" without
178/// "stale" is a forbidden report).
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct GenerationReport {
181    /// The dev-loop build generation now (or still) serving.
182    pub build_generation: u64,
183    /// The content digest of the serving generation's snapshot.
184    pub content_digest: [u8; 32],
185    /// The component's lifecycle incarnation ordinal after this
186    /// activation (the F-5a per-identity mint — strictly increasing per
187    /// identity, the registry-side freshness witness).
188    pub component_incarnation: u64,
189    /// The VM module generation of the component module (beamr's
190    /// per-name monotonic counter — the VM-side freshness witness;
191    /// surfacing it is sanctioned, correlating the two is a design note
192    /// first).
193    pub module_generation: u64,
194}
195
196/// S2: one typed status event on the management subscription. Every
197/// state transition of constraint 7 is observable here by content.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub enum DevStatusEvent {
200    /// The node is serving this generation and no work is in flight:
201    /// `RUNNING CURRENT`.
202    RunningCurrent(GenerationReport),
203    /// An activation barrier is in flight for this build generation:
204    /// `RELOADING`, at the named stage.
205    Reloading {
206        /// The candidate generation being activated.
207        build_generation: u64,
208        /// The stage the barrier is executing.
209        stage: ReloadStage,
210    },
211    /// Activation failed and the node serves last good:
212    /// `RELOAD FAILED — RUNNING LAST GOOD`. Diagnostics attached, stale
213    /// state named.
214    ReloadFailed {
215        /// The candidate generation that failed.
216        failed_generation: u64,
217        /// The stage that failed.
218        failed: ReloadStage,
219        /// Exact typed failure detail.
220        detail: String,
221        /// The stale-but-live state still serving.
222        serving: GenerationReport,
223    },
224    /// The node cannot serve: `NODE FAILED`. Never reported as anything
225    /// softer.
226    NodeFailed {
227        /// Exact typed detail (exit, tombstone, or connection evidence).
228        detail: String,
229    },
230}
231
232#[cfg(test)]
233mod tests {
234    #![allow(clippy::expect_used)]
235
236    use super::{
237        CandidateBytes, DevReply, DevRequest, DevStatusEvent, GenerationReport, NodeMode,
238        ReloadStage, StageRefusal,
239    };
240
241    fn report() -> GenerationReport {
242        GenerationReport {
243            build_generation: 4,
244            content_digest: [7; 32],
245            component_incarnation: 9,
246            module_generation: 5,
247        }
248    }
249
250    /// The schema IS the typed Rust message and validation IS serde at
251    /// both ends (F-3a R1's pinned ruling): every vocabulary type
252    /// round-trips.
253    #[test]
254    fn vocabulary_round_trips_through_serde() {
255        let request = DevRequest::StageAndActivate(CandidateBytes {
256            build_generation: 4,
257            content_digest: [7; 32],
258            component_beam: vec![1, 2, 3],
259            ffi_beam: vec![4, 5],
260        });
261        let json = serde_json::to_string(&request).expect("serializes");
262        let back: DevRequest = serde_json::from_str(&json).expect("deserializes");
263        assert_eq!(request, back);
264
265        for reply in [
266            DevReply::Activated(report()),
267            DevReply::Refused(StageRefusal::NotADevNode),
268            DevReply::Refused(StageRefusal::StaleGeneration {
269                offered: 3,
270                newest_seen: 4,
271            }),
272            DevReply::RolledBack {
273                failed: ReloadStage::StartFresh,
274                detail: "supervisor refused".to_owned(),
275                serving: report(),
276            },
277            DevReply::NodeFailed {
278                failed: ReloadStage::Restore,
279                detail: "restore load failed".to_owned(),
280            },
281        ] {
282            let json = serde_json::to_string(&reply).expect("serializes");
283            let back: DevReply = serde_json::from_str(&json).expect("deserializes");
284            assert_eq!(reply, back);
285        }
286
287        for event in [
288            DevStatusEvent::RunningCurrent(report()),
289            DevStatusEvent::Reloading {
290                build_generation: 5,
291                stage: ReloadStage::PurgeOld,
292            },
293            DevStatusEvent::ReloadFailed {
294                failed_generation: 5,
295                failed: ReloadStage::LoadCandidate,
296                detail: "invalid BEAM".to_owned(),
297                serving: report(),
298            },
299            DevStatusEvent::NodeFailed {
300                detail: "child exited: signal 9".to_owned(),
301            },
302        ] {
303            let json = serde_json::to_string(&event).expect("serializes");
304            let back: DevStatusEvent = serde_json::from_str(&json).expect("deserializes");
305            assert_eq!(event, back);
306        }
307    }
308
309    /// A payload that fails typed validation surfaces as the typed serde
310    /// refusal, never a silent default (no unknown-variant leniency).
311    #[test]
312    fn unknown_variants_refuse_typed() {
313        assert!(serde_json::from_str::<DevRequest>("{\"InstallPlugin\":{}}").is_err());
314        assert!(serde_json::from_str::<NodeMode>("\"Staging\"").is_err());
315    }
316}