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