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