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, &slot.tool_calls)?;
1228        ctx.partitions.knowledge.push_entry(
1229            slot.key.as_deref().map(Into::into),
1230            message,
1231            slot.tokens,
1232            slot.pinned,
1233        );
1234        if let Some(entry) = ctx.partitions.knowledge.entries.last_mut() {
1235            entry.evict_at_boundary = slot.evict_at_boundary;
1236            entry.use_count = slot.use_count;
1237            entry.last_used_step = slot.last_used_step;
1238            entry.pending = slot
1239                .pending
1240                .as_ref()
1241                .map(|pending| {
1242                    restore_message(&pending.role, &pending.body, &pending.tool_calls)
1243                        .map(|message| Box::new((message, pending.tokens)))
1244                })
1245                .transpose()?;
1246        }
1247    }
1248    ctx.partitions.system.measurements = state.system_measurements.clone();
1249    ctx.partitions.history.measurements = state.history_measurements.clone();
1250    ctx.restore_knowledge_checkpoint_state(
1251        state.knowledge_reference_step,
1252        state.knowledge_budget_warned,
1253    );
1254    ctx.partitions.signals = state.signals.clone();
1255    ctx.partitions.task_state = restore_task_state(&state.task_state);
1256    ctx.restore_state_generation(state.state_generation);
1257    ctx.last_activity_ms = state.last_activity_ms.get();
1258    ctx.last_compact_ms = state.last_compact_ms.map(WireU64::get);
1259    ctx.active_skills = state
1260        .active_skills
1261        .iter()
1262        .map(|lease| (lease.skill.as_str().into(), lease.lease_until_turn))
1263        .collect();
1264
1265    for handle in &state.handles {
1266        ctx.handles.insert(Handle {
1267            id: handle.handle_id,
1268            kind: restore_handle_kind(&handle.kind)?,
1269            residency: restore_residency(handle)?,
1270            tokens: handle.tokens,
1271            source: handle.source.as_deref().map(Into::into),
1272        });
1273    }
1274    ctx.restore_next_handle_id(state.next_handle_id);
1275    if !ctx.restore_frozen_history_len(state.frozen_history_len as usize) {
1276        return Err(incompatible(format!(
1277            "the checkpoint freezes {} history messages but restores only {}",
1278            state.frozen_history_len,
1279            ctx.partitions.history.messages.len()
1280        )));
1281    }
1282    Ok(())
1283}
1284
1285fn restore_message(
1286    role: &str,
1287    body: &StoredMessageBody,
1288    tool_calls: &[LogicalToolCall],
1289) -> Result<CoreMessage, KernelFault> {
1290    let role = role_from_label(role)
1291        .ok_or_else(|| incompatible(format!("the checkpoint carries message role {role:?}")))?;
1292    let content = match body {
1293        StoredMessageBody::Inline(inline) => message_content(
1294            inline.text.clone(),
1295            inline.tool_call_id.as_deref(),
1296            inline.is_error,
1297        ),
1298        StoredMessageBody::Reference(reference) => message_content(
1299            reference.preview.clone(),
1300            reference.tool_call_id.as_deref(),
1301            reference.is_error,
1302        ),
1303        StoredMessageBody::Structured(structured) => {
1304            if !structured.durable_tool_results.is_empty() {
1305                if structured.durable_content.is_some() {
1306                    return Err(incompatible(
1307                        "the checkpoint durable tool results must not carry another body form"
1308                            .to_string(),
1309                    ));
1310                }
1311                content_from_durable_tool_results(&structured.durable_tool_results).map_err(|error| incompatible(format!(
1312                    "the checkpoint carries durable tool results this runtime cannot restore: {error}"
1313                )))?
1314            } else if let Some(content) = &structured.durable_content {
1315                content.validate().map_err(|error| {
1316                    incompatible(format!(
1317                        "the checkpoint carries invalid durable content: {error}"
1318                    ))
1319                })?;
1320                content_from_durable(content).map_err(|error| incompatible(format!(
1321                    "the checkpoint carries durable content this runtime cannot restore: {error}"
1322                )))?
1323            } else {
1324                return Err(incompatible(
1325                    "the checkpoint structured message body has no content".to_string(),
1326                ));
1327            }
1328        }
1329    };
1330    Ok(CoreMessage {
1331        role,
1332        content,
1333        tool_calls: tool_calls
1334            .iter()
1335            .map(|call| {
1336                Ok(crate::types::message::ToolCall {
1337                    id: call.call_id.as_str().into(),
1338                    name: call.name.as_str().into(),
1339                    arguments: serde_json::from_str(&call.arguments).map_err(|error| {
1340                        incompatible(format!(
1341                            "tool call {} carries arguments that do not decode: {error}",
1342                            call.call_id
1343                        ))
1344                    })?,
1345                })
1346            })
1347            .collect::<Result<Vec<_>, KernelFault>>()?,
1348    })
1349}
1350
1351fn restore_handle_kind(label: &str) -> Result<HandleKind, KernelFault> {
1352    Ok(match label {
1353        "tool_result" => HandleKind::ToolResult,
1354        "memory_page" => HandleKind::MemoryPage,
1355        "knowledge_entry" => HandleKind::KnowledgeEntry,
1356        "sub_agent_join" => HandleKind::SubAgentJoin,
1357        other => {
1358            return Err(incompatible(format!(
1359                "the checkpoint carries handle kind {other:?}, which this kernel does not know"
1360            )));
1361        }
1362    })
1363}
1364
1365fn restore_residency(handle: &HandleState) -> Result<Residency, KernelFault> {
1366    let missing = |what: &str| {
1367        incompatible(format!(
1368            "handle {} is {} but carries no {what}",
1369            handle.handle_id, handle.residency
1370        ))
1371    };
1372    Ok(match handle.residency.as_str() {
1373        "resident" => Residency::Resident,
1374        "collapsed" => Residency::Collapsed,
1375        "external" => Residency::External {
1376            payload_ref: handle
1377                .payload_ref
1378                .clone()
1379                .ok_or_else(|| missing("locator"))?,
1380            digest: handle.digest.clone().ok_or_else(|| missing("digest"))?,
1381            original_size: handle
1382                .original_size
1383                .ok_or_else(|| missing("original size"))?
1384                .get(),
1385        },
1386        "paged_out" => Residency::PagedOut {
1387            payload_ref: handle
1388                .payload_ref
1389                .clone()
1390                .ok_or_else(|| missing("locator"))?,
1391            digest: handle.digest.clone().ok_or_else(|| missing("digest"))?,
1392        },
1393        other => {
1394            return Err(incompatible(format!(
1395                "the checkpoint carries residency {other:?}, which this kernel does not know"
1396            )));
1397        }
1398    })
1399}
1400
1401fn incompatible(message: String) -> KernelFault {
1402    KernelFault::new(KernelFaultCode::CheckpointIncompatible, message)
1403}
1404
1405fn project_task_state(state: &TaskState) -> LogicalTaskState {
1406    LogicalTaskState {
1407        goal: state.goal.clone(),
1408        criteria: state.criteria.clone(),
1409        plan: state
1410            .plan
1411            .iter()
1412            .map(|step| LogicalPlanStep {
1413                label: step.label.clone(),
1414                done: step.done,
1415            })
1416            .collect(),
1417        current_step: state.current_step.map(|index| index as u32),
1418        progress: state.progress.clone(),
1419        scratchpad: state.scratchpad.clone(),
1420        blocked_on: state.blocked_on.clone(),
1421        directives: state.directives.clone(),
1422        preserved_refs: state.preserved_refs.clone(),
1423        recent_actions: state.recent_actions.clone(),
1424        compression_log: state
1425            .compression_log
1426            .iter()
1427            .map(|entry| LogicalCompressionEntry {
1428                action: entry.action.clone(),
1429                summary: entry.summary.clone(),
1430            })
1431            .collect(),
1432        compression_log_dropped: WireU64::new(state.compression_log_dropped),
1433    }
1434}
1435
1436fn restore_task_state(state: &LogicalTaskState) -> TaskState {
1437    TaskState {
1438        goal: state.goal.clone(),
1439        criteria: state.criteria.clone(),
1440        plan: state
1441            .plan
1442            .iter()
1443            .map(|step| PlanStep {
1444                label: step.label.clone(),
1445                done: step.done,
1446            })
1447            .collect(),
1448        current_step: state.current_step.map(|index| index as usize),
1449        progress: state.progress.clone(),
1450        scratchpad: state.scratchpad.clone(),
1451        blocked_on: state.blocked_on.clone(),
1452        directives: state.directives.clone(),
1453        preserved_refs: state.preserved_refs.clone(),
1454        recent_actions: state.recent_actions.clone(),
1455        compression_log: state
1456            .compression_log
1457            .iter()
1458            .map(|entry| CompressionEntry {
1459                action: entry.action.clone(),
1460                summary: entry.summary.clone(),
1461            })
1462            .collect(),
1463        compression_log_dropped: state.compression_log_dropped.get(),
1464    }
1465}
1466
1467fn authority(message: &str) -> SyscallRefusal {
1468    SyscallRefusal::Fault(KernelFault::new(
1469        KernelFaultCode::InvalidAuthority,
1470        message.to_string(),
1471    ))
1472}
1473
1474fn denial_reason(disposition: &Disposition, fallback: &str) -> String {
1475    match disposition {
1476        Disposition::Deny { stage, reason } => format!("{stage}: {reason}"),
1477        Disposition::RateLimited { retry_after_ms } => {
1478            format!("rate limited; retry after {retry_after_ms}ms")
1479        }
1480        Disposition::Gate { reason, .. } => format!("awaiting approval: {reason}"),
1481        Disposition::Defer { slot } => format!("deferred at slot {slot}"),
1482        Disposition::Allow => fallback.to_string(),
1483    }
1484}
1485
1486/// What the kernel authored for a pending `PersistMemory` effect (§22.13).
1487#[derive(Debug, Clone, PartialEq)]
1488struct AuthoredMemoryWrite {
1489    binding_id: MemoryBindingId,
1490    name: String,
1491    kind: WireMemoryKind,
1492    size_bytes: u32,
1493}
1494
1495/// What the kernel authored for a pending `QueryMemory` effect.
1496#[derive(Debug, Clone, PartialEq)]
1497struct AuthoredMemoryQuery {
1498    binding_id: MemoryBindingId,
1499    text: String,
1500    requested_k: u32,
1501}
1502
1503/// What the kernel published for a pending `LoadPayload` effect (§7.10 rule 4).
1504#[derive(Debug, Clone, PartialEq)]
1505struct PendingPayloadLoad {
1506    /// The wire address of the handle — the tool `call_id` for an external result, the kernel-minted
1507    /// archive id for a paged-out one.
1508    handle_id: String,
1509    /// The digest the body has to reproduce. The kernel never saw the body, so this is the *only*
1510    /// thing that makes a restored payload the one that left.
1511    digest: String,
1512    /// Present only for [`Residency::External`], whose declared size the kernel admitted and can
1513    /// therefore hold the host to. A page-out archive is checked by digest alone.
1514    original_size: Option<u64>,
1515}
1516
1517/// What one admitted syscall produced.
1518#[derive(Debug, Default)]
1519struct SyscallOutcome {
1520    effects: Vec<KernelEffect>,
1521    /// Set only by `SubmitWorkflow`'s bootstrap arm — the one syscall that moves the focus.
1522    focus: Option<ExecutionFocus>,
1523    /// The DAG grew and still owes a spawn round. Honoured on the provider-tool path; on the
1524    /// child-completion path the completion's own drive produces the batch (§7.7 ordering).
1525    needs_workflow_round: bool,
1526    /// Optional structured response for read-like local syscalls.
1527    ack: Option<String>,
1528}
1529
1530/// Reduces the five canonical input classes onto the kernel's existing semantic mechanisms.
1531///
1532/// Use it as the plan function of [`KernelTransaction::prepare`](super::transaction::KernelTransaction::prepare):
1533///
1534/// ```ignore
1535/// let preparation = tx.prepare(&envelope, |ctx| driver.plan(ctx));
1536/// // ... host CAS-appends the record ...
1537/// let committed = tx.commit(&token, &head)?;
1538/// driver.note_committed(committed.step_seq)?;
1539/// ```
1540pub struct CanonicalOperationDriver {
1541    engine: Option<LoopStateMachine>,
1542    root_kind: Option<RootKind>,
1543    focus: Option<ExecutionFocus>,
1544    workflow_id: Option<WorkflowId>,
1545    /// Wire node identity by internal DAG index — the DAG the engine runs is index-addressed, the
1546    /// wire is not, and the mapping is what keeps a `SpawnTasks` effect nameable by the host.
1547    node_ids: Vec<NodeId>,
1548    /// Canonical source DAG in the same index order as `node_ids`. Checkpoint projection pairs it
1549    /// with the semantic node statuses without serializing the graph's private indexes.
1550    workflow_nodes: Vec<WireNode>,
1551    /// The attempt the kernel minted for each **live** task. §10.4: a host may not create or
1552    /// rewrite child identity through a resolution or a completion, so a completion that names an
1553    /// attempt this kernel never issued is refused rather than folded in. A completed attempt is
1554    /// removed here, which is what makes a second completion — and the `parent_requests` riding on
1555    /// it — a stale causation rather than a second helping of authority.
1556    attempts: BTreeMap<String, AttemptId>,
1557    /// §7.6 · the provider calls this operation is waiting on, by effect id. At most one is live
1558    /// (DEC-3); the map keys it so a resolution names its own call rather than "the current one".
1559    provider_calls: BTreeMap<EffectId, PendingProviderCall>,
1560    /// §22.13 · the memory records **this kernel authored** for its pending `PersistMemory`
1561    /// effects. The resolution reports these, never what the host echoes back: a receipt may carry
1562    /// its store's own locator and digest and nothing else, so no host reply can restate a name,
1563    /// kind, size, trust or provenance the kernel derived.
1564    pending_memory_writes: BTreeMap<EffectId, AuthoredMemoryWrite>,
1565    /// The same for `QueryMemory`: the query the kernel authored and the width it clamped.
1566    pending_memory_queries: BTreeMap<EffectId, AuthoredMemoryQuery>,
1567    /// §7.10 · the handle each pending `LoadPayload` addresses, with the digest the loaded body has
1568    /// to reproduce. Kept here rather than re-read from the handle table at resolution time, so a
1569    /// residency that moved in between cannot silently change what a page-in is verified against.
1570    pending_payload_loads: BTreeMap<EffectId, PendingPayloadLoad>,
1571    /// Tool call ids that already produced a syscall. A causation is spent once — replaying the
1572    /// same provider result with a fresh input id must not buy a second workflow append.
1573    consumed_calls: BTreeSet<String>,
1574    /// §13.2 / DEC-6 · the live-mutable half of the configuration, plus the revision two concurrent
1575    /// writers race on. Seeded from the genesis record's resolved configuration; only
1576    /// `HostCommand::ApplyPolicyPatch` moves it. Boot-only axes are read from `context.config`
1577    /// instead, which is why the transaction never needs a second copy of this.
1578    policy: Option<LivePolicyState>,
1579    /// §7.3 · the verification contract whose cascade is installed on the engine, if any.
1580    ///
1581    /// The engine's own `LoopAction::EvaluateMilestone` names only a phase, because internally
1582    /// there is one cascade and a phase id is enough. The wire needs the pair: `phase_id` is
1583    /// unique only within its contract, so `(contract_id, phase_id)` is the host's complete lookup
1584    /// key. The driver retains that contract id beside the semantic engine so the wire projection
1585    /// is total without duplicating host-owned verifier state inside the engine.
1586    loaded_contract_id: Option<String>,
1587    staged: Option<StagedFocus>,
1588    poison: Option<KernelFault>,
1589}
1590
1591impl Default for CanonicalOperationDriver {
1592    fn default() -> Self {
1593        Self::new()
1594    }
1595}
1596
1597impl CanonicalOperationDriver {
1598    pub fn new() -> Self {
1599        Self {
1600            engine: None,
1601            root_kind: None,
1602            focus: None,
1603            workflow_id: None,
1604            node_ids: Vec::new(),
1605            workflow_nodes: Vec::new(),
1606            attempts: BTreeMap::new(),
1607            provider_calls: BTreeMap::new(),
1608            pending_memory_writes: BTreeMap::new(),
1609            pending_memory_queries: BTreeMap::new(),
1610            pending_payload_loads: BTreeMap::new(),
1611            consumed_calls: BTreeSet::new(),
1612            policy: None,
1613            loaded_contract_id: None,
1614            staged: None,
1615            poison: None,
1616        }
1617    }
1618
1619    // ----- observers -----
1620
1621    /// The operation's root class. `None` until the root start commits; immutable afterwards.
1622    pub fn root_kind(&self) -> Option<RootKind> {
1623        self.root_kind
1624    }
1625
1626    /// Where control currently is. Moves only on a committed transition (§7.4).
1627    pub fn focus(&self) -> Option<&ExecutionFocus> {
1628        self.focus.as_ref()
1629    }
1630
1631    pub fn workflow_id(&self) -> Option<&WorkflowId> {
1632        self.workflow_id.as_ref()
1633    }
1634
1635    /// Return the kernel-issued live attempt for `task_id`.
1636    ///
1637    /// Bindings use this read-only projection to correlate a host completion with the live task
1638    /// attempt. The value comes from checkpointed kernel state; hosts must never synthesize it.
1639    pub fn attempt_id(&self, task_id: &str) -> Option<&AttemptId> {
1640        self.attempts.get(task_id)
1641    }
1642
1643    pub fn poison(&self) -> Option<&KernelFault> {
1644        self.poison.as_ref()
1645    }
1646
1647    /// Read-only access to the semantic engine, for tests and host projections.
1648    pub fn engine(&self) -> Option<&LoopStateMachine> {
1649        self.engine.as_ref()
1650    }
1651
1652    /// Where the driver's own fold says the operation is. The transaction stays the authority on
1653    /// lifecycle; this exists so a host projection never needs a second copy of the rule.
1654    pub fn lifecycle(&self) -> OperationLifecycle {
1655        match (self.engine.is_some(), self.root_kind) {
1656            (false, _) => OperationLifecycle::Created,
1657            (true, None) => OperationLifecycle::Configured,
1658            (true, Some(_)) => OperationLifecycle::Running,
1659        }
1660    }
1661}
1662
1663mod continuation;
1664mod effects;
1665mod events;
1666mod planning;
1667mod projection;
1668mod provider;
1669mod syscall;
1670
1671// ---------------------------------------------------------------------------------------------
1672// wire ⇄ semantic projections
1673// ---------------------------------------------------------------------------------------------
1674
1675fn root_task_id() -> TaskId {
1676    TaskId::new(ROOT_TASK_ID).expect("the root task id is a legal branded ref")
1677}
1678
1679// ---------------------------------------------------------------------------------------------
1680// §7.6 · the model-facing syscall surface
1681// ---------------------------------------------------------------------------------------------
1682
1683/// Tool names that reduce to a P1 syscall instead of to a host tool execution.
1684///
1685/// This list is what deletes §22.10's bypasses 4 and 5. Historically the SDK *removed*
1686/// `submit_workflow_nodes` / `start_workflow` from the tool loop, faked a tool result for the
1687/// model, and re-submitted the request as a separate kernel input with a submitter of its own
1688/// choosing — so the kernel never saw a `ProviderTool` causation at all. Here the names are the
1689/// kernel's, the arguments are decoded by the kernel, and the caller comes from the pending call.
1690///
1691/// F15 ruling (0.2.66, user-adjudicated — resolves the §7.6 SPEC-ISSUE): `RequestMemoryWrite`
1692/// has no entry here **by design** — the kernel gains no model-facing memory *write* surface.
1693/// `memory` is a search tool, long-term writes are extracted host-side (§22.13's 现状定位), and
1694/// the syscall's only caller channel is a child's `parent_requests` (child→parent only). If a
1695/// spec revision ever proposes a write surface, it must overturn this ruling explicitly.
1696pub const SYSCALL_TOOL_NAMES: &[&str] = &[
1697    "start_workflow",
1698    "submit_workflow_nodes",
1699    "skill",
1700    "update_plan",
1701    crate::context::manager::MEMORY_TOOL_NAME,
1702    crate::context::manager::READ_RESULT_TOOL_NAME,
1703    "send_message",
1704    "publish_channel",
1705    "receive_mailbox",
1706    "receive_channel",
1707    "read_object",
1708];
1709
1710fn is_syscall_tool(name: &str) -> bool {
1711    SYSCALL_TOOL_NAMES.contains(&name)
1712}
1713
1714/// Decode a recognised meta-tool call into its typed request.
1715///
1716/// A decode failure is a *rejection*, never a fault: the model wrote bad arguments, which is a
1717/// thing to answer with an audit fact rather than a host protocol violation.
1718fn decode_syscall(call: &WireToolCall) -> Result<SyscallRequest, SyscallRejection> {
1719    let arguments = call.arguments.get().clone();
1720    let name: &'static str = SYSCALL_TOOL_NAMES
1721        .iter()
1722        .copied()
1723        .find(|known| *known == call.name.as_str())
1724        .expect("only recognised syscall tools reach the decoder");
1725
1726    fn decode<T: serde::de::DeserializeOwned>(
1727        name: &'static str,
1728        arguments: serde_json::Value,
1729    ) -> Result<T, SyscallRejection> {
1730        serde_json::from_value(arguments)
1731            .map_err(|error| SyscallRejection::new(name, format!("malformed arguments: {error}")))
1732    }
1733
1734    match name {
1735        "start_workflow" => Ok(SyscallRequest::SubmitWorkflow(
1736            super::syscall::SubmitWorkflowRequest {
1737                spec: decode(name, arguments)?,
1738            },
1739        )),
1740        "submit_workflow_nodes" => {
1741            #[derive(serde::Deserialize)]
1742            struct Args {
1743                nodes: Vec<WireNode>,
1744            }
1745            let args: Args = decode(name, arguments)?;
1746            Ok(SyscallRequest::AppendWorkflowNodes(
1747                super::syscall::AppendWorkflowNodesRequest { nodes: args.nodes },
1748            ))
1749        }
1750        "skill" => {
1751            #[derive(serde::Deserialize)]
1752            struct Args {
1753                name: String,
1754                #[serde(default)]
1755                lease_turns: Option<u32>,
1756            }
1757            let args: Args = decode(name, arguments)?;
1758            Ok(SyscallRequest::ActivateSkill(
1759                super::syscall::ActivateSkillRequest {
1760                    name: args.name,
1761                    lease_turns: args.lease_turns,
1762                },
1763            ))
1764        }
1765        "update_plan" => Ok(SyscallRequest::UpdateTask(
1766            super::syscall::UpdateTaskRequest {
1767                update: decode(name, arguments)?,
1768            },
1769        )),
1770        crate::context::manager::MEMORY_TOOL_NAME => {
1771            #[derive(serde::Deserialize)]
1772            struct Args {
1773                #[serde(default)]
1774                query: String,
1775                #[serde(default)]
1776                kinds: Vec<WireMemoryKind>,
1777                #[serde(default)]
1778                top_k: Option<u32>,
1779            }
1780            let args: Args = decode(name, arguments)?;
1781            Ok(SyscallRequest::RequestMemoryQuery(
1782                super::syscall::RequestMemoryQueryRequest {
1783                    query: super::syscall::MemoryQueryProposal {
1784                        text: args.query,
1785                        kinds: args.kinds,
1786                        limit: args.top_k,
1787                    },
1788                },
1789            ))
1790        }
1791        crate::context::manager::READ_RESULT_TOOL_NAME => {
1792            #[derive(serde::Deserialize)]
1793            struct Args {
1794                call_id: String,
1795            }
1796            let args: Args = decode(name, arguments)?;
1797            let handle_id = super::scalar::HandleId::new(args.call_id).map_err(|error| {
1798                SyscallRejection::new(name, format!("malformed handle: {}", error.message))
1799            })?;
1800            Ok(SyscallRequest::PageIn(super::syscall::PageInRequest {
1801                handle_id,
1802            }))
1803        }
1804        "send_message" => Ok(SyscallRequest::SendMessage(decode(name, arguments)?)),
1805        "publish_channel" => Ok(SyscallRequest::PublishChannel(decode(name, arguments)?)),
1806        "receive_mailbox" => Ok(SyscallRequest::ReceiveMailbox(decode(name, arguments)?)),
1807        "receive_channel" => Ok(SyscallRequest::ReceiveChannel(decode(name, arguments)?)),
1808        "read_object" => Ok(SyscallRequest::ReadObject(decode(name, arguments)?)),
1809        other => unreachable!("unrecognised syscall tool {other}"),
1810    }
1811}
1812
1813/// The task a causation names. Both variants carry one, and neither lets a host choose it.
1814fn causation_task(causation: &SyscallCausation) -> TaskId {
1815    match causation {
1816        SyscallCausation::ProviderTool(provider) => provider.task_id.clone(),
1817        SyscallCausation::ChildAttempt(child) => child.task_id.clone(),
1818    }
1819}
1820
1821/// §7.6 · the authority families a quarantined caller may not touch. `None` ⇒ the request widens
1822/// nothing (a plan edit, a page-in of an address the caller already holds).
1823///
1824/// SPEC-ISSUE: §7.6 requires that "a quarantined task must not escalate through workflow append,
1825/// memory scope or capability mutation", but the canonical [`WorkflowNode`](super::root::WorkflowNode)
1826/// carries no trust level — the internal DAG has `NodeTrust::{Trusted,Quarantined}` and the wire
1827/// has no field for it. The refusal below is therefore complete but currently unreachable through
1828/// the contract: no canonical input can declare a node quarantined. Either §7.4's workflow node
1829/// grows a trust field, or §7.6 has to say where quarantine comes from.
1830fn privileged_family(request: &SyscallRequest) -> Option<&'static str> {
1831    match request {
1832        SyscallRequest::SubmitWorkflow(_) | SyscallRequest::AppendWorkflowNodes(_) => {
1833            Some("workflow")
1834        }
1835        SyscallRequest::RequestMemoryWrite(_) | SyscallRequest::RequestMemoryQuery(_) => {
1836            Some("memory")
1837        }
1838        SyscallRequest::ActivateSkill(_) => Some("capability"),
1839        SyscallRequest::SendMessage(_) | SyscallRequest::PublishChannel(_) => Some("ipc"),
1840        SyscallRequest::UpdateTask(_)
1841        | SyscallRequest::PageIn(_)
1842        | SyscallRequest::ReceiveMailbox(_)
1843        | SyscallRequest::ReceiveChannel(_)
1844        | SyscallRequest::ReadObject(_) => None,
1845    }
1846}
1847
1848fn core_task_update(update: &WireTaskUpdate) -> crate::context::task_state::TaskUpdate {
1849    crate::context::task_state::TaskUpdate {
1850        plan: update.plan.clone(),
1851        current_step: update.current_step.map(|step| step as usize),
1852        progress: update.progress.clone(),
1853        scratchpad: update.scratchpad.clone(),
1854        blocked_on: update.blocked_on.clone(),
1855        preserved_refs: update.preserved_refs.clone(),
1856        directives: update.directives.clone(),
1857    }
1858}
1859
1860fn mint_effect_id(operation_id: &OperationId, step_seq: WireU64, index: u32) -> EffectId {
1861    EffectId::new(format!("{operation_id}:step:{step_seq}:effect:{index}"))
1862        .expect("an operation-scoped effect id is always a legal branded ref")
1863}
1864
1865fn mint_workflow_id(operation_id: &OperationId, step_seq: WireU64) -> WorkflowId {
1866    WorkflowId::new(format!("{operation_id}:workflow:{step_seq}"))
1867        .expect("an operation-scoped workflow id is always a legal branded ref")
1868}
1869
1870/// `wf-node{N}` / `wf-node{N}-i{k}` → `N`. The internal DAG is index-addressed; the wire is not.
1871fn parse_node_index(agent_id: &str) -> Option<usize> {
1872    let rest = agent_id.strip_prefix("wf-node")?;
1873    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1874    digits.parse().ok()
1875}
1876
1877fn wire_node_ids(spec: &WireSpec) -> Vec<NodeId> {
1878    spec.nodes.iter().map(|node| node.node_id.clone()).collect()
1879}
1880
1881/// Wire DAG → the kernel's index-addressed DAG. Node identity is checked here: a duplicate id or a
1882/// dependency on a node the spec does not declare is refused before the engine sees the spec.
1883fn build_core_spec(spec: &WireSpec) -> Result<CoreWorkflowSpec, KernelFault> {
1884    let mut index_of: BTreeMap<&str, usize> = BTreeMap::new();
1885    for (index, node) in spec.nodes.iter().enumerate() {
1886        if index_of.insert(node.node_id.as_str(), index).is_some() {
1887            return Err(KernelFault::new(
1888                KernelFaultCode::InvalidConfig,
1889                format!(
1890                    "workflow node id {:?} appears twice; node identity is unique within a DAG",
1891                    node.node_id
1892                ),
1893            ));
1894        }
1895    }
1896    let mut nodes = Vec::with_capacity(spec.nodes.len());
1897    for node in &spec.nodes {
1898        let role = node
1899            .run_spec
1900            .as_ref()
1901            .and_then(|spec| spec.role)
1902            .map_or(AgentRole::Custom, core_role);
1903        let mut core = CoreWorkflowNode::new(runtime_task(&node.task), role);
1904        if let Some(isolation) = node.run_spec.as_ref().and_then(|spec| spec.isolation) {
1905            core = core.with_isolation(core_isolation(isolation));
1906        }
1907        if let Some(inheritance) = node
1908            .run_spec
1909            .as_ref()
1910            .and_then(|spec| spec.context_inheritance)
1911        {
1912            core.context_inheritance = core_context_inheritance(inheritance);
1913        }
1914        if let Some(metadata) = node
1915            .run_spec
1916            .as_ref()
1917            .map(|spec| spec.metadata.get())
1918            .and_then(serde_json::Value::as_object)
1919        {
1920            if let Some(model_hint) = metadata
1921                .get("model_hint")
1922                .and_then(serde_json::Value::as_str)
1923            {
1924                core = core.with_model_hint(model_hint);
1925            }
1926            if let Some(output_schema) = metadata.get("output_schema") {
1927                core = core.with_output_schema(output_schema.clone());
1928            }
1929            // spc_008-01: fine-grained capability requests, smuggled through the same generic
1930            // `metadata` escape hatch `model_hint`/`output_schema` already use rather than a new
1931            // dedicated wire field. Fails closed on malformed input rather than silently treating
1932            // it as "no capability requested" — a security-relevant declaration must not downgrade
1933            // itself into a no-op check on a parse error.
1934            if let Some(requested) = metadata.get("requested_capabilities") {
1935                let capabilities: Vec<crate::types::capability::Capability> =
1936                    serde_json::from_value(requested.clone()).map_err(|error| {
1937                        KernelFault::new(
1938                            KernelFaultCode::InvalidConfig,
1939                            format!(
1940                                "workflow node {:?} metadata.requested_capabilities is malformed: {error}",
1941                                node.node_id
1942                            ),
1943                        )
1944                    })?;
1945                core = core.with_requested_capabilities(capabilities);
1946            }
1947            // spc_008-02: same escape-hatch pattern, same fail-closed convention, for the
1948            // hierarchical budget grant a node's spawn requests.
1949            if let Some(requested) = metadata.get("requested_budget") {
1950                let budget: crate::scheduler::budget_grant::ResourceBudget =
1951                    serde_json::from_value(requested.clone()).map_err(|error| {
1952                        KernelFault::new(
1953                            KernelFaultCode::InvalidConfig,
1954                            format!(
1955                                "workflow node {:?} metadata.requested_budget is malformed: {error}",
1956                                node.node_id
1957                            ),
1958                        )
1959                    })?;
1960                core = core.with_requested_budget(budget);
1961            }
1962            if let Some(factors) = metadata.get("scheduling_factors") {
1963                let factors: crate::orchestration::task_graph::SchedulingFactors =
1964                    serde_json::from_value(factors.clone()).map_err(|error| {
1965                        KernelFault::new(
1966                            KernelFaultCode::InvalidConfig,
1967                            format!(
1968                                "workflow node {:?} metadata.scheduling_factors is malformed: {error}",
1969                                node.node_id
1970                            ),
1971                        )
1972                    })?;
1973                core = core.with_scheduling_factors(factors);
1974            }
1975        }
1976        let mut depends_on = Vec::with_capacity(node.depends_on.len());
1977        for dependency in &node.depends_on {
1978            let Some(&index) = index_of.get(dependency.as_str()) else {
1979                return Err(KernelFault::new(
1980                    KernelFaultCode::InvalidConfig,
1981                    format!(
1982                        "workflow node {:?} depends on {:?}, which this DAG does not declare",
1983                        node.node_id, dependency
1984                    ),
1985                ));
1986            };
1987            depends_on.push(index);
1988        }
1989        nodes.push(core.with_depends_on(depends_on));
1990    }
1991    let core = CoreWorkflowSpec::new(nodes);
1992    core.validate()
1993        .map_err(|error| KernelFault::new(KernelFaultCode::InvalidConfig, error.to_string()))?;
1994    Ok(core)
1995}
1996
1997fn runtime_task(task: &LogicalTask) -> RuntimeTask {
1998    RuntimeTask {
1999        goal: task.goal.clone(),
2000        criteria: task.criteria.clone(),
2001        metadata: task.metadata.get().clone(),
2002        lane: task.lane.as_ref().map(TaskLane::new).unwrap_or_default(),
2003    }
2004}
2005
2006/// The logical spec carries no host session identity, so its internal identity carries none.
2007fn agent_run_spec(spec: &LogicalAgentSpec) -> AgentRunSpec {
2008    AgentRunSpec {
2009        identity: AgentIdentity::new(ROOT_TASK_ID, NO_HOST_SESSION),
2010        role: spec.role.map_or(AgentRole::Custom, core_role),
2011        isolation: spec
2012            .isolation
2013            .map_or(AgentIsolation::Shared, core_isolation),
2014        goal: spec.goal.clone(),
2015        verification_contract_id: spec.verification_contract_id.as_deref().map(Into::into),
2016        capability_filter: AgentCapabilityFilter {
2017            allowed_kinds: spec
2018                .capability_filter
2019                .allowed_kinds
2020                .iter()
2021                .copied()
2022                .map(core_capability_kind)
2023                .collect(),
2024            allowed_ids: spec
2025                .capability_filter
2026                .allowed_ids
2027                .iter()
2028                .map(|id| id.as_str().into())
2029                .collect(),
2030        },
2031        milestones: None,
2032        metadata: spec.metadata.get().clone(),
2033        loop_round: spec.loop_round.as_ref().map(|round| LoopRoundSpec {
2034            max_rounds: round.max_rounds,
2035            min_sleep_ms: round.min_sleep_ms.map(WireU64::get),
2036            max_sleep_ms: round.max_sleep_ms.map(WireU64::get),
2037            default_action: round.default_action.clone(),
2038        }),
2039        exposure_baseline: spec
2040            .exposure_baseline
2041            .as_ref()
2042            .map(|ids| ids.iter().map(|id| id.as_str().into()).collect()),
2043        requested_capabilities: Vec::new(),
2044        requested_budget: None,
2045    }
2046}
2047
2048fn logical_agent_run_spec(spec: &AgentRunSpec) -> LogicalAgentSpec {
2049    LogicalAgentSpec {
2050        goal: spec.goal.clone(),
2051        role: match spec.role {
2052            AgentRole::Custom => None,
2053            AgentRole::Explore => Some(WireRole::Explore),
2054            AgentRole::Plan => Some(WireRole::Plan),
2055            AgentRole::Implement => Some(WireRole::Implement),
2056            AgentRole::Verify => Some(WireRole::Verify),
2057        },
2058        isolation: match spec.isolation {
2059            AgentIsolation::Shared => None,
2060            AgentIsolation::ReadOnly => Some(WireIsolation::ReadOnly),
2061            AgentIsolation::Worktree => Some(WireIsolation::Worktree),
2062            AgentIsolation::Remote => Some(WireIsolation::Remote),
2063        },
2064        context_inheritance: None,
2065        verification_contract_id: spec
2066            .verification_contract_id
2067            .as_ref()
2068            .map(ToString::to_string),
2069        capability_filter: super::root::CapabilityFilter {
2070            allowed_kinds: spec
2071                .capability_filter
2072                .allowed_kinds
2073                .iter()
2074                .copied()
2075                .map(wire_capability_kind)
2076                .collect(),
2077            allowed_ids: spec
2078                .capability_filter
2079                .allowed_ids
2080                .iter()
2081                .map(ToString::to_string)
2082                .collect(),
2083        },
2084        exposure_baseline: spec
2085            .exposure_baseline
2086            .as_ref()
2087            .map(|ids| ids.iter().map(ToString::to_string).collect()),
2088        loop_round: spec
2089            .loop_round
2090            .as_ref()
2091            .map(|round| super::root::LogicalLoopRoundSpec {
2092                max_rounds: round.max_rounds,
2093                min_sleep_ms: round.min_sleep_ms.map(WireU64::new),
2094                max_sleep_ms: round.max_sleep_ms.map(WireU64::new),
2095                default_action: round.default_action.clone(),
2096            }),
2097        metadata: super::scalar::BoundedJson::new(spec.metadata.clone())
2098            .expect("canonical run metadata remains bounded"),
2099    }
2100}
2101
2102fn core_capability_kind(
2103    kind: super::root::CapabilityKind,
2104) -> crate::types::capability::CapabilityKind {
2105    use super::root::CapabilityKind as Wire;
2106    use crate::types::capability::CapabilityKind as Core;
2107    match kind {
2108        Wire::Tool => Core::Tool,
2109        Wire::Skill => Core::Skill,
2110        Wire::Memory => Core::Memory,
2111        Wire::Knowledge => Core::Knowledge,
2112        Wire::McpServer => Core::McpServer,
2113        Wire::Command => Core::Command,
2114        Wire::Agent => Core::Agent,
2115    }
2116}
2117
2118fn wire_capability_kind(
2119    kind: crate::types::capability::CapabilityKind,
2120) -> super::root::CapabilityKind {
2121    use super::root::CapabilityKind as Wire;
2122    use crate::types::capability::CapabilityKind as Core;
2123    match kind {
2124        Core::Tool => Wire::Tool,
2125        Core::Skill => Wire::Skill,
2126        Core::Memory => Wire::Memory,
2127        Core::Knowledge => Wire::Knowledge,
2128        Core::McpServer => Wire::McpServer,
2129        Core::Command => Wire::Command,
2130        Core::Agent => Wire::Agent,
2131    }
2132}
2133
2134fn core_role(role: WireRole) -> AgentRole {
2135    match role {
2136        WireRole::Explore => AgentRole::Explore,
2137        WireRole::Plan => AgentRole::Plan,
2138        WireRole::Implement => AgentRole::Implement,
2139        WireRole::Verify => AgentRole::Verify,
2140        WireRole::Custom => AgentRole::Custom,
2141    }
2142}
2143
2144fn core_isolation(isolation: WireIsolation) -> AgentIsolation {
2145    match isolation {
2146        WireIsolation::Shared => AgentIsolation::Shared,
2147        WireIsolation::ReadOnly => AgentIsolation::ReadOnly,
2148        WireIsolation::Worktree => AgentIsolation::Worktree,
2149        WireIsolation::Remote => AgentIsolation::Remote,
2150    }
2151}
2152
2153fn core_context_inheritance(inheritance: WireContextInheritance) -> ContextInheritance {
2154    match inheritance {
2155        WireContextInheritance::None => ContextInheritance::None,
2156        WireContextInheritance::SystemOnly => ContextInheritance::SystemOnly,
2157        WireContextInheritance::Full => ContextInheritance::Full,
2158    }
2159}
2160
2161/// Internal role/isolation labels back onto the wire vocabulary. `None` is the *absent* field, not
2162/// a parse failure: `custom`/`shared` are the wire defaults, so omitting them keeps a launch spec
2163/// minimal instead of restating what the contract already implies.
2164fn parse_wire_role(label: &str) -> Option<WireRole> {
2165    match label {
2166        "explore" => Some(WireRole::Explore),
2167        "plan" => Some(WireRole::Plan),
2168        "implement" => Some(WireRole::Implement),
2169        "verify" => Some(WireRole::Verify),
2170        _ => None,
2171    }
2172}
2173
2174fn parse_wire_isolation(label: &str) -> Option<WireIsolation> {
2175    match label {
2176        "read_only" => Some(WireIsolation::ReadOnly),
2177        "worktree" => Some(WireIsolation::Worktree),
2178        "remote" => Some(WireIsolation::Remote),
2179        _ => None,
2180    }
2181}
2182
2183fn parse_wire_context_inheritance(label: &str) -> Option<WireContextInheritance> {
2184    match label {
2185        "none" => Some(WireContextInheritance::None),
2186        "system_only" => Some(WireContextInheritance::SystemOnly),
2187        "full" => Some(WireContextInheritance::Full),
2188        _ => None,
2189    }
2190}
2191
2192/// §7.4 · seed the P3 context partitions from the one initial context the start carried. This is
2193/// the whole of what used to be a dozen separate accepted inputs.
2194fn seed_initial_context(engine: &mut LoopStateMachine, initial: &InitialContext) {
2195    if !initial.messages.is_empty() {
2196        engine.preload_history(initial.messages.iter().map(logical_message).collect());
2197    }
2198    seed_knowledge(engine, &initial.knowledge);
2199    if !initial.requested_capabilities.is_empty() {
2200        engine.set_requested_capabilities(initial.requested_capabilities.clone());
2201    }
2202}
2203
2204/// The one place wire knowledge entries enter the P3 knowledge partition.
2205///
2206/// Shared by the initial context (§7.4), `HostCommand::SeedKnowledge` (DEC-9) and the upsert half
2207/// of `HostCommand::ApplyKnowledgeMutation` (§13.2), so the three cannot drift in how a keyed,
2208/// pinned or token-counted entry is stored.
2209fn seed_knowledge(engine: &mut LoopStateMachine, entries: &[super::root::KnowledgeEntry]) {
2210    if entries.is_empty() {
2211        return;
2212    }
2213    let entries: Vec<crate::mm::PageInEntry> = entries
2214        .iter()
2215        .map(|entry| crate::mm::PageInEntry {
2216            content: entry.content.clone(),
2217            tokens: entry.tokens,
2218            source: None,
2219            key: entry.key.clone(),
2220            pinned: entry.pinned,
2221        })
2222        .collect();
2223    engine.apply_page_in(&entries);
2224}
2225
2226/// §7.7 · project one logical signal onto the runtime signal the in-kernel router works with.
2227///
2228/// Three rules are load-bearing:
2229///
2230/// * the **business** signal id travels verbatim. It is what the disposition, expiry and
2231///   displacement audit facts name, so a derived id would report an identity no caller ever wrote;
2232/// * `timestamp_ms` is the **envelope's accepted time**, not the signal's `source_timestamp_ms`.
2233///   The source timestamp is audit metadata and stays out of every admission decision (§11.2);
2234/// * absent optional fields mean "the author did not say", not a default urgency or source that
2235///   would change how the signal is scheduled;
2236/// * `escalate_after_ms` is a **duration** the kernel anchors to that same accepted time. That is
2237///   what closes the old gap where §13.2 admitted `SignalPolicy.deadline_escalation` while §7.7
2238///   carried nothing that could ever come due, leaving the whole escalation axis unreachable
2239///   (Task 14 · adjudication §5n item 1). The kernel still invents no deadline from
2240///   `source_timestamp_ms`, which is not a clock (§11.2).
2241///
2242/// SPEC-ISSUE (task-targeted routing): §7.7 defines the address space (operation or logical task)
2243/// but no per-task attention semantics, and core holds **one** router per operation. A validated
2244/// task target therefore lands in the operation's queue rather than a queue of its own. Either
2245/// §7.7 states that the target is audit-only addressing, or per-task queues need a contract.
2246fn runtime_signal(
2247    signal: &LogicalSignal,
2248    accepted_at_ms: WireU64,
2249) -> crate::types::signal::RuntimeSignal {
2250    use crate::types::signal::{RuntimeSignal, SignalSource, SignalType, Urgency};
2251
2252    let source = match signal.source {
2253        Some(SignalSourceKind::Cron) => SignalSource::Cron,
2254        Some(SignalSourceKind::Gateway) => SignalSource::Gateway,
2255        Some(SignalSourceKind::Heartbeat) => SignalSource::Heartbeat,
2256        Some(SignalSourceKind::Custom) | None => SignalSource::Custom,
2257    };
2258    let urgency = match signal.urgency {
2259        Some(SignalUrgency::Low) => Urgency::Low,
2260        Some(SignalUrgency::High) => Urgency::High,
2261        Some(SignalUrgency::Critical) => Urgency::Critical,
2262        Some(SignalUrgency::Normal) | None => Urgency::Normal,
2263    };
2264    let mut runtime = RuntimeSignal::new(
2265        source,
2266        // `signal_type` is deliberately not on the canonical wire (adjudication §5n item 3):
2267        // urgency already expresses priority, nothing branches on the router's event/job/alert
2268        // distinction, and a second axis that changes no decision is one more thing four hosts
2269        // would have to agree about. Every canonical signal enters as an event.
2270        SignalType::Event,
2271        urgency,
2272        signal_summary(signal),
2273    )
2274    .with_id(signal.signal_id.as_str())
2275    .with_payload(signal.payload.get().clone())
2276    .with_timestamp(accepted_at_ms.get());
2277    if let Some(key) = &signal.dedupe_key {
2278        runtime = runtime.with_dedupe(key.as_str());
2279    }
2280    // §7.7 · `escalate_after_ms` is a duration; the router works in instants. Anchoring it to the
2281    // envelope's accepted time here is the whole point of carrying a duration on the wire: the
2282    // same bytes redelivered produce the same deadline relative to *this* admission, and no host
2283    // clock ever enters the payload (DEC-2).
2284    if let Some(after) = signal.escalate_after_ms {
2285        runtime = runtime.with_deadline(accepted_at_ms.get().saturating_add(after.get()));
2286    }
2287    runtime
2288}
2289
2290/// The model-facing one-liner a queued or interrupting signal becomes.
2291///
2292/// §7.7 carries a payload and no summary, so the summary is derived — deterministically, because a
2293/// replay must produce the same context bytes. A JSON string payload is its own summary; anything
2294/// else is its canonical serialization, bounded.
2295fn signal_summary(signal: &LogicalSignal) -> String {
2296    const SIGNAL_SUMMARY_MAX_BYTES: usize = 512;
2297    match signal.payload.get() {
2298        serde_json::Value::Null => signal.signal_id.as_str().to_string(),
2299        serde_json::Value::String(text) => {
2300            truncate_on_char_boundary(text, SIGNAL_SUMMARY_MAX_BYTES)
2301        }
2302        other => truncate_on_char_boundary(&other.to_string(), SIGNAL_SUMMARY_MAX_BYTES),
2303    }
2304}
2305
2306fn live_policy_label(patch: &super::command::LivePolicyPatch) -> &'static str {
2307    use super::command::LivePolicyPatch;
2308    match patch {
2309        LivePolicyPatch::ReplaceSignalPolicy(_) => "signal",
2310        LivePolicyPatch::ReplaceGovernancePolicy(_) => "governance",
2311        LivePolicyPatch::TightenResourceQuota(_) => "resource_quota",
2312        LivePolicyPatch::ReplaceRecoveryPolicy(_) => "recovery",
2313    }
2314}
2315
2316fn logical_message(message: &super::root::LogicalMessage) -> CoreMessage {
2317    CoreMessage {
2318        role: core_role_of(message.role),
2319        content: Content::Text(message.content.clone()),
2320        tool_calls: Vec::new(),
2321    }
2322}
2323
2324fn core_role_of(role: MessageRole) -> Role {
2325    match role {
2326        MessageRole::System => Role::System,
2327        MessageRole::User => Role::User,
2328        MessageRole::Assistant => Role::Assistant,
2329        MessageRole::Tool => Role::Tool,
2330    }
2331}
2332
2333fn wire_role_of(role: Role) -> MessageRole {
2334    match role {
2335        Role::System => MessageRole::System,
2336        Role::User => MessageRole::User,
2337        Role::Assistant => MessageRole::Assistant,
2338        Role::Tool => MessageRole::Tool,
2339    }
2340}
2341
2342fn rendered_context(
2343    context: &crate::context::renderer::InternalRenderedContext,
2344) -> WireRenderedContext {
2345    WireRenderedContext {
2346        system_stable: context.system_stable.clone(),
2347        system_knowledge: context.system_knowledge.clone(),
2348        turns: context.turns.iter().map(provider_message).collect(),
2349        state_turn: context.state_turn.as_ref().map(provider_message),
2350        frozen_prefix_len: context.frozen_prefix_len.map(|len| len as u32),
2351    }
2352}
2353
2354fn provider_message(message: &CoreMessage) -> ProviderMessage {
2355    let (content, tool_call_id) = match &message.content {
2356        Content::Parts(parts) => match parts.as_slice() {
2357            [
2358                ContentPart::ToolResult {
2359                    call_id, output, ..
2360                },
2361            ] => (output.clone(), Some(call_id.to_string())),
2362            _ => message_body_parts(message)
2363                .map(|(text, tool_call_id, _is_error)| (text, tool_call_id))
2364                .unwrap_or_default(),
2365        },
2366        Content::Text(_) => message_body_parts(message)
2367            .map(|(text, tool_call_id, _is_error)| (text, tool_call_id))
2368            .unwrap_or_default(),
2369    };
2370    ProviderMessage {
2371        role: wire_role_of(message.role),
2372        content,
2373        tool_calls: message
2374            .tool_calls
2375            .iter()
2376            .filter_map(|call| wire_tool_call(call).ok())
2377            .collect(),
2378        tool_call_id: tool_call_id.and_then(|call_id| super::scalar::CallId::new(call_id).ok()),
2379        tokens: None,
2380    }
2381}
2382
2383fn tool_schema(schema: &crate::types::message::ToolSchema) -> WireToolSchema {
2384    WireToolSchema {
2385        name: schema.name.to_string(),
2386        description: schema.description.clone(),
2387        parameters: super::scalar::BoundedJson::new(schema.parameters.clone())
2388            .unwrap_or_else(|_| Default::default()),
2389    }
2390}
2391
2392fn workflow_budget(budget: &crate::orchestration::workflow::WorkflowBudget) -> WireWorkflowBudget {
2393    WireWorkflowBudget {
2394        max_total_tokens: budget.tokens_max.map(WireU64::new),
2395        max_turns: None,
2396        max_concurrency: budget.max_concurrent_subagents.map(|max| max as u32),
2397    }
2398}
2399
2400fn sub_agent_result(completed: &ChildCompleted) -> SubAgentResult {
2401    let termination = match completed.result.status {
2402        ChildStatus::Completed => TerminationReason::Completed,
2403        ChildStatus::Failed => TerminationReason::Error,
2404        ChildStatus::Cancelled => TerminationReason::UserAbort,
2405    };
2406    SubAgentResult {
2407        agent_id: completed.task_id.as_str().into(),
2408        result: LoopResult {
2409            termination,
2410            final_message: completed
2411                .result
2412                .output
2413                .as_ref()
2414                .map(|text| CoreMessage::assistant(text.clone())),
2415            turns_used: completed
2416                .result
2417                .usage
2418                .as_ref()
2419                .and_then(|usage| usage.turns)
2420                .unwrap_or(0),
2421            total_tokens_used: completed
2422                .result
2423                .usage
2424                .as_ref()
2425                .and_then(|usage| usage.output_tokens)
2426                .map_or(0, WireU64::get),
2427            loop_continue: None,
2428            classify_branch: None,
2429            pace_decision: None,
2430            tournament_winner: None,
2431        },
2432    }
2433}
2434
2435fn attempt_ordinal(attempt_id: &AttemptId) -> Option<u32> {
2436    attempt_id.as_str().rsplit(':').next()?.parse().ok()
2437}
2438
2439fn supervision_label(policy: crate::scheduler::tcb::ChildFailurePolicy) -> &'static str {
2440    match policy {
2441        crate::scheduler::tcb::ChildFailurePolicy::Propagate => "propagate",
2442        crate::scheduler::tcb::ChildFailurePolicy::Isolate => "isolate",
2443        crate::scheduler::tcb::ChildFailurePolicy::Restart => "restart",
2444        crate::scheduler::tcb::ChildFailurePolicy::Retry => "retry",
2445        crate::scheduler::tcb::ChildFailurePolicy::Ignore => "ignore",
2446    }
2447}
2448
2449/// §7.12 · how an agent loop's own termination reason becomes an operation terminal.
2450///
2451/// The internal vocabulary has two reasons the wire's `TerminationReason` deliberately does not
2452/// carry: `user_abort` **is** a `Cancelled` terminal and `error` **is** a `Failed` one. Folding
2453/// either back into `Completed` would give the same event two representations, which is exactly
2454/// what the canonical union removed.
2455fn agent_terminal(result: &LoopResult) -> KernelTerminal {
2456    let usage = UsageReport {
2457        input_tokens: WireU64::new(result.total_tokens_used),
2458        output_tokens: WireU64::ZERO,
2459        turns: result.turns_used,
2460        cached_input_tokens: None,
2461    };
2462    let termination = match result.termination {
2463        TerminationReason::Completed => WireTermination::Completed,
2464        TerminationReason::MaxTurns => WireTermination::MaxTurns,
2465        TerminationReason::TokenBudget => WireTermination::TokenBudget,
2466        TerminationReason::Timeout => WireTermination::Deadline,
2467        TerminationReason::ContextOverflow => WireTermination::ContextOverflow,
2468        TerminationReason::NoProgress => WireTermination::NoProgress,
2469        TerminationReason::MilestoneExceeded => WireTermination::MilestoneExceeded,
2470        TerminationReason::UserAbort => {
2471            return KernelTerminal::Cancelled(CancelledTerminal {
2472                reason: CancellationReason::User,
2473                usage,
2474            });
2475        }
2476        TerminationReason::Error => {
2477            return KernelTerminal::Failed(FailedTerminal {
2478                failure: KernelFailure {
2479                    code: KernelFailureCode::InvariantViolated,
2480                    message: "the agent loop ended in an error state".to_string(),
2481                },
2482                usage,
2483            });
2484        }
2485    };
2486    KernelTerminal::Agent(AgentTerminal {
2487        result: WireLoopResult {
2488            termination,
2489            final_message: result.final_message.as_ref().map(provider_message),
2490            turns_used: result.turns_used,
2491            pace_decision: result.pace_decision.as_ref().map(|decision| {
2492                super::terminal::PaceDecision {
2493                    action: match decision.action {
2494                        CorePaceAction::Continue => super::terminal::PaceAction::Continue,
2495                        CorePaceAction::Sleep => super::terminal::PaceAction::Sleep,
2496                        CorePaceAction::Stop => super::terminal::PaceAction::Stop,
2497                    },
2498                    delay_ms: decision.delay_ms.map(WireU64::new),
2499                    reason: decision.reason.clone(),
2500                    coerced_from: decision.coerced_from.clone(),
2501                }
2502            }),
2503        },
2504        usage,
2505    })
2506}
2507
2508fn publishes(disposition: &StepDisposition, tag: EffectKindTag) -> bool {
2509    disposition
2510        .effects()
2511        .iter()
2512        .any(|effect| effect.tag() == tag)
2513}
2514
2515fn loop_action_label(action: &LoopAction) -> &'static str {
2516    match action {
2517        LoopAction::CallLLM { .. } => "call_provider",
2518        LoopAction::ExecuteTools { .. } => "execute_tools",
2519        LoopAction::RequestApproval { .. } => "request_approval",
2520        LoopAction::SpawnWorkflow { .. } => "spawn_tasks",
2521        LoopAction::PreemptSubAgents { .. } => "preempt_tasks",
2522        LoopAction::PersistMemory { .. } => "persist_memory",
2523        LoopAction::QueryMemory { .. } => "query_memory",
2524        LoopAction::ArchivePageOut { .. } => "archive_page_out",
2525        LoopAction::EvaluateMilestone { .. } => "evaluate_milestone",
2526        LoopAction::Done { .. } => "terminal",
2527        LoopAction::AwaitingResume => "awaiting_resume",
2528    }
2529}
2530
2531/// The model-facing answer to a P1 syscall the kernel executed.
2532///
2533/// 下一请求信息最大化: each says what happened *and* where the consequence will show up, so the
2534/// model's next turn does not have to guess whether a control-plane call took effect.
2535fn syscall_ack(name: &str) -> &'static str {
2536    match name {
2537        "start_workflow" => {
2538            "workflow accepted: its ready nodes are scheduled; each result arrives as that node \
2539             completes"
2540        }
2541        "submit_workflow_nodes" => {
2542            "nodes appended to the running workflow; each result arrives as that node completes"
2543        }
2544        "skill" => "skill activated: its guidance and tools are in this turn's context",
2545        "update_plan" => "plan updated: the new state renders in [TASK STATE] from here on",
2546        crate::context::manager::MEMORY_TOOL_NAME => {
2547            "memory search issued: matching records are added to this conversation before your \
2548             next turn"
2549        }
2550        crate::context::manager::READ_RESULT_TOOL_NAME => "page-in requested",
2551        "send_message" | "publish_channel" => "local handle routed",
2552        "receive_mailbox" | "receive_channel" | "read_object" => "local state returned",
2553        _ => "accepted",
2554    }
2555}
2556
2557fn validate_ipc_labels(message_id: &str, kind: &str) -> Result<(), SyscallRefusal> {
2558    if message_id.is_empty() || kind.is_empty() || message_id.len() > 256 || kind.len() > 256 {
2559        return Err(SyscallRefusal::Rejected(SyscallRejection::new(
2560            "local_ipc",
2561            "message_id and message_kind must contain 1..=256 bytes",
2562        )));
2563    }
2564    Ok(())
2565}
2566
2567fn resolve_ipc_handle(
2568    engine: &LoopStateMachine,
2569    handle_id: &super::scalar::HandleId,
2570) -> Result<crate::mm::handle::Handle, SyscallRefusal> {
2571    engine
2572        .ctx
2573        .handles
2574        .all()
2575        .iter()
2576        .find(|handle| {
2577            handle.source.as_deref() == Some(handle_id.as_str())
2578                || handle.id.to_string() == handle_id.as_str()
2579        })
2580        .cloned()
2581        .ok_or_else(|| {
2582            SyscallRefusal::Rejected(SyscallRejection::new(
2583                "local_ipc",
2584                format!("payload handle {handle_id} is not reachable by this operation"),
2585            ))
2586        })
2587}
2588
2589fn local_ipc_refusal(error: crate::scheduler::tcb::LocalIpcError) -> SyscallRefusal {
2590    let reason = match error {
2591        crate::scheduler::tcb::LocalIpcError::UnknownCaller => "unknown caller",
2592        crate::scheduler::tcb::LocalIpcError::CallerTerminal => "caller is terminal",
2593        crate::scheduler::tcb::LocalIpcError::UnknownRecipient => "unknown recipient",
2594        crate::scheduler::tcb::LocalIpcError::ChannelSubscribersMismatch => {
2595            "channel subscriber set is immutable"
2596        }
2597        crate::scheduler::tcb::LocalIpcError::NotSubscriber => "caller is not a channel subscriber",
2598        crate::scheduler::tcb::LocalIpcError::Full => "IPC capacity is full",
2599        crate::scheduler::tcb::LocalIpcError::Expired => "message TTL already expired",
2600        crate::scheduler::tcb::LocalIpcError::ObjectConflict => {
2601            "object id already names a different descriptor"
2602        }
2603    };
2604    SyscallRefusal::Rejected(SyscallRejection::new("local_ipc", reason))
2605}
2606
2607fn local_ipc_outcome(accepted: bool) -> SyscallOutcome {
2608    SyscallOutcome {
2609        ack: Some(
2610            serde_json::json!({
2611                "status": if accepted { "accepted" } else { "duplicate" },
2612            })
2613            .to_string(),
2614        ),
2615        ..SyscallOutcome::default()
2616    }
2617}
2618
2619fn ipc_messages_outcome(messages: &[crate::scheduler::mailbox::MailboxMessage]) -> SyscallOutcome {
2620    SyscallOutcome {
2621        ack: Some(
2622            serde_json::to_string(messages)
2623                .expect("canonical mailbox messages are always serializable"),
2624        ),
2625        ..SyscallOutcome::default()
2626    }
2627}
2628
2629/// Wire → semantic projections for the resolution half.
2630fn core_provider_message(message: &ProviderMessage) -> Result<CoreMessage, KernelFault> {
2631    Ok(CoreMessage {
2632        role: core_role_of(message.role),
2633        content: Content::Text(message.content.clone()),
2634        tool_calls: message.tool_calls.iter().map(core_tool_call).collect(),
2635    })
2636}
2637
2638fn core_tool_call(call: &WireToolCall) -> crate::types::message::ToolCall {
2639    crate::types::message::ToolCall {
2640        id: call.call_id.as_str().into(),
2641        name: call.name.as_str().into(),
2642        arguments: call.arguments.get().clone(),
2643    }
2644}
2645
2646fn wire_tool_call(call: &crate::types::message::ToolCall) -> Result<WireToolCall, KernelFault> {
2647    Ok(WireToolCall {
2648        call_id: super::scalar::CallId::new(call.id.as_str()).map_err(malformed)?,
2649        name: call.name.to_string(),
2650        arguments: super::scalar::BoundedJson::new(call.arguments.clone())
2651            .unwrap_or_else(|_| Default::default()),
2652    })
2653}
2654
2655fn wire_approval_request(
2656    request: &crate::scheduler::state_machine::ApprovalRequest,
2657) -> Result<WireApprovalRequest, KernelFault> {
2658    Ok(WireApprovalRequest {
2659        call_id: super::scalar::CallId::new(request.call_id.as_str()).map_err(malformed)?,
2660        tool_name: request.tool.clone(),
2661        arguments: super::scalar::BoundedJson::new(request.arguments.clone())
2662            .unwrap_or_else(|_| Default::default()),
2663        reason: (!request.reason.is_empty()).then(|| request.reason.clone()),
2664    })
2665}
2666
2667/// §7.10 · one returned tool result.
2668///
2669/// Both arms produce the same thing: the text that enters working context. For `Inline` that is the
2670/// body; for `External` it is the preview, and the body never crosses core at all — the host
2671/// persisted it before submitting, and the kernel holds only the reference the
2672/// [`ToolsSuccess`](super::effect::ToolsSuccess) carried. The residency transfer that records
2673/// *where* the body went happens after the engine has accepted the batch (see
2674/// `record_external_payloads`), because the handle it moves does not exist until the result is in
2675/// history.
2676///
2677/// The canonical [`ToolResultDisposition`] is binary, so the projection onto core's historical
2678/// `is_fatal` + six-way `ToolErrorKind` is total and lossless in the direction that matters: only
2679/// `Recoverable` and `Fatal` are reachable, and `UserInterrupt` — the one kind that still rolls a
2680/// turn back — has no canonical spelling at all. Cancellation travels on `HostControl::Cancel`
2681/// (§7.9), so that retired retry rung is not re-expressible here.
2682///
2683/// §7.10 rule 9 · failure is orthogonal to residency, so the two failure facts are read through
2684/// [`WireToolResultPayload::disposition`] / [`WireToolResultPayload::is_error`] and land in core
2685/// identically for both arms. A tool that failed *and* produced a body over the inline threshold —
2686/// the common shape, not a rare one — is now expressible, and its fatality reaches the batch
2687/// close-out on the same path an inline one does.
2688fn core_tool_result(payload: &WireToolResultPayload) -> ToolResult {
2689    let disposition = payload.disposition();
2690    let is_error = payload.is_error();
2691    let error_kind = match disposition {
2692        ToolResultDisposition::Fatal => Some(ToolErrorKind::Fatal),
2693        ToolResultDisposition::Recoverable => is_error.then_some(ToolErrorKind::Recoverable),
2694    };
2695    match payload {
2696        WireToolResultPayload::Inline(inline) => ToolResult {
2697            call_id: inline.call_id.as_str().into(),
2698            output: Content::Text(inline.result.output.clone()),
2699            durable_content: inline.result.durable_content.clone(),
2700            is_error,
2701            is_fatal: disposition.is_fatal(),
2702            error_kind,
2703        },
2704        WireToolResultPayload::External(external) => ToolResult {
2705            call_id: external.call_id.as_str().into(),
2706            output: Content::Text(external.preview.clone()),
2707            durable_content: None,
2708            is_error,
2709            is_fatal: disposition.is_fatal(),
2710            error_kind,
2711        },
2712    }
2713}
2714
2715/// §7.10 rules 1, 2 and 5 · the configured threshold is the **arbiter** of which arm a result may
2716/// take, checked before the engine sees anything.
2717///
2718/// `PayloadPolicy::inline_threshold_bytes` documents a total partition — "results at or above this
2719/// size are committed as `External` rather than inline" — so both directions are enforced here:
2720///
2721/// - an oversized `Inline` is refused rather than externalised by the kernel. The host must persist
2722///   before submission, so "reject" is the only answer that keeps rule 5 true.
2723/// - an undersized `External` is refused too, because it costs a `LoadPayload` round trip to read
2724///   something that would have fitted in the turn that produced it, and it makes the partition —
2725///   the one thing a host has to agree with the kernel about — untotal.
2726///
2727/// The digest must be one this kernel can *verify*: a page-in is checked by recomputing the digest
2728/// over the returned body, so a foreign algorithm would admit a payload whose restoration could
2729/// never be proved. The preview is bounded because it is the part that actually occupies context.
2730fn check_payload_policy(
2731    payload: &WireToolResultPayload,
2732    policy: &super::config::ResolvedPayloadPolicy,
2733) -> Result<(), KernelFault> {
2734    let threshold = policy.inline_threshold_bytes as u64;
2735    match payload {
2736        WireToolResultPayload::Inline(inline) => {
2737            let durable_size = inline
2738                .result
2739                .durable_content
2740                .as_ref()
2741                .map(|content| {
2742                    content.validate().map_err(|error| {
2743                        KernelFault::new(
2744                            KernelFaultCode::MalformedEnvelope,
2745                            format!(
2746                                "inline tool result {} carries invalid durable content: {error}",
2747                                inline.call_id
2748                            ),
2749                        )
2750                    })?;
2751                    serde_json::to_vec(content).map(|bytes| bytes.len() as u64).map_err(|error| {
2752                        KernelFault::new(
2753                            KernelFaultCode::MalformedEnvelope,
2754                            format!(
2755                                "inline tool result {} durable content cannot be encoded: {error}",
2756                                inline.call_id
2757                            ),
2758                        )
2759                    })
2760                })
2761                .transpose()?
2762                .unwrap_or(0);
2763            let size = (inline.result.output.len() as u64).max(durable_size);
2764            if size >= threshold {
2765                return Err(KernelFault::new(
2766                    KernelFaultCode::ResourceLimitExceeded,
2767                    format!(
2768                        "tool result {} is {size} bytes and this operation's payload policy \
2769                         externalises at {threshold}; the host persists the body and submits an \
2770                         external result — the kernel does not spool on its behalf (§7.10)",
2771                        inline.call_id
2772                    ),
2773                ));
2774            }
2775            Ok(())
2776        }
2777        WireToolResultPayload::External(external) => {
2778            if !is_verifiable_digest(external.digest.as_str()) {
2779                return Err(KernelFault::new(
2780                    KernelFaultCode::MalformedEnvelope,
2781                    format!(
2782                        "external tool result {} carries digest {}, which this kernel cannot \
2783                         verify; a paged-in body is checked by recomputing {}:<64 hex> over it",
2784                        external.call_id,
2785                        external.digest,
2786                        super::record::DIGEST_ALGORITHM
2787                    ),
2788                ));
2789            }
2790            let size = external.original_size.get();
2791            if size < threshold {
2792                return Err(KernelFault::new(
2793                    KernelFaultCode::MalformedEnvelope,
2794                    format!(
2795                        "external tool result {} declares {size} bytes but this operation's \
2796                         payload policy inlines below {threshold}; the threshold is the single \
2797                         arbiter of which arm a result takes (§7.10)",
2798                        external.call_id
2799                    ),
2800                ));
2801            }
2802            let preview = external.preview.len() as u64;
2803            if preview > policy.preview_bytes as u64 {
2804                return Err(KernelFault::new(
2805                    KernelFaultCode::ResourceLimitExceeded,
2806                    format!(
2807                        "external tool result {} carries a {preview}-byte preview and this \
2808                         operation keeps {} bytes resident",
2809                        external.call_id, policy.preview_bytes
2810                    ),
2811                ));
2812            }
2813            Ok(())
2814        }
2815    }
2816}
2817
2818/// Whether `digest` is a digest this kernel can recompute — `sha256:` plus 64 lowercase hex.
2819fn is_verifiable_digest(digest: &str) -> bool {
2820    let Some(hex) = digest.strip_prefix(super::record::DIGEST_ALGORITHM) else {
2821        return false;
2822    };
2823    let Some(hex) = hex.strip_prefix(':') else {
2824        return false;
2825    };
2826    hex.len() == 64
2827        && hex
2828            .bytes()
2829            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
2830}
2831
2832fn core_milestone_result(
2833    result: &super::effect::MilestoneCheckResult,
2834) -> crate::types::milestone::MilestoneCheckResult {
2835    crate::types::milestone::MilestoneCheckResult {
2836        phase_id: result.phase_id.clone(),
2837        passed: result.passed,
2838        reason: (!result.passed).then(|| {
2839            if result.failed_criteria.is_empty() {
2840                result.notes.clone()
2841            } else {
2842                format!("unmet criteria: {}", result.failed_criteria.join("; "))
2843            }
2844        }),
2845    }
2846}
2847
2848/// Project a wire contract skeleton onto the engine's phase cascade.
2849///
2850/// The skeleton carries the two things core decides — phase order and unlocks — and nothing else,
2851/// so the projection fills the rest from the engine's own defaults: no criteria (the host owns
2852/// them, §5.2), the default `HarnessEval` verifier, unlimited retries, terminate-on-exhaustion.
2853/// The one lookup here is `unlocks` → capability descriptor, and it cannot fail: `resolve` already
2854/// proved every id names a declared tool or skill, so the fallback marker is unreachable and
2855/// exists only to keep the projection total.
2856fn core_milestone_contract(
2857    contract: &super::config::VerificationContract,
2858    config: &ResolvedOperationConfig,
2859) -> crate::types::milestone::MilestoneContract {
2860    use crate::types::capability::{CapabilityDescriptor, CapabilityKind as CoreCapabilityKind};
2861    use crate::types::milestone::{MilestoneContract, MilestonePhase};
2862
2863    let mut cascade = MilestoneContract::new();
2864    for phase in &contract.phases {
2865        let unlocks = phase
2866            .unlocks
2867            .iter()
2868            .map(|id| {
2869                if let Some(tool) = config.tool_catalog.iter().find(|tool| &tool.name == id) {
2870                    CapabilityDescriptor::tool(core_tool_schema(tool))
2871                } else if let Some(skill) = config.skill_catalog.iter().find(|s| &s.name == id) {
2872                    CapabilityDescriptor::skill(core_skill(skill))
2873                } else {
2874                    CapabilityDescriptor::marker(
2875                        CoreCapabilityKind::Tool,
2876                        id.as_str(),
2877                        String::new(),
2878                    )
2879                }
2880            })
2881            .collect();
2882        cascade = cascade.phase(MilestonePhase {
2883            unlocks,
2884            ..MilestonePhase::new(phase.phase_id.clone())
2885        });
2886    }
2887    cascade
2888}
2889
2890fn core_memory_kind(kind: WireMemoryKind) -> crate::mm::memory::MemoryKind {
2891    match kind {
2892        WireMemoryKind::User => crate::mm::memory::MemoryKind::User,
2893        WireMemoryKind::Feedback => crate::mm::memory::MemoryKind::Feedback,
2894        WireMemoryKind::Project => crate::mm::memory::MemoryKind::Project,
2895        WireMemoryKind::Reference => crate::mm::memory::MemoryKind::Reference,
2896    }
2897}
2898
2899fn wire_memory_kind_label(kind: WireMemoryKind) -> &'static str {
2900    core_memory_kind(kind).label()
2901}
2902
2903/// The canonical memory binding is **opaque** (§7.8): it is not a tenant, not a namespace and not a
2904/// path. Host-facing observations still need to say which binding a fact belongs to, so the binding
2905/// id rides in the namespace slot and the tenant stays empty — the kernel derives no tenant because
2906/// the contract gives it none.
2907fn binding_scope(binding_id: &MemoryBindingId) -> crate::mm::memory::MemoryScope {
2908    crate::mm::memory::MemoryScope::new(String::new(), binding_id.as_str().to_string())
2909}
2910
2911/// The audit text of a host executor failure. Classification first, host prose second — a kernel
2912/// decision was already taken on the kind alone (§7.9), and this is only what the operator reads.
2913fn host_failure_text(failure: &HostEffectFailure) -> String {
2914    if failure.message.is_empty() {
2915        failure.kind.as_str().to_string()
2916    } else {
2917        format!("{}: {}", failure.kind.as_str(), failure.message)
2918    }
2919}
2920
2921/// A resolution for an effect this driver has no record of authoring. The transaction already
2922/// refuses one for an effect that is not pending, so reaching this means the driver's own ledger
2923/// and the journal disagree — a rebuild-from-records failure, not a host protocol error.
2924fn unowned_resolution(effect_id: &EffectId, what: &str) -> KernelFault {
2925    KernelFault::new(
2926        KernelFaultCode::RecordCorrupted,
2927        format!(
2928            "effect {effect_id} resolves a {what} this runtime never authored; the driver's ledger \
2929             no longer describes the journal — rebuild from the records"
2930        ),
2931    )
2932}
2933
2934fn truncate_on_char_boundary(text: &str, max_bytes: usize) -> String {
2935    if text.len() <= max_bytes {
2936        return text.to_string();
2937    }
2938    let mut end = max_bytes;
2939    while end > 0 && !text.is_char_boundary(end) {
2940        end -= 1;
2941    }
2942    text[..end].to_string()
2943}
2944
2945// ---------------------------------------------------------------------------------------------
2946// engine construction from the resolved configuration
2947// ---------------------------------------------------------------------------------------------
2948
2949/// Build the semantic kernel this operation runs on, from the configuration its genesis record
2950/// froze. Nothing here reads a compile-time default: every value comes off the record, which is
2951/// what makes a rebuild on a newer binary reproduce the same steps (§15.2).
2952fn build_engine(config: &ResolvedOperationConfig) -> LoopStateMachine {
2953    let execution = &config.execution_policy;
2954    let mut engine = LoopStateMachine::new(SchedulerBudget {
2955        max_tokens: execution.max_context_tokens,
2956        max_turns: execution.max_turns,
2957        max_total_tokens: execution.max_total_tokens.get(),
2958        max_wall_ms: execution.max_wall_ms.map(WireU64::get),
2959    });
2960    if let Some(grant) = config.budget_grant.clone() {
2961        engine.set_budget_grant(grant);
2962    }
2963    let scheduler_policy = config.scheduler_policy;
2964    engine.set_scheduler_policy(crate::scheduler::policy::SchedulerPolicyConfig {
2965        critical_path_weight: i64::from(scheduler_policy.critical_path_weight),
2966        fanout_weight: i64::from(scheduler_policy.fanout_weight),
2967        age_weight: i64::from(scheduler_policy.age_weight),
2968        token_cost_weight: i64::from(scheduler_policy.token_cost_weight),
2969        deadline_weight: i64::from(scheduler_policy.deadline_weight),
2970        process_priority_weight: i64::from(scheduler_policy.process_priority_weight),
2971        resource_pressure_weight: i64::from(scheduler_policy.resource_pressure_weight),
2972        budget_pressure_weight: i64::from(scheduler_policy.budget_pressure_weight),
2973    });
2974
2975    engine.set_criteria_gate(execution.criteria_gate_enabled);
2976    engine.set_repeat_fuse(crate::governance::repeat_fuse::RepeatFuseConfig {
2977        enabled: execution.repeat_fuse.enabled,
2978        deny_after: execution.repeat_fuse.deny_after,
2979        terminate_after: execution.repeat_fuse.terminate_after,
2980    });
2981    engine.set_entropy_watch(crate::scheduler::entropy::EntropyWatchConfig {
2982        enabled: execution.entropy_watch.enabled,
2983        threshold: f64::from(execution.entropy_watch.threshold_ppm.get()) / 1_000_000.0,
2984        hysteresis: f64::from(execution.entropy_watch.hysteresis_ppm.get()) / 1_000_000.0,
2985        cooldown_turns: execution.entropy_watch.cooldown_turns,
2986        notify_model: execution.entropy_watch.notify_model,
2987    });
2988    install_live_policies(&mut engine, config);
2989    engine
2990        .ctx
2991        .set_memory_enabled(config.feature_policy.memory_enabled);
2992    engine
2993        .ctx
2994        .set_knowledge_enabled(config.feature_policy.knowledge_enabled);
2995    engine
2996        .ctx
2997        .set_plan_tool_enabled(config.feature_policy.plan_tool_enabled);
2998    // §7.6 · the declared skill catalog is what makes `ActivateSkill` checkable: a name outside it
2999    // is a capability mutation with nothing behind it.
3000    engine
3001        .ctx
3002        .set_available_skills(config.skill_catalog.iter().map(core_skill).collect());
3003    engine.ctx.set_stable_core_tools(
3004        config
3005            .feature_policy
3006            .stable_core_tool_ids
3007            .iter()
3008            .map(|id| id.as_str().into()),
3009    );
3010    engine.ctx.config.knowledge_budget_ratio =
3011        config.context_policy.knowledge_budget_ppm.as_ratio();
3012    engine.ctx.config.collapse_assistant_narration =
3013        config.context_policy.collapse_old_assistant_narration;
3014    engine.tools = config.tool_catalog.iter().map(core_tool_schema).collect();
3015    engine
3016}
3017
3018/// Install the four §13.2 live-mutable policies onto an engine.
3019///
3020/// One installer, two callers: the genesis build and `HostCommand::ApplyPolicyPatch`. That is the
3021/// whole reason it exists — a patched policy that took a different code path into the engine than
3022/// the booted one is how "the same configuration means two things" starts.
3023///
3024/// A policy the operation never declared is deliberately **not** installed: §7.3's "the host never
3025/// said" is a value, distinct from an all-permissive policy the host did not state.
3026fn install_live_policies(engine: &mut LoopStateMachine, config: &ResolvedOperationConfig) {
3027    // §7.6 · the P1 gate is only a gate if the operation's declared caps actually reach it. Without
3028    // this the trap would allow every syscall on the canonical path regardless of what the genesis
3029    // record froze.
3030    if let Some(quota) = core_quota(&config.resource_quota) {
3031        engine.set_resource_quota(quota);
3032    }
3033    // The same argument for the tool gate: a governance policy the genesis record froze but the
3034    // engine never installed would make `RequestApproval` unpublishable and every declared rule
3035    // inert.
3036    if let Some(pipeline) = core_governance(&config.governance_policy) {
3037        engine.set_governance(pipeline);
3038    }
3039    engine.set_signal_policy(
3040        config.signal_policy.queue_max as usize,
3041        config.signal_policy.ttl_ms.map(WireU64::get),
3042        config.signal_policy.deadline_escalation,
3043    );
3044    // The two semantic ladders. Before this existed the resolved recovery policy was frozen into
3045    // the genesis record and then never reached the engine at all, so both the booted policy and
3046    // `ReplaceRecoveryPolicy` were inert and the engine's own compile-time defaults decided how
3047    // long a ladder ran — the exact "the record says one thing, the run does another" drift §15.2
3048    // forbids.
3049    engine.set_recovery_limits(
3050        config.recovery_policy.provider_recovery_attempts,
3051        config.recovery_policy.output_recovery_attempts,
3052    );
3053}
3054
3055/// `None` when the operation declared no axis at all. §7.3: "the host never said" is a value, and
3056/// it is *not* the same as an all-uncapped quota — an installed quota makes the workflow budget
3057/// observable, which is a statement the host did not make.
3058fn core_quota(
3059    quota: &super::config::ResourceQuota,
3060) -> Option<crate::governance::quota::ResourceQuota> {
3061    if quota == &super::config::ResourceQuota::default() {
3062        return None;
3063    }
3064    Some(crate::governance::quota::ResourceQuota {
3065        max_concurrent_subagents: quota.max_concurrent_subagents,
3066        max_total_subagents: quota.max_total_subagents,
3067        max_spawn_depth: quota.max_spawn_depth,
3068        memory_writes_per_window: quota
3069            .memory_writes_per_window
3070            .as_ref()
3071            .map(|window| (window.max_events, window.window_ms.get())),
3072        max_workflow_nodes: quota.max_workflow_nodes.map(|max| max as usize),
3073    })
3074}
3075
3076/// `None` when the operation declared no governance at all. Same "the host never said" rule as
3077/// [`core_quota`]: an installed all-allow pipeline is a statement the host did not make, and it
3078/// would silently change what a tool call means (every call would pass a gate that does not exist).
3079fn core_governance(
3080    policy: &super::config::ResolvedGovernancePolicy,
3081) -> Option<crate::governance::pipeline::GovernancePipeline> {
3082    use super::command::{ParamConstraint as WireConstraint, PolicyAction};
3083    use crate::governance::constraint::{ConstraintRule, ParamConstraint as CoreConstraint};
3084    use crate::governance::permission::PermissionRule;
3085    use crate::governance::rate_limit::RateLimit;
3086
3087    if policy.default_action == PolicyAction::Allow
3088        && policy.rules.is_empty()
3089        && policy.vetoed_tools.is_empty()
3090        && policy.rate_limits.is_empty()
3091        && policy.constraints.is_empty()
3092    {
3093        return None;
3094    }
3095    let mut pipeline = crate::governance::pipeline::GovernancePipeline::new(core_policy_action(
3096        policy.default_action,
3097    ));
3098    for rule in &policy.rules {
3099        pipeline.permission.add_rule(PermissionRule {
3100            tool_pattern: rule.tool_pattern.as_str().into(),
3101            action: core_policy_action(rule.action),
3102        });
3103    }
3104    for tool in &policy.vetoed_tools {
3105        pipeline.veto.block_tool(tool.clone());
3106    }
3107    for limit in &policy.rate_limits {
3108        pipeline.rate_limiter.set_limit(
3109            limit.tool.clone(),
3110            RateLimit {
3111                max_calls: limit.max_calls,
3112                window_ms: limit.window_ms.get(),
3113            },
3114        );
3115    }
3116    for constraint in &policy.constraints {
3117        let rule = match constraint {
3118            WireConstraint::Required(_) => ConstraintRule::Required,
3119            WireConstraint::Enum(spec) => ConstraintRule::Enum(spec.values.clone()),
3120            // §7.1.1 · the wire carries fixed-point micro-units so a bound is replayable; the
3121            // validator's own arithmetic is float, and this is the single conversion point.
3122            WireConstraint::Range(spec) => ConstraintRule::Range {
3123                min: spec.min_micros.map(|micros| micros as f64 / 1_000_000.0),
3124                max: spec.max_micros.map(|micros| micros as f64 / 1_000_000.0),
3125            },
3126        };
3127        pipeline.constraints.add(CoreConstraint {
3128            tool_name: constraint.tool().to_string(),
3129            param_path: constraint.param_path().to_string(),
3130            rule,
3131        });
3132    }
3133    Some(pipeline)
3134}
3135
3136fn core_policy_action(
3137    action: super::command::PolicyAction,
3138) -> crate::governance::permission::PermissionAction {
3139    use crate::governance::permission::PermissionAction;
3140    match action {
3141        super::command::PolicyAction::Allow => PermissionAction::Allow,
3142        super::command::PolicyAction::Deny => PermissionAction::Deny,
3143        super::command::PolicyAction::AskUser => PermissionAction::AskUser,
3144    }
3145}
3146
3147fn core_skill(skill: &super::config::SkillMetadata) -> crate::types::skill::SkillMetadata {
3148    crate::types::skill::SkillMetadata {
3149        name: skill.name.as_str().into(),
3150        description: skill.description.clone(),
3151        when_to_use: skill.when_to_use.clone(),
3152        allowed_tools: skill
3153            .allowed_tools
3154            .iter()
3155            .map(|tool| tool.as_str().into())
3156            .collect(),
3157        capability_grants: skill.capability_grants.clone(),
3158        effort: skill.effort,
3159        estimated_tokens: skill.estimated_tokens.unwrap_or(0),
3160    }
3161}
3162
3163fn ensure_skill_grants_are_attenuated(
3164    grants: &[crate::types::capability::Capability],
3165    parent_capabilities: &[crate::types::capability::Capability],
3166) -> Result<(), Vec<crate::types::capability::Capability>> {
3167    crate::types::capability::caps_subset(grants, parent_capabilities)
3168}
3169
3170fn skill_grant_attenuation_message(
3171    skill_name: &str,
3172    violations: &[crate::types::capability::Capability],
3173) -> String {
3174    format!(
3175        "skill {skill_name:?} declares capability grants that would widen the mounting agent's authority: {}",
3176        violations
3177            .iter()
3178            .map(|capability| capability.id.0.as_str())
3179            .collect::<Vec<_>>()
3180            .join(", ")
3181    )
3182}
3183
3184fn core_tool_schema(schema: &WireToolSchema) -> crate::types::message::ToolSchema {
3185    crate::types::message::ToolSchema {
3186        name: schema.name.as_str().into(),
3187        description: schema.description.clone(),
3188        parameters: schema.parameters.get().clone(),
3189    }
3190}
3191
3192fn malformed(error: super::scalar::WireScalarError) -> KernelFault {
3193    KernelFault::new(KernelFaultCode::MalformedEnvelope, error.message)
3194}
3195
3196#[cfg(test)]
3197mod tests;