1use 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
37fn 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct OperationConfig {
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub execution_policy: Option<ExecutionPolicy>,
84 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub budget_grant: Option<BudgetGrant>,
95 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub kernel_limits: Option<KernelLimits>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub memory_access: Option<MemoryAccessBinding>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub memory_policy: Option<MemoryPolicy>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub tool_catalog: Vec<ToolSchema>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub skill_catalog: Vec<SkillMetadata>,
122 #[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 pub host_effect_support: HostEffectSupport,
133}
134
135impl OperationConfig {
136 pub fn resolve(
138 &self,
139 defaults: &ConfigDefaults,
140 ) -> Result<ResolvedOperationConfig, WireRejection> {
141 resolve_operation_config(self, defaults)
142 }
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ExecutionPolicy {
158 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub deny_after: Option<u32>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub terminate_after: Option<u32>,
187}
188
189#[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 #[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#[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
227pub const MAX_SCHEDULER_WEIGHT: u32 = 1_000_000_000;
229
230#[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 #[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#[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#[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 #[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#[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#[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
347#[serde(deny_unknown_fields)]
348pub struct PayloadPolicy {
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub inline_threshold_bytes: Option<u32>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub preview_bytes: Option<u32>,
355}
356
357#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub collection_limits: Option<CollectionLimits>,
377}
378
379#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub promotion_recall_threshold: Option<WireU64>,
422}
423
424#[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct VerificationContract {
459 pub contract_id: String,
463 pub phases: Vec<MilestonePhase>,
464}
465
466#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
468#[serde(deny_unknown_fields)]
469pub struct MilestonePhase {
470 pub phase_id: String,
473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub unlocks: Vec<String>,
481}
482
483#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub stable_core_tool_ids: Option<Vec<String>>,
504}
505
506#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(deny_unknown_fields)]
528pub struct HostEffectSupport {
529 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
557#[serde(deny_unknown_fields)]
558pub struct ResolvedOperationConfig {
559 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 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 pub tail_bounds: TailBounds,
674}
675
676#[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 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 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 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#[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#[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
936pub 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 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
1043fn 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#[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 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
1486pub(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) = "a.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
1736fn 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
1747pub 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#[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 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 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 host_effect_support: HostEffectSupport::new(EffectKindTag::ALL),
2214 }
2215 }
2216
2217 #[test]
2222 fn deleted_and_host_side_config_appears_in_no_new_type() {
2223 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 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 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 #[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 #[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 #[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 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 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 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 #[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); 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 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 #[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 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 #[test]
2825 fn every_declared_capability_requires_its_effect_kind() {
2826 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 minimal_config()
2861 .resolve(&defaults())
2862 .expect("a minimal config needs only call_provider");
2863
2864 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
3223 fn a_broken_encoder_and_a_refused_configuration_are_different_rejections() {
3224 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 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 #[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 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 #[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 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 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}