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