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