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 = 64;
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;