Skip to main content

wire/
frames.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The process boundary's vocabulary: the Commands, the Queries, and
7//! the Event push. Encoding is JSON because the receiving
8//! end is a browser and a human reading a network panel is a design goal.
9//!
10//! Two invariants live in the type system rather than in a check:
11//!
12//! - `Command` is generic over the carrier of a secret. `WireCommand` fixes
13//!   that carrier to an uninhabited type, so a frame arriving from a socket
14//!   cannot be a `PutSecret` - not "is rejected", but has no representation.
15//!   Credentials are enrolled on the host machine, and that constraint is
16//!   held by construction rather than by a check.
17//! - Every state-changing Command owns an `IdemKey` field. There is no
18//!   constructor that omits it, so "double-clicking twice opens two Runs" is
19//!   not reachable from this type.
20//!
21//! Names of things this crate does not own - providers, templates, toolkits -
22//! travel as validated newtypes with no closed value list. The authority for
23//! which values are legal stays upstream (gateway, city, the broker);
24//! the mapping point is the assembly layer, and an unknown value is an error
25//! there, never a guess.
26
27use kernel::{Address, AxError, B3Hash, EventKind, EventRecord, RunId, Seq, TimeMs};
28use serde::{Deserialize, Serialize};
29
30/// Wire format version. Rises by one between two pushes, in the first commit
31/// that changes a frame's shape while every frame and kind name stays
32/// (wire D1); a changed name moves [`schema_hash`] on its own.
33pub const WIRE_V: u32 = 56;
34mod ask;
35mod monitor;
36/// Queries read state. They are cacheable and free of side effects, so
37/// none carries an `IdemKey`: a Query that needed one would have stopped
38/// being a Query. The name table is generated from the variant list by
39/// `named_frames!`, because the schema hash is built from it and a table
40/// that could drift from the enum would let two builds agree on a hash
41/// while disagreeing on what a frame means.
42mod query;
43
44pub use ask::{Answered, Ask, AskId, AskOutcome};
45pub use monitor::{BEAT_MAX_MS, BEAT_MIN_MS, BeatMs, Monitoring, Sample, Watched};
46pub use query::{QUERY_NAMES, Query};
47
48use crate::answer::Answer;
49use crate::command::{COMMAND_NAMES, WireCommand};
50
51/// A digest of the protocol surface, exchanged at connect time.
52///
53/// A browser can hold a cached older client while the server has moved on;
54/// that mismatch is the one error WebUI has that a native window does not,
55/// and this hash is its single answer. Pure: same inputs, same bytes, always.
56#[must_use]
57pub fn schema_hash() -> B3Hash {
58    let mut material = Vec::new();
59    material.extend_from_slice(b"sprawling/wire/");
60    material.extend_from_slice(&WIRE_V.to_le_bytes());
61    for name in COMMAND_NAMES {
62        material.push(b'C');
63        material.extend_from_slice(name.as_bytes());
64    }
65    for name in QUERY_NAMES {
66        material.push(b'Q');
67        material.extend_from_slice(name.as_bytes());
68    }
69    for kind in EventKind::ALL {
70        material.push(b'E');
71        material.extend_from_slice(format!("{kind:?}").as_bytes());
72    }
73    B3Hash::digest(&material)
74}
75
76/// The JSON Schema of the whole envelope: every named type reachable from
77/// [`ClientFrame`] or [`ServerFrame`], under `$defs`, both roots included.
78///
79/// This is what `cargo xtask wire-ts` generates the client from. It is
80/// not what the handshake compares: [`schema_hash`] reads the version,
81/// the two frame name tables and the event kind names and nothing else,
82/// so a doc comment edited here moves this document and leaves every
83/// connected page connected. Pure:
84/// same build, same bytes.
85#[cfg(feature = "schema")]
86#[must_use]
87pub fn wire_schema() -> serde_json::Value {
88    let mut generator = schemars::SchemaGenerator::default();
89    generator.subschema_for::<ClientFrame>();
90    generator.subschema_for::<ServerFrame>();
91    let definitions = generator.take_definitions(true);
92    serde_json::json!({
93        "$schema": schemars::consts::meta_schemas::DRAFT2020_12,
94        "title": "sprawling wire",
95        "$defs": definitions,
96    })
97}
98
99/// The client's opening frame.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102pub struct Hello {
103    pub wire_v: u32,
104    pub schema: B3Hash,
105    /// Present only when the server binds a non-loopback address.
106    pub token: Option<String>,
107}
108
109/// The server's answer to a `Hello` it accepted.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct Welcome {
113    pub wire_v: u32,
114    pub schema: B3Hash,
115    /// The seq of the last record the city had broadcast when this welcome
116    /// was sent. Every later record follows on the live stream, so a
117    /// client that reconnects fetches the records after its own mark up to
118    /// and including this seq; a record at the boundary may arrive twice,
119    /// and none arrives not at all.
120    pub resume_from: Option<Seq>,
121    /// Which city answered. The handshake is where a connection learns
122    /// whose city it is: the name is in the Ledger's first record, and a
123    /// client that only ever hears what happens *next* would otherwise
124    /// have to display "no city" over a city that has been running for a
125    /// month.
126    pub city: Option<Address>,
127    /// Which ledger answered: the chain hash of its first line. A client
128    /// whose mark came from a welcome with another epoch holds positions
129    /// in a different history, and rebuilds rather than resumes.
130    pub epoch: Option<B3Hash>,
131}
132
133/// Everything a client may send.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137pub enum ClientFrame {
138    Hello(Hello),
139    Command(Box<WireCommand>),
140    Ask(Ask),
141    Monitor(Monitoring),
142}
143
144/// Everything a server may send. Events are the push half; a `Refusal`
145/// carries the three-part refusal the interface renders verbatim.
146///
147/// **`Delta` is deliberately not an event.** An event is a thing that
148/// happened, and a token increment is not: it has no sequence number, it
149/// is never written to the Ledger, it cannot be replayed, and a client
150/// that missed one has lost nothing. Giving it a frame class of its own
151/// is what keeps that true — folded into the event stream it would
152/// become a second, unverifiable history of what the model said.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156pub enum ServerFrame {
157    Welcome(Welcome),
158    Event(Box<EventRecord>),
159    Answered(Box<Answered>),
160    Refusal(Box<AxError>),
161    /// Text a model is saying, before the call it belongs to has
162    /// settled. Discardable by construction: the run it belongs to is
163    /// named so a client can throw the buffer away when `model_returned`
164    /// arrives, and the settled text of that record is what a page
165    /// draws. Where the two disagree, the record wins.
166    Delta(Delta),
167    /// One line of the process log, as `docs/logging.md` defines it.
168    ///
169    /// **A log is not history**, and this frame is what keeps that
170    /// true while a person still gets to read it: the line has no
171    /// ledger sequence of its own, it is never written down, and a
172    /// client that missed one has lost nothing. It carries the ledger
173    /// position it was written at, which is the integer the two
174    /// timelines line up on.
175    Log(LogLine),
176    /// The ledger records this session's event stream skipped.
177    ///
178    /// A session that reads slower than the city writes loses the
179    /// middle of the stream: the buffer behind a subscription has a
180    /// fixed capacity, and one slow reader is left behind rather than
181    /// holding the writer up. Losing it in silence would let a page draw
182    /// a history with a hole in it while nothing said so. The
183    /// range is answered from the Ledger, which is the one home of what
184    /// happened.
185    ///
186    /// **Only the event stream states a gap.** An increment and a log
187    /// line are written down nowhere, so a range naming them would name
188    /// records that do not exist, and a reader that missed one has lost
189    /// nothing it could have acted on.
190    Lagged(Lagged),
191    /// What a command still running has written so far. Discardable by
192    /// the rule `Delta` follows: the call's result in the Ledger is the
193    /// authority on that output, and a page drops this once it lands.
194    Output(LiveOutput),
195    /// One monitor reading, sent only to a session that is watching.
196    Monitor(Sample),
197}
198
199/// Which of a command's two outputs a piece came from.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
203pub enum OutputStream {
204    Out,
205    Err,
206}
207
208/// One piece of a running command's output, on its way to a page.
209///
210/// `text` is decoded lossily: a piece ends at a byte bound, which can
211/// fall inside a character, and the settled result is what a page keeps.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214pub struct LiveOutput {
215    pub run: RunId,
216    pub stream: OutputStream,
217    pub text: String,
218}
219
220/// Ledger records that never reached a peer, named by both ends.
221///
222/// Both ends come from records this session can name, never from the
223/// count a lagged subscription reports: that count says how many
224/// messages were skipped and neither endpoint, so a reader holding it
225/// cannot ask for the range it lost. `from` is the record after the last
226/// one the session delivered; `to` is the record before the first one to
227/// arrive after the gap.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230pub struct Lagged {
231    pub from: Seq,
232    pub to: Seq,
233}
234
235/// One piece of what a model is producing, on its way to a page.
236///
237/// The piece says which of the two streams it came from, because a page
238/// draws them differently: prose is the answer and reasoning is folded
239/// away beside it.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct Delta {
243    pub run: RunId,
244    pub increment: kernel::Increment,
245}
246
247/// Who reads a log line, and when.
248///
249/// The five `docs/logging.md` names, spelled on the wire exactly as
250/// `runtime::diagnostics::Level` spells them on a terminal. This crate
251/// cannot depend on `runtime` — the graph points inward — so the
252/// mapping between the two lives at the assembly layer, where a test
253/// holds the two spellings equal name for name.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub enum LogLevel {
258    Refuse,
259    Effect,
260    Decide,
261    Trace,
262    Wire,
263}
264
265/// One diagnostic line on its way to a page.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
268pub struct LogLine {
269    /// Where the ledger stood when the line was written. Not a sequence
270    /// of this stream's own: two lines can share it, and a reader uses
271    /// it to put the line beside the history rather than to detect a
272    /// gap.
273    pub seq: Seq,
274    /// When this machine wrote it. Absent when the clock could not be
275    /// read, because `seq` is the anchor either way and dropping the
276    /// line would lose the diagnostic to save the timestamp.
277    pub t: Option<TimeMs>,
278    pub level: LogLevel,
279    pub module: String,
280    /// Which run it was written under, and absent for the city itself.
281    pub run: Option<RunId>,
282    pub line: String,
283}
284#[cfg(test)]
285#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
286#[path = "frames/tests.rs"]
287mod tests;