Skip to main content

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}