Skip to main content

deepstrike_core/runtime/kernel/wire/
config.rs

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