Skip to main content

deepstrike_core/runtime/kernel/wire/
config.rs

1//! Operation configuration (spec §7.3, §13.1, §13.3).
2//!
3//! One input class carries configuration, it is the genesis record, and it decodes inside the
4//! absolute bootstrap boundary. This module fixes *what* travels in it.
5//!
6//! Three rules shape the whole field tree:
7//!
8//! 1. **Boot-only by construction.** Every knob here is admissible exactly once, through
9//!    `ConfigureOperation`. The live surface is the closed [`LivePolicyPatch`](super::command::LivePolicyPatch)
10//!    union; a knob that appears in both would re-create the historical situation where
11//!    `ConfigureRun.governance` and `LoadGovernancePolicy` shared one implementation and therefore
12//!    made the boot/live distinction unenforceable (§13.1 现状注记).
13//! 2. **No implicit defaults survive the boundary.** [`OperationConfig`] is the *sparse* host
14//!    input; [`resolve_operation_config`] normalises it into a dense [`ResolvedOperationConfig`],
15//!    and that is what the genesis record stores. A replay therefore never re-applies a newer
16//!    binary's defaults (§7.3).
17//! 3. **Validation is atomic.** One illegal field rejects the whole `ConfigureOperation` and
18//!    changes nothing: `resolve_operation_config` returns `Result` and owns no state, so a partial
19//!    application is not expressible.
20//!
21//! Host concerns explicitly absent (§7.3 配置边界 table, §13.3): host effect retry/backoff, spool /
22//! blob directories, provider endpoint / key / protocol, checkpoint storage location, and
23//! `memory_path`. They are host executor and store configuration; a kernel that accepted them
24//! would be persisting facts it cannot reproduce.
25
26use serde::{Deserialize, Serialize};
27
28use super::KernelBootstrapLimits;
29use super::command::{
30    GovernancePolicy, PolicyAction, RecoveryPolicy, SignalPolicy, TailBoundsPolicy,
31};
32use super::effect::{EffectKindTag, MemoryAccessBinding, ToolSchema};
33use super::envelope::{WireRejection, WireRejectionKind};
34use super::fault::{KernelFault, KernelFaultCode};
35use super::scalar::{Ppm, WireU64};
36
37// ---------------------------------------------------------------------------------------------
38// rejection helpers
39// ---------------------------------------------------------------------------------------------
40
41/// A configuration value that decoded cleanly but breaks a contract rule.
42///
43/// [`WireRejectionKind::PolicyViolation`], never `InvalidScalar`: the host stated a coherent
44/// value and the kernel refuses to adopt it. Telling those two apart is what lets a host
45/// distinguish "fix your encoder" from "fix your configuration".
46fn invalid(message: impl Into<String>) -> WireRejection {
47    WireRejection::new(WireRejectionKind::PolicyViolation, message)
48}
49
50fn too_many(message: impl Into<String>) -> WireRejection {
51    WireRejection::new(WireRejectionKind::CollectionTooLarge, message)
52}
53
54fn require_le_u32(
55    label: &str,
56    requested: u32,
57    ceiling: u32,
58    ceiling_label: &str,
59) -> Result<(), WireRejection> {
60    if requested > ceiling {
61        return Err(invalid(format!(
62            "{label} {requested} is wider than {ceiling_label} {ceiling}; \
63             operation configuration may only tighten it"
64        )));
65    }
66    Ok(())
67}
68
69// ---------------------------------------------------------------------------------------------
70// OperationConfig — the sparse wire input
71// ---------------------------------------------------------------------------------------------
72
73/// Boot configuration for one operation (§7.3).
74///
75/// Sparse on purpose: a host states what it wants to differ from the kernel's compile-time
76/// defaults. `host_effect_support` is the one **mandatory** field — DEC-8 makes it an explicit
77/// declaration, and a default would be exactly the implicit assumption it exists to remove.
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct OperationConfig {
81    /// Turn/token/wall budgets plus the loop guards (criteria gate, repeat fuse, entropy watch).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub execution_policy: Option<ExecutionPolicy>,
84    /// Initial syscall-gate posture. Live changes go through
85    /// [`LivePolicyPatch::ReplaceGovernancePolicy`](super::command::LivePolicyPatch).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub governance_policy: Option<GovernancePolicy>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub scheduler_policy: Option<SchedulerPolicy>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub resource_quota: Option<ResourceQuota>,
92    /// RunGroup admission result. Absent ⇒ the operation is not reservation-backed.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub budget_grant: Option<BudgetGrant>,
95    /// Initial signal routing. Live changes go through
96    /// [`LivePolicyPatch::ReplaceSignalPolicy`](super::command::LivePolicyPatch).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub signal_policy: Option<SignalPolicy>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub context_policy: Option<ContextPolicy>,
101    /// Semantic recovery ladders the kernel owns. Host transport retry/backoff is **not** here.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub recovery_policy: Option<RecoveryPolicy>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub payload_policy: Option<PayloadPolicy>,
106    /// May only **tighten** [`KernelBootstrapLimits`].
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub kernel_limits: Option<KernelLimits>,
109    /// Opaque memory access binding. Absent ⇒ the operation has no memory plane at all.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub memory_access: Option<MemoryAccessBinding>,
112    /// Validation / recall / promotion thresholds for the memory plane. Never a path.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub memory_policy: Option<MemoryPolicy>,
115    /// Initial tool catalog (§13.3 · `SetTools`). Live narrowing/widening is a capability patch,
116    /// not a second catalog install.
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub tool_catalog: Vec<ToolSchema>,
119    /// Initial skill catalog (§13.3 · `SetAvailableSkills`).
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub skill_catalog: Vec<SkillMetadata>,
122    /// The verification contracts this operation may evaluate.
123    ///
124    /// A **skeleton**, not a specification: an ordered phase list and, per phase, the capabilities
125    /// passing it unlocks. See [`VerificationContract`] for why that is the exact line.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub verification_contracts: Vec<VerificationContract>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub feature_policy: Option<FeaturePolicy>,
130    /// DEC-8. Mandatory: the kernel emits an effect only for a kind the host declared it can
131    /// execute, and fail-closes on the rest.
132    pub host_effect_support: HostEffectSupport,
133}
134
135impl OperationConfig {
136    /// Normalise and validate. See [`resolve_operation_config`].
137    pub fn resolve(
138        &self,
139        defaults: &ConfigDefaults,
140    ) -> Result<ResolvedOperationConfig, WireRejection> {
141        resolve_operation_config(self, defaults)
142    }
143}
144
145// ---------------------------------------------------------------------------------------------
146// execution policy
147// ---------------------------------------------------------------------------------------------
148
149/// Budgets and loop guards (§13.3 · `SetCriteriaGate`, `SetRepeatFuse`, `SetEntropyWatch`, and the
150/// `SchedulerBudget` the constructor may no longer accept, §13.1).
151///
152/// `SetSchedulerBudget` has no counterpart in the live union: the only axis it ever carried was
153/// `max_wall_ms`, and the live form of that is
154/// [`HostCommand::UpdateDeadline`](super::command::HostCommand).
155#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ExecutionPolicy {
158    /// Context window size the pressure monitor works against.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub max_context_tokens: Option<u32>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub max_turns: Option<u32>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub max_total_tokens: Option<WireU64>,
165    /// Absolute wall budget. Absent ⇒ no wall-clock limit; that is a value, not a default.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub max_wall_ms: Option<WireU64>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub criteria_gate_enabled: Option<bool>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub repeat_fuse: Option<RepeatFusePolicy>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub entropy_watch: Option<EntropyWatchPolicy>,
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct RepeatFusePolicy {
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub enabled: Option<bool>,
181    /// Consecutive identical calls before the call is denied.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub deny_after: Option<u32>,
184    /// Consecutive identical calls before the operation terminates with `no_progress`.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub terminate_after: Option<u32>,
187}
188
189/// Entropy watch. The three historical `f64` knobs are fixed-point ppm (§7.1.1, §13.3): a
190/// threshold that differs by one ULP between languages is a different kernel decision.
191#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct EntropyWatchPolicy {
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub enabled: Option<bool>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub threshold_ppm: Option<Ppm>,
198    /// Re-arm only once the score falls below `threshold - hysteresis` (anti-flap).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub hysteresis_ppm: Option<Ppm>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub cooldown_turns: Option<u32>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub notify_model: Option<bool>,
205}
206
207// ---------------------------------------------------------------------------------------------
208// scheduler policy
209// ---------------------------------------------------------------------------------------------
210
211/// Ready-queue ordering weights. This is a strict value object with no format discriminator.
212#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct SchedulerPolicy {
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub critical_path_weight: Option<u32>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub fanout_weight: Option<u32>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub age_weight: Option<u32>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub token_cost_weight: Option<u32>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub deadline_weight: Option<u32>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub process_priority_weight: Option<u32>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub resource_pressure_weight: Option<u32>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub budget_pressure_weight: Option<u32>,
231}
232
233/// Upper bound of any scheduler weight.
234pub const MAX_SCHEDULER_WEIGHT: u32 = 1_000_000_000;
235
236// ---------------------------------------------------------------------------------------------
237// resource quota / budget grant
238// ---------------------------------------------------------------------------------------------
239
240/// Declarative caps enforced at the syscall trap. Absent axis ⇒ uncapped, which is a value.
241#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
242#[serde(deny_unknown_fields)]
243pub struct ResourceQuota {
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub max_concurrent_subagents: Option<u32>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub max_total_subagents: Option<u32>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub max_spawn_depth: Option<u32>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub max_workflow_nodes: Option<u32>,
252    /// Rolling-window memory-write rate limit. Named fields keep the contract self-describing and
253    /// allow strict unknown-field rejection.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub memory_writes_per_window: Option<RateWindow>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(deny_unknown_fields)]
260pub struct RateWindow {
261    pub max_events: u32,
262    pub window_ms: WireU64,
263}
264
265/// RunGroup admission result. `reservation_id` is opaque — the kernel enforces the grant locally
266/// and reports terminal usage against the same identity; it never interprets it.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct BudgetGrant {
270    pub reservation_id: String,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub tokens: Option<WireU64>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub subagents: Option<u32>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub rounds: Option<u32>,
277}
278
279// ---------------------------------------------------------------------------------------------
280// context policy
281// ---------------------------------------------------------------------------------------------
282
283/// Stable, replayable context behaviour. Every ratio is fixed-point ppm.
284///
285/// Two §13.3 rows land here: `SetKnowledgeBudget` (whose `f64` ratio becomes
286/// [`Self::knowledge_budget_ppm`]) and the retired split prompt-budget input (the U2 field that had no live
287/// setter and no §7.3 home).
288#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct ContextPolicy {
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub pressure_thresholds_ppm: Option<PressureThresholds>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub target_after_compress_ppm: Option<Ppm>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub preserve_recent_turns: Option<u32>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub renewal_carryover_ppm: Option<Ppm>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub collapse_old_assistant_narration: Option<bool>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub idle_micro_compact_minutes: Option<u32>,
303    /// Share of the context budget the knowledge partition may hold. `0` disables the partition's
304    /// budget entirely.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub knowledge_budget_ppm: Option<Ppm>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub prompt_budget: Option<PromptBudget>,
309}
310
311/// The five pressure thresholds. Replaced as one value: a partial threshold ladder is how the
312/// strictly-increasing invariant gets violated one field at a time.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(deny_unknown_fields)]
315pub struct PressureThresholds {
316    pub snip: Ppm,
317    pub micro: Ppm,
318    pub collapse: Ppm,
319    pub auto: Ppm,
320    pub renewal: Ppm,
321}
322
323/// Host-counted request overhead and hard reserves, deducted before the kernel renders any
324/// content.
325#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct PromptBudget {
328    pub prompt_overhead_tokens: u32,
329    pub output_reserve_tokens: u32,
330    pub safety_margin_tokens: u32,
331}
332
333impl PromptBudget {
334    pub fn reserved_tokens(self) -> u32 {
335        self.prompt_overhead_tokens
336            .saturating_add(self.output_reserve_tokens)
337            .saturating_add(self.safety_margin_tokens)
338    }
339}
340
341// ---------------------------------------------------------------------------------------------
342// payload policy
343// ---------------------------------------------------------------------------------------------
344
345/// Where the kernel draws the inline/external line for a tool result (§7.10).
346///
347/// There is deliberately **no** directory/root field: a `PayloadRef` is an opaque locator, never
348/// a host path (§7.10 rule 7).
349#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct PayloadPolicy {
352    /// Results at or above this size are committed as `External` rather than inline.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub inline_threshold_bytes: Option<u32>,
355    /// Bytes of preview the kernel keeps resident for an external payload.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub preview_bytes: Option<u32>,
358}
359
360// ---------------------------------------------------------------------------------------------
361// kernel limits
362// ---------------------------------------------------------------------------------------------
363
364/// Operation-scoped structural limits. These may only **tighten** [`KernelBootstrapLimits`];
365/// widening any axis rejects the whole `ConfigureOperation`.
366#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct KernelLimits {
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub max_input_bytes: Option<u32>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub max_json_depth: Option<u16>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub max_collection_entries: Option<u32>,
375    /// Per-collection bounds. `absolute_max_collection_entries` is one number for every container
376    /// in the document, which is the wrong granularity for a tool catalog and a knowledge
377    /// partition at the same time; each named bound may only tighten it further.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub collection_limits: Option<CollectionLimits>,
380}
381
382/// Named per-collection entry bounds. Absent ⇒ that collection inherits `max_collection_entries`.
383#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct CollectionLimits {
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub tool_catalog: Option<u32>,
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub skill_catalog: Option<u32>,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub knowledge_entries: Option<u32>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub initial_messages: Option<u32>,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub capability_grants: Option<u32>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub governance_rules: Option<u32>,
398}
399
400// ---------------------------------------------------------------------------------------------
401// memory
402// ---------------------------------------------------------------------------------------------
403
404/// Validation / recall / promotion thresholds (§13.3 · `SetMemoryPolicy`).
405///
406/// `memory_path` is **not** here and has no replacement: it moved to the host `MemoryStore`
407/// config (§14.4). The kernel performs no recall I/O, so a path in a kernel record is a fact the
408/// kernel can neither verify nor reproduce.
409#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct MemoryPolicy {
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub stale_warning_days: Option<u32>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub retrieval_top_k: Option<u32>,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub validation_enabled: Option<bool>,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub max_content_bytes: Option<u32>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub max_name_length: Option<u32>,
422    /// Recall count at which a record becomes a promotion candidate. Absent ⇒ no suggestion.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub promotion_recall_threshold: Option<WireU64>,
425}
426
427// ---------------------------------------------------------------------------------------------
428// catalogs
429// ---------------------------------------------------------------------------------------------
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432#[serde(deny_unknown_fields)]
433pub struct SkillMetadata {
434    pub name: String,
435    #[serde(default, skip_serializing_if = "String::is_empty")]
436    pub description: String,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub when_to_use: Option<String>,
439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
440    pub allowed_tools: Vec<String>,
441    /// Fine-grained authority made effective while this skill is active. The mounting agent's
442    /// authority is only known at start time, so attenuation is checked by each activation path.
443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
444    pub capability_grants: Vec<crate::types::capability::Capability>,
445    /// Effort level 1–5; scales the per-skill token budget.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub effort: Option<u8>,
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub estimated_tokens: Option<u32>,
450}
451
452/// One verification contract: an ordered cascade of phases the operation must pass in sequence.
453///
454/// **A skeleton, deliberately.** The kernel owns exactly two things about a contract — the order
455/// its phases run in, and what passing a phase unlocks — because both are kernel decisions: the
456/// order decides which `EvaluateMilestone` is published next, and the unlocks mutate the capability
457/// table, which is the operation's authority surface. Everything else about a contract — the
458/// acceptance criteria, the evidence, the verifier and the I/O that runs it — stays host-side
459/// (§5.2, adjudication §5m item 3). A bare `Vec<String>` of ids could not express the first two,
460/// so the phase cascade had no canonical producer at all and `EvaluateMilestone` was unreachable
461/// from the wire (Task 12 SPEC-ISSUE-4); a full contract type would have moved criteria ownership
462/// into core. This is the line between them.
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct VerificationContract {
466    /// Logical id. Unique within the operation, and the value
467    /// [`LogicalAgentSpec::verification_contract_id`](super::root::LogicalAgentSpec) resolves
468    /// against.
469    pub contract_id: String,
470    pub phases: Vec<MilestonePhase>,
471}
472
473/// One phase of a [`VerificationContract`].
474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
475#[serde(deny_unknown_fields)]
476pub struct MilestonePhase {
477    /// Stable id, unique within its contract. It is what `EvaluateMilestone` names and what the
478    /// host keys its verifier lookup on.
479    pub phase_id: String,
480    /// Capability ids mounted when this phase passes. Each must name an entry in the operation's
481    /// capability directory — the declared `tool_catalog` and `skill_catalog` — so a phase cannot
482    /// unlock a surface the operation never declared. Fail-closed at resolve time, for the same
483    /// reason `feature_policy.stable_core_tool_ids` is: discovering it as a mid-run capability
484    /// mutation with nothing behind it is strictly worse than discovering it at
485    /// `ConfigureOperation`.
486    #[serde(default, skip_serializing_if = "Vec::is_empty")]
487    pub unlocks: Vec<String>,
488}
489
490// ---------------------------------------------------------------------------------------------
491// feature policy
492// ---------------------------------------------------------------------------------------------
493
494/// The feature switches §13.3 folds together.
495#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct FeaturePolicy {
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub memory_enabled: Option<bool>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub knowledge_enabled: Option<bool>,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub plan_tool_enabled: Option<bool>,
504    /// Tool ids always exposed under skill gating — the exposure baseline.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub stable_core_tool_ids: Option<Vec<String>>,
507}
508
509// ---------------------------------------------------------------------------------------------
510// host effect support (DEC-8)
511// ---------------------------------------------------------------------------------------------
512
513/// The host's explicit declaration of which effect kinds it can execute (§7.3, DEC-8).
514///
515/// The kernel emits an effect only for a declared kind and fail-closes on the rest. Without this,
516/// the same effect produces a different outcome per language — Rust's `spawn_workflow` always
517/// fails, Python handles memory effects only inside a host syscall, and the Node/WASM `if/else-if`
518/// chains busy-wait on anything unhandled.
519#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct HostEffectSupport {
522    /// Not `skip_serializing_if`: an empty declaration ("I can execute nothing") must be visible
523    /// on the wire rather than indistinguishable from an omitted field.
524    pub supported: Vec<EffectKindTag>,
525}
526
527impl HostEffectSupport {
528    pub fn new(supported: impl IntoIterator<Item = EffectKindTag>) -> Self {
529        Self {
530            supported: supported.into_iter().collect(),
531        }
532    }
533
534    pub fn supports(&self, kind: EffectKindTag) -> bool {
535        self.supported.contains(&kind)
536    }
537}
538
539// ---------------------------------------------------------------------------------------------
540// ResolvedOperationConfig — what the genesis record stores
541// ---------------------------------------------------------------------------------------------
542
543/// The dense, defaults-free configuration written into the genesis record (§7.3).
544///
545/// Every `Option` that stood for "use whatever this binary defaults to" is gone. The `Option`s
546/// that remain — `max_wall_ms`, the quota axes, `budget_grant`, `memory_access` — encode a real
547/// value ("no wall limit", "uncapped", "not reservation-backed", "no memory plane"), which is why
548/// a replay of this record on a newer binary produces the same decisions it did on the first run.
549#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub struct ResolvedOperationConfig {
552    pub execution_policy: ResolvedExecutionPolicy,
553    pub governance_policy: ResolvedGovernancePolicy,
554    pub scheduler_policy: ResolvedSchedulerPolicy,
555    pub resource_quota: ResourceQuota,
556    pub budget_grant: Option<BudgetGrant>,
557    pub signal_policy: ResolvedSignalPolicy,
558    pub context_policy: ResolvedContextPolicy,
559    pub recovery_policy: ResolvedRecoveryPolicy,
560    pub payload_policy: ResolvedPayloadPolicy,
561    pub kernel_limits: ResolvedKernelLimits,
562    pub memory_access: Option<MemoryAccessBinding>,
563    pub memory_policy: ResolvedMemoryPolicy,
564    pub tool_catalog: Vec<ToolSchema>,
565    pub skill_catalog: Vec<SkillMetadata>,
566    pub verification_contracts: Vec<VerificationContract>,
567    pub feature_policy: ResolvedFeaturePolicy,
568    pub host_effect_support: HostEffectSupport,
569}
570
571impl ResolvedOperationConfig {
572    /// The contract this id names, or `None` when the catalog does not declare it.
573    pub fn verification_contract(&self, contract_id: &str) -> Option<&VerificationContract> {
574        self.verification_contracts
575            .iter()
576            .find(|contract| contract.contract_id == contract_id)
577    }
578}
579
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
581#[serde(deny_unknown_fields)]
582pub struct ResolvedExecutionPolicy {
583    pub max_context_tokens: u32,
584    pub max_turns: u32,
585    pub max_total_tokens: WireU64,
586    pub max_wall_ms: Option<WireU64>,
587    pub criteria_gate_enabled: bool,
588    pub repeat_fuse: ResolvedRepeatFuse,
589    pub entropy_watch: ResolvedEntropyWatch,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
593#[serde(deny_unknown_fields)]
594pub struct ResolvedRepeatFuse {
595    pub enabled: bool,
596    pub deny_after: u32,
597    pub terminate_after: u32,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct ResolvedEntropyWatch {
603    pub enabled: bool,
604    pub threshold_ppm: Ppm,
605    pub hysteresis_ppm: Ppm,
606    pub cooldown_turns: u32,
607    pub notify_model: bool,
608}
609
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612pub struct ResolvedGovernancePolicy {
613    pub default_action: PolicyAction,
614    pub rules: Vec<super::command::PolicyRule>,
615    pub vetoed_tools: Vec<String>,
616    pub rate_limits: Vec<super::command::RateLimitSpec>,
617    pub constraints: Vec<super::command::ParamConstraint>,
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(deny_unknown_fields)]
622pub struct ResolvedSchedulerPolicy {
623    pub critical_path_weight: u32,
624    pub fanout_weight: u32,
625    pub age_weight: u32,
626    pub token_cost_weight: u32,
627    #[serde(default)]
628    pub deadline_weight: u32,
629    #[serde(default)]
630    pub process_priority_weight: u32,
631    #[serde(default)]
632    pub resource_pressure_weight: u32,
633    #[serde(default)]
634    pub budget_pressure_weight: u32,
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
638#[serde(deny_unknown_fields)]
639pub struct ResolvedSignalPolicy {
640    pub queue_max: u32,
641    pub ttl_ms: Option<WireU64>,
642    pub deadline_escalation: bool,
643}
644
645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct ResolvedContextPolicy {
648    pub pressure_thresholds_ppm: PressureThresholds,
649    pub target_after_compress_ppm: Ppm,
650    pub preserve_recent_turns: u32,
651    pub renewal_carryover_ppm: Ppm,
652    pub collapse_old_assistant_narration: bool,
653    pub idle_micro_compact_minutes: u32,
654    pub knowledge_budget_ppm: Ppm,
655    pub prompt_budget: PromptBudget,
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(deny_unknown_fields)]
660pub struct ResolvedRecoveryPolicy {
661    pub provider_recovery_attempts: u8,
662    pub output_recovery_attempts: u8,
663    /// §12.3 · the journal tail this operation may carry between checkpoints.
664    ///
665    /// It lives in the **resolved** configuration, which means the genesis record freezes it: an
666    /// operation's tail bound cannot change under it because a later binary shipped a different
667    /// default, and a rebuild re-derives exactly the bound the original run was refused against.
668    /// The transaction adopts this value the moment the genesis record installs the configuration
669    /// (until then it runs on the bootstrap baseline, which is what bounds the genesis append
670    /// itself).
671    pub tail_bounds: TailBounds,
672}
673
674/// Soft watermark and hard limit of the journal tail, on both axes §12.3 names: canonical input
675/// count and bytes.
676///
677/// The hard limit is what makes [`KernelFaultCode::CheckpointRequired`] reachable, and reaching it
678/// is a **retryable, zero-mutation rejection** — not the historical overflow latch, which
679/// permanently disabled snapshots and all later staged transitions once it tripped.
680///
681/// Both axes exist because either one alone is escapable: a run of many tiny inputs blows the
682/// record count long before the byte budget, and a single oversized payload blows the byte budget
683/// on one record.
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct TailBounds {
687    pub soft_records: WireU64,
688    pub hard_records: WireU64,
689    pub soft_bytes: WireU64,
690    pub hard_bytes: WireU64,
691}
692
693impl TailBounds {
694    /// The documented default: ~500 records / 4 MiB before the host is asked to checkpoint, and
695    /// four times that before the next input is refused with a retryable `CheckpointRequired`.
696    pub const DEFAULT: Self = Self {
697        soft_records: WireU64::new(512),
698        hard_records: WireU64::new(2_048),
699        soft_bytes: WireU64::new(4 * 1024 * 1024),
700        hard_bytes: WireU64::new(16 * 1024 * 1024),
701    };
702
703    /// Fail closed on an incoherent bound rather than silently reordering it: a soft watermark
704    /// above its hard limit would mean "warn after it is already too late".
705    pub fn new(
706        soft_records: u64,
707        hard_records: u64,
708        soft_bytes: u64,
709        hard_bytes: u64,
710    ) -> Result<Self, KernelFault> {
711        let bounds = Self {
712            soft_records: WireU64::new(soft_records),
713            hard_records: WireU64::new(hard_records),
714            soft_bytes: WireU64::new(soft_bytes),
715            hard_bytes: WireU64::new(hard_bytes),
716        };
717        bounds
718            .check()
719            .map_err(|message| KernelFault::new(KernelFaultCode::InvalidConfig, message))?;
720        Ok(bounds)
721    }
722
723    /// The one place the coherence rules live, so the configuration resolver and the direct
724    /// constructor cannot drift apart.
725    pub(super) fn check(&self) -> Result<(), String> {
726        if self.soft_records > self.hard_records || self.soft_bytes > self.hard_bytes {
727            return Err(format!(
728                "recovery_policy.tail_bounds watermark ({} records / {} bytes) exceeds its hard \
729                 limit ({} records / {} bytes)",
730                self.soft_records, self.soft_bytes, self.hard_records, self.hard_bytes
731            ));
732        }
733        if self.hard_records.get() == 0 || self.hard_bytes.get() == 0 {
734            return Err(
735                "recovery_policy.tail_bounds hard limit of zero admits no transaction at all"
736                    .to_string(),
737            );
738        }
739        Ok(())
740    }
741}
742
743impl Default for TailBounds {
744    fn default() -> Self {
745        Self::DEFAULT
746    }
747}
748
749#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct ResolvedPayloadPolicy {
752    pub inline_threshold_bytes: u32,
753    pub preview_bytes: u32,
754}
755
756/// Structural limits after resolution: the three absolute axes plus a **dense** per-collection
757/// table. Every named bound is concrete here, so no later stage has to re-derive "which ceiling
758/// applies to this container".
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct ResolvedKernelLimits {
762    pub max_input_bytes: u32,
763    pub max_json_depth: u16,
764    pub max_collection_entries: u32,
765    pub collection_limits: ResolvedCollectionLimits,
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
769#[serde(deny_unknown_fields)]
770pub struct ResolvedCollectionLimits {
771    pub tool_catalog: u32,
772    pub skill_catalog: u32,
773    pub knowledge_entries: u32,
774    pub initial_messages: u32,
775    pub capability_grants: u32,
776    pub governance_rules: u32,
777}
778
779#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
780#[serde(deny_unknown_fields)]
781pub struct ResolvedMemoryPolicy {
782    pub stale_warning_days: u32,
783    pub retrieval_top_k: u32,
784    pub validation_enabled: bool,
785    pub max_content_bytes: u32,
786    pub max_name_length: u32,
787    pub promotion_recall_threshold: Option<WireU64>,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(deny_unknown_fields)]
792pub struct ResolvedFeaturePolicy {
793    pub memory_enabled: bool,
794    pub knowledge_enabled: bool,
795    pub plan_tool_enabled: bool,
796    pub stable_core_tool_ids: Vec<String>,
797}
798
799// ---------------------------------------------------------------------------------------------
800// defaults
801// ---------------------------------------------------------------------------------------------
802
803/// The kernel's compile-time baseline plus the bootstrap ceiling to resolve against.
804///
805/// This value is what "the current binary's defaults" means, made explicit. It is an input to
806/// resolution and never a fallback afterwards: once the genesis record holds a
807/// [`ResolvedOperationConfig`], changing these constants cannot change that operation's replay.
808#[derive(Debug, Clone, PartialEq)]
809pub struct ConfigDefaults {
810    pub bootstrap_limits: KernelBootstrapLimits,
811    pub baseline: ResolvedOperationConfig,
812}
813
814impl ConfigDefaults {
815    pub fn new(bootstrap_limits: KernelBootstrapLimits) -> Self {
816        let entries = bootstrap_limits.absolute_max_collection_entries;
817        Self {
818            bootstrap_limits,
819            baseline: ResolvedOperationConfig {
820                execution_policy: ResolvedExecutionPolicy {
821                    max_context_tokens: 128_000,
822                    max_turns: 25,
823                    max_total_tokens: WireU64::new(1_000_000),
824                    max_wall_ms: None,
825                    criteria_gate_enabled: true,
826                    repeat_fuse: ResolvedRepeatFuse {
827                        enabled: true,
828                        deny_after: 5,
829                        terminate_after: 8,
830                    },
831                    entropy_watch: ResolvedEntropyWatch {
832                        enabled: false,
833                        threshold_ppm: Ppm::from_ppm_const(650_000),
834                        hysteresis_ppm: Ppm::from_ppm_const(100_000),
835                        cooldown_turns: 4,
836                        notify_model: false,
837                    },
838                },
839                governance_policy: ResolvedGovernancePolicy {
840                    default_action: PolicyAction::Allow,
841                    rules: Vec::new(),
842                    vetoed_tools: Vec::new(),
843                    rate_limits: Vec::new(),
844                    constraints: Vec::new(),
845                },
846                scheduler_policy: ResolvedSchedulerPolicy {
847                    critical_path_weight: 1_000_000,
848                    fanout_weight: 10_000,
849                    age_weight: 1_000,
850                    token_cost_weight: 1,
851                    deadline_weight: 0,
852                    process_priority_weight: 0,
853                    resource_pressure_weight: 0,
854                    budget_pressure_weight: 0,
855                },
856                resource_quota: ResourceQuota::default(),
857                budget_grant: None,
858                signal_policy: ResolvedSignalPolicy {
859                    queue_max: 64,
860                    ttl_ms: None,
861                    deadline_escalation: false,
862                },
863                context_policy: ResolvedContextPolicy {
864                    pressure_thresholds_ppm: PressureThresholds {
865                        snip: Ppm::from_ppm_const(700_000),
866                        micro: Ppm::from_ppm_const(800_000),
867                        collapse: Ppm::from_ppm_const(900_000),
868                        auto: Ppm::from_ppm_const(950_000),
869                        renewal: Ppm::from_ppm_const(980_000),
870                    },
871                    target_after_compress_ppm: Ppm::from_ppm_const(650_000),
872                    preserve_recent_turns: 2,
873                    renewal_carryover_ppm: Ppm::from_ppm_const(50_000),
874                    collapse_old_assistant_narration: true,
875                    idle_micro_compact_minutes: 60,
876                    knowledge_budget_ppm: Ppm::from_ppm_const(250_000),
877                    prompt_budget: PromptBudget {
878                        prompt_overhead_tokens: 0,
879                        output_reserve_tokens: 0,
880                        safety_margin_tokens: 0,
881                    },
882                },
883                recovery_policy: ResolvedRecoveryPolicy {
884                    provider_recovery_attempts: 1,
885                    output_recovery_attempts: 1,
886                    tail_bounds: TailBounds::DEFAULT,
887                },
888                payload_policy: ResolvedPayloadPolicy {
889                    inline_threshold_bytes: 50 * 1024,
890                    preview_bytes: 2 * 1024,
891                },
892                kernel_limits: ResolvedKernelLimits {
893                    max_input_bytes: bootstrap_limits.absolute_max_input_bytes,
894                    max_json_depth: bootstrap_limits.absolute_max_json_depth,
895                    max_collection_entries: entries,
896                    collection_limits: ResolvedCollectionLimits {
897                        tool_catalog: entries,
898                        skill_catalog: entries,
899                        knowledge_entries: entries,
900                        initial_messages: entries,
901                        capability_grants: entries,
902                        governance_rules: entries,
903                    },
904                },
905                memory_access: None,
906                memory_policy: ResolvedMemoryPolicy {
907                    stale_warning_days: 2,
908                    retrieval_top_k: 5,
909                    validation_enabled: true,
910                    max_content_bytes: 10_000,
911                    max_name_length: 100,
912                    promotion_recall_threshold: None,
913                },
914                tool_catalog: Vec::new(),
915                skill_catalog: Vec::new(),
916                verification_contracts: Vec::new(),
917                feature_policy: ResolvedFeaturePolicy {
918                    memory_enabled: false,
919                    knowledge_enabled: false,
920                    plan_tool_enabled: false,
921                    stable_core_tool_ids: Vec::new(),
922                },
923                host_effect_support: HostEffectSupport::default(),
924            },
925        }
926    }
927}
928
929impl Default for ConfigDefaults {
930    fn default() -> Self {
931        Self::new(KernelBootstrapLimits::DEFAULT)
932    }
933}
934
935// ---------------------------------------------------------------------------------------------
936// resolution
937// ---------------------------------------------------------------------------------------------
938
939/// Normalise a sparse [`OperationConfig`] into the dense record the kernel stores, rejecting the
940/// whole configuration if any field — or any relationship between fields — is illegal.
941///
942/// Atomicity is structural, not a discipline: this function owns no state and returns either a
943/// complete resolved value or a rejection, so "the first eight fields applied and the ninth
944/// failed" has no representation.
945pub fn resolve_operation_config(
946    config: &OperationConfig,
947    defaults: &ConfigDefaults,
948) -> Result<ResolvedOperationConfig, WireRejection> {
949    let base = &defaults.baseline;
950
951    let kernel_limits = resolve_kernel_limits(
952        config.kernel_limits.as_ref(),
953        &defaults.bootstrap_limits,
954        &base.kernel_limits,
955    )?;
956    let execution_policy =
957        resolve_execution(config.execution_policy.as_ref(), &base.execution_policy)?;
958    let governance_policy = resolve_governance(
959        config.governance_policy.as_ref(),
960        &base.governance_policy,
961        kernel_limits.collection_limits.governance_rules,
962    )?;
963    let scheduler_policy =
964        resolve_scheduler(config.scheduler_policy.as_ref(), &base.scheduler_policy)?;
965    let resource_quota = resolve_quota(config.resource_quota.as_ref(), &base.resource_quota)?;
966    let budget_grant = resolve_budget_grant(config.budget_grant.as_ref())?;
967    let signal_policy = resolve_signal(config.signal_policy.as_ref(), &base.signal_policy)?;
968    let context_policy = resolve_context(
969        config.context_policy.as_ref(),
970        &base.context_policy,
971        execution_policy.max_context_tokens,
972    )?;
973    let recovery_policy = resolve_recovery(config.recovery_policy.as_ref(), &base.recovery_policy)?;
974    let payload_policy = resolve_payload(config.payload_policy.as_ref(), &base.payload_policy)?;
975    let memory_policy = resolve_memory_policy(config.memory_policy.as_ref(), &base.memory_policy)?;
976    let feature_policy = resolve_features(config.feature_policy.as_ref(), &base.feature_policy)?;
977
978    let tool_catalog = resolve_tool_catalog(
979        &config.tool_catalog,
980        kernel_limits.collection_limits.tool_catalog,
981    )?;
982    let skill_catalog = resolve_skill_catalog(
983        &config.skill_catalog,
984        kernel_limits.collection_limits.skill_catalog,
985        kernel_limits.collection_limits.capability_grants,
986        &tool_catalog,
987    )?;
988    let host_effect_support = resolve_host_effect_support(&config.host_effect_support)?;
989    let verification_contracts = resolve_verification_contracts(
990        &config.verification_contracts,
991        kernel_limits.max_collection_entries,
992        &tool_catalog,
993        &skill_catalog,
994    )?;
995
996    // cross-policy relationships that no single sub-resolver can see
997    if feature_policy.memory_enabled && config.memory_access.is_none() {
998        return Err(invalid(
999            "feature_policy.memory_enabled is true but no memory_access binding was configured",
1000        ));
1001    }
1002    require_declared_effect_support(
1003        &host_effect_support,
1004        &feature_policy,
1005        config.memory_access.as_ref(),
1006        &resource_quota,
1007        budget_grant.as_ref(),
1008        &governance_policy,
1009        &tool_catalog,
1010        &verification_contracts,
1011    )?;
1012    for tool_id in &feature_policy.stable_core_tool_ids {
1013        if !tool_catalog.iter().any(|tool| &tool.name == tool_id) {
1014            return Err(invalid(format!(
1015                "feature_policy.stable_core_tool_ids names {tool_id:?}, \
1016                 which the tool catalog does not declare"
1017            )));
1018        }
1019    }
1020
1021    Ok(ResolvedOperationConfig {
1022        execution_policy,
1023        governance_policy,
1024        scheduler_policy,
1025        resource_quota,
1026        budget_grant,
1027        signal_policy,
1028        context_policy,
1029        recovery_policy,
1030        payload_policy,
1031        kernel_limits,
1032        memory_access: config.memory_access.clone(),
1033        memory_policy,
1034        tool_catalog,
1035        skill_catalog,
1036        verification_contracts,
1037        feature_policy,
1038        host_effect_support,
1039    })
1040}
1041
1042/// §7.3 · normalise the verification-contract catalog.
1043///
1044/// Three rules, all of them "a reference must resolve to something this operation declared":
1045///
1046/// 1. **`contract_id` is unique.** Duplicates make
1047///    `LogicalAgentSpec.verification_contract_id` ambiguous, and an ambiguous authority reference
1048///    resolves by list order — i.e. silently.
1049/// 2. **`phase_id` is unique within its contract.** The phase id is what `EvaluateMilestone`
1050///    carries and what a `MilestoneCheckResult` names on the way back, so two phases sharing one
1051///    would let a verdict for the second advance the first.
1052/// 3. **Every `unlocks` entry names a declared capability.** The capability directory an operation
1053///    has at configure time is its `tool_catalog` plus its `skill_catalog`; unlocking anything else
1054///    is a mount with nothing behind it.
1055///
1056/// An empty contract (no phases) is refused too: it can never publish an `EvaluateMilestone`, so a
1057/// spec pointing at one would be a gate that silently is not there.
1058fn resolve_verification_contracts(
1059    contracts: &[VerificationContract],
1060    max_entries: u32,
1061    tool_catalog: &[ToolSchema],
1062    skill_catalog: &[SkillMetadata],
1063) -> Result<Vec<VerificationContract>, WireRejection> {
1064    if contracts.len() as u64 > max_entries as u64 {
1065        return Err(too_many(format!(
1066            "verification_contracts carries {} entries; the bound is {max_entries}",
1067            contracts.len()
1068        )));
1069    }
1070    let mut seen_contracts: Vec<&str> = Vec::with_capacity(contracts.len());
1071    for contract in contracts {
1072        if contract.contract_id.is_empty() {
1073            return Err(invalid(
1074                "a verification contract must carry a non-empty contract_id",
1075            ));
1076        }
1077        if seen_contracts.contains(&contract.contract_id.as_str()) {
1078            return Err(invalid(format!(
1079                "verification_contracts declares {:?} twice; a contract id is the reference \
1080                 `verification_contract_id` resolves against and must be unique",
1081                contract.contract_id
1082            )));
1083        }
1084        seen_contracts.push(&contract.contract_id);
1085
1086        if contract.phases.is_empty() {
1087            return Err(invalid(format!(
1088                "verification contract {:?} declares no phases; a contract with no phase can \
1089                 never be evaluated",
1090                contract.contract_id
1091            )));
1092        }
1093        if contract.phases.len() as u64 > max_entries as u64 {
1094            return Err(too_many(format!(
1095                "verification contract {:?} carries {} phases; the bound is {max_entries}",
1096                contract.contract_id,
1097                contract.phases.len()
1098            )));
1099        }
1100        let mut seen_phases: Vec<&str> = Vec::with_capacity(contract.phases.len());
1101        for phase in &contract.phases {
1102            if phase.phase_id.is_empty() {
1103                return Err(invalid(format!(
1104                    "verification contract {:?} carries a phase with an empty phase_id",
1105                    contract.contract_id
1106                )));
1107            }
1108            if seen_phases.contains(&phase.phase_id.as_str()) {
1109                return Err(invalid(format!(
1110                    "verification contract {:?} declares phase {:?} twice; a milestone verdict \
1111                     names its phase by id and could not say which one it advanced",
1112                    contract.contract_id, phase.phase_id
1113                )));
1114            }
1115            seen_phases.push(&phase.phase_id);
1116            for capability_id in &phase.unlocks {
1117                let declared = tool_catalog.iter().any(|tool| &tool.name == capability_id)
1118                    || skill_catalog
1119                        .iter()
1120                        .any(|skill| &skill.name == capability_id);
1121                if !declared {
1122                    return Err(invalid(format!(
1123                        "verification contract {:?} phase {:?} unlocks {capability_id:?}, which \
1124                         is in neither the tool catalog nor the skill catalog; a phase cannot \
1125                         mount a capability the operation never declared",
1126                        contract.contract_id, phase.phase_id
1127                    )));
1128                }
1129            }
1130        }
1131    }
1132    Ok(contracts.to_vec())
1133}
1134
1135/// Refuse a configuration that switches a capability on while declaring the host cannot execute
1136/// the effect that capability needs (DEC-8, §7.3).
1137///
1138/// DEC-8 already fail-closes at *runtime*: an undeclared effect kind is never emitted and the
1139/// kernel commits a fault instead. This check exists for the case runtime fail-closure handles
1140/// badly — a **self-contradictory configuration**, where the host asks for a capability in one
1141/// field and disowns its only execution path in another. Discovering that on turn 40, as a fault,
1142/// is strictly worse than discovering it at `ConfigureOperation`.
1143///
1144/// That is why the conditions are *affirmative declarations*, not absences. An uncapped
1145/// `resource_quota` does not mean "this operation will spawn"; it means the host never said. A
1146/// `max_total_subagents: 8`, on the other hand, is a statement of intent, and pairing it with an
1147/// undeclared `spawn_tasks` is a contradiction the host can only have written by mistake.
1148///
1149/// | effect kind | required when | why it is hard |
1150/// | --- | --- | --- |
1151/// | `call_provider` | always | no execution mode exists that never calls a provider |
1152/// | `execute_tools` | `tool_catalog` non-empty | a catalog the host cannot dispatch is a fail-open exposure surface |
1153/// | `load_payload` | `tool_catalog` non-empty | any result may exceed the inline threshold and become `External`; producing one the host cannot load back makes it unreadable (§7.10) |
1154/// | `request_approval` | governance can yield `AskUser` | the gate would otherwise produce an approval it has no way to ask for |
1155/// | `spawn_tasks` | quota/grant declares positive spawn or workflow capacity | declared capacity with no launch path |
1156/// | `preempt_tasks` | same condition as `spawn_tasks` | children you cannot stop leak past cancellation and budget exhaustion |
1157/// | `persist_memory` | `memory_enabled`, or the binding grants `write` | |
1158/// | `query_memory` | `memory_enabled`, or the binding grants `read` | |
1159/// | `archive_page_out` | `knowledge_enabled` | the knowledge partition is what gets swept out under budget pressure |
1160/// | `evaluate_milestone` | `verification_contracts` non-empty | a contract nothing can evaluate never resolves |
1161#[allow(clippy::too_many_arguments)]
1162fn require_declared_effect_support(
1163    support: &HostEffectSupport,
1164    features: &ResolvedFeaturePolicy,
1165    memory_access: Option<&MemoryAccessBinding>,
1166    quota: &ResourceQuota,
1167    grant: Option<&BudgetGrant>,
1168    governance: &ResolvedGovernancePolicy,
1169    tool_catalog: &[ToolSchema],
1170    verification_contracts: &[VerificationContract],
1171) -> Result<(), WireRejection> {
1172    let has_tools = !tool_catalog.is_empty();
1173    let can_ask_user = governance.default_action == PolicyAction::AskUser
1174        || governance
1175            .rules
1176            .iter()
1177            .any(|rule| rule.action == PolicyAction::AskUser);
1178    let declares_spawn_capacity = [
1179        quota.max_concurrent_subagents,
1180        quota.max_total_subagents,
1181        quota.max_spawn_depth,
1182        quota.max_workflow_nodes,
1183        grant.and_then(|grant| grant.subagents),
1184    ]
1185    .into_iter()
1186    .flatten()
1187    .any(|capacity| capacity > 0);
1188    let memory_write =
1189        features.memory_enabled || memory_access.is_some_and(|access| access.capabilities.write);
1190    let memory_read =
1191        features.memory_enabled || memory_access.is_some_and(|access| access.capabilities.read);
1192
1193    for (kind, required, because) in [
1194        (
1195            EffectKindTag::CallProvider,
1196            true,
1197            "every operation reaches a provider call",
1198        ),
1199        (
1200            EffectKindTag::ExecuteTools,
1201            has_tools,
1202            "tool_catalog declares tools this operation may dispatch",
1203        ),
1204        (
1205            EffectKindTag::LoadPayload,
1206            has_tools,
1207            "a tool result above the inline threshold becomes an external payload the kernel \
1208             must be able to page back in",
1209        ),
1210        (
1211            EffectKindTag::RequestApproval,
1212            can_ask_user,
1213            "governance_policy can return ask_user",
1214        ),
1215        (
1216            EffectKindTag::SpawnTasks,
1217            declares_spawn_capacity,
1218            "resource_quota or budget_grant declares spawn/workflow capacity",
1219        ),
1220        (
1221            EffectKindTag::PreemptTasks,
1222            declares_spawn_capacity,
1223            "an operation that may start child tasks must be able to stop them on \
1224             cancellation or budget exhaustion",
1225        ),
1226        (
1227            EffectKindTag::PersistMemory,
1228            memory_write,
1229            "the memory plane is writable",
1230        ),
1231        (
1232            EffectKindTag::QueryMemory,
1233            memory_read,
1234            "the memory plane is readable",
1235        ),
1236        (
1237            EffectKindTag::ArchivePageOut,
1238            features.knowledge_enabled,
1239            "feature_policy.knowledge_enabled exposes a partition that is paged out under \
1240             budget pressure",
1241        ),
1242        (
1243            EffectKindTag::EvaluateMilestone,
1244            !verification_contracts.is_empty(),
1245            "verification_contracts declares contracts that must be evaluated",
1246        ),
1247    ] {
1248        if required && !support.supports(kind) {
1249            return Err(invalid(format!(
1250                "host_effect_support does not declare {:?}, but {because}; \
1251                 a capability the host cannot execute must not be configured on",
1252                kind.as_str()
1253            )));
1254        }
1255    }
1256    Ok(())
1257}
1258
1259fn resolve_kernel_limits(
1260    limits: Option<&KernelLimits>,
1261    bootstrap: &KernelBootstrapLimits,
1262    base: &ResolvedKernelLimits,
1263) -> Result<ResolvedKernelLimits, WireRejection> {
1264    let mut resolved = *base;
1265    resolved.max_input_bytes = bootstrap.absolute_max_input_bytes;
1266    resolved.max_json_depth = bootstrap.absolute_max_json_depth;
1267    resolved.max_collection_entries = bootstrap.absolute_max_collection_entries;
1268
1269    if let Some(limits) = limits {
1270        if let Some(bytes) = limits.max_input_bytes {
1271            require_le_u32(
1272                "kernel_limits.max_input_bytes",
1273                bytes,
1274                bootstrap.absolute_max_input_bytes,
1275                "absolute_max_input_bytes",
1276            )?;
1277            if bytes == 0 {
1278                return Err(invalid("kernel_limits.max_input_bytes must be positive"));
1279            }
1280            resolved.max_input_bytes = bytes;
1281        }
1282        if let Some(depth) = limits.max_json_depth {
1283            require_le_u32(
1284                "kernel_limits.max_json_depth",
1285                u32::from(depth),
1286                u32::from(bootstrap.absolute_max_json_depth),
1287                "absolute_max_json_depth",
1288            )?;
1289            if depth == 0 {
1290                return Err(invalid("kernel_limits.max_json_depth must be positive"));
1291            }
1292            resolved.max_json_depth = depth;
1293        }
1294        if let Some(entries) = limits.max_collection_entries {
1295            require_le_u32(
1296                "kernel_limits.max_collection_entries",
1297                entries,
1298                bootstrap.absolute_max_collection_entries,
1299                "absolute_max_collection_entries",
1300            )?;
1301            if entries == 0 {
1302                return Err(invalid(
1303                    "kernel_limits.max_collection_entries must be positive",
1304                ));
1305            }
1306            resolved.max_collection_entries = entries;
1307        }
1308    }
1309
1310    let ceiling = resolved.max_collection_entries;
1311    let mut per_collection = ResolvedCollectionLimits {
1312        tool_catalog: ceiling,
1313        skill_catalog: ceiling,
1314        knowledge_entries: ceiling,
1315        initial_messages: ceiling,
1316        capability_grants: ceiling,
1317        governance_rules: ceiling,
1318    };
1319
1320    if let Some(named) = limits.and_then(|limits| limits.collection_limits.as_ref()) {
1321        for (label, requested, slot) in [
1322            (
1323                "tool_catalog",
1324                named.tool_catalog,
1325                &mut per_collection.tool_catalog,
1326            ),
1327            (
1328                "skill_catalog",
1329                named.skill_catalog,
1330                &mut per_collection.skill_catalog,
1331            ),
1332            (
1333                "knowledge_entries",
1334                named.knowledge_entries,
1335                &mut per_collection.knowledge_entries,
1336            ),
1337            (
1338                "initial_messages",
1339                named.initial_messages,
1340                &mut per_collection.initial_messages,
1341            ),
1342            (
1343                "capability_grants",
1344                named.capability_grants,
1345                &mut per_collection.capability_grants,
1346            ),
1347            (
1348                "governance_rules",
1349                named.governance_rules,
1350                &mut per_collection.governance_rules,
1351            ),
1352        ] {
1353            if let Some(requested) = requested {
1354                require_le_u32(
1355                    &format!("kernel_limits.collection_limits.{label}"),
1356                    requested,
1357                    ceiling,
1358                    "the resolved max_collection_entries",
1359                )?;
1360                *slot = requested;
1361            }
1362        }
1363    }
1364
1365    resolved.collection_limits = per_collection;
1366    Ok(resolved)
1367}
1368
1369fn resolve_execution(
1370    policy: Option<&ExecutionPolicy>,
1371    base: &ResolvedExecutionPolicy,
1372) -> Result<ResolvedExecutionPolicy, WireRejection> {
1373    let mut resolved = base.clone();
1374    if let Some(policy) = policy {
1375        if let Some(value) = policy.max_context_tokens {
1376            resolved.max_context_tokens = value;
1377        }
1378        if let Some(value) = policy.max_turns {
1379            resolved.max_turns = value;
1380        }
1381        if let Some(value) = policy.max_total_tokens {
1382            resolved.max_total_tokens = value;
1383        }
1384        // absent `max_wall_ms` keeps the baseline; clearing a deadline is `UpdateDeadline`'s job
1385        if let Some(value) = policy.max_wall_ms {
1386            resolved.max_wall_ms = Some(value);
1387        }
1388        if let Some(value) = policy.criteria_gate_enabled {
1389            resolved.criteria_gate_enabled = value;
1390        }
1391        if let Some(fuse) = &policy.repeat_fuse {
1392            if let Some(value) = fuse.enabled {
1393                resolved.repeat_fuse.enabled = value;
1394            }
1395            if let Some(value) = fuse.deny_after {
1396                resolved.repeat_fuse.deny_after = value;
1397            }
1398            if let Some(value) = fuse.terminate_after {
1399                resolved.repeat_fuse.terminate_after = value;
1400            }
1401        }
1402        if let Some(watch) = &policy.entropy_watch {
1403            if let Some(value) = watch.enabled {
1404                resolved.entropy_watch.enabled = value;
1405            }
1406            if let Some(value) = watch.threshold_ppm {
1407                resolved.entropy_watch.threshold_ppm = value;
1408            }
1409            if let Some(value) = watch.hysteresis_ppm {
1410                resolved.entropy_watch.hysteresis_ppm = value;
1411            }
1412            if let Some(value) = watch.cooldown_turns {
1413                resolved.entropy_watch.cooldown_turns = value;
1414            }
1415            if let Some(value) = watch.notify_model {
1416                resolved.entropy_watch.notify_model = value;
1417            }
1418        }
1419    }
1420
1421    if resolved.max_turns == 0 {
1422        return Err(invalid("execution_policy.max_turns must be positive"));
1423    }
1424    if resolved.max_context_tokens == 0 {
1425        return Err(invalid(
1426            "execution_policy.max_context_tokens must be positive",
1427        ));
1428    }
1429    if resolved.max_total_tokens.get() == 0 {
1430        return Err(invalid(
1431            "execution_policy.max_total_tokens must be positive",
1432        ));
1433    }
1434    if resolved.max_wall_ms.is_some_and(|ms| ms.get() == 0) {
1435        return Err(invalid(
1436            "execution_policy.max_wall_ms must be positive; omit it for no wall-clock limit",
1437        ));
1438    }
1439    if resolved.repeat_fuse.enabled {
1440        if resolved.repeat_fuse.deny_after == 0 {
1441            return Err(invalid(
1442                "execution_policy.repeat_fuse.deny_after must be positive while the fuse is enabled",
1443            ));
1444        }
1445        if resolved.repeat_fuse.terminate_after <= resolved.repeat_fuse.deny_after {
1446            return Err(invalid(format!(
1447                "execution_policy.repeat_fuse.terminate_after ({}) must exceed deny_after ({}); \
1448                 otherwise the run terminates before the deny ever takes effect",
1449                resolved.repeat_fuse.terminate_after, resolved.repeat_fuse.deny_after
1450            )));
1451        }
1452    }
1453    if resolved.entropy_watch.enabled
1454        && resolved.entropy_watch.hysteresis_ppm > resolved.entropy_watch.threshold_ppm
1455    {
1456        return Err(invalid(format!(
1457            "execution_policy.entropy_watch.hysteresis_ppm ({}) must not exceed threshold_ppm ({}); \
1458             a wider hysteresis than threshold can never re-arm",
1459            resolved.entropy_watch.hysteresis_ppm.get(),
1460            resolved.entropy_watch.threshold_ppm.get()
1461        )));
1462    }
1463    Ok(resolved)
1464}
1465
1466fn resolve_governance(
1467    policy: Option<&GovernancePolicy>,
1468    base: &ResolvedGovernancePolicy,
1469    rule_bound: u32,
1470) -> Result<ResolvedGovernancePolicy, WireRejection> {
1471    let mut resolved = base.clone();
1472    if let Some(policy) = policy {
1473        if let Some(action) = policy.default_action {
1474            resolved.default_action = action;
1475        }
1476        resolved.rules = policy.rules.clone();
1477        resolved.vetoed_tools = policy.vetoed_tools.clone();
1478        resolved.rate_limits = policy.rate_limits.clone();
1479        resolved.constraints = policy.constraints.clone();
1480    }
1481    validate_governance(&resolved, rule_bound)?;
1482    Ok(resolved)
1483}
1484
1485/// Shared by boot resolution and the live `ReplaceGovernancePolicy` patch, so the two can never
1486/// disagree about what a legal governance posture is.
1487pub(super) fn validate_governance(
1488    policy: &ResolvedGovernancePolicy,
1489    rule_bound: u32,
1490) -> Result<(), WireRejection> {
1491    let total = policy.rules.len() + policy.rate_limits.len() + policy.constraints.len();
1492    if total > rule_bound as usize {
1493        return Err(too_many(format!(
1494            "governance policy declares {total} rules/limits/constraints; \
1495             the resolved governance_rules bound is {rule_bound}"
1496        )));
1497    }
1498    for rule in &policy.rules {
1499        if rule.tool_pattern.is_empty() {
1500            return Err(invalid("governance rule tool_pattern must not be empty"));
1501        }
1502    }
1503    for tool in &policy.vetoed_tools {
1504        if tool.is_empty() {
1505            return Err(invalid("governance vetoed_tools entries must not be empty"));
1506        }
1507    }
1508    for limit in &policy.rate_limits {
1509        if limit.tool.is_empty() {
1510            return Err(invalid("governance rate limit tool must not be empty"));
1511        }
1512        if limit.window_ms.get() == 0 {
1513            return Err(invalid(format!(
1514                "governance rate limit for {:?} has a zero window",
1515                limit.tool
1516            )));
1517        }
1518    }
1519    for constraint in &policy.constraints {
1520        constraint.validate().map_err(invalid)?;
1521    }
1522    Ok(())
1523}
1524
1525fn resolve_scheduler(
1526    policy: Option<&SchedulerPolicy>,
1527    base: &ResolvedSchedulerPolicy,
1528) -> Result<ResolvedSchedulerPolicy, WireRejection> {
1529    let mut resolved = *base;
1530    if let Some(policy) = policy {
1531        if let Some(value) = policy.critical_path_weight {
1532            resolved.critical_path_weight = value;
1533        }
1534        if let Some(value) = policy.fanout_weight {
1535            resolved.fanout_weight = value;
1536        }
1537        if let Some(value) = policy.age_weight {
1538            resolved.age_weight = value;
1539        }
1540        if let Some(value) = policy.token_cost_weight {
1541            resolved.token_cost_weight = value;
1542        }
1543        if let Some(value) = policy.deadline_weight {
1544            resolved.deadline_weight = value;
1545        }
1546        if let Some(value) = policy.process_priority_weight {
1547            resolved.process_priority_weight = value;
1548        }
1549        if let Some(value) = policy.resource_pressure_weight {
1550            resolved.resource_pressure_weight = value;
1551        }
1552        if let Some(value) = policy.budget_pressure_weight {
1553            resolved.budget_pressure_weight = value;
1554        }
1555    }
1556    for (label, weight) in [
1557        ("critical_path_weight", resolved.critical_path_weight),
1558        ("fanout_weight", resolved.fanout_weight),
1559        ("age_weight", resolved.age_weight),
1560        ("token_cost_weight", resolved.token_cost_weight),
1561        ("deadline_weight", resolved.deadline_weight),
1562        ("process_priority_weight", resolved.process_priority_weight),
1563        (
1564            "resource_pressure_weight",
1565            resolved.resource_pressure_weight,
1566        ),
1567        ("budget_pressure_weight", resolved.budget_pressure_weight),
1568    ] {
1569        if weight > MAX_SCHEDULER_WEIGHT {
1570            return Err(invalid(format!(
1571                "scheduler_policy.{label} is {weight}; the bound is {MAX_SCHEDULER_WEIGHT}"
1572            )));
1573        }
1574    }
1575    Ok(resolved)
1576}
1577
1578fn resolve_quota(
1579    quota: Option<&ResourceQuota>,
1580    base: &ResourceQuota,
1581) -> Result<ResourceQuota, WireRejection> {
1582    let resolved = quota.cloned().unwrap_or_else(|| base.clone());
1583    validate_quota(&resolved)?;
1584    Ok(resolved)
1585}
1586
1587pub(super) fn validate_quota(quota: &ResourceQuota) -> Result<(), WireRejection> {
1588    if let (Some(concurrent), Some(total)) =
1589        (quota.max_concurrent_subagents, quota.max_total_subagents)
1590        && concurrent > total
1591    {
1592        return Err(invalid(format!(
1593            "resource_quota.max_concurrent_subagents ({concurrent}) exceeds \
1594             max_total_subagents ({total}); the concurrent cap can never be reached"
1595        )));
1596    }
1597    if quota.max_spawn_depth == Some(0) {
1598        return Err(invalid(
1599            "resource_quota.max_spawn_depth must be positive; omit it for no depth cap",
1600        ));
1601    }
1602    if let Some(window) = &quota.memory_writes_per_window
1603        && window.window_ms.get() == 0
1604    {
1605        return Err(invalid(
1606            "resource_quota.memory_writes_per_window.window_ms must be positive",
1607        ));
1608    }
1609    Ok(())
1610}
1611
1612fn resolve_budget_grant(grant: Option<&BudgetGrant>) -> Result<Option<BudgetGrant>, WireRejection> {
1613    let Some(grant) = grant else {
1614        return Ok(None);
1615    };
1616    if grant.reservation_id.is_empty() {
1617        return Err(invalid("budget_grant.reservation_id must not be empty"));
1618    }
1619    if grant.tokens.is_some_and(|tokens| tokens.get() == 0) {
1620        return Err(invalid(
1621            "budget_grant.tokens must be positive; a zero grant is a refused admission, \
1622             not a configuration",
1623        ));
1624    }
1625    Ok(Some(grant.clone()))
1626}
1627
1628fn resolve_signal(
1629    policy: Option<&SignalPolicy>,
1630    base: &ResolvedSignalPolicy,
1631) -> Result<ResolvedSignalPolicy, WireRejection> {
1632    let resolved = match policy {
1633        Some(policy) => ResolvedSignalPolicy {
1634            queue_max: policy.queue_max,
1635            ttl_ms: policy.ttl_ms,
1636            deadline_escalation: policy
1637                .deadline_escalation
1638                .unwrap_or(base.deadline_escalation),
1639        },
1640        None => *base,
1641    };
1642    validate_signal(&resolved)?;
1643    Ok(resolved)
1644}
1645
1646pub(super) fn validate_signal(policy: &ResolvedSignalPolicy) -> Result<(), WireRejection> {
1647    if policy.queue_max == 0 {
1648        return Err(invalid("signal_policy.queue_max must be positive"));
1649    }
1650    if policy.ttl_ms.is_some_and(|ttl| ttl.get() == 0) {
1651        return Err(invalid(
1652            "signal_policy.ttl_ms must be positive; omit it for no expiry",
1653        ));
1654    }
1655    Ok(())
1656}
1657
1658fn resolve_context(
1659    policy: Option<&ContextPolicy>,
1660    base: &ResolvedContextPolicy,
1661    max_context_tokens: u32,
1662) -> Result<ResolvedContextPolicy, WireRejection> {
1663    let mut resolved = base.clone();
1664    if let Some(policy) = policy {
1665        if let Some(value) = policy.pressure_thresholds_ppm {
1666            resolved.pressure_thresholds_ppm = value;
1667        }
1668        if let Some(value) = policy.target_after_compress_ppm {
1669            resolved.target_after_compress_ppm = value;
1670        }
1671        if let Some(value) = policy.preserve_recent_turns {
1672            resolved.preserve_recent_turns = value;
1673        }
1674        if let Some(value) = policy.renewal_carryover_ppm {
1675            resolved.renewal_carryover_ppm = value;
1676        }
1677        if let Some(value) = policy.collapse_old_assistant_narration {
1678            resolved.collapse_old_assistant_narration = value;
1679        }
1680        if let Some(value) = policy.idle_micro_compact_minutes {
1681            resolved.idle_micro_compact_minutes = value;
1682        }
1683        if let Some(value) = policy.knowledge_budget_ppm {
1684            resolved.knowledge_budget_ppm = value;
1685        }
1686        if let Some(value) = policy.prompt_budget {
1687            resolved.prompt_budget = value;
1688        }
1689    }
1690
1691    let t = &resolved.pressure_thresholds_ppm;
1692    if !(t.snip < t.micro && t.micro < t.collapse && t.collapse < t.auto && t.auto < t.renewal) {
1693        return Err(invalid(format!(
1694            "context_policy pressure thresholds must strictly increase \
1695             (snip {} < micro {} < collapse {} < auto {} < renewal {})",
1696            t.snip.get(),
1697            t.micro.get(),
1698            t.collapse.get(),
1699            t.auto.get(),
1700            t.renewal.get()
1701        )));
1702    }
1703    if resolved.target_after_compress_ppm >= t.snip {
1704        return Err(invalid(format!(
1705            "context_policy.target_after_compress_ppm ({}) must be below the snip threshold ({}); \
1706             otherwise a compression pass can never reach its own target",
1707            resolved.target_after_compress_ppm.get(),
1708            t.snip.get()
1709        )));
1710    }
1711    if resolved.preserve_recent_turns == 0 {
1712        return Err(invalid(
1713            "context_policy.preserve_recent_turns must be positive",
1714        ));
1715    }
1716    if resolved.knowledge_budget_ppm.get() + resolved.renewal_carryover_ppm.get() > Ppm::MAX_PPM {
1717        return Err(invalid(format!(
1718            "context_policy.knowledge_budget_ppm ({}) plus renewal_carryover_ppm ({}) exceeds \
1719             the whole context budget",
1720            resolved.knowledge_budget_ppm.get(),
1721            resolved.renewal_carryover_ppm.get()
1722        )));
1723    }
1724    if resolved.prompt_budget.reserved_tokens() >= max_context_tokens {
1725        return Err(invalid(format!(
1726            "context_policy.prompt_budget reserves {} tokens of a {max_context_tokens}-token \
1727             context window, leaving nothing to render",
1728            resolved.prompt_budget.reserved_tokens()
1729        )));
1730    }
1731    Ok(resolved)
1732}
1733
1734fn resolve_recovery(
1735    policy: Option<&RecoveryPolicy>,
1736    base: &ResolvedRecoveryPolicy,
1737) -> Result<ResolvedRecoveryPolicy, WireRejection> {
1738    let mut resolved = *base;
1739    if let Some(policy) = policy {
1740        if let Some(value) = policy.provider_recovery_attempts {
1741            resolved.provider_recovery_attempts = value;
1742        }
1743        if let Some(value) = policy.output_recovery_attempts {
1744            resolved.output_recovery_attempts = value;
1745        }
1746        if let Some(bounds) = &policy.tail_bounds {
1747            resolved.tail_bounds = apply_tail_bounds(bounds, resolved.tail_bounds);
1748        }
1749    }
1750    validate_recovery(&resolved)?;
1751    Ok(resolved)
1752}
1753
1754/// Per-axis override. A host that states one axis keeps the kernel's baseline on the other three,
1755/// which is the same sparse-overlay rule every other policy here follows.
1756fn apply_tail_bounds(policy: &TailBoundsPolicy, base: TailBounds) -> TailBounds {
1757    TailBounds {
1758        soft_records: policy.soft_records.unwrap_or(base.soft_records),
1759        hard_records: policy.hard_records.unwrap_or(base.hard_records),
1760        soft_bytes: policy.soft_bytes.unwrap_or(base.soft_bytes),
1761        hard_bytes: policy.hard_bytes.unwrap_or(base.hard_bytes),
1762    }
1763}
1764
1765/// Ceiling shared by both semantic recovery ladders.
1766pub const MAX_RECOVERY_ATTEMPTS: u8 = 16;
1767
1768pub(super) fn validate_recovery(policy: &ResolvedRecoveryPolicy) -> Result<(), WireRejection> {
1769    for (label, value) in [
1770        (
1771            "provider_recovery_attempts",
1772            policy.provider_recovery_attempts,
1773        ),
1774        ("output_recovery_attempts", policy.output_recovery_attempts),
1775    ] {
1776        if value > MAX_RECOVERY_ATTEMPTS {
1777            return Err(invalid(format!(
1778                "recovery_policy.{label} is {value}; the bound is {MAX_RECOVERY_ATTEMPTS}"
1779            )));
1780        }
1781    }
1782    policy.tail_bounds.check().map_err(invalid)?;
1783    Ok(())
1784}
1785
1786fn resolve_payload(
1787    policy: Option<&PayloadPolicy>,
1788    base: &ResolvedPayloadPolicy,
1789) -> Result<ResolvedPayloadPolicy, WireRejection> {
1790    let mut resolved = *base;
1791    if let Some(policy) = policy {
1792        if let Some(value) = policy.inline_threshold_bytes {
1793            resolved.inline_threshold_bytes = value;
1794        }
1795        if let Some(value) = policy.preview_bytes {
1796            resolved.preview_bytes = value;
1797        }
1798    }
1799    if resolved.inline_threshold_bytes == 0 {
1800        return Err(invalid(
1801            "payload_policy.inline_threshold_bytes must be positive",
1802        ));
1803    }
1804    if resolved.preview_bytes == 0 || resolved.preview_bytes > resolved.inline_threshold_bytes {
1805        return Err(invalid(format!(
1806            "payload_policy.preview_bytes ({}) must be positive and no larger than \
1807             inline_threshold_bytes ({})",
1808            resolved.preview_bytes, resolved.inline_threshold_bytes
1809        )));
1810    }
1811    Ok(resolved)
1812}
1813
1814fn resolve_memory_policy(
1815    policy: Option<&MemoryPolicy>,
1816    base: &ResolvedMemoryPolicy,
1817) -> Result<ResolvedMemoryPolicy, WireRejection> {
1818    let mut resolved = *base;
1819    if let Some(policy) = policy {
1820        if let Some(value) = policy.stale_warning_days {
1821            resolved.stale_warning_days = value;
1822        }
1823        if let Some(value) = policy.retrieval_top_k {
1824            resolved.retrieval_top_k = value;
1825        }
1826        if let Some(value) = policy.validation_enabled {
1827            resolved.validation_enabled = value;
1828        }
1829        if let Some(value) = policy.max_content_bytes {
1830            resolved.max_content_bytes = value;
1831        }
1832        if let Some(value) = policy.max_name_length {
1833            resolved.max_name_length = value;
1834        }
1835        if let Some(value) = policy.promotion_recall_threshold {
1836            resolved.promotion_recall_threshold = Some(value);
1837        }
1838    }
1839    if resolved.retrieval_top_k == 0 {
1840        return Err(invalid("memory_policy.retrieval_top_k must be positive"));
1841    }
1842    if resolved.validation_enabled
1843        && (resolved.max_content_bytes == 0 || resolved.max_name_length == 0)
1844    {
1845        return Err(invalid(
1846            "memory_policy validation is enabled but max_content_bytes / max_name_length is zero, \
1847             which rejects every write",
1848        ));
1849    }
1850    if resolved
1851        .promotion_recall_threshold
1852        .is_some_and(|threshold| threshold.get() == 0)
1853    {
1854        return Err(invalid(
1855            "memory_policy.promotion_recall_threshold must be positive; \
1856             omit it to disable promotion suggestions",
1857        ));
1858    }
1859    Ok(resolved)
1860}
1861
1862fn resolve_features(
1863    policy: Option<&FeaturePolicy>,
1864    base: &ResolvedFeaturePolicy,
1865) -> Result<ResolvedFeaturePolicy, WireRejection> {
1866    let mut resolved = base.clone();
1867    if let Some(policy) = policy {
1868        if let Some(value) = policy.memory_enabled {
1869            resolved.memory_enabled = value;
1870        }
1871        if let Some(value) = policy.knowledge_enabled {
1872            resolved.knowledge_enabled = value;
1873        }
1874        if let Some(value) = policy.plan_tool_enabled {
1875            resolved.plan_tool_enabled = value;
1876        }
1877        if let Some(ids) = &policy.stable_core_tool_ids {
1878            resolved.stable_core_tool_ids = ids.clone();
1879        }
1880    }
1881    for id in &resolved.stable_core_tool_ids {
1882        if id.is_empty() {
1883            return Err(invalid(
1884                "feature_policy.stable_core_tool_ids entries must not be empty",
1885            ));
1886        }
1887    }
1888    Ok(resolved)
1889}
1890
1891fn resolve_tool_catalog(
1892    catalog: &[ToolSchema],
1893    bound: u32,
1894) -> Result<Vec<ToolSchema>, WireRejection> {
1895    if catalog.len() > bound as usize {
1896        return Err(too_many(format!(
1897            "tool_catalog declares {} tools; the resolved bound is {bound}",
1898            catalog.len()
1899        )));
1900    }
1901    for (index, tool) in catalog.iter().enumerate() {
1902        if tool.name.is_empty() {
1903            return Err(invalid("tool_catalog entry has an empty name"));
1904        }
1905        if catalog[..index].iter().any(|other| other.name == tool.name) {
1906            return Err(invalid(format!(
1907                "tool_catalog declares {:?} twice; a catalog is a set, and a duplicate makes \
1908                 dispatch order-dependent",
1909                tool.name
1910            )));
1911        }
1912    }
1913    Ok(catalog.to_vec())
1914}
1915
1916fn resolve_skill_catalog(
1917    catalog: &[SkillMetadata],
1918    bound: u32,
1919    capability_grants_bound: u32,
1920    tools: &[ToolSchema],
1921) -> Result<Vec<SkillMetadata>, WireRejection> {
1922    if catalog.len() > bound as usize {
1923        return Err(too_many(format!(
1924            "skill_catalog declares {} skills; the resolved bound is {bound}",
1925            catalog.len()
1926        )));
1927    }
1928    for (index, skill) in catalog.iter().enumerate() {
1929        if skill.name.is_empty() {
1930            return Err(invalid("skill_catalog entry has an empty name"));
1931        }
1932        if catalog[..index]
1933            .iter()
1934            .any(|other| other.name == skill.name)
1935        {
1936            return Err(invalid(format!(
1937                "skill_catalog declares {:?} twice",
1938                skill.name
1939            )));
1940        }
1941        if skill
1942            .effort
1943            .is_some_and(|effort| !(1..=5).contains(&effort))
1944        {
1945            return Err(invalid(format!(
1946                "skill {:?} declares effort {}; the range is 1..=5",
1947                skill.name,
1948                skill.effort.unwrap_or_default()
1949            )));
1950        }
1951        if skill.capability_grants.len() > capability_grants_bound as usize {
1952            return Err(too_many(format!(
1953                "skill {:?} declares {} capability grants; the resolved bound is {capability_grants_bound}",
1954                skill.name,
1955                skill.capability_grants.len()
1956            )));
1957        }
1958        for tool in &skill.allowed_tools {
1959            if !tools.iter().any(|declared| &declared.name == tool) {
1960                return Err(invalid(format!(
1961                    "skill {:?} allows {tool:?}, which the tool catalog does not declare; \
1962                     a skill can only ever narrow the catalog",
1963                    skill.name
1964                )));
1965            }
1966        }
1967    }
1968    Ok(catalog.to_vec())
1969}
1970
1971fn resolve_host_effect_support(
1972    support: &HostEffectSupport,
1973) -> Result<HostEffectSupport, WireRejection> {
1974    for (index, kind) in support.supported.iter().enumerate() {
1975        if support.supported[..index].contains(kind) {
1976            return Err(invalid(format!(
1977                "host_effect_support declares {kind:?} twice"
1978            )));
1979        }
1980    }
1981    Ok(support.clone())
1982}
1983
1984// ---------------------------------------------------------------------------------------------
1985// tests
1986// ---------------------------------------------------------------------------------------------
1987
1988#[cfg(test)]
1989mod tests {
1990    use super::*;
1991    use crate::runtime::kernel::wire::command::{
1992        HostCommand, LivePolicyPatch, ParamConstraint, PolicyRule, RateLimitSpec,
1993        ReplaceGovernancePolicy, ReplaceRecoveryPolicy, ReplaceSignalPolicy, RequiredParam,
1994        TightenResourceQuota,
1995    };
1996    use crate::runtime::kernel::wire::effect::MemoryCapabilities;
1997    use crate::runtime::kernel::wire::scalar::{BoundedJson, MemoryBindingId, SCALAR_ERROR_MARKER};
1998    use serde_json::{Value, json};
1999    use std::collections::BTreeSet;
2000    use std::fs;
2001    use std::path::PathBuf;
2002
2003    // -----------------------------------------------------------------------------------------
2004    // helpers
2005    // -----------------------------------------------------------------------------------------
2006
2007    fn ppm(value: u32) -> Ppm {
2008        Ppm::new(value).unwrap()
2009    }
2010
2011    fn minimal_config() -> OperationConfig {
2012        OperationConfig {
2013            host_effect_support: HostEffectSupport::new([EffectKindTag::CallProvider]),
2014            ..OperationConfig::default()
2015        }
2016    }
2017
2018    fn defaults() -> ConfigDefaults {
2019        ConfigDefaults::default()
2020    }
2021
2022    fn fixture_dir() -> PathBuf {
2023        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
2024    }
2025
2026    fn fixtures_with_prefix(prefix: &str) -> Vec<(String, Value)> {
2027        let dir = fixture_dir();
2028        let mut names: Vec<String> = fs::read_dir(&dir)
2029            .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()))
2030            .map(|entry| {
2031                entry
2032                    .expect("dir entry")
2033                    .file_name()
2034                    .to_string_lossy()
2035                    .to_string()
2036            })
2037            .filter(|name| name.ends_with(".json") && name.starts_with(prefix))
2038            .collect();
2039        names.sort();
2040        assert!(!names.is_empty(), "no {prefix}*.json fixtures");
2041        names
2042            .into_iter()
2043            .map(|name| {
2044                let raw = fs::read_to_string(dir.join(&name))
2045                    .unwrap_or_else(|e| panic!("failed to read {name}: {e}"));
2046                let value: Value = serde_json::from_str(&raw)
2047                    .unwrap_or_else(|e| panic!("{name} is not JSON: {e}"));
2048                (name, value)
2049            })
2050            .collect()
2051    }
2052
2053    fn all_keys(value: &Value, out: &mut BTreeSet<String>) {
2054        match value {
2055            Value::Object(map) => {
2056                for (key, child) in map {
2057                    out.insert(key.clone());
2058                    all_keys(child, out);
2059                }
2060            }
2061            Value::Array(items) => items.iter().for_each(|item| all_keys(item, out)),
2062            _ => {}
2063        }
2064    }
2065
2066    /// A config exercising every field, so key-absence tests see the whole surface.
2067    fn fully_populated_config() -> OperationConfig {
2068        OperationConfig {
2069            execution_policy: Some(ExecutionPolicy {
2070                max_context_tokens: Some(200_000),
2071                max_turns: Some(40),
2072                max_total_tokens: Some(WireU64::new(2_000_000)),
2073                max_wall_ms: Some(WireU64::new(600_000)),
2074                criteria_gate_enabled: Some(true),
2075                repeat_fuse: Some(RepeatFusePolicy {
2076                    enabled: Some(true),
2077                    deny_after: Some(4),
2078                    terminate_after: Some(7),
2079                }),
2080                entropy_watch: Some(EntropyWatchPolicy {
2081                    enabled: Some(true),
2082                    threshold_ppm: Some(ppm(650_000)),
2083                    hysteresis_ppm: Some(ppm(100_000)),
2084                    cooldown_turns: Some(3),
2085                    notify_model: Some(true),
2086                }),
2087            }),
2088            governance_policy: Some(GovernancePolicy {
2089                default_action: Some(PolicyAction::AskUser),
2090                rules: vec![PolicyRule {
2091                    tool_pattern: "shell.*".to_string(),
2092                    action: PolicyAction::Deny,
2093                }],
2094                vetoed_tools: vec!["rm".to_string()],
2095                rate_limits: vec![RateLimitSpec {
2096                    tool: "search".to_string(),
2097                    max_calls: 10,
2098                    window_ms: WireU64::new(60_000),
2099                }],
2100                constraints: vec![ParamConstraint::Required(RequiredParam {
2101                    tool: "write".to_string(),
2102                    param_path: "destination".to_string(),
2103                })],
2104            }),
2105            scheduler_policy: Some(SchedulerPolicy {
2106                critical_path_weight: Some(900_000),
2107                fanout_weight: Some(9_000),
2108                age_weight: Some(900),
2109                token_cost_weight: Some(2),
2110                deadline_weight: None,
2111                process_priority_weight: None,
2112                resource_pressure_weight: None,
2113                budget_pressure_weight: None,
2114            }),
2115            resource_quota: Some(ResourceQuota {
2116                max_concurrent_subagents: Some(2),
2117                max_total_subagents: Some(8),
2118                max_spawn_depth: Some(2),
2119                max_workflow_nodes: Some(64),
2120                memory_writes_per_window: Some(RateWindow {
2121                    max_events: 4,
2122                    window_ms: WireU64::new(60_000),
2123                }),
2124            }),
2125            budget_grant: Some(BudgetGrant {
2126                reservation_id: "res-1".to_string(),
2127                tokens: Some(WireU64::new(500_000)),
2128                subagents: Some(4),
2129                rounds: Some(3),
2130            }),
2131            signal_policy: Some(SignalPolicy {
2132                queue_max: 32,
2133                ttl_ms: Some(WireU64::new(30_000)),
2134                deadline_escalation: Some(true),
2135            }),
2136            context_policy: Some(ContextPolicy {
2137                pressure_thresholds_ppm: Some(PressureThresholds {
2138                    snip: ppm(700_000),
2139                    micro: ppm(800_000),
2140                    collapse: ppm(900_000),
2141                    auto: ppm(950_000),
2142                    renewal: ppm(980_000),
2143                }),
2144                target_after_compress_ppm: Some(ppm(650_000)),
2145                preserve_recent_turns: Some(3),
2146                renewal_carryover_ppm: Some(ppm(50_000)),
2147                collapse_old_assistant_narration: Some(true),
2148                idle_micro_compact_minutes: Some(45),
2149                knowledge_budget_ppm: Some(ppm(250_000)),
2150                prompt_budget: Some(PromptBudget {
2151                    prompt_overhead_tokens: 1_200,
2152                    output_reserve_tokens: 4_000,
2153                    safety_margin_tokens: 500,
2154                }),
2155            }),
2156            recovery_policy: Some(RecoveryPolicy {
2157                provider_recovery_attempts: Some(2),
2158                output_recovery_attempts: Some(1),
2159                tail_bounds: Some(TailBoundsPolicy {
2160                    soft_records: Some(WireU64::new(8)),
2161                    hard_records: Some(WireU64::new(16)),
2162                    soft_bytes: Some(WireU64::new(4_096)),
2163                    hard_bytes: Some(WireU64::new(65_536)),
2164                }),
2165            }),
2166            payload_policy: Some(PayloadPolicy {
2167                inline_threshold_bytes: Some(32_768),
2168                preview_bytes: Some(1_024),
2169            }),
2170            kernel_limits: Some(KernelLimits {
2171                max_input_bytes: Some(1_048_576),
2172                max_json_depth: Some(32),
2173                max_collection_entries: Some(4_096),
2174                collection_limits: Some(CollectionLimits {
2175                    tool_catalog: Some(256),
2176                    skill_catalog: Some(64),
2177                    knowledge_entries: Some(512),
2178                    initial_messages: Some(1_024),
2179                    capability_grants: Some(128),
2180                    governance_rules: Some(64),
2181                }),
2182            }),
2183            memory_access: Some(MemoryAccessBinding {
2184                binding_id: MemoryBindingId::new("mem-binding-1").unwrap(),
2185                capabilities: MemoryCapabilities {
2186                    read: true,
2187                    write: true,
2188                },
2189            }),
2190            memory_policy: Some(MemoryPolicy {
2191                stale_warning_days: Some(7),
2192                retrieval_top_k: Some(8),
2193                validation_enabled: Some(true),
2194                max_content_bytes: Some(20_000),
2195                max_name_length: Some(120),
2196                promotion_recall_threshold: Some(WireU64::new(3)),
2197            }),
2198            tool_catalog: vec![
2199                ToolSchema {
2200                    name: "search".to_string(),
2201                    description: "search the corpus".to_string(),
2202                    parameters: BoundedJson::new(json!({"type": "object"})).unwrap(),
2203                },
2204                ToolSchema {
2205                    name: "write".to_string(),
2206                    description: "write a file".to_string(),
2207                    parameters: BoundedJson::new(json!({"type": "object"})).unwrap(),
2208                },
2209            ],
2210            verification_contracts: vec![VerificationContract {
2211                contract_id: "brief-quality-primary".to_string(),
2212                phases: vec![
2213                    MilestonePhase {
2214                        phase_id: "collect".to_string(),
2215                        unlocks: vec!["research".to_string()],
2216                    },
2217                    MilestonePhase {
2218                        phase_id: "write".to_string(),
2219                        unlocks: vec!["write".to_string()],
2220                    },
2221                ],
2222            }],
2223            skill_catalog: vec![SkillMetadata {
2224                name: "research".to_string(),
2225                description: "run a literature sweep".to_string(),
2226                when_to_use: Some("sources,citations".to_string()),
2227                allowed_tools: vec!["search".to_string()],
2228                capability_grants: Vec::new(),
2229                effort: Some(3),
2230                estimated_tokens: Some(900),
2231            }],
2232            feature_policy: Some(FeaturePolicy {
2233                memory_enabled: Some(true),
2234                knowledge_enabled: Some(true),
2235                plan_tool_enabled: Some(true),
2236                stable_core_tool_ids: Some(vec!["search".to_string()]),
2237            }),
2238            // an all-fields config switches on every capability, so it must declare every
2239            // effect kind those capabilities need
2240            host_effect_support: HostEffectSupport::new(EffectKindTag::ALL),
2241        }
2242    }
2243
2244    // -----------------------------------------------------------------------------------------
2245    // §13.3 · deleted items exist nowhere in the new contract
2246    // -----------------------------------------------------------------------------------------
2247
2248    #[test]
2249    fn deleted_and_host_side_config_appears_in_no_new_type() {
2250        // §13.3 deletions and host-side moves. If any of these ever reappears, the config
2251        // convergence silently regressed to the pre-Canonical surface.
2252        const BANNED: [&str; 16] = [
2253            "memory_path",
2254            "tokenizer",
2255            "host_effect_retry_attempts",
2256            "spool_dir",
2257            "spool_ref",
2258            "spool_threshold_bytes",
2259            "spool_preview_bytes",
2260            "archive_ref",
2261            "checkpoint_path",
2262            "endpoint",
2263            "api_key",
2264            "base_url",
2265            "provider",
2266            "session_id",
2267            "parent_session_id",
2268            "path_root",
2269        ];
2270
2271        let mut keys = BTreeSet::new();
2272        all_keys(
2273            &serde_json::to_value(fully_populated_config()).unwrap(),
2274            &mut keys,
2275        );
2276        all_keys(
2277            &serde_json::to_value(fully_populated_config().resolve(&defaults()).unwrap()).unwrap(),
2278            &mut keys,
2279        );
2280
2281        for banned in BANNED {
2282            assert!(
2283                !keys.contains(banned),
2284                "the canonical configuration still carries the removed field {banned:?}"
2285            );
2286        }
2287    }
2288
2289    #[test]
2290    fn no_sub_policy_carries_its_own_version_marker() {
2291        // The canonical configuration has no version axis.
2292        let mut keys = BTreeSet::new();
2293        all_keys(
2294            &serde_json::to_value(fully_populated_config()).unwrap(),
2295            &mut keys,
2296        );
2297        assert!(!keys.contains("version"));
2298        assert!(!keys.contains("abi_version"));
2299
2300        for probe in [
2301            json!({ "version": 1, "critical_path_weight": 10 }),
2302            json!({ "version": 1 }),
2303        ] {
2304            assert!(serde_json::from_value::<SchedulerPolicy>(probe).is_err());
2305        }
2306        assert!(
2307            serde_json::from_value::<ContextPolicy>(json!({ "version": 1 })).is_err(),
2308            "context policy must not accept a per-policy version"
2309        );
2310    }
2311
2312    #[test]
2313    fn there_is_no_scheduler_budget_setter_shaped_hole_in_the_live_union() {
2314        // §13.3 · `SetSchedulerBudget` becomes an explicit deadline command. Its only axis
2315        // (`max_wall_ms`) is boot-only here, and live changes go through `UpdateDeadline`.
2316        let mut patch_keys = BTreeSet::new();
2317        for patch in [
2318            LivePolicyPatch::ReplaceSignalPolicy(ReplaceSignalPolicy {
2319                policy: SignalPolicy {
2320                    queue_max: 8,
2321                    ttl_ms: Some(WireU64::new(1_000)),
2322                    deadline_escalation: Some(true),
2323                },
2324            }),
2325            LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
2326                policy: GovernancePolicy::default(),
2327            }),
2328            LivePolicyPatch::TightenResourceQuota(TightenResourceQuota {
2329                max_concurrent_subagents: Some(1),
2330                max_total_subagents: Some(2),
2331                max_spawn_depth: Some(1),
2332                max_workflow_nodes: Some(4),
2333            }),
2334            LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
2335                policy: RecoveryPolicy::default(),
2336            }),
2337        ] {
2338            all_keys(&serde_json::to_value(&patch).unwrap(), &mut patch_keys);
2339        }
2340        assert!(
2341            !patch_keys.contains("max_wall_ms"),
2342            "a wall-clock budget must not be reachable through a policy patch"
2343        );
2344    }
2345
2346    // -----------------------------------------------------------------------------------------
2347    // setup-only / live split (§13.1, §13.2)
2348    // -----------------------------------------------------------------------------------------
2349
2350    #[test]
2351    fn setup_only_configuration_is_unreachable_from_the_live_patch_union() {
2352        let setup_only = [
2353            "execution_policy",
2354            "scheduler_policy",
2355            "context_policy",
2356            "payload_policy",
2357            "kernel_limits",
2358            "memory_access",
2359            "memory_policy",
2360            "tool_catalog",
2361            "skill_catalog",
2362            "feature_policy",
2363            "host_effect_support",
2364            "budget_grant",
2365            "stable_core_tool_ids",
2366            "knowledge_budget_ppm",
2367            "prompt_budget",
2368            "binding_id",
2369        ];
2370
2371        let mut patch_keys = BTreeSet::new();
2372        for patch in [
2373            LivePolicyPatch::ReplaceSignalPolicy(ReplaceSignalPolicy {
2374                policy: SignalPolicy {
2375                    queue_max: 8,
2376                    ttl_ms: None,
2377                    deadline_escalation: None,
2378                },
2379            }),
2380            LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
2381                policy: GovernancePolicy {
2382                    default_action: Some(PolicyAction::Deny),
2383                    rules: vec![PolicyRule {
2384                        tool_pattern: "*".to_string(),
2385                        action: PolicyAction::Deny,
2386                    }],
2387                    vetoed_tools: vec!["rm".to_string()],
2388                    rate_limits: vec![RateLimitSpec {
2389                        tool: "search".to_string(),
2390                        max_calls: 1,
2391                        window_ms: WireU64::new(1_000),
2392                    }],
2393                    constraints: Vec::new(),
2394                },
2395            }),
2396            LivePolicyPatch::TightenResourceQuota(TightenResourceQuota {
2397                max_concurrent_subagents: Some(1),
2398                max_total_subagents: Some(2),
2399                max_spawn_depth: Some(1),
2400                max_workflow_nodes: Some(4),
2401            }),
2402            LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
2403                policy: RecoveryPolicy {
2404                    provider_recovery_attempts: Some(1),
2405                    output_recovery_attempts: Some(1),
2406                    tail_bounds: None,
2407                },
2408            }),
2409        ] {
2410            all_keys(&serde_json::to_value(&patch).unwrap(), &mut patch_keys);
2411        }
2412
2413        for key in setup_only {
2414            assert!(
2415                !patch_keys.contains(key),
2416                "{key:?} is setup-only but is reachable through LivePolicyPatch"
2417            );
2418        }
2419    }
2420
2421    // -----------------------------------------------------------------------------------------
2422    // strictness (§7.1)
2423    // -----------------------------------------------------------------------------------------
2424
2425    #[test]
2426    fn every_configuration_struct_rejects_unknown_fields() {
2427        assert!(
2428            serde_json::from_value::<OperationConfig>(json!({
2429                "host_effect_support": { "supported": [] },
2430                "tokenizer": "cl100k",
2431            }))
2432            .is_err()
2433        );
2434        assert!(
2435            serde_json::from_value::<MemoryPolicy>(json!({ "memory_path": "/tmp/mem" })).is_err(),
2436            "memory_path moved to the host MemoryStore config and must not decode"
2437        );
2438        assert!(
2439            serde_json::from_value::<PayloadPolicy>(json!({ "spool_dir": "/tmp/.spool" })).is_err()
2440        );
2441        assert!(
2442            serde_json::from_value::<ExecutionPolicy>(json!({ "max_tokens": 1 })).is_err(),
2443            "the context-window axis must reject its removed field name"
2444        );
2445        assert!(serde_json::from_value::<KernelLimits>(json!({ "max_bytes": 1 })).is_err());
2446        assert!(
2447            serde_json::from_value::<HostEffectSupport>(json!({ "supported": [], "all": true }))
2448                .is_err()
2449        );
2450        assert!(
2451            serde_json::from_value::<HostEffectSupport>(json!({})).is_err(),
2452            "DEC-8 support declaration is mandatory, not defaulted"
2453        );
2454        assert!(
2455            serde_json::from_value::<OperationConfig>(json!({})).is_err(),
2456            "a config without host_effect_support is not a config"
2457        );
2458    }
2459
2460    #[test]
2461    fn policy_ratios_are_fixed_point_not_floats() {
2462        assert!(
2463            serde_json::from_value::<ContextPolicy>(json!({ "knowledge_budget_ppm": 0.25 }))
2464                .is_err()
2465        );
2466        assert!(
2467            serde_json::from_value::<EntropyWatchPolicy>(json!({ "threshold_ppm": 0.65 })).is_err()
2468        );
2469        assert!(
2470            serde_json::from_value::<ContextPolicy>(json!({ "knowledge_budget_ppm": 250_000 }))
2471                .is_ok()
2472        );
2473        assert!(
2474            serde_json::from_value::<ContextPolicy>(json!({ "knowledge_budget_ppm": 1_000_001 }))
2475                .is_err(),
2476            "a ratio above 1.0 is not a ratio"
2477        );
2478    }
2479
2480    #[test]
2481    fn sixty_four_bit_config_axes_travel_as_decimal_strings() {
2482        assert!(
2483            serde_json::from_value::<ExecutionPolicy>(json!({ "max_total_tokens": 1_000_000 }))
2484                .is_err()
2485        );
2486        assert!(
2487            serde_json::from_value::<ExecutionPolicy>(json!({ "max_total_tokens": "1000000" }))
2488                .is_ok()
2489        );
2490        assert!(
2491            serde_json::from_value::<BudgetGrant>(
2492                json!({ "reservation_id": "r", "tokens": 5_000 })
2493            )
2494            .is_err()
2495        );
2496    }
2497
2498    #[test]
2499    fn the_effect_support_tag_set_is_closed() {
2500        assert!(
2501            serde_json::from_value::<HostEffectSupport>(
2502                json!({ "supported": ["spool_large_result"] })
2503            )
2504            .is_err(),
2505            "SpoolLargeResult is deleted; declaring support for it must not decode"
2506        );
2507        assert!(
2508            serde_json::from_value::<HostEffectSupport>(json!({ "supported": ["load_payload"] }))
2509                .is_ok()
2510        );
2511        assert_eq!(EffectKindTag::ALL.len(), 11);
2512    }
2513
2514    // -----------------------------------------------------------------------------------------
2515    // resolution: no implicit defaults survive (§7.3)
2516    // -----------------------------------------------------------------------------------------
2517
2518    #[test]
2519    fn resolution_removes_every_implicit_default() {
2520        let resolved = minimal_config().resolve(&defaults()).unwrap();
2521        let value = serde_json::to_value(&resolved).unwrap();
2522
2523        // Nothing that stands for "ask the binary" is left: every knob has a concrete value.
2524        assert_eq!(value["execution_policy"]["max_turns"], json!(25));
2525        assert_eq!(
2526            value["context_policy"]["knowledge_budget_ppm"],
2527            json!(250_000)
2528        );
2529        assert_eq!(value["payload_policy"]["preview_bytes"], json!(2_048));
2530        assert_eq!(value["memory_policy"]["retrieval_top_k"], json!(5));
2531        assert!(value.get("abi_version").is_none());
2532
2533        // The resolved record round-trips, because that is what the genesis record stores.
2534        let text = serde_json::to_string(&resolved).unwrap();
2535        let back: ResolvedOperationConfig = serde_json::from_str(&text).unwrap();
2536        assert_eq!(back, resolved);
2537    }
2538
2539    #[test]
2540    fn a_resolved_config_does_not_move_when_the_binary_defaults_move() {
2541        let resolved = minimal_config().resolve(&defaults()).unwrap();
2542
2543        let mut newer = defaults();
2544        newer.baseline.execution_policy.max_turns = 999;
2545        newer.baseline.context_policy.knowledge_budget_ppm = ppm(1_000);
2546
2547        // Re-decoding the stored record is unaffected; only a *fresh* resolve sees new defaults.
2548        let text = serde_json::to_string(&resolved).unwrap();
2549        let replayed: ResolvedOperationConfig = serde_json::from_str(&text).unwrap();
2550        assert_eq!(replayed.execution_policy.max_turns, 25);
2551        assert_eq!(
2552            minimal_config()
2553                .resolve(&newer)
2554                .unwrap()
2555                .execution_policy
2556                .max_turns,
2557            999
2558        );
2559    }
2560
2561    #[test]
2562    fn a_fully_populated_config_resolves_to_exactly_what_it_declared() {
2563        let resolved = fully_populated_config().resolve(&defaults()).unwrap();
2564        assert_eq!(resolved.execution_policy.max_turns, 40);
2565        assert_eq!(resolved.execution_policy.repeat_fuse.terminate_after, 7);
2566        assert_eq!(resolved.scheduler_policy.token_cost_weight, 2);
2567        assert_eq!(resolved.payload_policy.inline_threshold_bytes, 32_768);
2568        assert_eq!(resolved.kernel_limits.collection_limits.tool_catalog, 256);
2569        assert_eq!(resolved.memory_policy.retrieval_top_k, 8);
2570        assert_eq!(resolved.tool_catalog.len(), 2);
2571        assert!(resolved.feature_policy.memory_enabled);
2572    }
2573
2574    // -----------------------------------------------------------------------------------------
2575    // atomic cross-field validation (Task 5 verification)
2576    // -----------------------------------------------------------------------------------------
2577
2578    #[test]
2579    fn one_illegal_field_rejects_the_whole_configure_and_changes_nothing() {
2580        let mut config = fully_populated_config();
2581        config
2582            .execution_policy
2583            .as_mut()
2584            .unwrap()
2585            .repeat_fuse
2586            .as_mut()
2587            .unwrap()
2588            .terminate_after = Some(2); // < deny_after (4)
2589
2590        let before = config.clone();
2591        let rejection = config.resolve(&defaults()).expect_err("must reject");
2592        assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
2593        assert!(rejection.message.contains("terminate_after"));
2594        // resolution owns no state: the input is untouched and no partial resolved value exists
2595        assert_eq!(config, before);
2596    }
2597
2598    #[test]
2599    fn cross_field_relationships_are_all_enforced() {
2600        let cases: Vec<(&str, Box<dyn Fn(&mut OperationConfig)>, &str)> = vec![
2601            (
2602                "thresholds must strictly increase",
2603                Box::new(|config| {
2604                    config
2605                        .context_policy
2606                        .as_mut()
2607                        .unwrap()
2608                        .pressure_thresholds_ppm
2609                        .as_mut()
2610                        .unwrap()
2611                        .micro = ppm(600_000);
2612                }),
2613                "strictly increase",
2614            ),
2615            (
2616                "compression target must sit below snip",
2617                Box::new(|config| {
2618                    config
2619                        .context_policy
2620                        .as_mut()
2621                        .unwrap()
2622                        .target_after_compress_ppm = Some(ppm(750_000));
2623                }),
2624                "target_after_compress_ppm",
2625            ),
2626            (
2627                "prompt reserves cannot consume the window",
2628                Box::new(|config| {
2629                    config.context_policy.as_mut().unwrap().prompt_budget = Some(PromptBudget {
2630                        prompt_overhead_tokens: 200_000,
2631                        output_reserve_tokens: 1,
2632                        safety_margin_tokens: 0,
2633                    });
2634                }),
2635                "leaving nothing to render",
2636            ),
2637            (
2638                "preview cannot exceed the inline threshold",
2639                Box::new(|config| {
2640                    config.payload_policy.as_mut().unwrap().preview_bytes = Some(65_536);
2641                }),
2642                "preview_bytes",
2643            ),
2644            (
2645                "concurrent cap cannot exceed the cumulative cap",
2646                Box::new(|config| {
2647                    config
2648                        .resource_quota
2649                        .as_mut()
2650                        .unwrap()
2651                        .max_concurrent_subagents = Some(99);
2652                }),
2653                "max_concurrent_subagents",
2654            ),
2655            (
2656                "hysteresis cannot exceed the threshold",
2657                Box::new(|config| {
2658                    config
2659                        .execution_policy
2660                        .as_mut()
2661                        .unwrap()
2662                        .entropy_watch
2663                        .as_mut()
2664                        .unwrap()
2665                        .hysteresis_ppm = Some(ppm(900_000));
2666                }),
2667                "hysteresis_ppm",
2668            ),
2669            (
2670                "a skill cannot allow a tool the catalog never declared",
2671                Box::new(|config| {
2672                    config.skill_catalog[0].allowed_tools = vec!["undeclared".to_string()];
2673                }),
2674                "only ever narrow the catalog",
2675            ),
2676            (
2677                "a skill cannot exceed the capability-grants collection limit",
2678                Box::new(|config| {
2679                    config
2680                        .kernel_limits
2681                        .as_mut()
2682                        .unwrap()
2683                        .collection_limits
2684                        .as_mut()
2685                        .unwrap()
2686                        .capability_grants = Some(0);
2687                    config.skill_catalog[0].capability_grants =
2688                        vec![crate::types::capability::Capability {
2689                            id: crate::types::capability::CapabilityId("read-src".into()),
2690                            kind: crate::types::capability::CapabilityKind::Tool,
2691                            resource: crate::types::capability::ResourceSelector(
2692                                "/repo/src/**".into(),
2693                            ),
2694                            actions: crate::types::capability::ActionSet(
2695                                ["read".into()].into_iter().collect(),
2696                            ),
2697                            constraints: crate::types::capability::ConstraintSet::default(),
2698                            lease: None,
2699                            delegatable: false,
2700                            issuer: crate::types::capability::Principal("root".into()),
2701                        }];
2702                }),
2703                "capability grants",
2704            ),
2705            (
2706                "the exposure baseline cannot name an undeclared tool",
2707                Box::new(|config| {
2708                    config.feature_policy.as_mut().unwrap().stable_core_tool_ids =
2709                        Some(vec!["ghost".to_string()]);
2710                }),
2711                "tool catalog does not declare",
2712            ),
2713            (
2714                "memory cannot be enabled without a binding",
2715                Box::new(|config| {
2716                    config.memory_access = None;
2717                }),
2718                "no memory_access binding",
2719            ),
2720            (
2721                "a duplicate tool makes dispatch order-dependent",
2722                Box::new(|config| {
2723                    config.tool_catalog[1].name = "search".to_string();
2724                }),
2725                "twice",
2726            ),
2727        ];
2728
2729        for (label, mutate, expected_fragment) in cases {
2730            let mut config = fully_populated_config();
2731            mutate(&mut config);
2732            let before = config.clone();
2733            let rejection = config
2734                .resolve(&defaults())
2735                .err()
2736                .unwrap_or_else(|| panic!("{label}: expected a rejection"));
2737            assert!(
2738                rejection.message.contains(expected_fragment),
2739                "{label}: message {:?} does not name the broken relationship {expected_fragment:?}",
2740                rejection.message
2741            );
2742            assert_eq!(config, before, "{label}: rejection mutated the input");
2743        }
2744    }
2745
2746    #[test]
2747    fn cross_field_rejections_name_the_relationship_they_broke() {
2748        let mut config = fully_populated_config();
2749        config
2750            .context_policy
2751            .as_mut()
2752            .unwrap()
2753            .target_after_compress_ppm = Some(ppm(750_000));
2754        let rejection = config.resolve(&defaults()).expect_err("must reject");
2755        assert!(
2756            rejection.message.contains("target_after_compress_ppm")
2757                && rejection.message.contains("snip"),
2758            "unhelpful message: {}",
2759            rejection.message
2760        );
2761    }
2762
2763    // -----------------------------------------------------------------------------------------
2764    // only-tighten (§7.3, §13.1)
2765    // -----------------------------------------------------------------------------------------
2766
2767    #[test]
2768    fn operation_limits_may_only_tighten_the_bootstrap_ceiling() {
2769        let defaults = ConfigDefaults::new(KernelBootstrapLimits {
2770            absolute_max_input_bytes: 1_048_576,
2771            absolute_max_json_depth: 32,
2772            absolute_max_collection_entries: 1_024,
2773        });
2774
2775        let tighter = OperationConfig {
2776            kernel_limits: Some(KernelLimits {
2777                max_input_bytes: Some(65_536),
2778                max_json_depth: Some(16),
2779                max_collection_entries: Some(256),
2780                collection_limits: None,
2781            }),
2782            ..minimal_config()
2783        };
2784        let resolved = tighter.resolve(&defaults).unwrap();
2785        assert_eq!(resolved.kernel_limits.max_input_bytes, 65_536);
2786        assert_eq!(resolved.kernel_limits.collection_limits.tool_catalog, 256);
2787
2788        // equality with the bootstrap ceiling is "as tight as the ceiling", not a widening
2789        let at_ceiling = OperationConfig {
2790            kernel_limits: Some(KernelLimits {
2791                max_input_bytes: Some(1_048_576),
2792                max_json_depth: Some(32),
2793                max_collection_entries: Some(1_024),
2794                collection_limits: None,
2795            }),
2796            ..minimal_config()
2797        };
2798        assert!(at_ceiling.resolve(&defaults).is_ok());
2799    }
2800
2801    #[test]
2802    fn widening_any_bootstrap_axis_is_rejected_with_the_direction_named() {
2803        let defaults = ConfigDefaults::new(KernelBootstrapLimits {
2804            absolute_max_input_bytes: 1_048_576,
2805            absolute_max_json_depth: 32,
2806            absolute_max_collection_entries: 1_024,
2807        });
2808        for limits in [
2809            KernelLimits {
2810                max_input_bytes: Some(2_097_152),
2811                ..KernelLimits::default()
2812            },
2813            KernelLimits {
2814                max_json_depth: Some(64),
2815                ..KernelLimits::default()
2816            },
2817            KernelLimits {
2818                max_collection_entries: Some(65_536),
2819                ..KernelLimits::default()
2820            },
2821        ] {
2822            let config = OperationConfig {
2823                kernel_limits: Some(limits),
2824                ..minimal_config()
2825            };
2826            let rejection = config.resolve(&defaults).expect_err("widening rejected");
2827            assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
2828            assert!(
2829                rejection.message.contains("may only tighten"),
2830                "unexpected message: {}",
2831                rejection.message
2832            );
2833        }
2834    }
2835
2836    #[test]
2837    fn a_named_collection_bound_may_only_tighten_the_resolved_entry_ceiling() {
2838        let defaults = defaults();
2839        let config = OperationConfig {
2840            kernel_limits: Some(KernelLimits {
2841                max_collection_entries: Some(128),
2842                collection_limits: Some(CollectionLimits {
2843                    tool_catalog: Some(512),
2844                    ..CollectionLimits::default()
2845                }),
2846                ..KernelLimits::default()
2847            }),
2848            ..minimal_config()
2849        };
2850        let rejection = config.resolve(&defaults).expect_err("must reject");
2851        assert!(rejection.message.contains("collection_limits.tool_catalog"));
2852    }
2853
2854    #[test]
2855    fn per_collection_bounds_are_enforced_against_the_declared_catalog() {
2856        let mut config = fully_populated_config();
2857        config
2858            .kernel_limits
2859            .as_mut()
2860            .unwrap()
2861            .collection_limits
2862            .as_mut()
2863            .unwrap()
2864            .tool_catalog = Some(1);
2865        let rejection = config.resolve(&defaults()).expect_err("must reject");
2866        assert_eq!(rejection.kind, WireRejectionKind::CollectionTooLarge);
2867        assert!(rejection.message.contains("tool_catalog"));
2868    }
2869
2870    // -----------------------------------------------------------------------------------------
2871    // DEC-8 · feature ↔ host_effect_support cross-validation
2872    // -----------------------------------------------------------------------------------------
2873
2874    #[test]
2875    fn every_declared_capability_requires_its_effect_kind() {
2876        // The all-fields config switches on every producible capability, so dropping any one
2877        // required kind from the
2878        // support declaration must be refused — and the rejection must name the kind, because a
2879        // host reading it has to know which declaration to fix. Iterating `ALL` also means a new
2880        // effect kind cannot be added without deciding what switches it on. `MeasurePrompt` is a
2881        // reserved wire shape with no scheduler producer after SPC-013 A-00R, so it deliberately
2882        // imposes no host-support requirement.
2883        let full = fully_populated_config();
2884        full.resolve(&defaults())
2885            .expect("declaring every kind satisfies every trigger");
2886
2887        for dropped in EffectKindTag::ALL
2888            .into_iter()
2889            .filter(|kind| *kind != EffectKindTag::MeasurePrompt)
2890        {
2891            let mut config = full.clone();
2892            config.host_effect_support = HostEffectSupport::new(
2893                EffectKindTag::ALL
2894                    .into_iter()
2895                    .filter(|kind| *kind != dropped),
2896            );
2897            let before = config.clone();
2898            let rejection = config.resolve(&defaults()).err().unwrap_or_else(|| {
2899                panic!("{dropped:?} is switched on by the all-fields config but was not required")
2900            });
2901            assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
2902            assert!(
2903                rejection.message.contains(dropped.as_str()),
2904                "{dropped:?}: rejection does not name the missing kind: {}",
2905                rejection.message
2906            );
2907            assert_eq!(config, before, "{dropped:?}: rejection mutated the input");
2908        }
2909    }
2910
2911    #[test]
2912    fn an_absent_capability_imposes_no_effect_requirement() {
2913        // Fail-closure at configure time keys on affirmative declarations, not on absences: an
2914        // operation that never says it will spawn is not obliged to declare spawn_tasks, because
2915        // the runtime DEC-8 gate already refuses to emit what was never declared.
2916        minimal_config()
2917            .resolve(&defaults())
2918            .expect("a minimal config needs only call_provider");
2919
2920        // …but declaring a tool catalog *is* affirmative, and pulls in both tool effects.
2921        let with_tools = OperationConfig {
2922            tool_catalog: vec![ToolSchema {
2923                name: "search".to_string(),
2924                description: String::new(),
2925                parameters: BoundedJson::null(),
2926            }],
2927            ..minimal_config()
2928        };
2929        let rejection = with_tools.resolve(&defaults()).expect_err("must reject");
2930        assert!(rejection.message.contains("execute_tools"));
2931
2932        let ok = OperationConfig {
2933            host_effect_support: HostEffectSupport::new([
2934                EffectKindTag::CallProvider,
2935                EffectKindTag::ExecuteTools,
2936                EffectKindTag::LoadPayload,
2937            ]),
2938            ..with_tools
2939        };
2940        ok.resolve(&defaults())
2941            .expect("execute_tools + load_payload satisfy a tool catalog");
2942    }
2943
2944    // -----------------------------------------------------------------------------------------
2945    // §7.3 · the verification-contract skeleton (adjudication §5m item 4)
2946    // -----------------------------------------------------------------------------------------
2947
2948    /// A config that declares tools, skills and the milestone effect, so a contract skeleton has
2949    /// something to reference and something to publish.
2950    fn contract_host_config(contracts: Vec<VerificationContract>) -> OperationConfig {
2951        OperationConfig {
2952            host_effect_support: HostEffectSupport::new([
2953                EffectKindTag::CallProvider,
2954                EffectKindTag::ExecuteTools,
2955                EffectKindTag::LoadPayload,
2956                EffectKindTag::EvaluateMilestone,
2957            ]),
2958            tool_catalog: vec![ToolSchema {
2959                name: "search".to_string(),
2960                description: String::new(),
2961                parameters: BoundedJson::null(),
2962            }],
2963            skill_catalog: vec![SkillMetadata {
2964                name: "research".to_string(),
2965                description: String::new(),
2966                when_to_use: None,
2967                allowed_tools: Vec::new(),
2968                capability_grants: Vec::new(),
2969                effort: None,
2970                estimated_tokens: None,
2971            }],
2972            verification_contracts: contracts,
2973            ..minimal_config()
2974        }
2975    }
2976
2977    fn phase(phase_id: &str, unlocks: &[&str]) -> MilestonePhase {
2978        MilestonePhase {
2979            phase_id: phase_id.to_string(),
2980            unlocks: unlocks.iter().map(|id| (*id).to_string()).collect(),
2981        }
2982    }
2983
2984    #[test]
2985    fn a_contract_skeleton_carries_phase_order_and_unlocks_and_nothing_else() {
2986        // The two facts core owns — the cascade order and what a pass mounts. Criteria, evidence,
2987        // verifier and the I/O that runs them stay host-side (§5.2).
2988        let config = contract_host_config(vec![VerificationContract {
2989            contract_id: "brief-quality-primary".to_string(),
2990            phases: vec![phase("collect", &["research"]), phase("write", &["search"])],
2991        }]);
2992        let resolved = config.resolve(&defaults()).expect("a legal skeleton");
2993        let contract = resolved
2994            .verification_contract("brief-quality-primary")
2995            .expect("resolution keeps the catalog addressable by id");
2996        assert_eq!(
2997            contract
2998                .phases
2999                .iter()
3000                .map(|p| p.phase_id.as_str())
3001                .collect::<Vec<_>>(),
3002            vec!["collect", "write"],
3003            "the cascade order is a kernel fact and must survive resolution verbatim"
3004        );
3005        assert!(resolved.verification_contract("nope").is_none());
3006
3007        // the shape a fuller contract type would have carried is not expressible here
3008        let value = serde_json::to_value(&contract.phases[0]).unwrap();
3009        for host_owned in ["criteria", "required_evidence", "verifier", "retry_policy"] {
3010            assert!(
3011                value.get(host_owned).is_none(),
3012                "{host_owned} belongs to the host (§5.2)"
3013            );
3014        }
3015    }
3016
3017    #[test]
3018    fn a_contract_catalog_with_a_duplicate_id_is_refused() {
3019        // `verification_contract_id` resolves by id; two contracts sharing one would resolve by
3020        // list order, i.e. silently.
3021        let config = contract_host_config(vec![
3022            VerificationContract {
3023                contract_id: "brief-quality-primary".to_string(),
3024                phases: vec![phase("collect", &[])],
3025            },
3026            VerificationContract {
3027                contract_id: "brief-quality-primary".to_string(),
3028                phases: vec![phase("write", &[])],
3029            },
3030        ]);
3031        let before = config.clone();
3032        let rejection = config.resolve(&defaults()).expect_err("must reject");
3033        assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3034        assert!(
3035            rejection.message.contains("brief-quality-primary")
3036                && rejection.message.contains("twice"),
3037            "{}",
3038            rejection.message
3039        );
3040        assert_eq!(config, before, "a rejection mutates nothing");
3041    }
3042
3043    #[test]
3044    fn a_contract_with_a_duplicate_phase_id_is_refused() {
3045        // A verdict names its phase by id. Two phases sharing one means a verdict for the second
3046        // could advance the first.
3047        let config = contract_host_config(vec![VerificationContract {
3048            contract_id: "brief-quality-primary".to_string(),
3049            phases: vec![phase("collect", &[]), phase("collect", &["search"])],
3050        }]);
3051        let rejection = config.resolve(&defaults()).expect_err("must reject");
3052        assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3053        assert!(
3054            rejection.message.contains("collect"),
3055            "{}",
3056            rejection.message
3057        );
3058    }
3059
3060    #[test]
3061    fn a_phase_cannot_unlock_a_capability_the_operation_never_declared() {
3062        // The capability directory at configure time is tool_catalog ∪ skill_catalog. Unlocking
3063        // anything else is a mount with nothing behind it — the same fail-closure
3064        // `stable_core_tool_ids` already gets, and for the same reason.
3065        let config = contract_host_config(vec![VerificationContract {
3066            contract_id: "brief-quality-primary".to_string(),
3067            phases: vec![phase("collect", &["deploy_to_prod"])],
3068        }]);
3069        let rejection = config.resolve(&defaults()).expect_err("must reject");
3070        assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3071        assert!(
3072            rejection.message.contains("deploy_to_prod"),
3073            "{}",
3074            rejection.message
3075        );
3076
3077        // both directories count
3078        for declared in ["search", "research"] {
3079            contract_host_config(vec![VerificationContract {
3080                contract_id: "c".to_string(),
3081                phases: vec![phase("p", &[declared])],
3082            }])
3083            .resolve(&defaults())
3084            .unwrap_or_else(|e| panic!("{declared} is declared: {e}"));
3085        }
3086    }
3087
3088    #[test]
3089    fn a_contract_with_no_phase_is_refused() {
3090        // It can never publish an `EvaluateMilestone`, so a spec pointing at it would be a gate
3091        // that silently is not there.
3092        let config = contract_host_config(vec![VerificationContract {
3093            contract_id: "brief-quality-primary".to_string(),
3094            phases: Vec::new(),
3095        }]);
3096        let rejection = config.resolve(&defaults()).expect_err("must reject");
3097        assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3098        assert!(
3099            rejection.message.contains("no phases"),
3100            "{}",
3101            rejection.message
3102        );
3103
3104        for empty_id in [
3105            VerificationContract {
3106                contract_id: String::new(),
3107                phases: vec![phase("p", &[])],
3108            },
3109            VerificationContract {
3110                contract_id: "c".to_string(),
3111                phases: vec![phase("", &[])],
3112            },
3113        ] {
3114            assert!(
3115                contract_host_config(vec![empty_id])
3116                    .resolve(&defaults())
3117                    .is_err(),
3118                "an empty id names nothing"
3119            );
3120        }
3121    }
3122
3123    #[test]
3124    fn a_contract_catalog_still_requires_the_milestone_effect() {
3125        // DEC-8's configure-time twin: declaring a contract is an affirmative statement that the
3126        // operation will ask for a verdict.
3127        let mut config = contract_host_config(vec![VerificationContract {
3128            contract_id: "brief-quality-primary".to_string(),
3129            phases: vec![phase("collect", &[])],
3130        }]);
3131        config.host_effect_support = HostEffectSupport::new([
3132            EffectKindTag::CallProvider,
3133            EffectKindTag::ExecuteTools,
3134            EffectKindTag::LoadPayload,
3135        ]);
3136        let rejection = config.resolve(&defaults()).expect_err("must reject");
3137        assert!(
3138            rejection.message.contains("evaluate_milestone"),
3139            "{}",
3140            rejection.message
3141        );
3142    }
3143
3144    #[test]
3145    fn a_tool_catalog_also_requires_the_payload_page_in_path() {
3146        // §7.10: a result above the inline threshold becomes External. A host that can run tools
3147        // but cannot load payloads back produces results nothing can ever read.
3148        let config = OperationConfig {
3149            tool_catalog: vec![ToolSchema {
3150                name: "search".to_string(),
3151                description: String::new(),
3152                parameters: BoundedJson::null(),
3153            }],
3154            host_effect_support: HostEffectSupport::new([
3155                EffectKindTag::CallProvider,
3156                EffectKindTag::ExecuteTools,
3157            ]),
3158            ..OperationConfig::default()
3159        };
3160        let rejection = config.resolve(&defaults()).expect_err("must reject");
3161        assert!(rejection.message.contains("load_payload"));
3162    }
3163
3164    #[test]
3165    fn spawn_capacity_obliges_the_host_to_be_able_to_stop_children() {
3166        // Starting children you cannot preempt leaks them past cancellation and budget
3167        // exhaustion, so the two kinds are required together or not at all.
3168        let config = OperationConfig {
3169            resource_quota: Some(ResourceQuota {
3170                max_total_subagents: Some(4),
3171                ..ResourceQuota::default()
3172            }),
3173            host_effect_support: HostEffectSupport::new([
3174                EffectKindTag::CallProvider,
3175                EffectKindTag::SpawnTasks,
3176            ]),
3177            ..OperationConfig::default()
3178        };
3179        let rejection = config.resolve(&defaults()).expect_err("must reject");
3180        assert!(rejection.message.contains("preempt_tasks"));
3181
3182        // a quota that caps spawning at zero declares no capacity, so neither kind is required
3183        let no_capacity = OperationConfig {
3184            resource_quota: Some(ResourceQuota {
3185                max_total_subagents: Some(0),
3186                ..ResourceQuota::default()
3187            }),
3188            ..minimal_config()
3189        };
3190        no_capacity
3191            .resolve(&defaults())
3192            .expect("a zero cap is not a declaration of spawn capacity");
3193    }
3194
3195    #[test]
3196    fn an_ask_user_gate_requires_somewhere_to_ask() {
3197        for governance in [
3198            GovernancePolicy {
3199                default_action: Some(PolicyAction::AskUser),
3200                ..GovernancePolicy::default()
3201            },
3202            GovernancePolicy {
3203                default_action: Some(PolicyAction::Allow),
3204                rules: vec![PolicyRule {
3205                    tool_pattern: "shell.*".to_string(),
3206                    action: PolicyAction::AskUser,
3207                }],
3208                ..GovernancePolicy::default()
3209            },
3210        ] {
3211            let config = OperationConfig {
3212                governance_policy: Some(governance),
3213                ..minimal_config()
3214            };
3215            let rejection = config.resolve(&defaults()).expect_err("must reject");
3216            assert!(rejection.message.contains("request_approval"));
3217        }
3218    }
3219
3220    #[test]
3221    fn memory_needs_both_directions_not_either_one() {
3222        // The pre-Task-7 check accepted a config declaring *either* memory effect. A writable,
3223        // readable memory plane needs both, and "either" let half a plane through.
3224        for missing in [EffectKindTag::PersistMemory, EffectKindTag::QueryMemory] {
3225            let config = OperationConfig {
3226                memory_access: Some(MemoryAccessBinding {
3227                    binding_id: MemoryBindingId::new("mem-1").unwrap(),
3228                    capabilities: MemoryCapabilities {
3229                        read: true,
3230                        write: true,
3231                    },
3232                }),
3233                feature_policy: Some(FeaturePolicy {
3234                    memory_enabled: Some(true),
3235                    ..FeaturePolicy::default()
3236                }),
3237                host_effect_support: HostEffectSupport::new(
3238                    [
3239                        EffectKindTag::CallProvider,
3240                        EffectKindTag::PersistMemory,
3241                        EffectKindTag::QueryMemory,
3242                    ]
3243                    .into_iter()
3244                    .filter(|kind| *kind != missing),
3245                ),
3246                ..OperationConfig::default()
3247            };
3248            let rejection = config.resolve(&defaults()).expect_err("must reject");
3249            assert!(rejection.message.contains(missing.as_str()));
3250        }
3251    }
3252
3253    #[test]
3254    fn a_read_only_memory_binding_does_not_require_the_write_path() {
3255        // The binding's own capabilities are finer-grained than the feature switch: a read-only
3256        // plane never persists, so demanding persist_memory would be a false positive.
3257        let config = OperationConfig {
3258            memory_access: Some(MemoryAccessBinding {
3259                binding_id: MemoryBindingId::new("mem-ro").unwrap(),
3260                capabilities: MemoryCapabilities {
3261                    read: true,
3262                    write: false,
3263                },
3264            }),
3265            host_effect_support: HostEffectSupport::new([
3266                EffectKindTag::CallProvider,
3267                EffectKindTag::QueryMemory,
3268            ]),
3269            ..OperationConfig::default()
3270        };
3271        config
3272            .resolve(&defaults())
3273            .expect("a read-only binding needs only query_memory");
3274    }
3275
3276    // -----------------------------------------------------------------------------------------
3277    // rejection taxonomy
3278    // -----------------------------------------------------------------------------------------
3279
3280    #[test]
3281    fn a_broken_encoder_and_a_refused_configuration_are_different_rejections() {
3282        // Same field, two failure modes that need different host handling: the first never became
3283        // a value at all, the second is a coherent value the kernel declines to adopt. Collapsing
3284        // them onto one kind is what made "re-read and rebase" indistinguishable from "your
3285        // serializer is wrong".
3286        let scalar_error = serde_json::from_value::<ContextPolicy>(json!({
3287            "knowledge_budget_ppm": 0.25,
3288        }))
3289        .expect_err("a float ratio never becomes a Ppm");
3290        assert!(scalar_error.to_string().contains(SCALAR_ERROR_MARKER));
3291
3292        let mut config = fully_populated_config();
3293        config.context_policy.as_mut().unwrap().knowledge_budget_ppm = Some(ppm(990_000));
3294        let policy_error = config
3295            .resolve(&defaults())
3296            .expect_err("a knowledge budget that crowds out carryover is refused");
3297        assert_eq!(policy_error.kind, WireRejectionKind::PolicyViolation);
3298        assert_eq!(policy_error.kind.as_str(), "policy_violation");
3299    }
3300
3301    #[test]
3302    fn every_resolution_rejection_is_a_policy_violation_or_a_bound() {
3303        // The decode taxonomy stops at the boundary; nothing past it may claim to be a scalar,
3304        // unknown-field or missing-field fault, because by then the document already decoded.
3305        let mut cases: Vec<OperationConfig> = Vec::new();
3306
3307        let mut widen = minimal_config();
3308        widen.kernel_limits = Some(KernelLimits {
3309            max_json_depth: Some(u16::MAX),
3310            ..KernelLimits::default()
3311        });
3312        cases.push(widen);
3313
3314        let mut ladder = fully_populated_config();
3315        ladder
3316            .context_policy
3317            .as_mut()
3318            .unwrap()
3319            .preserve_recent_turns = Some(0);
3320        cases.push(ladder);
3321
3322        let mut catalog = fully_populated_config();
3323        catalog
3324            .kernel_limits
3325            .as_mut()
3326            .unwrap()
3327            .collection_limits
3328            .as_mut()
3329            .unwrap()
3330            .tool_catalog = Some(1);
3331        cases.push(catalog);
3332
3333        let mut quota = fully_populated_config();
3334        quota.resource_quota.as_mut().unwrap().max_spawn_depth = Some(0);
3335        cases.push(quota);
3336
3337        for config in cases {
3338            let kind = config.resolve(&defaults()).expect_err("must reject").kind;
3339            assert!(
3340                matches!(
3341                    kind,
3342                    WireRejectionKind::PolicyViolation | WireRejectionKind::CollectionTooLarge
3343                ),
3344                "resolution produced the decode-stage kind {kind:?}"
3345            );
3346        }
3347    }
3348
3349    // -----------------------------------------------------------------------------------------
3350    // lifecycle (§13.1 · setup-only means "once, before execution exists")
3351    // -----------------------------------------------------------------------------------------
3352
3353    #[test]
3354    fn configuration_is_admissible_only_before_the_operation_starts() {
3355        use crate::runtime::kernel::wire::envelope::{
3356            ConfigureOperation, KernelInput, OperationLifecycle,
3357        };
3358
3359        let configure = KernelInput::ConfigureOperation(ConfigureOperation {
3360            config: minimal_config(),
3361        });
3362        assert_eq!(
3363            configure.admissible_lifecycles(),
3364            &[OperationLifecycle::Created],
3365            "boot configuration is admissible exactly once, before any execution exists"
3366        );
3367
3368        // The live control plane, by contrast, is admissible while the operation runs — which is
3369        // precisely why nothing setup-only may travel through it.
3370        let control =
3371            KernelInput::HostControl(crate::runtime::kernel::wire::envelope::HostControl {
3372                command: HostCommand::ForceCompact(
3373                    crate::runtime::kernel::wire::command::ForceCompactCommand {},
3374                ),
3375            });
3376        assert!(
3377            control
3378                .admissible_lifecycles()
3379                .contains(&OperationLifecycle::Running)
3380        );
3381        assert!(
3382            !configure
3383                .admissible_lifecycles()
3384                .contains(&OperationLifecycle::Running),
3385            "a second ConfigureOperation against a running operation is an illegal lifecycle \
3386             mutation, not a live policy change"
3387        );
3388    }
3389
3390    // -----------------------------------------------------------------------------------------
3391    // fixtures
3392    // -----------------------------------------------------------------------------------------
3393
3394    #[test]
3395    fn configure_input_goldens_round_trip_through_the_typed_config() {
3396        let fixtures = fixtures_with_prefix("input_configure_");
3397        assert!(
3398            fixtures.len() >= 2,
3399            "need a minimal and a full configure golden, got {}",
3400            fixtures.len()
3401        );
3402
3403        let mut saw_minimal = false;
3404        let mut saw_full = false;
3405        for (name, fixture) in fixtures {
3406            let config: OperationConfig =
3407                serde_json::from_value(fixture["input"]["config"].clone())
3408                    .unwrap_or_else(|e| panic!("{name}: {e}"));
3409            assert_eq!(
3410                serde_json::to_value(&config).unwrap(),
3411                fixture["input"]["config"],
3412                "{name}: config round-trip changed the document"
3413            );
3414            config
3415                .resolve(&defaults())
3416                .unwrap_or_else(|e| panic!("{name}: golden must resolve: {e}"));
3417
3418            let field_count = fixture["input"]["config"].as_object().unwrap().len();
3419            saw_minimal |= field_count <= 2;
3420            saw_full |= field_count >= 15;
3421        }
3422        assert!(saw_minimal, "no minimal configure golden");
3423        assert!(saw_full, "no all-fields configure golden");
3424    }
3425
3426    #[test]
3427    fn the_resolved_golden_matches_what_resolution_produces() {
3428        // The genesis record stores this shape. Freezing it is what makes "a replay never
3429        // re-applies a newer binary's defaults" a checkable claim rather than an intention.
3430        let fixture = fixtures_with_prefix("golden_config_resolved")
3431            .into_iter()
3432            .next()
3433            .map(|(_, value)| value)
3434            .expect("a resolved-config golden must exist");
3435
3436        let config: OperationConfig =
3437            serde_json::from_value(fixture["config"].clone()).expect("golden config decodes");
3438        let resolved = config.resolve(&defaults()).expect("golden config resolves");
3439        assert_eq!(
3440            serde_json::to_value(&resolved).unwrap(),
3441            fixture["resolved"],
3442            "resolution drifted from the frozen golden"
3443        );
3444    }
3445
3446    #[test]
3447    fn config_rejection_fixtures_fail_closed_with_the_declared_kind() {
3448        // Decode-stage rejections are whole envelopes (they also feed the §7.1 harness);
3449        // resolution-stage rejections carry the bare config, because they decode successfully
3450        // and only fail once the cross-field rules run.
3451        let decode_stage = fixtures_with_prefix("reject_config_");
3452        assert!(
3453            decode_stage.len() >= 4,
3454            "too few decode-stage config rejections"
3455        );
3456        for (name, fixture) in &decode_stage {
3457            let expected = fixture["expect"].as_str().expect("expect");
3458            let config = fixture["envelope"]["input"]["config"].clone();
3459            let error = serde_json::from_value::<OperationConfig>(config)
3460                .expect_err(&format!("{name}: expected a decode rejection"));
3461            let message = error.to_string();
3462            let kind = if message.contains(SCALAR_ERROR_MARKER) {
3463                "invalid_scalar"
3464            } else if message.contains("unknown field") {
3465                "unknown_field"
3466            } else if message.contains("unknown variant") {
3467                "unknown_variant"
3468            } else if message.contains("missing field") {
3469                "missing_field"
3470            } else {
3471                "type_mismatch"
3472            };
3473            assert_eq!(kind, expected, "{name}: wrong kind ({message})");
3474        }
3475
3476        let resolution_stage: Vec<_> = fixtures_with_prefix("golden_config_reject_");
3477        assert!(
3478            resolution_stage.len() >= 4,
3479            "too few resolution-stage config rejections"
3480        );
3481        let mut kinds = BTreeSet::new();
3482        for (name, fixture) in &resolution_stage {
3483            let expected = fixture["expect"].as_str().expect("expect");
3484            let config: OperationConfig = serde_json::from_value(fixture["config"].clone())
3485                .unwrap_or_else(|e| panic!("{name}: a resolution-stage fixture must decode: {e}"));
3486            let defaults = fixture
3487                .get("bootstrap_limits")
3488                .map(|limits| ConfigDefaults::new(serde_json::from_value(limits.clone()).unwrap()))
3489                .unwrap_or_default();
3490            let rejection = config
3491                .resolve(&defaults)
3492                .map(|ok| panic!("{name}: expected a rejection, resolved {ok:?}"))
3493                .unwrap_err();
3494            assert_eq!(
3495                rejection.kind.as_str(),
3496                expected,
3497                "{name}: {}",
3498                rejection.message
3499            );
3500            kinds.insert(expected.to_string());
3501        }
3502        assert!(kinds.contains("policy_violation"));
3503        assert!(kinds.contains("collection_too_large"));
3504    }
3505
3506    #[test]
3507    fn config_fixtures_never_carry_host_owned_facts() {
3508        const BANNED: [&str; 7] = [
3509            "memory_path",
3510            "spool_dir",
3511            "tokenizer",
3512            "host_effect_retry_attempts",
3513            "session_id",
3514            "api_key",
3515            "endpoint",
3516        ];
3517        for prefix in ["input_configure_", "golden_config_"] {
3518            for (name, fixture) in fixtures_with_prefix(prefix) {
3519                let mut keys = BTreeSet::new();
3520                all_keys(&fixture, &mut keys);
3521                for banned in BANNED {
3522                    assert!(
3523                        !keys.contains(banned),
3524                        "{name}: configuration fixture carries the host-owned fact {banned:?}"
3525                    );
3526                }
3527            }
3528        }
3529    }
3530}