harness_loop_engine/level.rs
1//! Loop maturity levels and the human-gate abstraction.
2//!
3//! Loop engineering's central safety idea: a loop earns autonomy in
4//! stages. You start **report-only**, graduate to **assisted** (the loop
5//! proposes, a human approves), and only the narrowest, well-fenced loops
6//! ever run **unattended**. Each level changes two things: whether the
7//! maker sub-agent may write at all, and how the gate decides what to do
8//! with a verified proposal.
9
10use serde::{Deserialize, Serialize};
11use std::sync::Arc;
12
13/// How much autonomy a loop is trusted with.
14///
15/// The level is the single knob that ties together write-capability and
16/// gate policy — it is *not* the same as a tool permission mode (that is
17/// `harness-permissions`, which the engine derives from the level).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum LoopLevel {
21 /// **L1 — Report-only.** Discovery and visibility, no automated fixes.
22 /// The maker runs read-only; every proposal is escalated to a human as
23 /// a report. The safest place to start any loop.
24 L1Report,
25 /// **L2 — Assisted.** The maker may write inside an isolated sandbox,
26 /// but a human gates every change before it lands. The default for a
27 /// loop you are actively building trust in.
28 L2Assisted,
29 /// **L3 — Unattended.** Runs within strict guardrails: only actions on
30 /// the gate's allowlist commit automatically; anything else still
31 /// escalates. Reserve for narrow, well-understood loops.
32 L3Unattended,
33}
34
35impl LoopLevel {
36 /// Whether the maker sub-agent is permitted to mutate the workspace at
37 /// this level. L1 is strictly read-only.
38 pub fn maker_may_write(self) -> bool {
39 !matches!(self, LoopLevel::L1Report)
40 }
41
42 /// Short stable label for logs, reports, and memory entries.
43 pub fn label(self) -> &'static str {
44 match self {
45 LoopLevel::L1Report => "L1-report",
46 LoopLevel::L2Assisted => "L2-assisted",
47 LoopLevel::L3Unattended => "L3-unattended",
48 }
49 }
50}
51
52/// A change the maker produced and the checker verified, presented to the
53/// gate for a proceed-or-escalate decision.
54#[derive(Debug, Clone)]
55pub struct ProposedAction {
56 /// Stable kind used for allowlisting, e.g. `"commit"`, `"open-pr"`,
57 /// `"apply-patch"`, `"comment"`. Free-form; the gate matches on it.
58 pub kind: String,
59 /// Human-readable summary of what the loop wants to do.
60 pub summary: String,
61 /// Whether the checker reported the work as clean (tests/gates passed).
62 /// A gate may auto-proceed only on verified work.
63 pub verified: bool,
64}
65
66impl ProposedAction {
67 pub fn new(kind: impl Into<String>, summary: impl Into<String>, verified: bool) -> Self {
68 Self {
69 kind: kind.into(),
70 summary: summary.into(),
71 verified,
72 }
73 }
74}
75
76/// The gate's verdict for one proposed action.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum GateDecision {
79 /// Safe / allowlisted / verified — let the loop carry the action out.
80 AutoProceed,
81 /// Risky or ambiguous — hand it to a human with context. The loop
82 /// records the escalation and recurses on the next tick.
83 Escalate { reason: String },
84}
85
86impl GateDecision {
87 pub fn escalate(reason: impl Into<String>) -> Self {
88 GateDecision::Escalate {
89 reason: reason.into(),
90 }
91 }
92 pub fn is_auto(&self) -> bool {
93 matches!(self, GateDecision::AutoProceed)
94 }
95}
96
97/// Decides what happens to a verified proposal. Implementations encode the
98/// human-gate policy; the engine consults this once per round.
99pub trait HumanGate: Send + Sync {
100 fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision;
101}
102
103/// Never auto-proceeds — every proposal is escalated. This is the correct
104/// gate for L1 loops (and a safe default for anything you're unsure about).
105pub struct AlwaysEscalate;
106
107impl HumanGate for AlwaysEscalate {
108 fn decide(&self, _level: LoopLevel, _action: &ProposedAction) -> GateDecision {
109 GateDecision::escalate("report-only / human review required")
110 }
111}
112
113/// Auto-proceeds only for verified actions whose `kind` is on the
114/// allowlist, and only at L3. Everything else escalates. This is the
115/// workhorse gate for unattended loops with a narrow blast radius.
116pub struct AllowlistGate {
117 allow_kinds: Vec<String>,
118}
119
120impl AllowlistGate {
121 pub fn new<I, S>(kinds: I) -> Self
122 where
123 I: IntoIterator<Item = S>,
124 S: Into<String>,
125 {
126 Self {
127 allow_kinds: kinds.into_iter().map(Into::into).collect(),
128 }
129 }
130}
131
132impl HumanGate for AllowlistGate {
133 fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision {
134 if level != LoopLevel::L3Unattended {
135 return GateDecision::escalate("allowlist gate only auto-proceeds at L3");
136 }
137 if !action.verified {
138 return GateDecision::escalate("checker did not verify the work");
139 }
140 if self.allow_kinds.iter().any(|k| k == &action.kind) {
141 GateDecision::AutoProceed
142 } else {
143 GateDecision::escalate(format!("action kind `{}` is not allowlisted", action.kind))
144 }
145 }
146}
147
148/// Wraps an arbitrary closure as a gate — for custom policies (budget-aware,
149/// time-of-day, denylist, MCP-scope checks, …).
150pub struct CallbackGate<F>(pub F)
151where
152 F: Fn(LoopLevel, &ProposedAction) -> GateDecision + Send + Sync;
153
154impl<F> HumanGate for CallbackGate<F>
155where
156 F: Fn(LoopLevel, &ProposedAction) -> GateDecision + Send + Sync,
157{
158 fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision {
159 (self.0)(level, action)
160 }
161}
162
163/// The gate a level implies when the caller doesn't specify one.
164///
165/// L1 and L2 both default to [`AlwaysEscalate`] — L1 because it only ever
166/// reports, L2 because a human must gate every change. L3 has no safe
167/// default (an unattended loop needs an explicit allowlist), so it also
168/// defaults to `AlwaysEscalate` until the caller supplies an
169/// [`AllowlistGate`] via `LoopEngine::with_gate`.
170pub fn default_gate_for(_level: LoopLevel) -> Arc<dyn HumanGate> {
171 Arc::new(AlwaysEscalate)
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn l1_is_read_only() {
180 assert!(!LoopLevel::L1Report.maker_may_write());
181 assert!(LoopLevel::L2Assisted.maker_may_write());
182 assert!(LoopLevel::L3Unattended.maker_may_write());
183 }
184
185 #[test]
186 fn always_escalate_never_proceeds() {
187 let g = AlwaysEscalate;
188 let a = ProposedAction::new("commit", "x", true);
189 assert!(!g.decide(LoopLevel::L3Unattended, &a).is_auto());
190 }
191
192 #[test]
193 fn allowlist_gate_only_auto_at_l3_for_verified_allowlisted() {
194 let g = AllowlistGate::new(["comment", "commit"]);
195 // L3 + verified + allowlisted -> auto
196 assert!(
197 g.decide(
198 LoopLevel::L3Unattended,
199 &ProposedAction::new("commit", "s", true)
200 )
201 .is_auto()
202 );
203 // not allowlisted -> escalate
204 assert!(
205 !g.decide(
206 LoopLevel::L3Unattended,
207 &ProposedAction::new("force-push", "s", true)
208 )
209 .is_auto()
210 );
211 // not verified -> escalate
212 assert!(
213 !g.decide(
214 LoopLevel::L3Unattended,
215 &ProposedAction::new("commit", "s", false)
216 )
217 .is_auto()
218 );
219 // wrong level -> escalate
220 assert!(
221 !g.decide(
222 LoopLevel::L2Assisted,
223 &ProposedAction::new("commit", "s", true)
224 )
225 .is_auto()
226 );
227 }
228
229 #[test]
230 fn callback_gate_runs_closure() {
231 let g = CallbackGate(|_lvl, a: &ProposedAction| {
232 if a.kind == "ok" {
233 GateDecision::AutoProceed
234 } else {
235 GateDecision::escalate("nope")
236 }
237 });
238 assert!(
239 g.decide(
240 LoopLevel::L3Unattended,
241 &ProposedAction::new("ok", "", true)
242 )
243 .is_auto()
244 );
245 }
246}