Skip to main content

deepstrike_core/runtime/kernel/wire/
effect.rs

1//! Kernel effects, their resolutions and the payload shapes both sides carry
2//! (spec §7.8, §7.9, §7.10).
3//!
4//! Three rules shape this module:
5//!
6//! 1. **An effect is something the host must execute and report back.** Terminals, observations,
7//!    synchronous compaction, knowledge sweeps and budget reports left the union (§7.8, §22.7):
8//!    they are facts, not commands, so they can never sit in the pending-effect table waiting for
9//!    a resolution that will never come.
10//! 2. **Every effect has exactly one matching success payload and the same failure path.**
11//!    [`EffectKindTag::expected_success`] is a bijection onto [`EffectSuccessTag`], and
12//!    [`KernelEffect::accept_outcome`] refuses any other pairing — a host cannot answer a
13//!    `QueryMemory` with a provider message. Milestone evaluation is not special: it fails through
14//!    the same [`HostEffectFailure`] as everything else (B7).
15//! 3. **The kernel never retries** (DEC-5). A `Failed` outcome triggers exactly one policy
16//!    decision; `retryable` is host diagnostics, not an instruction, and a host that wants the
17//!    same intent attempted again must ask for it with a new causation.
18//!
19//! Two omissions are deliberate. No outcome payload carries a wall clock (DEC-2) — the envelope's
20//! accepted time is the only clock fact — and no external payload carries a filesystem path
21//! (§7.10 rule 7): [`PayloadRef`] is an opaque locator the kernel never interprets.
22
23use std::fmt;
24
25use serde::de::{self, Deserializer, Visitor};
26use serde::{Deserialize, Serialize, Serializer};
27
28use crate::context::measurement::PromptMeasurement;
29use crate::types::durable_content::DurableContent;
30
31use super::root::{LogicalAgentSpec, MessageRole};
32use super::scalar::{
33    AttemptId, BoundedJson, CallId, EffectId, FiniteF64, HandleId, InputId, MAX_ID_BYTES,
34    MemoryBindingId, NodeId, SCALAR_ERROR_MARKER, TaskId, WireScalarError, WireU64,
35};
36use super::syscall::{MemoryKind, SyscallCausation};
37
38// ---------------------------------------------------------------------------------------------
39// opaque references
40// ---------------------------------------------------------------------------------------------
41
42#[doc(hidden)]
43pub(crate) fn validate_opaque(label: &'static str, value: &str) -> Result<(), WireScalarError> {
44    if value.is_empty() {
45        return Err(WireScalarError::new(format!("{label} must not be empty")));
46    }
47    if value.len() > MAX_ID_BYTES {
48        return Err(WireScalarError::new(format!(
49            "{label} is {} bytes; the bound is {MAX_ID_BYTES}",
50            value.len()
51        )));
52    }
53    if value.chars().any(char::is_control) {
54        return Err(WireScalarError::new(format!(
55            "{label} must not contain control characters"
56        )));
57    }
58    Ok(())
59}
60
61/// Branded opaque reference. Same discipline as [`super::scalar`]'s identities: a non-empty,
62/// bounded, control-character-free string that is never a number and never a path.
63macro_rules! wire_opaque_ref {
64    ($(#[$doc:meta])* $name:ident, $label:literal) => {
65        $(#[$doc])*
66        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
67        pub struct $name(String);
68
69        impl $name {
70            pub fn new(value: impl Into<String>) -> Result<Self, WireScalarError> {
71                let value = value.into();
72                $crate::runtime::kernel::wire::effect::validate_opaque($label, &value)?;
73                Ok(Self(value))
74            }
75
76            pub fn as_str(&self) -> &str {
77                &self.0
78            }
79
80            pub fn into_string(self) -> String {
81                self.0
82            }
83        }
84
85        impl fmt::Display for $name {
86            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87                f.write_str(&self.0)
88            }
89        }
90
91        impl Serialize for $name {
92            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93                serializer.serialize_str(&self.0)
94            }
95        }
96
97        impl<'de> Deserialize<'de> for $name {
98            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
99                struct RefVisitor;
100
101                impl Visitor<'_> for RefVisitor {
102                    type Value = $name;
103
104                    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105                        f.write_str(concat!("a non-empty ", $label, " string"))
106                    }
107
108                    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
109                        $name::new(value).map_err(|err| {
110                            E::custom(format!("{SCALAR_ERROR_MARKER}: {}", err.message))
111                        })
112                    }
113
114                    fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
115                        Err(E::custom(format!(
116                            "{SCALAR_ERROR_MARKER}: {} must be a branded string, got {value}",
117                            $label
118                        )))
119                    }
120
121                    fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
122                        Err(E::custom(format!(
123                            "{SCALAR_ERROR_MARKER}: {} must be a branded string, got {value}",
124                            $label
125                        )))
126                    }
127
128                    fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
129                        Err(E::custom(format!(
130                            "{SCALAR_ERROR_MARKER}: {} must be a branded string, got null",
131                            $label
132                        )))
133                    }
134                }
135
136                deserializer.deserialize_any(RefVisitor)
137            }
138        }
139    };
140}
141
142pub(crate) use wire_opaque_ref;
143
144wire_opaque_ref!(
145    /// Opaque locator for a payload the host persisted (§7.10 rule 7).
146    ///
147    /// **Not a path.** The kernel never joins it, never opens it and never lets a meta-tool
148    /// interpret it; the only legal use is handing it back to the host in a `LoadPayload` effect.
149    /// The historical `spool_ref`/`archive_ref` were real filesystem paths, which is exactly how a
150    /// model-visible `read` tool learned to bypass the handle table.
151    PayloadRef,
152    "payload ref"
153);
154wire_opaque_ref!(
155    /// Content digest of a payload or record. Algorithm-prefixed (`sha256:…`) so a future
156    /// algorithm change is visible on the wire rather than silently reinterpreted.
157    Digest,
158    "digest"
159);
160wire_opaque_ref!(
161    /// Kernel-minted idempotency token for one task launch. The host keys its own launch
162    /// deduplication on it, which is what makes "the kernel does not retry" (DEC-5) safe: a host
163    /// re-attempt with the same token is the same launch, not a second child.
164    LaunchToken,
165    "launch token"
166);
167wire_opaque_ref!(
168    /// Opaque handle for one persisted memory record. Never a tenant, namespace or path.
169    MemoryRecordRef,
170    "memory record ref"
171);
172
173// ---------------------------------------------------------------------------------------------
174// §7.8 · the effect union
175// ---------------------------------------------------------------------------------------------
176
177/// One action the kernel published and is now waiting on.
178///
179/// `effect_id` is minted by the kernel, never by the host; `causation_input_id` names the accepted
180/// input that produced it, so every effect is traceable to one committed transition.
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct KernelEffect {
184    pub effect_id: EffectId,
185    pub causation_input_id: InputId,
186    pub effect: EffectKind,
187}
188
189impl KernelEffect {
190    pub fn tag(&self) -> EffectKindTag {
191        self.effect.tag()
192    }
193
194    /// The contract between a pending effect and the resolution a host offers for it.
195    ///
196    /// A `Failed` outcome is always admissible — the failure path is uniform across every effect
197    /// kind (§7.9). A `Succeeded` outcome is admissible only if its payload is *the* success shape
198    /// of this effect kind. Anything else is a host protocol error, not a kernel state change; the
199    /// caller turns it into a [`KernelFaultCode::UnexpectedEffectOutcome`](super::fault::KernelFaultCode)
200    /// rejection with zero mutation.
201    pub fn accept_outcome(&self, outcome: &EffectOutcome) -> Result<(), EffectResolutionMismatch> {
202        match outcome {
203            EffectOutcome::Failed(_) => Ok(()),
204            EffectOutcome::Succeeded(success) => {
205                let expected = self.effect.tag().expected_success();
206                let received = success.result.tag();
207                if expected == received {
208                    Ok(())
209                } else {
210                    Err(EffectResolutionMismatch {
211                        effect_id: self.effect_id.clone(),
212                        expected,
213                        received,
214                    })
215                }
216            }
217        }
218    }
219}
220
221/// A host answered a pending effect with the success payload of a different effect kind.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct EffectResolutionMismatch {
224    pub effect_id: EffectId,
225    pub expected: EffectSuccessTag,
226    pub received: EffectSuccessTag,
227}
228
229impl fmt::Display for EffectResolutionMismatch {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        write!(
232            f,
233            "effect {} expects a {} resolution, got {}",
234            self.effect_id,
235            self.expected.as_str(),
236            self.received.as_str()
237        )
238    }
239}
240
241impl std::error::Error for EffectResolutionMismatch {}
242
243/// The closed set of actions a host must execute and report back.
244///
245/// Newtype variants over strict structs, not inline struct variants: `deny_unknown_fields` does
246/// not apply to an inline variant of an internally tagged enum.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[serde(tag = "kind", rename_all = "snake_case")]
249pub enum EffectKind {
250    CallProvider(CallProviderEffect),
251    ExecuteTools(ExecuteToolsEffect),
252    RequestApproval(RequestApprovalEffect),
253    SpawnTasks(SpawnTasksEffect),
254    PreemptTasks(PreemptTasksEffect),
255    PersistMemory(PersistMemoryEffect),
256    QueryMemory(QueryMemoryEffect),
257    ArchivePageOut(ArchivePageOutEffect),
258    /// P3 page-in. Reached only through `SyscallRequest::PageIn`; `read_result` and friends reduce
259    /// to it instead of scanning a session log for the original bytes (§7.10 rule 4).
260    LoadPayload(LoadPayloadEffect),
261    EvaluateMilestone(EvaluateMilestoneEffect),
262    /// spc_011-C-02: preflight prompt-token measurement — see [`MeasurePromptEffect`].
263    MeasurePrompt(MeasurePromptEffect),
264}
265
266impl EffectKind {
267    pub fn tag(&self) -> EffectKindTag {
268        match self {
269            Self::CallProvider(_) => EffectKindTag::CallProvider,
270            Self::ExecuteTools(_) => EffectKindTag::ExecuteTools,
271            Self::RequestApproval(_) => EffectKindTag::RequestApproval,
272            Self::SpawnTasks(_) => EffectKindTag::SpawnTasks,
273            Self::PreemptTasks(_) => EffectKindTag::PreemptTasks,
274            Self::PersistMemory(_) => EffectKindTag::PersistMemory,
275            Self::QueryMemory(_) => EffectKindTag::QueryMemory,
276            Self::ArchivePageOut(_) => EffectKindTag::ArchivePageOut,
277            Self::LoadPayload(_) => EffectKindTag::LoadPayload,
278            Self::EvaluateMilestone(_) => EffectKindTag::EvaluateMilestone,
279            Self::MeasurePrompt(_) => EffectKindTag::MeasurePrompt,
280        }
281    }
282}
283
284/// The discriminant of [`EffectKind`], usable without a payload.
285///
286/// This is the vocabulary `host_effect_support` declares against (DEC-8, §7.3): the kernel refuses
287/// to emit an effect whose tag the host did not declare, and commits
288/// [`KernelFaultCode::UnsupportedEffect`](super::fault::KernelFaultCode) instead of letting the
289/// same effect produce four different outcomes in four languages.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum EffectKindTag {
293    CallProvider,
294    ExecuteTools,
295    RequestApproval,
296    SpawnTasks,
297    PreemptTasks,
298    PersistMemory,
299    QueryMemory,
300    ArchivePageOut,
301    LoadPayload,
302    EvaluateMilestone,
303    /// spc_011-C-02.
304    MeasurePrompt,
305}
306
307impl EffectKindTag {
308    pub const ALL: [Self; 11] = [
309        Self::CallProvider,
310        Self::ExecuteTools,
311        Self::RequestApproval,
312        Self::SpawnTasks,
313        Self::PreemptTasks,
314        Self::PersistMemory,
315        Self::QueryMemory,
316        Self::ArchivePageOut,
317        Self::LoadPayload,
318        Self::EvaluateMilestone,
319        Self::MeasurePrompt,
320    ];
321
322    pub fn as_str(self) -> &'static str {
323        match self {
324            Self::CallProvider => "call_provider",
325            Self::ExecuteTools => "execute_tools",
326            Self::RequestApproval => "request_approval",
327            Self::SpawnTasks => "spawn_tasks",
328            Self::PreemptTasks => "preempt_tasks",
329            Self::PersistMemory => "persist_memory",
330            Self::QueryMemory => "query_memory",
331            Self::ArchivePageOut => "archive_page_out",
332            Self::LoadPayload => "load_payload",
333            Self::EvaluateMilestone => "evaluate_milestone",
334            Self::MeasurePrompt => "measure_prompt",
335        }
336    }
337
338    /// The one success payload this effect kind accepts. Total and injective by construction.
339    pub fn expected_success(self) -> EffectSuccessTag {
340        match self {
341            Self::CallProvider => EffectSuccessTag::Provider,
342            Self::ExecuteTools => EffectSuccessTag::Tools,
343            Self::RequestApproval => EffectSuccessTag::Approval,
344            Self::SpawnTasks => EffectSuccessTag::TasksSpawned,
345            Self::PreemptTasks => EffectSuccessTag::TasksPreempted,
346            Self::PersistMemory => EffectSuccessTag::MemoryPersisted,
347            Self::QueryMemory => EffectSuccessTag::MemoryQueried,
348            Self::ArchivePageOut => EffectSuccessTag::PageOutArchived,
349            Self::LoadPayload => EffectSuccessTag::PayloadLoaded,
350            Self::EvaluateMilestone => EffectSuccessTag::MilestoneEvaluated,
351            Self::MeasurePrompt => EffectSuccessTag::PromptMeasured,
352        }
353    }
354}
355
356impl fmt::Display for EffectKindTag {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        f.write_str(self.as_str())
359    }
360}
361
362// ---------------------------------------------------------------------------------------------
363// §7.8 · effect payloads
364// ---------------------------------------------------------------------------------------------
365
366#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct CallProviderEffect {
369    pub context: RenderedContext,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub tools: Vec<ToolSchema>,
372}
373
374/// spc_011-C-02: a preflight token-count request. Reuses the same `RenderedContext`/`ToolSchema`
375/// shape `CallProviderEffect` already carries, rather than inventing a parallel candidate-request
376/// type — the kernel is asking "how many tokens would *this* request cost", the same request it
377/// would otherwise hand to `CallProvider`.
378///
379/// spc_011-C-06: no `provider`/`model` fields, matching `CallProviderEffect` exactly. An earlier
380/// draft of this struct carried both, reasoning that "the answer is meaningless without knowing
381/// which vendor should answer it" — but the kernel has no provider/model identity to put there:
382/// `OperationConfig` carries no such field, vendor selection is entirely a Host-side concern (the
383/// same Host that will execute `CallProvider` for this operation already knows which provider it
384/// dispatches to, the same way it already resolves that for `CallProviderEffect`, which has never
385/// carried these fields). This surfaced only when 011-C-06 tried to actually construct one inside
386/// the kernel and found nothing to put in `provider`/`model`.
387#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct MeasurePromptEffect {
390    pub context: RenderedContext,
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub tools: Vec<ToolSchema>,
393}
394
395#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct ExecuteToolsEffect {
398    pub calls: Vec<ToolCall>,
399}
400
401#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
402#[serde(deny_unknown_fields)]
403pub struct RequestApprovalEffect {
404    pub requests: Vec<ApprovalRequest>,
405}
406
407#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct SpawnTasksEffect {
410    pub tasks: Vec<TaskLaunch>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub budget: Option<WorkflowBudget>,
413}
414
415#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct PreemptTasksEffect {
418    pub attempts: Vec<TaskAttemptRef>,
419    pub reason: String,
420}
421
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct PersistMemoryEffect {
425    pub binding: MemoryAccessBinding,
426    pub memory: CanonicalMemoryWrite,
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
430#[serde(deny_unknown_fields)]
431pub struct QueryMemoryEffect {
432    pub binding: MemoryAccessBinding,
433    pub query: CanonicalMemoryQuery,
434    /// Already clamped by the operation's retrieval policy — the host does not re-decide it.
435    pub requested_k: u32,
436}
437
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
439#[serde(deny_unknown_fields)]
440pub struct ArchivePageOutEffect {
441    pub handle_id: HandleId,
442    pub payload: PageOutPayload,
443}
444
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[serde(deny_unknown_fields)]
447pub struct LoadPayloadEffect {
448    pub handle_id: HandleId,
449    pub payload_ref: PayloadRef,
450}
451
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct EvaluateMilestoneEffect {
455    pub request: MilestoneRequest,
456}
457
458/// What the kernel rendered for one provider call.
459///
460/// Partitioned rather than flattened because the partitions have different cache lifetimes: the
461/// identity and knowledge blocks and the leading `frozen_prefix_len` turns are byte-stable, the
462/// state turn is rebuilt every call and therefore always last.
463#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct RenderedContext {
466    #[serde(default, skip_serializing_if = "String::is_empty")]
467    pub system_stable: String,
468    #[serde(default, skip_serializing_if = "String::is_empty")]
469    pub system_knowledge: String,
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    pub turns: Vec<ProviderMessage>,
472    /// Volatile state turn, rendered after the cacheable history. `None` when there is none.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub state_turn: Option<ProviderMessage>,
475    /// Number of leading `turns` that form the byte-stable frozen prefix.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub frozen_prefix_len: Option<u32>,
478}
479
480/// A message on the provider boundary. Distinct from
481/// [`LogicalMessage`](super::root::LogicalMessage): a rendered/returned message can carry tool
482/// calls, an initial-context message cannot.
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484#[serde(deny_unknown_fields)]
485pub struct ProviderMessage {
486    pub role: MessageRole,
487    pub content: String,
488    #[serde(default, skip_serializing_if = "Vec::is_empty")]
489    pub tool_calls: Vec<ToolCall>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub tool_call_id: Option<CallId>,
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub tokens: Option<u32>,
494}
495
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497#[serde(deny_unknown_fields)]
498pub struct ToolSchema {
499    pub name: String,
500    #[serde(default, skip_serializing_if = "String::is_empty")]
501    pub description: String,
502    #[serde(default, skip_serializing_if = "BoundedJson::is_null")]
503    pub parameters: BoundedJson,
504}
505
506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
507#[serde(deny_unknown_fields)]
508pub struct ToolCall {
509    pub call_id: CallId,
510    pub name: String,
511    #[serde(default, skip_serializing_if = "BoundedJson::is_null")]
512    pub arguments: BoundedJson,
513}
514
515#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
516#[serde(deny_unknown_fields)]
517pub struct ApprovalRequest {
518    pub call_id: CallId,
519    pub tool_name: String,
520    #[serde(default, skip_serializing_if = "BoundedJson::is_null")]
521    pub arguments: BoundedJson,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub reason: Option<String>,
524}
525
526/// One child launch the host must perform.
527///
528/// The kernel mints `task_id`, `attempt_id` and `launch_token` before the effect is published, so
529/// the child's identity exists as a committed fact even if the launch fails — the historical
530/// "spawned ⇒ Running with no launch event" gap (B13/B26/D10) is not expressible here.
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(deny_unknown_fields)]
533pub struct TaskLaunch {
534    pub task_id: TaskId,
535    pub attempt_id: AttemptId,
536    pub launch_token: LaunchToken,
537    pub node_id: NodeId,
538    pub spec: LogicalAgentSpec,
539}
540
541#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
542#[serde(deny_unknown_fields)]
543pub struct WorkflowBudget {
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub max_total_tokens: Option<WireU64>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub max_turns: Option<u32>,
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub max_concurrency: Option<u32>,
550}
551
552#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
553#[serde(deny_unknown_fields)]
554pub struct TaskAttemptRef {
555    pub task_id: TaskId,
556    pub attempt_id: AttemptId,
557}
558
559/// The operation's memory authority, carried on every memory effect so the host never has to infer
560/// it. Opaque: never a tenant, a namespace or a path.
561#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct MemoryAccessBinding {
564    pub binding_id: MemoryBindingId,
565    pub capabilities: MemoryCapabilities,
566}
567
568#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(deny_unknown_fields)]
570pub struct MemoryCapabilities {
571    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
572    pub read: bool,
573    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
574    pub write: bool,
575}
576
577/// A memory write the **kernel** authored from a model proposal (§7.6).
578///
579/// The proposal contributed name/kind/content/evidence; provenance — accepted time and causation —
580/// is kernel-derived, which is why this is a `Canonical…` type and the syscall payload is only a
581/// `…Proposal`. `accepted_at_ms` is the envelope's accepted time restamped by the kernel, not a
582/// host clock: DEC-2 bans host clocks on *outcome* payloads, not kernel-authored effect payloads.
583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct CanonicalMemoryWrite {
586    pub name: String,
587    pub kind: MemoryKind,
588    pub content: String,
589    #[serde(default, skip_serializing_if = "String::is_empty")]
590    pub description: String,
591    #[serde(default, skip_serializing_if = "Vec::is_empty")]
592    pub evidence_refs: Vec<String>,
593    pub accepted_at_ms: WireU64,
594    pub causation: SyscallCausation,
595}
596
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599pub struct CanonicalMemoryQuery {
600    #[serde(default, skip_serializing_if = "String::is_empty")]
601    pub text: String,
602    #[serde(default, skip_serializing_if = "Vec::is_empty")]
603    pub kinds: Vec<MemoryKind>,
604    pub accepted_at_ms: WireU64,
605    pub causation: SyscallCausation,
606}
607
608/// The body the host must archive under pressure, with the facts the kernel keeps afterwards.
609/// After the resolution only the handle, digest, size and preview survive in core (§7.10 rule 5).
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612pub struct PageOutPayload {
613    pub content: String,
614    pub digest: Digest,
615    pub original_size: WireU64,
616    #[serde(default, skip_serializing_if = "String::is_empty")]
617    pub preview: String,
618}
619
620/// Which phase of which contract the host must evaluate — and nothing else.
621///
622/// The pair is the point: `phase_id` is unique only *within* its contract (§7.3), so
623/// `(contract_id, phase_id)` is the host's complete lookup key. Neither criteria nor required
624/// evidence travels here: under the §7.3 contract skeleton core never learns them, so a field for
625/// them could only ever be empty, and an always-empty field is an invitation for a host to believe
626/// the kernel knows something it does not (§5.2, adjudication §5m-3 as refined by §5p).
627#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
628#[serde(deny_unknown_fields)]
629pub struct MilestoneRequest {
630    pub contract_id: String,
631    pub phase_id: String,
632}
633
634// ---------------------------------------------------------------------------------------------
635// §7.9 · resolution
636// ---------------------------------------------------------------------------------------------
637
638/// The single entry point for the outcome of a kernel-owned pending effect.
639///
640/// Two arms, no third. There is no "succeeded, but…", no partial result and no per-effect result
641/// event of its own — which is what makes "each pending effect accepts exactly one resolution"
642/// checkable rather than aspirational.
643#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
644#[serde(tag = "status", rename_all = "snake_case")]
645pub enum EffectOutcome {
646    Succeeded(EffectSucceeded),
647    Failed(EffectFailed),
648}
649
650impl EffectOutcome {
651    /// `Some(tag)` for a success, `None` for a failure — the failure path is kind-agnostic.
652    pub fn success_tag(&self) -> Option<EffectSuccessTag> {
653        match self {
654            Self::Succeeded(success) => Some(success.result.tag()),
655            Self::Failed(_) => None,
656        }
657    }
658}
659
660/// The success arm. The effect kind is **implied** by the effect being resolved and by
661/// `result`'s own tag; the host never restates the effect id's kind here.
662#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct EffectSucceeded {
665    pub result: EffectSuccess,
666}
667
668/// The failure arm — identical for every effect kind (§7.9).
669#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
670#[serde(deny_unknown_fields)]
671pub struct EffectFailed {
672    pub failure: HostEffectFailure,
673}
674
675/// What the host can say about a failure. No wall clock, no host path, no stack, no raw vendor
676/// error — only facts the kernel can act on and replay. Vendor diagnostics belong in the host's
677/// own log, never in a kernel recovery decision.
678#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
679#[serde(deny_unknown_fields)]
680pub struct HostEffectFailure {
681    pub kind: HostEffectFailureKind,
682    #[serde(default, skip_serializing_if = "String::is_empty")]
683    pub message: String,
684    /// Host **advice**, never an instruction, and uniformly inert across all six failure kinds.
685    ///
686    /// DEC-5 is what makes that stronger than a naming convention: the kernel makes exactly one
687    /// policy decision per failure and never re-emits the same intent, so there is no branch for
688    /// this flag to select. The decision is chosen by *the effect kind the kernel itself
689    /// published* — never by the kind the host reports and never by this field — which is why
690    /// `retryable: true`, `retryable: false` and an absent `retryable` must plan byte-identical
691    /// steps for every one of the ten effect kinds.
692    ///
693    /// A host that wants another attempt asks again with a new causation and keeps its own
694    /// idempotency on the effect id / launch token. Retry ladders that a vendor would call
695    /// rate-limit or unavailable back-off run entirely inside the host and never reach the
696    /// kernel; when one is spent, the failure that arrives is [`HostEffectFailureKind::TransportExhausted`]
697    /// and nothing else.
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub retryable: Option<bool>,
700}
701
702/// The stable, cross-effect executor failure classification (§7.9).
703///
704/// Six values, closed, and the same six for every effect kind. This is the whole of what a host
705/// may say about *why* an effect did not happen: a vendor's own error taxonomy — `rate_limited`,
706/// `overloaded`, `service_unavailable`, `context_length_exceeded`, `429`, `503` — has no
707/// representation here and must be folded into one of these before it reaches the kernel. That
708/// fold is the host's job precisely because the alternative is core reading raw vendor strings,
709/// which is how one provider's wording silently became another provider's recovery policy (§22.8).
710///
711/// Deliberately *not* here:
712///
713/// * **cancellation** — that is `HostControl::Cancel`, never an effect failure;
714/// * **provider context overflow** — that is [`ProviderOutcome::ContextOverflow`], a semantic
715///   success outcome the kernel recovers from, not a transport failure;
716/// * **rate limiting and transient unavailability** — the host retries those against its own
717///   ladder and reports [`Self::TransportExhausted`] only once the ladder is spent. A distinct
718///   `rate_limited` code would be an invitation for the kernel to run a back-off it cannot see the
719///   inputs to, which is the redispatch DEC-5 deletes.
720#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
721#[serde(rename_all = "snake_case")]
722pub enum HostEffectFailureKind {
723    /// The host exhausted its own transport retry ladder. The kernel never saw the backoff, and
724    /// this is the *only* code a spent rate-limit or unavailable ladder may arrive as.
725    TransportExhausted,
726    /// The host cannot execute this effect at all — unknown kind included. DEC-7 makes answering
727    /// with this code an obligation: dropping the effect or busy-waiting on it is a contract
728    /// violation, and it is why an `if/else-if` chain without a final `else` is not a legal main
729    /// loop.
730    ProtocolError,
731    StorageUnavailable,
732    PermissionDenied,
733    ResourceExhausted,
734    Unknown,
735}
736
737impl HostEffectFailureKind {
738    pub const ALL: [Self; 6] = [
739        Self::TransportExhausted,
740        Self::ProtocolError,
741        Self::StorageUnavailable,
742        Self::PermissionDenied,
743        Self::ResourceExhausted,
744        Self::Unknown,
745    ];
746
747    pub fn as_str(self) -> &'static str {
748        match self {
749            Self::TransportExhausted => "transport_exhausted",
750            Self::ProtocolError => "protocol_error",
751            Self::StorageUnavailable => "storage_unavailable",
752            Self::PermissionDenied => "permission_denied",
753            Self::ResourceExhausted => "resource_exhausted",
754            Self::Unknown => "unknown",
755        }
756    }
757}
758
759/// The closed per-effect success union. One variant per [`EffectKind`], no more and no fewer.
760#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
761#[serde(tag = "kind", rename_all = "snake_case")]
762pub enum EffectSuccess {
763    Provider(ProviderSuccess),
764    Tools(ToolsSuccess),
765    Approval(ApprovalSuccess),
766    TasksSpawned(TasksSpawnedSuccess),
767    TasksPreempted(TasksPreemptedSuccess),
768    MemoryPersisted(MemoryPersistedSuccess),
769    MemoryQueried(MemoryQueriedSuccess),
770    PageOutArchived(PageOutArchivedSuccess),
771    PayloadLoaded(PayloadLoadedSuccess),
772    MilestoneEvaluated(MilestoneEvaluatedSuccess),
773    /// spc_011-C-02.
774    PromptMeasured(PromptMeasuredSuccess),
775}
776
777impl EffectSuccess {
778    pub fn tag(&self) -> EffectSuccessTag {
779        match self {
780            Self::Provider(_) => EffectSuccessTag::Provider,
781            Self::Tools(_) => EffectSuccessTag::Tools,
782            Self::Approval(_) => EffectSuccessTag::Approval,
783            Self::TasksSpawned(_) => EffectSuccessTag::TasksSpawned,
784            Self::TasksPreempted(_) => EffectSuccessTag::TasksPreempted,
785            Self::MemoryPersisted(_) => EffectSuccessTag::MemoryPersisted,
786            Self::MemoryQueried(_) => EffectSuccessTag::MemoryQueried,
787            Self::PageOutArchived(_) => EffectSuccessTag::PageOutArchived,
788            Self::PayloadLoaded(_) => EffectSuccessTag::PayloadLoaded,
789            Self::MilestoneEvaluated(_) => EffectSuccessTag::MilestoneEvaluated,
790            Self::PromptMeasured(_) => EffectSuccessTag::PromptMeasured,
791        }
792    }
793}
794
795/// The discriminant of [`EffectSuccess`], usable without a payload.
796#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
797#[serde(rename_all = "snake_case")]
798pub enum EffectSuccessTag {
799    Provider,
800    Tools,
801    Approval,
802    TasksSpawned,
803    TasksPreempted,
804    MemoryPersisted,
805    MemoryQueried,
806    PageOutArchived,
807    PayloadLoaded,
808    MilestoneEvaluated,
809    /// spc_011-C-02.
810    PromptMeasured,
811}
812
813impl EffectSuccessTag {
814    pub const ALL: [Self; 11] = [
815        Self::Provider,
816        Self::Tools,
817        Self::Approval,
818        Self::TasksSpawned,
819        Self::TasksPreempted,
820        Self::MemoryPersisted,
821        Self::MemoryQueried,
822        Self::PageOutArchived,
823        Self::PayloadLoaded,
824        Self::MilestoneEvaluated,
825        Self::PromptMeasured,
826    ];
827
828    pub fn as_str(self) -> &'static str {
829        match self {
830            Self::Provider => "provider",
831            Self::Tools => "tools",
832            Self::Approval => "approval",
833            Self::TasksSpawned => "tasks_spawned",
834            Self::TasksPreempted => "tasks_preempted",
835            Self::MemoryPersisted => "memory_persisted",
836            Self::MemoryQueried => "memory_queried",
837            Self::PageOutArchived => "page_out_archived",
838            Self::PayloadLoaded => "payload_loaded",
839            Self::MilestoneEvaluated => "milestone_evaluated",
840            Self::PromptMeasured => "prompt_measured",
841        }
842    }
843
844    /// The effect kind this success resolves. Inverse of
845    /// [`EffectKindTag::expected_success`].
846    pub fn resolves(self) -> EffectKindTag {
847        match self {
848            Self::Provider => EffectKindTag::CallProvider,
849            Self::Tools => EffectKindTag::ExecuteTools,
850            Self::Approval => EffectKindTag::RequestApproval,
851            Self::TasksSpawned => EffectKindTag::SpawnTasks,
852            Self::TasksPreempted => EffectKindTag::PreemptTasks,
853            Self::MemoryPersisted => EffectKindTag::PersistMemory,
854            Self::MemoryQueried => EffectKindTag::QueryMemory,
855            Self::PageOutArchived => EffectKindTag::ArchivePageOut,
856            Self::PayloadLoaded => EffectKindTag::LoadPayload,
857            Self::MilestoneEvaluated => EffectKindTag::EvaluateMilestone,
858            Self::PromptMeasured => EffectKindTag::MeasurePrompt,
859        }
860    }
861}
862
863impl fmt::Display for EffectSuccessTag {
864    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865        f.write_str(self.as_str())
866    }
867}
868
869// ---------------------------------------------------------------------------------------------
870// §7.9 · success payloads
871// ---------------------------------------------------------------------------------------------
872
873#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
874#[serde(deny_unknown_fields)]
875pub struct ProviderSuccess {
876    pub outcome: ProviderOutcome,
877}
878
879/// A provider call that reached the vendor and came back with something the kernel can reason
880/// about. `now_ms` is gone (DEC-2): the historical field fed the governance rate limiter directly
881/// and changed the byte fingerprint of every redelivery, which made idempotent replay unreachable
882/// on the highest-frequency path in the system.
883#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
884#[serde(tag = "kind", rename_all = "snake_case")]
885pub enum ProviderOutcome {
886    Completed(ProviderCompleted),
887    /// A **semantic** outcome, not a transport failure: the kernel compacts and re-emits a
888    /// provider effect. Classifying it as a failure is what used to collapse a recoverable
889    /// overflow into an unrecoverable one.
890    ContextOverflow(ProviderContextOverflow),
891}
892
893#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
894#[serde(deny_unknown_fields)]
895pub struct ProviderCompleted {
896    pub message: ProviderMessage,
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub observed_input_tokens: Option<u32>,
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub observed_output_tokens: Option<u32>,
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub stop_reason: Option<ProviderStopReason>,
903}
904
905#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
906#[serde(deny_unknown_fields)]
907pub struct ProviderContextOverflow {
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub observed_input_tokens: Option<u32>,
910}
911
912/// Typed stop reason — the canonical vocabulary every provider family maps *onto*.
913///
914/// The historical free-form string let each host forward its vendor's spelling verbatim, so the
915/// same event arrived as `end_turn`, `stop`, `STOP` or `FINISH_REASON_STOP` depending on who was
916/// calling. None of those decode here: a vendor word is a host-side mapping input, and `Other` is
917/// the honest landing place for anything the six do not cover. `Other` is deliberately *not* a
918/// pass-through — it carries no vendor text, because a string the kernel keeps is a string
919/// something will eventually branch on.
920#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
921#[serde(rename_all = "snake_case")]
922pub enum ProviderStopReason {
923    EndTurn,
924    ToolUse,
925    MaxTokens,
926    StopSequence,
927    ContentFilter,
928    Other,
929}
930
931impl ProviderStopReason {
932    pub const ALL: [Self; 6] = [
933        Self::EndTurn,
934        Self::ToolUse,
935        Self::MaxTokens,
936        Self::StopSequence,
937        Self::ContentFilter,
938        Self::Other,
939    ];
940
941    pub fn as_str(self) -> &'static str {
942        match self {
943            Self::EndTurn => "end_turn",
944            Self::ToolUse => "tool_use",
945            Self::MaxTokens => "max_tokens",
946            Self::StopSequence => "stop_sequence",
947            Self::ContentFilter => "content_filter",
948            Self::Other => "other",
949        }
950    }
951}
952
953impl fmt::Display for ProviderStopReason {
954    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
955        f.write_str(self.as_str())
956    }
957}
958
959#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
960#[serde(deny_unknown_fields)]
961pub struct ToolsSuccess {
962    pub results: Vec<ToolResultPayload>,
963}
964
965#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
966#[serde(deny_unknown_fields)]
967pub struct ApprovalSuccess {
968    #[serde(default, skip_serializing_if = "Vec::is_empty")]
969    pub approved_call_ids: Vec<CallId>,
970    #[serde(default, skip_serializing_if = "Vec::is_empty")]
971    pub denied_call_ids: Vec<CallId>,
972}
973
974#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
975#[serde(deny_unknown_fields)]
976pub struct TasksSpawnedSuccess {
977    pub attempts: Vec<TaskLaunchOutcome>,
978}
979
980/// The launch acknowledgement. Historically this was an unconditional echo of the ids with a
981/// hard-coded empty failure list, so "spawned" and "actually started" were indistinguishable.
982#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
983#[serde(deny_unknown_fields)]
984pub struct TaskLaunchOutcome {
985    pub task_id: TaskId,
986    pub attempt_id: AttemptId,
987    pub outcome: TaskLaunchStatus,
988}
989
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
991#[serde(tag = "status", rename_all = "snake_case")]
992pub enum TaskLaunchStatus {
993    Started(TaskLaunchStarted),
994    Failed(TaskLaunchFailed),
995}
996
997#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
998#[serde(deny_unknown_fields)]
999pub struct TaskLaunchStarted {}
1000
1001#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1002#[serde(deny_unknown_fields)]
1003pub struct TaskLaunchFailed {
1004    pub failure: TaskLaunchFailure,
1005}
1006
1007/// Per-launch failure. Reuses the cross-effect classification so a launch failure is triaged with
1008/// the same vocabulary as every other host failure.
1009#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1010#[serde(deny_unknown_fields)]
1011pub struct TaskLaunchFailure {
1012    pub kind: HostEffectFailureKind,
1013    #[serde(default, skip_serializing_if = "String::is_empty")]
1014    pub message: String,
1015}
1016
1017#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1018#[serde(deny_unknown_fields)]
1019pub struct TasksPreemptedSuccess {
1020    pub attempts: Vec<TaskPreemptOutcome>,
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1024#[serde(deny_unknown_fields)]
1025pub struct TaskPreemptOutcome {
1026    pub task_id: TaskId,
1027    pub attempt_id: AttemptId,
1028    pub outcome: TaskPreemptStatus,
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1032#[serde(tag = "status", rename_all = "snake_case")]
1033pub enum TaskPreemptStatus {
1034    Preempted(TaskPreempted),
1035    /// The attempt had already finished when the preemption arrived — a benign race, not a
1036    /// failure, and the kernel must be able to tell the two apart.
1037    AlreadyFinished(TaskAlreadyFinished),
1038    Failed(TaskPreemptFailed),
1039}
1040
1041#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1042#[serde(deny_unknown_fields)]
1043pub struct TaskPreempted {}
1044
1045#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1046#[serde(deny_unknown_fields)]
1047pub struct TaskAlreadyFinished {}
1048
1049#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1050#[serde(deny_unknown_fields)]
1051pub struct TaskPreemptFailed {
1052    pub failure: TaskLaunchFailure,
1053}
1054
1055#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1056#[serde(deny_unknown_fields)]
1057pub struct MemoryPersistedSuccess {
1058    pub receipt: MemoryPersistReceipt,
1059}
1060
1061#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1062#[serde(deny_unknown_fields)]
1063pub struct MemoryPersistReceipt {
1064    pub binding_id: MemoryBindingId,
1065    pub record_ref: MemoryRecordRef,
1066    pub digest: Digest,
1067}
1068
1069#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1070#[serde(deny_unknown_fields)]
1071pub struct MemoryQueriedSuccess {
1072    pub recalls: Vec<MemoryRecall>,
1073}
1074
1075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1076#[serde(deny_unknown_fields)]
1077pub struct MemoryRecall {
1078    pub record_ref: MemoryRecordRef,
1079    pub name: String,
1080    pub kind: MemoryKind,
1081    pub content: String,
1082    /// Observation-only relevance score. Finite by construction and never a branch input —
1083    /// thresholds that gate kernel decisions use fixed-point `Ppm`.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub score: Option<FiniteF64>,
1086}
1087
1088#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1089#[serde(deny_unknown_fields)]
1090pub struct PageOutArchivedSuccess {
1091    pub receipt: ArchiveReceipt,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1095#[serde(deny_unknown_fields)]
1096pub struct ArchiveReceipt {
1097    pub handle_id: HandleId,
1098    pub payload_ref: PayloadRef,
1099    pub digest: Digest,
1100    pub original_size: WireU64,
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1104#[serde(deny_unknown_fields)]
1105pub struct PayloadLoadedSuccess {
1106    pub handle_id: HandleId,
1107    pub payload: InlinePayload,
1108}
1109
1110/// A payload the host paged back in. `digest` and `original_size` let the kernel verify it is the
1111/// same body it archived.
1112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1113#[serde(deny_unknown_fields)]
1114pub struct InlinePayload {
1115    pub content: String,
1116    pub digest: Digest,
1117    pub original_size: WireU64,
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1121#[serde(deny_unknown_fields)]
1122pub struct MilestoneEvaluatedSuccess {
1123    pub result: MilestoneCheckResult,
1124}
1125
1126/// A milestone verdict. Note what is *not* here: an `error` field. Milestone execution failures
1127/// travel the same [`HostEffectFailure`] path as every other effect (B7) instead of being folded
1128/// into the verdict, so "the verifier could not run" and "the verifier said no" stay distinct.
1129#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct MilestoneCheckResult {
1132    pub phase_id: String,
1133    pub passed: bool,
1134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1135    pub failed_criteria: Vec<String>,
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub score: Option<FiniteF64>,
1138    #[serde(default, skip_serializing_if = "String::is_empty")]
1139    pub notes: String,
1140}
1141
1142/// spc_011-C-02: the answer to a `MeasurePrompt` effect — one [`PromptMeasurement`] fact, wrapped
1143/// the same way every other effect wraps its one matching success payload.
1144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1145#[serde(deny_unknown_fields)]
1146pub struct PromptMeasuredSuccess {
1147    pub measurement: PromptMeasurement,
1148}
1149
1150// ---------------------------------------------------------------------------------------------
1151// §7.10 · inline / external payload
1152// ---------------------------------------------------------------------------------------------
1153
1154/// One tool result, either inline or already persisted by the host.
1155///
1156/// The host persists the body **before** submitting `External` and hands the kernel only a
1157/// reference, a digest, a size and a preview. That is the whole point: the large body never
1158/// crosses core and never enters a journal record, whereas the historical path pushed the full
1159/// body in, had the kernel compute a preview, pushed the **full body back out** to be spooled, and
1160/// wrote it to the journal twice.
1161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1162#[serde(tag = "kind", rename_all = "snake_case")]
1163pub enum ToolResultPayload {
1164    Inline(InlineToolResult),
1165    External(ExternalToolResult),
1166}
1167
1168impl ToolResultPayload {
1169    pub fn call_id(&self) -> &CallId {
1170        match self {
1171            Self::Inline(inline) => &inline.call_id,
1172            Self::External(external) => &external.call_id,
1173        }
1174    }
1175
1176    /// The two failure facts, read the same way whichever side of the inline threshold the body
1177    /// landed on. Every caller that branches on failure must go through these: §7.10 rule 9 makes
1178    /// failure orthogonal to residency, so a check that is total over one arm and not the other is
1179    /// exactly the bug the rule exists to prevent.
1180    pub fn disposition(&self) -> ToolResultDisposition {
1181        match self {
1182            Self::Inline(inline) => inline.result.disposition,
1183            Self::External(external) => external.disposition,
1184        }
1185    }
1186
1187    pub fn is_error(&self) -> bool {
1188        match self {
1189            Self::Inline(inline) => inline.result.is_error,
1190            Self::External(external) => external.is_error,
1191        }
1192    }
1193}
1194
1195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1196#[serde(deny_unknown_fields)]
1197pub struct InlineToolResult {
1198    pub call_id: CallId,
1199    pub result: ToolResult,
1200}
1201
1202/// A tool result whose body the host persisted before submitting.
1203///
1204/// **Failure and size are orthogonal** (§7.10 rule 9). A tool that fails after producing a huge
1205/// diagnostic body is not a rare shape — it is the *common* one — so this arm carries the same two
1206/// failure facts as [`ToolResult`]: whether the call errored, and whether the executor could keep
1207/// going. Without them an externalised failure was indistinguishable from an externalised success,
1208/// and a fatal one could not exist at all.
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1210#[serde(deny_unknown_fields)]
1211pub struct ExternalToolResult {
1212    pub call_id: CallId,
1213    /// Opaque locator — see [`PayloadRef`]. Not a path, and never resolved by the kernel.
1214    pub payload_ref: PayloadRef,
1215    pub digest: Digest,
1216    pub original_size: WireU64,
1217    /// Bounded excerpt the kernel may keep resident and show the model.
1218    pub preview: String,
1219    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1220    pub is_error: bool,
1221    /// Mandatory, exactly as on the inline arm: the dangerous value must never be the silent one.
1222    pub disposition: ToolResultDisposition,
1223}
1224
1225/// An inline tool result body. `call_id` lives on [`InlineToolResult`], not here — one id, one
1226/// place.
1227///
1228/// spc_011-B-03: **same name, different thing** as `types::message::ContentPart::ToolResult` and
1229/// `types::message::ToolResult` — see the doc comment on the former for the full three-way
1230/// distinction. This one is the Host→Kernel execution-result *wire payload* (`#[serde(deny_unknown_fields)]`,
1231/// crosses all 4 SDK bindings) — semantically "the Host is telling the Kernel a tool call
1232/// finished, here's the result," not "a content block being rendered into a provider request."
1233/// `output` is the text projection. `durable_content`, when present, carries the
1234/// versioned provider-neutral blocks that the transcript and checkpoint preserve.
1235#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1236#[serde(deny_unknown_fields)]
1237pub struct ToolResult {
1238    pub output: String,
1239    #[serde(default, skip_serializing_if = "Option::is_none")]
1240    pub durable_content: Option<DurableContent>,
1241    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1242    pub is_error: bool,
1243    /// Whether the executor can keep going after this result. **Mandatory** — see
1244    /// [`ToolResultDisposition`].
1245    pub disposition: ToolResultDisposition,
1246    #[serde(default, skip_serializing_if = "Option::is_none")]
1247    pub tokens: Option<u32>,
1248}
1249
1250/// Can the batch continue past this result?
1251///
1252/// Binary and **required**: it has no serde default, so every result states it. The alternative —
1253/// defaulting to `recoverable` to save wire bytes — makes the dangerous value the silent one, which
1254/// is the wrong way round for a fail-closed contract, and it makes "the host did not say" and "the
1255/// host said it is fine" indistinguishable at exactly the point where they differ most.
1256///
1257/// The historical `is_fatal` + six-way `ToolErrorKind` collapsed to these two values because only
1258/// two distinctions ever changed a kernel decision. `user_interrupt` in particular is **not** here:
1259/// cancellation travels on `HostControl::Cancel` and nothing else (§7.9), so the rollback rung it
1260/// used to trigger was retired rather than being re-expressible as a tool result.
1261#[derive(
1262    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1263)]
1264#[serde(rename_all = "snake_case")]
1265pub enum ToolResultDisposition {
1266    /// The model can adapt and the executor kept going. Ordinary failures are this: a committed,
1267    /// model-visible error result is the trained-set convention, and erasing the attempt teaches
1268    /// the model nothing.
1269    #[default]
1270    Recoverable,
1271    /// The executor stopped. Every call this batch dispatched but did not answer is closed out by
1272    /// the kernel with a visible "not executed" result, so the tool_call/tool_result pairing the
1273    /// provider contract requires stays total (see the tools arm of the driver's resolution).
1274    Fatal,
1275}
1276
1277impl ToolResultDisposition {
1278    pub const ALL: [Self; 2] = [Self::Recoverable, Self::Fatal];
1279
1280    pub fn as_str(self) -> &'static str {
1281        match self {
1282            Self::Recoverable => "recoverable",
1283            Self::Fatal => "fatal",
1284        }
1285    }
1286
1287    pub fn is_fatal(self) -> bool {
1288        matches!(self, Self::Fatal)
1289    }
1290}
1291
1292impl fmt::Display for ToolResultDisposition {
1293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1294        f.write_str(self.as_str())
1295    }
1296}
1297
1298/// Where a P3 handle's body currently lives.
1299///
1300/// `External` and `PagedOut` are deliberately different states: the first is "generated over the
1301/// inline limit and never was resident", the second is "was resident, evicted under pressure".
1302/// The historical implementation collapsed both into one `spool` notion, which is why a paged-out
1303/// body and an oversized one could not be told apart on restore.
1304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1305#[serde(tag = "kind", rename_all = "snake_case")]
1306pub enum PayloadResidency {
1307    Resident(ResidentPayload),
1308    External(ExternalResidency),
1309    PagedOut(PagedOutResidency),
1310    /// Body dropped entirely; only the preview and accounting survive.
1311    Collapsed(CollapsedPayload),
1312}
1313
1314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1315#[serde(deny_unknown_fields)]
1316pub struct ResidentPayload {}
1317
1318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1319#[serde(deny_unknown_fields)]
1320pub struct ExternalResidency {
1321    pub payload_ref: PayloadRef,
1322    pub digest: Digest,
1323    pub original_size: WireU64,
1324}
1325
1326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1327#[serde(deny_unknown_fields)]
1328pub struct PagedOutResidency {
1329    pub payload_ref: PayloadRef,
1330    pub digest: Digest,
1331}
1332
1333#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1334#[serde(deny_unknown_fields)]
1335pub struct CollapsedPayload {}
1336
1337#[cfg(test)]
1338mod tests {
1339    use std::collections::BTreeSet;
1340    use std::fs;
1341    use std::path::PathBuf;
1342
1343    use serde_json::{Value, json};
1344
1345    use crate::context::measurement::{
1346        MeasurementConfidence, MeasurementSource, PromptMeasurement,
1347    };
1348
1349    use super::super::*;
1350
1351    // -----------------------------------------------------------------------------------------
1352    // helpers
1353    // -----------------------------------------------------------------------------------------
1354
1355    fn fixture_dir() -> PathBuf {
1356        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
1357    }
1358
1359    fn fixtures_with_prefix(prefix: &str) -> Vec<(String, Value)> {
1360        let dir = fixture_dir();
1361        let mut names: Vec<String> = fs::read_dir(&dir)
1362            .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()))
1363            .map(|entry| {
1364                entry
1365                    .expect("dir entry")
1366                    .file_name()
1367                    .to_string_lossy()
1368                    .to_string()
1369            })
1370            .filter(|name| name.ends_with(".json") && name.starts_with(prefix))
1371            .collect();
1372        names.sort();
1373        assert!(!names.is_empty(), "no {prefix}*.json fixtures");
1374        names
1375            .into_iter()
1376            .map(|name| {
1377                let raw = fs::read_to_string(dir.join(&name)).unwrap();
1378                let value: Value = serde_json::from_str(&raw).unwrap();
1379                (name, value)
1380            })
1381            .collect()
1382    }
1383
1384    fn keys(value: &Value, out: &mut BTreeSet<String>) {
1385        match value {
1386            Value::Object(map) => {
1387                for (key, child) in map {
1388                    out.insert(key.clone());
1389                    keys(child, out);
1390                }
1391            }
1392            Value::Array(items) => items.iter().for_each(|item| keys(item, out)),
1393            _ => {}
1394        }
1395    }
1396
1397    fn effect_json(outcome: Value) -> String {
1398        serde_json::to_string(&json!({
1399            "operation_id": "op-1",
1400            "input_id": "in-1",
1401            "observed_at_ms": "1700000000000",
1402            "input": { "kind": "resolve_effect", "effect_id": "op-1:step:1:effect:0", "outcome": outcome },
1403        }))
1404        .unwrap()
1405    }
1406
1407    fn decode_effect(outcome: Value) -> Result<WireEnvelope, WireRejection> {
1408        decode_envelope_json(&effect_json(outcome), &KernelBootstrapLimits::default())
1409    }
1410
1411    fn call_id(id: &str) -> CallId {
1412        CallId::new(id).unwrap()
1413    }
1414
1415    fn digest() -> Digest {
1416        Digest::new("sha256:3b1f4a7c9e2d05186a4c7f0b9d3e8c25714f6a0b8c5d2e9f1a3b6c8d0e2f4a61")
1417            .unwrap()
1418    }
1419
1420    fn payload_ref() -> PayloadRef {
1421        PayloadRef::new("payload:01J8Y2QK7C4N0V").unwrap()
1422    }
1423
1424    fn binding() -> MemoryAccessBinding {
1425        MemoryAccessBinding {
1426            binding_id: MemoryBindingId::new("binding-a").unwrap(),
1427            capabilities: MemoryCapabilities {
1428                read: true,
1429                write: true,
1430            },
1431        }
1432    }
1433
1434    fn causation() -> SyscallCausation {
1435        SyscallCausation::ProviderTool(ProviderToolCausation {
1436            provider_effect_id: EffectId::new("op-1:step:1:effect:0").unwrap(),
1437            call_id: call_id("call-1"),
1438            task_id: TaskId::new("task-1").unwrap(),
1439        })
1440    }
1441
1442    /// One sample per [`EffectKind`] variant, in [`EffectKindTag::ALL`] order.
1443    fn effect_samples() -> Vec<EffectKind> {
1444        vec![
1445            EffectKind::CallProvider(CallProviderEffect {
1446                context: RenderedContext::default(),
1447                tools: vec![ToolSchema {
1448                    name: "read_file".to_string(),
1449                    description: "read a file".to_string(),
1450                    parameters: BoundedJson::null(),
1451                }],
1452            }),
1453            EffectKind::ExecuteTools(ExecuteToolsEffect {
1454                calls: vec![ToolCall {
1455                    call_id: call_id("call-1"),
1456                    name: "read_file".to_string(),
1457                    arguments: BoundedJson::null(),
1458                }],
1459            }),
1460            EffectKind::RequestApproval(RequestApprovalEffect {
1461                requests: vec![ApprovalRequest {
1462                    call_id: call_id("call-1"),
1463                    tool_name: "rm".to_string(),
1464                    arguments: BoundedJson::null(),
1465                    reason: Some("destructive".to_string()),
1466                }],
1467            }),
1468            EffectKind::SpawnTasks(SpawnTasksEffect {
1469                tasks: vec![TaskLaunch {
1470                    task_id: TaskId::new("task-1").unwrap(),
1471                    attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1472                    launch_token: LaunchToken::new("launch-1").unwrap(),
1473                    node_id: NodeId::new("node-a").unwrap(),
1474                    spec: LogicalAgentSpec::new("research"),
1475                }],
1476                budget: None,
1477            }),
1478            EffectKind::PreemptTasks(PreemptTasksEffect {
1479                attempts: vec![TaskAttemptRef {
1480                    task_id: TaskId::new("task-1").unwrap(),
1481                    attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1482                }],
1483                reason: "budget exhausted".to_string(),
1484            }),
1485            EffectKind::PersistMemory(PersistMemoryEffect {
1486                binding: binding(),
1487                memory: CanonicalMemoryWrite {
1488                    name: "release pipeline".to_string(),
1489                    kind: MemoryKind::Project,
1490                    content: "tag v* publishes".to_string(),
1491                    description: String::new(),
1492                    evidence_refs: Vec::new(),
1493                    accepted_at_ms: WireU64::new(1_700_000_000_000),
1494                    causation: causation(),
1495                },
1496            }),
1497            EffectKind::QueryMemory(QueryMemoryEffect {
1498                binding: binding(),
1499                query: CanonicalMemoryQuery {
1500                    text: "release".to_string(),
1501                    kinds: vec![MemoryKind::Project],
1502                    accepted_at_ms: WireU64::new(1_700_000_000_000),
1503                    causation: causation(),
1504                },
1505                requested_k: 5,
1506            }),
1507            EffectKind::ArchivePageOut(ArchivePageOutEffect {
1508                handle_id: HandleId::new("handle-9").unwrap(),
1509                payload: PageOutPayload {
1510                    content: "the full tool output".to_string(),
1511                    digest: digest(),
1512                    original_size: WireU64::new(262_144),
1513                    preview: "the full…".to_string(),
1514                },
1515            }),
1516            EffectKind::LoadPayload(LoadPayloadEffect {
1517                handle_id: HandleId::new("handle-9").unwrap(),
1518                payload_ref: payload_ref(),
1519            }),
1520            EffectKind::EvaluateMilestone(EvaluateMilestoneEffect {
1521                request: MilestoneRequest {
1522                    contract_id: "brief-quality-primary".to_string(),
1523                    phase_id: "phase-2".to_string(),
1524                },
1525            }),
1526            EffectKind::MeasurePrompt(MeasurePromptEffect {
1527                context: RenderedContext::default(),
1528                tools: Vec::new(),
1529            }),
1530        ]
1531    }
1532
1533    /// One sample per [`EffectSuccess`] variant, in the same order as [`effect_samples`].
1534    fn success_samples() -> Vec<EffectSuccess> {
1535        vec![
1536            EffectSuccess::Provider(ProviderSuccess {
1537                outcome: ProviderOutcome::Completed(ProviderCompleted {
1538                    message: ProviderMessage {
1539                        role: MessageRole::Assistant,
1540                        content: "done".to_string(),
1541                        tool_calls: Vec::new(),
1542                        tool_call_id: None,
1543                        tokens: None,
1544                    },
1545                    observed_input_tokens: Some(120),
1546                    observed_output_tokens: Some(8),
1547                    stop_reason: Some(ProviderStopReason::EndTurn),
1548                }),
1549            }),
1550            EffectSuccess::Tools(ToolsSuccess {
1551                results: vec![
1552                    ToolResultPayload::Inline(InlineToolResult {
1553                        call_id: call_id("call-1"),
1554                        result: ToolResult {
1555                            output: "ok".to_string(),
1556                            durable_content: None,
1557                            is_error: false,
1558                            disposition: ToolResultDisposition::Recoverable,
1559                            tokens: Some(2),
1560                        },
1561                    }),
1562                    ToolResultPayload::External(ExternalToolResult {
1563                        call_id: call_id("call-2"),
1564                        payload_ref: payload_ref(),
1565                        digest: digest(),
1566                        original_size: WireU64::new(1_048_576),
1567                        preview: "total 42".to_string(),
1568                        is_error: false,
1569                        disposition: ToolResultDisposition::Recoverable,
1570                    }),
1571                ],
1572            }),
1573            EffectSuccess::Approval(ApprovalSuccess {
1574                approved_call_ids: vec![call_id("call-1")],
1575                denied_call_ids: vec![call_id("call-2")],
1576            }),
1577            EffectSuccess::TasksSpawned(TasksSpawnedSuccess {
1578                attempts: vec![TaskLaunchOutcome {
1579                    task_id: TaskId::new("task-1").unwrap(),
1580                    attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1581                    outcome: TaskLaunchStatus::Started(TaskLaunchStarted {}),
1582                }],
1583            }),
1584            EffectSuccess::TasksPreempted(TasksPreemptedSuccess {
1585                attempts: vec![TaskPreemptOutcome {
1586                    task_id: TaskId::new("task-1").unwrap(),
1587                    attempt_id: AttemptId::new("task-1:attempt:1").unwrap(),
1588                    outcome: TaskPreemptStatus::Preempted(TaskPreempted {}),
1589                }],
1590            }),
1591            EffectSuccess::MemoryPersisted(MemoryPersistedSuccess {
1592                receipt: MemoryPersistReceipt {
1593                    binding_id: MemoryBindingId::new("binding-a").unwrap(),
1594                    record_ref: MemoryRecordRef::new("memory:01J8Y2QK7C4N0W").unwrap(),
1595                    digest: digest(),
1596                },
1597            }),
1598            EffectSuccess::MemoryQueried(MemoryQueriedSuccess {
1599                recalls: vec![MemoryRecall {
1600                    record_ref: MemoryRecordRef::new("memory:01J8Y2QK7C4N0X").unwrap(),
1601                    name: "release pipeline".to_string(),
1602                    kind: MemoryKind::Project,
1603                    content: "tag v* publishes".to_string(),
1604                    score: Some(FiniteF64::new(0.82).unwrap()),
1605                }],
1606            }),
1607            EffectSuccess::PageOutArchived(PageOutArchivedSuccess {
1608                receipt: ArchiveReceipt {
1609                    handle_id: HandleId::new("handle-9").unwrap(),
1610                    payload_ref: payload_ref(),
1611                    digest: digest(),
1612                    original_size: WireU64::new(262_144),
1613                },
1614            }),
1615            EffectSuccess::PayloadLoaded(PayloadLoadedSuccess {
1616                handle_id: HandleId::new("handle-9").unwrap(),
1617                payload: InlinePayload {
1618                    content: "the full tool output".to_string(),
1619                    digest: digest(),
1620                    original_size: WireU64::new(262_144),
1621                },
1622            }),
1623            EffectSuccess::MilestoneEvaluated(MilestoneEvaluatedSuccess {
1624                result: MilestoneCheckResult {
1625                    phase_id: "phase-2".to_string(),
1626                    passed: true,
1627                    failed_criteria: Vec::new(),
1628                    score: None,
1629                    notes: String::new(),
1630                },
1631            }),
1632            EffectSuccess::PromptMeasured(PromptMeasuredSuccess {
1633                measurement: PromptMeasurement {
1634                    input_tokens: 4200,
1635                    source: MeasurementSource::Native {
1636                        provider: "anthropic".to_string(),
1637                    },
1638                    confidence: MeasurementConfidence::Exact,
1639                },
1640            }),
1641        ]
1642    }
1643
1644    fn kernel_effect(effect: EffectKind) -> KernelEffect {
1645        KernelEffect {
1646            effect_id: EffectId::new("op-1:step:1:effect:0").unwrap(),
1647            causation_input_id: InputId::new("in-1").unwrap(),
1648            effect,
1649        }
1650    }
1651
1652    // -----------------------------------------------------------------------------------------
1653    // §7.8 · the effect union
1654    // -----------------------------------------------------------------------------------------
1655
1656    #[test]
1657    fn the_effect_union_is_exactly_the_eleven_host_executable_actions() {
1658        let tags: BTreeSet<&str> = EffectKindTag::ALL.iter().map(|tag| tag.as_str()).collect();
1659        assert_eq!(
1660            tags,
1661            BTreeSet::from([
1662                "call_provider",
1663                "execute_tools",
1664                "request_approval",
1665                "spawn_tasks",
1666                "preempt_tasks",
1667                "persist_memory",
1668                "query_memory",
1669                "archive_page_out",
1670                "load_payload",
1671                "evaluate_milestone",
1672                "measure_prompt",
1673            ])
1674        );
1675        assert_eq!(EffectKindTag::ALL.len(), 11);
1676
1677        let sampled: Vec<EffectKindTag> = effect_samples().iter().map(EffectKind::tag).collect();
1678        assert_eq!(
1679            sampled,
1680            EffectKindTag::ALL.to_vec(),
1681            "one sample per variant"
1682        );
1683    }
1684
1685    #[test]
1686    fn terminal_and_observation_shapes_are_not_effects() {
1687        // §7.8 / §22.7: these all left the effect union. None of them may reappear as a tag.
1688        for gone in [
1689            "done",
1690            "terminal",
1691            "compact",
1692            "sync_compact",
1693            "knowledge_sweep",
1694            "workflow_completed",
1695            "control_rejection",
1696            "signal_disposition",
1697            "budget_usage",
1698            "spool_large_result",
1699        ] {
1700            assert!(
1701                !EffectKindTag::ALL.iter().any(|tag| tag.as_str() == gone),
1702                "{gone} is a terminal or an observation, not an effect"
1703            );
1704            let raw = json!({ "kind": gone });
1705            assert!(serde_json::from_value::<EffectKind>(raw).is_err());
1706        }
1707    }
1708
1709    #[test]
1710    fn every_effect_carries_its_kernel_minted_id_and_causation() {
1711        for effect in effect_samples() {
1712            let value = serde_json::to_value(kernel_effect(effect)).unwrap();
1713            assert_eq!(value["effect_id"], json!("op-1:step:1:effect:0"));
1714            assert_eq!(value["causation_input_id"], json!("in-1"));
1715        }
1716    }
1717
1718    // -----------------------------------------------------------------------------------------
1719    // §7.9 · one matching success per effect, one failure path for all of them
1720    // -----------------------------------------------------------------------------------------
1721
1722    #[test]
1723    fn each_effect_kind_has_exactly_one_matching_success_kind() {
1724        let expected: Vec<EffectSuccessTag> = EffectKindTag::ALL
1725            .iter()
1726            .map(|tag| tag.expected_success())
1727            .collect();
1728        let distinct: BTreeSet<&str> = expected.iter().map(|tag| tag.as_str()).collect();
1729        assert_eq!(
1730            distinct.len(),
1731            EffectKindTag::ALL.len(),
1732            "the effect→success map must be a bijection"
1733        );
1734        assert_eq!(
1735            distinct,
1736            EffectSuccessTag::ALL
1737                .iter()
1738                .map(|tag| tag.as_str())
1739                .collect::<BTreeSet<&str>>()
1740        );
1741
1742        let sampled: Vec<EffectSuccessTag> =
1743            success_samples().iter().map(EffectSuccess::tag).collect();
1744        assert_eq!(
1745            sampled, expected,
1746            "samples must line up 1:1 with the effects"
1747        );
1748    }
1749
1750    #[test]
1751    fn a_resolution_of_the_wrong_kind_is_rejected_for_every_wrong_pair() {
1752        let effects = effect_samples();
1753        let successes = success_samples();
1754        for (i, effect) in effects.iter().enumerate() {
1755            let pending = kernel_effect(effect.clone());
1756            for (j, success) in successes.iter().enumerate() {
1757                let outcome = EffectOutcome::Succeeded(EffectSucceeded {
1758                    result: success.clone(),
1759                });
1760                let verdict = pending.accept_outcome(&outcome);
1761                if i == j {
1762                    assert!(
1763                        verdict.is_ok(),
1764                        "{:?} must accept its own success payload",
1765                        effect.tag()
1766                    );
1767                } else {
1768                    let mismatch = verdict.expect_err("kind mismatch must be refused");
1769                    assert_eq!(mismatch.effect_id, pending.effect_id);
1770                    assert_eq!(mismatch.expected, effect.tag().expected_success());
1771                    assert_eq!(mismatch.received, success.tag());
1772                }
1773            }
1774        }
1775    }
1776
1777    #[test]
1778    fn every_effect_accepts_the_same_host_failure_including_milestone() {
1779        let kinds = [
1780            HostEffectFailureKind::TransportExhausted,
1781            HostEffectFailureKind::ProtocolError,
1782            HostEffectFailureKind::StorageUnavailable,
1783            HostEffectFailureKind::PermissionDenied,
1784            HostEffectFailureKind::ResourceExhausted,
1785            HostEffectFailureKind::Unknown,
1786        ];
1787        assert_eq!(kinds.len(), 6, "§7.9 fixes six executor failure classes");
1788
1789        for effect in effect_samples() {
1790            let pending = kernel_effect(effect);
1791            for kind in kinds {
1792                let outcome = EffectOutcome::Failed(EffectFailed {
1793                    failure: HostEffectFailure {
1794                        kind,
1795                        message: "boom".to_string(),
1796                        retryable: None,
1797                    },
1798                });
1799                assert!(
1800                    pending.accept_outcome(&outcome).is_ok(),
1801                    "{:?} must have the same failure path as every other effect",
1802                    pending.effect.tag()
1803                );
1804            }
1805        }
1806    }
1807
1808    #[test]
1809    fn the_outcome_union_has_exactly_two_arms() {
1810        let arms: BTreeSet<String> = [
1811            EffectOutcome::Succeeded(EffectSucceeded {
1812                result: success_samples().remove(0),
1813            }),
1814            EffectOutcome::Failed(EffectFailed {
1815                failure: HostEffectFailure {
1816                    kind: HostEffectFailureKind::Unknown,
1817                    message: String::new(),
1818                    retryable: None,
1819                },
1820            }),
1821        ]
1822        .iter()
1823        .map(|outcome| {
1824            serde_json::to_value(outcome).unwrap()["status"]
1825                .as_str()
1826                .unwrap()
1827                .to_string()
1828        })
1829        .collect();
1830        assert_eq!(
1831            arms,
1832            BTreeSet::from(["succeeded".to_string(), "failed".to_string()])
1833        );
1834
1835        for third in ["partial", "pending", "deferred", "succeeded_with_warnings"] {
1836            let raw = json!({ "status": third });
1837            assert!(
1838                serde_json::from_value::<EffectOutcome>(raw).is_err(),
1839                "{third} is not an outcome"
1840            );
1841        }
1842    }
1843
1844    #[test]
1845    fn the_kernel_never_retries_so_retryable_is_host_advice_only() {
1846        // DEC-5: `retryable` may travel as diagnostics, but it is optional and the kernel's own
1847        // decision path is the failure kind. Its presence must not change decoding.
1848        let with = json!({
1849            "status": "failed",
1850            "failure": { "kind": "transport_exhausted", "message": "429", "retryable": true },
1851        });
1852        let outcome: EffectOutcome = serde_json::from_value(with).unwrap();
1853        match outcome {
1854            EffectOutcome::Failed(failed) => {
1855                assert_eq!(failed.failure.retryable, Some(true));
1856                assert_eq!(
1857                    failed.failure.kind,
1858                    HostEffectFailureKind::TransportExhausted
1859                );
1860            }
1861            EffectOutcome::Succeeded(_) => panic!("failed outcome decoded as succeeded"),
1862        }
1863    }
1864
1865    // -----------------------------------------------------------------------------------------
1866    // §17 Task 14 · provider outcome and executor failure are the *only* canonical vocabulary
1867    // -----------------------------------------------------------------------------------------
1868
1869    /// The canonical stop-reason vocabulary is closed, and every provider family maps onto it.
1870    ///
1871    /// Task 14's "core does not parse a raw vendor error string" has a quieter twin: core does not
1872    /// accept a raw vendor *word* either. Every spelling below is a real stop/finish reason from
1873    /// some vendor's wire, and none of them decodes — a host must map before it submits.
1874    #[test]
1875    fn every_provider_family_maps_onto_the_canonical_stop_reason_vocabulary() {
1876        let canonical: BTreeSet<&str> = ProviderStopReason::ALL
1877            .iter()
1878            .map(|reason| reason.as_str())
1879            .collect();
1880        assert_eq!(
1881            canonical,
1882            BTreeSet::from([
1883                "end_turn",
1884                "tool_use",
1885                "max_tokens",
1886                "stop_sequence",
1887                "content_filter",
1888                "other",
1889            ])
1890        );
1891        for reason in ProviderStopReason::ALL {
1892            let decoded: ProviderStopReason =
1893                serde_json::from_value(json!(reason.as_str())).unwrap();
1894            assert_eq!(decoded, reason);
1895        }
1896
1897        // one row per vendor family the SDKs actually speak
1898        for vendor_word in [
1899            "stop",               // OpenAI chat completions
1900            "length",             // OpenAI chat completions
1901            "tool_calls",         // OpenAI chat completions
1902            "function_call",      // OpenAI protocol spelling
1903            "content_filter",     // OpenAI — same word, different meaning class
1904            "STOP",               // Gemini (SCREAMING_CASE)
1905            "MAX_TOKENS",         // Gemini
1906            "SAFETY",             // Gemini
1907            "FINISH_REASON_STOP", // Gemini proto spelling
1908            "eos",                // several open-weight servers
1909            "eos_token",          // llama.cpp / vLLM
1910            "sensitive",          // GLM
1911            "insufficient_system_resource",
1912        ] {
1913            let decoded = serde_json::from_value::<ProviderStopReason>(json!(vendor_word));
1914            if vendor_word == "content_filter" {
1915                assert!(decoded.is_ok(), "content_filter *is* canonical");
1916                continue;
1917            }
1918            assert!(
1919                decoded.is_err(),
1920                "{vendor_word:?} is a vendor spelling; the host maps it, core never learns it"
1921            );
1922        }
1923
1924        // `other` is the honest landing place, and it carries no vendor text with it
1925        let value = serde_json::to_value(ProviderStopReason::Other).unwrap();
1926        assert_eq!(value, json!("other"));
1927        assert!(
1928            serde_json::from_value::<ProviderStopReason>(
1929                json!({ "kind": "other", "raw": "insufficient_system_resource" })
1930            )
1931            .is_err(),
1932            "`other` is not a pass-through for vendor text"
1933        );
1934    }
1935
1936    /// The six executor failure classes are the whole vocabulary, and the classes a vendor would
1937    /// reach for are deliberately missing.
1938    ///
1939    /// * rate-limit / unavailable → the host retries and reports `transport_exhausted` when spent;
1940    /// * cancellation → `HostControl::Cancel`, never an effect failure;
1941    /// * context overflow → a *semantic* provider outcome, not a failure.
1942    #[test]
1943    fn no_vendor_failure_vocabulary_is_expressible_on_the_canonical_face() {
1944        assert_eq!(HostEffectFailureKind::ALL.len(), 6);
1945        let canonical: BTreeSet<&str> = HostEffectFailureKind::ALL
1946            .iter()
1947            .map(|kind| kind.as_str())
1948            .collect();
1949        assert_eq!(
1950            canonical,
1951            BTreeSet::from([
1952                "transport_exhausted",
1953                "protocol_error",
1954                "storage_unavailable",
1955                "permission_denied",
1956                "resource_exhausted",
1957                "unknown",
1958            ])
1959        );
1960
1961        for absent in [
1962            "rate_limited",
1963            "rate_limit_exceeded",
1964            "too_many_requests",
1965            "overloaded",
1966            "service_unavailable",
1967            "server_error",
1968            "timeout",
1969            "context_length_exceeded",
1970            "context_overflow",
1971            "cancelled",
1972            "canceled",
1973            "aborted",
1974            "user_interrupt",
1975            "interrupted",
1976        ] {
1977            assert!(
1978                serde_json::from_value::<HostEffectFailureKind>(json!(absent)).is_err(),
1979                "{absent:?} must not be an executor failure class"
1980            );
1981            assert!(
1982                !canonical.contains(absent),
1983                "{absent:?} leaked into the canonical vocabulary"
1984            );
1985        }
1986
1987        // A retry ladder that ran and lost reaches the kernel as exactly one code.
1988        let spent: EffectOutcome = serde_json::from_value(json!({
1989            "status": "failed",
1990            "failure": {
1991                "kind": "transport_exhausted",
1992                "message": "5 attempts over 41s",
1993                "retryable": false,
1994            },
1995        }))
1996        .unwrap();
1997        let EffectOutcome::Failed(failed) = spent else {
1998            panic!("a spent ladder is a failure");
1999        };
2000        assert_eq!(
2001            failed.failure.kind,
2002            HostEffectFailureKind::TransportExhausted
2003        );
2004    }
2005
2006    /// `retryable` is advice with the same (null) meaning on all six kinds, and DEC-5 is what makes
2007    /// that safe: the kernel's decision comes from the effect kind it published, so the flag has no
2008    /// branch to select. Encoding-level half of the claim; the behavioural differential lives in
2009    /// `driver.rs`.
2010    #[test]
2011    fn retryable_is_uniformly_optional_advice_across_all_six_failure_kinds() {
2012        for kind in HostEffectFailureKind::ALL {
2013            for retryable in [None, Some(true), Some(false)] {
2014                let mut failure = json!({ "kind": kind.as_str(), "message": "boom" });
2015                if let Some(flag) = retryable {
2016                    failure
2017                        .as_object_mut()
2018                        .unwrap()
2019                        .insert("retryable".to_string(), json!(flag));
2020                }
2021                let outcome: EffectOutcome =
2022                    serde_json::from_value(json!({ "status": "failed", "failure": failure }))
2023                        .unwrap();
2024                let EffectOutcome::Failed(failed) = outcome else {
2025                    panic!("failure decoded as success");
2026                };
2027                assert_eq!(failed.failure.kind, kind);
2028                assert_eq!(failed.failure.retryable, retryable);
2029            }
2030        }
2031    }
2032
2033    // -----------------------------------------------------------------------------------------
2034    // §7.10 · tool result disposition (adjudication §5m item 2)
2035    // -----------------------------------------------------------------------------------------
2036
2037    #[test]
2038    fn a_tool_result_must_state_whether_the_batch_can_continue() {
2039        // required: no serde default, so "the host did not say" is not a decodable state
2040        assert_eq!(
2041            decode_effect(json!({
2042                "status": "succeeded",
2043                "result": { "kind": "tools", "results": [
2044                    { "kind": "inline", "call_id": "c-1", "result": { "output": "ok" } }] },
2045            }))
2046            .unwrap_err()
2047            .kind,
2048            WireRejectionKind::MissingField
2049        );
2050
2051        // exactly two values
2052        assert_eq!(
2053            ToolResultDisposition::ALL
2054                .iter()
2055                .map(|d| d.as_str())
2056                .collect::<BTreeSet<&str>>(),
2057            BTreeSet::from(["recoverable", "fatal"])
2058        );
2059        for value in ["recoverable", "fatal"] {
2060            let decoded: ToolResultDisposition = serde_json::from_value(json!(value)).expect(value);
2061            assert_eq!(decoded.as_str(), value);
2062            assert_eq!(decoded.is_fatal(), value == "fatal");
2063        }
2064
2065        // the broader taxonomy's other four rungs are gone. `user_interrupt` in particular:
2066        // cancellation is `HostControl::Cancel` and nothing else (§7.9), so the rollback rung it
2067        // used to trigger was retired with its protocol path.
2068        for gone in [
2069            "user_interrupt",
2070            "governance_denied",
2071            "provider_failure",
2072            "timeout",
2073            "cancelled",
2074        ] {
2075            assert!(
2076                serde_json::from_value::<ToolResultDisposition>(json!(gone)).is_err(),
2077                "{gone:?} is not a tool-result disposition"
2078            );
2079        }
2080    }
2081
2082    /// §7.10 rule 9 · failure is orthogonal to residency.
2083    ///
2084    /// Both arms carry the same two failure facts and read them the same way. Before this, an
2085    /// externalised failure was indistinguishable from an externalised success — which made "did
2086    /// this call fail?" depend on how big its output happened to be.
2087    #[test]
2088    fn both_residency_arms_state_the_same_two_failure_facts() {
2089        let inline = ToolResultPayload::Inline(InlineToolResult {
2090            call_id: call_id("call-1"),
2091            result: ToolResult {
2092                output: "boom".to_string(),
2093                durable_content: None,
2094                is_error: true,
2095                disposition: ToolResultDisposition::Fatal,
2096                tokens: None,
2097            },
2098        });
2099        let external = ToolResultPayload::External(ExternalToolResult {
2100            call_id: call_id("call-2"),
2101            payload_ref: payload_ref(),
2102            digest: digest(),
2103            original_size: WireU64::new(1_048_576),
2104            preview: "Traceback (most recent call last):".to_string(),
2105            is_error: true,
2106            disposition: ToolResultDisposition::Fatal,
2107        });
2108        for payload in [&inline, &external] {
2109            assert!(payload.is_error(), "{payload:?}");
2110            assert_eq!(payload.disposition(), ToolResultDisposition::Fatal);
2111            assert!(payload.disposition().is_fatal());
2112        }
2113
2114        // `disposition` is mandatory on the external arm too — same argument as inline: the
2115        // dangerous value must not be the silent one.
2116        assert_eq!(
2117            decode_effect(json!({
2118                "status": "succeeded",
2119                "result": { "kind": "tools", "results": [
2120                    { "kind": "external", "call_id": "c-1", "payload_ref": "p-1",
2121                      "digest": "sha256:ab", "original_size": "1", "preview": "x" }] },
2122            }))
2123            .unwrap_err()
2124            .kind,
2125            WireRejectionKind::MissingField
2126        );
2127
2128        // `is_error` is not: absent means "the call succeeded", the same default the inline arm
2129        // has, and it stays off the wire when false.
2130        let ok: EffectOutcome = serde_json::from_value(json!({
2131            "status": "succeeded",
2132            "result": { "kind": "tools", "results": [
2133                { "kind": "external", "call_id": "c-1", "payload_ref": "p-1",
2134                  "digest": "sha256:ab", "original_size": "1", "preview": "x",
2135                  "disposition": "recoverable" }] },
2136        }))
2137        .unwrap();
2138        let EffectOutcome::Succeeded(success) = &ok else {
2139            panic!("not a success");
2140        };
2141        let EffectSuccess::Tools(tools) = &success.result else {
2142            panic!("not a tools success");
2143        };
2144        assert!(!tools.results[0].is_error());
2145        let value = serde_json::to_value(&tools.results[0]).unwrap();
2146        assert!(value.get("is_error").is_none(), "false stays off the wire");
2147        assert_eq!(value["disposition"], json!("recoverable"));
2148    }
2149
2150    /// §7.8 · a milestone request is the host's lookup key and nothing else.
2151    #[test]
2152    fn a_milestone_request_is_exactly_the_contract_and_phase_pair() {
2153        let request = MilestoneRequest {
2154            contract_id: "brief-quality-primary".to_string(),
2155            phase_id: "collect".to_string(),
2156        };
2157        let value = serde_json::to_value(&request).unwrap();
2158        assert_eq!(
2159            value.as_object().unwrap().keys().collect::<Vec<_>>(),
2160            vec!["contract_id", "phase_id"],
2161            "a phase id is unique only inside its contract, so the pair is the whole key"
2162        );
2163
2164        // criteria, evidence and the verifier are host-owned (§5.2). Under the §7.3 skeleton core
2165        // never learns them, so a field for them could only ever be empty — and an always-empty
2166        // field is an invitation to believe the kernel knows something it does not.
2167        for host_owned in ["criteria", "required_evidence", "verifier", "evidence"] {
2168            let mut with_extra = value.clone();
2169            with_extra
2170                .as_object_mut()
2171                .unwrap()
2172                .insert(host_owned.to_string(), json!([]));
2173            assert!(
2174                serde_json::from_value::<MilestoneRequest>(with_extra).is_err(),
2175                "{host_owned} must not decode"
2176            );
2177        }
2178        for missing in ["contract_id", "phase_id"] {
2179            let mut without = value.clone();
2180            without.as_object_mut().unwrap().remove(missing);
2181            assert!(
2182                serde_json::from_value::<MilestoneRequest>(without).is_err(),
2183                "{missing} is half the key and cannot be omitted"
2184            );
2185        }
2186    }
2187
2188    // -----------------------------------------------------------------------------------------
2189    // DEC-2 · no outcome payload carries a host clock
2190    // -----------------------------------------------------------------------------------------
2191
2192    #[test]
2193    fn no_effect_outcome_payload_carries_a_host_wall_clock() {
2194        const BANNED: [&str; 8] = [
2195            "now_ms",
2196            "observed_at_ms",
2197            "timestamp",
2198            "timestamp_ms",
2199            "started_at_ms",
2200            "completed_at_ms",
2201            "wall_clock_ms",
2202            "received_at_ms",
2203        ];
2204
2205        let mut outcomes: Vec<EffectOutcome> = success_samples()
2206            .into_iter()
2207            .map(|result| EffectOutcome::Succeeded(EffectSucceeded { result }))
2208            .collect();
2209        outcomes.push(EffectOutcome::Failed(EffectFailed {
2210            failure: HostEffectFailure {
2211                kind: HostEffectFailureKind::TransportExhausted,
2212                message: "socket hang up".to_string(),
2213                retryable: Some(false),
2214            },
2215        }));
2216
2217        for outcome in outcomes {
2218            let value = serde_json::to_value(&outcome).unwrap();
2219            let mut all = BTreeSet::new();
2220            keys(&value, &mut all);
2221            for banned in BANNED {
2222                assert!(
2223                    !all.contains(banned),
2224                    "outcome payload must not carry {banned:?}: {value}"
2225                );
2226            }
2227        }
2228    }
2229
2230    // -----------------------------------------------------------------------------------------
2231    // §7.10 · inline / external payload
2232    // -----------------------------------------------------------------------------------------
2233
2234    #[test]
2235    fn an_external_tool_result_is_a_handle_with_a_digest_size_and_preview() {
2236        let external = ToolResultPayload::External(ExternalToolResult {
2237            call_id: call_id("call-2"),
2238            payload_ref: payload_ref(),
2239            digest: digest(),
2240            original_size: WireU64::new(1_048_576),
2241            preview: "total 42".to_string(),
2242            is_error: false,
2243            disposition: ToolResultDisposition::Recoverable,
2244        });
2245        let value = serde_json::to_value(&external).unwrap();
2246        assert_eq!(value["kind"], json!("external"));
2247        for required in ["payload_ref", "digest", "original_size", "preview"] {
2248            assert!(value.get(required).is_some(), "external needs {required}");
2249        }
2250        assert_eq!(
2251            value["original_size"],
2252            json!("1048576"),
2253            "sizes are decimal-string u64, not JS numbers"
2254        );
2255
2256        // rule 7: a payload ref is an opaque locator, never interpreted as a file path
2257        let mut all = BTreeSet::new();
2258        keys(&value, &mut all);
2259        for banned in ["path", "file_path", "spool_ref", "spool_dir", "archive_ref"] {
2260            assert!(!all.contains(banned), "payload ref must stay opaque");
2261        }
2262    }
2263
2264    #[test]
2265    fn payload_residency_distinguishes_generated_over_limit_from_pressure_archival() {
2266        let residencies = [
2267            PayloadResidency::Resident(ResidentPayload {}),
2268            PayloadResidency::External(ExternalResidency {
2269                payload_ref: payload_ref(),
2270                digest: digest(),
2271                original_size: WireU64::new(1_048_576),
2272            }),
2273            PayloadResidency::PagedOut(PagedOutResidency {
2274                payload_ref: payload_ref(),
2275                digest: digest(),
2276            }),
2277            PayloadResidency::Collapsed(CollapsedPayload {}),
2278        ];
2279        let tags: BTreeSet<String> = residencies
2280            .iter()
2281            .map(|residency| {
2282                serde_json::to_value(residency).unwrap()["kind"]
2283                    .as_str()
2284                    .unwrap()
2285                    .to_string()
2286            })
2287            .collect();
2288        assert_eq!(
2289            tags,
2290            BTreeSet::from([
2291                "resident".to_string(),
2292                "external".to_string(),
2293                "paged_out".to_string(),
2294                "collapsed".to_string(),
2295            ]),
2296            "§7.10 keeps `external` (generated over limit) apart from `paged_out` (pressure)"
2297        );
2298
2299        // `paged_out` carries no original_size — that is the External-only fact
2300        let paged = serde_json::to_value(&residencies[2]).unwrap();
2301        assert!(paged.get("original_size").is_none());
2302    }
2303
2304    // -----------------------------------------------------------------------------------------
2305    // strictness
2306    // -----------------------------------------------------------------------------------------
2307
2308    #[test]
2309    fn unknown_success_kinds_fields_and_variants_are_rejected() {
2310        // unknown success kind
2311        assert_eq!(
2312            decode_effect(json!({ "status": "succeeded", "result": { "kind": "spooled" } }))
2313                .unwrap_err()
2314                .kind,
2315            WireRejectionKind::UnknownVariant
2316        );
2317        // unknown failure kind
2318        assert_eq!(
2319            decode_effect(json!({ "status": "failed", "failure": { "kind": "rate_limited" } }))
2320                .unwrap_err()
2321                .kind,
2322            WireRejectionKind::UnknownVariant
2323        );
2324        // unknown field on the outcome itself
2325        assert_eq!(
2326            decode_effect(json!({
2327                "status": "succeeded",
2328                "result": { "kind": "approval" },
2329                "now_ms": 1,
2330            }))
2331            .unwrap_err()
2332            .kind,
2333            WireRejectionKind::UnknownField
2334        );
2335        // unknown field inside a success payload
2336        assert_eq!(
2337            decode_effect(json!({
2338                "status": "succeeded",
2339                "result": { "kind": "payload_loaded", "handle_id": "h-1",
2340                            "payload": { "content": "x", "digest": "sha256:ab",
2341                                         "original_size": "1", "path": "/tmp/x" } },
2342            }))
2343            .unwrap_err()
2344            .kind,
2345            WireRejectionKind::UnknownField
2346        );
2347        // missing required field
2348        assert_eq!(
2349            decode_effect(json!({
2350                "status": "succeeded",
2351                "result": { "kind": "tools", "results": [
2352                    { "kind": "external", "call_id": "c-1", "payload_ref": "p-1",
2353                      "original_size": "1", "preview": "x" }] },
2354            }))
2355            .unwrap_err()
2356            .kind,
2357            WireRejectionKind::MissingField
2358        );
2359        // scalar rule inside a success payload
2360        assert_eq!(
2361            decode_effect(json!({
2362                "status": "succeeded",
2363                "result": { "kind": "payload_loaded", "handle_id": "h-1",
2364                            "payload": { "content": "x", "digest": "sha256:ab",
2365                                         "original_size": 1 } },
2366            }))
2367            .unwrap_err()
2368            .kind,
2369            WireRejectionKind::InvalidScalar
2370        );
2371    }
2372
2373    #[test]
2374    fn every_effect_and_success_payload_denies_unknown_fields() {
2375        for effect in effect_samples() {
2376            let mut value = serde_json::to_value(&effect).unwrap();
2377            value
2378                .as_object_mut()
2379                .unwrap()
2380                .insert("host_hint".to_string(), json!("x"));
2381            assert!(
2382                serde_json::from_value::<EffectKind>(value).is_err(),
2383                "{:?} must deny unknown fields",
2384                effect.tag()
2385            );
2386        }
2387        for success in success_samples() {
2388            let mut value = serde_json::to_value(&success).unwrap();
2389            value
2390                .as_object_mut()
2391                .unwrap()
2392                .insert("host_hint".to_string(), json!("x"));
2393            assert!(
2394                serde_json::from_value::<EffectSuccess>(value).is_err(),
2395                "{:?} must deny unknown fields",
2396                success.tag()
2397            );
2398        }
2399    }
2400
2401    // -----------------------------------------------------------------------------------------
2402    // goldens
2403    // -----------------------------------------------------------------------------------------
2404
2405    #[test]
2406    fn resolve_effect_goldens_cover_every_success_kind_and_the_failure_path() {
2407        let mut success_kinds: BTreeSet<String> = BTreeSet::new();
2408        let mut failure_kinds: BTreeSet<String> = BTreeSet::new();
2409
2410        for (name, fixture) in fixtures_with_prefix("input_resolve_") {
2411            let text = serde_json::to_string(&fixture).unwrap();
2412            let envelope = decode_envelope_json(&text, &KernelBootstrapLimits::default())
2413                .unwrap_or_else(|e| panic!("{name}: {e}"));
2414            assert_eq!(
2415                serde_json::to_value(&envelope).unwrap(),
2416                fixture,
2417                "{name}: round-trip changed the document"
2418            );
2419
2420            let KernelInput::ResolveEffect(resolve) = &envelope.input else {
2421                panic!("{name} is not a resolve_effect golden");
2422            };
2423            match &resolve.outcome {
2424                EffectOutcome::Succeeded(ok) => {
2425                    success_kinds.insert(ok.result.tag().as_str().to_string());
2426                }
2427                EffectOutcome::Failed(failed) => {
2428                    failure_kinds.insert(failed.failure.kind.as_str().to_string());
2429                }
2430            }
2431        }
2432
2433        assert_eq!(
2434            success_kinds,
2435            EffectSuccessTag::ALL
2436                .iter()
2437                .map(|tag| tag.as_str().to_string())
2438                .collect::<BTreeSet<String>>(),
2439            "every effect kind needs at least one resolve golden"
2440        );
2441        assert!(
2442            !failure_kinds.is_empty(),
2443            "the unified failure path needs a golden too"
2444        );
2445    }
2446
2447    #[test]
2448    fn the_milestone_effect_has_a_failure_channel_like_every_other_effect() {
2449        // B7: `MilestoneResult` historically had no error field at all.
2450        let golden = fixtures_with_prefix("input_resolve_effect_milestone_failed");
2451        assert_eq!(golden.len(), 1);
2452        let text = serde_json::to_string(&golden[0].1).unwrap();
2453        let envelope = decode_envelope_json(&text, &KernelBootstrapLimits::default()).unwrap();
2454        let KernelInput::ResolveEffect(resolve) = &envelope.input else {
2455            panic!("not a resolve_effect golden");
2456        };
2457        let EffectOutcome::Failed(failed) = &resolve.outcome else {
2458            panic!("milestone failure golden must be a Failed outcome");
2459        };
2460        assert_eq!(
2461            failed.failure.kind,
2462            HostEffectFailureKind::StorageUnavailable
2463        );
2464
2465        let milestone = kernel_effect(EffectKind::EvaluateMilestone(EvaluateMilestoneEffect {
2466            request: MilestoneRequest {
2467                contract_id: "brief-quality-primary".to_string(),
2468                phase_id: "phase-2".to_string(),
2469            },
2470        }));
2471        assert!(milestone.accept_outcome(&resolve.outcome).is_ok());
2472    }
2473
2474    #[test]
2475    fn effect_rejection_goldens_cover_the_payload_failure_modes() {
2476        let mut expected: BTreeSet<String> = BTreeSet::new();
2477        for (name, fixture) in fixtures_with_prefix("reject_effect_") {
2478            let kind = fixture["expect"]
2479                .as_str()
2480                .unwrap_or_else(|| panic!("{name}: missing `expect`"));
2481            let text = serde_json::to_string(&fixture["envelope"]).unwrap();
2482            let rejection = decode_envelope_json(&text, &KernelBootstrapLimits::default())
2483                .map(|ok| panic!("{name}: expected rejection, decoded {ok:?}"))
2484                .unwrap_err();
2485            assert_eq!(
2486                rejection.kind.as_str(),
2487                kind,
2488                "{name}: {}",
2489                rejection.message
2490            );
2491            expected.insert(kind.to_string());
2492        }
2493        for required in [
2494            "unknown_field",
2495            "unknown_variant",
2496            "missing_field",
2497            "invalid_scalar",
2498        ] {
2499            assert!(
2500                expected.contains(required),
2501                "effect rejection goldens must cover {required}"
2502            );
2503        }
2504    }
2505}