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