Skip to main content

deepstrike_core/runtime/kernel/wire/
effect.rs

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