deepstrike_core/runtime/kernel/wire/envelope.rs
1//! Input envelope and the five-class input taxonomy (spec §7.1, §7.2).
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6use super::KernelBootstrapLimits;
7use super::command::HostCommand;
8use super::config::OperationConfig;
9use super::effect::EffectOutcome;
10use super::event::ExternalEvent;
11use super::root::{InitialContext, RootEntry};
12use super::scalar::{EffectId, InputId, OperationId, SCALAR_ERROR_MARKER, WireU64};
13
14// ---------------------------------------------------------------------------------------------
15// envelope
16// ---------------------------------------------------------------------------------------------
17
18/// The one shape a host may hand the kernel (spec §7.1 calls it `KernelEnvelope`).
19///
20/// Everything the kernel needs about *this delivery* lives here and nowhere else:
21///
22/// * `operation_id` — bound by the first accepted input, immutable afterwards;
23/// * `input_id` — the caller-suppliable idempotency key (DEC-2). Retrying an intent with the
24/// same key must reach the same durable record; hosts mint one only when the caller does not;
25/// * `observed_at_ms` — the **only** host clock fact. No business input, effect outcome or
26/// external event may carry a second wall clock (§11.2);
27/// * `input` — the five-class business payload, which never repeats any of the above.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct WireEnvelope {
31 pub operation_id: OperationId,
32 pub input_id: InputId,
33 pub observed_at_ms: WireU64,
34 pub input: KernelInput,
35}
36
37impl WireEnvelope {
38 pub fn new(
39 operation_id: OperationId,
40 input_id: InputId,
41 observed_at_ms: WireU64,
42 input: KernelInput,
43 ) -> Self {
44 Self {
45 operation_id,
46 input_id,
47 observed_at_ms,
48 input,
49 }
50 }
51}
52
53// ---------------------------------------------------------------------------------------------
54// taxonomy
55// ---------------------------------------------------------------------------------------------
56
57/// The closed five-class input taxonomy (§7.2).
58///
59/// The classes exist for **authority and lifecycle**, not to shrink an enum: each one enters a
60/// different validation path ([`KernelInput::authority`]) and is admissible in a different set of
61/// lifecycle states ([`KernelInput::admissible_lifecycles`]). Both tables are exhaustive matches
62/// — the historical `_ =>` catch-all that let any unlisted variant through while `Running` has no
63/// equivalent here.
64///
65/// P1 syscalls are deliberately **not** a sixth class: a caller is always derived from a
66/// kernel-owned pending effect or a task attempt, never declared by the host (§7.6).
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "snake_case")]
69pub enum KernelInput {
70 /// Genesis input. Decoded inside the absolute bootstrap boundary and normalised into a
71 /// resolved configuration before it becomes the first journal record.
72 ConfigureOperation(ConfigureOperation),
73 /// The single atomic root start: one entry, one initial context.
74 StartOperation(StartOperation),
75 /// The one entry point for the outcome of a kernel-owned pending effect.
76 ResolveEffect(ResolveEffect),
77 /// A fact the host observed (a signal, a child completion) — not an effect result.
78 DeliverExternalEvent(DeliverExternalEvent),
79 /// The live control plane: cancel, compaction, task/capability/knowledge/policy updates.
80 HostControl(HostControl),
81}
82
83impl KernelInput {
84 /// Which validation path this class enters. Distinct per class by construction.
85 pub fn authority(&self) -> InputAuthority {
86 match self {
87 Self::ConfigureOperation(_) => InputAuthority::HostBootstrap,
88 Self::StartOperation(_) => InputAuthority::HostRoot,
89 Self::ResolveEffect(_) => InputAuthority::HostEffectResolution,
90 Self::DeliverExternalEvent(_) => InputAuthority::HostObservedFact,
91 Self::HostControl(_) => InputAuthority::HostControlPlane,
92 }
93 }
94
95 /// Lifecycle states in which this class is admissible (§6.1). Terminal states appear in no
96 /// list: after a terminal every state-changing input is refused, `DeliverSignal` included
97 /// (DEC-4).
98 pub fn admissible_lifecycles(&self) -> &'static [OperationLifecycle] {
99 match self {
100 Self::ConfigureOperation(_) => &[OperationLifecycle::Created],
101 Self::StartOperation(_) => &[OperationLifecycle::Configured],
102 Self::ResolveEffect(_) | Self::DeliverExternalEvent(_) => {
103 &[OperationLifecycle::Running, OperationLifecycle::Suspended]
104 }
105 Self::HostControl(_) => &[
106 OperationLifecycle::Configured,
107 OperationLifecycle::Running,
108 OperationLifecycle::Suspended,
109 ],
110 }
111 }
112}
113
114/// The validation path an input class enters. One per class — the type-level statement that the
115/// taxonomy is about authority rather than enum arity.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum InputAuthority {
119 /// Boot configuration, admissible exactly once, before any execution exists.
120 HostBootstrap,
121 /// Root start authority: the host chooses the root entry, and only once.
122 HostRoot,
123 /// Resolution of an effect the kernel itself published and is still waiting on.
124 HostEffectResolution,
125 /// A fact the host observed about the outside world.
126 HostObservedFact,
127 /// Live control-plane commands against a running operation.
128 HostControlPlane,
129}
130
131/// Operation lifecycle (§6), owned directly by the canonical wire contract.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum OperationLifecycle {
135 Created,
136 Configured,
137 Running,
138 Suspended,
139 Completed,
140 Cancelled,
141 Failed,
142}
143
144impl OperationLifecycle {
145 pub fn is_terminal(self) -> bool {
146 matches!(self, Self::Completed | Self::Cancelled | Self::Failed)
147 }
148}
149
150/// §7.2 · `ConfigureOperation { config }`. The 16 historical setup events collapse here.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct ConfigureOperation {
154 pub config: OperationConfig,
155}
156
157/// §7.2 · `StartOperation { entry, initial_context }`. The five historical start-ish events
158/// collapse into this one atomic input; `initial_context` lives here and **only** here, never
159/// duplicated inside a [`RootEntry`] variant.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct StartOperation {
163 pub entry: RootEntry,
164 pub initial_context: InitialContext,
165}
166
167/// §7.2 · `ResolveEffect { effect_id, outcome }`. The 11 historical result events collapse here.
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct ResolveEffect {
171 pub effect_id: EffectId,
172 pub outcome: EffectOutcome,
173}
174
175/// §7.2 · `DeliverExternalEvent { event }`.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct DeliverExternalEvent {
179 pub event: ExternalEvent,
180}
181
182/// §7.2 · `HostControl { command }`. The 10 historical control events collapse here.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct HostControl {
186 pub command: HostCommand,
187}
188
189// ---------------------------------------------------------------------------------------------
190// rejection
191// ---------------------------------------------------------------------------------------------
192
193/// Why an envelope never became a typed input. Every kind is fail-closed: nothing is decoded,
194/// nothing is staged, no state moves.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum WireRejectionKind {
198 /// Absolute byte boundary, enforced before parsing.
199 InputTooLarge,
200 /// Absolute nesting boundary, enforced before parsing.
201 DepthExceeded,
202 /// Absolute per-container entry boundary, enforced before parsing.
203 CollectionTooLarge,
204 /// The bytes are not JSON at all.
205 MalformedJson,
206 /// A struct carried a field the contract does not define.
207 UnknownField,
208 /// A tagged union carried a tag the contract does not define.
209 UnknownVariant,
210 /// A required field is absent.
211 MissingField,
212 /// A scalar broke a §7.1.1 rule (decimal `u64`, fixed-point ratio, finite float, branded id…).
213 InvalidScalar,
214 /// A value had the wrong JSON type for its field.
215 TypeMismatch,
216 /// The document decoded, but a value — or a relationship between values — breaks a contract
217 /// rule: a cross-field invariant, an operation limit that would widen its bootstrap ceiling,
218 /// a live patch that would grow a quota, a stale policy revision.
219 ///
220 /// Distinct from [`Self::InvalidScalar`] on purpose. A scalar rejection means the bytes never
221 /// became a value and no host could have meant anything by them; a policy violation means the
222 /// host stated a coherent value the kernel refuses to adopt. The two need different host
223 /// handling — the first is a serialization bug, the second is a configuration decision — and
224 /// collapsing them makes "re-read and rebase this patch" indistinguishable from "your encoder
225 /// is broken".
226 PolicyViolation,
227}
228
229impl WireRejectionKind {
230 pub fn as_str(self) -> &'static str {
231 match self {
232 Self::InputTooLarge => "input_too_large",
233 Self::DepthExceeded => "depth_exceeded",
234 Self::CollectionTooLarge => "collection_too_large",
235 Self::MalformedJson => "malformed_json",
236 Self::UnknownField => "unknown_field",
237 Self::UnknownVariant => "unknown_variant",
238 Self::MissingField => "missing_field",
239 Self::InvalidScalar => "invalid_scalar",
240 Self::TypeMismatch => "type_mismatch",
241 Self::PolicyViolation => "policy_violation",
242 }
243 }
244}
245
246/// A structured decode rejection.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct WireRejection {
249 pub kind: WireRejectionKind,
250 pub message: String,
251}
252
253impl WireRejection {
254 pub fn new(kind: WireRejectionKind, message: impl Into<String>) -> Self {
255 Self {
256 kind,
257 message: message.into(),
258 }
259 }
260}
261
262impl fmt::Display for WireRejection {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 write!(f, "{}: {}", self.kind.as_str(), self.message)
265 }
266}
267
268impl std::error::Error for WireRejection {}
269
270// ---------------------------------------------------------------------------------------------
271// decode
272// ---------------------------------------------------------------------------------------------
273
274/// Decode one wire envelope in the mandated order: **measure bytes → absolute structural
275/// boundary → decode** (§7.1). Parsing before measuring would let an oversized
276/// or pathologically nested document allocate first and be rejected second.
277pub fn decode_envelope_json(
278 input_json: &str,
279 limits: &KernelBootstrapLimits,
280) -> Result<WireEnvelope, WireRejection> {
281 if input_json.len() > limits.absolute_max_input_bytes as usize {
282 return Err(WireRejection::new(
283 WireRejectionKind::InputTooLarge,
284 format!(
285 "kernel input is {} bytes; the absolute bound is {} bytes",
286 input_json.len(),
287 limits.absolute_max_input_bytes
288 ),
289 ));
290 }
291
292 scan_structural_boundary(input_json, limits)?;
293
294 serde_json::from_str(input_json).map_err(classify_serde_error)
295}
296
297/// Serialize an envelope back to JSON. Canonical record bytes are a separate, core-owned
298/// concern (Task 6); this is the plain wire projection.
299pub fn encode_envelope_json(envelope: &WireEnvelope) -> String {
300 serde_json::to_string(envelope).expect("wire envelope is always serializable")
301}
302
303fn classify_serde_error(error: serde_json::Error) -> WireRejection {
304 let message = error.to_string();
305 let kind = if error.classify() == serde_json::error::Category::Syntax
306 || error.classify() == serde_json::error::Category::Eof
307 {
308 WireRejectionKind::MalformedJson
309 } else if message.contains(SCALAR_ERROR_MARKER) {
310 WireRejectionKind::InvalidScalar
311 } else if message.contains("unknown field") {
312 WireRejectionKind::UnknownField
313 } else if message.contains("unknown variant") {
314 WireRejectionKind::UnknownVariant
315 } else if message.contains("missing field") {
316 WireRejectionKind::MissingField
317 } else {
318 WireRejectionKind::TypeMismatch
319 };
320 WireRejection::new(kind, message)
321}
322
323/// Single pass over the raw bytes enforcing the absolute nesting depth and per-container entry
324/// bounds **before** any parser allocates. This is a boundary check, not a validator: malformed
325/// JSON is still the parser's business.
326///
327/// Public so every kernel JSON boundary can enforce the same allocation limits before parsing.
328pub fn scan_structural_boundary(
329 input_json: &str,
330 limits: &KernelBootstrapLimits,
331) -> Result<(), WireRejection> {
332 /// (separators seen at this level, whether the container has any content)
333 type Frame = (u64, bool);
334
335 let mut stack: Vec<Frame> = Vec::new();
336 let mut in_string = false;
337 let mut escaped = false;
338
339 for &byte in input_json.as_bytes() {
340 if in_string {
341 if escaped {
342 escaped = false;
343 } else if byte == b'\\' {
344 escaped = true;
345 } else if byte == b'"' {
346 in_string = false;
347 }
348 continue;
349 }
350
351 match byte {
352 b'"' => {
353 in_string = true;
354 mark_content(&mut stack);
355 }
356 b'{' | b'[' => {
357 mark_content(&mut stack);
358 stack.push((0, false));
359 if stack.len() > limits.absolute_max_json_depth as usize {
360 return Err(WireRejection::new(
361 WireRejectionKind::DepthExceeded,
362 format!(
363 "kernel input nests {} levels deep; the absolute bound is {}",
364 stack.len(),
365 limits.absolute_max_json_depth
366 ),
367 ));
368 }
369 }
370 b'}' | b']' => {
371 let Some((separators, has_content)) = stack.pop() else {
372 // unbalanced — leave the diagnosis to the parser
373 return Ok(());
374 };
375 let entries = if has_content { separators + 1 } else { 0 };
376 if entries > u64::from(limits.absolute_max_collection_entries) {
377 return Err(WireRejection::new(
378 WireRejectionKind::CollectionTooLarge,
379 format!(
380 "kernel input has a container with {entries} entries; \
381 the absolute bound is {}",
382 limits.absolute_max_collection_entries
383 ),
384 ));
385 }
386 }
387 b',' => {
388 if let Some(frame) = stack.last_mut() {
389 frame.0 += 1;
390 frame.1 = true;
391 }
392 }
393 byte if byte.is_ascii_whitespace() => {}
394 _ => mark_content(&mut stack),
395 }
396 }
397
398 Ok(())
399}
400
401fn mark_content(stack: &mut [(u64, bool)]) {
402 if let Some(frame) = stack.last_mut() {
403 frame.1 = true;
404 }
405}