1use serde::{Deserialize, Serialize};
4
5use super::root::{CapabilityGrant, CapabilityRef, KnowledgeEntry};
6use super::scalar::{CallId, WireU64};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(tag = "kind", rename_all = "snake_case")]
21pub enum HostCommand {
22 Cancel(CancelCommand),
23 ForceCompact(ForceCompactCommand),
24 UpdateTask(UpdateTaskCommand),
25 ApplyCapabilityPatch(ApplyCapabilityPatchCommand),
26 ApplyKnowledgeMutation(ApplyKnowledgeMutationCommand),
27 SeedKnowledge(SeedKnowledgeCommand),
31 ApplySkillActivation(ApplySkillActivationCommand),
35 ApplyPolicyPatch(ApplyPolicyPatchCommand),
36 UpdateDeadline(UpdateDeadlineCommand),
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct CancelCommand {
44 pub reason: CancellationReason,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub pending_call_ids: Vec<CallId>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum CancellationReason {
53 User,
54 Deadline,
55 LeaseLost,
56 HostShutdown,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct ForceCompactCommand {}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct UpdateTaskCommand {
67 pub update: TaskUpdate,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct TaskUpdate {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub plan: Option<Vec<String>>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub current_step: Option<u32>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub progress: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub scratchpad: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub blocked_on: Option<Vec<String>>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub preserved_refs: Option<Vec<String>>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub directives: Option<Vec<String>>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct ApplyCapabilityPatchCommand {
93 pub patch: CapabilityPatch,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct CapabilityPatch {
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub mount: Vec<CapabilityGrant>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub unmount: Vec<CapabilityRef>,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct ApplyKnowledgeMutationCommand {
108 pub mutation: KnowledgeMutation,
109}
110
111#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct KnowledgeMutation {
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub upsert: Vec<KnowledgeEntry>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub remove: Vec<String>,
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct SeedKnowledgeCommand {
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub entries: Vec<KnowledgeEntry>,
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct ApplySkillActivationCommand {
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub activate: Vec<SkillActivation>,
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub deactivate: Vec<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct SkillActivation {
143 pub name: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub lease_turns: Option<u32>,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct ApplyPolicyPatchCommand {
154 pub expected_revision: WireU64,
155 pub patch: LivePolicyPatch,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(tag = "kind", rename_all = "snake_case")]
166pub enum LivePolicyPatch {
167 ReplaceSignalPolicy(ReplaceSignalPolicy),
168 ReplaceGovernancePolicy(ReplaceGovernancePolicy),
169 TightenResourceQuota(TightenResourceQuota),
170 ReplaceRecoveryPolicy(ReplaceRecoveryPolicy),
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct ReplaceSignalPolicy {
176 pub policy: SignalPolicy,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct SignalPolicy {
183 pub queue_max: u32,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub ttl_ms: Option<WireU64>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub deadline_escalation: Option<bool>,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct ReplaceGovernancePolicy {
193 pub policy: GovernancePolicy,
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct GovernancePolicy {
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub default_action: Option<PolicyAction>,
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub rules: Vec<PolicyRule>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub vetoed_tools: Vec<String>,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub rate_limits: Vec<RateLimitSpec>,
215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
216 pub constraints: Vec<ParamConstraint>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct RateLimitSpec {
223 pub tool: String,
224 pub max_calls: u32,
225 pub window_ms: WireU64,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(tag = "kind", rename_all = "snake_case")]
238pub enum ParamConstraint {
239 Required(RequiredParam),
240 Enum(EnumParam),
241 Range(RangeParam),
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct RequiredParam {
247 pub tool: String,
248 pub param_path: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct EnumParam {
254 pub tool: String,
255 pub param_path: String,
256 pub values: Vec<String>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct RangeParam {
262 pub tool: String,
263 pub param_path: String,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub min_micros: Option<i64>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub max_micros: Option<i64>,
270}
271
272impl ParamConstraint {
273 pub fn tool(&self) -> &str {
274 match self {
275 Self::Required(c) => &c.tool,
276 Self::Enum(c) => &c.tool,
277 Self::Range(c) => &c.tool,
278 }
279 }
280
281 pub fn param_path(&self) -> &str {
282 match self {
283 Self::Required(c) => &c.param_path,
284 Self::Enum(c) => &c.param_path,
285 Self::Range(c) => &c.param_path,
286 }
287 }
288
289 pub fn validate(&self) -> Result<(), String> {
292 if self.tool().is_empty() {
293 return Err("governance constraint tool must not be empty".to_string());
294 }
295 if self.param_path().is_empty() {
296 return Err("governance constraint param_path must not be empty".to_string());
297 }
298 match self {
299 Self::Required(_) => Ok(()),
300 Self::Enum(c) => {
301 if c.values.is_empty() {
302 return Err(format!(
303 "enum constraint on {}.{} lists no permitted value, so every call is denied",
304 c.tool, c.param_path
305 ));
306 }
307 Ok(())
308 }
309 Self::Range(c) => match (c.min_micros, c.max_micros) {
310 (None, None) => Err(format!(
311 "range constraint on {}.{} bounds nothing",
312 c.tool, c.param_path
313 )),
314 (Some(min), Some(max)) if min > max => Err(format!(
315 "range constraint on {}.{} has min_micros {min} above max_micros {max}",
316 c.tool, c.param_path
317 )),
318 _ => Ok(()),
319 },
320 }
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case")]
326pub enum PolicyAction {
327 Allow,
328 Deny,
329 AskUser,
330}
331
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct PolicyRule {
335 pub tool_pattern: String,
336 pub action: PolicyAction,
337}
338
339#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
342#[serde(deny_unknown_fields)]
343pub struct TightenResourceQuota {
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub max_concurrent_subagents: Option<u32>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub max_total_subagents: Option<u32>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub max_spawn_depth: Option<u32>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub max_workflow_nodes: Option<u32>,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct ReplaceRecoveryPolicy {
357 pub policy: RecoveryPolicy,
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
362#[serde(deny_unknown_fields)]
363pub struct RecoveryPolicy {
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub provider_recovery_attempts: Option<u8>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub output_recovery_attempts: Option<u8>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub tail_bounds: Option<TailBoundsPolicy>,
373}
374
375#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct TailBoundsPolicy {
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub soft_records: Option<WireU64>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub hard_records: Option<WireU64>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub soft_bytes: Option<WireU64>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub hard_bytes: Option<WireU64>,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct UpdateDeadlineCommand {
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub deadline_ms: Option<WireU64>,
396}
397
398#[derive(Debug, Clone, PartialEq)]
416pub struct LivePolicyState {
417 revision: WireU64,
418 config: super::config::ResolvedOperationConfig,
419}
420
421impl LivePolicyState {
422 pub fn new(config: super::config::ResolvedOperationConfig) -> Self {
424 Self {
425 revision: WireU64::ZERO,
426 config,
427 }
428 }
429
430 pub fn restore(revision: WireU64, config: super::config::ResolvedOperationConfig) -> Self {
436 Self { revision, config }
437 }
438
439 pub fn revision(&self) -> WireU64 {
440 self.revision
441 }
442
443 pub fn config(&self) -> &super::config::ResolvedOperationConfig {
444 &self.config
445 }
446
447 pub fn apply(
453 &mut self,
454 command: &ApplyPolicyPatchCommand,
455 ) -> Result<WireU64, super::envelope::WireRejection> {
456 use super::envelope::{WireRejection, WireRejectionKind};
457
458 if command.expected_revision != self.revision {
459 return Err(WireRejection::new(
460 WireRejectionKind::PolicyViolation,
461 format!(
462 "policy revision mismatch: patch expects {}, the operation is at {}; \
463 re-read the policy and rebase the patch",
464 command.expected_revision, self.revision
465 ),
466 ));
467 }
468
469 let next = command.patch.apply_to(&self.config)?;
470 self.config = next;
471 self.revision = WireU64::new(self.revision.get().saturating_add(1));
472 Ok(self.revision)
473 }
474}
475
476impl LivePolicyPatch {
477 pub fn apply_to(
480 &self,
481 current: &super::config::ResolvedOperationConfig,
482 ) -> Result<super::config::ResolvedOperationConfig, super::envelope::WireRejection> {
483 use super::config::{
484 ResolvedRecoveryPolicy, ResolvedSignalPolicy, validate_quota, validate_recovery,
485 validate_signal,
486 };
487
488 let mut next = current.clone();
489 match self {
490 Self::ReplaceSignalPolicy(patch) => {
491 next.signal_policy = ResolvedSignalPolicy {
492 queue_max: patch.policy.queue_max,
493 ttl_ms: patch.policy.ttl_ms,
494 deadline_escalation: patch
495 .policy
496 .deadline_escalation
497 .unwrap_or(current.signal_policy.deadline_escalation),
498 };
499 validate_signal(&next.signal_policy)?;
500 }
501 Self::ReplaceGovernancePolicy(patch) => {
502 let policy = &patch.policy;
503 next.governance_policy.default_action = policy
504 .default_action
505 .unwrap_or(current.governance_policy.default_action);
506 next.governance_policy.rules = policy.rules.clone();
507 next.governance_policy.vetoed_tools = policy.vetoed_tools.clone();
508 next.governance_policy.rate_limits = policy.rate_limits.clone();
509 next.governance_policy.constraints = policy.constraints.clone();
510 super::config::validate_governance(
511 &next.governance_policy,
512 current.kernel_limits.collection_limits.governance_rules,
513 )?;
514 }
515 Self::TightenResourceQuota(patch) => {
516 next.resource_quota = patch.tighten(¤t.resource_quota)?;
517 validate_quota(&next.resource_quota)?;
518 }
519 Self::ReplaceRecoveryPolicy(patch) => {
520 if patch.policy.tail_bounds.is_some() {
524 return Err(super::envelope::WireRejection::new(
525 super::envelope::WireRejectionKind::PolicyViolation,
526 "recovery_policy.tail_bounds is boot-only: it is frozen in the genesis \
527 record and cannot be patched live",
528 ));
529 }
530 next.recovery_policy = ResolvedRecoveryPolicy {
531 provider_recovery_attempts: patch
532 .policy
533 .provider_recovery_attempts
534 .unwrap_or(current.recovery_policy.provider_recovery_attempts),
535 output_recovery_attempts: patch
536 .policy
537 .output_recovery_attempts
538 .unwrap_or(current.recovery_policy.output_recovery_attempts),
539 tail_bounds: current.recovery_policy.tail_bounds,
540 };
541 validate_recovery(&next.recovery_policy)?;
542 }
543 }
544 Ok(next)
545 }
546}
547
548impl TightenResourceQuota {
549 pub fn tighten(
556 &self,
557 current: &super::config::ResourceQuota,
558 ) -> Result<super::config::ResourceQuota, super::envelope::WireRejection> {
559 use super::envelope::{WireRejection, WireRejectionKind};
560
561 fn narrow(
562 label: &str,
563 requested: Option<u32>,
564 current: Option<u32>,
565 ) -> Result<Option<u32>, WireRejection> {
566 match (requested, current) {
567 (None, current) => Ok(current),
568 (Some(requested), Some(current)) if requested > current => Err(WireRejection::new(
569 WireRejectionKind::PolicyViolation,
570 format!(
571 "policy patch raises {label} from {current} to {requested}; \
572 a live quota change may only tighten — growing one is a reservation \
573 fact and needs its own contract"
574 ),
575 )),
576 (Some(requested), _) => Ok(Some(requested)),
577 }
578 }
579
580 Ok(super::config::ResourceQuota {
581 max_concurrent_subagents: narrow(
582 "max_concurrent_subagents",
583 self.max_concurrent_subagents,
584 current.max_concurrent_subagents,
585 )?,
586 max_total_subagents: narrow(
587 "max_total_subagents",
588 self.max_total_subagents,
589 current.max_total_subagents,
590 )?,
591 max_spawn_depth: narrow(
592 "max_spawn_depth",
593 self.max_spawn_depth,
594 current.max_spawn_depth,
595 )?,
596 max_workflow_nodes: narrow(
597 "max_workflow_nodes",
598 self.max_workflow_nodes,
599 current.max_workflow_nodes,
600 )?,
601 memory_writes_per_window: current.memory_writes_per_window.clone(),
604 })
605 }
606}
607
608#[cfg(test)]
613mod tests {
614 use super::*;
615 use crate::runtime::kernel::wire::config::{
616 ConfigDefaults, HostEffectSupport, OperationConfig, ResolvedOperationConfig, ResourceQuota,
617 };
618 use crate::runtime::kernel::wire::effect::EffectKindTag;
619 use crate::runtime::kernel::wire::envelope::WireRejectionKind;
620 use serde_json::json;
621
622 fn resolved(quota: Option<ResourceQuota>) -> ResolvedOperationConfig {
623 OperationConfig {
624 resource_quota: quota,
625 host_effect_support: HostEffectSupport::new([
628 EffectKindTag::CallProvider,
629 EffectKindTag::SpawnTasks,
630 EffectKindTag::PreemptTasks,
631 ]),
632 ..OperationConfig::default()
633 }
634 .resolve(&ConfigDefaults::default())
635 .expect("baseline config resolves")
636 }
637
638 fn quota() -> ResourceQuota {
639 ResourceQuota {
640 max_concurrent_subagents: Some(4),
641 max_total_subagents: Some(16),
642 max_spawn_depth: Some(3),
643 max_workflow_nodes: Some(64),
644 memory_writes_per_window: None,
645 }
646 }
647
648 fn patch(expected_revision: u64, patch: LivePolicyPatch) -> ApplyPolicyPatchCommand {
649 ApplyPolicyPatchCommand {
650 expected_revision: WireU64::new(expected_revision),
651 patch,
652 }
653 }
654
655 fn tighten(quota: TightenResourceQuota) -> LivePolicyPatch {
656 LivePolicyPatch::TightenResourceQuota(quota)
657 }
658
659 #[test]
664 fn a_patch_at_the_current_revision_applies_and_advances_it() {
665 let mut state = LivePolicyState::new(resolved(Some(quota())));
666 assert_eq!(state.revision(), WireU64::ZERO);
667
668 let next = state
669 .apply(&patch(
670 0,
671 tighten(TightenResourceQuota {
672 max_concurrent_subagents: Some(2),
673 ..TightenResourceQuota::default()
674 }),
675 ))
676 .expect("a patch at the current revision applies");
677 assert_eq!(next, WireU64::new(1));
678 assert_eq!(state.revision(), WireU64::new(1));
679 assert_eq!(
680 state.config().resource_quota.max_concurrent_subagents,
681 Some(2)
682 );
683 assert_eq!(state.config().resource_quota.max_total_subagents, Some(16));
685 }
686
687 #[test]
688 fn a_stale_revision_is_refused_and_changes_nothing() {
689 let mut state = LivePolicyState::new(resolved(Some(quota())));
690 state
691 .apply(&patch(
692 0,
693 tighten(TightenResourceQuota {
694 max_spawn_depth: Some(2),
695 ..TightenResourceQuota::default()
696 }),
697 ))
698 .unwrap();
699
700 let before = state.clone();
701 let rejection = state
703 .apply(&patch(
704 0,
705 tighten(TightenResourceQuota {
706 max_spawn_depth: Some(1),
707 ..TightenResourceQuota::default()
708 }),
709 ))
710 .expect_err("a stale patch must not silently overwrite");
711 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
712 assert!(rejection.message.contains("revision mismatch"));
713 assert_eq!(state, before, "a refused patch left state behind");
714
715 assert!(
717 state
718 .apply(&patch(
719 1,
720 tighten(TightenResourceQuota {
721 max_spawn_depth: Some(1),
722 ..TightenResourceQuota::default()
723 })
724 ))
725 .is_ok()
726 );
727 }
728
729 #[test]
730 fn a_future_revision_is_refused_too() {
731 let mut state = LivePolicyState::new(resolved(Some(quota())));
732 let rejection = state
733 .apply(&patch(9, tighten(TightenResourceQuota::default())))
734 .expect_err("a patch from the future is not a valid rebase either");
735 assert!(rejection.message.contains("revision mismatch"));
736 assert_eq!(state.revision(), WireU64::ZERO);
737 }
738
739 #[test]
740 fn the_revision_is_mandatory_on_the_wire() {
741 assert!(
742 serde_json::from_value::<ApplyPolicyPatchCommand>(json!({
743 "patch": { "kind": "replace_signal_policy", "policy": { "queue_max": 8 } },
744 }))
745 .is_err(),
746 "a patch without a revision silently overwrites another writer"
747 );
748 }
749
750 #[test]
751 fn no_policy_carries_a_version_of_its_own() {
752 assert!(
755 serde_json::from_value::<SignalPolicy>(json!({ "queue_max": 8, "version": 1 }))
756 .is_err()
757 );
758 assert!(serde_json::from_value::<GovernancePolicy>(json!({ "version": 1 })).is_err());
759 assert!(serde_json::from_value::<RecoveryPolicy>(json!({ "version": 1 })).is_err());
760 }
761
762 #[test]
767 fn a_live_quota_change_may_only_tighten() {
768 let mut state = LivePolicyState::new(resolved(Some(quota())));
769 for (label, widening) in [
770 (
771 "max_concurrent_subagents",
772 TightenResourceQuota {
773 max_concurrent_subagents: Some(99),
774 ..TightenResourceQuota::default()
775 },
776 ),
777 (
778 "max_total_subagents",
779 TightenResourceQuota {
780 max_total_subagents: Some(99),
781 ..TightenResourceQuota::default()
782 },
783 ),
784 (
785 "max_spawn_depth",
786 TightenResourceQuota {
787 max_spawn_depth: Some(9),
788 ..TightenResourceQuota::default()
789 },
790 ),
791 (
792 "max_workflow_nodes",
793 TightenResourceQuota {
794 max_workflow_nodes: Some(999),
795 ..TightenResourceQuota::default()
796 },
797 ),
798 ] {
799 let before = state.clone();
800 let rejection = state
801 .apply(&patch(0, tighten(widening)))
802 .expect_err("widening must be refused, not clamped");
803 assert!(
804 rejection.message.contains("may only tighten"),
805 "{label}: unexpected message {}",
806 rejection.message
807 );
808 assert!(rejection.message.contains(label));
809 assert_eq!(state, before, "{label}: a refused patch left state behind");
810 }
811 }
812
813 #[test]
814 fn capping_a_previously_uncapped_axis_is_a_tightening() {
815 let mut state = LivePolicyState::new(resolved(None));
816 assert_eq!(state.config().resource_quota.max_spawn_depth, None);
817 state
818 .apply(&patch(
819 0,
820 tighten(TightenResourceQuota {
821 max_spawn_depth: Some(2),
822 ..TightenResourceQuota::default()
823 }),
824 ))
825 .expect("uncapped ⇒ capped narrows the surface");
826 assert_eq!(state.config().resource_quota.max_spawn_depth, Some(2));
827 }
828
829 #[test]
830 fn an_absent_axis_means_unchanged_not_cleared() {
831 let mut state = LivePolicyState::new(resolved(Some(quota())));
832 state
833 .apply(&patch(0, tighten(TightenResourceQuota::default())))
834 .unwrap();
835 assert_eq!(state.config().resource_quota, quota());
836 }
837
838 #[test]
839 fn a_patch_that_fails_validation_leaves_the_revision_alone() {
840 let mut state = LivePolicyState::new(resolved(Some(quota())));
841 let before = state.clone();
842 let rejection = state
843 .apply(&patch(
844 0,
845 LivePolicyPatch::ReplaceSignalPolicy(ReplaceSignalPolicy {
846 policy: SignalPolicy {
847 queue_max: 0,
848 ttl_ms: None,
849 deadline_escalation: None,
850 },
851 }),
852 ))
853 .expect_err("a zero-length signal queue drops every signal");
854 assert!(rejection.message.contains("queue_max"));
855 assert_eq!(state, before);
856 }
857
858 #[test]
859 fn replacing_the_governance_policy_reuses_the_boot_validator() {
860 let mut state = LivePolicyState::new(resolved(None));
861 let rejection = state
862 .apply(&patch(
863 0,
864 LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
865 policy: GovernancePolicy {
866 constraints: vec![ParamConstraint::Enum(EnumParam {
867 tool: "write".to_string(),
868 param_path: "mode".to_string(),
869 values: Vec::new(),
870 })],
871 ..GovernancePolicy::default()
872 },
873 }),
874 ))
875 .expect_err("an enum constraint with no permitted value denies every call");
876 assert!(rejection.message.contains("no permitted value"));
877
878 state
879 .apply(&patch(
880 0,
881 LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
882 policy: GovernancePolicy {
883 default_action: Some(PolicyAction::Deny),
884 rules: vec![PolicyRule {
885 tool_pattern: "read.*".to_string(),
886 action: PolicyAction::Allow,
887 }],
888 ..GovernancePolicy::default()
889 },
890 }),
891 ))
892 .expect("a well-formed governance replacement applies");
893 assert_eq!(
894 state.config().governance_policy.default_action,
895 PolicyAction::Deny
896 );
897 }
898
899 #[test]
900 fn recovery_ladders_stay_inside_their_ceiling_live_too() {
901 let mut state = LivePolicyState::new(resolved(None));
902 let rejection = state
903 .apply(&patch(
904 0,
905 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
906 policy: RecoveryPolicy {
907 provider_recovery_attempts: Some(64),
908 output_recovery_attempts: None,
909 tail_bounds: None,
910 },
911 }),
912 ))
913 .expect_err("an unbounded recovery ladder is a livelock");
914 assert!(rejection.message.contains("provider_recovery_attempts"));
915 }
916
917 #[test]
921 fn the_tail_bound_is_boot_only_even_though_its_policy_is_live() {
922 let mut state = LivePolicyState::new(resolved(None));
923 let frozen = state.config().recovery_policy.tail_bounds;
924
925 let rejection = state
926 .apply(&patch(
927 0,
928 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
929 policy: RecoveryPolicy {
930 provider_recovery_attempts: None,
931 output_recovery_attempts: None,
932 tail_bounds: Some(TailBoundsPolicy {
933 hard_records: Some(WireU64::new(1_000_000)),
934 ..TailBoundsPolicy::default()
935 }),
936 },
937 }),
938 ))
939 .expect_err("the tail bound is not live-mutable");
940 assert!(rejection.message.contains("boot-only"), "{rejection}");
941 assert_eq!(
942 state.config().recovery_policy.tail_bounds,
943 frozen,
944 "a refused patch changes nothing"
945 );
946
947 state
949 .apply(&patch(
950 0,
951 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
952 policy: RecoveryPolicy {
953 provider_recovery_attempts: Some(3),
954 output_recovery_attempts: None,
955 tail_bounds: None,
956 },
957 }),
958 ))
959 .expect("the semantic ladders are live-mutable");
960 assert_eq!(state.config().recovery_policy.provider_recovery_attempts, 3);
961 assert_eq!(state.config().recovery_policy.tail_bounds, frozen);
962 }
963
964 #[test]
969 fn the_live_policy_union_is_closed_to_boot_only_policies() {
970 for unlisted in [
971 "replace_context_policy",
972 "replace_execution_policy",
973 "replace_scheduler_policy",
974 "replace_payload_policy",
975 "replace_feature_policy",
976 "set_tools",
977 "set_knowledge_budget",
978 "set_scheduler_budget",
979 "set_memory_policy",
980 "set_tokenizer",
981 ] {
982 let error = serde_json::from_value::<LivePolicyPatch>(json!({ "kind": unlisted }))
983 .expect_err("only the four §13.2 patches exist");
984 assert!(
985 error.to_string().contains("unknown variant"),
986 "{unlisted}: {error}"
987 );
988 }
989 }
990
991 #[test]
992 fn every_live_command_is_a_13_2_capability() {
993 let commands = [
995 json!({ "kind": "cancel", "reason": "user" }),
996 json!({ "kind": "update_deadline", "deadline_ms": "1700000000000" }),
997 json!({ "kind": "update_task", "update": { "progress": "halfway" } }),
998 json!({ "kind": "apply_capability_patch", "patch": {} }),
999 json!({ "kind": "apply_knowledge_mutation", "mutation": {} }),
1000 json!({ "kind": "force_compact" }),
1001 json!({ "kind": "apply_skill_activation", "activate": [{ "name": "research" }] }),
1002 json!({
1003 "kind": "apply_policy_patch",
1004 "expected_revision": "0",
1005 "patch": { "kind": "replace_signal_policy", "policy": { "queue_max": 8 } },
1006 }),
1007 json!({ "kind": "seed_knowledge", "entries": [] }),
1008 ];
1009 for command in commands {
1010 serde_json::from_value::<HostCommand>(command.clone())
1011 .unwrap_or_else(|e| panic!("{command}: {e}"));
1012 }
1013
1014 for boot_only in [
1015 json!({ "kind": "configure_run", "config": {} }),
1016 json!({ "kind": "set_tools", "tools": [] }),
1017 json!({ "kind": "load_governance_policy" }),
1018 json!({ "kind": "set_resource_quota", "quota": {} }),
1019 json!({ "kind": "set_scheduler_budget", "max_wall_ms": "1" }),
1020 json!({ "kind": "set_memory_policy", "memory_path": "/tmp" }),
1021 json!({ "kind": "resume" }),
1022 json!({ "kind": "spawn_sub_agent" }),
1023 json!({ "kind": "page_in", "entries": [] }),
1024 ] {
1025 assert!(
1026 serde_json::from_value::<HostCommand>(boot_only.clone()).is_err(),
1027 "{boot_only} must not decode as a live command"
1028 );
1029 }
1030 }
1031
1032 #[test]
1033 fn host_and_model_task_updates_do_not_share_a_wire_variant() {
1034 use crate::runtime::kernel::wire::syscall::SyscallRequest;
1035
1036 let update = json!({ "plan": ["a", "b"], "current_step": 1 });
1037 let host: HostCommand =
1038 serde_json::from_value(json!({ "kind": "update_task", "update": update })).unwrap();
1039 let model: SyscallRequest =
1040 serde_json::from_value(json!({ "kind": "update_task", "update": update })).unwrap();
1041
1042 match (host, model) {
1045 (HostCommand::UpdateTask(host), SyscallRequest::UpdateTask(model)) => {
1046 assert_eq!(host.update, model.update);
1047 }
1048 other => panic!("unexpected decode: {other:?}"),
1049 }
1050 }
1051
1052 #[test]
1057 fn range_constraints_are_fixed_point_not_floats() {
1058 assert!(
1059 serde_json::from_value::<ParamConstraint>(json!({
1060 "kind": "range", "tool": "sample", "param_path": "temperature", "min": 0.0, "max": 1.0,
1061 }))
1062 .is_err(),
1063 "an authoritative range bound must not be a language-default float"
1064 );
1065 let parsed: ParamConstraint = serde_json::from_value(json!({
1066 "kind": "range", "tool": "sample", "param_path": "temperature",
1067 "min_micros": 0, "max_micros": 1_000_000,
1068 }))
1069 .unwrap();
1070 assert!(parsed.validate().is_ok());
1071 }
1072
1073 #[test]
1074 fn a_range_constraint_that_can_never_hold_is_rejected() {
1075 for constraint in [
1076 ParamConstraint::Range(RangeParam {
1077 tool: "sample".to_string(),
1078 param_path: "t".to_string(),
1079 min_micros: None,
1080 max_micros: None,
1081 }),
1082 ParamConstraint::Range(RangeParam {
1083 tool: "sample".to_string(),
1084 param_path: "t".to_string(),
1085 min_micros: Some(2_000_000),
1086 max_micros: Some(1_000_000),
1087 }),
1088 ParamConstraint::Required(RequiredParam {
1089 tool: String::new(),
1090 param_path: "t".to_string(),
1091 }),
1092 ] {
1093 assert!(constraint.validate().is_err());
1094 }
1095 }
1096
1097 #[test]
1102 fn cancel_does_not_repeat_the_operation_id() {
1103 assert!(
1104 serde_json::from_value::<CancelCommand>(json!({
1105 "reason": "user", "operation_id": "op-1",
1106 }))
1107 .is_err(),
1108 "the envelope owns the operation id; repeating it forced three SDKs to special-case cancel"
1109 );
1110 }
1111}