Skip to main content

deepstrike_core/runtime/kernel/wire/
checkpoint.rs

1//! The logical checkpoint and its bounded tail (spec §12).
2//!
3//! A checkpoint is *not* a snapshot of the kernel's internals. It is the canonical DTO whose
4//! shape is a contract in its own right, and every design rule below corrects the retired
5//! full-journal recovery format:
6//!
7//! 1. **Nothing here is derived from a private layout.** [`LogicalKernelState`] is built by an
8//!    explicit projection — [`LogicalStateProjection`] from the semantic driver, the transition
9//!    partition from the transaction — so a field added to `LoopStateMachine` cannot silently
10//!    change the checkpoint format, and a field this DTO needs cannot silently disappear. The old
11//!    snapshot serialised the whole last planned step, rendered context and all, which made
12//!    the blob a function of the *rendered prompt* rather than of the state.
13//! 2. **Every piece of correctness state has exactly one home.** The four partitions of §12.1 —
14//!    transition / syscall / scheduler / context_vm — partition the state, they do not overlap it:
15//!    pending effects, the input replay ledger and the terminal live in `transition`, task attempts
16//!    in `scheduler`, P3 handles in `context_vm`, and the checkpoint header repeats none of them.
17//!    `single_ownership_is_structural` proves it by scanning the serialised document.
18//! 3. **The bounded tail is exact.** `tail_inputs` covers `(base_step_seq, through_step_seq]` with
19//!    no hole, no duplicate and nothing outside the range — checked at construction, so a
20//!    checkpoint that would replay a different history than the journal did is not constructible.
21//! 4. **Three digests, three questions.** `state_digest` answers "is this the logical state that
22//!    was captured", `tail_digest` answers "is this the tail that was captured", and
23//!    `checkpoint_digest` answers "is this the whole checkpoint, header included". They all use the
24//!    record layer's canonical bytes, so a host validator that already implements §7.1.1 for
25//!    records needs no second serialiser.
26//!
27//! What this module deliberately does **not** do: install, restore, rebase or ack. §12.3's second
28//! half is Task 16. What exists here is *generation* — [`KernelTransaction::checkpoint_candidate`]
29//! and the shapes it produces — plus the verification a restore will call into.
30
31use std::fmt;
32
33use serde::de::{self, Deserializer, Visitor};
34use serde::{Deserialize, Serialize, Serializer};
35
36use super::config::ResolvedOperationConfig;
37use super::effect::{Digest, KernelEffect, LaunchToken, wire_opaque_ref};
38use super::envelope::OperationLifecycle;
39use super::fault::{KernelFault, KernelFaultCode};
40use super::record::{NormalizedInput, RecordError, canonical_bytes, canonical_digest};
41use super::root::{ExecutionFocus, LogicalAgentSpec, LogicalTask, RootKind};
42use super::scalar::{
43    AttemptId, BoundedJson, CanonicalBytes, EffectId, InputId, MemoryBindingId, NodeId,
44    OperationId, SCALAR_ERROR_MARKER, SignalId, TaskId, WireScalarError, WireU64, WorkflowId,
45};
46use super::syscall::MemoryKind;
47use super::terminal::KernelTerminal;
48
49// ---------------------------------------------------------------------------------------------
50// errors
51// ---------------------------------------------------------------------------------------------
52
53/// Prefix of every checkpoint-layer rejection, so all four hosts classify on one marker.
54pub const CHECKPOINT_ERROR_MARKER: &str = "kernel checkpoint rejected";
55
56/// Why a checkpoint could not be assembled, decoded or verified.
57///
58/// The split is the recovery ladder, not the field that failed. `Incompatible` means "this blob is
59/// not for this kernel or not for this operation" — a host answers it by looking for a different
60/// checkpoint. `Corrupted` means "this blob claims to be ours and does not hold together" — the
61/// only answer is an older checkpoint plus more tail.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum CheckpointError {
64    /// Wrong checkpoint revision, wrong ABI revision, wrong operation, or a genesis/head this
65    /// operation never had.
66    Incompatible(String),
67    /// A digest disagrees with the bytes it summarises, or the bounded tail does not cover
68    /// `(base_step_seq, through_step_seq]` exactly.
69    Corrupted(String),
70    /// The value has no canonical byte representation at all.
71    NotCanonical(String),
72}
73
74impl CheckpointError {
75    pub fn message(&self) -> &str {
76        match self {
77            Self::Incompatible(message)
78            | Self::Corrupted(message)
79            | Self::NotCanonical(message) => message,
80        }
81    }
82
83    pub fn code(&self) -> KernelFaultCode {
84        match self {
85            Self::Incompatible(_) => KernelFaultCode::CheckpointIncompatible,
86            Self::Corrupted(_) => KernelFaultCode::CheckpointCorrupted,
87            Self::NotCanonical(_) => KernelFaultCode::MalformedEnvelope,
88        }
89    }
90
91    /// Host-facing projection (§7.13).
92    pub fn fault(&self) -> KernelFault {
93        KernelFault::new(self.code(), self.to_string())
94    }
95}
96
97impl fmt::Display for CheckpointError {
98    /// The rendered form names its own code.
99    ///
100    /// Not cosmetic: a checkpoint rejected inside `serde` reaches the caller as a *string*, and
101    /// [`from_checkpoint_bytes`](KernelCheckpoint::from_checkpoint_bytes) has to recover the class
102    /// from it. Printing the code is what keeps that recovery from being a guess based on which
103    /// words happen to be in the message.
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(
106            f,
107            "{CHECKPOINT_ERROR_MARKER} ({}): {}",
108            self.code().as_str(),
109            self.message()
110        )
111    }
112}
113
114impl std::error::Error for CheckpointError {}
115
116impl From<RecordError> for CheckpointError {
117    fn from(error: RecordError) -> Self {
118        Self::NotCanonical(error.message().to_string())
119    }
120}
121
122wire_opaque_ref!(
123    /// Handle the host returns to `ack_checkpoint` once the blob is durably installed (§12.3).
124    ///
125    /// It is **not** a [`KernelInput`](super::envelope::KernelInput) (§12.3 rule 4): acking is
126    /// runtime maintenance, it writes no record, and a crash between install and ack is recovered
127    /// from the installed checkpoint anyway. Deriving it from the checkpoint's own digest is what
128    /// makes "ack a checkpoint that was never handed out" unrepresentable.
129    CheckpointAckToken,
130    "checkpoint ack token"
131);
132
133// ---------------------------------------------------------------------------------------------
134// §12.1 · the bounded tail
135// ---------------------------------------------------------------------------------------------
136
137/// One accepted input inside a checkpoint's bounded tail.
138///
139/// It carries the normalised input (so the tail can be **replayed**) and the digest of the record
140/// that input produced (so the replay can be **verified** against the journal it came from).
141/// Neither alone is enough: digests without inputs make a checkpoint auditable but useless, and
142/// inputs without digests make a restore that silently disagrees with the journal possible.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct CanonicalInput {
146    pub step_seq: WireU64,
147    pub record_digest: Digest,
148    pub input: NormalizedInput,
149}
150
151impl CanonicalInput {
152    /// Project one durable record into its tail entry.
153    pub fn from_record(record: &super::record::KernelRecord) -> Result<Self, CheckpointError> {
154        Ok(Self {
155            step_seq: record.step_seq(),
156            record_digest: record.record_digest().clone(),
157            input: record.normalized_input()?,
158        })
159    }
160}
161
162// ---------------------------------------------------------------------------------------------
163// §12.1 · the four logical partitions
164// ---------------------------------------------------------------------------------------------
165
166/// The canonical logical state of one operation, partitioned as §12.1 requires.
167///
168/// The four fields are a *partition*: each piece of correctness state appears in exactly one of
169/// them, and the checkpoint header above repeats none of it. That is the property the historical
170/// snapshot lacked — it stored pending effects at the top level, again inside `last_step`, and a
171/// third time in the resumed-outcome vectors, so "which copy is authoritative" was decided by
172/// whichever restore path happened to run.
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct LogicalKernelState {
176    pub transition: TransitionState,
177    pub syscall: SyscallState,
178    pub scheduler: SchedulerState,
179    pub context_vm: ContextVmState,
180}
181
182/// §12.1 · operation lifecycle, execution focus, the effect ledger, input replay, cancellation and
183/// the terminal.
184///
185/// The step sequence is deliberately **not** here: the checkpoint header already states
186/// `base_step_seq` and `through_step_seq`, and §12.1's "the header must not duplicate sub-state"
187/// cuts both ways.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct TransitionState {
191    pub lifecycle: OperationLifecycle,
192    /// The configuration the genesis record froze (§8.1, §15.2 item 8).
193    ///
194    /// Here rather than "read it off the genesis record", because §12.3 rule 6 lets an acked
195    /// checkpoint reclaim the journal prefix *including genesis*: after that, the checkpoint is the
196    /// only place the resolved configuration still exists, and every later step is planned against
197    /// it. `genesis_digest` in the header keeps binding the identity; this carries the content.
198    pub resolved_config: ResolvedOperationConfig,
199    /// Immutable after the root start commits (§6.1.5).
200    #[serde(default)]
201    pub root_kind: Option<RootKind>,
202    /// Where control is (§7.4). Moves only on a committed transition, so a checkpoint states it
203    /// rather than deriving it.
204    #[serde(default)]
205    pub focus: Option<ExecutionFocus>,
206    /// The operation's only clock fact (§11.2): the newest accepted `observed_at_ms`. A restore
207    /// must not accept an input that precedes it.
208    pub last_observed_at_ms: WireU64,
209    /// Effects published by committed records and not yet resolved. **The** home of pending
210    /// effects — no other partition, and not the header.
211    #[serde(default)]
212    pub pending_effects: Vec<KernelEffect>,
213    /// Effects already answered, with the digest of the outcome that answered them. This is what
214    /// makes a redelivered `ResolveEffect` a `Replayed` instead of a second record (DEC-1).
215    #[serde(default)]
216    pub resolved_effects: Vec<ResolvedEffectState>,
217    /// Launch tokens the kernel minted with a `SpawnTasks` effect. Effect-resolution bookkeeping,
218    /// not task state — the task table lives in [`SchedulerState`].
219    #[serde(default)]
220    pub launch_tokens: Vec<LaunchTokenState>,
221    /// §12.3 rule 7 · the input replay/dedupe ledger. An ack must never empty it: it is what turns
222    /// a redelivery into an idempotent answer instead of a second durable record.
223    #[serde(default)]
224    pub accepted_inputs: Vec<AcceptedInputState>,
225    /// The cancellation this operation already accepted, so a retry of it is answered rather than
226    /// refused by the terminal it created.
227    #[serde(default)]
228    pub accepted_cancellation: Option<AcceptedCancellationState>,
229    /// The committed terminal. **The** home of the terminal.
230    #[serde(default)]
231    pub terminal: Option<KernelTerminal>,
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct ResolvedEffectState {
237    pub effect_id: EffectId,
238    pub outcome_digest: Digest,
239    pub input_id: InputId,
240    pub step_seq: WireU64,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct LaunchTokenState {
246    pub launch_token: LaunchToken,
247    pub step_seq: WireU64,
248}
249
250/// One entry of the replay ledger (§12.3 rules 7 and 10).
251///
252/// The digest is what makes the ledger answerable on its own. Below `base_step_seq` a restored
253/// runtime holds no step and, once retention has reclaimed the prefix, no record either — so the
254/// guarantee a redelivery gets down there is **idempotent acknowledgement, not step reproduction**:
255/// "input X is step N, record D". That is exactly what a caller retrying a lost response needs, and
256/// it is all a checkpoint has to remember.
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct AcceptedInputState {
260    pub input_id: InputId,
261    pub step_seq: WireU64,
262    pub record_digest: Digest,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct AcceptedCancellationState {
268    /// Digest of the canonical cancel command, so "the same cancellation" is decided by bytes.
269    pub command_digest: Digest,
270    pub input_id: InputId,
271    pub step_seq: WireU64,
272}
273
274/// §12.1 · governance revision, the live policy, the rate-limit window, and the provider-tool
275/// causation the P1 gate derives a caller from.
276#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct SyscallState {
279    /// §13.2 · the revision two concurrent policy writers race on. `None` before the genesis
280    /// record installs the policy.
281    #[serde(default)]
282    pub policy_revision: Option<WireU64>,
283    /// The live-mutable configuration as patched so far. Distinct from the genesis record's
284    /// resolved configuration, which is frozen — this is the value the gate reads today.
285    #[serde(default)]
286    pub live_config: Option<ResolvedOperationConfig>,
287    /// §7.6 · the provider calls this operation is waiting on and the tool surface each one
288    /// advertised. A tool call naming anything else has no causation to derive from.
289    #[serde(default)]
290    pub provider_calls: Vec<PendingProviderCallState>,
291    /// Tool call ids that already produced a syscall. A causation is spent once.
292    #[serde(default)]
293    pub consumed_call_ids: Vec<String>,
294    /// §22.13 · what the kernel authored for each pending memory write. The resolution reports
295    /// these, never what the host echoes back.
296    #[serde(default)]
297    pub authored_memory_writes: Vec<AuthoredMemoryWriteState>,
298    #[serde(default)]
299    pub authored_memory_queries: Vec<AuthoredMemoryQueryState>,
300    /// Rolling window of accepted memory-write timestamps, in the operation's own clock. The
301    /// window is a gate input, so dropping it at a checkpoint would hand the run a fresh quota.
302    #[serde(default)]
303    pub memory_write_window_ms: Vec<WireU64>,
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct PendingProviderCallState {
309    pub effect_id: EffectId,
310    /// The task whose turn issued the call — the caller a syscall inherits.
311    pub task_id: TaskId,
312    pub exposed_tools: Vec<String>,
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct AuthoredMemoryWriteState {
318    pub effect_id: EffectId,
319    pub binding_id: MemoryBindingId,
320    pub name: String,
321    pub kind: MemoryKind,
322    pub size_bytes: u32,
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct AuthoredMemoryQueryState {
328    pub effect_id: EffectId,
329    pub binding_id: MemoryBindingId,
330    pub text: String,
331    pub requested_k: u32,
332}
333
334/// §12.1 · the P2 plane: task control blocks and their attempts, budgets and waits, the workflow
335/// graph, queued signals plus dedupe memory, and the milestone cascade.
336#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct SchedulerState {
339    /// The active agent's logical run contract. Required even when absent so checkpoints from the
340    /// retired shape cannot be mistaken for current state.
341    pub run_spec: Option<LogicalAgentSpec>,
342    /// Exact task-tool surface advertised by the most recent provider call. `None` means no
343    /// provider call has been emitted yet and is fail-closed, not permissive.
344    pub advertised_tool_ids: Option<Vec<String>>,
345    pub turn: u32,
346    pub total_tokens: WireU64,
347    pub rounds_completed: u32,
348    pub subagents_spawned: u32,
349    /// First observed clock of the run, the anchor the wall-budget axis measures against.
350    #[serde(default)]
351    pub started_at_ms: Option<WireU64>,
352    /// §13.2 · the wall-clock budget an `UpdateDeadline` command last projected onto the axis.
353    /// Not derivable from the configuration — the command sets a duration measured from
354    /// [`Self::started_at_ms`] — so a restore that dropped it would un-bound the run.
355    #[serde(default)]
356    pub wall_budget_ms: Option<WireU64>,
357    /// The task table. **The** home of task lifecycle and, with [`Self::attempts`], of task
358    /// identity.
359    #[serde(default)]
360    pub tasks: Vec<TaskControlState>,
361    /// §10.4 · the attempt the kernel minted for each live task. A completion naming an attempt
362    /// that is not here has no authority, which is why the mapping is checkpointed rather than
363    /// re-derived from task ids.
364    #[serde(default)]
365    pub attempts: Vec<TaskAttemptState>,
366    #[serde(default)]
367    pub workflow: Option<WorkflowGraphState>,
368    #[serde(default)]
369    pub queued_signals: Vec<QueuedSignalState>,
370    /// Router dedupe keys in eviction order, including keys for already-dispatched signals.
371    #[serde(default)]
372    pub signal_dedupe_keys: Vec<String>,
373    #[serde(default)]
374    pub milestone: Option<MilestoneState>,
375    /// Session-disorder measurement and alert-gate state. These values intentionally survive a
376    /// canonical crash restore even though they do not participate in a same-process turn
377    /// rollback: otherwise the first post-restore sample forgets prior failures and rollbacks.
378    #[serde(default)]
379    pub entropy: EntropyState,
380    /// Durable local fan-out channels, including per-consumer cursors and dedupe memory.
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    pub channels: Vec<LocalChannelState>,
383    /// Handle-only local object registry; bodies remain in context/payload storage.
384    #[serde(default, skip_serializing_if = "Vec::is_empty")]
385    pub objects: Vec<crate::mm::handle::ObjectDescriptor>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct LocalChannelState {
391    pub channel_id: String,
392    pub channel: crate::scheduler::mailbox::Channel,
393}
394
395#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct EntropyState {
398    /// Oldest to newest, bounded by the kernel entropy window.
399    #[serde(default)]
400    pub window: Vec<EntropyTurnState>,
401    /// Rollbacks observed after the newest completed turn.
402    pub rollbacks_pending: u32,
403    /// Threshold watch hysteresis state.
404    pub disarmed: bool,
405    /// Most recent alert turn, retained after re-arming for cooldown enforcement.
406    #[serde(default)]
407    pub last_alert_turn: Option<u32>,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(deny_unknown_fields)]
412pub struct EntropyTurnState {
413    pub errored_results: u32,
414    pub total_results: u32,
415    pub rollbacks: u32,
416}
417
418/// One task control block, projected. The lifecycle travels as its label rather than as the
419/// internal enum: `TaskLifecycle::Done(TerminationReason)` is a semantic-kernel shape, and a
420/// checkpoint that mirrored it would be a checkpoint of a private layout.
421///
422/// The label alone is not *invertible*, though, and Task 16 needs it to be: a restore that rebuilt
423/// a finished task without the reason it finished for would hand the next transition a different
424/// task table than the uninterrupted run had. So the two data-carrying lifecycles travel with their
425/// data beside the label, while suspended tasks carry one canonical `wait_set`.
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct TaskControlState {
429    pub task_id: TaskId,
430    #[serde(default)]
431    pub parent_task_id: Option<TaskId>,
432    pub lifecycle: String,
433    #[serde(default, skip_serializing_if = "is_nested_runnable_cause")]
434    pub runnable_cause: crate::scheduler::tcb::RunnableCause,
435    /// Why a `done` task is done. `None` for every other lifecycle.
436    #[serde(default)]
437    pub termination: Option<String>,
438    /// Canonical heterogeneous wait state, including partial `All` satisfaction.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub wait_set: Option<TaskWaitSetState>,
441    #[serde(default)]
442    pub capability_ids: Vec<String>,
443    /// Fine-grained, resource/action-scoped grants held by this task. These are authority state,
444    /// not a derivable cache: a restored nested caller must receive the same delegation ceiling.
445    #[serde(default, skip_serializing_if = "Vec::is_empty")]
446    pub capabilities: Vec<crate::types::capability::Capability>,
447    /// Sub-agent process identity and join state. `None` only for the root task.
448    #[serde(default)]
449    pub process: Option<ChildProcessState>,
450    #[serde(default, skip_serializing_if = "is_default_supervision")]
451    pub supervision: crate::scheduler::tcb::SupervisionPolicy,
452    #[serde(default, skip_serializing_if = "Vec::is_empty")]
453    pub supervision_events: Vec<crate::scheduler::tcb::SupervisionEvent>,
454    pub tokens_used: WireU64,
455    pub turns_used: u32,
456    /// spc_009-06 · this task's own grantable pool (`Tcb.child_budget_remaining`) — `None` for
457    /// every task that was never seeded (the common case) or has none left; `Some` once seeded,
458    /// currently only for `root` (spc_009-05). Per-task, unlike [`SchedulerState::budget_grant`]
459    /// above, which is the single whole-operation admission grant it was derived from.
460    #[serde(default)]
461    pub child_budget_remaining: Option<crate::scheduler::budget_grant::ResourceBudget>,
462    /// Parent→child reservation and settlement audit. Required to prevent a restart from either
463    /// forgetting a debit or returning the same unused reservation twice.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub budget_grant: Option<crate::scheduler::budget_grant::BudgetGrant>,
466    /// Point-to-point queue plus dedupe memory. Empty mailboxes stay absent from canonical JSON.
467    #[serde(
468        default,
469        skip_serializing_if = "crate::scheduler::mailbox::Mailbox::is_empty"
470    )]
471    pub mailbox: crate::scheduler::mailbox::Mailbox,
472}
473
474fn is_default_supervision(value: &crate::scheduler::tcb::SupervisionPolicy) -> bool {
475    value == &crate::scheduler::tcb::SupervisionPolicy::default()
476}
477
478fn is_nested_runnable_cause(value: &crate::scheduler::tcb::RunnableCause) -> bool {
479    *value == crate::scheduler::tcb::RunnableCause::NestedTask
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(deny_unknown_fields)]
484pub struct TaskWaitSetState {
485    pub mode: String,
486    pub conditions: Vec<TaskWaitConditionState>,
487    #[serde(default, skip_serializing_if = "Vec::is_empty")]
488    pub satisfied: Vec<u32>,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[serde(tag = "kind", rename_all = "snake_case")]
493pub enum TaskWaitConditionState {
494    Effect { effect_id: EffectId },
495    Child { task_id: TaskId },
496    Children { task_ids: Vec<TaskId> },
497    Approval { approval_id: String },
498    Signal { filter: String },
499    Timer { deadline_ms: WireU64 },
500    Channel { channel_id: String },
501    Resource { resource_key: String },
502    External { subscription_id: String },
503}
504
505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct ChildProcessState {
508    pub role: String,
509    pub isolation: String,
510    pub context_inheritance: String,
511    /// Present once the child has joined; the task lifecycle alone does not reproduce its output.
512    #[serde(default)]
513    pub join_result: Option<BoundedJson>,
514}
515
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct TaskAttemptState {
519    pub task_id: TaskId,
520    pub attempt_id: AttemptId,
521}
522
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524#[serde(deny_unknown_fields)]
525pub struct WorkflowGraphState {
526    pub workflow_id: WorkflowId,
527    /// The complete, index-ordered DAG and its runtime state. The semantic scheduler rebuilds its
528    /// private reverse edges, ready heap and agent lookup from this projection during restore.
529    #[serde(default)]
530    pub nodes: Vec<WorkflowNodeState>,
531}
532
533/// One workflow node as source state rather than a snapshot of `TaskGraph` internals.
534///
535/// `kind` is explicit even though the canonical ABI currently admits only `spawn`: a checkpoint
536/// must fail closed if a future producer writes a control-flow kind this revision cannot rebuild.
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct WorkflowNodeState {
540    pub node_id: NodeId,
541    pub task: LogicalTask,
542    #[serde(default)]
543    pub depends_on: Vec<NodeId>,
544    #[serde(default)]
545    pub run_spec: Option<LogicalAgentSpec>,
546    pub kind: String,
547    pub status: String,
548    /// The deterministic child identity while this node is running.
549    #[serde(default)]
550    pub active_agent_id: Option<String>,
551    #[serde(default)]
552    pub iterations_completed: u32,
553}
554
555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct QueuedSignalState {
558    pub signal_id: SignalId,
559    pub source: String,
560    pub signal_type: String,
561    pub urgency: String,
562    pub summary: String,
563    #[serde(default)]
564    pub payload: BoundedJson,
565    #[serde(default)]
566    pub dedupe_key: Option<String>,
567    #[serde(default)]
568    pub deadline_ms: Option<WireU64>,
569    #[serde(default)]
570    pub coalesce_key: Option<String>,
571    pub coalesced_count: u32,
572    #[serde(default)]
573    pub recipient: Option<String>,
574    pub timestamp_ms: WireU64,
575    pub deadline_escalated: bool,
576    /// Every business key represented by this queue entry after coalescing.
577    #[serde(default)]
578    pub dedupe_keys: Vec<String>,
579}
580
581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
582#[serde(deny_unknown_fields)]
583pub struct MilestoneState {
584    /// The contract whose cascade is installed. `phase_id` is unique only inside it, so the pair
585    /// is the host's complete lookup key (§7.9 note 6).
586    pub contract_id: String,
587    #[serde(default)]
588    pub phase_id: Option<String>,
589    pub complete: bool,
590    /// Consecutive blocks on the current phase — the retry budget. A restore that reset it would
591    /// hand a stalled cascade a fresh set of attempts.
592    #[serde(default)]
593    pub blocked_count: u32,
594}
595
596/// §12.1 · the P3 plane: the handle table and its allocator, skills and their leases, the
597/// knowledge slots, the signal partition and the compaction/renewal clocks.
598///
599/// What is here is everything the context VM *cannot re-derive*: identity (handle ids and the
600/// allocator that mints them), leases, pin/evict marks, the pending page-in verification targets,
601/// the clocks the decay ladders read — and, since Task 16, the **stored messages** themselves.
602///
603/// The message projection is what makes §12.2 true (adjudication §5q-2). Task 15 carried only token
604/// counts and lengths on the theory that a tail replay could rebuild the bodies; it cannot, because
605/// a tail that starts above genesis never replays the inputs that produced the older messages. And
606/// as long as the bodies were unrecoverable, acking a checkpoint and pruning the journal prefix
607/// destroyed the rendering input for good. So they are here — as [`StoredMessageState`], a *source*
608/// projection. §15.2's ban is on derived planned steps and rendered context;
609/// nothing here is either. A body that is over §7.10's inline threshold is **not** inlined: an
610/// `External`/`PagedOut` residency travels as its handle reference and digest, exactly as it does in
611/// working context.
612#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub struct ContextVmState {
615    /// **The** home of P3 handle identity and residency.
616    #[serde(default)]
617    pub handles: Vec<HandleState>,
618    /// The monotonic allocator. Checkpointing it is what stops a restored kernel from re-issuing a
619    /// handle id that an outstanding effect still addresses.
620    pub next_handle_id: u32,
621    /// §7.10 rule 4 · the digest each pending `LoadPayload` will verify its body against. Held
622    /// here rather than re-read from the handle table at resolution time, so a residency that moved
623    /// in between cannot change what a page-in is checked against.
624    #[serde(default)]
625    pub pending_payload_loads: Vec<PendingPayloadLoadState>,
626    #[serde(default)]
627    pub active_skills: Vec<SkillLeaseState>,
628    #[serde(default)]
629    pub knowledge: Vec<KnowledgeSlotState>,
630    #[serde(default)]
631    pub signals: Vec<String>,
632    /// §5q-2 · the stored messages of the system and history partitions, in render order. **The**
633    /// home of message bodies; the knowledge partition carries its own inside
634    /// [`KnowledgeSlotState`], because a knowledge entry is an identified slot rather than a
635    /// positional message.
636    #[serde(default)]
637    pub messages: Vec<StoredMessageState>,
638    /// The durable task board (goal / plan / progress / directives). It renders into the prompt like
639    /// a message does, survives compression by construction, and is the one partition of §12.1's P3
640    /// plane that is neither a message list nor a handle.
641    pub task_state: LogicalTaskState,
642    pub partition_tokens: PartitionTokenState,
643    pub history_len: u32,
644    /// CoreMessage-count boundary projected as `frozen_prefix_len` on future provider effects.
645    #[serde(default)]
646    pub frozen_history_len: u32,
647    pub last_activity_ms: WireU64,
648    #[serde(default)]
649    pub last_compact_ms: Option<WireU64>,
650}
651
652/// Which partition a [`StoredMessageState`] belongs to.
653///
654/// Only the two positional partitions: knowledge entries are keyed slots with their own lifecycle
655/// flags, so they live in [`KnowledgeSlotState`] instead of being a third value here.
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
657#[serde(rename_all = "snake_case")]
658pub enum MessagePartition {
659    System,
660    History,
661}
662
663/// One stored message, projected (§12.1, adjudication §5q-2).
664///
665/// This is the durable representation of the **CanonicalMessageState** concept (0.2.67):
666/// the concept is the L1 semantic authority for messages; this DTO is merely its current
667/// storage vehicle. The two are deliberately decoupled so a future distributed persistence
668/// is not locked to the checkpoint DTO's shape.
669///
670/// This is the *source* the renderer reads, not the rendered result: no prompt assembly, no
671/// residency projection, no salience footer. A restore rebuilds the partitions from these and then
672/// renders exactly what an uninterrupted run would have rendered.
673#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
674#[serde(deny_unknown_fields)]
675pub struct StoredMessageState {
676    pub partition: MessagePartition,
677    /// `system` | `user` | `assistant` | `tool`.
678    pub role: String,
679    pub body: StoredMessageBody,
680    /// The calls an assistant message asked for — the half of "tool association" that points
681    /// forward.
682    #[serde(default)]
683    pub tool_calls: Vec<LogicalToolCall>,
684    /// The cached token count the partition counter was built from. Carried rather than recomputed
685    /// so a restore reproduces the same budget arithmetic even if the tokenizer moved.
686    pub tokens: u32,
687}
688
689/// A message body, inline or by reference (§7.10).
690///
691/// The reference arm is the whole point: a tool result that was over the inline threshold when it
692/// was generated (`External`) or that left working context under pressure (`PagedOut`) is already
693/// represented in context by a preview plus a handle, and a checkpoint that re-inlined it would put
694/// bytes back into the journal that §7.10 spent an effect kind keeping out.
695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
696#[serde(tag = "form", rename_all = "snake_case")]
697pub enum StoredMessageBody {
698    /// A body small enough to live in context.
699    Inline(InlineMessageBody),
700    /// A body that lives with the host. Carries the reference and the digest that verifies a
701    /// page-in, never the bytes.
702    Reference(ReferencedMessageBody),
703    /// A multimodal body the text projection cannot express (image or audio parts), carried as
704    /// canonical provider-neutral durable content.
705    ///
706    /// It exists so the projection is never *silently* lossy: a body that does not reduce to text
707    /// travels whole rather than being flattened to the text parts that happen to be next to it.
708    Structured(StructuredMessageBody),
709}
710
711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
712#[serde(deny_unknown_fields)]
713pub struct InlineMessageBody {
714    pub text: String,
715    /// For a `tool` message: the call this result answers, and whether it failed.
716    #[serde(default)]
717    pub tool_call_id: Option<String>,
718    #[serde(default)]
719    pub is_error: bool,
720}
721
722#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct ReferencedMessageBody {
725    /// The P3 handle that addresses the body. Its residency in [`ContextVmState::handles`] is
726    /// what a page-in reads.
727    pub handle_id: u32,
728    /// The digest a page-in must reproduce (§7.10 rule 4).
729    pub digest: String,
730    /// What is actually resident: the preview the model can see. Never the whole body.
731    pub preview: String,
732    #[serde(default)]
733    pub tool_call_id: Option<String>,
734    #[serde(default)]
735    pub is_error: bool,
736}
737
738#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
739#[serde(deny_unknown_fields)]
740pub struct StructuredMessageBody {
741    /// Provider-neutral durable content.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub durable_content: Option<crate::types::durable_content::DurableContent>,
744    /// Correlated durable results preserve one envelope per call id. A one-result message uses a
745    /// one-element vector; there is no alternate singular representation.
746    #[serde(default, skip_serializing_if = "Vec::is_empty")]
747    pub durable_tool_results: Vec<crate::types::durable_content::DurableToolResult>,
748}
749
750#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
751#[serde(deny_unknown_fields)]
752pub struct LogicalToolCall {
753    pub call_id: String,
754    pub name: String,
755    /// Canonical JSON text of the arguments. A string rather than a `Value` so the checkpoint's
756    /// canonical bytes are the arguments' canonical bytes, with no second serialiser in between.
757    pub arguments: String,
758}
759
760/// §12.1 · the durable task board, projected.
761///
762/// Explicitly re-declared rather than reusing `crate::context::task_state::TaskState`: that type is
763/// semantic-kernel state whose serde shape is free to move, and §12.1's first rule is that a field
764/// added there must not silently change the checkpoint format.
765#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
766#[serde(deny_unknown_fields)]
767pub struct LogicalTaskState {
768    #[serde(default)]
769    pub goal: String,
770    #[serde(default)]
771    pub criteria: Vec<String>,
772    #[serde(default)]
773    pub plan: Vec<LogicalPlanStep>,
774    #[serde(default)]
775    pub current_step: Option<u32>,
776    #[serde(default)]
777    pub progress: String,
778    #[serde(default)]
779    pub scratchpad: String,
780    #[serde(default)]
781    pub blocked_on: Vec<String>,
782    #[serde(default)]
783    pub directives: Vec<String>,
784    #[serde(default)]
785    pub preserved_refs: Vec<String>,
786    #[serde(default)]
787    pub recent_actions: Vec<String>,
788    #[serde(default)]
789    pub compression_log: Vec<LogicalCompressionEntry>,
790    #[serde(default)]
791    pub compression_log_dropped: WireU64,
792}
793
794#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct LogicalPlanStep {
797    pub label: String,
798    pub done: bool,
799}
800
801#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
802#[serde(deny_unknown_fields)]
803pub struct LogicalCompressionEntry {
804    pub action: String,
805    pub summary: String,
806}
807
808/// One P3 handle. `residency` is the label plus the locator fields that residency carries, so the
809/// DTO neither mirrors the internal enum's shape nor loses what a page-in needs.
810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
811#[serde(deny_unknown_fields)]
812pub struct HandleState {
813    pub handle_id: u32,
814    pub kind: String,
815    pub residency: String,
816    #[serde(default)]
817    pub payload_ref: Option<String>,
818    #[serde(default)]
819    pub digest: Option<String>,
820    #[serde(default)]
821    pub original_size: Option<WireU64>,
822    pub tokens: u32,
823    /// Link back to the source object in working context (a tool `call_id` for a tool result).
824    #[serde(default)]
825    pub source: Option<String>,
826}
827
828#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
829#[serde(deny_unknown_fields)]
830pub struct PendingPayloadLoadState {
831    pub effect_id: EffectId,
832    pub handle_id: String,
833    pub digest: String,
834    #[serde(default)]
835    pub original_size: Option<WireU64>,
836}
837
838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
839#[serde(deny_unknown_fields)]
840pub struct SkillLeaseState {
841    pub skill: String,
842    /// `None` = permanent; otherwise the turn the lease expires on.
843    #[serde(default)]
844    pub lease_until_turn: Option<u32>,
845}
846
847/// One knowledge slot, body included.
848///
849/// A knowledge entry has identity (its key) and its own lifecycle flags, so it is not a positional
850/// [`StoredMessageState`] — but it renders into the prompt all the same, which is why Task 16 gave it
851/// the same body projection. Without it, a restore rebuilt the *shape* of the knowledge partition
852/// and none of its content.
853#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
854#[serde(deny_unknown_fields)]
855pub struct KnowledgeSlotState {
856    /// `None` = an unkeyed append; keyed entries upsert.
857    #[serde(default)]
858    pub key: Option<String>,
859    pub role: String,
860    pub body: StoredMessageBody,
861    pub tokens: u32,
862    pub pinned: bool,
863    pub evict_at_boundary: bool,
864}
865
866#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
867#[serde(deny_unknown_fields)]
868pub struct PartitionTokenState {
869    pub system: u32,
870    pub knowledge: u32,
871    pub history: u32,
872}
873
874/// What the semantic driver contributes to a checkpoint.
875///
876/// Three of the four partitions plus the two transition fields the driver — not the transaction —
877/// owns. It is a value, not a borrow of the driver: the checkpoint is built from an explicit
878/// projection, never from a live reference into the engine.
879#[derive(Debug, Clone, PartialEq)]
880pub struct LogicalStateProjection {
881    pub root_kind: Option<RootKind>,
882    pub focus: Option<ExecutionFocus>,
883    pub syscall: SyscallState,
884    pub scheduler: SchedulerState,
885    pub context_vm: ContextVmState,
886}
887
888// ---------------------------------------------------------------------------------------------
889// §12.1 · the checkpoint
890// ---------------------------------------------------------------------------------------------
891
892/// Everything [`KernelCheckpoint::assemble`] needs. A struct rather than eight positional
893/// arguments, because two of them are step sequences and two are digests.
894#[derive(Debug, Clone, PartialEq)]
895pub struct CheckpointDraft {
896    pub operation_id: OperationId,
897    pub genesis_digest: Digest,
898    pub base_step_seq: WireU64,
899    pub base_record_digest: Digest,
900    pub through_step_seq: WireU64,
901    pub covered_transaction_head_digest: Digest,
902    pub logical_state: LogicalKernelState,
903    pub tail_inputs: Vec<CanonicalInput>,
904}
905
906/// One logical checkpoint (§12.1).
907///
908/// Fields are private and every digest is computed by [`Self::assemble`], so there is no
909/// constructor that takes a digest and "the host recomputed the hash and disagreed" is not a
910/// reachable state — the same discipline [`KernelRecord`](super::record::KernelRecord) uses.
911/// Decoding goes through the same verification, which is why a tampered blob fails at the boundary
912/// rather than half-way through a restore.
913#[derive(Debug, Clone, PartialEq, Serialize)]
914pub struct KernelCheckpoint {
915    operation_id: OperationId,
916    /// The digest of the operation's genesis record — its identity. A checkpoint built from
917    /// another operation's journal therefore cannot be installed by accident.
918    genesis_digest: Digest,
919    /// The step the logical state below describes.
920    base_step_seq: WireU64,
921    /// The record digest at `base_step_seq` — the chain anchor a tail replay starts from.
922    ///
923    /// The other end of the range the header already states. Without it a rebase could be
924    /// *verified* and never *replayed*: the record before the first tail entry is exactly the one an
925    /// acked checkpoint is allowed to have pruned, so its digest has to travel with the tail that
926    /// depends on it. For a full-state checkpoint it is the covered head, because `base == through`.
927    base_record_digest: Digest,
928    /// The step the checkpoint covers once its tail is replayed.
929    through_step_seq: WireU64,
930    /// The record digest at `through_step_seq`. §12.3 rule 2: install checks that this names the
931    /// through step, **not** that it is still the current head.
932    covered_transaction_head_digest: Digest,
933    logical_state: LogicalKernelState,
934    tail_inputs: Vec<CanonicalInput>,
935    state_digest: Digest,
936    tail_digest: Digest,
937    checkpoint_digest: Digest,
938}
939
940/// The digested body: every field of a checkpoint except the digest that summarises it.
941#[derive(Serialize)]
942struct CheckpointBody<'a> {
943    operation_id: &'a OperationId,
944    genesis_digest: &'a Digest,
945    base_step_seq: WireU64,
946    base_record_digest: &'a Digest,
947    through_step_seq: WireU64,
948    covered_transaction_head_digest: &'a Digest,
949    logical_state: &'a LogicalKernelState,
950    tail_inputs: &'a [CanonicalInput],
951    state_digest: &'a Digest,
952    tail_digest: &'a Digest,
953}
954
955impl KernelCheckpoint {
956    /// Build a checkpoint, computing all three digests and checking the tail covers
957    /// `(base_step_seq, through_step_seq]` exactly.
958    pub fn assemble(draft: CheckpointDraft) -> Result<Self, CheckpointError> {
959        let CheckpointDraft {
960            operation_id,
961            genesis_digest,
962            base_step_seq,
963            base_record_digest,
964            through_step_seq,
965            covered_transaction_head_digest,
966            logical_state,
967            tail_inputs,
968        } = draft;
969
970        validate_durable_message_bodies(&logical_state.context_vm)?;
971
972        check_tail(
973            &operation_id,
974            base_step_seq,
975            &base_record_digest,
976            through_step_seq,
977            &covered_transaction_head_digest,
978            &tail_inputs,
979        )?;
980
981        let state_digest = canonical_digest(canonical_bytes(&logical_state)?.as_slice());
982        let tail_digest = canonical_digest(canonical_bytes(&tail_inputs)?.as_slice());
983        let checkpoint_digest = Self::body_digest(&CheckpointBody {
984            operation_id: &operation_id,
985            genesis_digest: &genesis_digest,
986            base_step_seq,
987            base_record_digest: &base_record_digest,
988            through_step_seq,
989            covered_transaction_head_digest: &covered_transaction_head_digest,
990            logical_state: &logical_state,
991            tail_inputs: &tail_inputs,
992            state_digest: &state_digest,
993            tail_digest: &tail_digest,
994        })?;
995
996        Ok(Self {
997            operation_id,
998            genesis_digest,
999            base_step_seq,
1000            base_record_digest,
1001            through_step_seq,
1002            covered_transaction_head_digest,
1003            logical_state,
1004            tail_inputs,
1005            state_digest,
1006            tail_digest,
1007            checkpoint_digest,
1008        })
1009    }
1010
1011    fn body_digest(body: &CheckpointBody<'_>) -> Result<Digest, CheckpointError> {
1012        Ok(canonical_digest(canonical_bytes(body)?.as_slice()))
1013    }
1014
1015    // ----- read-only accessors -----
1016
1017    pub fn operation_id(&self) -> &OperationId {
1018        &self.operation_id
1019    }
1020
1021    pub fn genesis_digest(&self) -> &Digest {
1022        &self.genesis_digest
1023    }
1024
1025    pub fn base_step_seq(&self) -> WireU64 {
1026        self.base_step_seq
1027    }
1028
1029    pub fn base_record_digest(&self) -> &Digest {
1030        &self.base_record_digest
1031    }
1032
1033    pub fn through_step_seq(&self) -> WireU64 {
1034        self.through_step_seq
1035    }
1036
1037    pub fn covered_transaction_head_digest(&self) -> &Digest {
1038        &self.covered_transaction_head_digest
1039    }
1040
1041    pub fn logical_state(&self) -> &LogicalKernelState {
1042        &self.logical_state
1043    }
1044
1045    pub fn tail_inputs(&self) -> &[CanonicalInput] {
1046        &self.tail_inputs
1047    }
1048
1049    pub fn state_digest(&self) -> &Digest {
1050        &self.state_digest
1051    }
1052
1053    pub fn tail_digest(&self) -> &Digest {
1054        &self.tail_digest
1055    }
1056
1057    pub fn checkpoint_digest(&self) -> &Digest {
1058        &self.checkpoint_digest
1059    }
1060
1061    // ----- projection -----
1062
1063    /// Canonical bytes of the whole checkpoint — the blob a host persists.
1064    pub fn checkpoint_bytes(&self) -> CanonicalBytes {
1065        canonical_bytes(self).expect("a checkpoint contains only canonical scalars")
1066    }
1067
1068    /// Decode a checkpoint from its stored bytes, verifying every digest and the tail coverage.
1069    pub fn from_checkpoint_bytes(bytes: &[u8]) -> Result<Self, CheckpointError> {
1070        let text = std::str::from_utf8(bytes).map_err(|error| {
1071            CheckpointError::NotCanonical(format!("checkpoint bytes are not UTF-8: {error}"))
1072        })?;
1073        let document =
1074            serde_json::from_str(text).map_err(|error| decode_error(&error.to_string()))?;
1075        decode_checkpoint_value(document)
1076    }
1077
1078    /// The prefix an ack of this checkpoint may reclaim (§12.3 rule 6).
1079    pub fn boundary(&self) -> super::transaction::CheckpointBoundary {
1080        super::transaction::CheckpointBoundary {
1081            through_step_seq: self.through_step_seq,
1082            covered_head: self.covered_transaction_head_digest.clone(),
1083        }
1084    }
1085
1086    /// The §12.3 candidate this checkpoint hands the host.
1087    pub fn into_candidate(self) -> CheckpointCandidate {
1088        let ack_token = ack_token_for(
1089            &self.operation_id,
1090            self.through_step_seq,
1091            &self.checkpoint_digest,
1092        );
1093        CheckpointCandidate {
1094            checkpoint_bytes: self.checkpoint_bytes(),
1095            through_step_seq: self.through_step_seq,
1096            covered_head: self.covered_transaction_head_digest.clone(),
1097            state_digest: self.state_digest.clone(),
1098            ack_token,
1099        }
1100    }
1101
1102    // ----- verification -----
1103
1104    /// Recompute every digest from the bytes this checkpoint carries and re-check its tail.
1105    ///
1106    /// The first two lines of §12.2's ladder. `verify_belongs_to` adds the operation/genesis half;
1107    /// removed shapes are rejected by strict decoding before a checkpoint can exist.
1108    pub fn verify(&self) -> Result<(), CheckpointError> {
1109        validate_durable_message_bodies(&self.logical_state.context_vm)?;
1110        check_tail(
1111            &self.operation_id,
1112            self.base_step_seq,
1113            &self.base_record_digest,
1114            self.through_step_seq,
1115            &self.covered_transaction_head_digest,
1116            &self.tail_inputs,
1117        )?;
1118
1119        let state_digest = canonical_digest(canonical_bytes(&self.logical_state)?.as_slice());
1120        if state_digest != self.state_digest {
1121            return Err(CheckpointError::Corrupted(format!(
1122                "checkpoint {} through step {}: the logical state hashes to {state_digest}, \
1123                 but the checkpoint claims {}",
1124                self.operation_id, self.through_step_seq, self.state_digest
1125            )));
1126        }
1127        let tail_digest = canonical_digest(canonical_bytes(&self.tail_inputs)?.as_slice());
1128        if tail_digest != self.tail_digest {
1129            return Err(CheckpointError::Corrupted(format!(
1130                "checkpoint {} through step {}: the bounded tail hashes to {tail_digest}, \
1131                 but the checkpoint claims {}",
1132                self.operation_id, self.through_step_seq, self.tail_digest
1133            )));
1134        }
1135        let checkpoint_digest = Self::body_digest(&CheckpointBody {
1136            operation_id: &self.operation_id,
1137            genesis_digest: &self.genesis_digest,
1138            base_step_seq: self.base_step_seq,
1139            base_record_digest: &self.base_record_digest,
1140            through_step_seq: self.through_step_seq,
1141            covered_transaction_head_digest: &self.covered_transaction_head_digest,
1142            logical_state: &self.logical_state,
1143            tail_inputs: &self.tail_inputs,
1144            state_digest: &self.state_digest,
1145            tail_digest: &self.tail_digest,
1146        })?;
1147        if checkpoint_digest != self.checkpoint_digest {
1148            return Err(CheckpointError::Corrupted(format!(
1149                "checkpoint {} through step {}: the body hashes to {checkpoint_digest}, \
1150                 but the checkpoint claims {}",
1151                self.operation_id, self.through_step_seq, self.checkpoint_digest
1152            )));
1153        }
1154        Ok(())
1155    }
1156
1157    /// Whether this checkpoint is this operation's (§12.2 line 2).
1158    pub fn verify_belongs_to(
1159        &self,
1160        operation_id: &OperationId,
1161        genesis_digest: &Digest,
1162    ) -> Result<(), CheckpointError> {
1163        if &self.operation_id != operation_id {
1164            return Err(CheckpointError::Incompatible(format!(
1165                "checkpoint belongs to operation {}, this runtime to {operation_id}",
1166                self.operation_id
1167            )));
1168        }
1169        if &self.genesis_digest != genesis_digest {
1170            return Err(CheckpointError::Incompatible(format!(
1171                "checkpoint {operation_id} binds genesis {}, this journal's genesis is \
1172                 {genesis_digest}",
1173                self.genesis_digest
1174            )));
1175        }
1176        Ok(())
1177    }
1178}
1179
1180fn validate_durable_message_bodies(context: &ContextVmState) -> Result<(), CheckpointError> {
1181    let bodies = context
1182        .messages
1183        .iter()
1184        .map(|message| &message.body)
1185        .chain(context.knowledge.iter().map(|slot| &slot.body));
1186    for body in bodies {
1187        let StoredMessageBody::Structured(structured) = body else {
1188            continue;
1189        };
1190        let body_forms = usize::from(structured.durable_content.is_some())
1191            + usize::from(!structured.durable_tool_results.is_empty());
1192        if body_forms > 1 {
1193            return Err(CheckpointError::Incompatible(
1194                "structured message carries more than one durable body form".into(),
1195            ));
1196        }
1197        if !structured.durable_tool_results.is_empty() {
1198            for result in &structured.durable_tool_results {
1199                result.validate().map_err(|error| {
1200                    CheckpointError::Incompatible(format!(
1201                        "structured message carries invalid durable tool result: {error}"
1202                    ))
1203                })?;
1204            }
1205        } else if let Some(content) = &structured.durable_content {
1206            content.validate().map_err(|error| {
1207                CheckpointError::Incompatible(format!(
1208                    "structured message carries invalid durable content: {error}"
1209                ))
1210            })?;
1211        } else {
1212            return Err(CheckpointError::Incompatible(
1213                "structured message carries no durable content".into(),
1214            ));
1215        }
1216    }
1217    Ok(())
1218}
1219
1220/// §12.1 · the bounded tail covers `(base_step_seq, through_step_seq]` exactly.
1221///
1222/// One walk catches all four failure modes the spec names: a hole (a gap in the sequence), a
1223/// duplicate (the same step twice), an out-of-range entry (before `base` or after `through`), and a
1224/// length that disagrees with the range. It also refuses a tail entry from another operation —
1225/// the cheapest way to notice a checkpoint assembled from two journals.
1226fn check_tail(
1227    operation_id: &OperationId,
1228    base_step_seq: WireU64,
1229    base_record_digest: &Digest,
1230    through_step_seq: WireU64,
1231    covered_transaction_head_digest: &Digest,
1232    tail_inputs: &[CanonicalInput],
1233) -> Result<(), CheckpointError> {
1234    if base_step_seq > through_step_seq {
1235        return Err(CheckpointError::Corrupted(format!(
1236            "checkpoint {operation_id} bases at step {base_step_seq} but covers only through \
1237             {through_step_seq}"
1238        )));
1239    }
1240    // The two ends of the range meet when the range is empty: a full-state checkpoint's base *is*
1241    // its covered head, and a header that disagreed with itself about that would hand a restore two
1242    // different anchors for one record.
1243    if base_step_seq == through_step_seq && base_record_digest != covered_transaction_head_digest {
1244        return Err(CheckpointError::Corrupted(format!(
1245            "checkpoint {operation_id} covers no tail, so its base {base_record_digest} and its \
1246             covered head {covered_transaction_head_digest} name the same record — but they differ"
1247        )));
1248    }
1249    let expected = through_step_seq.get() - base_step_seq.get();
1250    if tail_inputs.len() as u64 != expected {
1251        return Err(CheckpointError::Corrupted(format!(
1252            "checkpoint {operation_id} covers ({base_step_seq}, {through_step_seq}] — {expected} \
1253             inputs — but its bounded tail holds {}",
1254            tail_inputs.len()
1255        )));
1256    }
1257    for (offset, entry) in tail_inputs.iter().enumerate() {
1258        let want = base_step_seq.get() + offset as u64 + 1;
1259        if entry.step_seq.get() != want {
1260            return Err(CheckpointError::Corrupted(format!(
1261                "checkpoint {operation_id} bounded tail is not the contiguous range \
1262                 ({base_step_seq}, {through_step_seq}]: position {offset} is step {} where step \
1263                 {want} was due",
1264                entry.step_seq
1265            )));
1266        }
1267        if &entry.input.operation_id != operation_id {
1268            return Err(CheckpointError::Incompatible(format!(
1269                "checkpoint {operation_id} bounded tail carries an input of operation {} at step \
1270                 {}",
1271                entry.input.operation_id, entry.step_seq
1272            )));
1273        }
1274    }
1275    // The last tail entry *is* the covered head; a tail that ends somewhere else covers a different
1276    // prefix than the header claims.
1277    if let Some(last) = tail_inputs.last()
1278        && &last.record_digest != covered_transaction_head_digest
1279    {
1280        return Err(CheckpointError::Corrupted(format!(
1281            "checkpoint {operation_id} claims covered head {covered_transaction_head_digest}, but \
1282             its bounded tail ends at {} on step {}",
1283            last.record_digest, last.step_seq
1284        )));
1285    }
1286    Ok(())
1287}
1288
1289fn ack_token_for(
1290    operation_id: &OperationId,
1291    through_step_seq: WireU64,
1292    checkpoint_digest: &Digest,
1293) -> CheckpointAckToken {
1294    CheckpointAckToken::new(format!(
1295        "{operation_id}:checkpoint:{through_step_seq}:{checkpoint_digest}"
1296    ))
1297    .expect("an operation-scoped checkpoint ack token is always a legal branded ref")
1298}
1299
1300/// §12.3 · what `kernel.checkpoint_candidate()` hands the host.
1301///
1302/// Exactly the five values of the spec's arrow, and nothing that would let a host reconstruct the
1303/// checkpoint itself: `checkpoint_bytes` is opaque storage, `through_step_seq`/`covered_head` are
1304/// the install precondition, `state_digest` is what an installed blob is audited against, and
1305/// `ack_token` is the maintenance handle that closes the loop.
1306#[derive(Debug, Clone, PartialEq)]
1307pub struct CheckpointCandidate {
1308    pub checkpoint_bytes: CanonicalBytes,
1309    pub through_step_seq: WireU64,
1310    pub covered_head: Digest,
1311    pub state_digest: Digest,
1312    pub ack_token: CheckpointAckToken,
1313}
1314
1315impl CheckpointCandidate {
1316    /// The boundary this candidate would let an ack reclaim (§12.3 rule 6).
1317    pub fn boundary(&self) -> super::transaction::CheckpointBoundary {
1318        super::transaction::CheckpointBoundary {
1319            through_step_seq: self.through_step_seq,
1320            covered_head: self.covered_head.clone(),
1321        }
1322    }
1323
1324    /// Decode the blob back into a verified checkpoint — what an install path does before it
1325    /// writes anything.
1326    pub fn decode(&self) -> Result<KernelCheckpoint, CheckpointError> {
1327        KernelCheckpoint::from_checkpoint_bytes(self.checkpoint_bytes.as_slice())
1328    }
1329}
1330
1331// ---------------------------------------------------------------------------------------------
1332// decoding
1333// ---------------------------------------------------------------------------------------------
1334
1335/// Current-version wire projection. It is only reached after revision dispatch, so
1336/// [`KernelCheckpoint`]'s fields stay private and every decoded checkpoint is verified before it
1337/// exists.
1338#[derive(Deserialize)]
1339#[serde(deny_unknown_fields)]
1340struct CheckpointProjection {
1341    operation_id: OperationId,
1342    genesis_digest: Digest,
1343    base_step_seq: WireU64,
1344    base_record_digest: Digest,
1345    through_step_seq: WireU64,
1346    covered_transaction_head_digest: Digest,
1347    logical_state: LogicalKernelState,
1348    tail_inputs: Vec<CanonicalInput>,
1349    state_digest: Digest,
1350    tail_digest: Digest,
1351    checkpoint_digest: Digest,
1352}
1353
1354fn decode_checkpoint_value(
1355    document: serde_json::Value,
1356) -> Result<KernelCheckpoint, CheckpointError> {
1357    decode_current_checkpoint(document)
1358}
1359
1360fn decode_current_checkpoint(
1361    document: serde_json::Value,
1362) -> Result<KernelCheckpoint, CheckpointError> {
1363    let projection = serde_json::from_value::<CheckpointProjection>(document)
1364        .map_err(|error| decode_error(&error.to_string()))?;
1365    let checkpoint = KernelCheckpoint {
1366        operation_id: projection.operation_id,
1367        genesis_digest: projection.genesis_digest,
1368        base_step_seq: projection.base_step_seq,
1369        base_record_digest: projection.base_record_digest,
1370        through_step_seq: projection.through_step_seq,
1371        covered_transaction_head_digest: projection.covered_transaction_head_digest,
1372        logical_state: projection.logical_state,
1373        tail_inputs: projection.tail_inputs,
1374        state_digest: projection.state_digest,
1375        tail_digest: projection.tail_digest,
1376        checkpoint_digest: projection.checkpoint_digest,
1377    };
1378    checkpoint.verify()?;
1379    Ok(checkpoint)
1380}
1381
1382/// Recover a rejection's class from the string `serde` hands back.
1383///
1384/// Three sources reach here: this module's own [`CheckpointError`] rendered by
1385/// [`fmt::Display`] (which names its code), the scalar layer's ABI-revision refusal, and
1386/// everything structural — an unknown field, a missing field, a value of the wrong shape.
1387fn decode_error(message: &str) -> CheckpointError {
1388    if message.contains(CHECKPOINT_ERROR_MARKER) {
1389        for code in [
1390            KernelFaultCode::CheckpointIncompatible,
1391            KernelFaultCode::CheckpointCorrupted,
1392        ] {
1393            if message.contains(&format!("{CHECKPOINT_ERROR_MARKER} ({})", code.as_str())) {
1394                return match code {
1395                    KernelFaultCode::CheckpointIncompatible => {
1396                        CheckpointError::Incompatible(message.to_string())
1397                    }
1398                    _ => CheckpointError::Corrupted(message.to_string()),
1399                };
1400            }
1401        }
1402        return CheckpointError::Corrupted(message.to_string());
1403    }
1404    if message.contains(SCALAR_ERROR_MARKER) && message.contains("ABI revision") {
1405        return CheckpointError::Incompatible(message.to_string());
1406    }
1407    CheckpointError::NotCanonical(format!("checkpoint does not decode: {message}"))
1408}
1409
1410impl<'de> Deserialize<'de> for KernelCheckpoint {
1411    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1412        let document = serde_json::Value::deserialize(deserializer)?;
1413        decode_checkpoint_value(document)
1414            .map_err(|error| serde::de::Error::custom(error.to_string()))
1415    }
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use std::collections::BTreeMap;
1421    use std::fs;
1422    use std::path::PathBuf;
1423
1424    use serde_json::Value;
1425
1426    use super::super::config::{ConfigDefaults, HostEffectSupport, OperationConfig};
1427    use super::super::effect::EffectKindTag;
1428    use super::super::envelope::{ConfigureOperation, KernelInput, WireEnvelope};
1429    use super::*;
1430
1431    // -----------------------------------------------------------------------------------------
1432    // helpers
1433    // -----------------------------------------------------------------------------------------
1434
1435    const OPERATION: &str = "op-checkpoint-1";
1436
1437    fn operation() -> OperationId {
1438        OperationId::new(OPERATION).unwrap()
1439    }
1440
1441    fn digest(label: &str) -> Digest {
1442        canonical_digest(label.as_bytes())
1443    }
1444
1445    fn normalized(input_id: &str, at: u64) -> NormalizedInput {
1446        let envelope = WireEnvelope::new(
1447            operation(),
1448            InputId::new(input_id).unwrap(),
1449            WireU64::new(at),
1450            KernelInput::ConfigureOperation(ConfigureOperation {
1451                config: OperationConfig {
1452                    host_effect_support: HostEffectSupport {
1453                        supported: vec![EffectKindTag::CallProvider],
1454                    },
1455                    ..OperationConfig::default()
1456                },
1457            }),
1458        );
1459        NormalizedInput::normalize(&envelope, &ConfigDefaults::default()).expect("normalizes")
1460    }
1461
1462    fn tail_entry(step_seq: u64) -> CanonicalInput {
1463        CanonicalInput {
1464            step_seq: WireU64::new(step_seq),
1465            record_digest: digest(&format!("record-{step_seq}")),
1466            input: normalized(&format!("in-{step_seq}"), 1_700_000_000_000 + step_seq),
1467        }
1468    }
1469
1470    fn resolved_config() -> ResolvedOperationConfig {
1471        OperationConfig {
1472            host_effect_support: HostEffectSupport {
1473                supported: vec![EffectKindTag::CallProvider],
1474            },
1475            ..OperationConfig::default()
1476        }
1477        .resolve(&ConfigDefaults::default())
1478        .expect("the default configuration resolves")
1479    }
1480
1481    fn logical_state() -> LogicalKernelState {
1482        LogicalKernelState {
1483            transition: TransitionState {
1484                lifecycle: OperationLifecycle::Running,
1485                resolved_config: resolved_config(),
1486                root_kind: Some(RootKind::Agent),
1487                focus: None,
1488                last_observed_at_ms: WireU64::new(1_700_000_002_000),
1489                pending_effects: Vec::new(),
1490                resolved_effects: Vec::new(),
1491                launch_tokens: Vec::new(),
1492                accepted_inputs: vec![AcceptedInputState {
1493                    input_id: InputId::new("in-configure").unwrap(),
1494                    step_seq: WireU64::ZERO,
1495                    record_digest: digest("record-0"),
1496                }],
1497                accepted_cancellation: None,
1498                terminal: None,
1499            },
1500            syscall: SyscallState::default(),
1501            scheduler: SchedulerState::default(),
1502            context_vm: ContextVmState::default(),
1503        }
1504    }
1505
1506    fn draft(base: u64, through: u64, tail: Vec<CanonicalInput>) -> CheckpointDraft {
1507        CheckpointDraft {
1508            operation_id: operation(),
1509            genesis_digest: digest("genesis"),
1510            base_step_seq: WireU64::new(base),
1511            base_record_digest: digest(&format!("record-{base}")),
1512            through_step_seq: WireU64::new(through),
1513            covered_transaction_head_digest: digest(&format!("record-{through}")),
1514            logical_state: logical_state(),
1515            tail_inputs: tail,
1516        }
1517    }
1518
1519    fn checkpoint() -> KernelCheckpoint {
1520        KernelCheckpoint::assemble(draft(3, 3, Vec::new())).expect("assembles")
1521    }
1522
1523    /// Round-trip a checkpoint through JSON with one field rewritten — the cheapest way to test a
1524    /// tamper without a constructor that could produce one.
1525    fn tampered(edit: impl FnOnce(&mut serde_json::Map<String, Value>)) -> CheckpointError {
1526        let mut document: serde_json::Map<String, Value> =
1527            serde_json::from_slice(checkpoint().checkpoint_bytes().as_slice()).unwrap();
1528        edit(&mut document);
1529        let bytes = serde_json::to_vec(&document).unwrap();
1530        KernelCheckpoint::from_checkpoint_bytes(&bytes)
1531            .expect_err("a tampered checkpoint must not decode")
1532    }
1533
1534    // -----------------------------------------------------------------------------------------
1535    // §12.1 · shape
1536    // -----------------------------------------------------------------------------------------
1537
1538    #[test]
1539    fn a_checkpoint_has_no_version_axis() {
1540        let document: Value =
1541            serde_json::from_slice(checkpoint().checkpoint_bytes().as_slice()).unwrap();
1542        assert!(document.get("checkpoint_version").is_none());
1543        assert!(document.get("abi_version").is_none());
1544    }
1545
1546    /// The load-bearing invariant of §12.1: each piece of correctness state has exactly one home,
1547    /// and the header repeats none of it.
1548    #[test]
1549    fn single_ownership_is_structural() {
1550        let checkpoint = checkpoint();
1551        let document: Value =
1552            serde_json::from_slice(checkpoint.checkpoint_bytes().as_slice()).unwrap();
1553        let state = &document["logical_state"];
1554
1555        // 1. every owned key appears exactly once in the whole logical state document. This is a
1556        //    total scan, not a spot check: the partitions serialise their whole key set (no
1557        //    `skip_serializing_if`), so an empty vector is still a visible claim of ownership.
1558        for (owned, owner) in [
1559            ("pending_effects", "transition"),
1560            ("resolved_effects", "transition"),
1561            ("launch_tokens", "transition"),
1562            ("accepted_inputs", "transition"),
1563            ("accepted_cancellation", "transition"),
1564            ("terminal", "transition"),
1565            ("attempts", "scheduler"),
1566            ("tasks", "scheduler"),
1567            ("handles", "context_vm"),
1568            ("pending_payload_loads", "context_vm"),
1569            ("provider_calls", "syscall"),
1570        ] {
1571            let mut seen = Vec::new();
1572            for partition in ["transition", "syscall", "scheduler", "context_vm"] {
1573                if state[partition]
1574                    .as_object()
1575                    .map(|map| map.contains_key(owned))
1576                    .unwrap_or(false)
1577                {
1578                    seen.push(partition);
1579                }
1580            }
1581            assert_eq!(
1582                seen,
1583                vec![owner],
1584                "{owned} must live in exactly one partition"
1585            );
1586        }
1587
1588        // 2. the four partitions share no key at all
1589        let mut home: BTreeMap<String, &str> = BTreeMap::new();
1590        for partition in ["transition", "syscall", "scheduler", "context_vm"] {
1591            for key in state[partition]
1592                .as_object()
1593                .expect("a partition object")
1594                .keys()
1595            {
1596                if let Some(previous) = home.insert(key.clone(), partition) {
1597                    panic!("key {key} lives in both {previous} and {partition}");
1598                }
1599            }
1600        }
1601
1602        // 3. the header stores no sub-state
1603        let header: Vec<&String> = document
1604            .as_object()
1605            .unwrap()
1606            .keys()
1607            .filter(|key| home.contains_key(*key))
1608            .collect();
1609        assert!(
1610            header.is_empty(),
1611            "the checkpoint header duplicates sub-state: {header:?}"
1612        );
1613    }
1614
1615    /// The DTO must be buildable without touching the semantic engine — the whole point of the
1616    /// explicit projection. A default projection is a legal (empty) checkpoint.
1617    #[test]
1618    fn the_dto_is_constructible_without_any_state_machine() {
1619        let state = LogicalKernelState {
1620            transition: TransitionState {
1621                lifecycle: OperationLifecycle::Created,
1622                resolved_config: resolved_config(),
1623                root_kind: None,
1624                focus: None,
1625                last_observed_at_ms: WireU64::ZERO,
1626                pending_effects: Vec::new(),
1627                resolved_effects: Vec::new(),
1628                launch_tokens: Vec::new(),
1629                accepted_inputs: Vec::new(),
1630                accepted_cancellation: None,
1631                terminal: None,
1632            },
1633            syscall: SyscallState::default(),
1634            scheduler: SchedulerState::default(),
1635            context_vm: ContextVmState::default(),
1636        };
1637        let mut draft = draft(0, 0, Vec::new());
1638        draft.logical_state = state;
1639        KernelCheckpoint::assemble(draft).expect("an empty logical state is still a checkpoint");
1640    }
1641
1642    // -----------------------------------------------------------------------------------------
1643    // §12.1 · digests
1644    // -----------------------------------------------------------------------------------------
1645
1646    #[test]
1647    fn the_three_digests_summarise_three_different_things() {
1648        let checkpoint = checkpoint();
1649        assert_eq!(
1650            checkpoint.state_digest(),
1651            &canonical_digest(
1652                canonical_bytes(checkpoint.logical_state())
1653                    .unwrap()
1654                    .as_slice()
1655            ),
1656        );
1657        assert_eq!(
1658            checkpoint.tail_digest(),
1659            &canonical_digest(
1660                canonical_bytes(checkpoint.tail_inputs())
1661                    .unwrap()
1662                    .as_slice()
1663            ),
1664        );
1665        assert_ne!(checkpoint.state_digest(), checkpoint.checkpoint_digest());
1666        assert_ne!(checkpoint.tail_digest(), checkpoint.checkpoint_digest());
1667        checkpoint
1668            .verify()
1669            .expect("a freshly built checkpoint verifies");
1670    }
1671
1672    /// The checkpoint digest covers the header too: moving `through_step_seq` without moving the
1673    /// digest is corruption, not a different-but-valid checkpoint.
1674    #[test]
1675    fn the_checkpoint_digest_covers_the_header_and_the_bounded_tail() {
1676        let with_tail = KernelCheckpoint::assemble(draft(3, 4, vec![tail_entry(4)])).unwrap();
1677        let without_tail = checkpoint();
1678        assert_eq!(
1679            with_tail.state_digest(),
1680            without_tail.state_digest(),
1681            "the same logical state digests the same either way"
1682        );
1683        assert_ne!(
1684            with_tail.checkpoint_digest(),
1685            without_tail.checkpoint_digest(),
1686            "but the checkpoint digest moves with the tail and the header"
1687        );
1688
1689        let error = tampered(|document| {
1690            document.insert("through_step_seq".to_string(), Value::String("9".into()));
1691        });
1692        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1693    }
1694
1695    #[test]
1696    fn a_checkpoint_round_trips_through_its_bytes() {
1697        let original = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(4)]))
1698            .expect("a bounded-tail checkpoint assembles");
1699        let decoded =
1700            KernelCheckpoint::from_checkpoint_bytes(original.checkpoint_bytes().as_slice())
1701                .expect("its own bytes decode");
1702        assert_eq!(decoded, original);
1703        assert_eq!(decoded.tail_inputs().len(), 2);
1704    }
1705
1706    #[test]
1707    fn structured_message_body_rejects_removed_body_forms() {
1708        assert!(
1709            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1710                "content_json": "{\\\"Text\\\":\\\"hello\\\"}"
1711            }))
1712            .is_err()
1713        );
1714        assert!(
1715            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1716                "schema_version": 1,
1717                "durable_content": {"blocks": []}
1718            }))
1719            .is_err()
1720        );
1721    }
1722
1723    #[test]
1724    fn structured_message_body_rejects_unknown_fields() {
1725        assert!(
1726            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1727                "durable_content": {"blocks": []},
1728                "unknown": true,
1729            }))
1730            .is_err()
1731        );
1732    }
1733
1734    #[test]
1735    fn removed_durable_content_schema_field_is_not_readable() {
1736        assert!(
1737            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1738                "durable_content": {"schema_version": 1, "blocks": []}
1739            }))
1740            .is_err()
1741        );
1742    }
1743
1744    #[test]
1745    fn checkpoint_rejects_durable_tool_result_with_a_second_body_form() {
1746        let mut draft = draft(3, 3, Vec::new());
1747        draft
1748            .logical_state
1749            .context_vm
1750            .messages
1751            .push(StoredMessageState {
1752                partition: MessagePartition::History,
1753                role: "tool".into(),
1754                body: StoredMessageBody::Structured(StructuredMessageBody {
1755                    durable_content: Some(crate::types::durable_content::DurableContent::text(
1756                        "wrong",
1757                    )),
1758                    durable_tool_results: vec![
1759                        crate::types::durable_content::DurableToolResult::text(
1760                            "call-1",
1761                            "also wrong",
1762                            false,
1763                        ),
1764                    ],
1765                }),
1766                tool_calls: Vec::new(),
1767                tokens: 0,
1768            });
1769        assert!(matches!(
1770            KernelCheckpoint::assemble(draft),
1771            Err(CheckpointError::Incompatible(_))
1772        ));
1773    }
1774
1775    // -----------------------------------------------------------------------------------------
1776    // corruption and incompatibility
1777    // -----------------------------------------------------------------------------------------
1778
1779    #[test]
1780    fn a_digest_that_does_not_match_its_bytes_is_corruption() {
1781        for field in ["state_digest", "tail_digest", "checkpoint_digest"] {
1782            let error = tampered(|document| {
1783                document.insert(
1784                    field.to_string(),
1785                    Value::String(digest("bogus").to_string()),
1786                );
1787            });
1788            assert_eq!(
1789                error.code(),
1790                KernelFaultCode::CheckpointCorrupted,
1791                "{field} must fail closed"
1792            );
1793            assert!(
1794                error.to_string().contains(CHECKPOINT_ERROR_MARKER),
1795                "{field}: every rejection carries the classifier marker"
1796            );
1797        }
1798    }
1799
1800    #[test]
1801    fn a_logical_state_edited_after_the_fact_is_corruption() {
1802        let error = tampered(|document| {
1803            document["logical_state"]["transition"]["lifecycle"] = Value::String("failed".into());
1804        });
1805        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1806        assert!(error.message().contains("logical state hashes to"));
1807    }
1808
1809    #[test]
1810    fn removed_version_fields_are_malformed() {
1811        for field in ["checkpoint_version", "abi_version"] {
1812            let error = tampered(|document| {
1813                document.insert(field.to_string(), Value::from(1));
1814            });
1815            assert_eq!(error.code(), KernelFaultCode::MalformedEnvelope);
1816        }
1817    }
1818
1819    #[test]
1820    fn an_unknown_field_is_refused_rather_than_ignored() {
1821        let error = tampered(|document| {
1822            // §12.4 deleted `last_step`; retired snapshots must fail closed.
1823            document.insert("last_step".to_string(), Value::Null);
1824        });
1825        assert_eq!(error.code(), KernelFaultCode::MalformedEnvelope);
1826    }
1827
1828    #[test]
1829    fn a_checkpoint_from_another_operation_or_genesis_is_incompatible() {
1830        let checkpoint = checkpoint();
1831        let other = OperationId::new("op-checkpoint-2").unwrap();
1832
1833        let error = checkpoint
1834            .verify_belongs_to(&other, &digest("genesis"))
1835            .expect_err("another operation's checkpoint is not installable");
1836        assert_eq!(error.code(), KernelFaultCode::CheckpointIncompatible);
1837        assert!(error.message().contains("belongs to operation"));
1838
1839        let error = checkpoint
1840            .verify_belongs_to(&operation(), &digest("another-genesis"))
1841            .expect_err("a different genesis is a different operation");
1842        assert_eq!(error.code(), KernelFaultCode::CheckpointIncompatible);
1843        assert!(error.message().contains("binds genesis"));
1844
1845        checkpoint
1846            .verify_belongs_to(&operation(), &digest("genesis"))
1847            .expect("its own operation and genesis are accepted");
1848    }
1849
1850    // -----------------------------------------------------------------------------------------
1851    // §12.1 · the bounded tail covers (base, through] exactly
1852    // -----------------------------------------------------------------------------------------
1853
1854    #[test]
1855    fn a_tail_that_covers_the_range_exactly_is_accepted() {
1856        KernelCheckpoint::assemble(draft(0, 0, Vec::new())).expect("an empty range needs no tail");
1857        KernelCheckpoint::assemble(draft(
1858            2,
1859            5,
1860            vec![tail_entry(3), tail_entry(4), tail_entry(5)],
1861        ))
1862        .expect("(2, 5] is three contiguous inputs");
1863    }
1864
1865    #[test]
1866    fn a_tail_with_a_hole_is_refused() {
1867        let error = KernelCheckpoint::assemble(draft(
1868            2,
1869            5,
1870            vec![tail_entry(3), tail_entry(5), tail_entry(6)],
1871        ))
1872        .expect_err("step 4 is missing");
1873        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1874        assert!(error.message().contains("step 4 was due"), "{error}");
1875    }
1876
1877    #[test]
1878    fn a_tail_with_a_duplicate_is_refused() {
1879        let error = KernelCheckpoint::assemble(draft(
1880            2,
1881            5,
1882            vec![tail_entry(3), tail_entry(3), tail_entry(4)],
1883        ))
1884        .expect_err("step 3 appears twice");
1885        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1886        assert!(error.message().contains("contiguous range"), "{error}");
1887    }
1888
1889    #[test]
1890    fn a_tail_entry_outside_the_range_is_refused() {
1891        // before the base
1892        let error = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(2), tail_entry(3)]))
1893            .expect_err("step 2 is the base, not part of (2, 4]");
1894        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1895
1896        // after the covered head
1897        let error = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(9)]))
1898            .expect_err("step 9 is past the covered head");
1899        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1900    }
1901
1902    #[test]
1903    fn a_tail_whose_length_disagrees_with_the_range_is_refused() {
1904        let error = KernelCheckpoint::assemble(draft(2, 5, vec![tail_entry(3)]))
1905            .expect_err("(2, 5] is three inputs, not one");
1906        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1907        assert!(error.message().contains("bounded tail holds 1"), "{error}");
1908
1909        let error = KernelCheckpoint::assemble(draft(4, 2, Vec::new()))
1910            .expect_err("a base past the covered head is not a range at all");
1911        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1912    }
1913
1914    #[test]
1915    fn a_tail_input_from_another_operation_is_incompatible() {
1916        let mut foreign = tail_entry(3);
1917        foreign.input.operation_id = OperationId::new("op-checkpoint-2").unwrap();
1918        let error = KernelCheckpoint::assemble(draft(2, 3, vec![foreign]))
1919            .expect_err("a tail assembled from two journals is not a checkpoint");
1920        assert_eq!(error.code(), KernelFaultCode::CheckpointIncompatible);
1921    }
1922
1923    /// Decoding re-runs the coverage check, so a hole punched into a stored blob is caught at the
1924    /// boundary rather than half-way through a replay.
1925    #[test]
1926    fn a_tail_edited_in_storage_is_refused_at_decode() {
1927        let original = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(4)]))
1928            .expect("assembles");
1929        let mut document: serde_json::Map<String, Value> =
1930            serde_json::from_slice(original.checkpoint_bytes().as_slice()).unwrap();
1931        let tail = document["tail_inputs"].as_array_mut().unwrap();
1932        tail.remove(0);
1933        let bytes = serde_json::to_vec(&document).unwrap();
1934        let error = KernelCheckpoint::from_checkpoint_bytes(&bytes)
1935            .expect_err("a truncated tail no longer covers its range");
1936        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1937    }
1938
1939    // -----------------------------------------------------------------------------------------
1940    // §12.3 · the candidate handle
1941    // -----------------------------------------------------------------------------------------
1942
1943    #[test]
1944    fn a_candidate_carries_the_five_values_of_the_spec_arrow() {
1945        let checkpoint = KernelCheckpoint::assemble(draft(3, 4, vec![tail_entry(4)])).unwrap();
1946        let expected_digest = checkpoint.checkpoint_digest().clone();
1947        let candidate = checkpoint.into_candidate();
1948
1949        assert_eq!(candidate.through_step_seq, WireU64::new(4));
1950        assert_eq!(candidate.covered_head, digest("record-4"));
1951        assert!(
1952            candidate.ack_token.as_str().contains(OPERATION)
1953                && candidate
1954                    .ack_token
1955                    .as_str()
1956                    .contains(expected_digest.as_str()),
1957            "the ack token names the checkpoint it acknowledges: {}",
1958            candidate.ack_token
1959        );
1960
1961        let decoded = candidate.decode().expect("the blob decodes and verifies");
1962        assert_eq!(decoded.checkpoint_digest(), &expected_digest);
1963        assert_eq!(decoded.state_digest(), &candidate.state_digest);
1964        assert_eq!(
1965            candidate.boundary().through_step_seq,
1966            candidate.through_step_seq
1967        );
1968    }
1969
1970    // -----------------------------------------------------------------------------------------
1971    // rejection fixtures
1972    // -----------------------------------------------------------------------------------------
1973
1974    fn fixture_dir() -> PathBuf {
1975        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
1976    }
1977
1978    /// Regenerate every `reject_checkpoint_*` blob from this module's own constructors.
1979    ///
1980    /// The fixtures used to be hand-written, which made them a second, drifting copy of the
1981    /// checkpoint shape: adding one field to the DTO invalidated all eight, and each had to be
1982    /// edited by hand into a document that still failed for the *declared* reason rather than for
1983    /// "missing field". Deriving them means a shape change costs one `BLESS_KERNEL_RECORD_FIXTURES=1`
1984    /// run, and — more importantly — a fixture can never claim to test a corruption while actually
1985    /// testing a stale schema.
1986    #[test]
1987    fn bless_checkpoint_rejection_fixtures() {
1988        if std::env::var("BLESS_KERNEL_RECORD_FIXTURES").as_deref() != Ok("1") {
1989            return;
1990        }
1991        let dir = fixture_dir();
1992        for (name, expect, description, mutate) in rejection_cases() {
1993            let mut document: serde_json::Map<String, Value> =
1994                serde_json::from_slice(mutate.0.checkpoint_bytes().as_slice()).unwrap();
1995            (mutate.1)(&mut document);
1996            let fixture = serde_json::json!({
1997                "expect": expect,
1998                "description": description,
1999                "checkpoint": Value::Object(document),
2000            });
2001            let mut text = serde_json::to_string_pretty(&fixture).unwrap();
2002            text.push('\n');
2003            fs::write(dir.join(name), text).unwrap_or_else(|e| panic!("cannot bless {name}: {e}"));
2004        }
2005    }
2006
2007    #[allow(clippy::type_complexity)]
2008    fn rejection_cases() -> Vec<(
2009        &'static str,
2010        &'static str,
2011        &'static str,
2012        (
2013            KernelCheckpoint,
2014            Box<dyn Fn(&mut serde_json::Map<String, Value>)>,
2015        ),
2016    )> {
2017        let full = || checkpoint();
2018        let with_tail =
2019            || KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(4)])).unwrap();
2020        vec![
2021            (
2022                "reject_checkpoint_removed_checkpoint_version.json",
2023                "malformed_envelope",
2024                "A checkpoint carrying the removed checkpoint version field is refused at the \
2025                 strict decode boundary.",
2026                (
2027                    full(),
2028                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2029                        d.insert("checkpoint_version".into(), Value::from(99u64));
2030                    }) as Box<dyn Fn(&mut serde_json::Map<String, Value>)>,
2031                ),
2032            ),
2033            (
2034                "reject_checkpoint_removed_abi_version.json",
2035                "malformed_envelope",
2036                "A checkpoint carrying the removed ABI version field is refused at the strict \
2037                 decode boundary.",
2038                (
2039                    full(),
2040                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2041                        d.insert("abi_version".into(), Value::from(1));
2042                    }),
2043                ),
2044            ),
2045            (
2046                "reject_checkpoint_state_digest_mismatch.json",
2047                "checkpoint_corrupted",
2048                "The logical state does not hash to the digest the checkpoint claims (spec 12.1).",
2049                (
2050                    full(),
2051                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2052                        d.insert(
2053                            "state_digest".into(),
2054                            Value::String(digest("bogus").to_string()),
2055                        );
2056                    }),
2057                ),
2058            ),
2059            (
2060                "reject_checkpoint_missing_field_checkpoint_digest.json",
2061                "malformed_envelope",
2062                "A structural refusal that names the field: a checkpoint without its own digest is \
2063                 not a checkpoint with an unverified digest.",
2064                (
2065                    full(),
2066                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2067                        d.remove("checkpoint_digest");
2068                    }),
2069                ),
2070            ),
2071            (
2072                "reject_checkpoint_unknown_field_last_step.json",
2073                "malformed_envelope",
2074                "Spec 12.4 deleted `last_step`; a blob that still carries one is refused \
2075                 rather than partially read.",
2076                (
2077                    full(),
2078                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2079                        d.insert("last_step".into(), Value::Null);
2080                    }),
2081                ),
2082            ),
2083            (
2084                "reject_checkpoint_base_disagrees_with_covered_head.json",
2085                "checkpoint_corrupted",
2086                "A full-state checkpoint covers no tail, so its base and its covered head name the \
2087                 same record; a header that disagrees with itself would hand a restore two \
2088                 different chain anchors (spec 12.1, Task 16).",
2089                (
2090                    full(),
2091                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2092                        d.insert(
2093                            "base_record_digest".into(),
2094                            Value::String(digest("another-record").to_string()),
2095                        );
2096                    }),
2097                ),
2098            ),
2099            (
2100                "reject_checkpoint_tail_hole.json",
2101                "checkpoint_corrupted",
2102                "The bounded tail must cover (base, through] with no hole (spec 12.1).",
2103                (
2104                    with_tail(),
2105                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2106                        d["tail_inputs"].as_array_mut().unwrap().remove(0);
2107                    }),
2108                ),
2109            ),
2110            (
2111                "reject_checkpoint_tail_duplicate.json",
2112                "checkpoint_corrupted",
2113                "The bounded tail must cover (base, through] with no duplicate (spec 12.1).",
2114                (
2115                    with_tail(),
2116                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2117                        let tail = d["tail_inputs"].as_array_mut().unwrap();
2118                        tail[1] = tail[0].clone();
2119                    }),
2120                ),
2121            ),
2122            (
2123                "reject_checkpoint_tail_foreign_operation.json",
2124                "checkpoint_incompatible",
2125                "A bounded tail assembled from two journals is not a checkpoint (spec 12.1).",
2126                (
2127                    with_tail(),
2128                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2129                        d["tail_inputs"][0]["input"]["operation_id"] =
2130                            Value::String("op-checkpoint-2".into());
2131                    }),
2132                ),
2133            ),
2134            (
2135                "reject_checkpoint_tail_ends_off_the_covered_head.json",
2136                "checkpoint_corrupted",
2137                "The last bounded-tail entry *is* the covered head; a tail that ends somewhere \
2138                 else covers a different prefix than the header claims (spec 12.1, Task 16).",
2139                (
2140                    with_tail(),
2141                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2142                        d["tail_inputs"][1]["record_digest"] =
2143                            Value::String(digest("some-other-record").to_string());
2144                    }),
2145                ),
2146            ),
2147        ]
2148    }
2149
2150    #[test]
2151    fn checkpoint_rejection_fixtures_fail_closed_with_the_declared_kind() {
2152        let dir = fixture_dir();
2153        let mut names: Vec<String> = fs::read_dir(&dir)
2154            .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()))
2155            .map(|entry| {
2156                entry
2157                    .expect("dir entry")
2158                    .file_name()
2159                    .to_string_lossy()
2160                    .to_string()
2161            })
2162            .filter(|name| name.starts_with("reject_checkpoint_") && name.ends_with(".json"))
2163            .collect();
2164        names.sort();
2165        assert!(
2166            names.len() >= 5,
2167            "too few checkpoint rejection fixtures: {names:?}"
2168        );
2169
2170        for name in names {
2171            let raw = fs::read_to_string(dir.join(&name)).expect("fixture reads");
2172            let fixture: Value = serde_json::from_str(&raw).expect("fixture is JSON");
2173            let expected = fixture["expect"]
2174                .as_str()
2175                .expect("every fixture declares `expect`");
2176            let bytes = serde_json::to_vec(&fixture["checkpoint"]).unwrap();
2177            let error = KernelCheckpoint::from_checkpoint_bytes(&bytes)
2178                .expect_err(&format!("{name}: expected a rejection"));
2179            assert_eq!(
2180                error.code().as_str(),
2181                expected,
2182                "{name}: {} (message: {})",
2183                error.code().as_str(),
2184                error.message()
2185            );
2186            // The `missing_field` / `unknown_field` naming convention has to mean something: both
2187            // are structural refusals, and both must name the field so a host can act on them.
2188            for (marker, needle) in [
2189                ("_missing_field_", "missing field"),
2190                ("_unknown_field_", "unknown field"),
2191            ] {
2192                if name.contains(marker) {
2193                    assert_eq!(
2194                        error.code(),
2195                        KernelFaultCode::MalformedEnvelope,
2196                        "{name}: a structural refusal is malformed_envelope"
2197                    );
2198                    assert!(
2199                        error.message().contains(needle),
2200                        "{name}: the rejection must say which field ({})",
2201                        error.message()
2202                    );
2203                }
2204            }
2205        }
2206    }
2207}