Skip to main content

deepstrike_core/runtime/kernel/wire/
driver.rs

1//! The canonical operation driver — the plan function of [`KernelTransaction`] (spec §5.4, §6, §7.4,
2//! §10.1, §10.2).
3//!
4//! This is the layer the migration's semantic half turns on. Everything above it is contract
5//! (envelope → record → transaction); everything below it is the kernel's existing semantic
6//! machinery (the scheduler's [`LoopStateMachine`], the P1 syscall gate, the P2 task table, the P3
7//! context VM). The driver is the **only** place the two meet, and §5.4 fixes exactly how: every
8//! wire input reduces to a P1/P2/P3 primitive, and none of them is allowed to grow a parallel
9//! business state machine here.
10//!
11//! Three properties define it:
12//!
13//! 1. **No protocol adapter.** The driver reads [`NormalizedPayload`] directly and reduces it to
14//!    scheduler/context primitives such as `LoopStateMachine::start`, `load_workflow`,
15//!    `resolve_workflow_spawn`, and `feed`. The canonical envelope is the only wire contract.
16//! 2. **`RootKind` is immutable and `ExecutionFocus` moves only on a committed transition**
17//!    (§6.1.5/6.1.6, §7.4). Both live in [`CanonicalOperationDriver`], and `plan` never writes
18//!    them — it *stages* the next value, and [`CanonicalOperationDriver::note_committed`] is what
19//!    installs it. There is no input, host command or otherwise, that sets a focus directly.
20//! 3. **A root start is one atomic input.** `ConfigureOperation` builds the engine;
21//!    `StartOperation` seeds the initial context, enters the root, and publishes the first effect
22//!    — a provider call for an agent root, a task spawn for a workflow root — in the *same*
23//!    planned step. The historical 12+ separate accepted inputs before a first provider call
24//!    (§3.3 item 20) have no equivalent path here.
25//! 4. **One resolution entry, one decision per failure** (§7.9). Every pending effect — provider,
26//!    tools, approval, spawn, preempt, memory, page-out, milestone — is answered through
27//!    `ResolveEffect` and nothing else, and a `Failed` outcome buys exactly one policy decision:
28//!    abandon, switch recovery ladder, or commit a terminal. The kernel never re-emits the same
29//!    intent (DEC-5), so the historical unbounded `retry_approval` / `retry_workflow_spawn` /
30//!    `retry_preempt` round trips are not expressible on this path. `ContextOverflow` is not a
31//!    failure at all: it is the one *semantic* provider outcome, and it feeds the compaction ladder.
32//!
33//! ### What `plan` may mutate
34//!
35//! [`KernelTransaction::prepare`] guarantees that a non-`Prepared` outcome leaves the *transaction*
36//! byte-for-byte unchanged, and it can reject after the planner has already run (an unsupported
37//! effect kind, a duplicate effect identity, the tail hard limit). The driver answers that in two
38//! layers:
39//!
40//! * every refusal the driver itself owns — root authority, focus depth, an unreducible input — is
41//!   decided **before** the semantic engine is touched, so it is a genuine zero-mutation rejection;
42//! * the engine advance that a successful plan performs is guarded by a staging slot. A second
43//!   `plan` without an intervening `note_committed` means the previous plan was discarded while the
44//!   engine had already moved, and the driver fails closed with a poison fault that names the only
45//!   legal recovery — rebuild from the journal (§8.3).
46use std::collections::{BTreeMap, BTreeSet};
47
48use serde::{Deserialize, Serialize};
49
50use super::checkpoint::{
51    AuthoredMemoryQueryState, AuthoredMemoryWriteState, ChildProcessState, ContextVmState,
52    EntropyState, EntropyTurnState, HandleState, InlineMessageBody, KnowledgeSlotState,
53    LocalChannelState, LogicalCompressionEntry, LogicalKernelState, LogicalPlanStep,
54    LogicalStateProjection, LogicalTaskState, LogicalToolCall, MessagePartition, MilestoneState,
55    PartitionTokenState, PendingPayloadLoadState, PendingProviderCallState, QueuedSignalState,
56    ReferencedMessageBody, SchedulerState, SkillLeaseState, StoredMessageBody, StoredMessageState,
57    StructuredMessageBody, SyscallState, TaskAttemptState, TaskControlState,
58    TaskWaitConditionState, TaskWaitSetState, WorkflowGraphState, WorkflowNodeState,
59};
60use super::command::{
61    ApplyCapabilityPatchCommand, ApplyKnowledgeMutationCommand, ApplyPolicyPatchCommand,
62    ApplySkillActivationCommand, CancelCommand, CancellationReason, HostCommand, LivePolicyState,
63    SeedKnowledgeCommand, TaskUpdate as WireTaskUpdate, UpdateDeadlineCommand, UpdateTaskCommand,
64};
65use super::config::ResolvedOperationConfig;
66use super::effect::{
67    ApprovalRequest as WireApprovalRequest, ArchivePageOutEffect, CallProviderEffect,
68    CanonicalMemoryQuery, CanonicalMemoryWrite, EffectKind, EffectKindTag, EffectOutcome,
69    EffectSuccess, EvaluateMilestoneEffect, ExecuteToolsEffect, HostEffectFailure, KernelEffect,
70    LaunchToken, LoadPayloadEffect, PageOutPayload, PayloadRef, PersistMemoryEffect,
71    PreemptTasksEffect, ProviderCompleted, ProviderMessage, ProviderOutcome, QueryMemoryEffect,
72    RenderedContext as WireRenderedContext, RequestApprovalEffect, SpawnTasksEffect,
73    TaskAttemptRef, TaskLaunch, ToolCall as WireToolCall, ToolResultDisposition,
74    ToolResultPayload as WireToolResultPayload, ToolSchema as WireToolSchema,
75    WorkflowBudget as WireWorkflowBudget,
76};
77use super::envelope::{OperationLifecycle, ResolveEffect};
78use super::event::{
79    ChildCompleted, ChildStatus, DeliverSignal, ExternalEvent, LogicalSignal, SignalSourceKind,
80    SignalTarget, SignalUrgency,
81};
82use super::fault::{KernelFault, KernelFaultCode};
83use super::record::NormalizedPayload;
84use super::root::{
85    AgentIsolation as WireIsolation, AgentRole as WireRole, ExecutionFocus, InitialContext,
86    LogicalAgentSpec, LogicalContextInheritance as WireContextInheritance, LogicalTask,
87    MessageRole, RootEntry, RootKind, WorkflowNode as WireNode, WorkflowSpec as WireSpec,
88};
89use super::scalar::{
90    AttemptId, EffectId, MemoryBindingId, NodeId, OperationId, TaskId, WireU64, WorkflowId,
91};
92use super::syscall::{
93    ChildAttemptCausation, MemoryKind as WireMemoryKind, ProviderToolCausation, SyscallCausation,
94    SyscallRequest,
95};
96use super::terminal::{
97    AgentTerminal, CancelledTerminal, EffectsDisposition, FailedTerminal, KernelFailure,
98    KernelFailureCode, KernelTerminal, LoopResult as WireLoopResult, StepDisposition,
99    TerminalDisposition, TerminationReason as WireTermination, UsageReport, WorkflowOutcome,
100    WorkflowStatus, WorkflowTerminal,
101};
102use super::transaction::{PlanContext, TransitionStep};
103
104use crate::context::manager::READ_RESULT_TOOL_NAME;
105use crate::context::task_state::{CompressionEntry, PlanStep, TaskState};
106use crate::mm::handle::{Handle, HandleKind, Residency};
107use crate::orchestration::task_graph::TaskStatus;
108use crate::orchestration::workflow::run::{WorkflowNodeStatus, WorkflowRuntimeNodeState};
109use crate::orchestration::workflow::{
110    WorkflowNode as CoreWorkflowNode, WorkflowSpec as CoreWorkflowSpec,
111};
112use crate::runtime::kernel::{KernelObservation, WorkflowSpawnFailure};
113use crate::scheduler::policy::SchedulerBudget;
114use crate::scheduler::state_machine::{
115    AdjudicatedTurn, AnsweredCall, IdleContinuation, LoopAction, LoopEvent, LoopStateMachine,
116};
117use crate::scheduler::tcb::{
118    ApprovalId, BudgetLedger, ChannelId, DurableWaitSet, LogicalDeadline, ProcInfo, ResourceKey,
119    SignalFilter, SubscriptionId, TaskLifecycle, Tcb, WaitCondition, WaitMode,
120};
121use crate::scheduler::wait_index::WaitKey;
122use crate::signals::queue::QueuedSignalRuntimeState;
123use crate::signals::router::SignalRouterRuntimeState;
124use crate::syscall::{Disposition, Syscall as CoreSyscall};
125use crate::types::agent::{
126    AgentCapabilityFilter, AgentIdentity, AgentIsolation, AgentRole, AgentRunSpec,
127    ContextInheritance, LoopRoundSpec,
128};
129use crate::types::durable_content::{
130    DurableContent, DurableContentBlock, DurableSource, DurableToolResult,
131};
132use crate::types::message::{Content, ContentPart, CoreMessage, Role, ToolErrorKind, ToolResult};
133use crate::types::result::{
134    LoopResult, PaceAction as CorePaceAction, SubAgentResult, TerminationReason,
135};
136use crate::types::signal::{RuntimeSignal, SignalSource, SignalType, Urgency};
137use crate::types::task::{RuntimeTask, TaskLane};
138
139// ---------------------------------------------------------------------------------------------
140// the planned step
141// ---------------------------------------------------------------------------------------------
142
143/// One planned transition, as the canonical driver produces it.
144///
145/// The record freezes only this value's **digest** (§22.12), so its shape is what a rebuild has to
146/// reproduce bit-for-bit. Three fields, each load-bearing:
147///
148/// * `root_kind` — the operation's immutable root class *after* this step. Present from the root
149///   start onward and never different from the value the start committed;
150/// * `focus` — the execution focus after this step. Because it is inside the digest, a focus that
151///   moved differently on a replay is a `RecordCorrupted` rebuild failure rather than a silent
152///   divergence;
153/// * `observations` — facts produced by this exact transition, published only after commit;
154/// * `disposition` — effects **or** a terminal, never both (§7.12).
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct PlannedStep {
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub root_kind: Option<RootKind>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub focus: Option<ExecutionFocus>,
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub observations: Vec<KernelObservation>,
163    pub disposition: StepDisposition,
164}
165
166impl PartialEq for PlannedStep {
167    fn eq(&self, other: &Self) -> bool {
168        self.root_kind == other.root_kind
169            && self.focus == other.focus
170            && self.disposition == other.disposition
171            && serde_json::to_vec(&self.observations).ok()
172                == serde_json::to_vec(&other.observations).ok()
173    }
174}
175
176impl PlannedStep {
177    fn quiet(root_kind: Option<RootKind>, focus: Option<ExecutionFocus>) -> Self {
178        Self {
179            root_kind,
180            focus,
181            observations: Vec::new(),
182            disposition: StepDisposition::Effects(EffectsDisposition::default()),
183        }
184    }
185}
186
187impl TransitionStep for PlannedStep {
188    fn disposition(&self) -> &StepDisposition {
189        &self.disposition
190    }
191}
192
193// ---------------------------------------------------------------------------------------------
194// the driver
195// ---------------------------------------------------------------------------------------------
196
197/// The internal id of the root agent task. The kernel's task table has used it since M1d; the
198/// canonical `TaskId` is the same string so an `AgentTurn` focus names a row that exists.
199pub const ROOT_TASK_ID: &str = "root";
200
201/// The empty session slot used when projecting the logical kernel spec into `AgentRunSpec`.
202/// Host session identity is not a kernel fact and the canonical wire has no field for one.
203const NO_HOST_SESSION: &str = "";
204
205#[derive(Debug, Clone, PartialEq)]
206struct StagedFocus {
207    step_seq: WireU64,
208    root_kind: Option<RootKind>,
209    focus: Option<ExecutionFocus>,
210}
211
212/// What the kernel published on one provider call, kept so a `ProviderTool` causation can be
213/// *derived* rather than believed (§7.6).
214///
215/// Two facts, both kernel-owned:
216///
217/// * `task_id` — the task whose turn issued the call. This is the caller a syscall inherits; no
218///   host names it;
219/// * `exposed_tools` — exactly the surface that turn advertised. A tool call naming anything else
220///   has no causation to derive from, so it is refused rather than adjudicated.
221#[derive(Debug, Clone, PartialEq)]
222struct PendingProviderCall {
223    task_id: TaskId,
224    exposed_tools: BTreeSet<String>,
225}
226
227/// A syscall the driver refused *after* its caller was established — a malformed request, a gate
228/// denial, a quarantined caller, a memory binding the operation does not hold.
229///
230/// Deliberately not a [`KernelFault`]. §7.7's GAP-4 and the §7.6 fixture both require that a
231/// refused request leaves an **audit fact** and no derived action, while the transition it arrived
232/// on still commits: a child's execution is not undone because the parent denied one of the
233/// requests it attached, and a model's bad argument is not a host protocol violation.
234#[derive(Debug, Clone, PartialEq)]
235struct SyscallRejection {
236    operation: &'static str,
237    /// The derived caller. Present on every rejection raised after causation succeeded, which is
238    /// all of them — an audit fact that cannot name who asked is not an audit fact.
239    subject: Option<String>,
240    reason: String,
241}
242
243impl SyscallRejection {
244    fn new(operation: &'static str, reason: impl Into<String>) -> Self {
245        Self {
246            operation,
247            subject: None,
248            reason: reason.into(),
249        }
250    }
251
252    fn by(mut self, caller: &TaskId) -> Self {
253        self.subject = Some(caller.as_str().to_string());
254        self
255    }
256}
257
258/// The two ways a syscall can fail, kept apart on purpose (§7.13 vs §7.7 GAP-4).
259#[derive(Debug, Clone)]
260enum SyscallRefusal {
261    /// **Who** could not be established, or the transition itself is inadmissible. Zero mutation,
262    /// and the whole input is refused.
263    Fault(KernelFault),
264    /// **What** was asked is refused. The caller was established, so the answer is an audit fact
265    /// the model reads on its next turn; the transition still commits.
266    Rejected(SyscallRejection),
267}
268
269/// Stable wire label of a handle kind, for the §12.1 context-VM projection.
270///
271/// Written out rather than derived from the internal enum's serde rename: the checkpoint's
272/// vocabulary is a contract, and a rename inside `mm::handle` must break this match arm instead of
273/// silently changing what a stored checkpoint means.
274fn handle_kind_label(kind: &HandleKind) -> &'static str {
275    match kind {
276        HandleKind::ToolResult => "tool_result",
277        HandleKind::MemoryPage => "memory_page",
278        HandleKind::KnowledgeEntry => "knowledge_entry",
279        HandleKind::SubAgentJoin => "sub_agent_join",
280    }
281}
282
283fn role_label(role: Role) -> &'static str {
284    match role {
285        Role::System => "system",
286        Role::User => "user",
287        Role::Assistant => "assistant",
288        Role::Tool => "tool",
289    }
290}
291
292fn role_from_label(label: &str) -> Option<Role> {
293    match label {
294        "system" => Some(Role::System),
295        "user" => Some(Role::User),
296        "assistant" => Some(Role::Assistant),
297        "tool" => Some(Role::Tool),
298        _ => None,
299    }
300}
301
302/// Reduce a stored message to `(text, tool_call_id, is_error)`, or report that it does not reduce.
303///
304/// `None` means "this body is multimodal": it carries an image or audio part, and flattening it to
305/// the text parts beside it would drop content a restore could never get back. Those travel as
306/// [`StoredMessageBody::Structured`] instead.
307#[allow(clippy::type_complexity)]
308fn message_body_parts(message: &CoreMessage) -> Option<(String, Option<String>, bool)> {
309    match &message.content {
310        Content::Text(text) => Some((text.clone(), None, false)),
311        Content::Parts(parts) => {
312            let mut text = String::new();
313            let mut tool_call_id = None;
314            let mut is_error = false;
315            for part in parts {
316                match part {
317                    ContentPart::Text { text: chunk } => text.push_str(chunk),
318                    ContentPart::ToolResult {
319                        call_id,
320                        output,
321                        is_error: failed,
322                        durable_content,
323                    } => {
324                        if tool_call_id.is_some() {
325                            // Two results in one message have two call ids; the pair projection
326                            // holds one. Carry the whole content instead of picking a winner.
327                            return None;
328                        }
329                        tool_call_id = Some(call_id.to_string());
330                        if durable_content.is_some() {
331                            // The correlated durable envelope must survive intact, never be
332                            // reduced to its text projection.
333                            return None;
334                        }
335                        text.push_str(output);
336                        is_error = *failed;
337                    }
338                    ContentPart::Image { .. } | ContentPart::Audio { .. } => return None,
339                }
340            }
341            Some((text, tool_call_id, is_error))
342        }
343    }
344}
345
346/// Rebuild the stored content a [`StoredMessageBody`] describes.
347///
348/// The exact inverse of [`message_body_parts`]: a body with a `tool_call_id` was a single-part tool
349/// result and goes back as one, everything else was flat text.
350fn message_content(text: String, tool_call_id: Option<&str>, is_error: bool) -> Content {
351    match tool_call_id {
352        Some(call_id) => Content::Parts(vec![ContentPart::ToolResult {
353            call_id: call_id.into(),
354            output: text,
355            is_error,
356            durable_content: None,
357        }]),
358        None => Content::Text(text),
359    }
360}
361
362fn content_to_durable(content: &Content) -> Result<DurableContent, String> {
363    let blocks = match content {
364        Content::Text(text) => vec![DurableContentBlock::Text { text: text.clone() }],
365        Content::Parts(parts) => parts
366            .iter()
367            .map(content_part_to_durable)
368            .collect::<Result<Vec<_>, _>>()?,
369    };
370    let content = DurableContent { blocks };
371    content.validate().map_err(|error| error.to_string())?;
372    Ok(content)
373}
374
375fn content_part_to_durable(part: &ContentPart) -> Result<DurableContentBlock, String> {
376    match part {
377        ContentPart::Text { text } => Ok(DurableContentBlock::Text { text: text.clone() }),
378        ContentPart::ToolResult { .. } => Err(
379            "a structured message cannot embed a tool result; durable tool results use their separate envelope".into(),
380        ),
381        ContentPart::Image { source, media_type, detail } => {
382            let provider_options = detail
383                .as_ref()
384                .map(|detail| serde_json::json!({ "detail": detail }));
385            Ok(DurableContentBlock::Image {
386                source: source.clone(),
387                media_type: media_type.clone(),
388                provider_options,
389            })
390        }
391        ContentPart::Audio { source, media_type } => Ok(DurableContentBlock::Audio {
392            source: source.clone(),
393            media_type: Some(media_type.clone()),
394            provider_options: None,
395        }),
396    }
397}
398
399fn durable_tool_result_from_content(content: &Content) -> Option<DurableToolResult> {
400    let Content::Parts(parts) = content else {
401        return None;
402    };
403    let [
404        ContentPart::ToolResult {
405            call_id,
406            is_error,
407            output,
408            durable_content,
409        },
410    ] = parts.as_slice()
411    else {
412        return None;
413    };
414    Some(durable_tool_result_from_part(
415        call_id,
416        output,
417        *is_error,
418        durable_content.as_ref(),
419    ))
420}
421
422fn durable_tool_results_from_content(content: &Content) -> Option<Vec<DurableToolResult>> {
423    let Content::Parts(parts) = content else {
424        return None;
425    };
426    if parts.len() < 2 {
427        return None;
428    }
429    let results = parts
430        .iter()
431        .map(|part| match part {
432            ContentPart::ToolResult {
433                call_id,
434                output,
435                is_error,
436                durable_content,
437            } => Some(durable_tool_result_from_part(
438                call_id,
439                output,
440                *is_error,
441                durable_content.as_ref(),
442            )),
443            _ => None,
444        })
445        .collect::<Option<Vec<_>>>()?;
446    Some(results)
447}
448
449fn durable_tool_result_from_part(
450    call_id: &str,
451    output: &str,
452    is_error: bool,
453    durable_content: Option<&DurableContent>,
454) -> DurableToolResult {
455    match durable_content {
456        Some(content) => DurableToolResult {
457            call_id: call_id.to_owned(),
458            is_error,
459            blocks: content.blocks.clone(),
460        },
461        None => DurableToolResult::text(call_id.to_owned(), output.to_owned(), is_error),
462    }
463}
464
465fn content_from_durable_tool_result(result: &DurableToolResult) -> Result<Content, String> {
466    result.validate().map_err(|error| error.to_string())?;
467    let output = result
468        .blocks
469        .iter()
470        .filter_map(|block| match block {
471            DurableContentBlock::Text { text } => Some(text.as_str()),
472            _ => None,
473        })
474        .collect::<String>();
475    Ok(Content::Parts(vec![ContentPart::ToolResult {
476        call_id: result.call_id.clone().into(),
477        output,
478        is_error: result.is_error,
479        durable_content: Some(DurableContent {
480            blocks: result.blocks.clone(),
481        }),
482    }]))
483}
484
485fn content_from_durable_tool_results(results: &[DurableToolResult]) -> Result<Content, String> {
486    let mut parts = Vec::with_capacity(results.len());
487    for result in results {
488        let Content::Parts(mut result_parts) = content_from_durable_tool_result(result)? else {
489            return Err("durable tool result did not restore to tool content".into());
490        };
491        parts.append(&mut result_parts);
492    }
493    Ok(Content::Parts(parts))
494}
495
496fn content_from_durable(content: &DurableContent) -> Result<Content, String> {
497    let parts = content
498        .blocks
499        .iter()
500        .map(durable_block_to_content_part)
501        .collect::<Result<Vec<_>, _>>()?;
502    if parts.len() == 1 {
503        if let ContentPart::Text { text } = &parts[0] {
504            return Ok(Content::Text(text.clone()));
505        }
506    }
507    Ok(Content::Parts(parts))
508}
509
510fn durable_block_to_content_part(block: &DurableContentBlock) -> Result<ContentPart, String> {
511    match block {
512        DurableContentBlock::Text { text } => Ok(ContentPart::Text { text: text.clone() }),
513        DurableContentBlock::Image {
514            source,
515            media_type,
516            provider_options,
517        } => match source {
518            DurableSource::Url { url } => Ok(ContentPart::Image {
519                source: DurableSource::Url { url: url.clone() },
520                media_type: media_type.clone(),
521                detail: provider_options
522                    .as_ref()
523                    .and_then(|value| value.get("detail"))
524                    .and_then(serde_json::Value::as_str)
525                    .map(str::to_string),
526            }),
527            DurableSource::Base64 { data } => Ok(ContentPart::Image {
528                source: DurableSource::Base64 { data: data.clone() },
529                media_type: media_type.clone(),
530                detail: provider_options
531                    .as_ref()
532                    .and_then(|value| value.get("detail"))
533                    .and_then(serde_json::Value::as_str)
534                    .map(str::to_string),
535            }),
536            _ => Err("this kernel only restores image url/base64 sources".into()),
537        },
538        DurableContentBlock::Audio {
539            source: DurableSource::Base64 { data },
540            media_type,
541            ..
542        } => Ok(ContentPart::Audio {
543            source: DurableSource::Base64 { data: data.clone() },
544            media_type: media_type
545                .clone()
546                .ok_or_else(|| "audio durable block requires media_type".to_string())?,
547        }),
548        DurableContentBlock::Audio { .. }
549        | DurableContentBlock::File { .. }
550        | DurableContentBlock::Video { .. } => {
551            Err("this kernel content vocabulary cannot restore the durable media source".into())
552        }
553    }
554}
555
556fn workflow_kind_label(state: &WorkflowRuntimeNodeState) -> &'static str {
557    match state.node.kind {
558        crate::orchestration::workflow::NodeKind::Spawn => "spawn",
559        crate::orchestration::workflow::NodeKind::Loop { .. } => "loop",
560        crate::orchestration::workflow::NodeKind::Classify { .. } => "classify",
561        crate::orchestration::workflow::NodeKind::Tournament { .. } => "tournament",
562        crate::orchestration::workflow::NodeKind::Reduce { .. } => "reduce",
563    }
564}
565
566fn workflow_status_label(status: TaskStatus) -> &'static str {
567    match status {
568        TaskStatus::Pending => "pending",
569        TaskStatus::Ready => "ready",
570        TaskStatus::Running => "running",
571        TaskStatus::Completed => "completed",
572        TaskStatus::CompletedPartial => "completed_partial",
573        TaskStatus::Failed => "failed",
574        TaskStatus::SkippedUpstreamFailed => "skipped_upstream_failed",
575    }
576}
577
578fn restore_workflow_status(label: &str) -> Result<TaskStatus, KernelFault> {
579    match label {
580        "pending" => Ok(TaskStatus::Pending),
581        "ready" => Ok(TaskStatus::Ready),
582        "running" => Ok(TaskStatus::Running),
583        "completed" => Ok(TaskStatus::Completed),
584        "completed_partial" => Ok(TaskStatus::CompletedPartial),
585        "failed" => Ok(TaskStatus::Failed),
586        "skipped_upstream_failed" => Ok(TaskStatus::SkippedUpstreamFailed),
587        other => Err(KernelFault::new(
588            KernelFaultCode::CheckpointIncompatible,
589            format!("workflow checkpoint carries unknown node status {other:?}"),
590        )),
591    }
592}
593
594fn agent_role_label(role: AgentRole) -> &'static str {
595    match role {
596        AgentRole::Explore => "explore",
597        AgentRole::Plan => "plan",
598        AgentRole::Implement => "implement",
599        AgentRole::Verify => "verify",
600        AgentRole::Custom => "custom",
601    }
602}
603
604fn restore_agent_role(label: &str) -> Result<AgentRole, KernelFault> {
605    match label {
606        "explore" => Ok(AgentRole::Explore),
607        "plan" => Ok(AgentRole::Plan),
608        "implement" => Ok(AgentRole::Implement),
609        "verify" => Ok(AgentRole::Verify),
610        "custom" => Ok(AgentRole::Custom),
611        other => Err(KernelFault::new(
612            KernelFaultCode::CheckpointIncompatible,
613            format!("child process carries unknown role {other:?}"),
614        )),
615    }
616}
617
618fn agent_isolation_label(isolation: AgentIsolation) -> &'static str {
619    match isolation {
620        AgentIsolation::Shared => "shared",
621        AgentIsolation::ReadOnly => "read_only",
622        AgentIsolation::Worktree => "worktree",
623        AgentIsolation::Remote => "remote",
624    }
625}
626
627fn restore_agent_isolation(label: &str) -> Result<AgentIsolation, KernelFault> {
628    match label {
629        "shared" => Ok(AgentIsolation::Shared),
630        "read_only" => Ok(AgentIsolation::ReadOnly),
631        "worktree" => Ok(AgentIsolation::Worktree),
632        "remote" => Ok(AgentIsolation::Remote),
633        other => Err(KernelFault::new(
634            KernelFaultCode::CheckpointIncompatible,
635            format!("child process carries unknown isolation {other:?}"),
636        )),
637    }
638}
639
640fn context_inheritance_label(inheritance: ContextInheritance) -> &'static str {
641    match inheritance {
642        ContextInheritance::None => "none",
643        ContextInheritance::SystemOnly => "system_only",
644        ContextInheritance::Full => "full",
645    }
646}
647
648fn restore_context_inheritance(label: &str) -> Result<ContextInheritance, KernelFault> {
649    match label {
650        "none" => Ok(ContextInheritance::None),
651        "system_only" => Ok(ContextInheritance::SystemOnly),
652        "full" => Ok(ContextInheritance::Full),
653        other => Err(KernelFault::new(
654            KernelFaultCode::CheckpointIncompatible,
655            format!("child process carries unknown context inheritance {other:?}"),
656        )),
657    }
658}
659
660fn queued_signal_state(queued: &QueuedSignalRuntimeState) -> QueuedSignalState {
661    let signal = &queued.signal;
662    QueuedSignalState {
663        signal_id: super::scalar::SignalId::new(signal.id.as_str())
664            .expect("a canonical runtime signal keeps its branded id"),
665        source: signal_source_label(signal.source).to_string(),
666        signal_type: signal_type_label(signal.signal_type).to_string(),
667        urgency: urgency_label(signal.urgency).to_string(),
668        summary: signal.summary.to_string(),
669        payload: super::scalar::BoundedJson::new(signal.payload.clone())
670            .expect("a canonical signal payload remains bounded"),
671        dedupe_key: signal.dedupe_key.as_ref().map(ToString::to_string),
672        deadline_ms: signal.deadline_ms.map(WireU64::new),
673        coalesce_key: signal.coalesce_key.as_ref().map(ToString::to_string),
674        coalesced_count: signal.coalesced_count,
675        recipient: signal.recipient.as_ref().map(ToString::to_string),
676        timestamp_ms: WireU64::new(signal.timestamp_ms),
677        deadline_escalated: queued.deadline_escalated,
678        dedupe_keys: queued.dedupe_keys.iter().map(ToString::to_string).collect(),
679    }
680}
681
682fn restore_queued_signal(
683    queued: &QueuedSignalState,
684) -> Result<QueuedSignalRuntimeState, KernelFault> {
685    if queued.coalesced_count == 0 {
686        return Err(KernelFault::new(
687            KernelFaultCode::CheckpointIncompatible,
688            format!(
689                "queued signal {} carries a zero coalesced count",
690                queued.signal_id
691            ),
692        ));
693    }
694    Ok(QueuedSignalRuntimeState {
695        signal: RuntimeSignal {
696            id: queued.signal_id.as_str().into(),
697            source: restore_signal_source(&queued.source)?,
698            signal_type: restore_signal_type(&queued.signal_type)?,
699            urgency: restore_urgency(&queued.urgency)?,
700            summary: queued.summary.as_str().into(),
701            payload: queued.payload.get().clone(),
702            dedupe_key: queued.dedupe_key.as_deref().map(Into::into),
703            deadline_ms: queued.deadline_ms.map(WireU64::get),
704            coalesce_key: queued.coalesce_key.as_deref().map(Into::into),
705            coalesced_count: queued.coalesced_count,
706            recipient: queued.recipient.as_deref().map(Into::into),
707            timestamp_ms: queued.timestamp_ms.get(),
708        },
709        deadline_escalated: queued.deadline_escalated,
710        dedupe_keys: queued
711            .dedupe_keys
712            .iter()
713            .map(|key| key.as_str().into())
714            .collect(),
715    })
716}
717
718fn signal_source_label(source: SignalSource) -> &'static str {
719    match source {
720        SignalSource::Cron => "cron",
721        SignalSource::Gateway => "gateway",
722        SignalSource::Heartbeat => "heartbeat",
723        SignalSource::Custom => "custom",
724    }
725}
726
727fn restore_signal_source(label: &str) -> Result<SignalSource, KernelFault> {
728    match label {
729        "cron" => Ok(SignalSource::Cron),
730        "gateway" => Ok(SignalSource::Gateway),
731        "heartbeat" => Ok(SignalSource::Heartbeat),
732        "custom" => Ok(SignalSource::Custom),
733        other => Err(KernelFault::new(
734            KernelFaultCode::CheckpointIncompatible,
735            format!("queued signal carries unknown source {other:?}"),
736        )),
737    }
738}
739
740fn signal_type_label(signal_type: SignalType) -> &'static str {
741    match signal_type {
742        SignalType::Event => "event",
743        SignalType::Job => "job",
744        SignalType::Alert => "alert",
745    }
746}
747
748fn restore_signal_type(label: &str) -> Result<SignalType, KernelFault> {
749    match label {
750        "event" => Ok(SignalType::Event),
751        "job" => Ok(SignalType::Job),
752        "alert" => Ok(SignalType::Alert),
753        other => Err(KernelFault::new(
754            KernelFaultCode::CheckpointIncompatible,
755            format!("queued signal carries unknown type {other:?}"),
756        )),
757    }
758}
759
760fn urgency_label(urgency: Urgency) -> &'static str {
761    match urgency {
762        Urgency::Low => "low",
763        Urgency::Normal => "normal",
764        Urgency::High => "high",
765        Urgency::Critical => "critical",
766    }
767}
768
769fn restore_urgency(label: &str) -> Result<Urgency, KernelFault> {
770    match label {
771        "low" => Ok(Urgency::Low),
772        "normal" => Ok(Urgency::Normal),
773        "high" => Ok(Urgency::High),
774        "critical" => Ok(Urgency::Critical),
775        other => Err(KernelFault::new(
776            KernelFaultCode::CheckpointIncompatible,
777            format!("queued signal carries unknown urgency {other:?}"),
778        )),
779    }
780}
781
782/// §12.2 · put the scheduler partition back on the engine.
783fn restore_scheduler(
784    engine: &mut LoopStateMachine,
785    config: &ResolvedOperationConfig,
786    state: &SchedulerState,
787) -> Result<(), KernelFault> {
788    engine.run_spec = state.run_spec.as_ref().map(agent_run_spec);
789    if let Some(names) = &state.advertised_tool_ids {
790        let unique: std::collections::BTreeSet<&str> = names.iter().map(String::as_str).collect();
791        if unique.len() != names.len() {
792            return Err(KernelFault::new(
793                KernelFaultCode::CheckpointIncompatible,
794                "advertised_tool_ids contains a duplicate tool id",
795            ));
796        }
797    }
798    engine.restore_advertised_tool_ids(state.advertised_tool_ids.clone());
799    engine.turn = state.turn;
800    engine.restore_budget_usage(state.total_tokens.get(), state.rounds_completed);
801    engine.restore_started_at_ms(state.started_at_ms.map(WireU64::get));
802    engine.set_wall_budget(state.wall_budget_ms.map(WireU64::get));
803    engine
804        .restore_entropy_checkpoint_state(crate::scheduler::entropy::EntropyTrackerRuntimeState {
805            window: state
806                .entropy
807                .window
808                .iter()
809                .map(|entry| crate::scheduler::entropy::EntropyTurnRuntimeState {
810                    errored_results: entry.errored_results,
811                    total_results: entry.total_results,
812                    rollbacks: entry.rollbacks,
813                })
814                .collect(),
815            rollbacks_pending: state.entropy.rollbacks_pending,
816            disarmed: state.entropy.disarmed,
817            last_alert_turn: state.entropy.last_alert_turn,
818        })
819        .map_err(|error| {
820            KernelFault::new(
821                KernelFaultCode::CheckpointIncompatible,
822                format!("entropy checkpoint could not be rebuilt: {error}"),
823            )
824        })?;
825
826    let limits = SchedulerBudget {
827        max_tokens: config.execution_policy.max_context_tokens,
828        max_turns: config.execution_policy.max_turns,
829        max_total_tokens: config.execution_policy.max_total_tokens.get(),
830        max_wall_ms: state.wall_budget_ms.map(WireU64::get),
831    };
832    let table = engine.task_table_mut();
833    for task in &state.tasks {
834        let mut tcb = Tcb::root(task.task_id.as_str(), limits.clone());
835        tcb.parent = task
836            .parent_task_id
837            .as_ref()
838            .map(|parent| parent.as_str().into());
839        tcb.state = restore_task_lifecycle(task)?;
840        tcb.runnable_cause = task.runnable_cause;
841        tcb.wait_set = task
842            .wait_set
843            .as_ref()
844            .map(|wait_set| restore_wait_set(&task.task_id, wait_set))
845            .transpose()?;
846        tcb.caps = task.capability_ids.iter().map(|cap| cap.into()).collect();
847        tcb.capabilities = task.capabilities.clone();
848        tcb.supervision = task.supervision.clone();
849        tcb.supervision_events = task.supervision_events.clone();
850        // spc_009-06: restore this task's own checkpointed pool verbatim — never re-derive it from
851        // `state.budget_grant` (the `set_budget_grant` call above only restores the whole-operation
852        // admission grant for reporting; re-seeding from it here would silently undo every debit a
853        // spawn made before this checkpoint was taken).
854        tcb.child_budget_remaining = task.child_budget_remaining;
855        tcb.budget_grant = task.budget_grant.clone();
856        tcb.mailbox = task.mailbox.clone();
857        if let Some(grant) = tcb.budget_grant.as_ref()
858            && (grant.child.as_str() != task.task_id.as_str()
859                || tcb.parent.as_deref() != Some(grant.parent.as_str()))
860        {
861            return Err(KernelFault::new(
862                KernelFaultCode::CheckpointIncompatible,
863                format!(
864                    "task {} carries a hierarchical budget grant for parent {} and child {}",
865                    task.task_id, grant.parent, grant.child
866                ),
867            ));
868        }
869        tcb.proc = task
870            .process
871            .as_ref()
872            .map(|process| {
873                let result = process
874                    .join_result
875                    .as_ref()
876                    .map(|value| {
877                        serde_json::from_value(value.get().clone()).map_err(|error| {
878                            KernelFault::new(
879                                KernelFaultCode::CheckpointIncompatible,
880                                format!(
881                                    "task {} carries an invalid child join result: {error}",
882                                    task.task_id
883                                ),
884                            )
885                        })
886                    })
887                    .transpose()?;
888                if result.as_ref().is_some_and(|result: &SubAgentResult| {
889                    result.agent_id.as_str() != task.task_id.as_str()
890                }) {
891                    return Err(KernelFault::new(
892                        KernelFaultCode::CheckpointIncompatible,
893                        format!(
894                            "task {} carries a join result for another child",
895                            task.task_id
896                        ),
897                    ));
898                }
899                Ok(ProcInfo {
900                    role: restore_agent_role(&process.role)?,
901                    isolation: restore_agent_isolation(&process.isolation)?,
902                    context_inheritance: restore_context_inheritance(&process.context_inheritance)?,
903                    result,
904                })
905            })
906            .transpose()?;
907        tcb.budget = BudgetLedger {
908            limits: limits.clone(),
909            turns: task.turns_used,
910            total_tokens: task.tokens_used.get(),
911            started_at_ms: state.started_at_ms.map(WireU64::get),
912        };
913        table.insert(tcb);
914    }
915    let mut restored_channels = BTreeMap::new();
916    for channel in &state.channels {
917        let id = ChannelId(channel.channel_id.as_str().into());
918        if restored_channels
919            .insert(id, channel.channel.clone())
920            .is_some()
921        {
922            return Err(KernelFault::new(
923                KernelFaultCode::CheckpointIncompatible,
924                format!("duplicate local channel {:?}", channel.channel_id),
925            ));
926        }
927    }
928    table.restore_channels(restored_channels);
929    let mut restored_objects = BTreeMap::new();
930    for object in &state.objects {
931        if table.get(object.owner.as_str()).is_none() {
932            return Err(KernelFault::new(
933                KernelFaultCode::CheckpointIncompatible,
934                format!("object {} names unknown owner {}", object.id, object.owner),
935            ));
936        }
937        if restored_objects.insert(object.id, object.clone()).is_some() {
938            return Err(KernelFault::new(
939                KernelFaultCode::CheckpointIncompatible,
940                format!("duplicate local object {}", object.id),
941            ));
942        }
943    }
944    table.restore_objects(restored_objects);
945    // spc_002-09: `children` is not on the wire (derivable from `parent`); `insert` above only
946    // registers a child when its parent row already exists, which the wire's task order does not
947    // guarantee. Recompute from the now-complete `parent` links rather than trust insertion order.
948    table.rebuild_children();
949    // WaitIndex is derived state. Recompute it from every task's restored durable wait set.
950    table.rebuild_wait_index();
951
952    let queued = state
953        .queued_signals
954        .iter()
955        .map(restore_queued_signal)
956        .collect::<Result<Vec<_>, _>>()?;
957    engine
958        .restore_signal_checkpoint_state(SignalRouterRuntimeState {
959            queued,
960            seen_order: state
961                .signal_dedupe_keys
962                .iter()
963                .map(|key| key.as_str().into())
964                .collect(),
965        })
966        .map_err(|error| {
967            KernelFault::new(
968                KernelFaultCode::CheckpointIncompatible,
969                format!("signal checkpoint could not be rebuilt: {error}"),
970            )
971        })?;
972
973    if let Some(workflow) = &state.workflow {
974        let wire_spec = WireSpec {
975            name: String::new(),
976            nodes: workflow
977                .nodes
978                .iter()
979                .map(|node| WireNode {
980                    node_id: node.node_id.clone(),
981                    task: node.task.clone(),
982                    depends_on: node.depends_on.clone(),
983                    run_spec: node.run_spec.clone(),
984                })
985                .collect(),
986        };
987        let core_spec = build_core_spec(&wire_spec).map_err(|fault| {
988            KernelFault::new(KernelFaultCode::CheckpointIncompatible, fault.message)
989        })?;
990        let runtime_states: Result<Vec<_>, KernelFault> = workflow
991            .nodes
992            .iter()
993            .enumerate()
994            .zip(core_spec.nodes.iter())
995            .map(|((index, node), core)| {
996                if node.kind != "spawn" {
997                    return Err(KernelFault::new(
998                        KernelFaultCode::CheckpointIncompatible,
999                        format!(
1000                            "workflow node {} carries unsupported checkpoint kind {:?}",
1001                            node.node_id, node.kind
1002                        ),
1003                    ));
1004                }
1005                let result = engine
1006                    .task_table()
1007                    .get(&crate::orchestration::workflow::node_agent_id(index))
1008                    .and_then(|task| task.proc.as_ref())
1009                    .and_then(|process| process.result.as_ref())
1010                    .map(|result| result.result.clone());
1011                Ok(WorkflowRuntimeNodeState {
1012                    node: core.clone(),
1013                    status: restore_workflow_status(&node.status)?,
1014                    result,
1015                    active_agent_id: node.active_agent_id.clone(),
1016                    iterations_completed: node.iterations_completed as usize,
1017                })
1018            })
1019            .collect();
1020        let run = crate::orchestration::workflow::WorkflowRun::restore_from_checkpoint(
1021            &core_spec,
1022            &runtime_states?,
1023        )
1024        .map_err(|error| {
1025            KernelFault::new(
1026                KernelFaultCode::CheckpointIncompatible,
1027                format!("workflow checkpoint could not be rebuilt: {error}"),
1028            )
1029        })?;
1030        engine.restore_checkpoint_workflow(run);
1031    }
1032    Ok(())
1033}
1034
1035fn restore_task_lifecycle(task: &TaskControlState) -> Result<TaskLifecycle, KernelFault> {
1036    let lifecycle = match task.lifecycle.as_str() {
1037        "pending_launch" => TaskLifecycle::PendingLaunch,
1038        "starting" => TaskLifecycle::Starting,
1039        "ready" => TaskLifecycle::Ready,
1040        "running" => TaskLifecycle::Running,
1041        "suspended" => TaskLifecycle::Suspended,
1042        "done" => {
1043            let label = task.termination.as_deref().ok_or_else(|| {
1044                incompatible(format!(
1045                    "task {} is done but the checkpoint does not say why; a finished task without \
1046                     its termination reason is not restorable",
1047                    task.task_id
1048                ))
1049            })?;
1050            TaskLifecycle::Done(termination_from_label(label).ok_or_else(|| {
1051                incompatible(format!(
1052                    "task {} names termination reason {label:?}, which this kernel does not know",
1053                    task.task_id
1054                ))
1055            })?)
1056        }
1057        other => {
1058            return Err(incompatible(format!(
1059                "task {} names lifecycle {other:?}, which this kernel does not know",
1060                task.task_id
1061            )));
1062        }
1063    };
1064    Ok(lifecycle)
1065}
1066
1067fn project_wait_set(wait_set: &DurableWaitSet) -> TaskWaitSetState {
1068    TaskWaitSetState {
1069        mode: match wait_set.mode {
1070            WaitMode::Any => "any",
1071            WaitMode::All => "all",
1072        }
1073        .to_string(),
1074        conditions: wait_set
1075            .conditions
1076            .iter()
1077            .map(|condition| match condition {
1078                WaitCondition::Effect(effect_id) => TaskWaitConditionState::Effect {
1079                    effect_id: effect_id.clone(),
1080                },
1081                WaitCondition::Child(task_id) => TaskWaitConditionState::Child {
1082                    task_id: TaskId::new(task_id.as_str())
1083                        .expect("an internal task id is a legal branded ref"),
1084                },
1085                WaitCondition::Children(task_ids) => TaskWaitConditionState::Children {
1086                    task_ids: task_ids
1087                        .iter()
1088                        .map(|task_id| {
1089                            TaskId::new(task_id.as_str())
1090                                .expect("an internal task id is a legal branded ref")
1091                        })
1092                        .collect(),
1093                },
1094                WaitCondition::Approval(ApprovalId(id)) => TaskWaitConditionState::Approval {
1095                    approval_id: id.to_string(),
1096                },
1097                WaitCondition::Signal(SignalFilter(filter)) => TaskWaitConditionState::Signal {
1098                    filter: filter.to_string(),
1099                },
1100                WaitCondition::Timer(LogicalDeadline(deadline_ms)) => {
1101                    TaskWaitConditionState::Timer {
1102                        deadline_ms: WireU64::new(*deadline_ms),
1103                    }
1104                }
1105                WaitCondition::Channel(ChannelId(id)) => TaskWaitConditionState::Channel {
1106                    channel_id: id.to_string(),
1107                },
1108                WaitCondition::Resource(ResourceKey(key)) => TaskWaitConditionState::Resource {
1109                    resource_key: key.to_string(),
1110                },
1111                WaitCondition::External(SubscriptionId(id)) => TaskWaitConditionState::External {
1112                    subscription_id: id.to_string(),
1113                },
1114            })
1115            .collect(),
1116        satisfied: wait_set
1117            .satisfied
1118            .iter()
1119            .map(|index| *index as u32)
1120            .collect(),
1121    }
1122}
1123
1124fn restore_wait_set(
1125    task_id: &TaskId,
1126    state: &TaskWaitSetState,
1127) -> Result<DurableWaitSet, KernelFault> {
1128    let mode = match state.mode.as_str() {
1129        "any" => WaitMode::Any,
1130        "all" => WaitMode::All,
1131        other => {
1132            return Err(incompatible(format!(
1133                "task {task_id} wait set names mode {other:?}, which this kernel does not know"
1134            )));
1135        }
1136    };
1137    if state.conditions.is_empty() {
1138        return Err(incompatible(format!(
1139            "task {task_id} carries an empty durable WaitSet"
1140        )));
1141    }
1142    let conditions = state
1143        .conditions
1144        .iter()
1145        .map(|condition| match condition {
1146            TaskWaitConditionState::Effect { effect_id } => {
1147                WaitCondition::Effect(effect_id.clone())
1148            }
1149            TaskWaitConditionState::Child { task_id } => {
1150                WaitCondition::Child(task_id.as_str().into())
1151            }
1152            TaskWaitConditionState::Children { task_ids } => WaitCondition::Children(
1153                task_ids
1154                    .iter()
1155                    .map(|task_id| task_id.as_str().into())
1156                    .collect(),
1157            ),
1158            TaskWaitConditionState::Approval { approval_id } => {
1159                WaitCondition::Approval(ApprovalId(approval_id.as_str().into()))
1160            }
1161            TaskWaitConditionState::Signal { filter } => {
1162                WaitCondition::Signal(SignalFilter(filter.as_str().into()))
1163            }
1164            TaskWaitConditionState::Timer { deadline_ms } => {
1165                WaitCondition::Timer(LogicalDeadline(deadline_ms.get()))
1166            }
1167            TaskWaitConditionState::Channel { channel_id } => {
1168                WaitCondition::Channel(ChannelId(channel_id.as_str().into()))
1169            }
1170            TaskWaitConditionState::Resource { resource_key } => {
1171                WaitCondition::Resource(ResourceKey(resource_key.as_str().into()))
1172            }
1173            TaskWaitConditionState::External { subscription_id } => {
1174                WaitCondition::External(SubscriptionId(subscription_id.as_str().into()))
1175            }
1176        })
1177        .collect::<Vec<_>>();
1178    let mut satisfied = BTreeSet::new();
1179    for index in &state.satisfied {
1180        let index = *index as usize;
1181        if index >= conditions.len() || !satisfied.insert(index) {
1182            return Err(incompatible(format!(
1183                "task {task_id} carries invalid satisfied WaitSet index {index}"
1184            )));
1185        }
1186    }
1187    Ok(DurableWaitSet {
1188        mode,
1189        conditions,
1190        satisfied,
1191    })
1192}
1193
1194fn termination_from_label(label: &str) -> Option<TerminationReason> {
1195    Some(match label {
1196        "completed" => TerminationReason::Completed,
1197        "max_turns" => TerminationReason::MaxTurns,
1198        "token_budget" => TerminationReason::TokenBudget,
1199        "timeout" => TerminationReason::Timeout,
1200        "user_abort" => TerminationReason::UserAbort,
1201        "error" => TerminationReason::Error,
1202        "milestone_exceeded" => TerminationReason::MilestoneExceeded,
1203        "context_overflow" => TerminationReason::ContextOverflow,
1204        "no_progress" => TerminationReason::NoProgress,
1205        _ => return None,
1206    })
1207}
1208
1209/// §12.2 · put the context-VM partition back on the engine.
1210///
1211/// Order matters: the messages are pushed first so the partition token counters land on the
1212/// checkpoint's own numbers, then the handle table is repopulated **by id** so a handle addresses
1213/// the same body it addressed before, then the allocator is moved past all of them.
1214fn restore_context_vm(
1215    engine: &mut LoopStateMachine,
1216    state: &ContextVmState,
1217) -> Result<(), KernelFault> {
1218    let ctx = &mut engine.ctx;
1219    for entry in &state.messages {
1220        let message = restore_message(&entry.role, &entry.body, &entry.tool_calls)?;
1221        match entry.partition {
1222            MessagePartition::System => ctx.partitions.system.push(message, entry.tokens),
1223            MessagePartition::History => ctx.partitions.history.push(message, entry.tokens),
1224        }
1225    }
1226    for slot in &state.knowledge {
1227        let message = restore_message(&slot.role, &slot.body, &[])?;
1228        ctx.partitions.knowledge.push_entry(
1229            slot.key.as_deref().map(Into::into),
1230            message,
1231            slot.tokens,
1232            slot.pinned,
1233        );
1234        // The boundary-eviction mark is bookkeeping the push path does not take, so it is set
1235        // straight onto the entry it belongs to — a slot that was marked for removal must still be
1236        // marked after a restore, or the next sweep keeps something the run had already dropped.
1237        if slot.evict_at_boundary
1238            && let Some(entry) = ctx.partitions.knowledge.entries.last_mut()
1239        {
1240            entry.evict_at_boundary = true;
1241        }
1242    }
1243    ctx.partitions.signals = state.signals.clone();
1244    ctx.partitions.task_state = restore_task_state(&state.task_state);
1245    ctx.last_activity_ms = state.last_activity_ms.get();
1246    ctx.last_compact_ms = state.last_compact_ms.map(WireU64::get);
1247    ctx.active_skills = state
1248        .active_skills
1249        .iter()
1250        .map(|lease| (lease.skill.as_str().into(), lease.lease_until_turn))
1251        .collect();
1252
1253    for handle in &state.handles {
1254        ctx.handles.insert(Handle {
1255            id: handle.handle_id,
1256            kind: restore_handle_kind(&handle.kind)?,
1257            residency: restore_residency(handle)?,
1258            tokens: handle.tokens,
1259            source: handle.source.as_deref().map(Into::into),
1260        });
1261    }
1262    ctx.restore_next_handle_id(state.next_handle_id);
1263    if !ctx.restore_frozen_history_len(state.frozen_history_len as usize) {
1264        return Err(incompatible(format!(
1265            "the checkpoint freezes {} history messages but restores only {}",
1266            state.frozen_history_len,
1267            ctx.partitions.history.messages.len()
1268        )));
1269    }
1270    Ok(())
1271}
1272
1273fn restore_message(
1274    role: &str,
1275    body: &StoredMessageBody,
1276    tool_calls: &[LogicalToolCall],
1277) -> Result<CoreMessage, KernelFault> {
1278    let role = role_from_label(role)
1279        .ok_or_else(|| incompatible(format!("the checkpoint carries message role {role:?}")))?;
1280    let content = match body {
1281        StoredMessageBody::Inline(inline) => message_content(
1282            inline.text.clone(),
1283            inline.tool_call_id.as_deref(),
1284            inline.is_error,
1285        ),
1286        StoredMessageBody::Reference(reference) => message_content(
1287            reference.preview.clone(),
1288            reference.tool_call_id.as_deref(),
1289            reference.is_error,
1290        ),
1291        StoredMessageBody::Structured(structured) => {
1292            if !structured.durable_tool_results.is_empty() {
1293                if structured.durable_content.is_some() {
1294                    return Err(incompatible(
1295                        "the checkpoint durable tool results must not carry another body form"
1296                            .to_string(),
1297                    ));
1298                }
1299                content_from_durable_tool_results(&structured.durable_tool_results).map_err(|error| incompatible(format!(
1300                    "the checkpoint carries durable tool results this runtime cannot restore: {error}"
1301                )))?
1302            } else if let Some(content) = &structured.durable_content {
1303                content.validate().map_err(|error| {
1304                    incompatible(format!(
1305                        "the checkpoint carries invalid durable content: {error}"
1306                    ))
1307                })?;
1308                content_from_durable(content).map_err(|error| incompatible(format!(
1309                    "the checkpoint carries durable content this runtime cannot restore: {error}"
1310                )))?
1311            } else {
1312                return Err(incompatible(
1313                    "the checkpoint structured message body has no content".to_string(),
1314                ));
1315            }
1316        }
1317    };
1318    Ok(CoreMessage {
1319        role,
1320        content,
1321        tool_calls: tool_calls
1322            .iter()
1323            .map(|call| {
1324                Ok(crate::types::message::ToolCall {
1325                    id: call.call_id.as_str().into(),
1326                    name: call.name.as_str().into(),
1327                    arguments: serde_json::from_str(&call.arguments).map_err(|error| {
1328                        incompatible(format!(
1329                            "tool call {} carries arguments that do not decode: {error}",
1330                            call.call_id
1331                        ))
1332                    })?,
1333                })
1334            })
1335            .collect::<Result<Vec<_>, KernelFault>>()?,
1336    })
1337}
1338
1339fn restore_handle_kind(label: &str) -> Result<HandleKind, KernelFault> {
1340    Ok(match label {
1341        "tool_result" => HandleKind::ToolResult,
1342        "memory_page" => HandleKind::MemoryPage,
1343        "knowledge_entry" => HandleKind::KnowledgeEntry,
1344        "sub_agent_join" => HandleKind::SubAgentJoin,
1345        other => {
1346            return Err(incompatible(format!(
1347                "the checkpoint carries handle kind {other:?}, which this kernel does not know"
1348            )));
1349        }
1350    })
1351}
1352
1353fn restore_residency(handle: &HandleState) -> Result<Residency, KernelFault> {
1354    let missing = |what: &str| {
1355        incompatible(format!(
1356            "handle {} is {} but carries no {what}",
1357            handle.handle_id, handle.residency
1358        ))
1359    };
1360    Ok(match handle.residency.as_str() {
1361        "resident" => Residency::Resident,
1362        "collapsed" => Residency::Collapsed,
1363        "external" => Residency::External {
1364            payload_ref: handle
1365                .payload_ref
1366                .clone()
1367                .ok_or_else(|| missing("locator"))?,
1368            digest: handle.digest.clone().ok_or_else(|| missing("digest"))?,
1369            original_size: handle
1370                .original_size
1371                .ok_or_else(|| missing("original size"))?
1372                .get(),
1373        },
1374        "paged_out" => Residency::PagedOut {
1375            payload_ref: handle
1376                .payload_ref
1377                .clone()
1378                .ok_or_else(|| missing("locator"))?,
1379            digest: handle.digest.clone().ok_or_else(|| missing("digest"))?,
1380        },
1381        other => {
1382            return Err(incompatible(format!(
1383                "the checkpoint carries residency {other:?}, which this kernel does not know"
1384            )));
1385        }
1386    })
1387}
1388
1389fn incompatible(message: String) -> KernelFault {
1390    KernelFault::new(KernelFaultCode::CheckpointIncompatible, message)
1391}
1392
1393fn project_task_state(state: &TaskState) -> LogicalTaskState {
1394    LogicalTaskState {
1395        goal: state.goal.clone(),
1396        criteria: state.criteria.clone(),
1397        plan: state
1398            .plan
1399            .iter()
1400            .map(|step| LogicalPlanStep {
1401                label: step.label.clone(),
1402                done: step.done,
1403            })
1404            .collect(),
1405        current_step: state.current_step.map(|index| index as u32),
1406        progress: state.progress.clone(),
1407        scratchpad: state.scratchpad.clone(),
1408        blocked_on: state.blocked_on.clone(),
1409        directives: state.directives.clone(),
1410        preserved_refs: state.preserved_refs.clone(),
1411        recent_actions: state.recent_actions.clone(),
1412        compression_log: state
1413            .compression_log
1414            .iter()
1415            .map(|entry| LogicalCompressionEntry {
1416                action: entry.action.clone(),
1417                summary: entry.summary.clone(),
1418            })
1419            .collect(),
1420        compression_log_dropped: WireU64::new(state.compression_log_dropped),
1421    }
1422}
1423
1424fn restore_task_state(state: &LogicalTaskState) -> TaskState {
1425    TaskState {
1426        goal: state.goal.clone(),
1427        criteria: state.criteria.clone(),
1428        plan: state
1429            .plan
1430            .iter()
1431            .map(|step| PlanStep {
1432                label: step.label.clone(),
1433                done: step.done,
1434            })
1435            .collect(),
1436        current_step: state.current_step.map(|index| index as usize),
1437        progress: state.progress.clone(),
1438        scratchpad: state.scratchpad.clone(),
1439        blocked_on: state.blocked_on.clone(),
1440        directives: state.directives.clone(),
1441        preserved_refs: state.preserved_refs.clone(),
1442        recent_actions: state.recent_actions.clone(),
1443        compression_log: state
1444            .compression_log
1445            .iter()
1446            .map(|entry| CompressionEntry {
1447                action: entry.action.clone(),
1448                summary: entry.summary.clone(),
1449            })
1450            .collect(),
1451        compression_log_dropped: state.compression_log_dropped.get(),
1452    }
1453}
1454
1455fn authority(message: &str) -> SyscallRefusal {
1456    SyscallRefusal::Fault(KernelFault::new(
1457        KernelFaultCode::InvalidAuthority,
1458        message.to_string(),
1459    ))
1460}
1461
1462fn denial_reason(disposition: &Disposition, fallback: &str) -> String {
1463    match disposition {
1464        Disposition::Deny { stage, reason } => format!("{stage}: {reason}"),
1465        Disposition::RateLimited { retry_after_ms } => {
1466            format!("rate limited; retry after {retry_after_ms}ms")
1467        }
1468        Disposition::Gate { reason, .. } => format!("awaiting approval: {reason}"),
1469        Disposition::Defer { slot } => format!("deferred at slot {slot}"),
1470        Disposition::Allow => fallback.to_string(),
1471    }
1472}
1473
1474/// What the kernel authored for a pending `PersistMemory` effect (§22.13).
1475#[derive(Debug, Clone, PartialEq)]
1476struct AuthoredMemoryWrite {
1477    binding_id: MemoryBindingId,
1478    name: String,
1479    kind: WireMemoryKind,
1480    size_bytes: u32,
1481}
1482
1483/// What the kernel authored for a pending `QueryMemory` effect.
1484#[derive(Debug, Clone, PartialEq)]
1485struct AuthoredMemoryQuery {
1486    binding_id: MemoryBindingId,
1487    text: String,
1488    requested_k: u32,
1489}
1490
1491/// What the kernel published for a pending `LoadPayload` effect (§7.10 rule 4).
1492#[derive(Debug, Clone, PartialEq)]
1493struct PendingPayloadLoad {
1494    /// The wire address of the handle — the tool `call_id` for an external result, the kernel-minted
1495    /// archive id for a paged-out one.
1496    handle_id: String,
1497    /// The digest the body has to reproduce. The kernel never saw the body, so this is the *only*
1498    /// thing that makes a restored payload the one that left.
1499    digest: String,
1500    /// Present only for [`Residency::External`], whose declared size the kernel admitted and can
1501    /// therefore hold the host to. A page-out archive is checked by digest alone.
1502    original_size: Option<u64>,
1503}
1504
1505/// What one admitted syscall produced.
1506#[derive(Debug, Default)]
1507struct SyscallOutcome {
1508    effects: Vec<KernelEffect>,
1509    /// Set only by `SubmitWorkflow`'s bootstrap arm — the one syscall that moves the focus.
1510    focus: Option<ExecutionFocus>,
1511    /// The DAG grew and still owes a spawn round. Honoured on the provider-tool path; on the
1512    /// child-completion path the completion's own drive produces the batch (§7.7 ordering).
1513    needs_workflow_round: bool,
1514    /// Optional structured response for read-like local syscalls.
1515    ack: Option<String>,
1516}
1517
1518/// Reduces the five canonical input classes onto the kernel's existing semantic mechanisms.
1519///
1520/// Use it as the plan function of [`KernelTransaction::prepare`](super::transaction::KernelTransaction::prepare):
1521///
1522/// ```ignore
1523/// let preparation = tx.prepare(&envelope, |ctx| driver.plan(ctx));
1524/// // ... host CAS-appends the record ...
1525/// let committed = tx.commit(&token, &head)?;
1526/// driver.note_committed(committed.step_seq)?;
1527/// ```
1528pub struct CanonicalOperationDriver {
1529    engine: Option<LoopStateMachine>,
1530    root_kind: Option<RootKind>,
1531    focus: Option<ExecutionFocus>,
1532    workflow_id: Option<WorkflowId>,
1533    /// Wire node identity by internal DAG index — the DAG the engine runs is index-addressed, the
1534    /// wire is not, and the mapping is what keeps a `SpawnTasks` effect nameable by the host.
1535    node_ids: Vec<NodeId>,
1536    /// Canonical source DAG in the same index order as `node_ids`. Checkpoint projection pairs it
1537    /// with the semantic node statuses without serializing the graph's private indexes.
1538    workflow_nodes: Vec<WireNode>,
1539    /// The attempt the kernel minted for each **live** task. §10.4: a host may not create or
1540    /// rewrite child identity through a resolution or a completion, so a completion that names an
1541    /// attempt this kernel never issued is refused rather than folded in. A completed attempt is
1542    /// removed here, which is what makes a second completion — and the `parent_requests` riding on
1543    /// it — a stale causation rather than a second helping of authority.
1544    attempts: BTreeMap<String, AttemptId>,
1545    /// §7.6 · the provider calls this operation is waiting on, by effect id. At most one is live
1546    /// (DEC-3); the map keys it so a resolution names its own call rather than "the current one".
1547    provider_calls: BTreeMap<EffectId, PendingProviderCall>,
1548    /// §22.13 · the memory records **this kernel authored** for its pending `PersistMemory`
1549    /// effects. The resolution reports these, never what the host echoes back: a receipt may carry
1550    /// its store's own locator and digest and nothing else, so no host reply can restate a name,
1551    /// kind, size, trust or provenance the kernel derived.
1552    pending_memory_writes: BTreeMap<EffectId, AuthoredMemoryWrite>,
1553    /// The same for `QueryMemory`: the query the kernel authored and the width it clamped.
1554    pending_memory_queries: BTreeMap<EffectId, AuthoredMemoryQuery>,
1555    /// §7.10 · the handle each pending `LoadPayload` addresses, with the digest the loaded body has
1556    /// to reproduce. Kept here rather than re-read from the handle table at resolution time, so a
1557    /// residency that moved in between cannot silently change what a page-in is verified against.
1558    pending_payload_loads: BTreeMap<EffectId, PendingPayloadLoad>,
1559    /// Tool call ids that already produced a syscall. A causation is spent once — replaying the
1560    /// same provider result with a fresh input id must not buy a second workflow append.
1561    consumed_calls: BTreeSet<String>,
1562    /// §13.2 / DEC-6 · the live-mutable half of the configuration, plus the revision two concurrent
1563    /// writers race on. Seeded from the genesis record's resolved configuration; only
1564    /// `HostCommand::ApplyPolicyPatch` moves it. Boot-only axes are read from `context.config`
1565    /// instead, which is why the transaction never needs a second copy of this.
1566    policy: Option<LivePolicyState>,
1567    /// §7.3 · the verification contract whose cascade is installed on the engine, if any.
1568    ///
1569    /// The engine's own `LoopAction::EvaluateMilestone` names only a phase, because internally
1570    /// there is one cascade and a phase id is enough. The wire needs the pair: `phase_id` is
1571    /// unique only within its contract, so `(contract_id, phase_id)` is the host's complete lookup
1572    /// key. The driver retains that contract id beside the semantic engine so the wire projection
1573    /// is total without duplicating host-owned verifier state inside the engine.
1574    loaded_contract_id: Option<String>,
1575    staged: Option<StagedFocus>,
1576    poison: Option<KernelFault>,
1577}
1578
1579impl Default for CanonicalOperationDriver {
1580    fn default() -> Self {
1581        Self::new()
1582    }
1583}
1584
1585impl CanonicalOperationDriver {
1586    pub fn new() -> Self {
1587        Self {
1588            engine: None,
1589            root_kind: None,
1590            focus: None,
1591            workflow_id: None,
1592            node_ids: Vec::new(),
1593            workflow_nodes: Vec::new(),
1594            attempts: BTreeMap::new(),
1595            provider_calls: BTreeMap::new(),
1596            pending_memory_writes: BTreeMap::new(),
1597            pending_memory_queries: BTreeMap::new(),
1598            pending_payload_loads: BTreeMap::new(),
1599            consumed_calls: BTreeSet::new(),
1600            policy: None,
1601            loaded_contract_id: None,
1602            staged: None,
1603            poison: None,
1604        }
1605    }
1606
1607    // ----- observers -----
1608
1609    /// The operation's root class. `None` until the root start commits; immutable afterwards.
1610    pub fn root_kind(&self) -> Option<RootKind> {
1611        self.root_kind
1612    }
1613
1614    /// Where control currently is. Moves only on a committed transition (§7.4).
1615    pub fn focus(&self) -> Option<&ExecutionFocus> {
1616        self.focus.as_ref()
1617    }
1618
1619    pub fn workflow_id(&self) -> Option<&WorkflowId> {
1620        self.workflow_id.as_ref()
1621    }
1622
1623    /// Return the kernel-issued live attempt for `task_id`.
1624    ///
1625    /// Bindings use this read-only projection to correlate a host completion with the live task
1626    /// attempt. The value comes from checkpointed kernel state; hosts must never synthesize it.
1627    pub fn attempt_id(&self, task_id: &str) -> Option<&AttemptId> {
1628        self.attempts.get(task_id)
1629    }
1630
1631    pub fn poison(&self) -> Option<&KernelFault> {
1632        self.poison.as_ref()
1633    }
1634
1635    /// Read-only access to the semantic engine, for tests and host projections.
1636    pub fn engine(&self) -> Option<&LoopStateMachine> {
1637        self.engine.as_ref()
1638    }
1639
1640    /// Where the driver's own fold says the operation is. The transaction stays the authority on
1641    /// lifecycle; this exists so a host projection never needs a second copy of the rule.
1642    pub fn lifecycle(&self) -> OperationLifecycle {
1643        match (self.engine.is_some(), self.root_kind) {
1644            (false, _) => OperationLifecycle::Created,
1645            (true, None) => OperationLifecycle::Configured,
1646            (true, Some(_)) => OperationLifecycle::Running,
1647        }
1648    }
1649}
1650
1651mod continuation;
1652mod effects;
1653mod events;
1654mod planning;
1655mod projection;
1656mod provider;
1657mod syscall;
1658
1659// ---------------------------------------------------------------------------------------------
1660// wire ⇄ semantic projections
1661// ---------------------------------------------------------------------------------------------
1662
1663fn root_task_id() -> TaskId {
1664    TaskId::new(ROOT_TASK_ID).expect("the root task id is a legal branded ref")
1665}
1666
1667// ---------------------------------------------------------------------------------------------
1668// §7.6 · the model-facing syscall surface
1669// ---------------------------------------------------------------------------------------------
1670
1671/// Tool names that reduce to a P1 syscall instead of to a host tool execution.
1672///
1673/// This list is what deletes §22.10's bypasses 4 and 5. Historically the SDK *removed*
1674/// `submit_workflow_nodes` / `start_workflow` from the tool loop, faked a tool result for the
1675/// model, and re-submitted the request as a separate kernel input with a submitter of its own
1676/// choosing — so the kernel never saw a `ProviderTool` causation at all. Here the names are the
1677/// kernel's, the arguments are decoded by the kernel, and the caller comes from the pending call.
1678///
1679/// F15 ruling (0.2.66, user-adjudicated — resolves the §7.6 SPEC-ISSUE): `RequestMemoryWrite`
1680/// has no entry here **by design** — the kernel gains no model-facing memory *write* surface.
1681/// `memory` is a search tool, long-term writes are extracted host-side (§22.13's 现状定位), and
1682/// the syscall's only caller channel is a child's `parent_requests` (child→parent only). If a
1683/// spec revision ever proposes a write surface, it must overturn this ruling explicitly.
1684pub const SYSCALL_TOOL_NAMES: &[&str] = &[
1685    "start_workflow",
1686    "submit_workflow_nodes",
1687    "skill",
1688    "update_plan",
1689    crate::context::manager::MEMORY_TOOL_NAME,
1690    crate::context::manager::READ_RESULT_TOOL_NAME,
1691    "send_message",
1692    "publish_channel",
1693    "receive_mailbox",
1694    "receive_channel",
1695    "read_object",
1696];
1697
1698fn is_syscall_tool(name: &str) -> bool {
1699    SYSCALL_TOOL_NAMES.contains(&name)
1700}
1701
1702/// Decode a recognised meta-tool call into its typed request.
1703///
1704/// A decode failure is a *rejection*, never a fault: the model wrote bad arguments, which is a
1705/// thing to answer with an audit fact rather than a host protocol violation.
1706fn decode_syscall(call: &WireToolCall) -> Result<SyscallRequest, SyscallRejection> {
1707    let arguments = call.arguments.get().clone();
1708    let name: &'static str = SYSCALL_TOOL_NAMES
1709        .iter()
1710        .copied()
1711        .find(|known| *known == call.name.as_str())
1712        .expect("only recognised syscall tools reach the decoder");
1713
1714    fn decode<T: serde::de::DeserializeOwned>(
1715        name: &'static str,
1716        arguments: serde_json::Value,
1717    ) -> Result<T, SyscallRejection> {
1718        serde_json::from_value(arguments)
1719            .map_err(|error| SyscallRejection::new(name, format!("malformed arguments: {error}")))
1720    }
1721
1722    match name {
1723        "start_workflow" => Ok(SyscallRequest::SubmitWorkflow(
1724            super::syscall::SubmitWorkflowRequest {
1725                spec: decode(name, arguments)?,
1726            },
1727        )),
1728        "submit_workflow_nodes" => {
1729            #[derive(serde::Deserialize)]
1730            struct Args {
1731                nodes: Vec<WireNode>,
1732            }
1733            let args: Args = decode(name, arguments)?;
1734            Ok(SyscallRequest::AppendWorkflowNodes(
1735                super::syscall::AppendWorkflowNodesRequest { nodes: args.nodes },
1736            ))
1737        }
1738        "skill" => {
1739            #[derive(serde::Deserialize)]
1740            struct Args {
1741                name: String,
1742                #[serde(default)]
1743                lease_turns: Option<u32>,
1744            }
1745            let args: Args = decode(name, arguments)?;
1746            Ok(SyscallRequest::ActivateSkill(
1747                super::syscall::ActivateSkillRequest {
1748                    name: args.name,
1749                    lease_turns: args.lease_turns,
1750                },
1751            ))
1752        }
1753        "update_plan" => Ok(SyscallRequest::UpdateTask(
1754            super::syscall::UpdateTaskRequest {
1755                update: decode(name, arguments)?,
1756            },
1757        )),
1758        crate::context::manager::MEMORY_TOOL_NAME => {
1759            #[derive(serde::Deserialize)]
1760            struct Args {
1761                #[serde(default)]
1762                query: String,
1763                #[serde(default)]
1764                kinds: Vec<WireMemoryKind>,
1765                #[serde(default)]
1766                top_k: Option<u32>,
1767            }
1768            let args: Args = decode(name, arguments)?;
1769            Ok(SyscallRequest::RequestMemoryQuery(
1770                super::syscall::RequestMemoryQueryRequest {
1771                    query: super::syscall::MemoryQueryProposal {
1772                        text: args.query,
1773                        kinds: args.kinds,
1774                        limit: args.top_k,
1775                    },
1776                },
1777            ))
1778        }
1779        crate::context::manager::READ_RESULT_TOOL_NAME => {
1780            #[derive(serde::Deserialize)]
1781            struct Args {
1782                call_id: String,
1783            }
1784            let args: Args = decode(name, arguments)?;
1785            let handle_id = super::scalar::HandleId::new(args.call_id).map_err(|error| {
1786                SyscallRejection::new(name, format!("malformed handle: {}", error.message))
1787            })?;
1788            Ok(SyscallRequest::PageIn(super::syscall::PageInRequest {
1789                handle_id,
1790            }))
1791        }
1792        "send_message" => Ok(SyscallRequest::SendMessage(decode(name, arguments)?)),
1793        "publish_channel" => Ok(SyscallRequest::PublishChannel(decode(name, arguments)?)),
1794        "receive_mailbox" => Ok(SyscallRequest::ReceiveMailbox(decode(name, arguments)?)),
1795        "receive_channel" => Ok(SyscallRequest::ReceiveChannel(decode(name, arguments)?)),
1796        "read_object" => Ok(SyscallRequest::ReadObject(decode(name, arguments)?)),
1797        other => unreachable!("unrecognised syscall tool {other}"),
1798    }
1799}
1800
1801/// The task a causation names. Both variants carry one, and neither lets a host choose it.
1802fn causation_task(causation: &SyscallCausation) -> TaskId {
1803    match causation {
1804        SyscallCausation::ProviderTool(provider) => provider.task_id.clone(),
1805        SyscallCausation::ChildAttempt(child) => child.task_id.clone(),
1806    }
1807}
1808
1809/// §7.6 · the authority families a quarantined caller may not touch. `None` ⇒ the request widens
1810/// nothing (a plan edit, a page-in of an address the caller already holds).
1811///
1812/// SPEC-ISSUE: §7.6 requires that "a quarantined task must not escalate through workflow append,
1813/// memory scope or capability mutation", but the canonical [`WorkflowNode`](super::root::WorkflowNode)
1814/// carries no trust level — the internal DAG has `NodeTrust::{Trusted,Quarantined}` and the wire
1815/// has no field for it. The refusal below is therefore complete but currently unreachable through
1816/// the contract: no canonical input can declare a node quarantined. Either §7.4's workflow node
1817/// grows a trust field, or §7.6 has to say where quarantine comes from.
1818fn privileged_family(request: &SyscallRequest) -> Option<&'static str> {
1819    match request {
1820        SyscallRequest::SubmitWorkflow(_) | SyscallRequest::AppendWorkflowNodes(_) => {
1821            Some("workflow")
1822        }
1823        SyscallRequest::RequestMemoryWrite(_) | SyscallRequest::RequestMemoryQuery(_) => {
1824            Some("memory")
1825        }
1826        SyscallRequest::ActivateSkill(_) => Some("capability"),
1827        SyscallRequest::SendMessage(_) | SyscallRequest::PublishChannel(_) => Some("ipc"),
1828        SyscallRequest::UpdateTask(_)
1829        | SyscallRequest::PageIn(_)
1830        | SyscallRequest::ReceiveMailbox(_)
1831        | SyscallRequest::ReceiveChannel(_)
1832        | SyscallRequest::ReadObject(_) => None,
1833    }
1834}
1835
1836fn core_task_update(update: &WireTaskUpdate) -> crate::context::task_state::TaskUpdate {
1837    crate::context::task_state::TaskUpdate {
1838        plan: update.plan.clone(),
1839        current_step: update.current_step.map(|step| step as usize),
1840        progress: update.progress.clone(),
1841        scratchpad: update.scratchpad.clone(),
1842        blocked_on: update.blocked_on.clone(),
1843        preserved_refs: update.preserved_refs.clone(),
1844        directives: update.directives.clone(),
1845    }
1846}
1847
1848fn mint_effect_id(operation_id: &OperationId, step_seq: WireU64, index: u32) -> EffectId {
1849    EffectId::new(format!("{operation_id}:step:{step_seq}:effect:{index}"))
1850        .expect("an operation-scoped effect id is always a legal branded ref")
1851}
1852
1853fn mint_workflow_id(operation_id: &OperationId, step_seq: WireU64) -> WorkflowId {
1854    WorkflowId::new(format!("{operation_id}:workflow:{step_seq}"))
1855        .expect("an operation-scoped workflow id is always a legal branded ref")
1856}
1857
1858/// `wf-node{N}` / `wf-node{N}-i{k}` → `N`. The internal DAG is index-addressed; the wire is not.
1859fn parse_node_index(agent_id: &str) -> Option<usize> {
1860    let rest = agent_id.strip_prefix("wf-node")?;
1861    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1862    digits.parse().ok()
1863}
1864
1865fn wire_node_ids(spec: &WireSpec) -> Vec<NodeId> {
1866    spec.nodes.iter().map(|node| node.node_id.clone()).collect()
1867}
1868
1869/// Wire DAG → the kernel's index-addressed DAG. Node identity is checked here: a duplicate id or a
1870/// dependency on a node the spec does not declare is refused before the engine sees the spec.
1871fn build_core_spec(spec: &WireSpec) -> Result<CoreWorkflowSpec, KernelFault> {
1872    let mut index_of: BTreeMap<&str, usize> = BTreeMap::new();
1873    for (index, node) in spec.nodes.iter().enumerate() {
1874        if index_of.insert(node.node_id.as_str(), index).is_some() {
1875            return Err(KernelFault::new(
1876                KernelFaultCode::InvalidConfig,
1877                format!(
1878                    "workflow node id {:?} appears twice; node identity is unique within a DAG",
1879                    node.node_id
1880                ),
1881            ));
1882        }
1883    }
1884    let mut nodes = Vec::with_capacity(spec.nodes.len());
1885    for node in &spec.nodes {
1886        let role = node
1887            .run_spec
1888            .as_ref()
1889            .and_then(|spec| spec.role)
1890            .map_or(AgentRole::Custom, core_role);
1891        let mut core = CoreWorkflowNode::new(runtime_task(&node.task), role);
1892        if let Some(isolation) = node.run_spec.as_ref().and_then(|spec| spec.isolation) {
1893            core = core.with_isolation(core_isolation(isolation));
1894        }
1895        if let Some(inheritance) = node
1896            .run_spec
1897            .as_ref()
1898            .and_then(|spec| spec.context_inheritance)
1899        {
1900            core.context_inheritance = core_context_inheritance(inheritance);
1901        }
1902        if let Some(metadata) = node
1903            .run_spec
1904            .as_ref()
1905            .map(|spec| spec.metadata.get())
1906            .and_then(serde_json::Value::as_object)
1907        {
1908            if let Some(model_hint) = metadata
1909                .get("model_hint")
1910                .and_then(serde_json::Value::as_str)
1911            {
1912                core = core.with_model_hint(model_hint);
1913            }
1914            if let Some(output_schema) = metadata.get("output_schema") {
1915                core = core.with_output_schema(output_schema.clone());
1916            }
1917            // spc_008-01: fine-grained capability requests, smuggled through the same generic
1918            // `metadata` escape hatch `model_hint`/`output_schema` already use rather than a new
1919            // dedicated wire field. Fails closed on malformed input rather than silently treating
1920            // it as "no capability requested" — a security-relevant declaration must not downgrade
1921            // itself into a no-op check on a parse error.
1922            if let Some(requested) = metadata.get("requested_capabilities") {
1923                let capabilities: Vec<crate::types::capability::Capability> =
1924                    serde_json::from_value(requested.clone()).map_err(|error| {
1925                        KernelFault::new(
1926                            KernelFaultCode::InvalidConfig,
1927                            format!(
1928                                "workflow node {:?} metadata.requested_capabilities is malformed: {error}",
1929                                node.node_id
1930                            ),
1931                        )
1932                    })?;
1933                core = core.with_requested_capabilities(capabilities);
1934            }
1935            // spc_008-02: same escape-hatch pattern, same fail-closed convention, for the
1936            // hierarchical budget grant a node's spawn requests.
1937            if let Some(requested) = metadata.get("requested_budget") {
1938                let budget: crate::scheduler::budget_grant::ResourceBudget =
1939                    serde_json::from_value(requested.clone()).map_err(|error| {
1940                        KernelFault::new(
1941                            KernelFaultCode::InvalidConfig,
1942                            format!(
1943                                "workflow node {:?} metadata.requested_budget is malformed: {error}",
1944                                node.node_id
1945                            ),
1946                        )
1947                    })?;
1948                core = core.with_requested_budget(budget);
1949            }
1950            if let Some(factors) = metadata.get("scheduling_factors") {
1951                let factors: crate::orchestration::task_graph::SchedulingFactors =
1952                    serde_json::from_value(factors.clone()).map_err(|error| {
1953                        KernelFault::new(
1954                            KernelFaultCode::InvalidConfig,
1955                            format!(
1956                                "workflow node {:?} metadata.scheduling_factors is malformed: {error}",
1957                                node.node_id
1958                            ),
1959                        )
1960                    })?;
1961                core = core.with_scheduling_factors(factors);
1962            }
1963        }
1964        let mut depends_on = Vec::with_capacity(node.depends_on.len());
1965        for dependency in &node.depends_on {
1966            let Some(&index) = index_of.get(dependency.as_str()) else {
1967                return Err(KernelFault::new(
1968                    KernelFaultCode::InvalidConfig,
1969                    format!(
1970                        "workflow node {:?} depends on {:?}, which this DAG does not declare",
1971                        node.node_id, dependency
1972                    ),
1973                ));
1974            };
1975            depends_on.push(index);
1976        }
1977        nodes.push(core.with_depends_on(depends_on));
1978    }
1979    let core = CoreWorkflowSpec::new(nodes);
1980    core.validate()
1981        .map_err(|error| KernelFault::new(KernelFaultCode::InvalidConfig, error.to_string()))?;
1982    Ok(core)
1983}
1984
1985fn runtime_task(task: &LogicalTask) -> RuntimeTask {
1986    RuntimeTask {
1987        goal: task.goal.clone(),
1988        criteria: task.criteria.clone(),
1989        metadata: task.metadata.get().clone(),
1990        lane: task.lane.as_ref().map(TaskLane::new).unwrap_or_default(),
1991    }
1992}
1993
1994/// The logical spec carries no host session identity, so its internal identity carries none.
1995fn agent_run_spec(spec: &LogicalAgentSpec) -> AgentRunSpec {
1996    AgentRunSpec {
1997        identity: AgentIdentity::new(ROOT_TASK_ID, NO_HOST_SESSION),
1998        role: spec.role.map_or(AgentRole::Custom, core_role),
1999        isolation: spec
2000            .isolation
2001            .map_or(AgentIsolation::Shared, core_isolation),
2002        goal: spec.goal.clone(),
2003        verification_contract_id: spec.verification_contract_id.as_deref().map(Into::into),
2004        capability_filter: AgentCapabilityFilter {
2005            allowed_kinds: spec
2006                .capability_filter
2007                .allowed_kinds
2008                .iter()
2009                .copied()
2010                .map(core_capability_kind)
2011                .collect(),
2012            allowed_ids: spec
2013                .capability_filter
2014                .allowed_ids
2015                .iter()
2016                .map(|id| id.as_str().into())
2017                .collect(),
2018        },
2019        milestones: None,
2020        metadata: spec.metadata.get().clone(),
2021        loop_round: spec.loop_round.as_ref().map(|round| LoopRoundSpec {
2022            max_rounds: round.max_rounds,
2023            min_sleep_ms: round.min_sleep_ms.map(WireU64::get),
2024            max_sleep_ms: round.max_sleep_ms.map(WireU64::get),
2025            default_action: round.default_action.clone(),
2026        }),
2027        exposure_baseline: spec
2028            .exposure_baseline
2029            .as_ref()
2030            .map(|ids| ids.iter().map(|id| id.as_str().into()).collect()),
2031        requested_capabilities: Vec::new(),
2032        requested_budget: None,
2033    }
2034}
2035
2036fn logical_agent_run_spec(spec: &AgentRunSpec) -> LogicalAgentSpec {
2037    LogicalAgentSpec {
2038        goal: spec.goal.clone(),
2039        role: match spec.role {
2040            AgentRole::Custom => None,
2041            AgentRole::Explore => Some(WireRole::Explore),
2042            AgentRole::Plan => Some(WireRole::Plan),
2043            AgentRole::Implement => Some(WireRole::Implement),
2044            AgentRole::Verify => Some(WireRole::Verify),
2045        },
2046        isolation: match spec.isolation {
2047            AgentIsolation::Shared => None,
2048            AgentIsolation::ReadOnly => Some(WireIsolation::ReadOnly),
2049            AgentIsolation::Worktree => Some(WireIsolation::Worktree),
2050            AgentIsolation::Remote => Some(WireIsolation::Remote),
2051        },
2052        context_inheritance: None,
2053        verification_contract_id: spec
2054            .verification_contract_id
2055            .as_ref()
2056            .map(ToString::to_string),
2057        capability_filter: super::root::CapabilityFilter {
2058            allowed_kinds: spec
2059                .capability_filter
2060                .allowed_kinds
2061                .iter()
2062                .copied()
2063                .map(wire_capability_kind)
2064                .collect(),
2065            allowed_ids: spec
2066                .capability_filter
2067                .allowed_ids
2068                .iter()
2069                .map(ToString::to_string)
2070                .collect(),
2071        },
2072        exposure_baseline: spec
2073            .exposure_baseline
2074            .as_ref()
2075            .map(|ids| ids.iter().map(ToString::to_string).collect()),
2076        loop_round: spec
2077            .loop_round
2078            .as_ref()
2079            .map(|round| super::root::LogicalLoopRoundSpec {
2080                max_rounds: round.max_rounds,
2081                min_sleep_ms: round.min_sleep_ms.map(WireU64::new),
2082                max_sleep_ms: round.max_sleep_ms.map(WireU64::new),
2083                default_action: round.default_action.clone(),
2084            }),
2085        metadata: super::scalar::BoundedJson::new(spec.metadata.clone())
2086            .expect("canonical run metadata remains bounded"),
2087    }
2088}
2089
2090fn core_capability_kind(
2091    kind: super::root::CapabilityKind,
2092) -> crate::types::capability::CapabilityKind {
2093    use super::root::CapabilityKind as Wire;
2094    use crate::types::capability::CapabilityKind as Core;
2095    match kind {
2096        Wire::Tool => Core::Tool,
2097        Wire::Skill => Core::Skill,
2098        Wire::Memory => Core::Memory,
2099        Wire::Knowledge => Core::Knowledge,
2100        Wire::McpServer => Core::McpServer,
2101        Wire::Command => Core::Command,
2102        Wire::Agent => Core::Agent,
2103    }
2104}
2105
2106fn wire_capability_kind(
2107    kind: crate::types::capability::CapabilityKind,
2108) -> super::root::CapabilityKind {
2109    use super::root::CapabilityKind as Wire;
2110    use crate::types::capability::CapabilityKind as Core;
2111    match kind {
2112        Core::Tool => Wire::Tool,
2113        Core::Skill => Wire::Skill,
2114        Core::Memory => Wire::Memory,
2115        Core::Knowledge => Wire::Knowledge,
2116        Core::McpServer => Wire::McpServer,
2117        Core::Command => Wire::Command,
2118        Core::Agent => Wire::Agent,
2119    }
2120}
2121
2122fn core_role(role: WireRole) -> AgentRole {
2123    match role {
2124        WireRole::Explore => AgentRole::Explore,
2125        WireRole::Plan => AgentRole::Plan,
2126        WireRole::Implement => AgentRole::Implement,
2127        WireRole::Verify => AgentRole::Verify,
2128        WireRole::Custom => AgentRole::Custom,
2129    }
2130}
2131
2132fn core_isolation(isolation: WireIsolation) -> AgentIsolation {
2133    match isolation {
2134        WireIsolation::Shared => AgentIsolation::Shared,
2135        WireIsolation::ReadOnly => AgentIsolation::ReadOnly,
2136        WireIsolation::Worktree => AgentIsolation::Worktree,
2137        WireIsolation::Remote => AgentIsolation::Remote,
2138    }
2139}
2140
2141fn core_context_inheritance(inheritance: WireContextInheritance) -> ContextInheritance {
2142    match inheritance {
2143        WireContextInheritance::None => ContextInheritance::None,
2144        WireContextInheritance::SystemOnly => ContextInheritance::SystemOnly,
2145        WireContextInheritance::Full => ContextInheritance::Full,
2146    }
2147}
2148
2149/// Internal role/isolation labels back onto the wire vocabulary. `None` is the *absent* field, not
2150/// a parse failure: `custom`/`shared` are the wire defaults, so omitting them keeps a launch spec
2151/// minimal instead of restating what the contract already implies.
2152fn parse_wire_role(label: &str) -> Option<WireRole> {
2153    match label {
2154        "explore" => Some(WireRole::Explore),
2155        "plan" => Some(WireRole::Plan),
2156        "implement" => Some(WireRole::Implement),
2157        "verify" => Some(WireRole::Verify),
2158        _ => None,
2159    }
2160}
2161
2162fn parse_wire_isolation(label: &str) -> Option<WireIsolation> {
2163    match label {
2164        "read_only" => Some(WireIsolation::ReadOnly),
2165        "worktree" => Some(WireIsolation::Worktree),
2166        "remote" => Some(WireIsolation::Remote),
2167        _ => None,
2168    }
2169}
2170
2171fn parse_wire_context_inheritance(label: &str) -> Option<WireContextInheritance> {
2172    match label {
2173        "none" => Some(WireContextInheritance::None),
2174        "system_only" => Some(WireContextInheritance::SystemOnly),
2175        "full" => Some(WireContextInheritance::Full),
2176        _ => None,
2177    }
2178}
2179
2180/// §7.4 · seed the P3 context partitions from the one initial context the start carried. This is
2181/// the whole of what used to be a dozen separate accepted inputs.
2182fn seed_initial_context(engine: &mut LoopStateMachine, initial: &InitialContext) {
2183    if !initial.messages.is_empty() {
2184        engine.preload_history(initial.messages.iter().map(logical_message).collect());
2185    }
2186    seed_knowledge(engine, &initial.knowledge);
2187    if !initial.requested_capabilities.is_empty() {
2188        engine.set_requested_capabilities(initial.requested_capabilities.clone());
2189    }
2190}
2191
2192/// The one place wire knowledge entries enter the P3 knowledge partition.
2193///
2194/// Shared by the initial context (§7.4), `HostCommand::SeedKnowledge` (DEC-9) and the upsert half
2195/// of `HostCommand::ApplyKnowledgeMutation` (§13.2), so the three cannot drift in how a keyed,
2196/// pinned or token-counted entry is stored.
2197fn seed_knowledge(engine: &mut LoopStateMachine, entries: &[super::root::KnowledgeEntry]) {
2198    if entries.is_empty() {
2199        return;
2200    }
2201    let entries: Vec<crate::mm::PageInEntry> = entries
2202        .iter()
2203        .map(|entry| crate::mm::PageInEntry {
2204            content: entry.content.clone(),
2205            tokens: entry.tokens,
2206            source: None,
2207            key: entry.key.clone(),
2208            pinned: entry.pinned,
2209        })
2210        .collect();
2211    engine.apply_page_in(&entries);
2212}
2213
2214/// §7.7 · project one logical signal onto the runtime signal the in-kernel router works with.
2215///
2216/// Three rules are load-bearing:
2217///
2218/// * the **business** signal id travels verbatim. It is what the disposition, expiry and
2219///   displacement audit facts name, so a derived id would report an identity no caller ever wrote;
2220/// * `timestamp_ms` is the **envelope's accepted time**, not the signal's `source_timestamp_ms`.
2221///   The source timestamp is audit metadata and stays out of every admission decision (§11.2);
2222/// * absent optional fields mean "the author did not say", not a default urgency or source that
2223///   would change how the signal is scheduled;
2224/// * `escalate_after_ms` is a **duration** the kernel anchors to that same accepted time. That is
2225///   what closes the old gap where §13.2 admitted `SignalPolicy.deadline_escalation` while §7.7
2226///   carried nothing that could ever come due, leaving the whole escalation axis unreachable
2227///   (Task 14 · adjudication §5n item 1). The kernel still invents no deadline from
2228///   `source_timestamp_ms`, which is not a clock (§11.2).
2229///
2230/// SPEC-ISSUE (task-targeted routing): §7.7 defines the address space (operation or logical task)
2231/// but no per-task attention semantics, and core holds **one** router per operation. A validated
2232/// task target therefore lands in the operation's queue rather than a queue of its own. Either
2233/// §7.7 states that the target is audit-only addressing, or per-task queues need a contract.
2234fn runtime_signal(
2235    signal: &LogicalSignal,
2236    accepted_at_ms: WireU64,
2237) -> crate::types::signal::RuntimeSignal {
2238    use crate::types::signal::{RuntimeSignal, SignalSource, SignalType, Urgency};
2239
2240    let source = match signal.source {
2241        Some(SignalSourceKind::Cron) => SignalSource::Cron,
2242        Some(SignalSourceKind::Gateway) => SignalSource::Gateway,
2243        Some(SignalSourceKind::Heartbeat) => SignalSource::Heartbeat,
2244        Some(SignalSourceKind::Custom) | None => SignalSource::Custom,
2245    };
2246    let urgency = match signal.urgency {
2247        Some(SignalUrgency::Low) => Urgency::Low,
2248        Some(SignalUrgency::High) => Urgency::High,
2249        Some(SignalUrgency::Critical) => Urgency::Critical,
2250        Some(SignalUrgency::Normal) | None => Urgency::Normal,
2251    };
2252    let mut runtime = RuntimeSignal::new(
2253        source,
2254        // `signal_type` is deliberately not on the canonical wire (adjudication §5n item 3):
2255        // urgency already expresses priority, nothing branches on the router's event/job/alert
2256        // distinction, and a second axis that changes no decision is one more thing four hosts
2257        // would have to agree about. Every canonical signal enters as an event.
2258        SignalType::Event,
2259        urgency,
2260        signal_summary(signal),
2261    )
2262    .with_id(signal.signal_id.as_str())
2263    .with_payload(signal.payload.get().clone())
2264    .with_timestamp(accepted_at_ms.get());
2265    if let Some(key) = &signal.dedupe_key {
2266        runtime = runtime.with_dedupe(key.as_str());
2267    }
2268    // §7.7 · `escalate_after_ms` is a duration; the router works in instants. Anchoring it to the
2269    // envelope's accepted time here is the whole point of carrying a duration on the wire: the
2270    // same bytes redelivered produce the same deadline relative to *this* admission, and no host
2271    // clock ever enters the payload (DEC-2).
2272    if let Some(after) = signal.escalate_after_ms {
2273        runtime = runtime.with_deadline(accepted_at_ms.get().saturating_add(after.get()));
2274    }
2275    runtime
2276}
2277
2278/// The model-facing one-liner a queued or interrupting signal becomes.
2279///
2280/// §7.7 carries a payload and no summary, so the summary is derived — deterministically, because a
2281/// replay must produce the same context bytes. A JSON string payload is its own summary; anything
2282/// else is its canonical serialization, bounded.
2283fn signal_summary(signal: &LogicalSignal) -> String {
2284    const SIGNAL_SUMMARY_MAX_BYTES: usize = 512;
2285    match signal.payload.get() {
2286        serde_json::Value::Null => signal.signal_id.as_str().to_string(),
2287        serde_json::Value::String(text) => {
2288            truncate_on_char_boundary(text, SIGNAL_SUMMARY_MAX_BYTES)
2289        }
2290        other => truncate_on_char_boundary(&other.to_string(), SIGNAL_SUMMARY_MAX_BYTES),
2291    }
2292}
2293
2294fn live_policy_label(patch: &super::command::LivePolicyPatch) -> &'static str {
2295    use super::command::LivePolicyPatch;
2296    match patch {
2297        LivePolicyPatch::ReplaceSignalPolicy(_) => "signal",
2298        LivePolicyPatch::ReplaceGovernancePolicy(_) => "governance",
2299        LivePolicyPatch::TightenResourceQuota(_) => "resource_quota",
2300        LivePolicyPatch::ReplaceRecoveryPolicy(_) => "recovery",
2301    }
2302}
2303
2304fn logical_message(message: &super::root::LogicalMessage) -> CoreMessage {
2305    CoreMessage {
2306        role: core_role_of(message.role),
2307        content: Content::Text(message.content.clone()),
2308        tool_calls: Vec::new(),
2309    }
2310}
2311
2312fn core_role_of(role: MessageRole) -> Role {
2313    match role {
2314        MessageRole::System => Role::System,
2315        MessageRole::User => Role::User,
2316        MessageRole::Assistant => Role::Assistant,
2317        MessageRole::Tool => Role::Tool,
2318    }
2319}
2320
2321fn wire_role_of(role: Role) -> MessageRole {
2322    match role {
2323        Role::System => MessageRole::System,
2324        Role::User => MessageRole::User,
2325        Role::Assistant => MessageRole::Assistant,
2326        Role::Tool => MessageRole::Tool,
2327    }
2328}
2329
2330fn rendered_context(
2331    context: &crate::context::renderer::InternalRenderedContext,
2332) -> WireRenderedContext {
2333    WireRenderedContext {
2334        system_stable: context.system_stable.clone(),
2335        system_knowledge: context.system_knowledge.clone(),
2336        turns: context.turns.iter().map(provider_message).collect(),
2337        state_turn: context.state_turn.as_ref().map(provider_message),
2338        frozen_prefix_len: context.frozen_prefix_len.map(|len| len as u32),
2339    }
2340}
2341
2342fn provider_message(message: &CoreMessage) -> ProviderMessage {
2343    let (content, tool_call_id) = match &message.content {
2344        Content::Parts(parts) => match parts.as_slice() {
2345            [
2346                ContentPart::ToolResult {
2347                    call_id, output, ..
2348                },
2349            ] => (output.clone(), Some(call_id.to_string())),
2350            _ => message_body_parts(message)
2351                .map(|(text, tool_call_id, _is_error)| (text, tool_call_id))
2352                .unwrap_or_default(),
2353        },
2354        Content::Text(_) => message_body_parts(message)
2355            .map(|(text, tool_call_id, _is_error)| (text, tool_call_id))
2356            .unwrap_or_default(),
2357    };
2358    ProviderMessage {
2359        role: wire_role_of(message.role),
2360        content,
2361        tool_calls: message
2362            .tool_calls
2363            .iter()
2364            .filter_map(|call| wire_tool_call(call).ok())
2365            .collect(),
2366        tool_call_id: tool_call_id.and_then(|call_id| super::scalar::CallId::new(call_id).ok()),
2367        tokens: None,
2368    }
2369}
2370
2371fn tool_schema(schema: &crate::types::message::ToolSchema) -> WireToolSchema {
2372    WireToolSchema {
2373        name: schema.name.to_string(),
2374        description: schema.description.clone(),
2375        parameters: super::scalar::BoundedJson::new(schema.parameters.clone())
2376            .unwrap_or_else(|_| Default::default()),
2377    }
2378}
2379
2380fn workflow_budget(budget: &crate::orchestration::workflow::WorkflowBudget) -> WireWorkflowBudget {
2381    WireWorkflowBudget {
2382        max_total_tokens: budget.tokens_max.map(WireU64::new),
2383        max_turns: None,
2384        max_concurrency: budget.max_concurrent_subagents.map(|max| max as u32),
2385    }
2386}
2387
2388fn sub_agent_result(completed: &ChildCompleted) -> SubAgentResult {
2389    let termination = match completed.result.status {
2390        ChildStatus::Completed => TerminationReason::Completed,
2391        ChildStatus::Failed => TerminationReason::Error,
2392        ChildStatus::Cancelled => TerminationReason::UserAbort,
2393    };
2394    SubAgentResult {
2395        agent_id: completed.task_id.as_str().into(),
2396        result: LoopResult {
2397            termination,
2398            final_message: completed
2399                .result
2400                .output
2401                .as_ref()
2402                .map(|text| CoreMessage::assistant(text.clone())),
2403            turns_used: completed
2404                .result
2405                .usage
2406                .as_ref()
2407                .and_then(|usage| usage.turns)
2408                .unwrap_or(0),
2409            total_tokens_used: completed
2410                .result
2411                .usage
2412                .as_ref()
2413                .and_then(|usage| usage.output_tokens)
2414                .map_or(0, WireU64::get),
2415            loop_continue: None,
2416            classify_branch: None,
2417            pace_decision: None,
2418            tournament_winner: None,
2419        },
2420    }
2421}
2422
2423fn attempt_ordinal(attempt_id: &AttemptId) -> Option<u32> {
2424    attempt_id.as_str().rsplit(':').next()?.parse().ok()
2425}
2426
2427fn supervision_label(policy: crate::scheduler::tcb::ChildFailurePolicy) -> &'static str {
2428    match policy {
2429        crate::scheduler::tcb::ChildFailurePolicy::Propagate => "propagate",
2430        crate::scheduler::tcb::ChildFailurePolicy::Isolate => "isolate",
2431        crate::scheduler::tcb::ChildFailurePolicy::Restart => "restart",
2432        crate::scheduler::tcb::ChildFailurePolicy::Retry => "retry",
2433        crate::scheduler::tcb::ChildFailurePolicy::Ignore => "ignore",
2434    }
2435}
2436
2437/// §7.12 · how an agent loop's own termination reason becomes an operation terminal.
2438///
2439/// The internal vocabulary has two reasons the wire's `TerminationReason` deliberately does not
2440/// carry: `user_abort` **is** a `Cancelled` terminal and `error` **is** a `Failed` one. Folding
2441/// either back into `Completed` would give the same event two representations, which is exactly
2442/// what the canonical union removed.
2443fn agent_terminal(result: &LoopResult) -> KernelTerminal {
2444    let usage = UsageReport {
2445        input_tokens: WireU64::new(result.total_tokens_used),
2446        output_tokens: WireU64::ZERO,
2447        turns: result.turns_used,
2448        cached_input_tokens: None,
2449    };
2450    let termination = match result.termination {
2451        TerminationReason::Completed => WireTermination::Completed,
2452        TerminationReason::MaxTurns => WireTermination::MaxTurns,
2453        TerminationReason::TokenBudget => WireTermination::TokenBudget,
2454        TerminationReason::Timeout => WireTermination::Deadline,
2455        TerminationReason::ContextOverflow => WireTermination::ContextOverflow,
2456        TerminationReason::NoProgress => WireTermination::NoProgress,
2457        TerminationReason::MilestoneExceeded => WireTermination::MilestoneExceeded,
2458        TerminationReason::UserAbort => {
2459            return KernelTerminal::Cancelled(CancelledTerminal {
2460                reason: CancellationReason::User,
2461                usage,
2462            });
2463        }
2464        TerminationReason::Error => {
2465            return KernelTerminal::Failed(FailedTerminal {
2466                failure: KernelFailure {
2467                    code: KernelFailureCode::InvariantViolated,
2468                    message: "the agent loop ended in an error state".to_string(),
2469                },
2470                usage,
2471            });
2472        }
2473    };
2474    KernelTerminal::Agent(AgentTerminal {
2475        result: WireLoopResult {
2476            termination,
2477            final_message: result.final_message.as_ref().map(provider_message),
2478            turns_used: result.turns_used,
2479            pace_decision: result.pace_decision.as_ref().map(|decision| {
2480                super::terminal::PaceDecision {
2481                    action: match decision.action {
2482                        CorePaceAction::Continue => super::terminal::PaceAction::Continue,
2483                        CorePaceAction::Sleep => super::terminal::PaceAction::Sleep,
2484                        CorePaceAction::Stop => super::terminal::PaceAction::Stop,
2485                    },
2486                    delay_ms: decision.delay_ms.map(WireU64::new),
2487                    reason: decision.reason.clone(),
2488                    coerced_from: decision.coerced_from.clone(),
2489                }
2490            }),
2491        },
2492        usage,
2493    })
2494}
2495
2496fn publishes(disposition: &StepDisposition, tag: EffectKindTag) -> bool {
2497    disposition
2498        .effects()
2499        .iter()
2500        .any(|effect| effect.tag() == tag)
2501}
2502
2503fn loop_action_label(action: &LoopAction) -> &'static str {
2504    match action {
2505        LoopAction::CallLLM { .. } => "call_provider",
2506        LoopAction::ExecuteTools { .. } => "execute_tools",
2507        LoopAction::RequestApproval { .. } => "request_approval",
2508        LoopAction::SpawnWorkflow { .. } => "spawn_tasks",
2509        LoopAction::PreemptSubAgents { .. } => "preempt_tasks",
2510        LoopAction::PersistMemory { .. } => "persist_memory",
2511        LoopAction::QueryMemory { .. } => "query_memory",
2512        LoopAction::ArchivePageOut { .. } => "archive_page_out",
2513        LoopAction::EvaluateMilestone { .. } => "evaluate_milestone",
2514        LoopAction::Done { .. } => "terminal",
2515        LoopAction::AwaitingResume => "awaiting_resume",
2516    }
2517}
2518
2519/// The model-facing answer to a P1 syscall the kernel executed.
2520///
2521/// 下一请求信息最大化: each says what happened *and* where the consequence will show up, so the
2522/// model's next turn does not have to guess whether a control-plane call took effect.
2523fn syscall_ack(name: &str) -> &'static str {
2524    match name {
2525        "start_workflow" => {
2526            "workflow accepted: its ready nodes are scheduled; each result arrives as that node \
2527             completes"
2528        }
2529        "submit_workflow_nodes" => {
2530            "nodes appended to the running workflow; each result arrives as that node completes"
2531        }
2532        "skill" => "skill activated: its guidance and tools are in this turn's context",
2533        "update_plan" => "plan updated: the new state renders in [TASK STATE] from here on",
2534        crate::context::manager::MEMORY_TOOL_NAME => {
2535            "memory search issued: matching records are added to this conversation before your \
2536             next turn"
2537        }
2538        crate::context::manager::READ_RESULT_TOOL_NAME => "page-in requested",
2539        "send_message" | "publish_channel" => "local handle routed",
2540        "receive_mailbox" | "receive_channel" | "read_object" => "local state returned",
2541        _ => "accepted",
2542    }
2543}
2544
2545fn validate_ipc_labels(message_id: &str, kind: &str) -> Result<(), SyscallRefusal> {
2546    if message_id.is_empty() || kind.is_empty() || message_id.len() > 256 || kind.len() > 256 {
2547        return Err(SyscallRefusal::Rejected(SyscallRejection::new(
2548            "local_ipc",
2549            "message_id and message_kind must contain 1..=256 bytes",
2550        )));
2551    }
2552    Ok(())
2553}
2554
2555fn resolve_ipc_handle(
2556    engine: &LoopStateMachine,
2557    handle_id: &super::scalar::HandleId,
2558) -> Result<crate::mm::handle::Handle, SyscallRefusal> {
2559    engine
2560        .ctx
2561        .handles
2562        .all()
2563        .iter()
2564        .find(|handle| {
2565            handle.source.as_deref() == Some(handle_id.as_str())
2566                || handle.id.to_string() == handle_id.as_str()
2567        })
2568        .cloned()
2569        .ok_or_else(|| {
2570            SyscallRefusal::Rejected(SyscallRejection::new(
2571                "local_ipc",
2572                format!("payload handle {handle_id} is not reachable by this operation"),
2573            ))
2574        })
2575}
2576
2577fn local_ipc_refusal(error: crate::scheduler::tcb::LocalIpcError) -> SyscallRefusal {
2578    let reason = match error {
2579        crate::scheduler::tcb::LocalIpcError::UnknownCaller => "unknown caller",
2580        crate::scheduler::tcb::LocalIpcError::CallerTerminal => "caller is terminal",
2581        crate::scheduler::tcb::LocalIpcError::UnknownRecipient => "unknown recipient",
2582        crate::scheduler::tcb::LocalIpcError::ChannelSubscribersMismatch => {
2583            "channel subscriber set is immutable"
2584        }
2585        crate::scheduler::tcb::LocalIpcError::NotSubscriber => "caller is not a channel subscriber",
2586        crate::scheduler::tcb::LocalIpcError::Full => "IPC capacity is full",
2587        crate::scheduler::tcb::LocalIpcError::Expired => "message TTL already expired",
2588        crate::scheduler::tcb::LocalIpcError::ObjectConflict => {
2589            "object id already names a different descriptor"
2590        }
2591    };
2592    SyscallRefusal::Rejected(SyscallRejection::new("local_ipc", reason))
2593}
2594
2595fn local_ipc_outcome(accepted: bool) -> SyscallOutcome {
2596    SyscallOutcome {
2597        ack: Some(
2598            serde_json::json!({
2599                "status": if accepted { "accepted" } else { "duplicate" },
2600            })
2601            .to_string(),
2602        ),
2603        ..SyscallOutcome::default()
2604    }
2605}
2606
2607fn ipc_messages_outcome(messages: &[crate::scheduler::mailbox::MailboxMessage]) -> SyscallOutcome {
2608    SyscallOutcome {
2609        ack: Some(
2610            serde_json::to_string(messages)
2611                .expect("canonical mailbox messages are always serializable"),
2612        ),
2613        ..SyscallOutcome::default()
2614    }
2615}
2616
2617/// Wire → semantic projections for the resolution half.
2618fn core_provider_message(message: &ProviderMessage) -> Result<CoreMessage, KernelFault> {
2619    Ok(CoreMessage {
2620        role: core_role_of(message.role),
2621        content: Content::Text(message.content.clone()),
2622        tool_calls: message.tool_calls.iter().map(core_tool_call).collect(),
2623    })
2624}
2625
2626fn core_tool_call(call: &WireToolCall) -> crate::types::message::ToolCall {
2627    crate::types::message::ToolCall {
2628        id: call.call_id.as_str().into(),
2629        name: call.name.as_str().into(),
2630        arguments: call.arguments.get().clone(),
2631    }
2632}
2633
2634fn wire_tool_call(call: &crate::types::message::ToolCall) -> Result<WireToolCall, KernelFault> {
2635    Ok(WireToolCall {
2636        call_id: super::scalar::CallId::new(call.id.as_str()).map_err(malformed)?,
2637        name: call.name.to_string(),
2638        arguments: super::scalar::BoundedJson::new(call.arguments.clone())
2639            .unwrap_or_else(|_| Default::default()),
2640    })
2641}
2642
2643fn wire_approval_request(
2644    request: &crate::scheduler::state_machine::ApprovalRequest,
2645) -> Result<WireApprovalRequest, KernelFault> {
2646    Ok(WireApprovalRequest {
2647        call_id: super::scalar::CallId::new(request.call_id.as_str()).map_err(malformed)?,
2648        tool_name: request.tool.clone(),
2649        arguments: super::scalar::BoundedJson::new(request.arguments.clone())
2650            .unwrap_or_else(|_| Default::default()),
2651        reason: (!request.reason.is_empty()).then(|| request.reason.clone()),
2652    })
2653}
2654
2655/// §7.10 · one returned tool result.
2656///
2657/// Both arms produce the same thing: the text that enters working context. For `Inline` that is the
2658/// body; for `External` it is the preview, and the body never crosses core at all — the host
2659/// persisted it before submitting, and the kernel holds only the reference the
2660/// [`ToolsSuccess`](super::effect::ToolsSuccess) carried. The residency transfer that records
2661/// *where* the body went happens after the engine has accepted the batch (see
2662/// `record_external_payloads`), because the handle it moves does not exist until the result is in
2663/// history.
2664///
2665/// The canonical [`ToolResultDisposition`] is binary, so the projection onto core's historical
2666/// `is_fatal` + six-way `ToolErrorKind` is total and lossless in the direction that matters: only
2667/// `Recoverable` and `Fatal` are reachable, and `UserInterrupt` — the one kind that still rolls a
2668/// turn back — has no canonical spelling at all. Cancellation travels on `HostControl::Cancel`
2669/// (§7.9), so that retired retry rung is not re-expressible here.
2670///
2671/// §7.10 rule 9 · failure is orthogonal to residency, so the two failure facts are read through
2672/// [`WireToolResultPayload::disposition`] / [`WireToolResultPayload::is_error`] and land in core
2673/// identically for both arms. A tool that failed *and* produced a body over the inline threshold —
2674/// the common shape, not a rare one — is now expressible, and its fatality reaches the batch
2675/// close-out on the same path an inline one does.
2676fn core_tool_result(payload: &WireToolResultPayload) -> ToolResult {
2677    let disposition = payload.disposition();
2678    let is_error = payload.is_error();
2679    let error_kind = match disposition {
2680        ToolResultDisposition::Fatal => Some(ToolErrorKind::Fatal),
2681        ToolResultDisposition::Recoverable => is_error.then_some(ToolErrorKind::Recoverable),
2682    };
2683    match payload {
2684        WireToolResultPayload::Inline(inline) => ToolResult {
2685            call_id: inline.call_id.as_str().into(),
2686            output: Content::Text(inline.result.output.clone()),
2687            durable_content: inline.result.durable_content.clone(),
2688            is_error,
2689            is_fatal: disposition.is_fatal(),
2690            error_kind,
2691        },
2692        WireToolResultPayload::External(external) => ToolResult {
2693            call_id: external.call_id.as_str().into(),
2694            output: Content::Text(external.preview.clone()),
2695            durable_content: None,
2696            is_error,
2697            is_fatal: disposition.is_fatal(),
2698            error_kind,
2699        },
2700    }
2701}
2702
2703/// §7.10 rules 1, 2 and 5 · the configured threshold is the **arbiter** of which arm a result may
2704/// take, checked before the engine sees anything.
2705///
2706/// `PayloadPolicy::inline_threshold_bytes` documents a total partition — "results at or above this
2707/// size are committed as `External` rather than inline" — so both directions are enforced here:
2708///
2709/// - an oversized `Inline` is refused rather than externalised by the kernel. The host must persist
2710///   before submission, so "reject" is the only answer that keeps rule 5 true.
2711/// - an undersized `External` is refused too, because it costs a `LoadPayload` round trip to read
2712///   something that would have fitted in the turn that produced it, and it makes the partition —
2713///   the one thing a host has to agree with the kernel about — untotal.
2714///
2715/// The digest must be one this kernel can *verify*: a page-in is checked by recomputing the digest
2716/// over the returned body, so a foreign algorithm would admit a payload whose restoration could
2717/// never be proved. The preview is bounded because it is the part that actually occupies context.
2718fn check_payload_policy(
2719    payload: &WireToolResultPayload,
2720    policy: &super::config::ResolvedPayloadPolicy,
2721) -> Result<(), KernelFault> {
2722    let threshold = policy.inline_threshold_bytes as u64;
2723    match payload {
2724        WireToolResultPayload::Inline(inline) => {
2725            let durable_size = inline
2726                .result
2727                .durable_content
2728                .as_ref()
2729                .map(|content| {
2730                    content.validate().map_err(|error| {
2731                        KernelFault::new(
2732                            KernelFaultCode::MalformedEnvelope,
2733                            format!(
2734                                "inline tool result {} carries invalid durable content: {error}",
2735                                inline.call_id
2736                            ),
2737                        )
2738                    })?;
2739                    serde_json::to_vec(content).map(|bytes| bytes.len() as u64).map_err(|error| {
2740                        KernelFault::new(
2741                            KernelFaultCode::MalformedEnvelope,
2742                            format!(
2743                                "inline tool result {} durable content cannot be encoded: {error}",
2744                                inline.call_id
2745                            ),
2746                        )
2747                    })
2748                })
2749                .transpose()?
2750                .unwrap_or(0);
2751            let size = (inline.result.output.len() as u64).max(durable_size);
2752            if size >= threshold {
2753                return Err(KernelFault::new(
2754                    KernelFaultCode::ResourceLimitExceeded,
2755                    format!(
2756                        "tool result {} is {size} bytes and this operation's payload policy \
2757                         externalises at {threshold}; the host persists the body and submits an \
2758                         external result — the kernel does not spool on its behalf (§7.10)",
2759                        inline.call_id
2760                    ),
2761                ));
2762            }
2763            Ok(())
2764        }
2765        WireToolResultPayload::External(external) => {
2766            if !is_verifiable_digest(external.digest.as_str()) {
2767                return Err(KernelFault::new(
2768                    KernelFaultCode::MalformedEnvelope,
2769                    format!(
2770                        "external tool result {} carries digest {}, which this kernel cannot \
2771                         verify; a paged-in body is checked by recomputing {}:<64 hex> over it",
2772                        external.call_id,
2773                        external.digest,
2774                        super::record::DIGEST_ALGORITHM
2775                    ),
2776                ));
2777            }
2778            let size = external.original_size.get();
2779            if size < threshold {
2780                return Err(KernelFault::new(
2781                    KernelFaultCode::MalformedEnvelope,
2782                    format!(
2783                        "external tool result {} declares {size} bytes but this operation's \
2784                         payload policy inlines below {threshold}; the threshold is the single \
2785                         arbiter of which arm a result takes (§7.10)",
2786                        external.call_id
2787                    ),
2788                ));
2789            }
2790            let preview = external.preview.len() as u64;
2791            if preview > policy.preview_bytes as u64 {
2792                return Err(KernelFault::new(
2793                    KernelFaultCode::ResourceLimitExceeded,
2794                    format!(
2795                        "external tool result {} carries a {preview}-byte preview and this \
2796                         operation keeps {} bytes resident",
2797                        external.call_id, policy.preview_bytes
2798                    ),
2799                ));
2800            }
2801            Ok(())
2802        }
2803    }
2804}
2805
2806/// Whether `digest` is a digest this kernel can recompute — `sha256:` plus 64 lowercase hex.
2807fn is_verifiable_digest(digest: &str) -> bool {
2808    let Some(hex) = digest.strip_prefix(super::record::DIGEST_ALGORITHM) else {
2809        return false;
2810    };
2811    let Some(hex) = hex.strip_prefix(':') else {
2812        return false;
2813    };
2814    hex.len() == 64
2815        && hex
2816            .bytes()
2817            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
2818}
2819
2820fn core_milestone_result(
2821    result: &super::effect::MilestoneCheckResult,
2822) -> crate::types::milestone::MilestoneCheckResult {
2823    crate::types::milestone::MilestoneCheckResult {
2824        phase_id: result.phase_id.clone(),
2825        passed: result.passed,
2826        reason: (!result.passed).then(|| {
2827            if result.failed_criteria.is_empty() {
2828                result.notes.clone()
2829            } else {
2830                format!("unmet criteria: {}", result.failed_criteria.join("; "))
2831            }
2832        }),
2833    }
2834}
2835
2836/// Project a wire contract skeleton onto the engine's phase cascade.
2837///
2838/// The skeleton carries the two things core decides — phase order and unlocks — and nothing else,
2839/// so the projection fills the rest from the engine's own defaults: no criteria (the host owns
2840/// them, §5.2), the default `HarnessEval` verifier, unlimited retries, terminate-on-exhaustion.
2841/// The one lookup here is `unlocks` → capability descriptor, and it cannot fail: `resolve` already
2842/// proved every id names a declared tool or skill, so the fallback marker is unreachable and
2843/// exists only to keep the projection total.
2844fn core_milestone_contract(
2845    contract: &super::config::VerificationContract,
2846    config: &ResolvedOperationConfig,
2847) -> crate::types::milestone::MilestoneContract {
2848    use crate::types::capability::{CapabilityDescriptor, CapabilityKind as CoreCapabilityKind};
2849    use crate::types::milestone::{MilestoneContract, MilestonePhase};
2850
2851    let mut cascade = MilestoneContract::new();
2852    for phase in &contract.phases {
2853        let unlocks = phase
2854            .unlocks
2855            .iter()
2856            .map(|id| {
2857                if let Some(tool) = config.tool_catalog.iter().find(|tool| &tool.name == id) {
2858                    CapabilityDescriptor::tool(core_tool_schema(tool))
2859                } else if let Some(skill) = config.skill_catalog.iter().find(|s| &s.name == id) {
2860                    CapabilityDescriptor::skill(core_skill(skill))
2861                } else {
2862                    CapabilityDescriptor::marker(
2863                        CoreCapabilityKind::Tool,
2864                        id.as_str(),
2865                        String::new(),
2866                    )
2867                }
2868            })
2869            .collect();
2870        cascade = cascade.phase(MilestonePhase {
2871            unlocks,
2872            ..MilestonePhase::new(phase.phase_id.clone())
2873        });
2874    }
2875    cascade
2876}
2877
2878fn core_memory_kind(kind: WireMemoryKind) -> crate::mm::memory::MemoryKind {
2879    match kind {
2880        WireMemoryKind::User => crate::mm::memory::MemoryKind::User,
2881        WireMemoryKind::Feedback => crate::mm::memory::MemoryKind::Feedback,
2882        WireMemoryKind::Project => crate::mm::memory::MemoryKind::Project,
2883        WireMemoryKind::Reference => crate::mm::memory::MemoryKind::Reference,
2884    }
2885}
2886
2887fn wire_memory_kind_label(kind: WireMemoryKind) -> &'static str {
2888    core_memory_kind(kind).label()
2889}
2890
2891/// The canonical memory binding is **opaque** (§7.8): it is not a tenant, not a namespace and not a
2892/// path. Host-facing observations still need to say which binding a fact belongs to, so the binding
2893/// id rides in the namespace slot and the tenant stays empty — the kernel derives no tenant because
2894/// the contract gives it none.
2895fn binding_scope(binding_id: &MemoryBindingId) -> crate::mm::memory::MemoryScope {
2896    crate::mm::memory::MemoryScope::new(String::new(), binding_id.as_str().to_string())
2897}
2898
2899/// The audit text of a host executor failure. Classification first, host prose second — a kernel
2900/// decision was already taken on the kind alone (§7.9), and this is only what the operator reads.
2901fn host_failure_text(failure: &HostEffectFailure) -> String {
2902    if failure.message.is_empty() {
2903        failure.kind.as_str().to_string()
2904    } else {
2905        format!("{}: {}", failure.kind.as_str(), failure.message)
2906    }
2907}
2908
2909/// A resolution for an effect this driver has no record of authoring. The transaction already
2910/// refuses one for an effect that is not pending, so reaching this means the driver's own ledger
2911/// and the journal disagree — a rebuild-from-records failure, not a host protocol error.
2912fn unowned_resolution(effect_id: &EffectId, what: &str) -> KernelFault {
2913    KernelFault::new(
2914        KernelFaultCode::RecordCorrupted,
2915        format!(
2916            "effect {effect_id} resolves a {what} this runtime never authored; the driver's ledger \
2917             no longer describes the journal — rebuild from the records"
2918        ),
2919    )
2920}
2921
2922fn truncate_on_char_boundary(text: &str, max_bytes: usize) -> String {
2923    if text.len() <= max_bytes {
2924        return text.to_string();
2925    }
2926    let mut end = max_bytes;
2927    while end > 0 && !text.is_char_boundary(end) {
2928        end -= 1;
2929    }
2930    text[..end].to_string()
2931}
2932
2933// ---------------------------------------------------------------------------------------------
2934// engine construction from the resolved configuration
2935// ---------------------------------------------------------------------------------------------
2936
2937/// Build the semantic kernel this operation runs on, from the configuration its genesis record
2938/// froze. Nothing here reads a compile-time default: every value comes off the record, which is
2939/// what makes a rebuild on a newer binary reproduce the same steps (§15.2).
2940fn build_engine(config: &ResolvedOperationConfig) -> LoopStateMachine {
2941    let execution = &config.execution_policy;
2942    let mut engine = LoopStateMachine::new(SchedulerBudget {
2943        max_tokens: execution.max_context_tokens,
2944        max_turns: execution.max_turns,
2945        max_total_tokens: execution.max_total_tokens.get(),
2946        max_wall_ms: execution.max_wall_ms.map(WireU64::get),
2947    });
2948    if let Some(grant) = config.budget_grant.clone() {
2949        engine.set_budget_grant(grant);
2950    }
2951    let scheduler_policy = config.scheduler_policy;
2952    engine.set_scheduler_policy(crate::scheduler::policy::SchedulerPolicyConfig {
2953        critical_path_weight: i64::from(scheduler_policy.critical_path_weight),
2954        fanout_weight: i64::from(scheduler_policy.fanout_weight),
2955        age_weight: i64::from(scheduler_policy.age_weight),
2956        token_cost_weight: i64::from(scheduler_policy.token_cost_weight),
2957        deadline_weight: i64::from(scheduler_policy.deadline_weight),
2958        process_priority_weight: i64::from(scheduler_policy.process_priority_weight),
2959        resource_pressure_weight: i64::from(scheduler_policy.resource_pressure_weight),
2960        budget_pressure_weight: i64::from(scheduler_policy.budget_pressure_weight),
2961    });
2962
2963    engine.set_criteria_gate(execution.criteria_gate_enabled);
2964    engine.set_repeat_fuse(crate::governance::repeat_fuse::RepeatFuseConfig {
2965        enabled: execution.repeat_fuse.enabled,
2966        deny_after: execution.repeat_fuse.deny_after,
2967        terminate_after: execution.repeat_fuse.terminate_after,
2968    });
2969    engine.set_entropy_watch(crate::scheduler::entropy::EntropyWatchConfig {
2970        enabled: execution.entropy_watch.enabled,
2971        threshold: f64::from(execution.entropy_watch.threshold_ppm.get()) / 1_000_000.0,
2972        hysteresis: f64::from(execution.entropy_watch.hysteresis_ppm.get()) / 1_000_000.0,
2973        cooldown_turns: execution.entropy_watch.cooldown_turns,
2974        notify_model: execution.entropy_watch.notify_model,
2975    });
2976    install_live_policies(&mut engine, config);
2977    engine
2978        .ctx
2979        .set_memory_enabled(config.feature_policy.memory_enabled);
2980    engine
2981        .ctx
2982        .set_knowledge_enabled(config.feature_policy.knowledge_enabled);
2983    engine
2984        .ctx
2985        .set_plan_tool_enabled(config.feature_policy.plan_tool_enabled);
2986    // §7.6 · the declared skill catalog is what makes `ActivateSkill` checkable: a name outside it
2987    // is a capability mutation with nothing behind it.
2988    engine
2989        .ctx
2990        .set_available_skills(config.skill_catalog.iter().map(core_skill).collect());
2991    engine.ctx.set_stable_core_tools(
2992        config
2993            .feature_policy
2994            .stable_core_tool_ids
2995            .iter()
2996            .map(|id| id.as_str().into()),
2997    );
2998    engine.ctx.config.knowledge_budget_ratio =
2999        config.context_policy.knowledge_budget_ppm.as_ratio();
3000    engine.ctx.config.collapse_assistant_narration =
3001        config.context_policy.collapse_old_assistant_narration;
3002    engine.tools = config.tool_catalog.iter().map(core_tool_schema).collect();
3003    engine
3004}
3005
3006/// Install the four §13.2 live-mutable policies onto an engine.
3007///
3008/// One installer, two callers: the genesis build and `HostCommand::ApplyPolicyPatch`. That is the
3009/// whole reason it exists — a patched policy that took a different code path into the engine than
3010/// the booted one is how "the same configuration means two things" starts.
3011///
3012/// A policy the operation never declared is deliberately **not** installed: §7.3's "the host never
3013/// said" is a value, distinct from an all-permissive policy the host did not state.
3014fn install_live_policies(engine: &mut LoopStateMachine, config: &ResolvedOperationConfig) {
3015    // §7.6 · the P1 gate is only a gate if the operation's declared caps actually reach it. Without
3016    // this the trap would allow every syscall on the canonical path regardless of what the genesis
3017    // record froze.
3018    if let Some(quota) = core_quota(&config.resource_quota) {
3019        engine.set_resource_quota(quota);
3020    }
3021    // The same argument for the tool gate: a governance policy the genesis record froze but the
3022    // engine never installed would make `RequestApproval` unpublishable and every declared rule
3023    // inert.
3024    if let Some(pipeline) = core_governance(&config.governance_policy) {
3025        engine.set_governance(pipeline);
3026    }
3027    engine.set_signal_policy(
3028        config.signal_policy.queue_max as usize,
3029        config.signal_policy.ttl_ms.map(WireU64::get),
3030        config.signal_policy.deadline_escalation,
3031    );
3032    // The two semantic ladders. Before this existed the resolved recovery policy was frozen into
3033    // the genesis record and then never reached the engine at all, so both the booted policy and
3034    // `ReplaceRecoveryPolicy` were inert and the engine's own compile-time defaults decided how
3035    // long a ladder ran — the exact "the record says one thing, the run does another" drift §15.2
3036    // forbids.
3037    engine.set_recovery_limits(
3038        config.recovery_policy.provider_recovery_attempts,
3039        config.recovery_policy.output_recovery_attempts,
3040    );
3041}
3042
3043/// `None` when the operation declared no axis at all. §7.3: "the host never said" is a value, and
3044/// it is *not* the same as an all-uncapped quota — an installed quota makes the workflow budget
3045/// observable, which is a statement the host did not make.
3046fn core_quota(
3047    quota: &super::config::ResourceQuota,
3048) -> Option<crate::governance::quota::ResourceQuota> {
3049    if quota == &super::config::ResourceQuota::default() {
3050        return None;
3051    }
3052    Some(crate::governance::quota::ResourceQuota {
3053        max_concurrent_subagents: quota.max_concurrent_subagents,
3054        max_total_subagents: quota.max_total_subagents,
3055        max_spawn_depth: quota.max_spawn_depth,
3056        memory_writes_per_window: quota
3057            .memory_writes_per_window
3058            .as_ref()
3059            .map(|window| (window.max_events, window.window_ms.get())),
3060        max_workflow_nodes: quota.max_workflow_nodes.map(|max| max as usize),
3061    })
3062}
3063
3064/// `None` when the operation declared no governance at all. Same "the host never said" rule as
3065/// [`core_quota`]: an installed all-allow pipeline is a statement the host did not make, and it
3066/// would silently change what a tool call means (every call would pass a gate that does not exist).
3067fn core_governance(
3068    policy: &super::config::ResolvedGovernancePolicy,
3069) -> Option<crate::governance::pipeline::GovernancePipeline> {
3070    use super::command::{ParamConstraint as WireConstraint, PolicyAction};
3071    use crate::governance::constraint::{ConstraintRule, ParamConstraint as CoreConstraint};
3072    use crate::governance::permission::PermissionRule;
3073    use crate::governance::rate_limit::RateLimit;
3074
3075    if policy.default_action == PolicyAction::Allow
3076        && policy.rules.is_empty()
3077        && policy.vetoed_tools.is_empty()
3078        && policy.rate_limits.is_empty()
3079        && policy.constraints.is_empty()
3080    {
3081        return None;
3082    }
3083    let mut pipeline = crate::governance::pipeline::GovernancePipeline::new(core_policy_action(
3084        policy.default_action,
3085    ));
3086    for rule in &policy.rules {
3087        pipeline.permission.add_rule(PermissionRule {
3088            tool_pattern: rule.tool_pattern.as_str().into(),
3089            action: core_policy_action(rule.action),
3090        });
3091    }
3092    for tool in &policy.vetoed_tools {
3093        pipeline.veto.block_tool(tool.clone());
3094    }
3095    for limit in &policy.rate_limits {
3096        pipeline.rate_limiter.set_limit(
3097            limit.tool.clone(),
3098            RateLimit {
3099                max_calls: limit.max_calls,
3100                window_ms: limit.window_ms.get(),
3101            },
3102        );
3103    }
3104    for constraint in &policy.constraints {
3105        let rule = match constraint {
3106            WireConstraint::Required(_) => ConstraintRule::Required,
3107            WireConstraint::Enum(spec) => ConstraintRule::Enum(spec.values.clone()),
3108            // §7.1.1 · the wire carries fixed-point micro-units so a bound is replayable; the
3109            // validator's own arithmetic is float, and this is the single conversion point.
3110            WireConstraint::Range(spec) => ConstraintRule::Range {
3111                min: spec.min_micros.map(|micros| micros as f64 / 1_000_000.0),
3112                max: spec.max_micros.map(|micros| micros as f64 / 1_000_000.0),
3113            },
3114        };
3115        pipeline.constraints.add(CoreConstraint {
3116            tool_name: constraint.tool().to_string(),
3117            param_path: constraint.param_path().to_string(),
3118            rule,
3119        });
3120    }
3121    Some(pipeline)
3122}
3123
3124fn core_policy_action(
3125    action: super::command::PolicyAction,
3126) -> crate::governance::permission::PermissionAction {
3127    use crate::governance::permission::PermissionAction;
3128    match action {
3129        super::command::PolicyAction::Allow => PermissionAction::Allow,
3130        super::command::PolicyAction::Deny => PermissionAction::Deny,
3131        super::command::PolicyAction::AskUser => PermissionAction::AskUser,
3132    }
3133}
3134
3135fn core_skill(skill: &super::config::SkillMetadata) -> crate::types::skill::SkillMetadata {
3136    crate::types::skill::SkillMetadata {
3137        name: skill.name.as_str().into(),
3138        description: skill.description.clone(),
3139        when_to_use: skill.when_to_use.clone(),
3140        allowed_tools: skill
3141            .allowed_tools
3142            .iter()
3143            .map(|tool| tool.as_str().into())
3144            .collect(),
3145        capability_grants: skill.capability_grants.clone(),
3146        effort: skill.effort,
3147        estimated_tokens: skill.estimated_tokens.unwrap_or(0),
3148    }
3149}
3150
3151fn ensure_skill_grants_are_attenuated(
3152    grants: &[crate::types::capability::Capability],
3153    parent_capabilities: &[crate::types::capability::Capability],
3154) -> Result<(), Vec<crate::types::capability::Capability>> {
3155    crate::types::capability::caps_subset(grants, parent_capabilities)
3156}
3157
3158fn skill_grant_attenuation_message(
3159    skill_name: &str,
3160    violations: &[crate::types::capability::Capability],
3161) -> String {
3162    format!(
3163        "skill {skill_name:?} declares capability grants that would widen the mounting agent's authority: {}",
3164        violations
3165            .iter()
3166            .map(|capability| capability.id.0.as_str())
3167            .collect::<Vec<_>>()
3168            .join(", ")
3169    )
3170}
3171
3172fn core_tool_schema(schema: &WireToolSchema) -> crate::types::message::ToolSchema {
3173    crate::types::message::ToolSchema {
3174        name: schema.name.as_str().into(),
3175        description: schema.description.clone(),
3176        parameters: schema.parameters.get().clone(),
3177    }
3178}
3179
3180fn malformed(error: super::scalar::WireScalarError) -> KernelFault {
3181    KernelFault::new(KernelFaultCode::MalformedEnvelope, error.message)
3182}
3183
3184#[cfg(test)]
3185mod tests;