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