Skip to main content

deepstrike_core/runtime/kernel/wire/
effect.rs

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