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}