deepstrike_core/syscall/mod.rs
1//! Primitive P1: the single syscall trap boundary.
2//!
3//! Every variant of [`Syscall`] has a live gate call site; this is wiring, not a type scaffold.
4//! `evaluate_syscall` ([`crate::scheduler::state_machine`]'s `gate.rs`) is the one adjudicator, and
5//! it is reached from:
6//!
7//! * [`Syscall::Invoke`] — the governance pipeline, from `gate_tool_calls`;
8//! * [`Syscall::Spawn`] — `state_machine/process.rs`'s `spawn_sub_agent`, and the workflow run
9//! queue's deferrable variant in `spawn_ready_workflow_nodes`;
10//! * [`Syscall::WriteMemory`] — the `WriteMemory` trap in `runtime/kernel/runtime.rs`, and the
11//! canonical driver's `RequestMemoryWrite` reduction;
12//! * [`Syscall::SubmitNodes`] / [`Syscall::LoadWorkflow`] — `state_machine/workflow.rs`'s
13//! `append_workflow_nodes` and `submit_workflow`'s bootstrap arm.
14//!
15//! **Who the caller is, is not on this type.** A `Syscall` carries only *what* is asked. The
16//! canonical ABI derives the caller from kernel-owned causation — a tool call inside the provider
17//! result being resolved, or a kernel-issued child attempt (spec §7.6) — and refuses the request
18//! before this gate is reached when that derivation fails. There is no host-declared actor, and
19//! adding one to this enum would reintroduce exactly the bypass §22.10 rejects.
20//!
21//! Concept overlap this primitive collapses: the two parallel decision vocabularies
22//! ([`crate::types::policy::GovernanceVerdict`] and `SignalDisposition`). Tool/spawn/memory
23//! decisions converge on [`Disposition`]; signals feed the P2 scheduler instead.
24
25use crate::mm::memory::MemoryRecord;
26use crate::scheduler::tcb::WaitReason;
27use crate::types::agent::IsolationManifest;
28use crate::types::message::ToolCall;
29use crate::types::policy::GovernanceVerdict;
30
31/// An effectful request from the SDK that the kernel must adjudicate.
32///
33/// Every side-effecting service request becomes a `Syscall` variant; the opcode is **data**, so
34/// adding a service does not add a new ABI shape (unlike the per-feature `Load*Policy` events today).
35#[derive(Debug, Clone)]
36pub enum Syscall {
37 /// Model-proposed tool call (today: the only thing through the governance gate).
38 Invoke(ToolCall),
39 /// Spawn a sub-agent (today: bypasses the gate).
40 Spawn(IsolationManifest),
41 /// Persist a long-term memory entry.
42 WriteMemory(MemoryRecord),
43 /// R3-1: append `count` nodes to the in-flight workflow DAG at runtime. Gating DAG growth through
44 /// the trap lets a `ResourceQuota` backstop a runaway loop-until-done (denied past
45 /// `max_workflow_nodes`); per-node spawns are still gated separately by `Spawn`.
46 SubmitNodes { count: usize },
47 /// M5/G1: an agent authors a whole workflow `spec` (`node_count` nodes). Bootstraps the DAG when
48 /// none is active, else flattens onto it — either way it is gated by the same `max_workflow_nodes`
49 /// quota as `SubmitNodes` (a spec is just a node batch with a bootstrap fast-path), so an
50 /// agent-authored harness cannot overgrow the DAG past the run's budget.
51 LoadWorkflow { node_count: usize },
52}
53
54/// The kernel's adjudication of a [`Syscall`]. Generalizes [`GovernanceVerdict`]:
55/// `AskUser` becomes [`Disposition::Gate`] (suspend the calling task via the P2 TCB),
56/// which is where this primitive meets P2.
57#[derive(Debug, Clone)]
58pub enum Disposition {
59 /// Proceed as requested.
60 Allow,
61 /// Reject. `stage` names the gate stage that vetoed.
62 Deny { stage: &'static str, reason: String },
63 /// Suspend the calling task until an external party resolves it (e.g. human approval).
64 /// `reason` carries the human-readable justification (e.g. the governance `AskUser` reason).
65 Gate { wait: WaitReason, reason: String },
66 /// Accept but queue for later scheduling (backpressure).
67 Defer { slot: u32 },
68 /// Rejected by a rate limiter; retry permitted after the delay.
69 RateLimited { retry_after_ms: u64 },
70}
71
72impl Disposition {
73 /// Whether the syscall may proceed to execution now.
74 pub fn is_allowed(&self) -> bool {
75 matches!(self, Self::Allow)
76 }
77}
78
79/// Bridge from the existing tool-decision vocabulary. `AskUser` → `Gate(Approval)`: a tool
80/// awaiting human approval suspends the task, which M2+M1 realize via the TCB.
81impl From<GovernanceVerdict> for Disposition {
82 fn from(verdict: GovernanceVerdict) -> Self {
83 match verdict {
84 GovernanceVerdict::Allow => Disposition::Allow,
85 GovernanceVerdict::Deny { stage, reason } => Disposition::Deny { stage, reason },
86 GovernanceVerdict::RateLimited { retry_after_ms } => {
87 Disposition::RateLimited { retry_after_ms }
88 }
89 GovernanceVerdict::AskUser { reason } => Disposition::Gate {
90 wait: WaitReason::Approval,
91 reason,
92 },
93 }
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn verdict_allow_maps_to_allow() {
103 let d: Disposition = GovernanceVerdict::Allow.into();
104 assert!(d.is_allowed());
105 }
106
107 #[test]
108 fn verdict_deny_preserves_stage_and_reason() {
109 let d: Disposition = GovernanceVerdict::Deny {
110 stage: "veto",
111 reason: "blocked".into(),
112 }
113 .into();
114 match d {
115 Disposition::Deny { stage, reason } => {
116 assert_eq!(stage, "veto");
117 assert_eq!(reason, "blocked");
118 }
119 other => panic!("expected Deny, got {other:?}"),
120 }
121 assert!(
122 !Disposition::Deny {
123 stage: "veto",
124 reason: String::new()
125 }
126 .is_allowed()
127 );
128 }
129
130 #[test]
131 fn verdict_ask_user_maps_to_gate_approval() {
132 let d: Disposition = GovernanceVerdict::AskUser {
133 reason: "confirm".into(),
134 }
135 .into();
136 assert!(matches!(
137 &d,
138 Disposition::Gate { wait: WaitReason::Approval, reason } if reason == "confirm"
139 ));
140 assert!(!d.is_allowed());
141 }
142
143 #[test]
144 fn verdict_rate_limited_preserves_delay() {
145 let d: Disposition = GovernanceVerdict::RateLimited {
146 retry_after_ms: 500,
147 }
148 .into();
149 assert!(matches!(
150 d,
151 Disposition::RateLimited {
152 retry_after_ms: 500
153 }
154 ));
155 }
156}