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