1use serde::{Deserialize, Serialize};
27
28use super::KernelBootstrapLimits;
29use super::command::{
30 GovernancePolicy, PolicyAction, RecoveryPolicy, SignalPolicy, TailBoundsPolicy,
31};
32use super::effect::{EffectKindTag, MemoryAccessBinding, ToolSchema};
33use super::envelope::{WireRejection, WireRejectionKind};
34use super::fault::{KernelFault, KernelFaultCode};
35use super::scalar::{Ppm, WireU64};
36
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)]
194#[serde(deny_unknown_fields)]
195pub struct EntropyWatchPolicy {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub enabled: Option<bool>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub threshold_ppm: Option<Ppm>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub hysteresis_ppm: Option<Ppm>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub cooldown_turns: Option<u32>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub notify_model: Option<bool>,
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct SchedulerPolicy {
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub critical_path_weight: Option<u32>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub fanout_weight: Option<u32>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub age_weight: Option<u32>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub token_cost_weight: Option<u32>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub deadline_weight: Option<u32>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub process_priority_weight: Option<u32>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub resource_pressure_weight: Option<u32>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub budget_pressure_weight: Option<u32>,
233}
234
235pub const MAX_SCHEDULER_WEIGHT: u32 = 1_000_000_000;
237
238#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct ResourceQuota {
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub max_concurrent_subagents: Option<u32>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub max_total_subagents: Option<u32>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub max_spawn_depth: Option<u32>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub max_workflow_nodes: Option<u32>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub memory_writes_per_window: Option<RateWindow>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct RateWindow {
266 pub max_events: u32,
267 pub window_ms: WireU64,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct BudgetGrant {
275 pub reservation_id: String,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub tokens: Option<WireU64>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub subagents: Option<u32>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub rounds: Option<u32>,
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
294#[serde(deny_unknown_fields)]
295pub struct ContextPolicy {
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub pressure_thresholds_ppm: Option<PressureThresholds>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub target_after_compress_ppm: Option<Ppm>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub preserve_recent_turns: Option<u32>,
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub renewal_carryover_ppm: Option<Ppm>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub collapse_old_assistant_narration: Option<bool>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub idle_micro_compact_minutes: Option<u32>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub knowledge_budget_ppm: Option<Ppm>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub prompt_budget: Option<PromptBudget>,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct PressureThresholds {
321 pub snip: Ppm,
322 pub micro: Ppm,
323 pub collapse: Ppm,
324 pub auto: Ppm,
325 pub renewal: Ppm,
326}
327
328#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct PromptBudget {
333 pub prompt_overhead_tokens: u32,
334 pub output_reserve_tokens: u32,
335 pub safety_margin_tokens: u32,
336}
337
338impl PromptBudget {
339 pub fn reserved_tokens(self) -> u32 {
340 self.prompt_overhead_tokens
341 .saturating_add(self.output_reserve_tokens)
342 .saturating_add(self.safety_margin_tokens)
343 }
344}
345
346#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct PayloadPolicy {
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub inline_threshold_bytes: Option<u32>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub preview_bytes: Option<u32>,
363}
364
365#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
372#[serde(deny_unknown_fields)]
373pub struct KernelLimits {
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub max_input_bytes: Option<u32>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub max_json_depth: Option<u16>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub max_collection_entries: Option<u32>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub collection_limits: Option<CollectionLimits>,
385}
386
387#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct CollectionLimits {
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub tool_catalog: Option<u32>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub skill_catalog: Option<u32>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub knowledge_entries: Option<u32>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub initial_messages: Option<u32>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub capability_grants: Option<u32>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub governance_rules: Option<u32>,
403}
404
405#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
415#[serde(deny_unknown_fields)]
416pub struct MemoryPolicy {
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub stale_warning_days: Option<u32>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub retrieval_top_k: Option<u32>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub validation_enabled: Option<bool>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub max_content_bytes: Option<u32>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub max_name_length: Option<u32>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub promotion_recall_threshold: Option<WireU64>,
430}
431
432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437#[serde(deny_unknown_fields)]
438pub struct SkillMetadata {
439 pub name: String,
440 #[serde(default, skip_serializing_if = "String::is_empty")]
441 pub description: String,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub when_to_use: Option<String>,
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
445 pub allowed_tools: Vec<String>,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub capability_grants: Vec<crate::types::capability::Capability>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub effort: Option<u8>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub estimated_tokens: Option<u32>,
455}
456
457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472#[serde(deny_unknown_fields)]
473pub struct VerificationContract {
474 pub contract_id: String,
478 pub phases: Vec<MilestonePhase>,
479}
480
481#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483#[serde(deny_unknown_fields)]
484pub struct MilestonePhase {
485 pub phase_id: String,
488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub unlocks: Vec<String>,
496}
497
498#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
504#[serde(deny_unknown_fields)]
505pub struct FeaturePolicy {
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub memory_enabled: Option<bool>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub knowledge_enabled: Option<bool>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub plan_tool_enabled: Option<bool>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub stable_core_tool_ids: Option<Vec<String>>,
515}
516
517#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct HostEffectSupport {
530 pub supported: Vec<EffectKindTag>,
533}
534
535impl HostEffectSupport {
536 pub fn new(supported: impl IntoIterator<Item = EffectKindTag>) -> Self {
537 Self {
538 supported: supported.into_iter().collect(),
539 }
540 }
541
542 pub fn supports(&self, kind: EffectKindTag) -> bool {
543 self.supported.contains(&kind)
544 }
545}
546
547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
558#[serde(deny_unknown_fields)]
559pub struct ResolvedOperationConfig {
560 pub execution_policy: ResolvedExecutionPolicy,
561 pub governance_policy: ResolvedGovernancePolicy,
562 pub scheduler_policy: ResolvedSchedulerPolicy,
563 pub resource_quota: ResourceQuota,
564 pub budget_grant: Option<BudgetGrant>,
565 pub signal_policy: ResolvedSignalPolicy,
566 pub context_policy: ResolvedContextPolicy,
567 pub recovery_policy: ResolvedRecoveryPolicy,
568 pub payload_policy: ResolvedPayloadPolicy,
569 pub kernel_limits: ResolvedKernelLimits,
570 pub memory_access: Option<MemoryAccessBinding>,
571 pub memory_policy: ResolvedMemoryPolicy,
572 pub tool_catalog: Vec<ToolSchema>,
573 pub skill_catalog: Vec<SkillMetadata>,
574 pub verification_contracts: Vec<VerificationContract>,
575 pub feature_policy: ResolvedFeaturePolicy,
576 pub host_effect_support: HostEffectSupport,
577}
578
579impl ResolvedOperationConfig {
580 pub fn verification_contract(&self, contract_id: &str) -> Option<&VerificationContract> {
582 self.verification_contracts
583 .iter()
584 .find(|contract| contract.contract_id == contract_id)
585 }
586}
587
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct ResolvedExecutionPolicy {
591 pub max_context_tokens: u32,
592 pub max_turns: u32,
593 pub max_total_tokens: WireU64,
594 pub max_wall_ms: Option<WireU64>,
595 pub criteria_gate_enabled: bool,
596 pub repeat_fuse: ResolvedRepeatFuse,
597 pub entropy_watch: ResolvedEntropyWatch,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct ResolvedRepeatFuse {
603 pub enabled: bool,
604 pub deny_after: u32,
605 pub terminate_after: u32,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(deny_unknown_fields)]
610pub struct ResolvedEntropyWatch {
611 pub enabled: bool,
612 pub threshold_ppm: Ppm,
613 pub hysteresis_ppm: Ppm,
614 pub cooldown_turns: u32,
615 pub notify_model: bool,
616}
617
618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
619#[serde(deny_unknown_fields)]
620pub struct ResolvedGovernancePolicy {
621 pub default_action: PolicyAction,
622 pub rules: Vec<super::command::PolicyRule>,
623 pub vetoed_tools: Vec<String>,
624 pub rate_limits: Vec<super::command::RateLimitSpec>,
625 pub constraints: Vec<super::command::ParamConstraint>,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
629#[serde(deny_unknown_fields)]
630pub struct ResolvedSchedulerPolicy {
631 pub critical_path_weight: u32,
632 pub fanout_weight: u32,
633 pub age_weight: u32,
634 pub token_cost_weight: u32,
635 #[serde(default)]
636 pub deadline_weight: u32,
637 #[serde(default)]
638 pub process_priority_weight: u32,
639 #[serde(default)]
640 pub resource_pressure_weight: u32,
641 #[serde(default)]
642 pub budget_pressure_weight: u32,
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct ResolvedSignalPolicy {
648 pub queue_max: u32,
649 pub ttl_ms: Option<WireU64>,
650 pub deadline_escalation: bool,
651}
652
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[serde(deny_unknown_fields)]
655pub struct ResolvedContextPolicy {
656 pub pressure_thresholds_ppm: PressureThresholds,
657 pub target_after_compress_ppm: Ppm,
658 pub preserve_recent_turns: u32,
659 pub renewal_carryover_ppm: Ppm,
660 pub collapse_old_assistant_narration: bool,
661 pub idle_micro_compact_minutes: u32,
662 pub knowledge_budget_ppm: Ppm,
663 pub prompt_budget: PromptBudget,
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
667#[serde(deny_unknown_fields)]
668pub struct ResolvedRecoveryPolicy {
669 pub provider_recovery_attempts: u8,
670 pub output_recovery_attempts: u8,
671 pub tail_bounds: TailBounds,
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
693#[serde(deny_unknown_fields)]
694pub struct TailBounds {
695 pub soft_records: WireU64,
696 pub hard_records: WireU64,
697 pub soft_bytes: WireU64,
698 pub hard_bytes: WireU64,
699}
700
701impl TailBounds {
702 pub const DEFAULT: Self = Self {
705 soft_records: WireU64::new(512),
706 hard_records: WireU64::new(2_048),
707 soft_bytes: WireU64::new(4 * 1024 * 1024),
708 hard_bytes: WireU64::new(16 * 1024 * 1024),
709 };
710
711 pub fn new(
714 soft_records: u64,
715 hard_records: u64,
716 soft_bytes: u64,
717 hard_bytes: u64,
718 ) -> Result<Self, KernelFault> {
719 let bounds = Self {
720 soft_records: WireU64::new(soft_records),
721 hard_records: WireU64::new(hard_records),
722 soft_bytes: WireU64::new(soft_bytes),
723 hard_bytes: WireU64::new(hard_bytes),
724 };
725 bounds
726 .check()
727 .map_err(|message| KernelFault::new(KernelFaultCode::InvalidConfig, message))?;
728 Ok(bounds)
729 }
730
731 pub(super) fn check(&self) -> Result<(), String> {
734 if self.soft_records > self.hard_records || self.soft_bytes > self.hard_bytes {
735 return Err(format!(
736 "recovery_policy.tail_bounds watermark ({} records / {} bytes) exceeds its hard \
737 limit ({} records / {} bytes)",
738 self.soft_records, self.soft_bytes, self.hard_records, self.hard_bytes
739 ));
740 }
741 if self.hard_records.get() == 0 || self.hard_bytes.get() == 0 {
742 return Err(
743 "recovery_policy.tail_bounds hard limit of zero admits no transaction at all"
744 .to_string(),
745 );
746 }
747 Ok(())
748 }
749}
750
751impl Default for TailBounds {
752 fn default() -> Self {
753 Self::DEFAULT
754 }
755}
756
757#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
758#[serde(deny_unknown_fields)]
759pub struct ResolvedPayloadPolicy {
760 pub inline_threshold_bytes: u32,
761 pub preview_bytes: u32,
762}
763
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
768#[serde(deny_unknown_fields)]
769pub struct ResolvedKernelLimits {
770 pub max_input_bytes: u32,
771 pub max_json_depth: u16,
772 pub max_collection_entries: u32,
773 pub collection_limits: ResolvedCollectionLimits,
774}
775
776#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
777#[serde(deny_unknown_fields)]
778pub struct ResolvedCollectionLimits {
779 pub tool_catalog: u32,
780 pub skill_catalog: u32,
781 pub knowledge_entries: u32,
782 pub initial_messages: u32,
783 pub capability_grants: u32,
784 pub governance_rules: u32,
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
788#[serde(deny_unknown_fields)]
789pub struct ResolvedMemoryPolicy {
790 pub stale_warning_days: u32,
791 pub retrieval_top_k: u32,
792 pub validation_enabled: bool,
793 pub max_content_bytes: u32,
794 pub max_name_length: u32,
795 pub promotion_recall_threshold: Option<WireU64>,
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(deny_unknown_fields)]
800pub struct ResolvedFeaturePolicy {
801 pub memory_enabled: bool,
802 pub knowledge_enabled: bool,
803 pub plan_tool_enabled: bool,
804 pub stable_core_tool_ids: Vec<String>,
805}
806
807#[derive(Debug, Clone, PartialEq)]
817pub struct ConfigDefaults {
818 pub bootstrap_limits: KernelBootstrapLimits,
819 pub baseline: ResolvedOperationConfig,
820}
821
822impl ConfigDefaults {
823 pub fn new(bootstrap_limits: KernelBootstrapLimits) -> Self {
824 let entries = bootstrap_limits.absolute_max_collection_entries;
825 Self {
826 bootstrap_limits,
827 baseline: ResolvedOperationConfig {
828 execution_policy: ResolvedExecutionPolicy {
829 max_context_tokens: 128_000,
830 max_turns: 25,
831 max_total_tokens: WireU64::new(1_000_000),
832 max_wall_ms: None,
833 criteria_gate_enabled: true,
834 repeat_fuse: ResolvedRepeatFuse {
835 enabled: true,
836 deny_after: 5,
837 terminate_after: 8,
838 },
839 entropy_watch: ResolvedEntropyWatch {
840 enabled: false,
841 threshold_ppm: Ppm::from_ppm_const(650_000),
842 hysteresis_ppm: Ppm::from_ppm_const(100_000),
843 cooldown_turns: 4,
844 notify_model: false,
845 },
846 },
847 governance_policy: ResolvedGovernancePolicy {
848 default_action: PolicyAction::Allow,
849 rules: Vec::new(),
850 vetoed_tools: Vec::new(),
851 rate_limits: Vec::new(),
852 constraints: Vec::new(),
853 },
854 scheduler_policy: ResolvedSchedulerPolicy {
855 critical_path_weight: 1_000_000,
856 fanout_weight: 10_000,
857 age_weight: 1_000,
858 token_cost_weight: 1,
859 deadline_weight: 0,
860 process_priority_weight: 0,
861 resource_pressure_weight: 0,
862 budget_pressure_weight: 0,
863 },
864 resource_quota: ResourceQuota::default(),
865 budget_grant: None,
866 signal_policy: ResolvedSignalPolicy {
867 queue_max: 64,
868 ttl_ms: None,
869 deadline_escalation: false,
870 },
871 context_policy: ResolvedContextPolicy {
872 pressure_thresholds_ppm: PressureThresholds {
873 snip: Ppm::from_ppm_const(700_000),
874 micro: Ppm::from_ppm_const(800_000),
875 collapse: Ppm::from_ppm_const(900_000),
876 auto: Ppm::from_ppm_const(950_000),
877 renewal: Ppm::from_ppm_const(980_000),
878 },
879 target_after_compress_ppm: Ppm::from_ppm_const(650_000),
880 preserve_recent_turns: 2,
881 renewal_carryover_ppm: Ppm::from_ppm_const(50_000),
882 collapse_old_assistant_narration: true,
883 idle_micro_compact_minutes: 60,
884 knowledge_budget_ppm: Ppm::from_ppm_const(250_000),
885 prompt_budget: PromptBudget {
886 prompt_overhead_tokens: 0,
887 output_reserve_tokens: 0,
888 safety_margin_tokens: 0,
889 },
890 },
891 recovery_policy: ResolvedRecoveryPolicy {
892 provider_recovery_attempts: 1,
893 output_recovery_attempts: 1,
894 tail_bounds: TailBounds::DEFAULT,
895 },
896 payload_policy: ResolvedPayloadPolicy {
897 inline_threshold_bytes: 50 * 1024,
898 preview_bytes: 2 * 1024,
899 },
900 kernel_limits: ResolvedKernelLimits {
901 max_input_bytes: bootstrap_limits.absolute_max_input_bytes,
902 max_json_depth: bootstrap_limits.absolute_max_json_depth,
903 max_collection_entries: entries,
904 collection_limits: ResolvedCollectionLimits {
905 tool_catalog: entries,
906 skill_catalog: entries,
907 knowledge_entries: entries,
908 initial_messages: entries,
909 capability_grants: entries,
910 governance_rules: entries,
911 },
912 },
913 memory_access: None,
914 memory_policy: ResolvedMemoryPolicy {
915 stale_warning_days: 2,
916 retrieval_top_k: 5,
917 validation_enabled: true,
918 max_content_bytes: 10_000,
919 max_name_length: 100,
920 promotion_recall_threshold: None,
921 },
922 tool_catalog: Vec::new(),
923 skill_catalog: Vec::new(),
924 verification_contracts: Vec::new(),
925 feature_policy: ResolvedFeaturePolicy {
926 memory_enabled: false,
927 knowledge_enabled: false,
928 plan_tool_enabled: false,
929 stable_core_tool_ids: Vec::new(),
930 },
931 host_effect_support: HostEffectSupport::default(),
932 },
933 }
934 }
935}
936
937impl Default for ConfigDefaults {
938 fn default() -> Self {
939 Self::new(KernelBootstrapLimits::DEFAULT)
940 }
941}
942
943pub fn resolve_operation_config(
954 config: &OperationConfig,
955 defaults: &ConfigDefaults,
956) -> Result<ResolvedOperationConfig, WireRejection> {
957 let base = &defaults.baseline;
958
959 let kernel_limits = resolve_kernel_limits(
960 config.kernel_limits.as_ref(),
961 &defaults.bootstrap_limits,
962 &base.kernel_limits,
963 )?;
964 let execution_policy =
965 resolve_execution(config.execution_policy.as_ref(), &base.execution_policy)?;
966 let governance_policy = resolve_governance(
967 config.governance_policy.as_ref(),
968 &base.governance_policy,
969 kernel_limits.collection_limits.governance_rules,
970 )?;
971 let scheduler_policy =
972 resolve_scheduler(config.scheduler_policy.as_ref(), &base.scheduler_policy)?;
973 let resource_quota = resolve_quota(config.resource_quota.as_ref(), &base.resource_quota)?;
974 let budget_grant = resolve_budget_grant(config.budget_grant.as_ref())?;
975 let signal_policy = resolve_signal(config.signal_policy.as_ref(), &base.signal_policy)?;
976 let context_policy = resolve_context(
977 config.context_policy.as_ref(),
978 &base.context_policy,
979 execution_policy.max_context_tokens,
980 )?;
981 let recovery_policy = resolve_recovery(config.recovery_policy.as_ref(), &base.recovery_policy)?;
982 let payload_policy = resolve_payload(config.payload_policy.as_ref(), &base.payload_policy)?;
983 let memory_policy = resolve_memory_policy(config.memory_policy.as_ref(), &base.memory_policy)?;
984 let feature_policy = resolve_features(config.feature_policy.as_ref(), &base.feature_policy)?;
985
986 let tool_catalog = resolve_tool_catalog(
987 &config.tool_catalog,
988 kernel_limits.collection_limits.tool_catalog,
989 )?;
990 let skill_catalog = resolve_skill_catalog(
991 &config.skill_catalog,
992 kernel_limits.collection_limits.skill_catalog,
993 kernel_limits.collection_limits.capability_grants,
994 &tool_catalog,
995 )?;
996 let host_effect_support = resolve_host_effect_support(&config.host_effect_support)?;
997 let verification_contracts = resolve_verification_contracts(
998 &config.verification_contracts,
999 kernel_limits.max_collection_entries,
1000 &tool_catalog,
1001 &skill_catalog,
1002 )?;
1003
1004 if feature_policy.memory_enabled && config.memory_access.is_none() {
1006 return Err(invalid(
1007 "feature_policy.memory_enabled is true but no memory_access binding was configured",
1008 ));
1009 }
1010 require_declared_effect_support(
1011 &host_effect_support,
1012 &feature_policy,
1013 config.memory_access.as_ref(),
1014 &resource_quota,
1015 budget_grant.as_ref(),
1016 &governance_policy,
1017 &tool_catalog,
1018 &verification_contracts,
1019 )?;
1020 for tool_id in &feature_policy.stable_core_tool_ids {
1021 if !tool_catalog.iter().any(|tool| &tool.name == tool_id) {
1022 return Err(invalid(format!(
1023 "feature_policy.stable_core_tool_ids names {tool_id:?}, \
1024 which the tool catalog does not declare"
1025 )));
1026 }
1027 }
1028
1029 Ok(ResolvedOperationConfig {
1030 execution_policy,
1031 governance_policy,
1032 scheduler_policy,
1033 resource_quota,
1034 budget_grant,
1035 signal_policy,
1036 context_policy,
1037 recovery_policy,
1038 payload_policy,
1039 kernel_limits,
1040 memory_access: config.memory_access.clone(),
1041 memory_policy,
1042 tool_catalog,
1043 skill_catalog,
1044 verification_contracts,
1045 feature_policy,
1046 host_effect_support,
1047 })
1048}
1049
1050fn resolve_verification_contracts(
1067 contracts: &[VerificationContract],
1068 max_entries: u32,
1069 tool_catalog: &[ToolSchema],
1070 skill_catalog: &[SkillMetadata],
1071) -> Result<Vec<VerificationContract>, WireRejection> {
1072 if contracts.len() as u64 > max_entries as u64 {
1073 return Err(too_many(format!(
1074 "verification_contracts carries {} entries; the bound is {max_entries}",
1075 contracts.len()
1076 )));
1077 }
1078 let mut seen_contracts: Vec<&str> = Vec::with_capacity(contracts.len());
1079 for contract in contracts {
1080 if contract.contract_id.is_empty() {
1081 return Err(invalid(
1082 "a verification contract must carry a non-empty contract_id",
1083 ));
1084 }
1085 if seen_contracts.contains(&contract.contract_id.as_str()) {
1086 return Err(invalid(format!(
1087 "verification_contracts declares {:?} twice; a contract id is the reference \
1088 `verification_contract_id` resolves against and must be unique",
1089 contract.contract_id
1090 )));
1091 }
1092 seen_contracts.push(&contract.contract_id);
1093
1094 if contract.phases.is_empty() {
1095 return Err(invalid(format!(
1096 "verification contract {:?} declares no phases; a contract with no phase can \
1097 never be evaluated",
1098 contract.contract_id
1099 )));
1100 }
1101 if contract.phases.len() as u64 > max_entries as u64 {
1102 return Err(too_many(format!(
1103 "verification contract {:?} carries {} phases; the bound is {max_entries}",
1104 contract.contract_id,
1105 contract.phases.len()
1106 )));
1107 }
1108 let mut seen_phases: Vec<&str> = Vec::with_capacity(contract.phases.len());
1109 for phase in &contract.phases {
1110 if phase.phase_id.is_empty() {
1111 return Err(invalid(format!(
1112 "verification contract {:?} carries a phase with an empty phase_id",
1113 contract.contract_id
1114 )));
1115 }
1116 if seen_phases.contains(&phase.phase_id.as_str()) {
1117 return Err(invalid(format!(
1118 "verification contract {:?} declares phase {:?} twice; a milestone verdict \
1119 names its phase by id and could not say which one it advanced",
1120 contract.contract_id, phase.phase_id
1121 )));
1122 }
1123 seen_phases.push(&phase.phase_id);
1124 for capability_id in &phase.unlocks {
1125 let declared = tool_catalog.iter().any(|tool| &tool.name == capability_id)
1126 || skill_catalog
1127 .iter()
1128 .any(|skill| &skill.name == capability_id);
1129 if !declared {
1130 return Err(invalid(format!(
1131 "verification contract {:?} phase {:?} unlocks {capability_id:?}, which \
1132 is in neither the tool catalog nor the skill catalog; a phase cannot \
1133 mount a capability the operation never declared",
1134 contract.contract_id, phase.phase_id
1135 )));
1136 }
1137 }
1138 }
1139 }
1140 Ok(contracts.to_vec())
1141}
1142
1143#[allow(clippy::too_many_arguments)]
1170fn require_declared_effect_support(
1171 support: &HostEffectSupport,
1172 features: &ResolvedFeaturePolicy,
1173 memory_access: Option<&MemoryAccessBinding>,
1174 quota: &ResourceQuota,
1175 grant: Option<&BudgetGrant>,
1176 governance: &ResolvedGovernancePolicy,
1177 tool_catalog: &[ToolSchema],
1178 verification_contracts: &[VerificationContract],
1179) -> Result<(), WireRejection> {
1180 let has_tools = !tool_catalog.is_empty();
1181 let can_ask_user = governance.default_action == PolicyAction::AskUser
1182 || governance
1183 .rules
1184 .iter()
1185 .any(|rule| rule.action == PolicyAction::AskUser);
1186 let declares_spawn_capacity = [
1187 quota.max_concurrent_subagents,
1188 quota.max_total_subagents,
1189 quota.max_spawn_depth,
1190 quota.max_workflow_nodes,
1191 grant.and_then(|grant| grant.subagents),
1192 ]
1193 .into_iter()
1194 .flatten()
1195 .any(|capacity| capacity > 0);
1196 let memory_write =
1197 features.memory_enabled || memory_access.is_some_and(|access| access.capabilities.write);
1198 let memory_read =
1199 features.memory_enabled || memory_access.is_some_and(|access| access.capabilities.read);
1200
1201 for (kind, required, because) in [
1202 (
1203 EffectKindTag::CallProvider,
1204 true,
1205 "every operation reaches a provider call",
1206 ),
1207 (
1208 EffectKindTag::ExecuteTools,
1209 has_tools,
1210 "tool_catalog declares tools this operation may dispatch",
1211 ),
1212 (
1213 EffectKindTag::LoadPayload,
1214 has_tools,
1215 "a tool result above the inline threshold becomes an external payload the kernel \
1216 must be able to page back in",
1217 ),
1218 (
1219 EffectKindTag::RequestApproval,
1220 can_ask_user,
1221 "governance_policy can return ask_user",
1222 ),
1223 (
1224 EffectKindTag::SpawnTasks,
1225 declares_spawn_capacity,
1226 "resource_quota or budget_grant declares spawn/workflow capacity",
1227 ),
1228 (
1229 EffectKindTag::PreemptTasks,
1230 declares_spawn_capacity,
1231 "an operation that may start child tasks must be able to stop them on \
1232 cancellation or budget exhaustion",
1233 ),
1234 (
1235 EffectKindTag::PersistMemory,
1236 memory_write,
1237 "the memory plane is writable",
1238 ),
1239 (
1240 EffectKindTag::QueryMemory,
1241 memory_read,
1242 "the memory plane is readable",
1243 ),
1244 (
1245 EffectKindTag::ArchivePageOut,
1246 features.knowledge_enabled,
1247 "feature_policy.knowledge_enabled exposes a partition that is paged out under \
1248 budget pressure",
1249 ),
1250 (
1251 EffectKindTag::EvaluateMilestone,
1252 !verification_contracts.is_empty(),
1253 "verification_contracts declares contracts that must be evaluated",
1254 ),
1255 ] {
1256 if required && !support.supports(kind) {
1257 return Err(invalid(format!(
1258 "host_effect_support does not declare {:?}, but {because}; \
1259 a capability the host cannot execute must not be configured on",
1260 kind.as_str()
1261 )));
1262 }
1263 }
1264 Ok(())
1265}
1266
1267fn resolve_kernel_limits(
1268 limits: Option<&KernelLimits>,
1269 bootstrap: &KernelBootstrapLimits,
1270 base: &ResolvedKernelLimits,
1271) -> Result<ResolvedKernelLimits, WireRejection> {
1272 let mut resolved = *base;
1273 resolved.max_input_bytes = bootstrap.absolute_max_input_bytes;
1274 resolved.max_json_depth = bootstrap.absolute_max_json_depth;
1275 resolved.max_collection_entries = bootstrap.absolute_max_collection_entries;
1276
1277 if let Some(limits) = limits {
1278 if let Some(bytes) = limits.max_input_bytes {
1279 require_le_u32(
1280 "kernel_limits.max_input_bytes",
1281 bytes,
1282 bootstrap.absolute_max_input_bytes,
1283 "absolute_max_input_bytes",
1284 )?;
1285 if bytes == 0 {
1286 return Err(invalid("kernel_limits.max_input_bytes must be positive"));
1287 }
1288 resolved.max_input_bytes = bytes;
1289 }
1290 if let Some(depth) = limits.max_json_depth {
1291 require_le_u32(
1292 "kernel_limits.max_json_depth",
1293 u32::from(depth),
1294 u32::from(bootstrap.absolute_max_json_depth),
1295 "absolute_max_json_depth",
1296 )?;
1297 if depth == 0 {
1298 return Err(invalid("kernel_limits.max_json_depth must be positive"));
1299 }
1300 resolved.max_json_depth = depth;
1301 }
1302 if let Some(entries) = limits.max_collection_entries {
1303 require_le_u32(
1304 "kernel_limits.max_collection_entries",
1305 entries,
1306 bootstrap.absolute_max_collection_entries,
1307 "absolute_max_collection_entries",
1308 )?;
1309 if entries == 0 {
1310 return Err(invalid(
1311 "kernel_limits.max_collection_entries must be positive",
1312 ));
1313 }
1314 resolved.max_collection_entries = entries;
1315 }
1316 }
1317
1318 let ceiling = resolved.max_collection_entries;
1319 let mut per_collection = ResolvedCollectionLimits {
1320 tool_catalog: ceiling,
1321 skill_catalog: ceiling,
1322 knowledge_entries: ceiling,
1323 initial_messages: ceiling,
1324 capability_grants: ceiling,
1325 governance_rules: ceiling,
1326 };
1327
1328 if let Some(named) = limits.and_then(|limits| limits.collection_limits.as_ref()) {
1329 for (label, requested, slot) in [
1330 (
1331 "tool_catalog",
1332 named.tool_catalog,
1333 &mut per_collection.tool_catalog,
1334 ),
1335 (
1336 "skill_catalog",
1337 named.skill_catalog,
1338 &mut per_collection.skill_catalog,
1339 ),
1340 (
1341 "knowledge_entries",
1342 named.knowledge_entries,
1343 &mut per_collection.knowledge_entries,
1344 ),
1345 (
1346 "initial_messages",
1347 named.initial_messages,
1348 &mut per_collection.initial_messages,
1349 ),
1350 (
1351 "capability_grants",
1352 named.capability_grants,
1353 &mut per_collection.capability_grants,
1354 ),
1355 (
1356 "governance_rules",
1357 named.governance_rules,
1358 &mut per_collection.governance_rules,
1359 ),
1360 ] {
1361 if let Some(requested) = requested {
1362 require_le_u32(
1363 &format!("kernel_limits.collection_limits.{label}"),
1364 requested,
1365 ceiling,
1366 "the resolved max_collection_entries",
1367 )?;
1368 *slot = requested;
1369 }
1370 }
1371 }
1372
1373 resolved.collection_limits = per_collection;
1374 Ok(resolved)
1375}
1376
1377fn resolve_execution(
1378 policy: Option<&ExecutionPolicy>,
1379 base: &ResolvedExecutionPolicy,
1380) -> Result<ResolvedExecutionPolicy, WireRejection> {
1381 let mut resolved = base.clone();
1382 if let Some(policy) = policy {
1383 if let Some(value) = policy.max_context_tokens {
1384 resolved.max_context_tokens = value;
1385 }
1386 if let Some(value) = policy.max_turns {
1387 resolved.max_turns = value;
1388 }
1389 if let Some(value) = policy.max_total_tokens {
1390 resolved.max_total_tokens = value;
1391 }
1392 if let Some(value) = policy.max_wall_ms {
1394 resolved.max_wall_ms = Some(value);
1395 }
1396 if let Some(value) = policy.criteria_gate_enabled {
1397 resolved.criteria_gate_enabled = value;
1398 }
1399 if let Some(fuse) = &policy.repeat_fuse {
1400 if let Some(value) = fuse.enabled {
1401 resolved.repeat_fuse.enabled = value;
1402 }
1403 if let Some(value) = fuse.deny_after {
1404 resolved.repeat_fuse.deny_after = value;
1405 }
1406 if let Some(value) = fuse.terminate_after {
1407 resolved.repeat_fuse.terminate_after = value;
1408 }
1409 }
1410 if let Some(watch) = &policy.entropy_watch {
1411 if let Some(value) = watch.enabled {
1412 resolved.entropy_watch.enabled = value;
1413 }
1414 if let Some(value) = watch.threshold_ppm {
1415 resolved.entropy_watch.threshold_ppm = value;
1416 }
1417 if let Some(value) = watch.hysteresis_ppm {
1418 resolved.entropy_watch.hysteresis_ppm = value;
1419 }
1420 if let Some(value) = watch.cooldown_turns {
1421 resolved.entropy_watch.cooldown_turns = value;
1422 }
1423 if let Some(value) = watch.notify_model {
1424 resolved.entropy_watch.notify_model = value;
1425 }
1426 }
1427 }
1428
1429 if resolved.max_turns == 0 {
1430 return Err(invalid("execution_policy.max_turns must be positive"));
1431 }
1432 if resolved.max_context_tokens == 0 {
1433 return Err(invalid(
1434 "execution_policy.max_context_tokens must be positive",
1435 ));
1436 }
1437 if resolved.max_total_tokens.get() == 0 {
1438 return Err(invalid(
1439 "execution_policy.max_total_tokens must be positive",
1440 ));
1441 }
1442 if resolved.max_wall_ms.is_some_and(|ms| ms.get() == 0) {
1443 return Err(invalid(
1444 "execution_policy.max_wall_ms must be positive; omit it for no wall-clock limit",
1445 ));
1446 }
1447 if resolved.repeat_fuse.enabled {
1448 if resolved.repeat_fuse.deny_after == 0 {
1449 return Err(invalid(
1450 "execution_policy.repeat_fuse.deny_after must be positive while the fuse is enabled",
1451 ));
1452 }
1453 if resolved.repeat_fuse.terminate_after <= resolved.repeat_fuse.deny_after {
1454 return Err(invalid(format!(
1455 "execution_policy.repeat_fuse.terminate_after ({}) must exceed deny_after ({}); \
1456 otherwise the run terminates before the deny ever takes effect",
1457 resolved.repeat_fuse.terminate_after, resolved.repeat_fuse.deny_after
1458 )));
1459 }
1460 }
1461 if resolved.entropy_watch.enabled
1462 && resolved.entropy_watch.hysteresis_ppm > resolved.entropy_watch.threshold_ppm
1463 {
1464 return Err(invalid(format!(
1465 "execution_policy.entropy_watch.hysteresis_ppm ({}) must not exceed threshold_ppm ({}); \
1466 a wider hysteresis than threshold can never re-arm",
1467 resolved.entropy_watch.hysteresis_ppm.get(),
1468 resolved.entropy_watch.threshold_ppm.get()
1469 )));
1470 }
1471 Ok(resolved)
1472}
1473
1474fn resolve_governance(
1475 policy: Option<&GovernancePolicy>,
1476 base: &ResolvedGovernancePolicy,
1477 rule_bound: u32,
1478) -> Result<ResolvedGovernancePolicy, WireRejection> {
1479 let mut resolved = base.clone();
1480 if let Some(policy) = policy {
1481 if let Some(action) = policy.default_action {
1482 resolved.default_action = action;
1483 }
1484 resolved.rules = policy.rules.clone();
1485 resolved.vetoed_tools = policy.vetoed_tools.clone();
1486 resolved.rate_limits = policy.rate_limits.clone();
1487 resolved.constraints = policy.constraints.clone();
1488 }
1489 validate_governance(&resolved, rule_bound)?;
1490 Ok(resolved)
1491}
1492
1493pub(super) fn validate_governance(
1496 policy: &ResolvedGovernancePolicy,
1497 rule_bound: u32,
1498) -> Result<(), WireRejection> {
1499 let total = policy.rules.len() + policy.rate_limits.len() + policy.constraints.len();
1500 if total > rule_bound as usize {
1501 return Err(too_many(format!(
1502 "governance policy declares {total} rules/limits/constraints; \
1503 the resolved governance_rules bound is {rule_bound}"
1504 )));
1505 }
1506 for rule in &policy.rules {
1507 if rule.tool_pattern.is_empty() {
1508 return Err(invalid("governance rule tool_pattern must not be empty"));
1509 }
1510 }
1511 for tool in &policy.vetoed_tools {
1512 if tool.is_empty() {
1513 return Err(invalid("governance vetoed_tools entries must not be empty"));
1514 }
1515 }
1516 for limit in &policy.rate_limits {
1517 if limit.tool.is_empty() {
1518 return Err(invalid("governance rate limit tool must not be empty"));
1519 }
1520 if limit.window_ms.get() == 0 {
1521 return Err(invalid(format!(
1522 "governance rate limit for {:?} has a zero window",
1523 limit.tool
1524 )));
1525 }
1526 }
1527 for constraint in &policy.constraints {
1528 constraint.validate().map_err(invalid)?;
1529 }
1530 Ok(())
1531}
1532
1533fn resolve_scheduler(
1534 policy: Option<&SchedulerPolicy>,
1535 base: &ResolvedSchedulerPolicy,
1536) -> Result<ResolvedSchedulerPolicy, WireRejection> {
1537 let mut resolved = *base;
1538 if let Some(policy) = policy {
1539 if let Some(value) = policy.critical_path_weight {
1540 resolved.critical_path_weight = value;
1541 }
1542 if let Some(value) = policy.fanout_weight {
1543 resolved.fanout_weight = value;
1544 }
1545 if let Some(value) = policy.age_weight {
1546 resolved.age_weight = value;
1547 }
1548 if let Some(value) = policy.token_cost_weight {
1549 resolved.token_cost_weight = value;
1550 }
1551 if let Some(value) = policy.deadline_weight {
1552 resolved.deadline_weight = value;
1553 }
1554 if let Some(value) = policy.process_priority_weight {
1555 resolved.process_priority_weight = value;
1556 }
1557 if let Some(value) = policy.resource_pressure_weight {
1558 resolved.resource_pressure_weight = value;
1559 }
1560 if let Some(value) = policy.budget_pressure_weight {
1561 resolved.budget_pressure_weight = value;
1562 }
1563 }
1564 for (label, weight) in [
1565 ("critical_path_weight", resolved.critical_path_weight),
1566 ("fanout_weight", resolved.fanout_weight),
1567 ("age_weight", resolved.age_weight),
1568 ("token_cost_weight", resolved.token_cost_weight),
1569 ("deadline_weight", resolved.deadline_weight),
1570 ("process_priority_weight", resolved.process_priority_weight),
1571 (
1572 "resource_pressure_weight",
1573 resolved.resource_pressure_weight,
1574 ),
1575 ("budget_pressure_weight", resolved.budget_pressure_weight),
1576 ] {
1577 if weight > MAX_SCHEDULER_WEIGHT {
1578 return Err(invalid(format!(
1579 "scheduler_policy.{label} is {weight}; the bound is {MAX_SCHEDULER_WEIGHT}"
1580 )));
1581 }
1582 }
1583 Ok(resolved)
1584}
1585
1586fn resolve_quota(
1587 quota: Option<&ResourceQuota>,
1588 base: &ResourceQuota,
1589) -> Result<ResourceQuota, WireRejection> {
1590 let resolved = quota.cloned().unwrap_or_else(|| base.clone());
1591 validate_quota(&resolved)?;
1592 Ok(resolved)
1593}
1594
1595pub(super) fn validate_quota(quota: &ResourceQuota) -> Result<(), WireRejection> {
1596 if let (Some(concurrent), Some(total)) =
1597 (quota.max_concurrent_subagents, quota.max_total_subagents)
1598 && concurrent > total
1599 {
1600 return Err(invalid(format!(
1601 "resource_quota.max_concurrent_subagents ({concurrent}) exceeds \
1602 max_total_subagents ({total}); the concurrent cap can never be reached"
1603 )));
1604 }
1605 if quota.max_spawn_depth == Some(0) {
1606 return Err(invalid(
1607 "resource_quota.max_spawn_depth must be positive; omit it for no depth cap",
1608 ));
1609 }
1610 if let Some(window) = "a.memory_writes_per_window
1611 && window.window_ms.get() == 0
1612 {
1613 return Err(invalid(
1614 "resource_quota.memory_writes_per_window.window_ms must be positive",
1615 ));
1616 }
1617 Ok(())
1618}
1619
1620fn resolve_budget_grant(grant: Option<&BudgetGrant>) -> Result<Option<BudgetGrant>, WireRejection> {
1621 let Some(grant) = grant else {
1622 return Ok(None);
1623 };
1624 if grant.reservation_id.is_empty() {
1625 return Err(invalid("budget_grant.reservation_id must not be empty"));
1626 }
1627 if grant.tokens.is_some_and(|tokens| tokens.get() == 0) {
1628 return Err(invalid(
1629 "budget_grant.tokens must be positive; a zero grant is a refused admission, \
1630 not a configuration",
1631 ));
1632 }
1633 Ok(Some(grant.clone()))
1634}
1635
1636fn resolve_signal(
1637 policy: Option<&SignalPolicy>,
1638 base: &ResolvedSignalPolicy,
1639) -> Result<ResolvedSignalPolicy, WireRejection> {
1640 let resolved = match policy {
1641 Some(policy) => ResolvedSignalPolicy {
1642 queue_max: policy.queue_max,
1643 ttl_ms: policy.ttl_ms,
1644 deadline_escalation: policy
1645 .deadline_escalation
1646 .unwrap_or(base.deadline_escalation),
1647 },
1648 None => *base,
1649 };
1650 validate_signal(&resolved)?;
1651 Ok(resolved)
1652}
1653
1654pub(super) fn validate_signal(policy: &ResolvedSignalPolicy) -> Result<(), WireRejection> {
1655 if policy.queue_max == 0 {
1656 return Err(invalid("signal_policy.queue_max must be positive"));
1657 }
1658 if policy.ttl_ms.is_some_and(|ttl| ttl.get() == 0) {
1659 return Err(invalid(
1660 "signal_policy.ttl_ms must be positive; omit it for no expiry",
1661 ));
1662 }
1663 Ok(())
1664}
1665
1666fn resolve_context(
1667 policy: Option<&ContextPolicy>,
1668 base: &ResolvedContextPolicy,
1669 max_context_tokens: u32,
1670) -> Result<ResolvedContextPolicy, WireRejection> {
1671 let mut resolved = base.clone();
1672 if let Some(policy) = policy {
1673 if let Some(value) = policy.pressure_thresholds_ppm {
1674 resolved.pressure_thresholds_ppm = value;
1675 }
1676 if let Some(value) = policy.target_after_compress_ppm {
1677 resolved.target_after_compress_ppm = value;
1678 }
1679 if let Some(value) = policy.preserve_recent_turns {
1680 resolved.preserve_recent_turns = value;
1681 }
1682 if let Some(value) = policy.renewal_carryover_ppm {
1683 resolved.renewal_carryover_ppm = value;
1684 }
1685 if let Some(value) = policy.collapse_old_assistant_narration {
1686 resolved.collapse_old_assistant_narration = value;
1687 }
1688 if let Some(value) = policy.idle_micro_compact_minutes {
1689 resolved.idle_micro_compact_minutes = value;
1690 }
1691 if let Some(value) = policy.knowledge_budget_ppm {
1692 resolved.knowledge_budget_ppm = value;
1693 }
1694 if let Some(value) = policy.prompt_budget {
1695 resolved.prompt_budget = value;
1696 }
1697 }
1698
1699 let t = &resolved.pressure_thresholds_ppm;
1700 if !(t.snip < t.micro && t.micro < t.collapse && t.collapse < t.auto && t.auto < t.renewal) {
1701 return Err(invalid(format!(
1702 "context_policy pressure thresholds must strictly increase \
1703 (snip {} < micro {} < collapse {} < auto {} < renewal {})",
1704 t.snip.get(),
1705 t.micro.get(),
1706 t.collapse.get(),
1707 t.auto.get(),
1708 t.renewal.get()
1709 )));
1710 }
1711 if resolved.target_after_compress_ppm >= t.snip {
1712 return Err(invalid(format!(
1713 "context_policy.target_after_compress_ppm ({}) must be below the snip threshold ({}); \
1714 otherwise a compression pass can never reach its own target",
1715 resolved.target_after_compress_ppm.get(),
1716 t.snip.get()
1717 )));
1718 }
1719 if resolved.preserve_recent_turns == 0 {
1720 return Err(invalid(
1721 "context_policy.preserve_recent_turns must be positive",
1722 ));
1723 }
1724 if resolved.knowledge_budget_ppm.get() + resolved.renewal_carryover_ppm.get() > Ppm::MAX_PPM {
1725 return Err(invalid(format!(
1726 "context_policy.knowledge_budget_ppm ({}) plus renewal_carryover_ppm ({}) exceeds \
1727 the whole context budget",
1728 resolved.knowledge_budget_ppm.get(),
1729 resolved.renewal_carryover_ppm.get()
1730 )));
1731 }
1732 if resolved.prompt_budget.reserved_tokens() >= max_context_tokens {
1733 return Err(invalid(format!(
1734 "context_policy.prompt_budget reserves {} tokens of a {max_context_tokens}-token \
1735 context window, leaving nothing to render",
1736 resolved.prompt_budget.reserved_tokens()
1737 )));
1738 }
1739 Ok(resolved)
1740}
1741
1742fn resolve_recovery(
1743 policy: Option<&RecoveryPolicy>,
1744 base: &ResolvedRecoveryPolicy,
1745) -> Result<ResolvedRecoveryPolicy, WireRejection> {
1746 let mut resolved = *base;
1747 if let Some(policy) = policy {
1748 if let Some(value) = policy.provider_recovery_attempts {
1749 resolved.provider_recovery_attempts = value;
1750 }
1751 if let Some(value) = policy.output_recovery_attempts {
1752 resolved.output_recovery_attempts = value;
1753 }
1754 if let Some(bounds) = &policy.tail_bounds {
1755 resolved.tail_bounds = apply_tail_bounds(bounds, resolved.tail_bounds);
1756 }
1757 }
1758 validate_recovery(&resolved)?;
1759 Ok(resolved)
1760}
1761
1762fn apply_tail_bounds(policy: &TailBoundsPolicy, base: TailBounds) -> TailBounds {
1765 TailBounds {
1766 soft_records: policy.soft_records.unwrap_or(base.soft_records),
1767 hard_records: policy.hard_records.unwrap_or(base.hard_records),
1768 soft_bytes: policy.soft_bytes.unwrap_or(base.soft_bytes),
1769 hard_bytes: policy.hard_bytes.unwrap_or(base.hard_bytes),
1770 }
1771}
1772
1773pub const MAX_RECOVERY_ATTEMPTS: u8 = 16;
1775
1776pub(super) fn validate_recovery(policy: &ResolvedRecoveryPolicy) -> Result<(), WireRejection> {
1777 for (label, value) in [
1778 (
1779 "provider_recovery_attempts",
1780 policy.provider_recovery_attempts,
1781 ),
1782 ("output_recovery_attempts", policy.output_recovery_attempts),
1783 ] {
1784 if value > MAX_RECOVERY_ATTEMPTS {
1785 return Err(invalid(format!(
1786 "recovery_policy.{label} is {value}; the bound is {MAX_RECOVERY_ATTEMPTS}"
1787 )));
1788 }
1789 }
1790 policy.tail_bounds.check().map_err(invalid)?;
1791 Ok(())
1792}
1793
1794fn resolve_payload(
1795 policy: Option<&PayloadPolicy>,
1796 base: &ResolvedPayloadPolicy,
1797) -> Result<ResolvedPayloadPolicy, WireRejection> {
1798 let mut resolved = *base;
1799 if let Some(policy) = policy {
1800 if let Some(value) = policy.inline_threshold_bytes {
1801 resolved.inline_threshold_bytes = value;
1802 }
1803 if let Some(value) = policy.preview_bytes {
1804 resolved.preview_bytes = value;
1805 }
1806 }
1807 if resolved.inline_threshold_bytes == 0 {
1808 return Err(invalid(
1809 "payload_policy.inline_threshold_bytes must be positive",
1810 ));
1811 }
1812 if resolved.preview_bytes == 0 || resolved.preview_bytes > resolved.inline_threshold_bytes {
1813 return Err(invalid(format!(
1814 "payload_policy.preview_bytes ({}) must be positive and no larger than \
1815 inline_threshold_bytes ({})",
1816 resolved.preview_bytes, resolved.inline_threshold_bytes
1817 )));
1818 }
1819 Ok(resolved)
1820}
1821
1822fn resolve_memory_policy(
1823 policy: Option<&MemoryPolicy>,
1824 base: &ResolvedMemoryPolicy,
1825) -> Result<ResolvedMemoryPolicy, WireRejection> {
1826 let mut resolved = *base;
1827 if let Some(policy) = policy {
1828 if let Some(value) = policy.stale_warning_days {
1829 resolved.stale_warning_days = value;
1830 }
1831 if let Some(value) = policy.retrieval_top_k {
1832 resolved.retrieval_top_k = value;
1833 }
1834 if let Some(value) = policy.validation_enabled {
1835 resolved.validation_enabled = value;
1836 }
1837 if let Some(value) = policy.max_content_bytes {
1838 resolved.max_content_bytes = value;
1839 }
1840 if let Some(value) = policy.max_name_length {
1841 resolved.max_name_length = value;
1842 }
1843 if let Some(value) = policy.promotion_recall_threshold {
1844 resolved.promotion_recall_threshold = Some(value);
1845 }
1846 }
1847 if resolved.retrieval_top_k == 0 {
1848 return Err(invalid("memory_policy.retrieval_top_k must be positive"));
1849 }
1850 if resolved.validation_enabled
1851 && (resolved.max_content_bytes == 0 || resolved.max_name_length == 0)
1852 {
1853 return Err(invalid(
1854 "memory_policy validation is enabled but max_content_bytes / max_name_length is zero, \
1855 which rejects every write",
1856 ));
1857 }
1858 if resolved
1859 .promotion_recall_threshold
1860 .is_some_and(|threshold| threshold.get() == 0)
1861 {
1862 return Err(invalid(
1863 "memory_policy.promotion_recall_threshold must be positive; \
1864 omit it to disable promotion suggestions",
1865 ));
1866 }
1867 Ok(resolved)
1868}
1869
1870fn resolve_features(
1871 policy: Option<&FeaturePolicy>,
1872 base: &ResolvedFeaturePolicy,
1873) -> Result<ResolvedFeaturePolicy, WireRejection> {
1874 let mut resolved = base.clone();
1875 if let Some(policy) = policy {
1876 if let Some(value) = policy.memory_enabled {
1877 resolved.memory_enabled = value;
1878 }
1879 if let Some(value) = policy.knowledge_enabled {
1880 resolved.knowledge_enabled = value;
1881 }
1882 if let Some(value) = policy.plan_tool_enabled {
1883 resolved.plan_tool_enabled = value;
1884 }
1885 if let Some(ids) = &policy.stable_core_tool_ids {
1886 resolved.stable_core_tool_ids = ids.clone();
1887 }
1888 }
1889 for id in &resolved.stable_core_tool_ids {
1890 if id.is_empty() {
1891 return Err(invalid(
1892 "feature_policy.stable_core_tool_ids entries must not be empty",
1893 ));
1894 }
1895 }
1896 Ok(resolved)
1897}
1898
1899fn resolve_tool_catalog(
1900 catalog: &[ToolSchema],
1901 bound: u32,
1902) -> Result<Vec<ToolSchema>, WireRejection> {
1903 if catalog.len() > bound as usize {
1904 return Err(too_many(format!(
1905 "tool_catalog declares {} tools; the resolved bound is {bound}",
1906 catalog.len()
1907 )));
1908 }
1909 for (index, tool) in catalog.iter().enumerate() {
1910 if tool.name.is_empty() {
1911 return Err(invalid("tool_catalog entry has an empty name"));
1912 }
1913 if catalog[..index].iter().any(|other| other.name == tool.name) {
1914 return Err(invalid(format!(
1915 "tool_catalog declares {:?} twice; a catalog is a set, and a duplicate makes \
1916 dispatch order-dependent",
1917 tool.name
1918 )));
1919 }
1920 }
1921 Ok(catalog.to_vec())
1922}
1923
1924fn resolve_skill_catalog(
1925 catalog: &[SkillMetadata],
1926 bound: u32,
1927 capability_grants_bound: u32,
1928 tools: &[ToolSchema],
1929) -> Result<Vec<SkillMetadata>, WireRejection> {
1930 if catalog.len() > bound as usize {
1931 return Err(too_many(format!(
1932 "skill_catalog declares {} skills; the resolved bound is {bound}",
1933 catalog.len()
1934 )));
1935 }
1936 for (index, skill) in catalog.iter().enumerate() {
1937 if skill.name.is_empty() {
1938 return Err(invalid("skill_catalog entry has an empty name"));
1939 }
1940 if catalog[..index]
1941 .iter()
1942 .any(|other| other.name == skill.name)
1943 {
1944 return Err(invalid(format!(
1945 "skill_catalog declares {:?} twice",
1946 skill.name
1947 )));
1948 }
1949 if skill
1950 .effort
1951 .is_some_and(|effort| !(1..=5).contains(&effort))
1952 {
1953 return Err(invalid(format!(
1954 "skill {:?} declares effort {}; the range is 1..=5",
1955 skill.name,
1956 skill.effort.unwrap_or_default()
1957 )));
1958 }
1959 if skill.capability_grants.len() > capability_grants_bound as usize {
1960 return Err(too_many(format!(
1961 "skill {:?} declares {} capability grants; the resolved bound is {capability_grants_bound}",
1962 skill.name,
1963 skill.capability_grants.len()
1964 )));
1965 }
1966 for tool in &skill.allowed_tools {
1967 if !tools.iter().any(|declared| &declared.name == tool) {
1968 return Err(invalid(format!(
1969 "skill {:?} allows {tool:?}, which the tool catalog does not declare; \
1970 a skill can only ever narrow the catalog",
1971 skill.name
1972 )));
1973 }
1974 }
1975 }
1976 Ok(catalog.to_vec())
1977}
1978
1979fn resolve_host_effect_support(
1980 support: &HostEffectSupport,
1981) -> Result<HostEffectSupport, WireRejection> {
1982 for (index, kind) in support.supported.iter().enumerate() {
1983 if support.supported[..index].contains(kind) {
1984 return Err(invalid(format!(
1985 "host_effect_support declares {kind:?} twice"
1986 )));
1987 }
1988 }
1989 Ok(support.clone())
1990}
1991
1992#[cfg(test)]
1997mod tests {
1998 use super::*;
1999 use crate::runtime::kernel::wire::command::{
2000 HostCommand, LivePolicyPatch, ParamConstraint, PolicyRule, RateLimitSpec,
2001 ReplaceGovernancePolicy, ReplaceRecoveryPolicy, ReplaceSignalPolicy, RequiredParam,
2002 TightenResourceQuota,
2003 };
2004 use crate::runtime::kernel::wire::effect::MemoryCapabilities;
2005 use crate::runtime::kernel::wire::scalar::{BoundedJson, MemoryBindingId, SCALAR_ERROR_MARKER};
2006 use serde_json::{Value, json};
2007 use std::collections::BTreeSet;
2008 use std::fs;
2009 use std::path::PathBuf;
2010
2011 fn ppm(value: u32) -> Ppm {
2016 Ppm::new(value).unwrap()
2017 }
2018
2019 fn minimal_config() -> OperationConfig {
2020 OperationConfig {
2021 host_effect_support: HostEffectSupport::new([EffectKindTag::CallProvider]),
2022 ..OperationConfig::default()
2023 }
2024 }
2025
2026 fn defaults() -> ConfigDefaults {
2027 ConfigDefaults::default()
2028 }
2029
2030 fn fixture_dir() -> PathBuf {
2031 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/kernel-wire")
2032 }
2033
2034 fn fixtures_with_prefix(prefix: &str) -> Vec<(String, Value)> {
2035 let dir = fixture_dir();
2036 let mut names: Vec<String> = fs::read_dir(&dir)
2037 .unwrap_or_else(|e| panic!("failed to read {}: {e}", dir.display()))
2038 .map(|entry| {
2039 entry
2040 .expect("dir entry")
2041 .file_name()
2042 .to_string_lossy()
2043 .to_string()
2044 })
2045 .filter(|name| name.ends_with(".json") && name.starts_with(prefix))
2046 .collect();
2047 names.sort();
2048 assert!(!names.is_empty(), "no {prefix}*.json fixtures");
2049 names
2050 .into_iter()
2051 .map(|name| {
2052 let raw = fs::read_to_string(dir.join(&name))
2053 .unwrap_or_else(|e| panic!("failed to read {name}: {e}"));
2054 let value: Value = serde_json::from_str(&raw)
2055 .unwrap_or_else(|e| panic!("{name} is not JSON: {e}"));
2056 (name, value)
2057 })
2058 .collect()
2059 }
2060
2061 fn all_keys(value: &Value, out: &mut BTreeSet<String>) {
2062 match value {
2063 Value::Object(map) => {
2064 for (key, child) in map {
2065 out.insert(key.clone());
2066 all_keys(child, out);
2067 }
2068 }
2069 Value::Array(items) => items.iter().for_each(|item| all_keys(item, out)),
2070 _ => {}
2071 }
2072 }
2073
2074 fn fully_populated_config() -> OperationConfig {
2076 OperationConfig {
2077 execution_policy: Some(ExecutionPolicy {
2078 max_context_tokens: Some(200_000),
2079 max_turns: Some(40),
2080 max_total_tokens: Some(WireU64::new(2_000_000)),
2081 max_wall_ms: Some(WireU64::new(600_000)),
2082 criteria_gate_enabled: Some(true),
2083 repeat_fuse: Some(RepeatFusePolicy {
2084 enabled: Some(true),
2085 deny_after: Some(4),
2086 terminate_after: Some(7),
2087 }),
2088 entropy_watch: Some(EntropyWatchPolicy {
2089 enabled: Some(true),
2090 threshold_ppm: Some(ppm(650_000)),
2091 hysteresis_ppm: Some(ppm(100_000)),
2092 cooldown_turns: Some(3),
2093 notify_model: Some(true),
2094 }),
2095 }),
2096 governance_policy: Some(GovernancePolicy {
2097 default_action: Some(PolicyAction::AskUser),
2098 rules: vec![PolicyRule {
2099 tool_pattern: "shell.*".to_string(),
2100 action: PolicyAction::Deny,
2101 }],
2102 vetoed_tools: vec!["rm".to_string()],
2103 rate_limits: vec![RateLimitSpec {
2104 tool: "search".to_string(),
2105 max_calls: 10,
2106 window_ms: WireU64::new(60_000),
2107 }],
2108 constraints: vec![ParamConstraint::Required(RequiredParam {
2109 tool: "write".to_string(),
2110 param_path: "destination".to_string(),
2111 })],
2112 }),
2113 scheduler_policy: Some(SchedulerPolicy {
2114 critical_path_weight: Some(900_000),
2115 fanout_weight: Some(9_000),
2116 age_weight: Some(900),
2117 token_cost_weight: Some(2),
2118 deadline_weight: None,
2119 process_priority_weight: None,
2120 resource_pressure_weight: None,
2121 budget_pressure_weight: None,
2122 }),
2123 resource_quota: Some(ResourceQuota {
2124 max_concurrent_subagents: Some(2),
2125 max_total_subagents: Some(8),
2126 max_spawn_depth: Some(2),
2127 max_workflow_nodes: Some(64),
2128 memory_writes_per_window: Some(RateWindow {
2129 max_events: 4,
2130 window_ms: WireU64::new(60_000),
2131 }),
2132 }),
2133 budget_grant: Some(BudgetGrant {
2134 reservation_id: "res-1".to_string(),
2135 tokens: Some(WireU64::new(500_000)),
2136 subagents: Some(4),
2137 rounds: Some(3),
2138 }),
2139 signal_policy: Some(SignalPolicy {
2140 queue_max: 32,
2141 ttl_ms: Some(WireU64::new(30_000)),
2142 deadline_escalation: Some(true),
2143 }),
2144 context_policy: Some(ContextPolicy {
2145 pressure_thresholds_ppm: Some(PressureThresholds {
2146 snip: ppm(700_000),
2147 micro: ppm(800_000),
2148 collapse: ppm(900_000),
2149 auto: ppm(950_000),
2150 renewal: ppm(980_000),
2151 }),
2152 target_after_compress_ppm: Some(ppm(650_000)),
2153 preserve_recent_turns: Some(3),
2154 renewal_carryover_ppm: Some(ppm(50_000)),
2155 collapse_old_assistant_narration: Some(true),
2156 idle_micro_compact_minutes: Some(45),
2157 knowledge_budget_ppm: Some(ppm(250_000)),
2158 prompt_budget: Some(PromptBudget {
2159 prompt_overhead_tokens: 1_200,
2160 output_reserve_tokens: 4_000,
2161 safety_margin_tokens: 500,
2162 }),
2163 }),
2164 recovery_policy: Some(RecoveryPolicy {
2165 provider_recovery_attempts: Some(2),
2166 output_recovery_attempts: Some(1),
2167 tail_bounds: Some(TailBoundsPolicy {
2168 soft_records: Some(WireU64::new(8)),
2169 hard_records: Some(WireU64::new(16)),
2170 soft_bytes: Some(WireU64::new(4_096)),
2171 hard_bytes: Some(WireU64::new(65_536)),
2172 }),
2173 }),
2174 payload_policy: Some(PayloadPolicy {
2175 inline_threshold_bytes: Some(32_768),
2176 preview_bytes: Some(1_024),
2177 }),
2178 kernel_limits: Some(KernelLimits {
2179 max_input_bytes: Some(1_048_576),
2180 max_json_depth: Some(32),
2181 max_collection_entries: Some(4_096),
2182 collection_limits: Some(CollectionLimits {
2183 tool_catalog: Some(256),
2184 skill_catalog: Some(64),
2185 knowledge_entries: Some(512),
2186 initial_messages: Some(1_024),
2187 capability_grants: Some(128),
2188 governance_rules: Some(64),
2189 }),
2190 }),
2191 memory_access: Some(MemoryAccessBinding {
2192 binding_id: MemoryBindingId::new("mem-binding-1").unwrap(),
2193 capabilities: MemoryCapabilities {
2194 read: true,
2195 write: true,
2196 },
2197 }),
2198 memory_policy: Some(MemoryPolicy {
2199 stale_warning_days: Some(7),
2200 retrieval_top_k: Some(8),
2201 validation_enabled: Some(true),
2202 max_content_bytes: Some(20_000),
2203 max_name_length: Some(120),
2204 promotion_recall_threshold: Some(WireU64::new(3)),
2205 }),
2206 tool_catalog: vec![
2207 ToolSchema {
2208 name: "search".to_string(),
2209 description: "search the corpus".to_string(),
2210 parameters: BoundedJson::new(json!({"type": "object"})).unwrap(),
2211 },
2212 ToolSchema {
2213 name: "write".to_string(),
2214 description: "write a file".to_string(),
2215 parameters: BoundedJson::new(json!({"type": "object"})).unwrap(),
2216 },
2217 ],
2218 verification_contracts: vec![VerificationContract {
2219 contract_id: "brief-quality-primary".to_string(),
2220 phases: vec![
2221 MilestonePhase {
2222 phase_id: "collect".to_string(),
2223 unlocks: vec!["research".to_string()],
2224 },
2225 MilestonePhase {
2226 phase_id: "write".to_string(),
2227 unlocks: vec!["write".to_string()],
2228 },
2229 ],
2230 }],
2231 skill_catalog: vec![SkillMetadata {
2232 name: "research".to_string(),
2233 description: "run a literature sweep".to_string(),
2234 when_to_use: Some("sources,citations".to_string()),
2235 allowed_tools: vec!["search".to_string()],
2236 capability_grants: Vec::new(),
2237 effort: Some(3),
2238 estimated_tokens: Some(900),
2239 }],
2240 feature_policy: Some(FeaturePolicy {
2241 memory_enabled: Some(true),
2242 knowledge_enabled: Some(true),
2243 plan_tool_enabled: Some(true),
2244 stable_core_tool_ids: Some(vec!["search".to_string()]),
2245 }),
2246 host_effect_support: HostEffectSupport::new(EffectKindTag::ALL),
2249 }
2250 }
2251
2252 #[test]
2257 fn deleted_and_host_side_config_appears_in_no_new_type() {
2258 const BANNED: [&str; 16] = [
2261 "memory_path",
2262 "tokenizer",
2263 "host_effect_retry_attempts",
2264 "spool_dir",
2265 "spool_ref",
2266 "spool_threshold_bytes",
2267 "spool_preview_bytes",
2268 "archive_ref",
2269 "checkpoint_path",
2270 "endpoint",
2271 "api_key",
2272 "base_url",
2273 "provider",
2274 "session_id",
2275 "parent_session_id",
2276 "path_root",
2277 ];
2278
2279 let mut keys = BTreeSet::new();
2280 all_keys(
2281 &serde_json::to_value(fully_populated_config()).unwrap(),
2282 &mut keys,
2283 );
2284 all_keys(
2285 &serde_json::to_value(fully_populated_config().resolve(&defaults()).unwrap()).unwrap(),
2286 &mut keys,
2287 );
2288
2289 for banned in BANNED {
2290 assert!(
2291 !keys.contains(banned),
2292 "the canonical configuration still carries the removed field {banned:?}"
2293 );
2294 }
2295 }
2296
2297 #[test]
2298 fn no_sub_policy_carries_its_own_version_marker() {
2299 let mut keys = BTreeSet::new();
2301 all_keys(
2302 &serde_json::to_value(fully_populated_config()).unwrap(),
2303 &mut keys,
2304 );
2305 assert!(!keys.contains("version"));
2306 assert!(!keys.contains("abi_version"));
2307
2308 for probe in [
2309 json!({ "version": 1, "critical_path_weight": 10 }),
2310 json!({ "version": 1 }),
2311 ] {
2312 assert!(serde_json::from_value::<SchedulerPolicy>(probe).is_err());
2313 }
2314 assert!(
2315 serde_json::from_value::<ContextPolicy>(json!({ "version": 1 })).is_err(),
2316 "context policy must not accept a per-policy version"
2317 );
2318 }
2319
2320 #[test]
2321 fn there_is_no_scheduler_budget_setter_shaped_hole_in_the_live_union() {
2322 let mut patch_keys = BTreeSet::new();
2325 for patch in [
2326 LivePolicyPatch::ReplaceSignalPolicy(ReplaceSignalPolicy {
2327 policy: SignalPolicy {
2328 queue_max: 8,
2329 ttl_ms: Some(WireU64::new(1_000)),
2330 deadline_escalation: Some(true),
2331 },
2332 }),
2333 LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
2334 policy: GovernancePolicy::default(),
2335 }),
2336 LivePolicyPatch::TightenResourceQuota(TightenResourceQuota {
2337 max_concurrent_subagents: Some(1),
2338 max_total_subagents: Some(2),
2339 max_spawn_depth: Some(1),
2340 max_workflow_nodes: Some(4),
2341 }),
2342 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
2343 policy: RecoveryPolicy::default(),
2344 }),
2345 ] {
2346 all_keys(&serde_json::to_value(&patch).unwrap(), &mut patch_keys);
2347 }
2348 assert!(
2349 !patch_keys.contains("max_wall_ms"),
2350 "a wall-clock budget must not be reachable through a policy patch"
2351 );
2352 }
2353
2354 #[test]
2359 fn setup_only_configuration_is_unreachable_from_the_live_patch_union() {
2360 let setup_only = [
2361 "execution_policy",
2362 "scheduler_policy",
2363 "context_policy",
2364 "payload_policy",
2365 "kernel_limits",
2366 "memory_access",
2367 "memory_policy",
2368 "tool_catalog",
2369 "skill_catalog",
2370 "feature_policy",
2371 "host_effect_support",
2372 "budget_grant",
2373 "stable_core_tool_ids",
2374 "knowledge_budget_ppm",
2375 "prompt_budget",
2376 "binding_id",
2377 ];
2378
2379 let mut patch_keys = BTreeSet::new();
2380 for patch in [
2381 LivePolicyPatch::ReplaceSignalPolicy(ReplaceSignalPolicy {
2382 policy: SignalPolicy {
2383 queue_max: 8,
2384 ttl_ms: None,
2385 deadline_escalation: None,
2386 },
2387 }),
2388 LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
2389 policy: GovernancePolicy {
2390 default_action: Some(PolicyAction::Deny),
2391 rules: vec![PolicyRule {
2392 tool_pattern: "*".to_string(),
2393 action: PolicyAction::Deny,
2394 }],
2395 vetoed_tools: vec!["rm".to_string()],
2396 rate_limits: vec![RateLimitSpec {
2397 tool: "search".to_string(),
2398 max_calls: 1,
2399 window_ms: WireU64::new(1_000),
2400 }],
2401 constraints: Vec::new(),
2402 },
2403 }),
2404 LivePolicyPatch::TightenResourceQuota(TightenResourceQuota {
2405 max_concurrent_subagents: Some(1),
2406 max_total_subagents: Some(2),
2407 max_spawn_depth: Some(1),
2408 max_workflow_nodes: Some(4),
2409 }),
2410 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
2411 policy: RecoveryPolicy {
2412 provider_recovery_attempts: Some(1),
2413 output_recovery_attempts: Some(1),
2414 tail_bounds: None,
2415 },
2416 }),
2417 ] {
2418 all_keys(&serde_json::to_value(&patch).unwrap(), &mut patch_keys);
2419 }
2420
2421 for key in setup_only {
2422 assert!(
2423 !patch_keys.contains(key),
2424 "{key:?} is setup-only but is reachable through LivePolicyPatch"
2425 );
2426 }
2427 }
2428
2429 #[test]
2434 fn every_configuration_struct_rejects_unknown_fields() {
2435 assert!(
2436 serde_json::from_value::<OperationConfig>(json!({
2437 "host_effect_support": { "supported": [] },
2438 "tokenizer": "cl100k",
2439 }))
2440 .is_err()
2441 );
2442 assert!(
2443 serde_json::from_value::<MemoryPolicy>(json!({ "memory_path": "/tmp/mem" })).is_err(),
2444 "memory_path moved to the host MemoryStore config and must not decode"
2445 );
2446 assert!(
2447 serde_json::from_value::<PayloadPolicy>(json!({ "spool_dir": "/tmp/.spool" })).is_err()
2448 );
2449 assert!(
2450 serde_json::from_value::<ExecutionPolicy>(json!({ "max_tokens": 1 })).is_err(),
2451 "the context-window axis must reject its removed field name"
2452 );
2453 assert!(serde_json::from_value::<KernelLimits>(json!({ "max_bytes": 1 })).is_err());
2454 assert!(
2455 serde_json::from_value::<HostEffectSupport>(json!({ "supported": [], "all": true }))
2456 .is_err()
2457 );
2458 assert!(
2459 serde_json::from_value::<HostEffectSupport>(json!({})).is_err(),
2460 "DEC-8 support declaration is mandatory, not defaulted"
2461 );
2462 assert!(
2463 serde_json::from_value::<OperationConfig>(json!({})).is_err(),
2464 "a config without host_effect_support is not a config"
2465 );
2466 }
2467
2468 #[test]
2469 fn policy_ratios_are_fixed_point_not_floats() {
2470 assert!(
2471 serde_json::from_value::<ContextPolicy>(json!({ "knowledge_budget_ppm": 0.25 }))
2472 .is_err()
2473 );
2474 assert!(
2475 serde_json::from_value::<EntropyWatchPolicy>(json!({ "threshold_ppm": 0.65 })).is_err()
2476 );
2477 assert!(
2478 serde_json::from_value::<ContextPolicy>(json!({ "knowledge_budget_ppm": 250_000 }))
2479 .is_ok()
2480 );
2481 assert!(
2482 serde_json::from_value::<ContextPolicy>(json!({ "knowledge_budget_ppm": 1_000_001 }))
2483 .is_err(),
2484 "a ratio above 1.0 is not a ratio"
2485 );
2486 }
2487
2488 #[test]
2489 fn sixty_four_bit_config_axes_travel_as_decimal_strings() {
2490 assert!(
2491 serde_json::from_value::<ExecutionPolicy>(json!({ "max_total_tokens": 1_000_000 }))
2492 .is_err()
2493 );
2494 assert!(
2495 serde_json::from_value::<ExecutionPolicy>(json!({ "max_total_tokens": "1000000" }))
2496 .is_ok()
2497 );
2498 assert!(
2499 serde_json::from_value::<BudgetGrant>(
2500 json!({ "reservation_id": "r", "tokens": 5_000 })
2501 )
2502 .is_err()
2503 );
2504 }
2505
2506 #[test]
2507 fn the_effect_support_tag_set_is_closed() {
2508 assert!(
2509 serde_json::from_value::<HostEffectSupport>(
2510 json!({ "supported": ["spool_large_result"] })
2511 )
2512 .is_err(),
2513 "SpoolLargeResult is deleted; declaring support for it must not decode"
2514 );
2515 assert!(
2516 serde_json::from_value::<HostEffectSupport>(json!({ "supported": ["load_payload"] }))
2517 .is_ok()
2518 );
2519 assert_eq!(EffectKindTag::ALL.len(), 11);
2520 }
2521
2522 #[test]
2527 fn resolution_removes_every_implicit_default() {
2528 let resolved = minimal_config().resolve(&defaults()).unwrap();
2529 let value = serde_json::to_value(&resolved).unwrap();
2530
2531 assert_eq!(value["execution_policy"]["max_turns"], json!(25));
2533 assert_eq!(
2534 value["context_policy"]["knowledge_budget_ppm"],
2535 json!(250_000)
2536 );
2537 assert_eq!(value["payload_policy"]["preview_bytes"], json!(2_048));
2538 assert_eq!(value["memory_policy"]["retrieval_top_k"], json!(5));
2539 assert!(value.get("abi_version").is_none());
2540
2541 let text = serde_json::to_string(&resolved).unwrap();
2543 let back: ResolvedOperationConfig = serde_json::from_str(&text).unwrap();
2544 assert_eq!(back, resolved);
2545 }
2546
2547 #[test]
2548 fn a_resolved_config_does_not_move_when_the_binary_defaults_move() {
2549 let resolved = minimal_config().resolve(&defaults()).unwrap();
2550
2551 let mut newer = defaults();
2552 newer.baseline.execution_policy.max_turns = 999;
2553 newer.baseline.context_policy.knowledge_budget_ppm = ppm(1_000);
2554
2555 let text = serde_json::to_string(&resolved).unwrap();
2557 let replayed: ResolvedOperationConfig = serde_json::from_str(&text).unwrap();
2558 assert_eq!(replayed.execution_policy.max_turns, 25);
2559 assert_eq!(
2560 minimal_config()
2561 .resolve(&newer)
2562 .unwrap()
2563 .execution_policy
2564 .max_turns,
2565 999
2566 );
2567 }
2568
2569 #[test]
2570 fn a_fully_populated_config_resolves_to_exactly_what_it_declared() {
2571 let resolved = fully_populated_config().resolve(&defaults()).unwrap();
2572 assert_eq!(resolved.execution_policy.max_turns, 40);
2573 assert_eq!(resolved.execution_policy.repeat_fuse.terminate_after, 7);
2574 assert_eq!(resolved.scheduler_policy.token_cost_weight, 2);
2575 assert_eq!(resolved.payload_policy.inline_threshold_bytes, 32_768);
2576 assert_eq!(resolved.kernel_limits.collection_limits.tool_catalog, 256);
2577 assert_eq!(resolved.memory_policy.retrieval_top_k, 8);
2578 assert_eq!(resolved.tool_catalog.len(), 2);
2579 assert!(resolved.feature_policy.memory_enabled);
2580 }
2581
2582 #[test]
2587 fn one_illegal_field_rejects_the_whole_configure_and_changes_nothing() {
2588 let mut config = fully_populated_config();
2589 config
2590 .execution_policy
2591 .as_mut()
2592 .unwrap()
2593 .repeat_fuse
2594 .as_mut()
2595 .unwrap()
2596 .terminate_after = Some(2); let before = config.clone();
2599 let rejection = config.resolve(&defaults()).expect_err("must reject");
2600 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
2601 assert!(rejection.message.contains("terminate_after"));
2602 assert_eq!(config, before);
2604 }
2605
2606 #[test]
2607 fn cross_field_relationships_are_all_enforced() {
2608 let cases: Vec<(&str, Box<dyn Fn(&mut OperationConfig)>, &str)> = vec![
2609 (
2610 "thresholds must strictly increase",
2611 Box::new(|config| {
2612 config
2613 .context_policy
2614 .as_mut()
2615 .unwrap()
2616 .pressure_thresholds_ppm
2617 .as_mut()
2618 .unwrap()
2619 .micro = ppm(600_000);
2620 }),
2621 "strictly increase",
2622 ),
2623 (
2624 "compression target must sit below snip",
2625 Box::new(|config| {
2626 config
2627 .context_policy
2628 .as_mut()
2629 .unwrap()
2630 .target_after_compress_ppm = Some(ppm(750_000));
2631 }),
2632 "target_after_compress_ppm",
2633 ),
2634 (
2635 "prompt reserves cannot consume the window",
2636 Box::new(|config| {
2637 config.context_policy.as_mut().unwrap().prompt_budget = Some(PromptBudget {
2638 prompt_overhead_tokens: 200_000,
2639 output_reserve_tokens: 1,
2640 safety_margin_tokens: 0,
2641 });
2642 }),
2643 "leaving nothing to render",
2644 ),
2645 (
2646 "preview cannot exceed the inline threshold",
2647 Box::new(|config| {
2648 config.payload_policy.as_mut().unwrap().preview_bytes = Some(65_536);
2649 }),
2650 "preview_bytes",
2651 ),
2652 (
2653 "concurrent cap cannot exceed the cumulative cap",
2654 Box::new(|config| {
2655 config
2656 .resource_quota
2657 .as_mut()
2658 .unwrap()
2659 .max_concurrent_subagents = Some(99);
2660 }),
2661 "max_concurrent_subagents",
2662 ),
2663 (
2664 "hysteresis cannot exceed the threshold",
2665 Box::new(|config| {
2666 config
2667 .execution_policy
2668 .as_mut()
2669 .unwrap()
2670 .entropy_watch
2671 .as_mut()
2672 .unwrap()
2673 .hysteresis_ppm = Some(ppm(900_000));
2674 }),
2675 "hysteresis_ppm",
2676 ),
2677 (
2678 "a skill cannot allow a tool the catalog never declared",
2679 Box::new(|config| {
2680 config.skill_catalog[0].allowed_tools = vec!["undeclared".to_string()];
2681 }),
2682 "only ever narrow the catalog",
2683 ),
2684 (
2685 "a skill cannot exceed the capability-grants collection limit",
2686 Box::new(|config| {
2687 config
2688 .kernel_limits
2689 .as_mut()
2690 .unwrap()
2691 .collection_limits
2692 .as_mut()
2693 .unwrap()
2694 .capability_grants = Some(0);
2695 config.skill_catalog[0].capability_grants =
2696 vec![crate::types::capability::Capability {
2697 id: crate::types::capability::CapabilityId("read-src".into()),
2698 kind: crate::types::capability::CapabilityKind::Tool,
2699 resource: crate::types::capability::ResourceSelector(
2700 "/repo/src/**".into(),
2701 ),
2702 actions: crate::types::capability::ActionSet(
2703 ["read".into()].into_iter().collect(),
2704 ),
2705 constraints: crate::types::capability::ConstraintSet::default(),
2706 lease: None,
2707 delegatable: false,
2708 issuer: crate::types::capability::Principal("root".into()),
2709 }];
2710 }),
2711 "capability grants",
2712 ),
2713 (
2714 "the exposure baseline cannot name an undeclared tool",
2715 Box::new(|config| {
2716 config.feature_policy.as_mut().unwrap().stable_core_tool_ids =
2717 Some(vec!["ghost".to_string()]);
2718 }),
2719 "tool catalog does not declare",
2720 ),
2721 (
2722 "memory cannot be enabled without a binding",
2723 Box::new(|config| {
2724 config.memory_access = None;
2725 }),
2726 "no memory_access binding",
2727 ),
2728 (
2729 "a duplicate tool makes dispatch order-dependent",
2730 Box::new(|config| {
2731 config.tool_catalog[1].name = "search".to_string();
2732 }),
2733 "twice",
2734 ),
2735 ];
2736
2737 for (label, mutate, expected_fragment) in cases {
2738 let mut config = fully_populated_config();
2739 mutate(&mut config);
2740 let before = config.clone();
2741 let rejection = config
2742 .resolve(&defaults())
2743 .err()
2744 .unwrap_or_else(|| panic!("{label}: expected a rejection"));
2745 assert!(
2746 rejection.message.contains(expected_fragment),
2747 "{label}: message {:?} does not name the broken relationship {expected_fragment:?}",
2748 rejection.message
2749 );
2750 assert_eq!(config, before, "{label}: rejection mutated the input");
2751 }
2752 }
2753
2754 #[test]
2755 fn cross_field_rejections_name_the_relationship_they_broke() {
2756 let mut config = fully_populated_config();
2757 config
2758 .context_policy
2759 .as_mut()
2760 .unwrap()
2761 .target_after_compress_ppm = Some(ppm(750_000));
2762 let rejection = config.resolve(&defaults()).expect_err("must reject");
2763 assert!(
2764 rejection.message.contains("target_after_compress_ppm")
2765 && rejection.message.contains("snip"),
2766 "unhelpful message: {}",
2767 rejection.message
2768 );
2769 }
2770
2771 #[test]
2776 fn operation_limits_may_only_tighten_the_bootstrap_ceiling() {
2777 let defaults = ConfigDefaults::new(KernelBootstrapLimits {
2778 absolute_max_input_bytes: 1_048_576,
2779 absolute_max_json_depth: 32,
2780 absolute_max_collection_entries: 1_024,
2781 });
2782
2783 let tighter = OperationConfig {
2784 kernel_limits: Some(KernelLimits {
2785 max_input_bytes: Some(65_536),
2786 max_json_depth: Some(16),
2787 max_collection_entries: Some(256),
2788 collection_limits: None,
2789 }),
2790 ..minimal_config()
2791 };
2792 let resolved = tighter.resolve(&defaults).unwrap();
2793 assert_eq!(resolved.kernel_limits.max_input_bytes, 65_536);
2794 assert_eq!(resolved.kernel_limits.collection_limits.tool_catalog, 256);
2795
2796 let at_ceiling = OperationConfig {
2798 kernel_limits: Some(KernelLimits {
2799 max_input_bytes: Some(1_048_576),
2800 max_json_depth: Some(32),
2801 max_collection_entries: Some(1_024),
2802 collection_limits: None,
2803 }),
2804 ..minimal_config()
2805 };
2806 assert!(at_ceiling.resolve(&defaults).is_ok());
2807 }
2808
2809 #[test]
2810 fn widening_any_bootstrap_axis_is_rejected_with_the_direction_named() {
2811 let defaults = ConfigDefaults::new(KernelBootstrapLimits {
2812 absolute_max_input_bytes: 1_048_576,
2813 absolute_max_json_depth: 32,
2814 absolute_max_collection_entries: 1_024,
2815 });
2816 for limits in [
2817 KernelLimits {
2818 max_input_bytes: Some(2_097_152),
2819 ..KernelLimits::default()
2820 },
2821 KernelLimits {
2822 max_json_depth: Some(64),
2823 ..KernelLimits::default()
2824 },
2825 KernelLimits {
2826 max_collection_entries: Some(65_536),
2827 ..KernelLimits::default()
2828 },
2829 ] {
2830 let config = OperationConfig {
2831 kernel_limits: Some(limits),
2832 ..minimal_config()
2833 };
2834 let rejection = config.resolve(&defaults).expect_err("widening rejected");
2835 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
2836 assert!(
2837 rejection.message.contains("may only tighten"),
2838 "unexpected message: {}",
2839 rejection.message
2840 );
2841 }
2842 }
2843
2844 #[test]
2845 fn a_named_collection_bound_may_only_tighten_the_resolved_entry_ceiling() {
2846 let defaults = defaults();
2847 let config = OperationConfig {
2848 kernel_limits: Some(KernelLimits {
2849 max_collection_entries: Some(128),
2850 collection_limits: Some(CollectionLimits {
2851 tool_catalog: Some(512),
2852 ..CollectionLimits::default()
2853 }),
2854 ..KernelLimits::default()
2855 }),
2856 ..minimal_config()
2857 };
2858 let rejection = config.resolve(&defaults).expect_err("must reject");
2859 assert!(rejection.message.contains("collection_limits.tool_catalog"));
2860 }
2861
2862 #[test]
2863 fn per_collection_bounds_are_enforced_against_the_declared_catalog() {
2864 let mut config = fully_populated_config();
2865 config
2866 .kernel_limits
2867 .as_mut()
2868 .unwrap()
2869 .collection_limits
2870 .as_mut()
2871 .unwrap()
2872 .tool_catalog = Some(1);
2873 let rejection = config.resolve(&defaults()).expect_err("must reject");
2874 assert_eq!(rejection.kind, WireRejectionKind::CollectionTooLarge);
2875 assert!(rejection.message.contains("tool_catalog"));
2876 }
2877
2878 #[test]
2883 fn every_declared_capability_requires_its_effect_kind() {
2884 let full = fully_populated_config();
2892 full.resolve(&defaults())
2893 .expect("declaring every kind satisfies every trigger");
2894
2895 for dropped in EffectKindTag::ALL
2896 .into_iter()
2897 .filter(|kind| *kind != EffectKindTag::MeasurePrompt)
2898 {
2899 let mut config = full.clone();
2900 config.host_effect_support = HostEffectSupport::new(
2901 EffectKindTag::ALL
2902 .into_iter()
2903 .filter(|kind| *kind != dropped),
2904 );
2905 let before = config.clone();
2906 let rejection = config.resolve(&defaults()).err().unwrap_or_else(|| {
2907 panic!("{dropped:?} is switched on by the all-fields config but was not required")
2908 });
2909 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
2910 assert!(
2911 rejection.message.contains(dropped.as_str()),
2912 "{dropped:?}: rejection does not name the missing kind: {}",
2913 rejection.message
2914 );
2915 assert_eq!(config, before, "{dropped:?}: rejection mutated the input");
2916 }
2917 }
2918
2919 #[test]
2920 fn an_absent_capability_imposes_no_effect_requirement() {
2921 minimal_config()
2925 .resolve(&defaults())
2926 .expect("a minimal config needs only call_provider");
2927
2928 let with_tools = OperationConfig {
2930 tool_catalog: vec![ToolSchema {
2931 name: "search".to_string(),
2932 description: String::new(),
2933 parameters: BoundedJson::null(),
2934 }],
2935 ..minimal_config()
2936 };
2937 let rejection = with_tools.resolve(&defaults()).expect_err("must reject");
2938 assert!(rejection.message.contains("execute_tools"));
2939
2940 let ok = OperationConfig {
2941 host_effect_support: HostEffectSupport::new([
2942 EffectKindTag::CallProvider,
2943 EffectKindTag::ExecuteTools,
2944 EffectKindTag::LoadPayload,
2945 ]),
2946 ..with_tools
2947 };
2948 ok.resolve(&defaults())
2949 .expect("execute_tools + load_payload satisfy a tool catalog");
2950 }
2951
2952 fn contract_host_config(contracts: Vec<VerificationContract>) -> OperationConfig {
2959 OperationConfig {
2960 host_effect_support: HostEffectSupport::new([
2961 EffectKindTag::CallProvider,
2962 EffectKindTag::ExecuteTools,
2963 EffectKindTag::LoadPayload,
2964 EffectKindTag::EvaluateMilestone,
2965 ]),
2966 tool_catalog: vec![ToolSchema {
2967 name: "search".to_string(),
2968 description: String::new(),
2969 parameters: BoundedJson::null(),
2970 }],
2971 skill_catalog: vec![SkillMetadata {
2972 name: "research".to_string(),
2973 description: String::new(),
2974 when_to_use: None,
2975 allowed_tools: Vec::new(),
2976 capability_grants: Vec::new(),
2977 effort: None,
2978 estimated_tokens: None,
2979 }],
2980 verification_contracts: contracts,
2981 ..minimal_config()
2982 }
2983 }
2984
2985 fn phase(phase_id: &str, unlocks: &[&str]) -> MilestonePhase {
2986 MilestonePhase {
2987 phase_id: phase_id.to_string(),
2988 unlocks: unlocks.iter().map(|id| (*id).to_string()).collect(),
2989 }
2990 }
2991
2992 #[test]
2993 fn a_contract_skeleton_carries_phase_order_and_unlocks_and_nothing_else() {
2994 let config = contract_host_config(vec![VerificationContract {
2997 contract_id: "brief-quality-primary".to_string(),
2998 phases: vec![phase("collect", &["research"]), phase("write", &["search"])],
2999 }]);
3000 let resolved = config.resolve(&defaults()).expect("a legal skeleton");
3001 let contract = resolved
3002 .verification_contract("brief-quality-primary")
3003 .expect("resolution keeps the catalog addressable by id");
3004 assert_eq!(
3005 contract
3006 .phases
3007 .iter()
3008 .map(|p| p.phase_id.as_str())
3009 .collect::<Vec<_>>(),
3010 vec!["collect", "write"],
3011 "the cascade order is a kernel fact and must survive resolution verbatim"
3012 );
3013 assert!(resolved.verification_contract("nope").is_none());
3014
3015 let value = serde_json::to_value(&contract.phases[0]).unwrap();
3017 for host_owned in ["criteria", "required_evidence", "verifier", "retry_policy"] {
3018 assert!(
3019 value.get(host_owned).is_none(),
3020 "{host_owned} belongs to the host (§5.2)"
3021 );
3022 }
3023 }
3024
3025 #[test]
3026 fn a_contract_catalog_with_a_duplicate_id_is_refused() {
3027 let config = contract_host_config(vec![
3030 VerificationContract {
3031 contract_id: "brief-quality-primary".to_string(),
3032 phases: vec![phase("collect", &[])],
3033 },
3034 VerificationContract {
3035 contract_id: "brief-quality-primary".to_string(),
3036 phases: vec![phase("write", &[])],
3037 },
3038 ]);
3039 let before = config.clone();
3040 let rejection = config.resolve(&defaults()).expect_err("must reject");
3041 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3042 assert!(
3043 rejection.message.contains("brief-quality-primary")
3044 && rejection.message.contains("twice"),
3045 "{}",
3046 rejection.message
3047 );
3048 assert_eq!(config, before, "a rejection mutates nothing");
3049 }
3050
3051 #[test]
3052 fn a_contract_with_a_duplicate_phase_id_is_refused() {
3053 let config = contract_host_config(vec![VerificationContract {
3056 contract_id: "brief-quality-primary".to_string(),
3057 phases: vec![phase("collect", &[]), phase("collect", &["search"])],
3058 }]);
3059 let rejection = config.resolve(&defaults()).expect_err("must reject");
3060 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3061 assert!(
3062 rejection.message.contains("collect"),
3063 "{}",
3064 rejection.message
3065 );
3066 }
3067
3068 #[test]
3069 fn a_phase_cannot_unlock_a_capability_the_operation_never_declared() {
3070 let config = contract_host_config(vec![VerificationContract {
3074 contract_id: "brief-quality-primary".to_string(),
3075 phases: vec![phase("collect", &["deploy_to_prod"])],
3076 }]);
3077 let rejection = config.resolve(&defaults()).expect_err("must reject");
3078 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3079 assert!(
3080 rejection.message.contains("deploy_to_prod"),
3081 "{}",
3082 rejection.message
3083 );
3084
3085 for declared in ["search", "research"] {
3087 contract_host_config(vec![VerificationContract {
3088 contract_id: "c".to_string(),
3089 phases: vec![phase("p", &[declared])],
3090 }])
3091 .resolve(&defaults())
3092 .unwrap_or_else(|e| panic!("{declared} is declared: {e}"));
3093 }
3094 }
3095
3096 #[test]
3097 fn a_contract_with_no_phase_is_refused() {
3098 let config = contract_host_config(vec![VerificationContract {
3101 contract_id: "brief-quality-primary".to_string(),
3102 phases: Vec::new(),
3103 }]);
3104 let rejection = config.resolve(&defaults()).expect_err("must reject");
3105 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
3106 assert!(
3107 rejection.message.contains("no phases"),
3108 "{}",
3109 rejection.message
3110 );
3111
3112 for empty_id in [
3113 VerificationContract {
3114 contract_id: String::new(),
3115 phases: vec![phase("p", &[])],
3116 },
3117 VerificationContract {
3118 contract_id: "c".to_string(),
3119 phases: vec![phase("", &[])],
3120 },
3121 ] {
3122 assert!(
3123 contract_host_config(vec![empty_id])
3124 .resolve(&defaults())
3125 .is_err(),
3126 "an empty id names nothing"
3127 );
3128 }
3129 }
3130
3131 #[test]
3132 fn a_contract_catalog_still_requires_the_milestone_effect() {
3133 let mut config = contract_host_config(vec![VerificationContract {
3136 contract_id: "brief-quality-primary".to_string(),
3137 phases: vec![phase("collect", &[])],
3138 }]);
3139 config.host_effect_support = HostEffectSupport::new([
3140 EffectKindTag::CallProvider,
3141 EffectKindTag::ExecuteTools,
3142 EffectKindTag::LoadPayload,
3143 ]);
3144 let rejection = config.resolve(&defaults()).expect_err("must reject");
3145 assert!(
3146 rejection.message.contains("evaluate_milestone"),
3147 "{}",
3148 rejection.message
3149 );
3150 }
3151
3152 #[test]
3153 fn a_tool_catalog_also_requires_the_payload_page_in_path() {
3154 let config = OperationConfig {
3157 tool_catalog: vec![ToolSchema {
3158 name: "search".to_string(),
3159 description: String::new(),
3160 parameters: BoundedJson::null(),
3161 }],
3162 host_effect_support: HostEffectSupport::new([
3163 EffectKindTag::CallProvider,
3164 EffectKindTag::ExecuteTools,
3165 ]),
3166 ..OperationConfig::default()
3167 };
3168 let rejection = config.resolve(&defaults()).expect_err("must reject");
3169 assert!(rejection.message.contains("load_payload"));
3170 }
3171
3172 #[test]
3173 fn spawn_capacity_obliges_the_host_to_be_able_to_stop_children() {
3174 let config = OperationConfig {
3177 resource_quota: Some(ResourceQuota {
3178 max_total_subagents: Some(4),
3179 ..ResourceQuota::default()
3180 }),
3181 host_effect_support: HostEffectSupport::new([
3182 EffectKindTag::CallProvider,
3183 EffectKindTag::SpawnTasks,
3184 ]),
3185 ..OperationConfig::default()
3186 };
3187 let rejection = config.resolve(&defaults()).expect_err("must reject");
3188 assert!(rejection.message.contains("preempt_tasks"));
3189
3190 let no_capacity = OperationConfig {
3192 resource_quota: Some(ResourceQuota {
3193 max_total_subagents: Some(0),
3194 ..ResourceQuota::default()
3195 }),
3196 ..minimal_config()
3197 };
3198 no_capacity
3199 .resolve(&defaults())
3200 .expect("a zero cap is not a declaration of spawn capacity");
3201 }
3202
3203 #[test]
3204 fn an_ask_user_gate_requires_somewhere_to_ask() {
3205 for governance in [
3206 GovernancePolicy {
3207 default_action: Some(PolicyAction::AskUser),
3208 ..GovernancePolicy::default()
3209 },
3210 GovernancePolicy {
3211 default_action: Some(PolicyAction::Allow),
3212 rules: vec![PolicyRule {
3213 tool_pattern: "shell.*".to_string(),
3214 action: PolicyAction::AskUser,
3215 }],
3216 ..GovernancePolicy::default()
3217 },
3218 ] {
3219 let config = OperationConfig {
3220 governance_policy: Some(governance),
3221 ..minimal_config()
3222 };
3223 let rejection = config.resolve(&defaults()).expect_err("must reject");
3224 assert!(rejection.message.contains("request_approval"));
3225 }
3226 }
3227
3228 #[test]
3229 fn memory_needs_both_directions_not_either_one() {
3230 for missing in [EffectKindTag::PersistMemory, EffectKindTag::QueryMemory] {
3233 let config = OperationConfig {
3234 memory_access: Some(MemoryAccessBinding {
3235 binding_id: MemoryBindingId::new("mem-1").unwrap(),
3236 capabilities: MemoryCapabilities {
3237 read: true,
3238 write: true,
3239 },
3240 }),
3241 feature_policy: Some(FeaturePolicy {
3242 memory_enabled: Some(true),
3243 ..FeaturePolicy::default()
3244 }),
3245 host_effect_support: HostEffectSupport::new(
3246 [
3247 EffectKindTag::CallProvider,
3248 EffectKindTag::PersistMemory,
3249 EffectKindTag::QueryMemory,
3250 ]
3251 .into_iter()
3252 .filter(|kind| *kind != missing),
3253 ),
3254 ..OperationConfig::default()
3255 };
3256 let rejection = config.resolve(&defaults()).expect_err("must reject");
3257 assert!(rejection.message.contains(missing.as_str()));
3258 }
3259 }
3260
3261 #[test]
3262 fn a_read_only_memory_binding_does_not_require_the_write_path() {
3263 let config = OperationConfig {
3266 memory_access: Some(MemoryAccessBinding {
3267 binding_id: MemoryBindingId::new("mem-ro").unwrap(),
3268 capabilities: MemoryCapabilities {
3269 read: true,
3270 write: false,
3271 },
3272 }),
3273 host_effect_support: HostEffectSupport::new([
3274 EffectKindTag::CallProvider,
3275 EffectKindTag::QueryMemory,
3276 ]),
3277 ..OperationConfig::default()
3278 };
3279 config
3280 .resolve(&defaults())
3281 .expect("a read-only binding needs only query_memory");
3282 }
3283
3284 #[test]
3289 fn a_broken_encoder_and_a_refused_configuration_are_different_rejections() {
3290 let scalar_error = serde_json::from_value::<ContextPolicy>(json!({
3295 "knowledge_budget_ppm": 0.25,
3296 }))
3297 .expect_err("a float ratio never becomes a Ppm");
3298 assert!(scalar_error.to_string().contains(SCALAR_ERROR_MARKER));
3299
3300 let mut config = fully_populated_config();
3301 config.context_policy.as_mut().unwrap().knowledge_budget_ppm = Some(ppm(990_000));
3302 let policy_error = config
3303 .resolve(&defaults())
3304 .expect_err("a knowledge budget that crowds out carryover is refused");
3305 assert_eq!(policy_error.kind, WireRejectionKind::PolicyViolation);
3306 assert_eq!(policy_error.kind.as_str(), "policy_violation");
3307 }
3308
3309 #[test]
3310 fn every_resolution_rejection_is_a_policy_violation_or_a_bound() {
3311 let mut cases: Vec<OperationConfig> = Vec::new();
3314
3315 let mut widen = minimal_config();
3316 widen.kernel_limits = Some(KernelLimits {
3317 max_json_depth: Some(u16::MAX),
3318 ..KernelLimits::default()
3319 });
3320 cases.push(widen);
3321
3322 let mut ladder = fully_populated_config();
3323 ladder
3324 .context_policy
3325 .as_mut()
3326 .unwrap()
3327 .preserve_recent_turns = Some(0);
3328 cases.push(ladder);
3329
3330 let mut catalog = fully_populated_config();
3331 catalog
3332 .kernel_limits
3333 .as_mut()
3334 .unwrap()
3335 .collection_limits
3336 .as_mut()
3337 .unwrap()
3338 .tool_catalog = Some(1);
3339 cases.push(catalog);
3340
3341 let mut quota = fully_populated_config();
3342 quota.resource_quota.as_mut().unwrap().max_spawn_depth = Some(0);
3343 cases.push(quota);
3344
3345 for config in cases {
3346 let kind = config.resolve(&defaults()).expect_err("must reject").kind;
3347 assert!(
3348 matches!(
3349 kind,
3350 WireRejectionKind::PolicyViolation | WireRejectionKind::CollectionTooLarge
3351 ),
3352 "resolution produced the decode-stage kind {kind:?}"
3353 );
3354 }
3355 }
3356
3357 #[test]
3362 fn configuration_is_admissible_only_before_the_operation_starts() {
3363 use crate::runtime::kernel::wire::envelope::{
3364 ConfigureOperation, KernelInput, OperationLifecycle,
3365 };
3366
3367 let configure = KernelInput::ConfigureOperation(ConfigureOperation {
3368 config: minimal_config(),
3369 });
3370 assert_eq!(
3371 configure.admissible_lifecycles(),
3372 &[OperationLifecycle::Created],
3373 "boot configuration is admissible exactly once, before any execution exists"
3374 );
3375
3376 let control =
3379 KernelInput::HostControl(crate::runtime::kernel::wire::envelope::HostControl {
3380 command: HostCommand::ForceCompact(
3381 crate::runtime::kernel::wire::command::ForceCompactCommand {},
3382 ),
3383 });
3384 assert!(
3385 control
3386 .admissible_lifecycles()
3387 .contains(&OperationLifecycle::Running)
3388 );
3389 assert!(
3390 !configure
3391 .admissible_lifecycles()
3392 .contains(&OperationLifecycle::Running),
3393 "a second ConfigureOperation against a running operation is an illegal lifecycle \
3394 mutation, not a live policy change"
3395 );
3396 }
3397
3398 #[test]
3403 fn configure_input_goldens_round_trip_through_the_typed_config() {
3404 let fixtures = fixtures_with_prefix("input_configure_");
3405 assert!(
3406 fixtures.len() >= 2,
3407 "need a minimal and a full configure golden, got {}",
3408 fixtures.len()
3409 );
3410
3411 let mut saw_minimal = false;
3412 let mut saw_full = false;
3413 for (name, fixture) in fixtures {
3414 let config: OperationConfig =
3415 serde_json::from_value(fixture["input"]["config"].clone())
3416 .unwrap_or_else(|e| panic!("{name}: {e}"));
3417 assert_eq!(
3418 serde_json::to_value(&config).unwrap(),
3419 fixture["input"]["config"],
3420 "{name}: config round-trip changed the document"
3421 );
3422 config
3423 .resolve(&defaults())
3424 .unwrap_or_else(|e| panic!("{name}: golden must resolve: {e}"));
3425
3426 let field_count = fixture["input"]["config"].as_object().unwrap().len();
3427 saw_minimal |= field_count <= 2;
3428 saw_full |= field_count >= 15;
3429 }
3430 assert!(saw_minimal, "no minimal configure golden");
3431 assert!(saw_full, "no all-fields configure golden");
3432 }
3433
3434 #[test]
3435 fn the_resolved_golden_matches_what_resolution_produces() {
3436 let fixture = fixtures_with_prefix("golden_config_resolved")
3439 .into_iter()
3440 .next()
3441 .map(|(_, value)| value)
3442 .expect("a resolved-config golden must exist");
3443
3444 let config: OperationConfig =
3445 serde_json::from_value(fixture["config"].clone()).expect("golden config decodes");
3446 let resolved = config.resolve(&defaults()).expect("golden config resolves");
3447 assert_eq!(
3448 serde_json::to_value(&resolved).unwrap(),
3449 fixture["resolved"],
3450 "resolution drifted from the frozen golden"
3451 );
3452 }
3453
3454 #[test]
3455 fn config_rejection_fixtures_fail_closed_with_the_declared_kind() {
3456 let decode_stage = fixtures_with_prefix("reject_config_");
3460 assert!(
3461 decode_stage.len() >= 4,
3462 "too few decode-stage config rejections"
3463 );
3464 for (name, fixture) in &decode_stage {
3465 let expected = fixture["expect"].as_str().expect("expect");
3466 let config = fixture["envelope"]["input"]["config"].clone();
3467 let error = serde_json::from_value::<OperationConfig>(config)
3468 .expect_err(&format!("{name}: expected a decode rejection"));
3469 let message = error.to_string();
3470 let kind = if message.contains(SCALAR_ERROR_MARKER) {
3471 "invalid_scalar"
3472 } else if message.contains("unknown field") {
3473 "unknown_field"
3474 } else if message.contains("unknown variant") {
3475 "unknown_variant"
3476 } else if message.contains("missing field") {
3477 "missing_field"
3478 } else {
3479 "type_mismatch"
3480 };
3481 assert_eq!(kind, expected, "{name}: wrong kind ({message})");
3482 }
3483
3484 let resolution_stage: Vec<_> = fixtures_with_prefix("golden_config_reject_");
3485 assert!(
3486 resolution_stage.len() >= 4,
3487 "too few resolution-stage config rejections"
3488 );
3489 let mut kinds = BTreeSet::new();
3490 for (name, fixture) in &resolution_stage {
3491 let expected = fixture["expect"].as_str().expect("expect");
3492 let config: OperationConfig = serde_json::from_value(fixture["config"].clone())
3493 .unwrap_or_else(|e| panic!("{name}: a resolution-stage fixture must decode: {e}"));
3494 let defaults = fixture
3495 .get("bootstrap_limits")
3496 .map(|limits| ConfigDefaults::new(serde_json::from_value(limits.clone()).unwrap()))
3497 .unwrap_or_default();
3498 let rejection = config
3499 .resolve(&defaults)
3500 .map(|ok| panic!("{name}: expected a rejection, resolved {ok:?}"))
3501 .unwrap_err();
3502 assert_eq!(
3503 rejection.kind.as_str(),
3504 expected,
3505 "{name}: {}",
3506 rejection.message
3507 );
3508 kinds.insert(expected.to_string());
3509 }
3510 assert!(kinds.contains("policy_violation"));
3511 assert!(kinds.contains("collection_too_large"));
3512 }
3513
3514 #[test]
3515 fn config_fixtures_never_carry_host_owned_facts() {
3516 const BANNED: [&str; 7] = [
3517 "memory_path",
3518 "spool_dir",
3519 "tokenizer",
3520 "host_effect_retry_attempts",
3521 "session_id",
3522 "api_key",
3523 "endpoint",
3524 ];
3525 for prefix in ["input_configure_", "golden_config_"] {
3526 for (name, fixture) in fixtures_with_prefix(prefix) {
3527 let mut keys = BTreeSet::new();
3528 all_keys(&fixture, &mut keys);
3529 for banned in BANNED {
3530 assert!(
3531 !keys.contains(banned),
3532 "{name}: configuration fixture carries the host-owned fact {banned:?}"
3533 );
3534 }
3535 }
3536 }
3537 }
3538}