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)]
163#[serde(tag = "kind", rename_all = "snake_case")]
164pub enum LivePolicyPatch {
165 ReplaceSignalPolicy(ReplaceSignalPolicy),
166 ReplaceGovernancePolicy(ReplaceGovernancePolicy),
167 TightenResourceQuota(TightenResourceQuota),
168 ReplaceRecoveryPolicy(ReplaceRecoveryPolicy),
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct ReplaceSignalPolicy {
174 pub policy: SignalPolicy,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct SignalPolicy {
181 pub queue_max: u32,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub ttl_ms: Option<WireU64>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub deadline_escalation: Option<bool>,
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct ReplaceGovernancePolicy {
191 pub policy: GovernancePolicy,
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct GovernancePolicy {
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub default_action: Option<PolicyAction>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub rules: Vec<PolicyRule>,
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub vetoed_tools: Vec<String>,
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub rate_limits: Vec<RateLimitSpec>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub constraints: Vec<ParamConstraint>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct RateLimitSpec {
219 pub tool: String,
220 pub max_calls: u32,
221 pub window_ms: WireU64,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(tag = "kind", rename_all = "snake_case")]
234pub enum ParamConstraint {
235 Required(RequiredParam),
236 Enum(EnumParam),
237 Range(RangeParam),
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct RequiredParam {
243 pub tool: String,
244 pub param_path: String,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct EnumParam {
250 pub tool: String,
251 pub param_path: String,
252 pub values: Vec<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct RangeParam {
258 pub tool: String,
259 pub param_path: String,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub min_micros: Option<i64>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub max_micros: Option<i64>,
266}
267
268impl ParamConstraint {
269 pub fn tool(&self) -> &str {
270 match self {
271 Self::Required(c) => &c.tool,
272 Self::Enum(c) => &c.tool,
273 Self::Range(c) => &c.tool,
274 }
275 }
276
277 pub fn param_path(&self) -> &str {
278 match self {
279 Self::Required(c) => &c.param_path,
280 Self::Enum(c) => &c.param_path,
281 Self::Range(c) => &c.param_path,
282 }
283 }
284
285 pub fn validate(&self) -> Result<(), String> {
288 if self.tool().is_empty() {
289 return Err("governance constraint tool must not be empty".to_string());
290 }
291 if self.param_path().is_empty() {
292 return Err("governance constraint param_path must not be empty".to_string());
293 }
294 match self {
295 Self::Required(_) => Ok(()),
296 Self::Enum(c) => {
297 if c.values.is_empty() {
298 return Err(format!(
299 "enum constraint on {}.{} lists no permitted value, so every call is denied",
300 c.tool, c.param_path
301 ));
302 }
303 Ok(())
304 }
305 Self::Range(c) => match (c.min_micros, c.max_micros) {
306 (None, None) => Err(format!(
307 "range constraint on {}.{} bounds nothing",
308 c.tool, c.param_path
309 )),
310 (Some(min), Some(max)) if min > max => Err(format!(
311 "range constraint on {}.{} has min_micros {min} above max_micros {max}",
312 c.tool, c.param_path
313 )),
314 _ => Ok(()),
315 },
316 }
317 }
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum PolicyAction {
323 Allow,
324 Deny,
325 AskUser,
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct PolicyRule {
331 pub tool_pattern: String,
332 pub action: PolicyAction,
333}
334
335#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
338#[serde(deny_unknown_fields)]
339pub struct TightenResourceQuota {
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub max_concurrent_subagents: Option<u32>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub max_total_subagents: Option<u32>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub max_spawn_depth: Option<u32>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub max_workflow_nodes: Option<u32>,
348}
349
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
351#[serde(deny_unknown_fields)]
352pub struct ReplaceRecoveryPolicy {
353 pub policy: RecoveryPolicy,
354}
355
356#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
358#[serde(deny_unknown_fields)]
359pub struct RecoveryPolicy {
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub provider_recovery_attempts: Option<u8>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub output_recovery_attempts: Option<u8>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub tail_bounds: Option<TailBoundsPolicy>,
369}
370
371#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(deny_unknown_fields)]
375pub struct TailBoundsPolicy {
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub soft_records: Option<WireU64>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub hard_records: Option<WireU64>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub soft_bytes: Option<WireU64>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub hard_bytes: Option<WireU64>,
384}
385
386#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct UpdateDeadlineCommand {
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub deadline_ms: Option<WireU64>,
392}
393
394#[derive(Debug, Clone, PartialEq)]
412pub struct LivePolicyState {
413 revision: WireU64,
414 config: super::config::ResolvedOperationConfig,
415}
416
417impl LivePolicyState {
418 pub fn new(config: super::config::ResolvedOperationConfig) -> Self {
420 Self {
421 revision: WireU64::ZERO,
422 config,
423 }
424 }
425
426 pub fn restore(revision: WireU64, config: super::config::ResolvedOperationConfig) -> Self {
432 Self { revision, config }
433 }
434
435 pub fn revision(&self) -> WireU64 {
436 self.revision
437 }
438
439 pub fn config(&self) -> &super::config::ResolvedOperationConfig {
440 &self.config
441 }
442
443 pub fn apply(
449 &mut self,
450 command: &ApplyPolicyPatchCommand,
451 ) -> Result<WireU64, super::envelope::WireRejection> {
452 use super::envelope::{WireRejection, WireRejectionKind};
453
454 if command.expected_revision != self.revision {
455 return Err(WireRejection::new(
456 WireRejectionKind::PolicyViolation,
457 format!(
458 "policy revision mismatch: patch expects {}, the operation is at {}; \
459 re-read the policy and rebase the patch",
460 command.expected_revision, self.revision
461 ),
462 ));
463 }
464
465 let next = command.patch.apply_to(&self.config)?;
466 self.config = next;
467 self.revision = WireU64::new(self.revision.get().saturating_add(1));
468 Ok(self.revision)
469 }
470}
471
472impl LivePolicyPatch {
473 pub fn apply_to(
476 &self,
477 current: &super::config::ResolvedOperationConfig,
478 ) -> Result<super::config::ResolvedOperationConfig, super::envelope::WireRejection> {
479 use super::config::{
480 ResolvedRecoveryPolicy, ResolvedSignalPolicy, validate_quota, validate_recovery,
481 validate_signal,
482 };
483
484 let mut next = current.clone();
485 match self {
486 Self::ReplaceSignalPolicy(patch) => {
487 next.signal_policy = ResolvedSignalPolicy {
488 queue_max: patch.policy.queue_max,
489 ttl_ms: patch.policy.ttl_ms,
490 deadline_escalation: patch
491 .policy
492 .deadline_escalation
493 .unwrap_or(current.signal_policy.deadline_escalation),
494 };
495 validate_signal(&next.signal_policy)?;
496 }
497 Self::ReplaceGovernancePolicy(patch) => {
498 let policy = &patch.policy;
499 next.governance_policy.default_action = policy
500 .default_action
501 .unwrap_or(current.governance_policy.default_action);
502 next.governance_policy.rules = policy.rules.clone();
503 next.governance_policy.vetoed_tools = policy.vetoed_tools.clone();
504 next.governance_policy.rate_limits = policy.rate_limits.clone();
505 next.governance_policy.constraints = policy.constraints.clone();
506 super::config::validate_governance(
507 &next.governance_policy,
508 current.kernel_limits.collection_limits.governance_rules,
509 )?;
510 }
511 Self::TightenResourceQuota(patch) => {
512 next.resource_quota = patch.tighten(¤t.resource_quota)?;
513 validate_quota(&next.resource_quota)?;
514 }
515 Self::ReplaceRecoveryPolicy(patch) => {
516 if patch.policy.tail_bounds.is_some() {
520 return Err(super::envelope::WireRejection::new(
521 super::envelope::WireRejectionKind::PolicyViolation,
522 "recovery_policy.tail_bounds is boot-only: it is frozen in the genesis \
523 record and cannot be patched live",
524 ));
525 }
526 next.recovery_policy = ResolvedRecoveryPolicy {
527 provider_recovery_attempts: patch
528 .policy
529 .provider_recovery_attempts
530 .unwrap_or(current.recovery_policy.provider_recovery_attempts),
531 output_recovery_attempts: patch
532 .policy
533 .output_recovery_attempts
534 .unwrap_or(current.recovery_policy.output_recovery_attempts),
535 tail_bounds: current.recovery_policy.tail_bounds,
536 };
537 validate_recovery(&next.recovery_policy)?;
538 }
539 }
540 Ok(next)
541 }
542}
543
544impl TightenResourceQuota {
545 pub fn tighten(
552 &self,
553 current: &super::config::ResourceQuota,
554 ) -> Result<super::config::ResourceQuota, super::envelope::WireRejection> {
555 use super::envelope::{WireRejection, WireRejectionKind};
556
557 fn narrow(
558 label: &str,
559 requested: Option<u32>,
560 current: Option<u32>,
561 ) -> Result<Option<u32>, WireRejection> {
562 match (requested, current) {
563 (None, current) => Ok(current),
564 (Some(requested), Some(current)) if requested > current => Err(WireRejection::new(
565 WireRejectionKind::PolicyViolation,
566 format!(
567 "policy patch raises {label} from {current} to {requested}; \
568 a live quota change may only tighten — growing one is a reservation \
569 fact and needs its own contract"
570 ),
571 )),
572 (Some(requested), _) => Ok(Some(requested)),
573 }
574 }
575
576 Ok(super::config::ResourceQuota {
577 max_concurrent_subagents: narrow(
578 "max_concurrent_subagents",
579 self.max_concurrent_subagents,
580 current.max_concurrent_subagents,
581 )?,
582 max_total_subagents: narrow(
583 "max_total_subagents",
584 self.max_total_subagents,
585 current.max_total_subagents,
586 )?,
587 max_spawn_depth: narrow(
588 "max_spawn_depth",
589 self.max_spawn_depth,
590 current.max_spawn_depth,
591 )?,
592 max_workflow_nodes: narrow(
593 "max_workflow_nodes",
594 self.max_workflow_nodes,
595 current.max_workflow_nodes,
596 )?,
597 memory_writes_per_window: current.memory_writes_per_window.clone(),
600 })
601 }
602}
603
604#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::runtime::kernel::wire::config::{
612 ConfigDefaults, HostEffectSupport, OperationConfig, ResolvedOperationConfig, ResourceQuota,
613 };
614 use crate::runtime::kernel::wire::effect::EffectKindTag;
615 use crate::runtime::kernel::wire::envelope::WireRejectionKind;
616 use serde_json::json;
617
618 fn resolved(quota: Option<ResourceQuota>) -> ResolvedOperationConfig {
619 OperationConfig {
620 resource_quota: quota,
621 host_effect_support: HostEffectSupport::new([
624 EffectKindTag::CallProvider,
625 EffectKindTag::SpawnTasks,
626 EffectKindTag::PreemptTasks,
627 ]),
628 ..OperationConfig::default()
629 }
630 .resolve(&ConfigDefaults::default())
631 .expect("baseline config resolves")
632 }
633
634 fn quota() -> ResourceQuota {
635 ResourceQuota {
636 max_concurrent_subagents: Some(4),
637 max_total_subagents: Some(16),
638 max_spawn_depth: Some(3),
639 max_workflow_nodes: Some(64),
640 memory_writes_per_window: None,
641 }
642 }
643
644 fn patch(expected_revision: u64, patch: LivePolicyPatch) -> ApplyPolicyPatchCommand {
645 ApplyPolicyPatchCommand {
646 expected_revision: WireU64::new(expected_revision),
647 patch,
648 }
649 }
650
651 fn tighten(quota: TightenResourceQuota) -> LivePolicyPatch {
652 LivePolicyPatch::TightenResourceQuota(quota)
653 }
654
655 #[test]
660 fn a_patch_at_the_current_revision_applies_and_advances_it() {
661 let mut state = LivePolicyState::new(resolved(Some(quota())));
662 assert_eq!(state.revision(), WireU64::ZERO);
663
664 let next = state
665 .apply(&patch(
666 0,
667 tighten(TightenResourceQuota {
668 max_concurrent_subagents: Some(2),
669 ..TightenResourceQuota::default()
670 }),
671 ))
672 .expect("a patch at the current revision applies");
673 assert_eq!(next, WireU64::new(1));
674 assert_eq!(state.revision(), WireU64::new(1));
675 assert_eq!(
676 state.config().resource_quota.max_concurrent_subagents,
677 Some(2)
678 );
679 assert_eq!(state.config().resource_quota.max_total_subagents, Some(16));
681 }
682
683 #[test]
684 fn a_stale_revision_is_refused_and_changes_nothing() {
685 let mut state = LivePolicyState::new(resolved(Some(quota())));
686 state
687 .apply(&patch(
688 0,
689 tighten(TightenResourceQuota {
690 max_spawn_depth: Some(2),
691 ..TightenResourceQuota::default()
692 }),
693 ))
694 .unwrap();
695
696 let before = state.clone();
697 let rejection = state
699 .apply(&patch(
700 0,
701 tighten(TightenResourceQuota {
702 max_spawn_depth: Some(1),
703 ..TightenResourceQuota::default()
704 }),
705 ))
706 .expect_err("a stale patch must not silently overwrite");
707 assert_eq!(rejection.kind, WireRejectionKind::PolicyViolation);
708 assert!(rejection.message.contains("revision mismatch"));
709 assert_eq!(state, before, "a refused patch left state behind");
710
711 assert!(
713 state
714 .apply(&patch(
715 1,
716 tighten(TightenResourceQuota {
717 max_spawn_depth: Some(1),
718 ..TightenResourceQuota::default()
719 })
720 ))
721 .is_ok()
722 );
723 }
724
725 #[test]
726 fn a_future_revision_is_refused_too() {
727 let mut state = LivePolicyState::new(resolved(Some(quota())));
728 let rejection = state
729 .apply(&patch(9, tighten(TightenResourceQuota::default())))
730 .expect_err("a patch from the future is not a valid rebase either");
731 assert!(rejection.message.contains("revision mismatch"));
732 assert_eq!(state.revision(), WireU64::ZERO);
733 }
734
735 #[test]
736 fn the_revision_is_mandatory_on_the_wire() {
737 assert!(
738 serde_json::from_value::<ApplyPolicyPatchCommand>(json!({
739 "patch": { "kind": "replace_signal_policy", "policy": { "queue_max": 8 } },
740 }))
741 .is_err(),
742 "a patch without a revision silently overwrites another writer"
743 );
744 }
745
746 #[test]
747 fn no_policy_carries_a_version_of_its_own() {
748 assert!(
751 serde_json::from_value::<SignalPolicy>(json!({ "queue_max": 8, "version": 1 }))
752 .is_err()
753 );
754 assert!(serde_json::from_value::<GovernancePolicy>(json!({ "version": 1 })).is_err());
755 assert!(serde_json::from_value::<RecoveryPolicy>(json!({ "version": 1 })).is_err());
756 }
757
758 #[test]
763 fn a_live_quota_change_may_only_tighten() {
764 let mut state = LivePolicyState::new(resolved(Some(quota())));
765 for (label, widening) in [
766 (
767 "max_concurrent_subagents",
768 TightenResourceQuota {
769 max_concurrent_subagents: Some(99),
770 ..TightenResourceQuota::default()
771 },
772 ),
773 (
774 "max_total_subagents",
775 TightenResourceQuota {
776 max_total_subagents: Some(99),
777 ..TightenResourceQuota::default()
778 },
779 ),
780 (
781 "max_spawn_depth",
782 TightenResourceQuota {
783 max_spawn_depth: Some(9),
784 ..TightenResourceQuota::default()
785 },
786 ),
787 (
788 "max_workflow_nodes",
789 TightenResourceQuota {
790 max_workflow_nodes: Some(999),
791 ..TightenResourceQuota::default()
792 },
793 ),
794 ] {
795 let before = state.clone();
796 let rejection = state
797 .apply(&patch(0, tighten(widening)))
798 .expect_err("widening must be refused, not clamped");
799 assert!(
800 rejection.message.contains("may only tighten"),
801 "{label}: unexpected message {}",
802 rejection.message
803 );
804 assert!(rejection.message.contains(label));
805 assert_eq!(state, before, "{label}: a refused patch left state behind");
806 }
807 }
808
809 #[test]
810 fn capping_a_previously_uncapped_axis_is_a_tightening() {
811 let mut state = LivePolicyState::new(resolved(None));
812 assert_eq!(state.config().resource_quota.max_spawn_depth, None);
813 state
814 .apply(&patch(
815 0,
816 tighten(TightenResourceQuota {
817 max_spawn_depth: Some(2),
818 ..TightenResourceQuota::default()
819 }),
820 ))
821 .expect("uncapped ⇒ capped narrows the surface");
822 assert_eq!(state.config().resource_quota.max_spawn_depth, Some(2));
823 }
824
825 #[test]
826 fn an_absent_axis_means_unchanged_not_cleared() {
827 let mut state = LivePolicyState::new(resolved(Some(quota())));
828 state
829 .apply(&patch(0, tighten(TightenResourceQuota::default())))
830 .unwrap();
831 assert_eq!(state.config().resource_quota, quota());
832 }
833
834 #[test]
835 fn a_patch_that_fails_validation_leaves_the_revision_alone() {
836 let mut state = LivePolicyState::new(resolved(Some(quota())));
837 let before = state.clone();
838 let rejection = state
839 .apply(&patch(
840 0,
841 LivePolicyPatch::ReplaceSignalPolicy(ReplaceSignalPolicy {
842 policy: SignalPolicy {
843 queue_max: 0,
844 ttl_ms: None,
845 deadline_escalation: None,
846 },
847 }),
848 ))
849 .expect_err("a zero-length signal queue drops every signal");
850 assert!(rejection.message.contains("queue_max"));
851 assert_eq!(state, before);
852 }
853
854 #[test]
855 fn replacing_the_governance_policy_reuses_the_boot_validator() {
856 let mut state = LivePolicyState::new(resolved(None));
857 let rejection = state
858 .apply(&patch(
859 0,
860 LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
861 policy: GovernancePolicy {
862 constraints: vec![ParamConstraint::Enum(EnumParam {
863 tool: "write".to_string(),
864 param_path: "mode".to_string(),
865 values: Vec::new(),
866 })],
867 ..GovernancePolicy::default()
868 },
869 }),
870 ))
871 .expect_err("an enum constraint with no permitted value denies every call");
872 assert!(rejection.message.contains("no permitted value"));
873
874 state
875 .apply(&patch(
876 0,
877 LivePolicyPatch::ReplaceGovernancePolicy(ReplaceGovernancePolicy {
878 policy: GovernancePolicy {
879 default_action: Some(PolicyAction::Deny),
880 rules: vec![PolicyRule {
881 tool_pattern: "read.*".to_string(),
882 action: PolicyAction::Allow,
883 }],
884 ..GovernancePolicy::default()
885 },
886 }),
887 ))
888 .expect("a well-formed governance replacement applies");
889 assert_eq!(
890 state.config().governance_policy.default_action,
891 PolicyAction::Deny
892 );
893 }
894
895 #[test]
896 fn recovery_ladders_stay_inside_their_ceiling_live_too() {
897 let mut state = LivePolicyState::new(resolved(None));
898 let rejection = state
899 .apply(&patch(
900 0,
901 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
902 policy: RecoveryPolicy {
903 provider_recovery_attempts: Some(64),
904 output_recovery_attempts: None,
905 tail_bounds: None,
906 },
907 }),
908 ))
909 .expect_err("an unbounded recovery ladder is a livelock");
910 assert!(rejection.message.contains("provider_recovery_attempts"));
911 }
912
913 #[test]
917 fn the_tail_bound_is_boot_only_even_though_its_policy_is_live() {
918 let mut state = LivePolicyState::new(resolved(None));
919 let frozen = state.config().recovery_policy.tail_bounds;
920
921 let rejection = state
922 .apply(&patch(
923 0,
924 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
925 policy: RecoveryPolicy {
926 provider_recovery_attempts: None,
927 output_recovery_attempts: None,
928 tail_bounds: Some(TailBoundsPolicy {
929 hard_records: Some(WireU64::new(1_000_000)),
930 ..TailBoundsPolicy::default()
931 }),
932 },
933 }),
934 ))
935 .expect_err("the tail bound is not live-mutable");
936 assert!(rejection.message.contains("boot-only"), "{rejection}");
937 assert_eq!(
938 state.config().recovery_policy.tail_bounds,
939 frozen,
940 "a refused patch changes nothing"
941 );
942
943 state
945 .apply(&patch(
946 0,
947 LivePolicyPatch::ReplaceRecoveryPolicy(ReplaceRecoveryPolicy {
948 policy: RecoveryPolicy {
949 provider_recovery_attempts: Some(3),
950 output_recovery_attempts: None,
951 tail_bounds: None,
952 },
953 }),
954 ))
955 .expect("the semantic ladders are live-mutable");
956 assert_eq!(state.config().recovery_policy.provider_recovery_attempts, 3);
957 assert_eq!(state.config().recovery_policy.tail_bounds, frozen);
958 }
959
960 #[test]
965 fn the_live_policy_union_is_closed_to_boot_only_policies() {
966 for unlisted in [
967 "replace_context_policy",
968 "replace_execution_policy",
969 "replace_scheduler_policy",
970 "replace_payload_policy",
971 "replace_feature_policy",
972 "set_tools",
973 "set_knowledge_budget",
974 "set_scheduler_budget",
975 "set_memory_policy",
976 "set_tokenizer",
977 ] {
978 let error = serde_json::from_value::<LivePolicyPatch>(json!({ "kind": unlisted }))
979 .expect_err("only the four §13.2 patches exist");
980 assert!(
981 error.to_string().contains("unknown variant"),
982 "{unlisted}: {error}"
983 );
984 }
985 }
986
987 #[test]
988 fn every_live_command_is_a_13_2_capability() {
989 let commands = [
991 json!({ "kind": "cancel", "reason": "user" }),
992 json!({ "kind": "update_deadline", "deadline_ms": "1700000000000" }),
993 json!({ "kind": "update_task", "update": { "progress": "halfway" } }),
994 json!({ "kind": "apply_capability_patch", "patch": {} }),
995 json!({ "kind": "apply_knowledge_mutation", "mutation": {} }),
996 json!({ "kind": "force_compact" }),
997 json!({ "kind": "apply_skill_activation", "activate": [{ "name": "research" }] }),
998 json!({
999 "kind": "apply_policy_patch",
1000 "expected_revision": "0",
1001 "patch": { "kind": "replace_signal_policy", "policy": { "queue_max": 8 } },
1002 }),
1003 json!({ "kind": "seed_knowledge", "entries": [] }),
1004 ];
1005 for command in commands {
1006 serde_json::from_value::<HostCommand>(command.clone())
1007 .unwrap_or_else(|e| panic!("{command}: {e}"));
1008 }
1009
1010 for boot_only in [
1011 json!({ "kind": "configure_run", "config": {} }),
1012 json!({ "kind": "set_tools", "tools": [] }),
1013 json!({ "kind": "load_governance_policy" }),
1014 json!({ "kind": "set_resource_quota", "quota": {} }),
1015 json!({ "kind": "set_scheduler_budget", "max_wall_ms": "1" }),
1016 json!({ "kind": "set_memory_policy", "memory_path": "/tmp" }),
1017 json!({ "kind": "resume" }),
1018 json!({ "kind": "spawn_sub_agent" }),
1019 json!({ "kind": "page_in", "entries": [] }),
1020 ] {
1021 assert!(
1022 serde_json::from_value::<HostCommand>(boot_only.clone()).is_err(),
1023 "{boot_only} must not decode as a live command"
1024 );
1025 }
1026 }
1027
1028 #[test]
1029 fn host_and_model_task_updates_do_not_share_a_wire_variant() {
1030 use crate::runtime::kernel::wire::syscall::SyscallRequest;
1031
1032 let update = json!({ "plan": ["a", "b"], "current_step": 1 });
1033 let host: HostCommand =
1034 serde_json::from_value(json!({ "kind": "update_task", "update": update })).unwrap();
1035 let model: SyscallRequest =
1036 serde_json::from_value(json!({ "kind": "update_task", "update": update })).unwrap();
1037
1038 match (host, model) {
1041 (HostCommand::UpdateTask(host), SyscallRequest::UpdateTask(model)) => {
1042 assert_eq!(host.update, model.update);
1043 }
1044 other => panic!("unexpected decode: {other:?}"),
1045 }
1046 }
1047
1048 #[test]
1053 fn range_constraints_are_fixed_point_not_floats() {
1054 assert!(
1055 serde_json::from_value::<ParamConstraint>(json!({
1056 "kind": "range", "tool": "sample", "param_path": "temperature", "min": 0.0, "max": 1.0,
1057 }))
1058 .is_err(),
1059 "an authoritative range bound must not be a language-default float"
1060 );
1061 let parsed: ParamConstraint = serde_json::from_value(json!({
1062 "kind": "range", "tool": "sample", "param_path": "temperature",
1063 "min_micros": 0, "max_micros": 1_000_000,
1064 }))
1065 .unwrap();
1066 assert!(parsed.validate().is_ok());
1067 }
1068
1069 #[test]
1070 fn a_range_constraint_that_can_never_hold_is_rejected() {
1071 for constraint in [
1072 ParamConstraint::Range(RangeParam {
1073 tool: "sample".to_string(),
1074 param_path: "t".to_string(),
1075 min_micros: None,
1076 max_micros: None,
1077 }),
1078 ParamConstraint::Range(RangeParam {
1079 tool: "sample".to_string(),
1080 param_path: "t".to_string(),
1081 min_micros: Some(2_000_000),
1082 max_micros: Some(1_000_000),
1083 }),
1084 ParamConstraint::Required(RequiredParam {
1085 tool: String::new(),
1086 param_path: "t".to_string(),
1087 }),
1088 ] {
1089 assert!(constraint.validate().is_err());
1090 }
1091 }
1092
1093 #[test]
1098 fn cancel_does_not_repeat_the_operation_id() {
1099 assert!(
1100 serde_json::from_value::<CancelCommand>(json!({
1101 "reason": "user", "operation_id": "op-1",
1102 }))
1103 .is_err(),
1104 "the envelope owns the operation id; repeating it forced three SDKs to special-case cancel"
1105 );
1106 }
1107}