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