Skip to main content

deepstrike_core/runtime/kernel/wire/
record.rs

1//! The durable kernel record, its canonical bytes and its digest chain
2//! (spec §8.1, §8.2, §12.1, §15.2).
3//!
4//! Three properties define this module, and every public item exists to make one of them
5//! unrepresentable-if-violated rather than merely documented:
6//!
7//! 1. **Core is the only implementation of canonical bytes and digests** (§15.2). A record is
8//!    built by [`KernelRecord::chain`], which computes every digest itself; the fields are private
9//!    and read-only, so a host cannot hand-assemble a record or re-serialise one to recompute a
10//!    hash. Decoding a record re-verifies it, which is why a tampered journal entry fails at the
11//!    boundary instead of somewhere deep in a replay.
12//! 2. **The durable record never carries the planned step** (§8.1, §22.12). It stores the
13//!    normalised input plus a `step_digest`; a rebuild re-runs the deterministic transition over
14//!    the canonical input and compares digests. That is what keeps rendered provider contexts and
15//!    large action payloads out of the journal, so record size is a function of the *input*, never
16//!    of the step it produced.
17//! 3. **The chain is the operation's identity** (§12.1). The genesis record binds the
18//!    [`ResolvedOperationConfig`] — not the sparse config, and not "whatever this binary defaults
19//!    to today" — and carries no previous digest at all; its `record_digest` is the operation's
20//!    `genesis_digest`.
21
22use std::fmt;
23use std::fmt::Write as _;
24
25use serde::de::Deserializer;
26use serde::{Deserialize, Serialize};
27use sha2::{Digest as _, Sha256};
28
29use super::config::{ConfigDefaults, ResolvedOperationConfig};
30use super::effect::Digest;
31use super::envelope::{
32    DeliverExternalEvent, HostControl, KernelInput, OperationLifecycle, ResolveEffect,
33    StartOperation, WireEnvelope, WireRejection,
34};
35use super::fault::KernelFaultCode;
36use super::scalar::{CanonicalBytes, InputId, JS_SAFE_INTEGER_MAX, OperationId, WireU64};
37
38// ---------------------------------------------------------------------------------------------
39// errors
40// ---------------------------------------------------------------------------------------------
41
42/// Prefix of every record-layer rejection, so all four hosts can classify on one marker.
43pub const RECORD_ERROR_MARKER: &str = "kernel record rejected";
44
45/// Why a record could not be built, decoded or verified.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RecordError {
48    /// A value has no canonical byte representation (a non-finite float, an integer no JSON host
49    /// can hold exactly, a document nested past the canonical bound).
50    NotCanonical(String),
51    /// The record does not follow its predecessor: wrong operation, wrong sequence, wrong
52    /// previous digest, a second genesis, or a genesis that is not a configuration.
53    ChainBroken(String),
54    /// A stored digest disagrees with the bytes it claims to summarise — the record was edited
55    /// after core produced it.
56    DigestMismatch(String),
57}
58
59impl RecordError {
60    pub fn message(&self) -> &str {
61        match self {
62            Self::NotCanonical(message)
63            | Self::ChainBroken(message)
64            | Self::DigestMismatch(message) => message,
65        }
66    }
67
68    /// Fault code a host-facing rejection carries (§7.13).
69    ///
70    /// `DigestMismatch` is [`KernelFaultCode::RecordCorrupted`] — its own code since the 2026-07-29
71    /// adjudication, because a broken record and a broken checkpoint have different recovery
72    /// ladders and folding them together told a host to fall back to a checkpoint when the journal
73    /// itself was the thing that no longer verified.
74    ///
75    /// `ChainBroken` stays [`KernelFaultCode::TransactionConflict`]: the same variant reports "this
76    /// input has no legal position after that head" (a caller error, no corruption involved) and
77    /// "this stored chain does not link up". The transaction layer, which knows it is verifying
78    /// *stored* records rather than placing a new one, re-labels the latter as `RecordCorrupted`.
79    pub fn code(&self) -> KernelFaultCode {
80        match self {
81            Self::NotCanonical(_) => KernelFaultCode::MalformedEnvelope,
82            Self::ChainBroken(_) => KernelFaultCode::TransactionConflict,
83            Self::DigestMismatch(_) => KernelFaultCode::RecordCorrupted,
84        }
85    }
86}
87
88impl fmt::Display for RecordError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{RECORD_ERROR_MARKER}: {}", self.message())
91    }
92}
93
94impl std::error::Error for RecordError {}
95
96// ---------------------------------------------------------------------------------------------
97// canonical bytes (§7.1.1)
98// ---------------------------------------------------------------------------------------------
99
100/// Nesting bound of the canonical writer. Well above the §7.3 bootstrap depth ceiling: this is a
101/// stack guard for a recursive writer, not a second contract limit.
102pub const CANONICAL_MAX_DEPTH: usize = 128;
103
104/// Digest algorithm label. Carried in every [`Digest`] as an explicit `sha256:` prefix so a future
105/// algorithm change is a visible wire change rather than a silent reinterpretation.
106pub const DIGEST_ALGORITHM: &str = "sha256";
107
108/// Serialise a value to canonical bytes.
109///
110/// The rules, in full — they are the contract every host validator re-implements in Phase 6:
111///
112/// | shape | canonical form |
113/// | --- | --- |
114/// | `null` / `true` / `false` | the literal |
115/// | string | JSON string, minimal escaping (`"`, `\`, C0 controls) |
116/// | integer | shortest decimal; `-0` is `0`; magnitudes above 2^53−1 are **rejected** |
117/// | non-integral float | shortest round-trip decimal |
118/// | array | `[a,b]` — no whitespace |
119/// | object | `{"a":1,"b":2}` — keys ascending by Unicode code point, no whitespace |
120///
121/// Two rules earn their keep. Integers beyond the double-safe range are rejected rather than
122/// emitted, because a JS host that parses record bytes would silently round them — every logical
123/// `u64` on this wire already travels as a decimal string (§7.1.1), so the only way to hit this is
124/// an opaque host payload, and failing closed beats a digest that means two different numbers in
125/// two languages. Key ordering is by **code point**, which for UTF-8 is byte order; a JavaScript
126/// validator must therefore sort with a code-point comparator rather than the default
127/// UTF-16 `Array.prototype.sort`, which differs above the BMP.
128pub fn canonical_bytes<T: Serialize + ?Sized>(value: &T) -> Result<CanonicalBytes, RecordError> {
129    let value = serde_json::to_value(value).map_err(|error| {
130        RecordError::NotCanonical(format!("value is not serialisable: {error}"))
131    })?;
132    let mut out = String::new();
133    write_canonical(&value, 1, &mut out)?;
134    Ok(CanonicalBytes::new(out.into_bytes()))
135}
136
137/// SHA-256 over exactly these bytes, projected as `sha256:<64 lowercase hex digits>`.
138pub fn canonical_digest(bytes: &[u8]) -> Digest {
139    let hash = Sha256::digest(bytes);
140    let mut text = String::with_capacity(DIGEST_ALGORITHM.len() + 1 + hash.len() * 2);
141    text.push_str(DIGEST_ALGORITHM);
142    text.push(':');
143    for byte in hash {
144        write!(text, "{byte:02x}").expect("writing to a String cannot fail");
145    }
146    Digest::new(text).expect("an algorithm-prefixed hex digest is always a legal branded ref")
147}
148
149fn write_canonical(
150    value: &serde_json::Value,
151    depth: usize,
152    out: &mut String,
153) -> Result<(), RecordError> {
154    if depth > CANONICAL_MAX_DEPTH {
155        return Err(RecordError::NotCanonical(format!(
156            "value nests deeper than {CANONICAL_MAX_DEPTH}"
157        )));
158    }
159    match value {
160        serde_json::Value::Null => out.push_str("null"),
161        serde_json::Value::Bool(true) => out.push_str("true"),
162        serde_json::Value::Bool(false) => out.push_str("false"),
163        serde_json::Value::Number(number) => write_canonical_number(number, out)?,
164        serde_json::Value::String(text) => write_canonical_string(text, out),
165        serde_json::Value::Array(items) => {
166            out.push('[');
167            for (index, item) in items.iter().enumerate() {
168                if index > 0 {
169                    out.push(',');
170                }
171                write_canonical(item, depth + 1, out)?;
172            }
173            out.push(']');
174        }
175        serde_json::Value::Object(map) => {
176            let mut keys: Vec<&String> = map.keys().collect();
177            keys.sort_unstable();
178            out.push('{');
179            for (index, key) in keys.into_iter().enumerate() {
180                if index > 0 {
181                    out.push(',');
182                }
183                write_canonical_string(key, out);
184                out.push(':');
185                write_canonical(&map[key], depth + 1, out)?;
186            }
187            out.push('}');
188        }
189    }
190    Ok(())
191}
192
193fn write_canonical_string(text: &str, out: &mut String) {
194    let encoded = serde_json::to_string(text).expect("a Rust string is always JSON-encodable");
195    out.push_str(&encoded);
196}
197
198fn write_canonical_number(
199    number: &serde_json::Number,
200    out: &mut String,
201) -> Result<(), RecordError> {
202    const SAFE: i128 = JS_SAFE_INTEGER_MAX as i128;
203
204    let unsafe_integer = |value: i128| {
205        RecordError::NotCanonical(format!(
206            "integer {value} exceeds the cross-language exact-integer range \
207             (±{JS_SAFE_INTEGER_MAX}); logical u64 travels as a decimal string"
208        ))
209    };
210
211    if let Some(value) = number.as_u64() {
212        if i128::from(value) > SAFE {
213            return Err(unsafe_integer(i128::from(value)));
214        }
215        write!(out, "{value}").expect("writing to a String cannot fail");
216        return Ok(());
217    }
218    if let Some(value) = number.as_i64() {
219        if i128::from(value) < -SAFE {
220            return Err(unsafe_integer(i128::from(value)));
221        }
222        write!(out, "{value}").expect("writing to a String cannot fail");
223        return Ok(());
224    }
225
226    let float = number
227        .as_f64()
228        .filter(|value| value.is_finite())
229        .ok_or_else(|| RecordError::NotCanonical(format!("number {number} is not finite")))?;
230    // An integral float and the same integer must produce the same bytes, or two numerically
231    // equal documents would digest differently. `-0.0` collapses into `0` here too.
232    if float.fract() == 0.0 && float.abs() <= JS_SAFE_INTEGER_MAX as f64 {
233        write!(out, "{}", float as i64).expect("writing to a String cannot fail");
234    } else {
235        let encoded = serde_json::to_string(&float).expect("a finite f64 is always JSON-encodable");
236        out.push_str(&encoded);
237    }
238    Ok(())
239}
240
241// ---------------------------------------------------------------------------------------------
242// §8.1 · the normalised input a record stores
243// ---------------------------------------------------------------------------------------------
244
245/// One accepted envelope after normalisation — the shape whose canonical bytes the record stores
246/// (§12.1 calls the serialised form a `CanonicalInput`).
247///
248/// It mirrors [`WireEnvelope`] with exactly one difference: the genesis arm carries the **resolved**
249/// configuration instead of the sparse one. That single substitution is what makes a journal
250/// replayable across kernel upgrades (§15.2, Task 6b) — every default this operation runs on is
251/// frozen in its first record, so changing a compile-time default cannot change an old operation's
252/// decisions.
253///
254/// The envelope-shaped wrapper is deliberate: `observed_at_ms` is the operation's only clock fact
255/// (§11.2) and `input_id` is its idempotency key (§7.1), so a canonical input that dropped them
256/// could not be replayed on its own.
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct NormalizedInput {
260    pub operation_id: OperationId,
261    pub input_id: InputId,
262    pub observed_at_ms: WireU64,
263    pub input: NormalizedPayload,
264}
265
266/// The five input classes after normalisation. Same tag vocabulary as [`KernelInput`] — a record
267/// does not invent a second name for an input class.
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269#[serde(tag = "kind", rename_all = "snake_case")]
270pub enum NormalizedPayload {
271    /// Genesis. Carries the dense [`ResolvedOperationConfig`], never the sparse wire config.
272    ConfigureOperation(ResolvedConfiguration),
273    StartOperation(StartOperation),
274    ResolveEffect(ResolveEffect),
275    DeliverExternalEvent(DeliverExternalEvent),
276    HostControl(HostControl),
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(deny_unknown_fields)]
281pub struct ResolvedConfiguration {
282    pub config: ResolvedOperationConfig,
283}
284
285impl NormalizedPayload {
286    /// Whether this payload may only appear as the first record of an operation.
287    pub fn is_genesis(&self) -> bool {
288        matches!(self, Self::ConfigureOperation(_))
289    }
290
291    pub fn kind(&self) -> &'static str {
292        match self {
293            Self::ConfigureOperation(_) => "configure_operation",
294            Self::StartOperation(_) => "start_operation",
295            Self::ResolveEffect(_) => "resolve_effect",
296            Self::DeliverExternalEvent(_) => "deliver_external_event",
297            Self::HostControl(_) => "host_control",
298        }
299    }
300
301    /// §6.1 · the lifecycles this class is admissible in, read off the *normalised* payload.
302    ///
303    /// The same table [`KernelInput::admissible_lifecycles`] states, reachable from a record. A
304    /// restore replays canonical inputs rather than envelopes and must apply exactly the same
305    /// lifecycle gate, so the table has to be reachable from both sides of normalisation — and
306    /// delegating keeps there being one table.
307    pub fn admissible_lifecycles(&self) -> &'static [OperationLifecycle] {
308        match self {
309            Self::ConfigureOperation(_) => &[OperationLifecycle::Created],
310            Self::StartOperation(_) => &[OperationLifecycle::Configured],
311            Self::ResolveEffect(_) | Self::DeliverExternalEvent(_) => {
312                &[OperationLifecycle::Running, OperationLifecycle::Suspended]
313            }
314            Self::HostControl(_) => &[
315                OperationLifecycle::Configured,
316                OperationLifecycle::Running,
317                OperationLifecycle::Suspended,
318            ],
319        }
320    }
321}
322
323impl NormalizedInput {
324    /// Normalise one decoded envelope. The genesis arm resolves its configuration against the
325    /// binary's defaults **once**, here; every later reader takes the resolved value from the
326    /// record instead of re-deriving it.
327    pub fn normalize(
328        envelope: &WireEnvelope,
329        defaults: &ConfigDefaults,
330    ) -> Result<Self, WireRejection> {
331        let input = match &envelope.input {
332            KernelInput::ConfigureOperation(configure) => {
333                NormalizedPayload::ConfigureOperation(ResolvedConfiguration {
334                    config: configure.config.resolve(defaults)?,
335                })
336            }
337            KernelInput::StartOperation(start) => NormalizedPayload::StartOperation(start.clone()),
338            KernelInput::ResolveEffect(resolve) => {
339                NormalizedPayload::ResolveEffect(resolve.clone())
340            }
341            KernelInput::DeliverExternalEvent(event) => {
342                NormalizedPayload::DeliverExternalEvent(event.clone())
343            }
344            KernelInput::HostControl(control) => NormalizedPayload::HostControl(control.clone()),
345        };
346        Ok(Self {
347            operation_id: envelope.operation_id.clone(),
348            input_id: envelope.input_id.clone(),
349            observed_at_ms: envelope.observed_at_ms,
350            input,
351        })
352    }
353
354    pub fn is_genesis(&self) -> bool {
355        self.input.is_genesis()
356    }
357
358    /// The resolved configuration, on the genesis input only.
359    pub fn resolved_config(&self) -> Option<&ResolvedOperationConfig> {
360        match &self.input {
361            NormalizedPayload::ConfigureOperation(configure) => Some(&configure.config),
362            _ => None,
363        }
364    }
365}
366
367// ---------------------------------------------------------------------------------------------
368// §8.1 · the durable record
369// ---------------------------------------------------------------------------------------------
370
371/// One durable transition (§8.1).
372///
373/// Every field is private and every digest is computed by [`KernelRecord::chain`]: there is no
374/// constructor that takes a digest, so "the host recomputed the hash and disagreed" is not a
375/// reachable state. [`KernelRecord::record_bytes`] is what the host hands to
376/// `KernelJournal::compare_and_append`, and [`KernelRecord::expected_head`] is the CAS precondition
377/// that goes with it.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
379pub struct KernelRecord {
380    operation_id: OperationId,
381    input_id: InputId,
382    step_seq: WireU64,
383    /// `None` on the genesis record only — the CAS expected head of an operation's first append is
384    /// empty (§8.1).
385    previous_record_digest: Option<Digest>,
386    /// Canonical bytes of the [`NormalizedInput`]. Projected as an explicit base64 envelope in
387    /// JSON (§7.1.1); native bindings see bytes.
388    canonical_input: CanonicalBytes,
389    input_digest: Digest,
390    /// Digest of the ephemeral planned step. The step itself never enters the journal (§22.12).
391    step_digest: Digest,
392    record_digest: Digest,
393}
394
395/// Everything a chain successor needs to know about its predecessor.
396///
397/// Exactly three facts — the operation it belongs to, where it sits, and what it hashes to — and
398/// deliberately not the record itself. §12.2 restores a runtime whose predecessor record may have
399/// been pruned under an acked checkpoint; the anchor is what survives that.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct ChainAnchor {
402    pub operation_id: OperationId,
403    pub step_seq: WireU64,
404    pub record_digest: Digest,
405}
406
407/// The digested body: every field of a record except the digest that summarises it.
408#[derive(Serialize)]
409struct RecordBody<'a> {
410    operation_id: &'a OperationId,
411    input_id: &'a InputId,
412    step_seq: WireU64,
413    previous_record_digest: Option<&'a Digest>,
414    canonical_input: &'a CanonicalBytes,
415    input_digest: &'a Digest,
416    step_digest: &'a Digest,
417}
418
419impl KernelRecord {
420    /// Build the next record of an operation.
421    ///
422    /// `previous = None` builds the genesis record: `step_seq` 0, no previous digest, and a payload
423    /// that **must** be the resolved configuration. Every later record must carry a non-genesis
424    /// payload, the same `operation_id`, and links to its predecessor's digest — a second
425    /// `ConfigureOperation` has no legal position in a chain (§6.1: it is admissible only in
426    /// `Created`).
427    pub fn chain<S: Serialize + ?Sized>(
428        previous: Option<&Self>,
429        input: &NormalizedInput,
430        planned_step: &S,
431    ) -> Result<Self, RecordError> {
432        Self::chain_after(previous.map(Self::anchor).as_ref(), input, planned_step)
433    }
434
435    /// The three facts a successor reads off this record.
436    pub fn anchor(&self) -> ChainAnchor {
437        ChainAnchor {
438            operation_id: self.operation_id.clone(),
439            step_seq: self.step_seq,
440            record_digest: self.record_digest.clone(),
441        }
442    }
443
444    /// [`Self::chain`], anchored on the predecessor's *facts* rather than on the predecessor.
445    ///
446    /// The distinction is what makes §12.2's bounded-tail restore possible at all: a restored
447    /// runtime replays the tail on top of a checkpoint whose covered prefix may already have been
448    /// pruned, so the record before the first tail entry no longer exists anywhere — only its
449    /// digest and its sequence do, and the checkpoint carries them.
450    pub fn chain_after<S: Serialize + ?Sized>(
451        previous: Option<&ChainAnchor>,
452        input: &NormalizedInput,
453        planned_step: &S,
454    ) -> Result<Self, RecordError> {
455        let (step_seq, previous_record_digest) = match previous {
456            None => {
457                if !input.is_genesis() {
458                    return Err(RecordError::ChainBroken(format!(
459                        "an operation's first record must be its resolved configuration, got {}",
460                        input.input.kind()
461                    )));
462                }
463                (WireU64::ZERO, None)
464            }
465            Some(previous) => {
466                if input.is_genesis() {
467                    return Err(RecordError::ChainBroken(
468                        "an operation is configured exactly once; a second configure_operation \
469                         has no position in the chain"
470                            .to_string(),
471                    ));
472                }
473                if previous.operation_id != input.operation_id {
474                    return Err(RecordError::ChainBroken(format!(
475                        "input belongs to operation {}, but the chain head belongs to {}",
476                        input.operation_id, previous.operation_id
477                    )));
478                }
479                let next = previous.step_seq.get().checked_add(1).ok_or_else(|| {
480                    RecordError::ChainBroken("step sequence overflowed u64".to_string())
481                })?;
482                (WireU64::new(next), Some(previous.record_digest.clone()))
483            }
484        };
485
486        let canonical_input = canonical_bytes(input)?;
487        let input_digest = canonical_digest(canonical_input.as_slice());
488        let step_digest = canonical_digest(canonical_bytes(planned_step)?.as_slice());
489        let record_digest = Self::body_digest(
490            &input.operation_id,
491            &input.input_id,
492            step_seq,
493            previous_record_digest.as_ref(),
494            &canonical_input,
495            &input_digest,
496            &step_digest,
497        )?;
498
499        Ok(Self {
500            operation_id: input.operation_id.clone(),
501            input_id: input.input_id.clone(),
502            step_seq,
503            previous_record_digest,
504            canonical_input,
505            input_digest,
506            step_digest,
507            record_digest,
508        })
509    }
510
511    #[allow(clippy::too_many_arguments)]
512    fn body_digest(
513        operation_id: &OperationId,
514        input_id: &InputId,
515        step_seq: WireU64,
516        previous_record_digest: Option<&Digest>,
517        canonical_input: &CanonicalBytes,
518        input_digest: &Digest,
519        step_digest: &Digest,
520    ) -> Result<Digest, RecordError> {
521        let body = RecordBody {
522            operation_id,
523            input_id,
524            step_seq,
525            previous_record_digest,
526            canonical_input,
527            input_digest,
528            step_digest,
529        };
530        Ok(canonical_digest(canonical_bytes(&body)?.as_slice()))
531    }
532
533    // ----- read-only accessors -----
534
535    pub fn operation_id(&self) -> &OperationId {
536        &self.operation_id
537    }
538
539    pub fn input_id(&self) -> &InputId {
540        &self.input_id
541    }
542
543    pub fn step_seq(&self) -> WireU64 {
544        self.step_seq
545    }
546
547    pub fn previous_record_digest(&self) -> Option<&Digest> {
548        self.previous_record_digest.as_ref()
549    }
550
551    /// The CAS precondition for appending this record (§8.2 line 5). `None` means "the operation
552    /// has no journal head yet", which only its genesis record may assert.
553    pub fn expected_head(&self) -> Option<&Digest> {
554        self.previous_record_digest.as_ref()
555    }
556
557    pub fn canonical_input(&self) -> &CanonicalBytes {
558        &self.canonical_input
559    }
560
561    pub fn input_digest(&self) -> &Digest {
562        &self.input_digest
563    }
564
565    pub fn step_digest(&self) -> &Digest {
566        &self.step_digest
567    }
568
569    pub fn record_digest(&self) -> &Digest {
570        &self.record_digest
571    }
572
573    pub fn is_genesis(&self) -> bool {
574        self.previous_record_digest.is_none()
575    }
576
577    // ----- journal projection -----
578
579    /// Canonical bytes of the whole record — what the journal stores and what
580    /// [`Self::from_record_bytes`] reads back.
581    pub fn record_bytes(&self) -> CanonicalBytes {
582        canonical_bytes(self).expect("a record contains only canonical scalars")
583    }
584
585    /// Decode a record from its journal bytes, verifying every digest it carries.
586    pub fn from_record_bytes(bytes: &[u8]) -> Result<Self, RecordError> {
587        let text = std::str::from_utf8(bytes).map_err(|error| {
588            RecordError::NotCanonical(format!("record bytes are not UTF-8: {error}"))
589        })?;
590        serde_json::from_str(text).map_err(|error| decode_error(&error.to_string()))
591    }
592
593    /// Decode the normalised input this record froze — the entry point of a rebuild (§12.2).
594    pub fn normalized_input(&self) -> Result<NormalizedInput, RecordError> {
595        let text = std::str::from_utf8(self.canonical_input.as_slice()).map_err(|error| {
596            RecordError::NotCanonical(format!("canonical input is not UTF-8: {error}"))
597        })?;
598        serde_json::from_str(text).map_err(|error| {
599            RecordError::NotCanonical(format!("canonical input does not decode: {error}"))
600        })
601    }
602
603    // ----- verification -----
604
605    /// Recompute this record's own digests from the bytes it carries.
606    pub fn verify(&self) -> Result<(), RecordError> {
607        let input_digest = canonical_digest(self.canonical_input.as_slice());
608        if input_digest != self.input_digest {
609            return Err(RecordError::DigestMismatch(format!(
610                "record {} step {}: canonical input hashes to {input_digest}, \
611                 but the record claims {}",
612                self.operation_id, self.step_seq, self.input_digest
613            )));
614        }
615        let record_digest = Self::body_digest(
616            &self.operation_id,
617            &self.input_id,
618            self.step_seq,
619            self.previous_record_digest.as_ref(),
620            &self.canonical_input,
621            &self.input_digest,
622            &self.step_digest,
623        )?;
624        if record_digest != self.record_digest {
625            return Err(RecordError::DigestMismatch(format!(
626                "record {} step {}: body hashes to {record_digest}, but the record claims {}",
627                self.operation_id, self.step_seq, self.record_digest
628            )));
629        }
630        Ok(())
631    }
632
633    /// Verify a rebuilt step against the digest this record froze (§8.1).
634    pub fn verify_step<S: Serialize + ?Sized>(&self, planned_step: &S) -> Result<(), RecordError> {
635        let digest = canonical_digest(canonical_bytes(planned_step)?.as_slice());
636        if digest != self.step_digest {
637            return Err(RecordError::DigestMismatch(format!(
638                "record {} step {}: the rebuilt step hashes to {digest}, \
639                 but the record froze {}",
640                self.operation_id, self.step_seq, self.step_digest
641            )));
642        }
643        Ok(())
644    }
645
646    /// Verify this record follows `previous` (`None` = it claims to be a genesis).
647    pub fn verify_follows(&self, previous: Option<&Self>) -> Result<(), RecordError> {
648        self.verify()?;
649        match (previous, self.previous_record_digest.as_ref()) {
650            (None, None) => {
651                if self.step_seq != WireU64::ZERO {
652                    return Err(RecordError::ChainBroken(format!(
653                        "a genesis record is step 0, got step {}",
654                        self.step_seq
655                    )));
656                }
657                if self.normalized_input()?.is_genesis() {
658                    Ok(())
659                } else {
660                    Err(RecordError::ChainBroken(
661                        "a genesis record must carry the resolved configuration".to_string(),
662                    ))
663                }
664            }
665            (None, Some(digest)) => Err(RecordError::ChainBroken(format!(
666                "record {} step {} expects head {digest}, but the operation has no head",
667                self.operation_id, self.step_seq
668            ))),
669            (Some(previous), None) => Err(RecordError::ChainBroken(format!(
670                "record {} step {} claims to be a genesis, but the operation head is {}",
671                self.operation_id, self.step_seq, previous.record_digest
672            ))),
673            (Some(previous), Some(digest)) => {
674                if previous.operation_id != self.operation_id {
675                    return Err(RecordError::ChainBroken(format!(
676                        "record belongs to operation {}, its predecessor to {}",
677                        self.operation_id, previous.operation_id
678                    )));
679                }
680                if digest != &previous.record_digest {
681                    return Err(RecordError::ChainBroken(format!(
682                        "record {} step {} expects head {digest}, but the head is {}",
683                        self.operation_id, self.step_seq, previous.record_digest
684                    )));
685                }
686                if previous.step_seq.get().checked_add(1) != Some(self.step_seq.get()) {
687                    return Err(RecordError::ChainBroken(format!(
688                        "record {} is step {}, but its predecessor is step {}",
689                        self.operation_id, self.step_seq, previous.step_seq
690                    )));
691                }
692                Ok(())
693            }
694        }
695    }
696}
697
698/// The §7.13 preparation result once its durable half is known: [`KernelRecord`] is the `Record`
699/// instance of [`KernelPreparation`], and Task 7 fills in the ephemeral planned step.
700pub type RecordPreparation<Step> = super::fault::KernelPreparation<KernelRecord, Step>;
701
702/// Verify a whole chain and return the operation's `genesis_digest` (§12.1).
703///
704/// The returned digest is the identity a checkpoint binds itself to: an operation is its genesis
705/// record's digest, so a checkpoint built from another operation's journal cannot be installed by
706/// accident.
707pub fn verify_record_chain(records: &[KernelRecord]) -> Result<&Digest, RecordError> {
708    let Some(genesis) = records.first() else {
709        return Err(RecordError::ChainBroken(
710            "an operation chain starts at its genesis record; this one is empty".to_string(),
711        ));
712    };
713    genesis.verify_follows(None)?;
714    for pair in records.windows(2) {
715        pair[1].verify_follows(Some(&pair[0]))?;
716    }
717    Ok(&genesis.record_digest)
718}
719
720// ---------------------------------------------------------------------------------------------
721// decoding
722// ---------------------------------------------------------------------------------------------
723
724/// Wire projection of a record, used only as the decode target. Decoding goes through it so that
725/// [`KernelRecord`]'s fields stay private and every decoded record is verified before it exists.
726#[derive(Deserialize)]
727#[serde(deny_unknown_fields)]
728struct RecordProjection {
729    operation_id: OperationId,
730    input_id: InputId,
731    step_seq: WireU64,
732    previous_record_digest: Option<Digest>,
733    canonical_input: CanonicalBytes,
734    input_digest: Digest,
735    step_digest: Digest,
736    record_digest: Digest,
737}
738
739fn decode_error(message: &str) -> RecordError {
740    if message.contains(RECORD_ERROR_MARKER) {
741        RecordError::DigestMismatch(message.to_string())
742    } else {
743        RecordError::NotCanonical(format!("record does not decode: {message}"))
744    }
745}
746
747impl<'de> Deserialize<'de> for KernelRecord {
748    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
749        let projection = RecordProjection::deserialize(deserializer)?;
750        let record = Self {
751            operation_id: projection.operation_id,
752            input_id: projection.input_id,
753            step_seq: projection.step_seq,
754            previous_record_digest: projection.previous_record_digest,
755            canonical_input: projection.canonical_input,
756            input_digest: projection.input_digest,
757            step_digest: projection.step_digest,
758            record_digest: projection.record_digest,
759        };
760        record
761            .verify()
762            .map_err(|error| serde::de::Error::custom(error.to_string()))?;
763        Ok(record)
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use std::collections::BTreeSet;
770    use std::fs;
771    use std::path::PathBuf;
772
773    use serde_json::{Value, json};
774
775    use super::super::*;
776
777    // -----------------------------------------------------------------------------------------
778    // helpers
779    // -----------------------------------------------------------------------------------------
780
781    fn operation() -> OperationId {
782        OperationId::new("op-record-1").unwrap()
783    }
784
785    fn input_id(seq: u32) -> InputId {
786        InputId::new(format!("in-{seq}")).unwrap()
787    }
788
789    fn boot_config() -> OperationConfig {
790        OperationConfig {
791            execution_policy: Some(ExecutionPolicy {
792                max_turns: Some(12),
793                ..ExecutionPolicy::default()
794            }),
795            host_effect_support: HostEffectSupport::new([
796                EffectKindTag::CallProvider,
797                EffectKindTag::ExecuteTools,
798            ]),
799            ..OperationConfig::default()
800        }
801    }
802
803    fn envelope(seq: u32, observed_at_ms: u64, input: KernelInput) -> WireEnvelope {
804        WireEnvelope::new(
805            operation(),
806            input_id(seq),
807            WireU64::new(observed_at_ms),
808            input,
809        )
810    }
811
812    fn configure_envelope() -> WireEnvelope {
813        envelope(
814            0,
815            1_700_000_000_000,
816            KernelInput::ConfigureOperation(ConfigureOperation {
817                config: boot_config(),
818            }),
819        )
820    }
821
822    fn start_envelope() -> WireEnvelope {
823        envelope(
824            1,
825            1_700_000_000_500,
826            KernelInput::StartOperation(StartOperation {
827                entry: RootEntry::Agent(RootAgentEntry {
828                    task: LogicalTask::new("write the brief"),
829                    run_spec: None,
830                }),
831                initial_context: InitialContext::default(),
832            }),
833        )
834    }
835
836    fn resolve_envelope() -> WireEnvelope {
837        envelope(
838            2,
839            1_700_000_001_000,
840            KernelInput::ResolveEffect(ResolveEffect {
841                effect_id: EffectId::new("op-record-1:step:1:effect:0").unwrap(),
842                outcome: EffectOutcome::Failed(EffectFailed {
843                    failure: HostEffectFailure {
844                        kind: HostEffectFailureKind::ProtocolError,
845                        message: "provider refused the request".to_string(),
846                        retryable: Some(false),
847                    },
848                }),
849            }),
850        )
851    }
852
853    fn cancel_envelope() -> WireEnvelope {
854        envelope(
855            3,
856            1_700_000_002_000,
857            KernelInput::HostControl(HostControl {
858                command: HostCommand::Cancel(CancelCommand {
859                    reason: CancellationReason::User,
860                    pending_call_ids: vec![],
861                }),
862            }),
863        )
864    }
865
866    fn normalize(envelope: &WireEnvelope) -> NormalizedInput {
867        NormalizedInput::normalize(envelope, &ConfigDefaults::default())
868            .expect("the sample envelope normalises")
869    }
870
871    fn step(name: &str) -> Value {
872        json!({ "planned": name, "effects": [{ "kind": "call_provider" }] })
873    }
874
875    fn genesis_record() -> KernelRecord {
876        KernelRecord::chain(None, &normalize(&configure_envelope()), &step("configure")).unwrap()
877    }
878
879    /// genesis then start then resolve
880    fn sample_chain() -> Vec<KernelRecord> {
881        let genesis = genesis_record();
882        let started = KernelRecord::chain(
883            Some(&genesis),
884            &normalize(&start_envelope()),
885            &step("start"),
886        )
887        .unwrap();
888        let resolved = KernelRecord::chain(
889            Some(&started),
890            &normalize(&resolve_envelope()),
891            &step("resolve"),
892        )
893        .unwrap();
894        vec![genesis, started, resolved]
895    }
896
897    fn canonical_text<T: Serialize + ?Sized>(value: &T) -> String {
898        String::from_utf8(canonical_bytes(value).unwrap().into_vec()).unwrap()
899    }
900
901    fn fixture_dir() -> PathBuf {
902        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
903    }
904
905    /// Read a frozen golden, or rewrite it when `BLESS_KERNEL_RECORD_FIXTURES=1`.
906    ///
907    /// The blessing path exists so a deliberate contract change is a one-command, reviewable diff;
908    /// the default path is an assertion that today's bytes are the frozen bytes.
909    fn golden(name: &str, produced: &Value) -> Value {
910        let path = fixture_dir().join(name);
911        if std::env::var("BLESS_KERNEL_RECORD_FIXTURES").as_deref() == Ok("1") {
912            let mut text = serde_json::to_string_pretty(produced).unwrap();
913            text.push('\n');
914            fs::write(&path, text).unwrap_or_else(|e| panic!("cannot bless {name}: {e}"));
915            return produced.clone();
916        }
917        let raw = fs::read_to_string(&path).unwrap_or_else(|e| {
918            panic!("missing golden {name} ({e}); re-bless with BLESS_KERNEL_RECORD_FIXTURES=1")
919        });
920        serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{name} is not JSON: {e}"))
921    }
922
923    // -----------------------------------------------------------------------------------------
924    // canonical bytes (spec 7.1.1)
925    // -----------------------------------------------------------------------------------------
926
927    #[test]
928    fn canonical_bytes_sort_keys_and_carry_no_whitespace() {
929        assert_eq!(
930            canonical_text(&json!({ "b": 1, "a": { "d": [1, 2], "c": true }, "": null })),
931            r#"{"":null,"a":{"c":true,"d":[1,2]},"b":1}"#
932        );
933        assert_eq!(canonical_text(&json!([])), "[]");
934        assert_eq!(canonical_text(&json!({})), "{}");
935        assert_eq!(
936            canonical_text(&json!("quote \" backslash \\ newline \n tab \t")),
937            r#""quote \" backslash \\ newline \n tab \t""#
938        );
939        assert_eq!(canonical_text(&json!("\u{1}")), "\"\\u0001\"");
940    }
941
942    #[test]
943    fn canonical_object_keys_sort_by_code_point_not_utf16() {
944        // U+10000 precedes U+FFFD under UTF-16 ordering (its lead surrogate is 0xD800) and follows
945        // it under code-point ordering. Canonical bytes use code points, so a JavaScript validator
946        // must sort with a code-point comparator rather than the default one.
947        assert_eq!(
948            canonical_text(&json!({ "\u{10000}": 1, "\u{fffd}": 2, "z": 3 })),
949            "{\"z\":3,\"\u{fffd}\":2,\"\u{10000}\":1}"
950        );
951    }
952
953    #[test]
954    fn canonical_numbers_are_language_neutral() {
955        assert_eq!(canonical_text(&json!(0)), "0");
956        assert_eq!(canonical_text(&json!(-0.0)), "0", "-0 and 0 are one value");
957        assert_eq!(canonical_text(&json!(2.0)), "2", "2 and 2.0 are one value");
958        assert_eq!(canonical_text(&json!(-17)), "-17");
959        assert_eq!(canonical_text(&json!(0.5)), "0.5");
960        assert_eq!(
961            canonical_text(&json!(JS_SAFE_INTEGER_MAX)),
962            "9007199254740991"
963        );
964    }
965
966    #[test]
967    fn canonical_bytes_reject_what_no_host_can_read_back() {
968        let too_large = serde_json::from_str::<Value>("9007199254740992").unwrap();
969        let error = canonical_bytes(&too_large).expect_err("beyond the exact-integer range");
970        assert!(matches!(error, RecordError::NotCanonical(_)), "{error}");
971
972        let too_negative = serde_json::from_str::<Value>("-9007199254740992").unwrap();
973        assert!(canonical_bytes(&too_negative).is_err());
974
975        let mut deep = json!(0);
976        for _ in 0..(CANONICAL_MAX_DEPTH + 2) {
977            deep = Value::Array(vec![deep]);
978        }
979        assert!(canonical_bytes(&deep).is_err(), "recursion must be bounded");
980    }
981
982    #[test]
983    fn canonical_bytes_are_byte_identical_across_repeated_runs() {
984        let input = normalize(&configure_envelope());
985        let first = canonical_bytes(&input).unwrap();
986        for _ in 0..8 {
987            assert_eq!(canonical_bytes(&input).unwrap(), first);
988        }
989
990        // and a value assembled in a different key order is the same value
991        let one = canonical_bytes(&json!({ "a": 1, "b": 2 })).unwrap();
992        let other = canonical_bytes(&json!({ "b": 2, "a": 1 })).unwrap();
993        assert_eq!(one, other);
994    }
995
996    #[test]
997    fn the_digest_is_sha256_over_exactly_the_canonical_bytes() {
998        // known answers: the algorithm is plain SHA-256, hex, lowercase
999        assert_eq!(
1000            canonical_digest(b"").as_str(),
1001            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1002        );
1003        assert_eq!(
1004            canonical_digest(b"abc").as_str(),
1005            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1006        );
1007        assert_eq!(
1008            canonical_digest(canonical_text(&json!({ "a": 1 })).as_bytes()).as_str(),
1009            canonical_digest(br#"{"a":1}"#).as_str()
1010        );
1011    }
1012
1013    // -----------------------------------------------------------------------------------------
1014    // genesis (spec 8.1, 12.1, Task 6b)
1015    // -----------------------------------------------------------------------------------------
1016
1017    #[test]
1018    fn genesis_binds_the_resolved_config_and_an_empty_previous_head() {
1019        let record = genesis_record();
1020
1021        assert!(record.is_genesis());
1022        assert_eq!(record.step_seq(), WireU64::ZERO);
1023        assert_eq!(record.previous_record_digest(), None);
1024        assert_eq!(
1025            record.expected_head(),
1026            None,
1027            "genesis appends against an empty head"
1028        );
1029
1030        let stored = record.normalized_input().unwrap();
1031        let resolved = stored
1032            .resolved_config()
1033            .expect("genesis stores a resolved config");
1034        assert_eq!(
1035            resolved,
1036            &boot_config().resolve(&ConfigDefaults::default()).unwrap(),
1037            "the genesis record freezes the resolved configuration, not the sparse one"
1038        );
1039        assert_eq!(resolved.execution_policy.max_turns, 12);
1040
1041        // and it is dense: every field a later replay needs is present, no Option stands for
1042        // "ask the binary".
1043        let value = serde_json::to_value(resolved).unwrap();
1044        for required in [
1045            "execution_policy",
1046            "governance_policy",
1047            "scheduler_policy",
1048            "resource_quota",
1049            "signal_policy",
1050            "context_policy",
1051            "recovery_policy",
1052            "payload_policy",
1053            "kernel_limits",
1054            "memory_policy",
1055            "feature_policy",
1056            "host_effect_support",
1057        ] {
1058            assert!(
1059                value.get(required).is_some(),
1060                "resolved config lacks {required}"
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn the_genesis_digest_is_the_operations_identity() {
1067        let chain = sample_chain();
1068        let genesis_digest = verify_record_chain(&chain).unwrap();
1069        assert_eq!(genesis_digest, chain[0].record_digest());
1070        assert_eq!(
1071            chain[1].previous_record_digest(),
1072            Some(genesis_digest),
1073            "the first transition links straight to the genesis digest"
1074        );
1075    }
1076
1077    #[test]
1078    fn a_genesis_replay_survives_kernel_default_drift() {
1079        // A newer binary with different compile-time defaults resolves *new* configs differently...
1080        let mut drifted = ConfigDefaults::default();
1081        drifted.baseline.execution_policy.max_context_tokens = 999;
1082        drifted.baseline.recovery_policy.provider_recovery_attempts = 9;
1083
1084        let record = genesis_record();
1085        // ...but a rebuild reads the frozen resolved config out of the record instead of resolving
1086        // again, so the drift cannot reach the replay.
1087        let replayed = record.normalized_input().unwrap();
1088        assert_eq!(
1089            replayed
1090                .resolved_config()
1091                .unwrap()
1092                .execution_policy
1093                .max_context_tokens,
1094            ConfigDefaults::default()
1095                .baseline
1096                .execution_policy
1097                .max_context_tokens
1098        );
1099        assert_ne!(
1100            drifted.baseline.execution_policy.max_context_tokens,
1101            replayed
1102                .resolved_config()
1103                .unwrap()
1104                .execution_policy
1105                .max_context_tokens,
1106            "the fixture must actually drift, or this test proves nothing"
1107        );
1108        assert_eq!(
1109            canonical_bytes(&replayed).unwrap(),
1110            *record.canonical_input()
1111        );
1112        record
1113            .verify_step(&step("configure"))
1114            .expect("the frozen step still verifies");
1115    }
1116
1117    // -----------------------------------------------------------------------------------------
1118    // the chain (spec 8.1, 15.2)
1119    // -----------------------------------------------------------------------------------------
1120
1121    #[test]
1122    fn each_record_links_to_its_predecessor() {
1123        let chain = sample_chain();
1124        for (index, record) in chain.iter().enumerate() {
1125            assert_eq!(record.step_seq(), WireU64::new(index as u64));
1126            assert_eq!(record.operation_id(), &operation());
1127            if index == 0 {
1128                assert!(record.previous_record_digest().is_none());
1129            } else {
1130                assert_eq!(
1131                    record.previous_record_digest(),
1132                    Some(chain[index - 1].record_digest())
1133                );
1134            }
1135        }
1136        verify_record_chain(&chain).unwrap();
1137    }
1138
1139    #[test]
1140    fn the_chain_refuses_a_genesis_in_the_wrong_position() {
1141        // a non-configuration cannot open an operation
1142        let error = KernelRecord::chain(None, &normalize(&start_envelope()), &step("start"))
1143            .expect_err("only a resolved configuration opens a chain");
1144        assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1145
1146        // and a configuration cannot re-open one
1147        let genesis = genesis_record();
1148        let error = KernelRecord::chain(
1149            Some(&genesis),
1150            &normalize(&configure_envelope()),
1151            &step("configure again"),
1152        )
1153        .expect_err("an operation is configured exactly once");
1154        assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1155    }
1156
1157    #[test]
1158    fn a_record_cannot_chain_onto_another_operation() {
1159        let genesis = genesis_record();
1160        let mut foreign = normalize(&start_envelope());
1161        foreign.operation_id = OperationId::new("op-other").unwrap();
1162        let error = KernelRecord::chain(Some(&genesis), &foreign, &step("start"))
1163            .expect_err("operations do not share a chain");
1164        assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1165    }
1166
1167    #[test]
1168    fn tampering_with_any_link_is_detected() {
1169        let chain = sample_chain();
1170
1171        // 1. an edited field inside a record no longer matches its own digest
1172        for field in [
1173            "operation_id",
1174            "input_id",
1175            "step_seq",
1176            "input_digest",
1177            "step_digest",
1178            "previous_record_digest",
1179        ] {
1180            let mut value = serde_json::to_value(&chain[2]).unwrap();
1181            value[field] = match field {
1182                "step_seq" => json!("99"),
1183                "previous_record_digest" | "input_digest" | "step_digest" => {
1184                    json!(canonical_digest(b"forged").as_str())
1185                }
1186                _ => json!("forged"),
1187            };
1188            let error = serde_json::from_value::<KernelRecord>(value)
1189                .expect_err(&format!("editing {field} must be detected"));
1190            assert!(
1191                error.to_string().contains(RECORD_ERROR_MARKER),
1192                "{field}: {error}"
1193            );
1194        }
1195
1196        // 2. an edited canonical input no longer matches input_digest
1197        let mut value = serde_json::to_value(&chain[1]).unwrap();
1198        value["canonical_input"]["data"] =
1199            serde_json::to_value(CanonicalBytes::new(b"{}".to_vec())).unwrap()["data"].clone();
1200        assert!(serde_json::from_value::<KernelRecord>(value).is_err());
1201
1202        // 3. a re-digested forgery passes verify() but breaks the chain
1203        let forged = KernelRecord::chain(
1204            Some(&chain[0]),
1205            &normalize(&cancel_envelope()),
1206            &step("forged"),
1207        )
1208        .unwrap();
1209        let mut broken = chain.clone();
1210        broken[1] = forged;
1211        let error = verify_record_chain(&broken).expect_err("link 2 no longer follows link 1");
1212        assert!(matches!(error, RecordError::ChainBroken(_)), "{error}");
1213
1214        // 4. dropping a link is a broken chain, not a shorter one
1215        let gapped = vec![chain[0].clone(), chain[2].clone()];
1216        assert!(verify_record_chain(&gapped).is_err());
1217
1218        // 5. an empty journal has no genesis
1219        assert!(verify_record_chain(&[]).is_err());
1220    }
1221
1222    #[test]
1223    fn a_record_verifies_the_step_a_rebuild_recomputes() {
1224        let chain = sample_chain();
1225        chain[1]
1226            .verify_step(&step("start"))
1227            .expect("the same step verifies");
1228        let error = chain[1]
1229            .verify_step(&step("something else"))
1230            .expect_err("a different step must not verify");
1231        assert!(matches!(error, RecordError::DigestMismatch(_)), "{error}");
1232    }
1233
1234    // -----------------------------------------------------------------------------------------
1235    // the record never carries the step (spec 8.1, 22.12)
1236    // -----------------------------------------------------------------------------------------
1237
1238    #[test]
1239    fn the_record_is_exactly_the_eight_declared_fields() {
1240        let value = serde_json::to_value(genesis_record()).unwrap();
1241        let keys: BTreeSet<String> = value.as_object().unwrap().keys().cloned().collect();
1242        assert_eq!(
1243            keys,
1244            BTreeSet::from([
1245                "operation_id".to_string(),
1246                "input_id".to_string(),
1247                "step_seq".to_string(),
1248                "previous_record_digest".to_string(),
1249                "canonical_input".to_string(),
1250                "input_digest".to_string(),
1251                "step_digest".to_string(),
1252                "record_digest".to_string(),
1253            ])
1254        );
1255    }
1256
1257    #[test]
1258    fn no_planned_step_survives_into_the_durable_record() {
1259        const BANNED: [&str; 8] = [
1260            "step",
1261            "planned_step",
1262            "committed_step",
1263            "actions",
1264            "effects",
1265            "rendered_context",
1266            "messages",
1267            "faults",
1268        ];
1269
1270        let record = KernelRecord::chain(
1271            None,
1272            &normalize(&configure_envelope()),
1273            &json!({
1274                "actions": [{ "kind": "call_provider" }],
1275                "rendered_context": { "messages": [{ "role": "user", "content": "x" }] },
1276                "effects": [{ "effect_id": "op-record-1:step:0:effect:0" }],
1277            }),
1278        )
1279        .unwrap();
1280
1281        let text = String::from_utf8(record.record_bytes().into_vec()).unwrap();
1282        let value: Value = serde_json::from_str(&text).unwrap();
1283        let mut keys = BTreeSet::new();
1284        collect_keys(&value, &mut keys);
1285        for banned in BANNED {
1286            assert!(
1287                !keys.contains(banned),
1288                "the record leaked the step key {banned:?}"
1289            );
1290        }
1291        assert!(!text.contains("rendered_context"));
1292        assert!(!text.contains("call_provider"));
1293    }
1294
1295    #[test]
1296    fn record_size_is_a_function_of_the_input_not_of_the_step() {
1297        let input = normalize(&resolve_envelope());
1298        let genesis = genesis_record();
1299
1300        let tiny = KernelRecord::chain(Some(&genesis), &input, &json!({})).unwrap();
1301        let huge = KernelRecord::chain(
1302            Some(&genesis),
1303            &input,
1304            &json!({ "rendered_context": "x".repeat(200_000) }),
1305        )
1306        .unwrap();
1307
1308        assert_eq!(
1309            tiny.record_bytes().len(),
1310            huge.record_bytes().len(),
1311            "a 200 KiB step must not grow the journal record by one byte"
1312        );
1313        assert_ne!(tiny.step_digest(), huge.step_digest());
1314        assert_eq!(tiny.input_digest(), huge.input_digest());
1315    }
1316
1317    fn collect_keys(value: &Value, out: &mut BTreeSet<String>) {
1318        match value {
1319            Value::Object(map) => {
1320                for (key, item) in map {
1321                    out.insert(key.clone());
1322                    collect_keys(item, out);
1323                }
1324            }
1325            Value::Array(items) => items.iter().for_each(|item| collect_keys(item, out)),
1326            _ => {}
1327        }
1328    }
1329
1330    // -----------------------------------------------------------------------------------------
1331    // journal projection (spec 8.2)
1332    // -----------------------------------------------------------------------------------------
1333
1334    #[test]
1335    fn records_round_trip_through_their_journal_bytes() {
1336        for record in sample_chain() {
1337            let bytes = record.record_bytes();
1338            let back = KernelRecord::from_record_bytes(bytes.as_slice()).unwrap();
1339            assert_eq!(back, record);
1340            assert_eq!(back.record_bytes(), bytes, "journal bytes are stable");
1341
1342            // the canonical input decodes back into the typed input a rebuild replays
1343            let input = record.normalized_input().unwrap();
1344            assert_eq!(canonical_bytes(&input).unwrap(), *record.canonical_input());
1345        }
1346    }
1347
1348    #[test]
1349    fn a_record_with_removed_or_unknown_fields_fails_closed() {
1350        let mut value = serde_json::to_value(genesis_record()).unwrap();
1351        value["abi_version"] = json!(1);
1352        assert!(serde_json::from_value::<KernelRecord>(value).is_err());
1353
1354        let mut value = serde_json::to_value(genesis_record()).unwrap();
1355        value["surprise"] = json!(true);
1356        assert!(
1357            serde_json::from_value::<KernelRecord>(value).is_err(),
1358            "a record with an unknown field is not a record"
1359        );
1360    }
1361
1362    #[test]
1363    fn the_record_is_the_durable_half_of_a_preparation() {
1364        let record = genesis_record();
1365        let preparation: RecordPreparation<Value> =
1366            KernelPreparation::Prepared(PreparedTransition {
1367                token: PrepareToken::new("prepare-1").unwrap(),
1368                record: record.clone(),
1369                planned_step: step("configure"),
1370            });
1371
1372        assert_eq!(preparation.record(), Some(&record));
1373        assert!(
1374            preparation.token().is_some(),
1375            "only a prepared transition commits"
1376        );
1377
1378        let text = serde_json::to_string(&preparation).unwrap();
1379        let back: RecordPreparation<Value> = serde_json::from_str(&text).unwrap();
1380        assert_eq!(back, preparation);
1381
1382        // the planned step travels with the preparation and stops there
1383        record
1384            .verify_step(preparation.step().unwrap())
1385            .expect("the preparation's step is the one the record froze");
1386    }
1387
1388    // -----------------------------------------------------------------------------------------
1389    // golden fixtures: the Phase 6 cross-language source of truth
1390    // -----------------------------------------------------------------------------------------
1391
1392    #[test]
1393    fn golden_canonical_bytes_vectors() {
1394        let vectors = vec![
1395            ("empty_object", json!({})),
1396            ("empty_array", json!([])),
1397            ("key_order", json!({ "b": 1, "A": 2, "a": 3, "": 4 })),
1398            (
1399                "code_point_key_order",
1400                json!({ "\u{10000}": 1, "\u{fffd}": 2, "z": 3 }),
1401            ),
1402            ("escapes", json!("\" \\ \n \t \u{1} \u{e9} \u{1f600}")),
1403            (
1404                "numbers",
1405                json!([0, -0.0, 2.0, -17, 0.5, 9007199254740991u64]),
1406            ),
1407            (
1408                "null_and_bools",
1409                json!({ "a": null, "b": true, "c": false }),
1410            ),
1411            (
1412                "nested",
1413                json!({ "outer": { "inner": [1, { "deep": "value" }] } }),
1414            ),
1415            (
1416                "wire_u64_is_a_string",
1417                json!({ "step_seq": "18446744073709551615" }),
1418            ),
1419        ];
1420
1421        let produced = json!({
1422            "description":
1423                "Canonical byte vectors (spec 7.1.1). `canonical` is the exact UTF-8 byte string \
1424                 core produces; `digest` is SHA-256 over those bytes, hex, sha256-prefixed. \
1425                 Object keys sort by Unicode code point.",
1426            "vectors": vectors
1427                .iter()
1428                .map(|(name, value)| {
1429                    let canonical = canonical_text(value);
1430                    json!({
1431                        "name": name,
1432                        "value": value,
1433                        "canonical": canonical,
1434                        "digest": canonical_digest(canonical.as_bytes()).as_str(),
1435                    })
1436                })
1437                .collect::<Vec<_>>(),
1438            "rejected": [
1439                { "name": "integer_beyond_exact_range", "value": 9007199254740992u64 },
1440                { "name": "negative_integer_beyond_exact_range", "value": -9007199254740992i64 },
1441            ],
1442        });
1443
1444        let expected = golden("golden_record_canonical_bytes.json", &produced);
1445        assert_eq!(produced, expected, "canonical byte vectors drifted");
1446
1447        for rejected in expected["rejected"].as_array().unwrap() {
1448            assert!(
1449                canonical_bytes(&rejected["value"]).is_err(),
1450                "{} must have no canonical form",
1451                rejected["name"]
1452            );
1453        }
1454    }
1455
1456    #[test]
1457    fn golden_genesis_record() {
1458        let envelope = configure_envelope();
1459        let input = normalize(&envelope);
1460        let planned = step("configure");
1461        let record = KernelRecord::chain(None, &input, &planned).unwrap();
1462
1463        let produced = json!({
1464            "description":
1465                "Genesis record (spec 8.1, 12.1). `canonical_input` holds the resolved \
1466                 configuration, `previous_record_digest` is null, and `record_digest` is the \
1467                 operation's genesis_digest.",
1468            "envelope": serde_json::to_value(&envelope).unwrap(),
1469            "step": planned,
1470            "normalized_input": serde_json::to_value(&input).unwrap(),
1471            "canonical_input": canonical_text(&input),
1472            "record": serde_json::to_value(&record).unwrap(),
1473            "record_bytes": String::from_utf8(record.record_bytes().into_vec()).unwrap(),
1474            "genesis_digest": record.record_digest().as_str(),
1475        });
1476
1477        let expected = golden("golden_record_genesis.json", &produced);
1478        assert_eq!(produced, expected, "the genesis record drifted");
1479        assert_eq!(expected["record"]["previous_record_digest"], Value::Null);
1480    }
1481
1482    #[test]
1483    fn golden_transition_record() {
1484        let genesis = genesis_record();
1485        let envelope = resolve_envelope();
1486        let input = normalize(&envelope);
1487        let planned = step("resolve");
1488        let record = KernelRecord::chain(Some(&genesis), &input, &planned).unwrap();
1489
1490        let produced = json!({
1491            "description":
1492                "A non-genesis record (spec 8.1). It stores the normalised envelope plus a step \
1493                 digest, never the planned step, and links to `previous_record`'s digest.",
1494            "previous_record": serde_json::to_value(&genesis).unwrap(),
1495            "envelope": serde_json::to_value(&envelope).unwrap(),
1496            "step": planned,
1497            "normalized_input": serde_json::to_value(&input).unwrap(),
1498            "canonical_input": canonical_text(&input),
1499            "record": serde_json::to_value(&record).unwrap(),
1500            "record_bytes": String::from_utf8(record.record_bytes().into_vec()).unwrap(),
1501        });
1502
1503        let expected = golden("golden_record_transition.json", &produced);
1504        assert_eq!(produced, expected, "the transition record drifted");
1505
1506        // the fixture is self-checking: its previous record really is this record's head
1507        let previous: KernelRecord =
1508            serde_json::from_value(expected["previous_record"].clone()).unwrap();
1509        let decoded: KernelRecord = serde_json::from_value(expected["record"].clone()).unwrap();
1510        decoded.verify_follows(Some(&previous)).unwrap();
1511    }
1512
1513    #[test]
1514    fn golden_record_chain_of_three() {
1515        let envelopes = [configure_envelope(), start_envelope(), resolve_envelope()];
1516        let steps = [step("configure"), step("start"), step("resolve")];
1517
1518        let mut records: Vec<KernelRecord> = Vec::new();
1519        let mut links = Vec::new();
1520        for (envelope, planned) in envelopes.iter().zip(steps.iter()) {
1521            let input = normalize(envelope);
1522            let record = KernelRecord::chain(records.last(), &input, planned).unwrap();
1523            links.push(json!({
1524                "envelope": serde_json::to_value(envelope).unwrap(),
1525                "step": planned,
1526                "record": serde_json::to_value(&record).unwrap(),
1527            }));
1528            records.push(record);
1529        }
1530
1531        let produced = json!({
1532            "description":
1533                "Three-link record chain (spec 8.1, 12.1). Replaying the envelopes through \
1534                 normalisation and KernelRecord::chain must reproduce every record byte for \
1535                 byte; `genesis_digest` is the operation's identity and `head_digest` its CAS head.",
1536            "genesis_digest": records[0].record_digest().as_str(),
1537            "head_digest": records[2].record_digest().as_str(),
1538            "links": links,
1539        });
1540
1541        let expected = golden("golden_record_chain.json", &produced);
1542        assert_eq!(produced, expected, "the record chain drifted");
1543
1544        let decoded: Vec<KernelRecord> = expected["links"]
1545            .as_array()
1546            .unwrap()
1547            .iter()
1548            .map(|link| serde_json::from_value(link["record"].clone()).unwrap())
1549            .collect();
1550        assert_eq!(
1551            verify_record_chain(&decoded).unwrap().as_str(),
1552            expected["genesis_digest"].as_str().unwrap()
1553        );
1554    }
1555}