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