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. `SIGNAL_POLICY_VERSION` and
161/// `SignalPolicyConfig.version` are gone: their optimistic-concurrency role is now
162/// [`ApplyPolicyPatchCommand::expected_revision`], and §16.1 leaves exactly two revision markers
163/// on the wire (`abi_version`, `checkpoint_version`).
164#[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/// Signal routing policy. Replaced atomically — a partial signal policy is not a thing.
180#[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/// Syscall-gate policy — the complete posture, replaced atomically.
197///
198/// The same type is the operation's initial governance (§13.1) and the payload of the one live
199/// governance mutation (§13.2). That is deliberate and is *not* the historical mistake: the
200/// legacy `ConfigureRun.governance` and `LoadGovernancePolicy` shared an **implementation** while
201/// living on two wire paths with no authority distinction. Here there is one wire shape, and the
202/// authority is carried by the input class that transports it — `ConfigureOperation` once, or a
203/// revision-guarded [`ApplyPolicyPatchCommand`] afterwards.
204#[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/// Per-tool rolling-window rate limit.
220#[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/// Structural constraint on a tool call's parameters. Pattern/predicate matching stays in the host
229/// execution layer — the kernel only enforces shapes it can replay identically.
230///
231/// The addressed field is `param_path`, not `path`: it points inside the call's *arguments*, and
232/// a bare `path` in a kernel input is exactly the host filesystem fact §7.4 keeps out of the ABI.
233///
234/// [`RangeParam`] carries its bounds as **fixed-point micro-units** rather than `f64`: a range
235/// check is an authoritative branch, and §7.1.1 keeps language-default floats out of those.
236#[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    /// Inclusive lower bound in micro-units (`1_500_000` ⇒ `1.5`).
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub min_micros: Option<i64>,
267    /// Inclusive upper bound in micro-units.
268    #[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    /// Structural self-consistency. A constraint that can never hold is a configuration error,
290    /// not a permanently-denying gate discovered on the first tool call.
291    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/// Quotas only ever shrink at runtime. Growing one is a reservation fact, not a policy patch, and
340/// needs its own contract before it exists.
341#[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/// Semantic recovery ladders the kernel owns. Host transport retry/backoff stays host-side.
361#[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    /// §12.3 · how much journal tail this operation may carry between checkpoints. Boot-only and
369    /// frozen in the genesis record, because the bound a run was refused against must survive a
370    /// binary whose defaults moved.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub tail_bounds: Option<TailBoundsPolicy>,
373}
374
375/// Sparse override of [`TailBounds`](super::config::TailBounds). Each axis is independent: a host
376/// that only wants to shorten the record watermark keeps the kernel's byte bounds.
377#[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/// Absolute deadline for the operation. `None` clears it.
391#[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// ---------------------------------------------------------------------------------------------
399// live policy state (§13.2, DEC-6)
400// ---------------------------------------------------------------------------------------------
401
402/// The kernel-side live policy state: the resolved configuration plus the revision that guards
403/// mutations of it.
404///
405/// Two rules live here rather than at each callsite:
406///
407/// 1. **Optimistic concurrency belongs to the patch, not to a policy.** DEC-6 removed
408///    `SIGNAL_POLICY_VERSION` and `SignalPolicyConfig.version`; a signal policy has no version of
409///    its own, and the single revision counter below is what two concurrent writers race on.
410///    A patch whose `expected_revision` does not match the current one is refused, so the second
411///    writer re-reads instead of silently overwriting the first.
412/// 2. **Live resource changes only ever shrink.** Growing a quota is a *reservation fact* — it
413///    needs an admission decision from whoever owns the budget — not a policy edit, so it has no
414///    representation here.
415#[derive(Debug, Clone, PartialEq)]
416pub struct LivePolicyState {
417    revision: WireU64,
418    config: super::config::ResolvedOperationConfig,
419}
420
421impl LivePolicyState {
422    /// Start from the genesis record's resolved configuration at revision 0.
423    pub fn new(config: super::config::ResolvedOperationConfig) -> Self {
424        Self {
425            revision: WireU64::ZERO,
426            config,
427        }
428    }
429
430    /// §12.2 · reinstall the live policy a checkpoint recorded, revision included.
431    ///
432    /// Distinct from [`Self::new`] on purpose: `new` starts a *fresh* operation at revision 0 with
433    /// the genesis configuration, and using it for a restore would silently rewind the revision a
434    /// concurrent patch writer is racing on.
435    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    /// Apply one revision-guarded patch.
448    ///
449    /// Atomic in the same structural sense as configuration resolution: the next configuration is
450    /// built and validated as a whole *before* anything is stored, so a rejected patch leaves both
451    /// the configuration and the revision exactly as they were.
452    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    /// Produce the configuration this patch would install, or reject it. Pure: it never mutates
478    /// the input, which is what makes [`LivePolicyState::apply`] all-or-nothing.
479    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(&current.resource_quota)?;
517                validate_quota(&next.resource_quota)?;
518            }
519            Self::ReplaceRecoveryPolicy(patch) => {
520                // §13.1 · the semantic recovery ladders are live-mutable, the tail bound is not.
521                // It is frozen in the genesis record (§5e-5), and a live widen would let a run
522                // escape the very bound a `CheckpointRequired` just refused it against.
523                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    /// Fold this patch onto the current quota, refusing any axis that would widen it.
550    ///
551    /// "Uncapped ⇒ capped" is a tightening and is allowed; "capped ⇒ uncapped" is not expressible
552    /// (an absent axis means *unchanged* here, not *cleared*), and a larger cap is refused
553    /// outright rather than clamped — a silently clamped budget is a budget the host thinks it
554    /// raised.
555    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            // not a live axis: a rate window is part of the governance posture, and the live form
602            // of that is `ReplaceGovernancePolicy`
603            memory_writes_per_window: current.memory_writes_per_window.clone(),
604        })
605    }
606}
607
608// ---------------------------------------------------------------------------------------------
609// tests
610// ---------------------------------------------------------------------------------------------
611
612#[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            // a quota that declares spawn capacity obliges the host to declare it can launch and
626            // stop child tasks (DEC-8 config-time cross-validation)
627            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    // -----------------------------------------------------------------------------------------
660    // DEC-6 · optimistic concurrency lives on the patch, not on a policy
661    // -----------------------------------------------------------------------------------------
662
663    #[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        // untouched axes survive a partial patch
684        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        // the second writer read revision 0 before the first writer committed
702        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        // rebasing onto the new revision is the whole point of refusing
716        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        // DEC-6 / §16.1: `SIGNAL_POLICY_VERSION` and `SignalPolicyConfig.version` are gone; the
753        // revision is `ApplyPolicyPatchCommand::expected_revision` and nothing else.
754        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    // -----------------------------------------------------------------------------------------
763    // live quota changes only ever tighten
764    // -----------------------------------------------------------------------------------------
765
766    #[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    /// §12.3 / §5e-5 · the tail bound is frozen by the genesis record. A live patch that tried to
918    /// widen it would let a run escape the very limit a `CheckpointRequired` just refused it
919    /// against — and one that tried to narrow it would refuse inputs the journal already accepted.
920    #[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        // the rest of the recovery policy is still live, and the bound rides through untouched
948        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    // -----------------------------------------------------------------------------------------
965    // §13.2 · the live union is closed
966    // -----------------------------------------------------------------------------------------
967
968    #[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        // §13.2 enumerates the live surface. Anything outside it is boot-only by construction.
994        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        // Same mutation payload, two different authority paths — the distinction the retired
1043        // shared task-update input could not express.
1044        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    // -----------------------------------------------------------------------------------------
1053    // governance constraint scalars
1054    // -----------------------------------------------------------------------------------------
1055
1056    #[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    // -----------------------------------------------------------------------------------------
1098    // cancel carries no duplicated identity
1099    // -----------------------------------------------------------------------------------------
1100
1101    #[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}