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    /// Monotonic ContextState generation used to invalidate an admitted ContextPlan after restore.
616    #[serde(default)]
617    pub state_generation: u64,
618    /// Optimization evidence must survive restore without changing source/confidence/fingerprint.
619    #[serde(default)]
620    pub system_measurements: Vec<crate::context::measurement::TokenMeasurement>,
621    #[serde(default)]
622    pub history_measurements: Vec<crate::context::measurement::TokenMeasurement>,
623    #[serde(default)]
624    pub knowledge_reference_step: u64,
625    #[serde(default)]
626    pub knowledge_budget_warned: bool,
627    /// **The** home of P3 handle identity and residency.
628    #[serde(default)]
629    pub handles: Vec<HandleState>,
630    /// The monotonic allocator. Checkpointing it is what stops a restored kernel from re-issuing a
631    /// handle id that an outstanding effect still addresses.
632    pub next_handle_id: u32,
633    /// §7.10 rule 4 · the digest each pending `LoadPayload` will verify its body against. Held
634    /// here rather than re-read from the handle table at resolution time, so a residency that moved
635    /// in between cannot change what a page-in is checked against.
636    #[serde(default)]
637    pub pending_payload_loads: Vec<PendingPayloadLoadState>,
638    #[serde(default)]
639    pub active_skills: Vec<SkillLeaseState>,
640    #[serde(default)]
641    pub knowledge: Vec<KnowledgeSlotState>,
642    #[serde(default)]
643    pub signals: Vec<String>,
644    /// §5q-2 · the stored messages of the system and history partitions, in render order. **The**
645    /// home of message bodies; the knowledge partition carries its own inside
646    /// [`KnowledgeSlotState`], because a knowledge entry is an identified slot rather than a
647    /// positional message.
648    #[serde(default)]
649    pub messages: Vec<StoredMessageState>,
650    /// The durable task board (goal / plan / progress / directives). It renders into the prompt like
651    /// a message does, survives compression by construction, and is the one partition of §12.1's P3
652    /// plane that is neither a message list nor a handle.
653    pub task_state: LogicalTaskState,
654    pub partition_tokens: PartitionTokenState,
655    pub history_len: u32,
656    /// CoreMessage-count boundary projected as `frozen_prefix_len` on future provider effects.
657    #[serde(default)]
658    pub frozen_history_len: u32,
659    pub last_activity_ms: WireU64,
660    #[serde(default)]
661    pub last_compact_ms: Option<WireU64>,
662}
663
664/// Which partition a [`StoredMessageState`] belongs to.
665///
666/// Only the two positional partitions: knowledge entries are keyed slots with their own lifecycle
667/// flags, so they live in [`KnowledgeSlotState`] instead of being a third value here.
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(rename_all = "snake_case")]
670pub enum MessagePartition {
671    System,
672    History,
673}
674
675/// One stored message, projected (§12.1, adjudication §5q-2).
676///
677/// This is the durable representation of the **CanonicalMessageState** concept (0.2.67):
678/// the concept is the L1 semantic authority for messages; this DTO is merely its current
679/// storage vehicle. The two are deliberately decoupled so a future distributed persistence
680/// is not locked to the checkpoint DTO's shape.
681///
682/// This is the *source* the renderer reads, not the rendered result: no prompt assembly, no
683/// residency projection, no salience footer. A restore rebuilds the partitions from these and then
684/// renders exactly what an uninterrupted run would have rendered.
685#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
686#[serde(deny_unknown_fields)]
687pub struct StoredMessageState {
688    pub partition: MessagePartition,
689    /// `system` | `user` | `assistant` | `tool`.
690    pub role: String,
691    pub body: StoredMessageBody,
692    /// The calls an assistant message asked for — the half of "tool association" that points
693    /// forward.
694    #[serde(default)]
695    pub tool_calls: Vec<LogicalToolCall>,
696    /// The cached token count the partition counter was built from. Carried rather than recomputed
697    /// so a restore reproduces the same budget arithmetic even if the tokenizer moved.
698    pub tokens: u32,
699}
700
701/// A message body, inline or by reference (§7.10).
702///
703/// The reference arm is the whole point: a tool result that was over the inline threshold when it
704/// was generated (`External`) or that left working context under pressure (`PagedOut`) is already
705/// represented in context by a preview plus a handle, and a checkpoint that re-inlined it would put
706/// bytes back into the journal that §7.10 spent an effect kind keeping out.
707#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
708#[serde(tag = "form", rename_all = "snake_case")]
709pub enum StoredMessageBody {
710    /// A body small enough to live in context.
711    Inline(InlineMessageBody),
712    /// A body that lives with the host. Carries the reference and the digest that verifies a
713    /// page-in, never the bytes.
714    Reference(ReferencedMessageBody),
715    /// A multimodal body the text projection cannot express (image or audio parts), carried as
716    /// canonical provider-neutral durable content.
717    ///
718    /// It exists so the projection is never *silently* lossy: a body that does not reduce to text
719    /// travels whole rather than being flattened to the text parts that happen to be next to it.
720    Structured(StructuredMessageBody),
721}
722
723#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
724#[serde(deny_unknown_fields)]
725pub struct InlineMessageBody {
726    pub text: String,
727    /// For a `tool` message: the call this result answers, and whether it failed.
728    #[serde(default)]
729    pub tool_call_id: Option<String>,
730    #[serde(default)]
731    pub is_error: bool,
732}
733
734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
735#[serde(deny_unknown_fields)]
736pub struct ReferencedMessageBody {
737    /// The P3 handle that addresses the body. Its residency in [`ContextVmState::handles`] is
738    /// what a page-in reads.
739    pub handle_id: u32,
740    /// The digest a page-in must reproduce (§7.10 rule 4).
741    pub digest: String,
742    /// What is actually resident: the preview the model can see. Never the whole body.
743    pub preview: String,
744    #[serde(default)]
745    pub tool_call_id: Option<String>,
746    #[serde(default)]
747    pub is_error: bool,
748}
749
750#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
751#[serde(deny_unknown_fields)]
752pub struct StructuredMessageBody {
753    /// Provider-neutral durable content.
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub durable_content: Option<crate::types::durable_content::DurableContent>,
756    /// Correlated durable results preserve one envelope per call id. A one-result message uses a
757    /// one-element vector; there is no alternate singular representation.
758    #[serde(default, skip_serializing_if = "Vec::is_empty")]
759    pub durable_tool_results: Vec<crate::types::durable_content::DurableToolResult>,
760}
761
762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763#[serde(deny_unknown_fields)]
764pub struct LogicalToolCall {
765    pub call_id: String,
766    pub name: String,
767    /// Canonical JSON text of the arguments. A string rather than a `Value` so the checkpoint's
768    /// canonical bytes are the arguments' canonical bytes, with no second serialiser in between.
769    pub arguments: String,
770}
771
772/// §12.1 · the durable task board, projected.
773///
774/// Explicitly re-declared rather than reusing `crate::context::task_state::TaskState`: that type is
775/// semantic-kernel state whose serde shape is free to move, and §12.1's first rule is that a field
776/// added there must not silently change the checkpoint format.
777#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
778#[serde(deny_unknown_fields)]
779pub struct LogicalTaskState {
780    #[serde(default)]
781    pub goal: String,
782    #[serde(default)]
783    pub criteria: Vec<String>,
784    #[serde(default)]
785    pub plan: Vec<LogicalPlanStep>,
786    #[serde(default)]
787    pub current_step: Option<u32>,
788    #[serde(default)]
789    pub progress: String,
790    #[serde(default)]
791    pub scratchpad: String,
792    #[serde(default)]
793    pub blocked_on: Vec<String>,
794    #[serde(default)]
795    pub directives: Vec<String>,
796    #[serde(default)]
797    pub preserved_refs: Vec<String>,
798    #[serde(default)]
799    pub recent_actions: Vec<String>,
800    #[serde(default)]
801    pub compression_log: Vec<LogicalCompressionEntry>,
802    #[serde(default)]
803    pub compression_log_dropped: WireU64,
804}
805
806#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
807#[serde(deny_unknown_fields)]
808pub struct LogicalPlanStep {
809    pub label: String,
810    pub done: bool,
811}
812
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814#[serde(deny_unknown_fields)]
815pub struct LogicalCompressionEntry {
816    pub action: String,
817    pub summary: String,
818}
819
820/// One P3 handle. `residency` is the label plus the locator fields that residency carries, so the
821/// DTO neither mirrors the internal enum's shape nor loses what a page-in needs.
822#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
823#[serde(deny_unknown_fields)]
824pub struct HandleState {
825    pub handle_id: u32,
826    pub kind: String,
827    pub residency: String,
828    #[serde(default)]
829    pub payload_ref: Option<String>,
830    #[serde(default)]
831    pub digest: Option<String>,
832    #[serde(default)]
833    pub original_size: Option<WireU64>,
834    pub tokens: u32,
835    /// Link back to the source object in working context (a tool `call_id` for a tool result).
836    #[serde(default)]
837    pub source: Option<String>,
838}
839
840#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
841#[serde(deny_unknown_fields)]
842pub struct PendingPayloadLoadState {
843    pub effect_id: EffectId,
844    pub handle_id: String,
845    pub digest: String,
846    #[serde(default)]
847    pub original_size: Option<WireU64>,
848}
849
850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
851#[serde(deny_unknown_fields)]
852pub struct SkillLeaseState {
853    pub skill: String,
854    /// `None` = permanent; otherwise the turn the lease expires on.
855    #[serde(default)]
856    pub lease_until_turn: Option<u32>,
857}
858
859/// One knowledge slot, body included.
860///
861/// A knowledge entry has identity (its key) and its own lifecycle flags, so it is not a positional
862/// [`StoredMessageState`] — but it renders into the prompt all the same, which is why Task 16 gave it
863/// the same body projection. Without it, a restore rebuilt the *shape* of the knowledge partition
864/// and none of its content.
865#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
866#[serde(deny_unknown_fields)]
867pub struct KnowledgeSlotState {
868    /// `None` = an unkeyed append; keyed entries upsert.
869    #[serde(default)]
870    pub key: Option<String>,
871    pub role: String,
872    pub body: StoredMessageBody,
873    pub tokens: u32,
874    pub pinned: bool,
875    pub evict_at_boundary: bool,
876    #[serde(default)]
877    pub tool_calls: Vec<LogicalToolCall>,
878    /// The replacement staged for the next cache-generation boundary.
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub pending: Option<Box<StoredMessageState>>,
881    #[serde(default)]
882    pub use_count: u64,
883    #[serde(default)]
884    pub last_used_step: Option<u64>,
885}
886
887#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct PartitionTokenState {
890    pub system: u32,
891    pub knowledge: u32,
892    pub history: u32,
893}
894
895/// What the semantic driver contributes to a checkpoint.
896///
897/// Three of the four partitions plus the two transition fields the driver — not the transaction —
898/// owns. It is a value, not a borrow of the driver: the checkpoint is built from an explicit
899/// projection, never from a live reference into the engine.
900#[derive(Debug, Clone, PartialEq)]
901pub struct LogicalStateProjection {
902    pub root_kind: Option<RootKind>,
903    pub focus: Option<ExecutionFocus>,
904    pub syscall: SyscallState,
905    pub scheduler: SchedulerState,
906    pub context_vm: ContextVmState,
907}
908
909// ---------------------------------------------------------------------------------------------
910// §12.1 · the checkpoint
911// ---------------------------------------------------------------------------------------------
912
913/// Everything [`KernelCheckpoint::assemble`] needs. A struct rather than eight positional
914/// arguments, because two of them are step sequences and two are digests.
915#[derive(Debug, Clone, PartialEq)]
916pub struct CheckpointDraft {
917    pub operation_id: OperationId,
918    pub genesis_digest: Digest,
919    pub base_step_seq: WireU64,
920    pub base_record_digest: Digest,
921    pub through_step_seq: WireU64,
922    pub covered_transaction_head_digest: Digest,
923    pub logical_state: LogicalKernelState,
924    pub tail_inputs: Vec<CanonicalInput>,
925}
926
927/// One logical checkpoint (§12.1).
928///
929/// Fields are private and every digest is computed by [`Self::assemble`], so there is no
930/// constructor that takes a digest and "the host recomputed the hash and disagreed" is not a
931/// reachable state — the same discipline [`KernelRecord`](super::record::KernelRecord) uses.
932/// Decoding goes through the same verification, which is why a tampered blob fails at the boundary
933/// rather than half-way through a restore.
934#[derive(Debug, Clone, PartialEq, Serialize)]
935pub struct KernelCheckpoint {
936    operation_id: OperationId,
937    /// The digest of the operation's genesis record — its identity. A checkpoint built from
938    /// another operation's journal therefore cannot be installed by accident.
939    genesis_digest: Digest,
940    /// The step the logical state below describes.
941    base_step_seq: WireU64,
942    /// The record digest at `base_step_seq` — the chain anchor a tail replay starts from.
943    ///
944    /// The other end of the range the header already states. Without it a rebase could be
945    /// *verified* and never *replayed*: the record before the first tail entry is exactly the one an
946    /// acked checkpoint is allowed to have pruned, so its digest has to travel with the tail that
947    /// depends on it. For a full-state checkpoint it is the covered head, because `base == through`.
948    base_record_digest: Digest,
949    /// The step the checkpoint covers once its tail is replayed.
950    through_step_seq: WireU64,
951    /// The record digest at `through_step_seq`. §12.3 rule 2: install checks that this names the
952    /// through step, **not** that it is still the current head.
953    covered_transaction_head_digest: Digest,
954    logical_state: LogicalKernelState,
955    tail_inputs: Vec<CanonicalInput>,
956    state_digest: Digest,
957    tail_digest: Digest,
958    checkpoint_digest: Digest,
959}
960
961/// The digested body: every field of a checkpoint except the digest that summarises it.
962#[derive(Serialize)]
963struct CheckpointBody<'a> {
964    operation_id: &'a OperationId,
965    genesis_digest: &'a Digest,
966    base_step_seq: WireU64,
967    base_record_digest: &'a Digest,
968    through_step_seq: WireU64,
969    covered_transaction_head_digest: &'a Digest,
970    logical_state: &'a LogicalKernelState,
971    tail_inputs: &'a [CanonicalInput],
972    state_digest: &'a Digest,
973    tail_digest: &'a Digest,
974}
975
976impl KernelCheckpoint {
977    /// Build a checkpoint, computing all three digests and checking the tail covers
978    /// `(base_step_seq, through_step_seq]` exactly.
979    pub fn assemble(draft: CheckpointDraft) -> Result<Self, CheckpointError> {
980        let CheckpointDraft {
981            operation_id,
982            genesis_digest,
983            base_step_seq,
984            base_record_digest,
985            through_step_seq,
986            covered_transaction_head_digest,
987            logical_state,
988            tail_inputs,
989        } = draft;
990
991        validate_durable_message_bodies(&logical_state.context_vm)?;
992
993        check_tail(
994            &operation_id,
995            base_step_seq,
996            &base_record_digest,
997            through_step_seq,
998            &covered_transaction_head_digest,
999            &tail_inputs,
1000        )?;
1001
1002        let state_digest = canonical_digest(canonical_bytes(&logical_state)?.as_slice());
1003        let tail_digest = canonical_digest(canonical_bytes(&tail_inputs)?.as_slice());
1004        let checkpoint_digest = Self::body_digest(&CheckpointBody {
1005            operation_id: &operation_id,
1006            genesis_digest: &genesis_digest,
1007            base_step_seq,
1008            base_record_digest: &base_record_digest,
1009            through_step_seq,
1010            covered_transaction_head_digest: &covered_transaction_head_digest,
1011            logical_state: &logical_state,
1012            tail_inputs: &tail_inputs,
1013            state_digest: &state_digest,
1014            tail_digest: &tail_digest,
1015        })?;
1016
1017        Ok(Self {
1018            operation_id,
1019            genesis_digest,
1020            base_step_seq,
1021            base_record_digest,
1022            through_step_seq,
1023            covered_transaction_head_digest,
1024            logical_state,
1025            tail_inputs,
1026            state_digest,
1027            tail_digest,
1028            checkpoint_digest,
1029        })
1030    }
1031
1032    fn body_digest(body: &CheckpointBody<'_>) -> Result<Digest, CheckpointError> {
1033        Ok(canonical_digest(canonical_bytes(body)?.as_slice()))
1034    }
1035
1036    // ----- read-only accessors -----
1037
1038    pub fn operation_id(&self) -> &OperationId {
1039        &self.operation_id
1040    }
1041
1042    pub fn genesis_digest(&self) -> &Digest {
1043        &self.genesis_digest
1044    }
1045
1046    pub fn base_step_seq(&self) -> WireU64 {
1047        self.base_step_seq
1048    }
1049
1050    pub fn base_record_digest(&self) -> &Digest {
1051        &self.base_record_digest
1052    }
1053
1054    pub fn through_step_seq(&self) -> WireU64 {
1055        self.through_step_seq
1056    }
1057
1058    pub fn covered_transaction_head_digest(&self) -> &Digest {
1059        &self.covered_transaction_head_digest
1060    }
1061
1062    pub fn logical_state(&self) -> &LogicalKernelState {
1063        &self.logical_state
1064    }
1065
1066    pub fn tail_inputs(&self) -> &[CanonicalInput] {
1067        &self.tail_inputs
1068    }
1069
1070    pub fn state_digest(&self) -> &Digest {
1071        &self.state_digest
1072    }
1073
1074    pub fn tail_digest(&self) -> &Digest {
1075        &self.tail_digest
1076    }
1077
1078    pub fn checkpoint_digest(&self) -> &Digest {
1079        &self.checkpoint_digest
1080    }
1081
1082    // ----- projection -----
1083
1084    /// Canonical bytes of the whole checkpoint — the blob a host persists.
1085    pub fn checkpoint_bytes(&self) -> CanonicalBytes {
1086        canonical_bytes(self).expect("a checkpoint contains only canonical scalars")
1087    }
1088
1089    /// Decode a checkpoint from its stored bytes, verifying every digest and the tail coverage.
1090    pub fn from_checkpoint_bytes(bytes: &[u8]) -> Result<Self, CheckpointError> {
1091        let text = std::str::from_utf8(bytes).map_err(|error| {
1092            CheckpointError::NotCanonical(format!("checkpoint bytes are not UTF-8: {error}"))
1093        })?;
1094        let document =
1095            serde_json::from_str(text).map_err(|error| decode_error(&error.to_string()))?;
1096        decode_checkpoint_value(document)
1097    }
1098
1099    /// The prefix an ack of this checkpoint may reclaim (§12.3 rule 6).
1100    pub fn boundary(&self) -> super::transaction::CheckpointBoundary {
1101        super::transaction::CheckpointBoundary {
1102            through_step_seq: self.through_step_seq,
1103            covered_head: self.covered_transaction_head_digest.clone(),
1104        }
1105    }
1106
1107    /// The §12.3 candidate this checkpoint hands the host.
1108    pub fn into_candidate(self) -> CheckpointCandidate {
1109        let ack_token = ack_token_for(
1110            &self.operation_id,
1111            self.through_step_seq,
1112            &self.checkpoint_digest,
1113        );
1114        CheckpointCandidate {
1115            checkpoint_bytes: self.checkpoint_bytes(),
1116            through_step_seq: self.through_step_seq,
1117            covered_head: self.covered_transaction_head_digest.clone(),
1118            state_digest: self.state_digest.clone(),
1119            ack_token,
1120        }
1121    }
1122
1123    // ----- verification -----
1124
1125    /// Recompute every digest from the bytes this checkpoint carries and re-check its tail.
1126    ///
1127    /// The first two lines of §12.2's ladder. `verify_belongs_to` adds the operation/genesis half;
1128    /// removed shapes are rejected by strict decoding before a checkpoint can exist.
1129    pub fn verify(&self) -> Result<(), CheckpointError> {
1130        validate_durable_message_bodies(&self.logical_state.context_vm)?;
1131        check_tail(
1132            &self.operation_id,
1133            self.base_step_seq,
1134            &self.base_record_digest,
1135            self.through_step_seq,
1136            &self.covered_transaction_head_digest,
1137            &self.tail_inputs,
1138        )?;
1139
1140        let state_digest = canonical_digest(canonical_bytes(&self.logical_state)?.as_slice());
1141        if state_digest != self.state_digest {
1142            return Err(CheckpointError::Corrupted(format!(
1143                "checkpoint {} through step {}: the logical state hashes to {state_digest}, \
1144                 but the checkpoint claims {}",
1145                self.operation_id, self.through_step_seq, self.state_digest
1146            )));
1147        }
1148        let tail_digest = canonical_digest(canonical_bytes(&self.tail_inputs)?.as_slice());
1149        if tail_digest != self.tail_digest {
1150            return Err(CheckpointError::Corrupted(format!(
1151                "checkpoint {} through step {}: the bounded tail hashes to {tail_digest}, \
1152                 but the checkpoint claims {}",
1153                self.operation_id, self.through_step_seq, self.tail_digest
1154            )));
1155        }
1156        let checkpoint_digest = Self::body_digest(&CheckpointBody {
1157            operation_id: &self.operation_id,
1158            genesis_digest: &self.genesis_digest,
1159            base_step_seq: self.base_step_seq,
1160            base_record_digest: &self.base_record_digest,
1161            through_step_seq: self.through_step_seq,
1162            covered_transaction_head_digest: &self.covered_transaction_head_digest,
1163            logical_state: &self.logical_state,
1164            tail_inputs: &self.tail_inputs,
1165            state_digest: &self.state_digest,
1166            tail_digest: &self.tail_digest,
1167        })?;
1168        if checkpoint_digest != self.checkpoint_digest {
1169            return Err(CheckpointError::Corrupted(format!(
1170                "checkpoint {} through step {}: the body hashes to {checkpoint_digest}, \
1171                 but the checkpoint claims {}",
1172                self.operation_id, self.through_step_seq, self.checkpoint_digest
1173            )));
1174        }
1175        Ok(())
1176    }
1177
1178    /// Whether this checkpoint is this operation's (§12.2 line 2).
1179    pub fn verify_belongs_to(
1180        &self,
1181        operation_id: &OperationId,
1182        genesis_digest: &Digest,
1183    ) -> Result<(), CheckpointError> {
1184        if &self.operation_id != operation_id {
1185            return Err(CheckpointError::Incompatible(format!(
1186                "checkpoint belongs to operation {}, this runtime to {operation_id}",
1187                self.operation_id
1188            )));
1189        }
1190        if &self.genesis_digest != genesis_digest {
1191            return Err(CheckpointError::Incompatible(format!(
1192                "checkpoint {operation_id} binds genesis {}, this journal's genesis is \
1193                 {genesis_digest}",
1194                self.genesis_digest
1195            )));
1196        }
1197        Ok(())
1198    }
1199}
1200
1201fn validate_durable_message_bodies(context: &ContextVmState) -> Result<(), CheckpointError> {
1202    let bodies = context
1203        .messages
1204        .iter()
1205        .map(|message| &message.body)
1206        .chain(context.knowledge.iter().map(|slot| &slot.body))
1207        .chain(
1208            context
1209                .knowledge
1210                .iter()
1211                .filter_map(|slot| slot.pending.as_ref().map(|pending| &pending.body)),
1212        );
1213    for body in bodies {
1214        let StoredMessageBody::Structured(structured) = body else {
1215            continue;
1216        };
1217        let body_forms = usize::from(structured.durable_content.is_some())
1218            + usize::from(!structured.durable_tool_results.is_empty());
1219        if body_forms > 1 {
1220            return Err(CheckpointError::Incompatible(
1221                "structured message carries more than one durable body form".into(),
1222            ));
1223        }
1224        if !structured.durable_tool_results.is_empty() {
1225            for result in &structured.durable_tool_results {
1226                result.validate().map_err(|error| {
1227                    CheckpointError::Incompatible(format!(
1228                        "structured message carries invalid durable tool result: {error}"
1229                    ))
1230                })?;
1231            }
1232        } else if let Some(content) = &structured.durable_content {
1233            content.validate().map_err(|error| {
1234                CheckpointError::Incompatible(format!(
1235                    "structured message carries invalid durable content: {error}"
1236                ))
1237            })?;
1238        } else {
1239            return Err(CheckpointError::Incompatible(
1240                "structured message carries no durable content".into(),
1241            ));
1242        }
1243    }
1244    Ok(())
1245}
1246
1247/// §12.1 · the bounded tail covers `(base_step_seq, through_step_seq]` exactly.
1248///
1249/// One walk catches all four failure modes the spec names: a hole (a gap in the sequence), a
1250/// duplicate (the same step twice), an out-of-range entry (before `base` or after `through`), and a
1251/// length that disagrees with the range. It also refuses a tail entry from another operation —
1252/// the cheapest way to notice a checkpoint assembled from two journals.
1253fn check_tail(
1254    operation_id: &OperationId,
1255    base_step_seq: WireU64,
1256    base_record_digest: &Digest,
1257    through_step_seq: WireU64,
1258    covered_transaction_head_digest: &Digest,
1259    tail_inputs: &[CanonicalInput],
1260) -> Result<(), CheckpointError> {
1261    if base_step_seq > through_step_seq {
1262        return Err(CheckpointError::Corrupted(format!(
1263            "checkpoint {operation_id} bases at step {base_step_seq} but covers only through \
1264             {through_step_seq}"
1265        )));
1266    }
1267    // The two ends of the range meet when the range is empty: a full-state checkpoint's base *is*
1268    // its covered head, and a header that disagreed with itself about that would hand a restore two
1269    // different anchors for one record.
1270    if base_step_seq == through_step_seq && base_record_digest != covered_transaction_head_digest {
1271        return Err(CheckpointError::Corrupted(format!(
1272            "checkpoint {operation_id} covers no tail, so its base {base_record_digest} and its \
1273             covered head {covered_transaction_head_digest} name the same record — but they differ"
1274        )));
1275    }
1276    let expected = through_step_seq.get() - base_step_seq.get();
1277    if tail_inputs.len() as u64 != expected {
1278        return Err(CheckpointError::Corrupted(format!(
1279            "checkpoint {operation_id} covers ({base_step_seq}, {through_step_seq}] — {expected} \
1280             inputs — but its bounded tail holds {}",
1281            tail_inputs.len()
1282        )));
1283    }
1284    for (offset, entry) in tail_inputs.iter().enumerate() {
1285        let want = base_step_seq.get() + offset as u64 + 1;
1286        if entry.step_seq.get() != want {
1287            return Err(CheckpointError::Corrupted(format!(
1288                "checkpoint {operation_id} bounded tail is not the contiguous range \
1289                 ({base_step_seq}, {through_step_seq}]: position {offset} is step {} where step \
1290                 {want} was due",
1291                entry.step_seq
1292            )));
1293        }
1294        if &entry.input.operation_id != operation_id {
1295            return Err(CheckpointError::Incompatible(format!(
1296                "checkpoint {operation_id} bounded tail carries an input of operation {} at step \
1297                 {}",
1298                entry.input.operation_id, entry.step_seq
1299            )));
1300        }
1301    }
1302    // The last tail entry *is* the covered head; a tail that ends somewhere else covers a different
1303    // prefix than the header claims.
1304    if let Some(last) = tail_inputs.last()
1305        && &last.record_digest != covered_transaction_head_digest
1306    {
1307        return Err(CheckpointError::Corrupted(format!(
1308            "checkpoint {operation_id} claims covered head {covered_transaction_head_digest}, but \
1309             its bounded tail ends at {} on step {}",
1310            last.record_digest, last.step_seq
1311        )));
1312    }
1313    Ok(())
1314}
1315
1316fn ack_token_for(
1317    operation_id: &OperationId,
1318    through_step_seq: WireU64,
1319    checkpoint_digest: &Digest,
1320) -> CheckpointAckToken {
1321    CheckpointAckToken::new(format!(
1322        "{operation_id}:checkpoint:{through_step_seq}:{checkpoint_digest}"
1323    ))
1324    .expect("an operation-scoped checkpoint ack token is always a legal branded ref")
1325}
1326
1327/// §12.3 · what `kernel.checkpoint_candidate()` hands the host.
1328///
1329/// Exactly the five values of the spec's arrow, and nothing that would let a host reconstruct the
1330/// checkpoint itself: `checkpoint_bytes` is opaque storage, `through_step_seq`/`covered_head` are
1331/// the install precondition, `state_digest` is what an installed blob is audited against, and
1332/// `ack_token` is the maintenance handle that closes the loop.
1333#[derive(Debug, Clone, PartialEq)]
1334pub struct CheckpointCandidate {
1335    pub checkpoint_bytes: CanonicalBytes,
1336    pub through_step_seq: WireU64,
1337    pub covered_head: Digest,
1338    pub state_digest: Digest,
1339    pub ack_token: CheckpointAckToken,
1340}
1341
1342impl CheckpointCandidate {
1343    /// The boundary this candidate would let an ack reclaim (§12.3 rule 6).
1344    pub fn boundary(&self) -> super::transaction::CheckpointBoundary {
1345        super::transaction::CheckpointBoundary {
1346            through_step_seq: self.through_step_seq,
1347            covered_head: self.covered_head.clone(),
1348        }
1349    }
1350
1351    /// Decode the blob back into a verified checkpoint — what an install path does before it
1352    /// writes anything.
1353    pub fn decode(&self) -> Result<KernelCheckpoint, CheckpointError> {
1354        KernelCheckpoint::from_checkpoint_bytes(self.checkpoint_bytes.as_slice())
1355    }
1356}
1357
1358// ---------------------------------------------------------------------------------------------
1359// decoding
1360// ---------------------------------------------------------------------------------------------
1361
1362/// Current-version wire projection. It is only reached after revision dispatch, so
1363/// [`KernelCheckpoint`]'s fields stay private and every decoded checkpoint is verified before it
1364/// exists.
1365#[derive(Deserialize)]
1366#[serde(deny_unknown_fields)]
1367struct CheckpointProjection {
1368    operation_id: OperationId,
1369    genesis_digest: Digest,
1370    base_step_seq: WireU64,
1371    base_record_digest: Digest,
1372    through_step_seq: WireU64,
1373    covered_transaction_head_digest: Digest,
1374    logical_state: LogicalKernelState,
1375    tail_inputs: Vec<CanonicalInput>,
1376    state_digest: Digest,
1377    tail_digest: Digest,
1378    checkpoint_digest: Digest,
1379}
1380
1381fn decode_checkpoint_value(
1382    document: serde_json::Value,
1383) -> Result<KernelCheckpoint, CheckpointError> {
1384    decode_current_checkpoint(document)
1385}
1386
1387fn decode_current_checkpoint(
1388    document: serde_json::Value,
1389) -> Result<KernelCheckpoint, CheckpointError> {
1390    let projection = serde_json::from_value::<CheckpointProjection>(document)
1391        .map_err(|error| decode_error(&error.to_string()))?;
1392    let checkpoint = KernelCheckpoint {
1393        operation_id: projection.operation_id,
1394        genesis_digest: projection.genesis_digest,
1395        base_step_seq: projection.base_step_seq,
1396        base_record_digest: projection.base_record_digest,
1397        through_step_seq: projection.through_step_seq,
1398        covered_transaction_head_digest: projection.covered_transaction_head_digest,
1399        logical_state: projection.logical_state,
1400        tail_inputs: projection.tail_inputs,
1401        state_digest: projection.state_digest,
1402        tail_digest: projection.tail_digest,
1403        checkpoint_digest: projection.checkpoint_digest,
1404    };
1405    checkpoint.verify()?;
1406    Ok(checkpoint)
1407}
1408
1409/// Recover a rejection's class from the string `serde` hands back.
1410///
1411/// Three sources reach here: this module's own [`CheckpointError`] rendered by
1412/// [`fmt::Display`] (which names its code), the scalar layer's ABI-revision refusal, and
1413/// everything structural — an unknown field, a missing field, a value of the wrong shape.
1414fn decode_error(message: &str) -> CheckpointError {
1415    if message.contains(CHECKPOINT_ERROR_MARKER) {
1416        for code in [
1417            KernelFaultCode::CheckpointIncompatible,
1418            KernelFaultCode::CheckpointCorrupted,
1419        ] {
1420            if message.contains(&format!("{CHECKPOINT_ERROR_MARKER} ({})", code.as_str())) {
1421                return match code {
1422                    KernelFaultCode::CheckpointIncompatible => {
1423                        CheckpointError::Incompatible(message.to_string())
1424                    }
1425                    _ => CheckpointError::Corrupted(message.to_string()),
1426                };
1427            }
1428        }
1429        return CheckpointError::Corrupted(message.to_string());
1430    }
1431    if message.contains(SCALAR_ERROR_MARKER) && message.contains("ABI revision") {
1432        return CheckpointError::Incompatible(message.to_string());
1433    }
1434    CheckpointError::NotCanonical(format!("checkpoint does not decode: {message}"))
1435}
1436
1437impl<'de> Deserialize<'de> for KernelCheckpoint {
1438    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1439        let document = serde_json::Value::deserialize(deserializer)?;
1440        decode_checkpoint_value(document)
1441            .map_err(|error| serde::de::Error::custom(error.to_string()))
1442    }
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use std::collections::BTreeMap;
1448    use std::fs;
1449    use std::path::PathBuf;
1450
1451    use serde_json::Value;
1452
1453    use super::super::config::{ConfigDefaults, HostEffectSupport, OperationConfig};
1454    use super::super::effect::EffectKindTag;
1455    use super::super::envelope::{ConfigureOperation, KernelInput, WireEnvelope};
1456    use super::*;
1457
1458    // -----------------------------------------------------------------------------------------
1459    // helpers
1460    // -----------------------------------------------------------------------------------------
1461
1462    const OPERATION: &str = "op-checkpoint-1";
1463
1464    fn operation() -> OperationId {
1465        OperationId::new(OPERATION).unwrap()
1466    }
1467
1468    fn digest(label: &str) -> Digest {
1469        canonical_digest(label.as_bytes())
1470    }
1471
1472    fn normalized(input_id: &str, at: u64) -> NormalizedInput {
1473        let envelope = WireEnvelope::new(
1474            operation(),
1475            InputId::new(input_id).unwrap(),
1476            WireU64::new(at),
1477            KernelInput::ConfigureOperation(ConfigureOperation {
1478                config: OperationConfig {
1479                    host_effect_support: HostEffectSupport {
1480                        supported: vec![EffectKindTag::CallProvider],
1481                    },
1482                    ..OperationConfig::default()
1483                },
1484            }),
1485        );
1486        NormalizedInput::normalize(&envelope, &ConfigDefaults::default()).expect("normalizes")
1487    }
1488
1489    fn tail_entry(step_seq: u64) -> CanonicalInput {
1490        CanonicalInput {
1491            step_seq: WireU64::new(step_seq),
1492            record_digest: digest(&format!("record-{step_seq}")),
1493            input: normalized(&format!("in-{step_seq}"), 1_700_000_000_000 + step_seq),
1494        }
1495    }
1496
1497    fn resolved_config() -> ResolvedOperationConfig {
1498        OperationConfig {
1499            host_effect_support: HostEffectSupport {
1500                supported: vec![EffectKindTag::CallProvider],
1501            },
1502            ..OperationConfig::default()
1503        }
1504        .resolve(&ConfigDefaults::default())
1505        .expect("the default configuration resolves")
1506    }
1507
1508    fn logical_state() -> LogicalKernelState {
1509        LogicalKernelState {
1510            transition: TransitionState {
1511                lifecycle: OperationLifecycle::Running,
1512                resolved_config: resolved_config(),
1513                root_kind: Some(RootKind::Agent),
1514                focus: None,
1515                last_observed_at_ms: WireU64::new(1_700_000_002_000),
1516                pending_effects: Vec::new(),
1517                resolved_effects: Vec::new(),
1518                launch_tokens: Vec::new(),
1519                accepted_inputs: vec![AcceptedInputState {
1520                    input_id: InputId::new("in-configure").unwrap(),
1521                    step_seq: WireU64::ZERO,
1522                    record_digest: digest("record-0"),
1523                }],
1524                accepted_cancellation: None,
1525                terminal: None,
1526            },
1527            syscall: SyscallState::default(),
1528            scheduler: SchedulerState::default(),
1529            context_vm: ContextVmState::default(),
1530        }
1531    }
1532
1533    fn draft(base: u64, through: u64, tail: Vec<CanonicalInput>) -> CheckpointDraft {
1534        CheckpointDraft {
1535            operation_id: operation(),
1536            genesis_digest: digest("genesis"),
1537            base_step_seq: WireU64::new(base),
1538            base_record_digest: digest(&format!("record-{base}")),
1539            through_step_seq: WireU64::new(through),
1540            covered_transaction_head_digest: digest(&format!("record-{through}")),
1541            logical_state: logical_state(),
1542            tail_inputs: tail,
1543        }
1544    }
1545
1546    fn checkpoint() -> KernelCheckpoint {
1547        KernelCheckpoint::assemble(draft(3, 3, Vec::new())).expect("assembles")
1548    }
1549
1550    /// Round-trip a checkpoint through JSON with one field rewritten — the cheapest way to test a
1551    /// tamper without a constructor that could produce one.
1552    fn tampered(edit: impl FnOnce(&mut serde_json::Map<String, Value>)) -> CheckpointError {
1553        let mut document: serde_json::Map<String, Value> =
1554            serde_json::from_slice(checkpoint().checkpoint_bytes().as_slice()).unwrap();
1555        edit(&mut document);
1556        let bytes = serde_json::to_vec(&document).unwrap();
1557        KernelCheckpoint::from_checkpoint_bytes(&bytes)
1558            .expect_err("a tampered checkpoint must not decode")
1559    }
1560
1561    // -----------------------------------------------------------------------------------------
1562    // §12.1 · shape
1563    // -----------------------------------------------------------------------------------------
1564
1565    #[test]
1566    fn a_checkpoint_has_no_version_axis() {
1567        let document: Value =
1568            serde_json::from_slice(checkpoint().checkpoint_bytes().as_slice()).unwrap();
1569        assert!(document.get("checkpoint_version").is_none());
1570        assert!(document.get("abi_version").is_none());
1571    }
1572
1573    /// The load-bearing invariant of §12.1: each piece of correctness state has exactly one home,
1574    /// and the header repeats none of it.
1575    #[test]
1576    fn single_ownership_is_structural() {
1577        let checkpoint = checkpoint();
1578        let document: Value =
1579            serde_json::from_slice(checkpoint.checkpoint_bytes().as_slice()).unwrap();
1580        let state = &document["logical_state"];
1581
1582        // 1. every owned key appears exactly once in the whole logical state document. This is a
1583        //    total scan, not a spot check: the partitions serialise their whole key set (no
1584        //    `skip_serializing_if`), so an empty vector is still a visible claim of ownership.
1585        for (owned, owner) in [
1586            ("pending_effects", "transition"),
1587            ("resolved_effects", "transition"),
1588            ("launch_tokens", "transition"),
1589            ("accepted_inputs", "transition"),
1590            ("accepted_cancellation", "transition"),
1591            ("terminal", "transition"),
1592            ("attempts", "scheduler"),
1593            ("tasks", "scheduler"),
1594            ("handles", "context_vm"),
1595            ("pending_payload_loads", "context_vm"),
1596            ("provider_calls", "syscall"),
1597        ] {
1598            let mut seen = Vec::new();
1599            for partition in ["transition", "syscall", "scheduler", "context_vm"] {
1600                if state[partition]
1601                    .as_object()
1602                    .map(|map| map.contains_key(owned))
1603                    .unwrap_or(false)
1604                {
1605                    seen.push(partition);
1606                }
1607            }
1608            assert_eq!(
1609                seen,
1610                vec![owner],
1611                "{owned} must live in exactly one partition"
1612            );
1613        }
1614
1615        // 2. the four partitions share no key at all
1616        let mut home: BTreeMap<String, &str> = BTreeMap::new();
1617        for partition in ["transition", "syscall", "scheduler", "context_vm"] {
1618            for key in state[partition]
1619                .as_object()
1620                .expect("a partition object")
1621                .keys()
1622            {
1623                if let Some(previous) = home.insert(key.clone(), partition) {
1624                    panic!("key {key} lives in both {previous} and {partition}");
1625                }
1626            }
1627        }
1628
1629        // 3. the header stores no sub-state
1630        let header: Vec<&String> = document
1631            .as_object()
1632            .unwrap()
1633            .keys()
1634            .filter(|key| home.contains_key(*key))
1635            .collect();
1636        assert!(
1637            header.is_empty(),
1638            "the checkpoint header duplicates sub-state: {header:?}"
1639        );
1640    }
1641
1642    /// The DTO must be buildable without touching the semantic engine — the whole point of the
1643    /// explicit projection. A default projection is a legal (empty) checkpoint.
1644    #[test]
1645    fn the_dto_is_constructible_without_any_state_machine() {
1646        let state = LogicalKernelState {
1647            transition: TransitionState {
1648                lifecycle: OperationLifecycle::Created,
1649                resolved_config: resolved_config(),
1650                root_kind: None,
1651                focus: None,
1652                last_observed_at_ms: WireU64::ZERO,
1653                pending_effects: Vec::new(),
1654                resolved_effects: Vec::new(),
1655                launch_tokens: Vec::new(),
1656                accepted_inputs: Vec::new(),
1657                accepted_cancellation: None,
1658                terminal: None,
1659            },
1660            syscall: SyscallState::default(),
1661            scheduler: SchedulerState::default(),
1662            context_vm: ContextVmState::default(),
1663        };
1664        let mut draft = draft(0, 0, Vec::new());
1665        draft.logical_state = state;
1666        KernelCheckpoint::assemble(draft).expect("an empty logical state is still a checkpoint");
1667    }
1668
1669    // -----------------------------------------------------------------------------------------
1670    // §12.1 · digests
1671    // -----------------------------------------------------------------------------------------
1672
1673    #[test]
1674    fn the_three_digests_summarise_three_different_things() {
1675        let checkpoint = checkpoint();
1676        assert_eq!(
1677            checkpoint.state_digest(),
1678            &canonical_digest(
1679                canonical_bytes(checkpoint.logical_state())
1680                    .unwrap()
1681                    .as_slice()
1682            ),
1683        );
1684        assert_eq!(
1685            checkpoint.tail_digest(),
1686            &canonical_digest(
1687                canonical_bytes(checkpoint.tail_inputs())
1688                    .unwrap()
1689                    .as_slice()
1690            ),
1691        );
1692        assert_ne!(checkpoint.state_digest(), checkpoint.checkpoint_digest());
1693        assert_ne!(checkpoint.tail_digest(), checkpoint.checkpoint_digest());
1694        checkpoint
1695            .verify()
1696            .expect("a freshly built checkpoint verifies");
1697    }
1698
1699    /// The checkpoint digest covers the header too: moving `through_step_seq` without moving the
1700    /// digest is corruption, not a different-but-valid checkpoint.
1701    #[test]
1702    fn the_checkpoint_digest_covers_the_header_and_the_bounded_tail() {
1703        let with_tail = KernelCheckpoint::assemble(draft(3, 4, vec![tail_entry(4)])).unwrap();
1704        let without_tail = checkpoint();
1705        assert_eq!(
1706            with_tail.state_digest(),
1707            without_tail.state_digest(),
1708            "the same logical state digests the same either way"
1709        );
1710        assert_ne!(
1711            with_tail.checkpoint_digest(),
1712            without_tail.checkpoint_digest(),
1713            "but the checkpoint digest moves with the tail and the header"
1714        );
1715
1716        let error = tampered(|document| {
1717            document.insert("through_step_seq".to_string(), Value::String("9".into()));
1718        });
1719        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1720    }
1721
1722    #[test]
1723    fn a_checkpoint_round_trips_through_its_bytes() {
1724        let original = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(4)]))
1725            .expect("a bounded-tail checkpoint assembles");
1726        let decoded =
1727            KernelCheckpoint::from_checkpoint_bytes(original.checkpoint_bytes().as_slice())
1728                .expect("its own bytes decode");
1729        assert_eq!(decoded, original);
1730        assert_eq!(decoded.tail_inputs().len(), 2);
1731    }
1732
1733    #[test]
1734    fn structured_message_body_rejects_removed_body_forms() {
1735        assert!(
1736            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1737                "content_json": "{\\\"Text\\\":\\\"hello\\\"}"
1738            }))
1739            .is_err()
1740        );
1741        assert!(
1742            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1743                "schema_version": 1,
1744                "durable_content": {"blocks": []}
1745            }))
1746            .is_err()
1747        );
1748    }
1749
1750    #[test]
1751    fn structured_message_body_rejects_unknown_fields() {
1752        assert!(
1753            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1754                "durable_content": {"blocks": []},
1755                "unknown": true,
1756            }))
1757            .is_err()
1758        );
1759    }
1760
1761    #[test]
1762    fn removed_durable_content_schema_field_is_not_readable() {
1763        assert!(
1764            serde_json::from_value::<StructuredMessageBody>(serde_json::json!({
1765                "durable_content": {"schema_version": 1, "blocks": []}
1766            }))
1767            .is_err()
1768        );
1769    }
1770
1771    #[test]
1772    fn checkpoint_rejects_durable_tool_result_with_a_second_body_form() {
1773        let mut draft = draft(3, 3, Vec::new());
1774        draft
1775            .logical_state
1776            .context_vm
1777            .messages
1778            .push(StoredMessageState {
1779                partition: MessagePartition::History,
1780                role: "tool".into(),
1781                body: StoredMessageBody::Structured(StructuredMessageBody {
1782                    durable_content: Some(crate::types::durable_content::DurableContent::text(
1783                        "wrong",
1784                    )),
1785                    durable_tool_results: vec![
1786                        crate::types::durable_content::DurableToolResult::text(
1787                            "call-1",
1788                            "also wrong",
1789                            false,
1790                        ),
1791                    ],
1792                }),
1793                tool_calls: Vec::new(),
1794                tokens: 0,
1795            });
1796        assert!(matches!(
1797            KernelCheckpoint::assemble(draft),
1798            Err(CheckpointError::Incompatible(_))
1799        ));
1800    }
1801
1802    // -----------------------------------------------------------------------------------------
1803    // corruption and incompatibility
1804    // -----------------------------------------------------------------------------------------
1805
1806    #[test]
1807    fn a_digest_that_does_not_match_its_bytes_is_corruption() {
1808        for field in ["state_digest", "tail_digest", "checkpoint_digest"] {
1809            let error = tampered(|document| {
1810                document.insert(
1811                    field.to_string(),
1812                    Value::String(digest("bogus").to_string()),
1813                );
1814            });
1815            assert_eq!(
1816                error.code(),
1817                KernelFaultCode::CheckpointCorrupted,
1818                "{field} must fail closed"
1819            );
1820            assert!(
1821                error.to_string().contains(CHECKPOINT_ERROR_MARKER),
1822                "{field}: every rejection carries the classifier marker"
1823            );
1824        }
1825    }
1826
1827    #[test]
1828    fn a_logical_state_edited_after_the_fact_is_corruption() {
1829        let error = tampered(|document| {
1830            document["logical_state"]["transition"]["lifecycle"] = Value::String("failed".into());
1831        });
1832        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1833        assert!(error.message().contains("logical state hashes to"));
1834    }
1835
1836    #[test]
1837    fn removed_version_fields_are_malformed() {
1838        for field in ["checkpoint_version", "abi_version"] {
1839            let error = tampered(|document| {
1840                document.insert(field.to_string(), Value::from(1));
1841            });
1842            assert_eq!(error.code(), KernelFaultCode::MalformedEnvelope);
1843        }
1844    }
1845
1846    #[test]
1847    fn an_unknown_field_is_refused_rather_than_ignored() {
1848        let error = tampered(|document| {
1849            // §12.4 deleted `last_step`; retired snapshots must fail closed.
1850            document.insert("last_step".to_string(), Value::Null);
1851        });
1852        assert_eq!(error.code(), KernelFaultCode::MalformedEnvelope);
1853    }
1854
1855    #[test]
1856    fn a_checkpoint_from_another_operation_or_genesis_is_incompatible() {
1857        let checkpoint = checkpoint();
1858        let other = OperationId::new("op-checkpoint-2").unwrap();
1859
1860        let error = checkpoint
1861            .verify_belongs_to(&other, &digest("genesis"))
1862            .expect_err("another operation's checkpoint is not installable");
1863        assert_eq!(error.code(), KernelFaultCode::CheckpointIncompatible);
1864        assert!(error.message().contains("belongs to operation"));
1865
1866        let error = checkpoint
1867            .verify_belongs_to(&operation(), &digest("another-genesis"))
1868            .expect_err("a different genesis is a different operation");
1869        assert_eq!(error.code(), KernelFaultCode::CheckpointIncompatible);
1870        assert!(error.message().contains("binds genesis"));
1871
1872        checkpoint
1873            .verify_belongs_to(&operation(), &digest("genesis"))
1874            .expect("its own operation and genesis are accepted");
1875    }
1876
1877    // -----------------------------------------------------------------------------------------
1878    // §12.1 · the bounded tail covers (base, through] exactly
1879    // -----------------------------------------------------------------------------------------
1880
1881    #[test]
1882    fn a_tail_that_covers_the_range_exactly_is_accepted() {
1883        KernelCheckpoint::assemble(draft(0, 0, Vec::new())).expect("an empty range needs no tail");
1884        KernelCheckpoint::assemble(draft(
1885            2,
1886            5,
1887            vec![tail_entry(3), tail_entry(4), tail_entry(5)],
1888        ))
1889        .expect("(2, 5] is three contiguous inputs");
1890    }
1891
1892    #[test]
1893    fn a_tail_with_a_hole_is_refused() {
1894        let error = KernelCheckpoint::assemble(draft(
1895            2,
1896            5,
1897            vec![tail_entry(3), tail_entry(5), tail_entry(6)],
1898        ))
1899        .expect_err("step 4 is missing");
1900        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1901        assert!(error.message().contains("step 4 was due"), "{error}");
1902    }
1903
1904    #[test]
1905    fn a_tail_with_a_duplicate_is_refused() {
1906        let error = KernelCheckpoint::assemble(draft(
1907            2,
1908            5,
1909            vec![tail_entry(3), tail_entry(3), tail_entry(4)],
1910        ))
1911        .expect_err("step 3 appears twice");
1912        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1913        assert!(error.message().contains("contiguous range"), "{error}");
1914    }
1915
1916    #[test]
1917    fn a_tail_entry_outside_the_range_is_refused() {
1918        // before the base
1919        let error = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(2), tail_entry(3)]))
1920            .expect_err("step 2 is the base, not part of (2, 4]");
1921        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1922
1923        // after the covered head
1924        let error = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(9)]))
1925            .expect_err("step 9 is past the covered head");
1926        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1927    }
1928
1929    #[test]
1930    fn a_tail_whose_length_disagrees_with_the_range_is_refused() {
1931        let error = KernelCheckpoint::assemble(draft(2, 5, vec![tail_entry(3)]))
1932            .expect_err("(2, 5] is three inputs, not one");
1933        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1934        assert!(error.message().contains("bounded tail holds 1"), "{error}");
1935
1936        let error = KernelCheckpoint::assemble(draft(4, 2, Vec::new()))
1937            .expect_err("a base past the covered head is not a range at all");
1938        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1939    }
1940
1941    #[test]
1942    fn a_tail_input_from_another_operation_is_incompatible() {
1943        let mut foreign = tail_entry(3);
1944        foreign.input.operation_id = OperationId::new("op-checkpoint-2").unwrap();
1945        let error = KernelCheckpoint::assemble(draft(2, 3, vec![foreign]))
1946            .expect_err("a tail assembled from two journals is not a checkpoint");
1947        assert_eq!(error.code(), KernelFaultCode::CheckpointIncompatible);
1948    }
1949
1950    /// Decoding re-runs the coverage check, so a hole punched into a stored blob is caught at the
1951    /// boundary rather than half-way through a replay.
1952    #[test]
1953    fn a_tail_edited_in_storage_is_refused_at_decode() {
1954        let original = KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(4)]))
1955            .expect("assembles");
1956        let mut document: serde_json::Map<String, Value> =
1957            serde_json::from_slice(original.checkpoint_bytes().as_slice()).unwrap();
1958        let tail = document["tail_inputs"].as_array_mut().unwrap();
1959        tail.remove(0);
1960        let bytes = serde_json::to_vec(&document).unwrap();
1961        let error = KernelCheckpoint::from_checkpoint_bytes(&bytes)
1962            .expect_err("a truncated tail no longer covers its range");
1963        assert_eq!(error.code(), KernelFaultCode::CheckpointCorrupted);
1964    }
1965
1966    // -----------------------------------------------------------------------------------------
1967    // §12.3 · the candidate handle
1968    // -----------------------------------------------------------------------------------------
1969
1970    #[test]
1971    fn a_candidate_carries_the_five_values_of_the_spec_arrow() {
1972        let checkpoint = KernelCheckpoint::assemble(draft(3, 4, vec![tail_entry(4)])).unwrap();
1973        let expected_digest = checkpoint.checkpoint_digest().clone();
1974        let candidate = checkpoint.into_candidate();
1975
1976        assert_eq!(candidate.through_step_seq, WireU64::new(4));
1977        assert_eq!(candidate.covered_head, digest("record-4"));
1978        assert!(
1979            candidate.ack_token.as_str().contains(OPERATION)
1980                && candidate
1981                    .ack_token
1982                    .as_str()
1983                    .contains(expected_digest.as_str()),
1984            "the ack token names the checkpoint it acknowledges: {}",
1985            candidate.ack_token
1986        );
1987
1988        let decoded = candidate.decode().expect("the blob decodes and verifies");
1989        assert_eq!(decoded.checkpoint_digest(), &expected_digest);
1990        assert_eq!(decoded.state_digest(), &candidate.state_digest);
1991        assert_eq!(
1992            candidate.boundary().through_step_seq,
1993            candidate.through_step_seq
1994        );
1995    }
1996
1997    // -----------------------------------------------------------------------------------------
1998    // rejection fixtures
1999    // -----------------------------------------------------------------------------------------
2000
2001    fn fixture_dir() -> PathBuf {
2002        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
2003    }
2004
2005    /// Regenerate every `reject_checkpoint_*` blob from this module's own constructors.
2006    ///
2007    /// The fixtures used to be hand-written, which made them a second, drifting copy of the
2008    /// checkpoint shape: adding one field to the DTO invalidated all eight, and each had to be
2009    /// edited by hand into a document that still failed for the *declared* reason rather than for
2010    /// "missing field". Deriving them means a shape change costs one `BLESS_KERNEL_RECORD_FIXTURES=1`
2011    /// run, and — more importantly — a fixture can never claim to test a corruption while actually
2012    /// testing a stale schema.
2013    #[test]
2014    fn bless_checkpoint_rejection_fixtures() {
2015        if std::env::var("BLESS_KERNEL_RECORD_FIXTURES").as_deref() != Ok("1") {
2016            return;
2017        }
2018        let dir = fixture_dir();
2019        for (name, expect, description, mutate) in rejection_cases() {
2020            let mut document: serde_json::Map<String, Value> =
2021                serde_json::from_slice(mutate.0.checkpoint_bytes().as_slice()).unwrap();
2022            (mutate.1)(&mut document);
2023            let fixture = serde_json::json!({
2024                "expect": expect,
2025                "description": description,
2026                "checkpoint": Value::Object(document),
2027            });
2028            let mut text = serde_json::to_string_pretty(&fixture).unwrap();
2029            text.push('\n');
2030            fs::write(dir.join(name), text).unwrap_or_else(|e| panic!("cannot bless {name}: {e}"));
2031        }
2032    }
2033
2034    #[allow(clippy::type_complexity)]
2035    fn rejection_cases() -> Vec<(
2036        &'static str,
2037        &'static str,
2038        &'static str,
2039        (
2040            KernelCheckpoint,
2041            Box<dyn Fn(&mut serde_json::Map<String, Value>)>,
2042        ),
2043    )> {
2044        let full = || checkpoint();
2045        let with_tail =
2046            || KernelCheckpoint::assemble(draft(2, 4, vec![tail_entry(3), tail_entry(4)])).unwrap();
2047        vec![
2048            (
2049                "reject_checkpoint_removed_checkpoint_version.json",
2050                "malformed_envelope",
2051                "A checkpoint carrying the removed checkpoint version field is refused at the \
2052                 strict decode boundary.",
2053                (
2054                    full(),
2055                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2056                        d.insert("checkpoint_version".into(), Value::from(99u64));
2057                    }) as Box<dyn Fn(&mut serde_json::Map<String, Value>)>,
2058                ),
2059            ),
2060            (
2061                "reject_checkpoint_removed_abi_version.json",
2062                "malformed_envelope",
2063                "A checkpoint carrying the removed ABI version field is refused at the strict \
2064                 decode boundary.",
2065                (
2066                    full(),
2067                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2068                        d.insert("abi_version".into(), Value::from(1));
2069                    }),
2070                ),
2071            ),
2072            (
2073                "reject_checkpoint_state_digest_mismatch.json",
2074                "checkpoint_corrupted",
2075                "The logical state does not hash to the digest the checkpoint claims (spec 12.1).",
2076                (
2077                    full(),
2078                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2079                        d.insert(
2080                            "state_digest".into(),
2081                            Value::String(digest("bogus").to_string()),
2082                        );
2083                    }),
2084                ),
2085            ),
2086            (
2087                "reject_checkpoint_missing_field_checkpoint_digest.json",
2088                "malformed_envelope",
2089                "A structural refusal that names the field: a checkpoint without its own digest is \
2090                 not a checkpoint with an unverified digest.",
2091                (
2092                    full(),
2093                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2094                        d.remove("checkpoint_digest");
2095                    }),
2096                ),
2097            ),
2098            (
2099                "reject_checkpoint_unknown_field_last_step.json",
2100                "malformed_envelope",
2101                "Spec 12.4 deleted `last_step`; a blob that still carries one is refused \
2102                 rather than partially read.",
2103                (
2104                    full(),
2105                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2106                        d.insert("last_step".into(), Value::Null);
2107                    }),
2108                ),
2109            ),
2110            (
2111                "reject_checkpoint_base_disagrees_with_covered_head.json",
2112                "checkpoint_corrupted",
2113                "A full-state checkpoint covers no tail, so its base and its covered head name the \
2114                 same record; a header that disagrees with itself would hand a restore two \
2115                 different chain anchors (spec 12.1, Task 16).",
2116                (
2117                    full(),
2118                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2119                        d.insert(
2120                            "base_record_digest".into(),
2121                            Value::String(digest("another-record").to_string()),
2122                        );
2123                    }),
2124                ),
2125            ),
2126            (
2127                "reject_checkpoint_tail_hole.json",
2128                "checkpoint_corrupted",
2129                "The bounded tail must cover (base, through] with no hole (spec 12.1).",
2130                (
2131                    with_tail(),
2132                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2133                        d["tail_inputs"].as_array_mut().unwrap().remove(0);
2134                    }),
2135                ),
2136            ),
2137            (
2138                "reject_checkpoint_tail_duplicate.json",
2139                "checkpoint_corrupted",
2140                "The bounded tail must cover (base, through] with no duplicate (spec 12.1).",
2141                (
2142                    with_tail(),
2143                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2144                        let tail = d["tail_inputs"].as_array_mut().unwrap();
2145                        tail[1] = tail[0].clone();
2146                    }),
2147                ),
2148            ),
2149            (
2150                "reject_checkpoint_tail_foreign_operation.json",
2151                "checkpoint_incompatible",
2152                "A bounded tail assembled from two journals is not a checkpoint (spec 12.1).",
2153                (
2154                    with_tail(),
2155                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2156                        d["tail_inputs"][0]["input"]["operation_id"] =
2157                            Value::String("op-checkpoint-2".into());
2158                    }),
2159                ),
2160            ),
2161            (
2162                "reject_checkpoint_tail_ends_off_the_covered_head.json",
2163                "checkpoint_corrupted",
2164                "The last bounded-tail entry *is* the covered head; a tail that ends somewhere \
2165                 else covers a different prefix than the header claims (spec 12.1, Task 16).",
2166                (
2167                    with_tail(),
2168                    Box::new(|d: &mut serde_json::Map<String, Value>| {
2169                        d["tail_inputs"][1]["record_digest"] =
2170                            Value::String(digest("some-other-record").to_string());
2171                    }),
2172                ),
2173            ),
2174        ]
2175    }
2176
2177    #[test]
2178    fn checkpoint_rejection_fixtures_fail_closed_with_the_declared_kind() {
2179        let dir = fixture_dir();
2180        let mut names: Vec<String> = fs::read_dir(&dir)
2181            .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()))
2182            .map(|entry| {
2183                entry
2184                    .expect("dir entry")
2185                    .file_name()
2186                    .to_string_lossy()
2187                    .to_string()
2188            })
2189            .filter(|name| name.starts_with("reject_checkpoint_") && name.ends_with(".json"))
2190            .collect();
2191        names.sort();
2192        assert!(
2193            names.len() >= 5,
2194            "too few checkpoint rejection fixtures: {names:?}"
2195        );
2196
2197        for name in names {
2198            let raw = fs::read_to_string(dir.join(&name)).expect("fixture reads");
2199            let fixture: Value = serde_json::from_str(&raw).expect("fixture is JSON");
2200            let expected = fixture["expect"]
2201                .as_str()
2202                .expect("every fixture declares `expect`");
2203            let bytes = serde_json::to_vec(&fixture["checkpoint"]).unwrap();
2204            let error = KernelCheckpoint::from_checkpoint_bytes(&bytes)
2205                .expect_err(&format!("{name}: expected a rejection"));
2206            assert_eq!(
2207                error.code().as_str(),
2208                expected,
2209                "{name}: {} (message: {})",
2210                error.code().as_str(),
2211                error.message()
2212            );
2213            // The `missing_field` / `unknown_field` naming convention has to mean something: both
2214            // are structural refusals, and both must name the field so a host can act on them.
2215            for (marker, needle) in [
2216                ("_missing_field_", "missing field"),
2217                ("_unknown_field_", "unknown field"),
2218            ] {
2219                if name.contains(marker) {
2220                    assert_eq!(
2221                        error.code(),
2222                        KernelFaultCode::MalformedEnvelope,
2223                        "{name}: a structural refusal is malformed_envelope"
2224                    );
2225                    assert!(
2226                        error.message().contains(needle),
2227                        "{name}: the rejection must say which field ({})",
2228                        error.message()
2229                    );
2230                }
2231            }
2232        }
2233    }
2234}