Skip to main content

deepstrike_core/runtime/kernel/wire/
command.rs

1//! Host control plane (spec §7.5).
2
3use serde::{Deserialize, Serialize};
4
5use super::root::{CapabilityGrant, CapabilityRef, KnowledgeEntry};
6use super::scalar::{CallId, WireU64};
7
8/// Live commands a host may issue against a running operation.
9///
10/// Two rules shape this union:
11///
12/// 1. **No boot config through the control plane.** Setup-only configuration travels once,
13///    through `ConfigureOperation`; a live command exists only where a real callsite proved a
14///    running operation must change.
15/// 2. **Authority is explicit.** `UpdateTask` here is the *host's* plan update. The model's
16///    `update_task` is a P1 syscall whose causation the kernel derives from the provider effect
17///    (§7.6) — the two must not share a wire variant, because sharing one is exactly how the
18///    current ABI lost the ability to tell a host plan edit from a model tool call.
19#[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    /// DEC-9: the host seeding entries into the knowledge partition. Named apart from the P1
28    /// syscall `PageIn { handle_id }` on purpose — the two are opposite directions and must never
29    /// share a name again.
30    SeedKnowledge(SeedKnowledgeCommand),
31    /// §13.2 · skill activation/deactivation. Host-side only for deactivation: there is
32    /// deliberately no model-facing unload (it invites thrash), and the model's *activation* is
33    /// the P1 `ActivateSkill` syscall, whose authority the kernel derives rather than accepts.
34    ApplySkillActivation(ApplySkillActivationCommand),
35    ApplyPolicyPatch(ApplyPolicyPatchCommand),
36    UpdateDeadline(UpdateDeadlineCommand),
37}
38
39/// Cancellation. The operation id lives in the envelope and is **not** repeated here: the
40/// historical duplicate field forced every SDK to special-case cancel when building an input.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct CancelCommand {
44    pub reason: CancellationReason,
45    /// Logical calls the host wants abandoned. One id namespace only.
46    #[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/// Empty payload rather than a unit variant so `deny_unknown_fields` still applies.
60#[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/// A partial edit of the task state. Every field is optional; absent ⇒ unchanged.
71#[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    /// Keyed upsert; entries without a key append.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub upsert: Vec<KnowledgeEntry>,
117    /// Keys to drop at the next boundary. Errs open: an unknown key is a no-op.
118    #[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/// §13.2 · skill activation state. Both directions in one command so a swap is atomic.
130#[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    /// Skill names to deactivate. Errs open: not-active is a no-op.
136    #[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    /// Auto-deactivate after this many turns. `None` ⇒ until explicitly deactivated.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub lease_turns: Option<u32>,
147}
148
149/// Optimistic policy update. The revision is mandatory: a patch without one silently overwrites
150/// whatever another writer just installed.
151#[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/// The closed set of policies that may change while an operation runs (§13.2).
159///
160/// Everything absent from this union is boot-only by construction. Optimistic concurrency belongs
161/// to [`ApplyPolicyPatchCommand::expected_revision`], not to a policy-format discriminator.
162#[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/// Signal routing policy. Replaced atomically — a partial signal policy is not a thing.
178#[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/// Syscall-gate policy — the complete posture, replaced atomically.
195///
196/// The same type is the operation's initial governance (§13.1) and the payload of its live
197/// governance mutation (§13.2). There is one wire shape; authority is carried by the input class
198/// that transports it — `ConfigureOperation` once, or a revision-guarded
199/// [`ApplyPolicyPatchCommand`] afterwards.
200#[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/// Per-tool rolling-window rate limit.
216#[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/// Structural constraint on a tool call's parameters. Pattern/predicate matching stays in the host
225/// execution layer — the kernel only enforces shapes it can replay identically.
226///
227/// The addressed field is `param_path`, not `path`: it points inside the call's *arguments*, and
228/// a bare `path` in a kernel input is exactly the host filesystem fact §7.4 keeps out of the ABI.
229///
230/// [`RangeParam`] carries its bounds as **fixed-point micro-units** rather than `f64`: a range
231/// check is an authoritative branch, and §7.1.1 keeps language-default floats out of those.
232#[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    /// Inclusive lower bound in micro-units (`1_500_000` ⇒ `1.5`).
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub min_micros: Option<i64>,
263    /// Inclusive upper bound in micro-units.
264    #[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    /// Structural self-consistency. A constraint that can never hold is a configuration error,
286    /// not a permanently-denying gate discovered on the first tool call.
287    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/// Quotas only ever shrink at runtime. Growing one is a reservation fact, not a policy patch, and
336/// needs its own contract before it exists.
337#[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/// Semantic recovery ladders the kernel owns. Host transport retry/backoff stays host-side.
357#[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    /// §12.3 · how much journal tail this operation may carry between checkpoints. Boot-only and
365    /// frozen in the genesis record, because the bound a run was refused against must survive a
366    /// binary whose defaults moved.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub tail_bounds: Option<TailBoundsPolicy>,
369}
370
371/// Sparse override of [`TailBounds`](super::config::TailBounds). Each axis is independent: a host
372/// that only wants to shorten the record watermark keeps the kernel's byte bounds.
373#[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/// Absolute deadline for the operation. `None` clears it.
387#[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// ---------------------------------------------------------------------------------------------
395// live policy state (§13.2, DEC-6)
396// ---------------------------------------------------------------------------------------------
397
398/// The kernel-side live policy state: the resolved configuration plus the revision that guards
399/// mutations of it.
400///
401/// Two rules live here rather than at each callsite:
402///
403/// 1. **Optimistic concurrency belongs to the patch, not to a policy.** DEC-6 removed
404///    `SIGNAL_POLICY_VERSION` and `SignalPolicyConfig.version`; a signal policy has no version of
405///    its own, and the single revision counter below is what two concurrent writers race on.
406///    A patch whose `expected_revision` does not match the current one is refused, so the second
407///    writer re-reads instead of silently overwriting the first.
408/// 2. **Live resource changes only ever shrink.** Growing a quota is a *reservation fact* — it
409///    needs an admission decision from whoever owns the budget — not a policy edit, so it has no
410///    representation here.
411#[derive(Debug, Clone, PartialEq)]
412pub struct LivePolicyState {
413    revision: WireU64,
414    config: super::config::ResolvedOperationConfig,
415}
416
417impl LivePolicyState {
418    /// Start from the genesis record's resolved configuration at revision 0.
419    pub fn new(config: super::config::ResolvedOperationConfig) -> Self {
420        Self {
421            revision: WireU64::ZERO,
422            config,
423        }
424    }
425
426    /// §12.2 · reinstall the live policy a checkpoint recorded, revision included.
427    ///
428    /// Distinct from [`Self::new`] on purpose: `new` starts a *fresh* operation at revision 0 with
429    /// the genesis configuration, and using it for a restore would silently rewind the revision a
430    /// concurrent patch writer is racing on.
431    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    /// Apply one revision-guarded patch.
444    ///
445    /// Atomic in the same structural sense as configuration resolution: the next configuration is
446    /// built and validated as a whole *before* anything is stored, so a rejected patch leaves both
447    /// the configuration and the revision exactly as they were.
448    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    /// Produce the configuration this patch would install, or reject it. Pure: it never mutates
474    /// the input, which is what makes [`LivePolicyState::apply`] all-or-nothing.
475    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(&current.resource_quota)?;
513                validate_quota(&next.resource_quota)?;
514            }
515            Self::ReplaceRecoveryPolicy(patch) => {
516                // §13.1 · the semantic recovery ladders are live-mutable, the tail bound is not.
517                // It is frozen in the genesis record (§5e-5), and a live widen would let a run
518                // escape the very bound a `CheckpointRequired` just refused it against.
519                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    /// Fold this patch onto the current quota, refusing any axis that would widen it.
546    ///
547    /// "Uncapped ⇒ capped" is a tightening and is allowed; "capped ⇒ uncapped" is not expressible
548    /// (an absent axis means *unchanged* here, not *cleared*), and a larger cap is refused
549    /// outright rather than clamped — a silently clamped budget is a budget the host thinks it
550    /// raised.
551    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            // not a live axis: a rate window is part of the governance posture, and the live form
598            // of that is `ReplaceGovernancePolicy`
599            memory_writes_per_window: current.memory_writes_per_window.clone(),
600        })
601    }
602}
603
604// ---------------------------------------------------------------------------------------------
605// tests
606// ---------------------------------------------------------------------------------------------
607
608#[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            // a quota that declares spawn capacity obliges the host to declare it can launch and
622            // stop child tasks (DEC-8 config-time cross-validation)
623            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    // -----------------------------------------------------------------------------------------
656    // DEC-6 · optimistic concurrency lives on the patch, not on a policy
657    // -----------------------------------------------------------------------------------------
658
659    #[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        // untouched axes survive a partial patch
680        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        // the second writer read revision 0 before the first writer committed
698        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        // rebasing onto the new revision is the whole point of refusing
712        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        // DEC-6 / §16.1: `SIGNAL_POLICY_VERSION` and `SignalPolicyConfig.version` are gone; the
749        // revision is `ApplyPolicyPatchCommand::expected_revision` and nothing else.
750        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    // -----------------------------------------------------------------------------------------
759    // live quota changes only ever tighten
760    // -----------------------------------------------------------------------------------------
761
762    #[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    /// §12.3 / §5e-5 · the tail bound is frozen by the genesis record. A live patch that tried to
914    /// widen it would let a run escape the very limit a `CheckpointRequired` just refused it
915    /// against — and one that tried to narrow it would refuse inputs the journal already accepted.
916    #[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        // the rest of the recovery policy is still live, and the bound rides through untouched
944        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    // -----------------------------------------------------------------------------------------
961    // §13.2 · the live union is closed
962    // -----------------------------------------------------------------------------------------
963
964    #[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        // §13.2 enumerates the live surface. Anything outside it is boot-only by construction.
990        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        // Same mutation payload, two different authority paths — the distinction the retired
1039        // shared task-update input could not express.
1040        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    // -----------------------------------------------------------------------------------------
1049    // governance constraint scalars
1050    // -----------------------------------------------------------------------------------------
1051
1052    #[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    // -----------------------------------------------------------------------------------------
1094    // cancel carries no duplicated identity
1095    // -----------------------------------------------------------------------------------------
1096
1097    #[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}