zero_commands/friction.rs
1//! Friction decisions — the runtime half of the risk-asymmetry
2//! invariant (ADR-013 / ADR-014, Addendum A §3 and §6.3).
3//!
4//! The compile-time half lives in `risk.rs`: a `FrictionGate` can
5//! only ever be parameterised over `Increases`. A risk-reducing
6//! or neutral command is *structurally unable* to be friction-
7//! wrapped. That's the guarantee.
8//!
9//! This module adds the runtime half: given the operator's
10//! current behavioural label and a command's [`RiskDirection`],
11//! produce a [`FrictionDecision`] — Proceed, Pause, or
12//! TypedConfirm — that a caller (the TUI, the non-interactive
13//! entrypoint, a headless scheduler) can honor.
14//!
15//! The decision is purposely stateless. The caller is responsible
16//! for the timer (Pause) and the input check (TypedConfirm); we
17//! only tell it *what* the friction shape is.
18//!
19//! # Invariants
20//!
21//! - `RiskDirection::Reduces` always resolves to [`FrictionDecision::Proceed`].
22//! This is tested. A regression here is the "operator can't
23//! `/kill` at 2 AM" failure mode the architecture exists to
24//! prevent.
25//! - `RiskDirection::Neutral` always resolves to Proceed. Reads,
26//! mode switches, log clears never pause.
27//! - `RiskDirection::Increases` picks Pause or TypedConfirm
28//! according to [`FrictionLevel::from_label`] (Phase 1:
29//! L0/L1/L2 only; L3/L4 are Phase 2).
30
31use std::time::Duration;
32
33use serde::{Deserialize, Serialize};
34use zero_operator_state::friction::{FrictionLevel, RiskContext};
35use zero_operator_state::label::Label;
36
37use crate::risk::RiskDirection;
38
39/// The confirmation word the operator must type at TILT (L2) to
40/// execute a risk-increasing command. Constant so tests, TUI, and
41/// automation key on the same value.
42///
43/// Per Addendum A §6.2: "At TILT the single-key `e` is replaced
44/// by the typed string `execute`."
45pub const TYPED_CONFIRM_WORD: &str = "execute";
46
47/// The typed re-read phrase the operator must enter verbatim at
48/// L3 (TILT + guardrail proximity) when no engine-reported
49/// drawdown number is available to tailor a richer sentence.
50///
51/// The intention of §3.2 is "re-read the exact disclosure phrase
52/// about current guardrail proximity". When drawdown/alert
53/// numbers are known, `decide_with_risk` formats a longer phrase
54/// that interpolates them; when the engine has not yet reported
55/// a pair (fresh connect, older engine), we fall back to this
56/// fixed string so the operator still has something concrete to
57/// type and the 30 s pause still applies. The phrase is
58/// deliberately longer than `execute` — the re-read is the
59/// friction; a short word would be an easier bypass than the
60/// typed proximity sentence.
61pub const FALLBACK_REREAD_PHRASE: &str = "i acknowledge i am approaching a hard guardrail";
62
63/// How the caller must honor friction for a single risk-increasing
64/// command.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case", tag = "kind")]
67pub enum FrictionDecision {
68 /// No friction — run the command immediately.
69 Proceed,
70 /// The operator must observe a visible countdown, then the
71 /// command runs. `pause` is the required duration (3s at L1).
72 ///
73 /// The caller owns the timer and is expected to render the
74 /// countdown so the operator sees the pause happening — it is
75 /// *not* a hidden delay.
76 Pause {
77 #[serde(with = "duration_seconds")]
78 pause: Duration,
79 level: FrictionLevel,
80 },
81 /// The operator must type [`TYPED_CONFIRM_WORD`] verbatim and
82 /// the `pause` must elapse before the command runs. This is
83 /// the TILT friction — ten-second pause + typed-word (§6.2).
84 ///
85 /// The confirm word itself is not serialised — it is fully
86 /// determined by the level and always reads as
87 /// [`TYPED_CONFIRM_WORD`]. Callers read it via
88 /// [`FrictionDecision::confirm_word`].
89 TypedConfirm {
90 #[serde(with = "duration_seconds")]
91 pause: Duration,
92 level: FrictionLevel,
93 },
94 /// M2 §3: L3 friction. The operator must wait out a longer
95 /// pause (30 s by default — see
96 /// [`FrictionLevel::pause`]) **and** type back the
97 /// proximity-disclosure `phrase` verbatim before the command
98 /// re-dispatches.
99 ///
100 /// Unlike [`Self::TypedConfirm`], the phrase here is dynamic
101 /// — it embeds the current drawdown / alert numbers so the
102 /// operator *reads what is happening right now* rather than
103 /// rote-typing `execute`. Serialised as-is so JSON consumers
104 /// can log the exact phrase shown to the operator.
105 WaitAndReread {
106 #[serde(with = "duration_seconds")]
107 pause: Duration,
108 level: FrictionLevel,
109 phrase: String,
110 },
111 /// M2 §3: L4 friction — refusal. The command is dropped and
112 /// no amount of waiting or typing can run it: the engine is
113 /// halted and the dead-man switch is load-bearing.
114 ///
115 /// Only `Reduces` commands continue to flow (they take the
116 /// un-gated path entirely — see [`decide_with_risk`]). The
117 /// `reason` carries the halt-flag label the engine reported
118 /// so the TUI can surface "engine halted: global_halt" rather
119 /// than a bare refusal.
120 HardStop {
121 level: FrictionLevel,
122 reason: String,
123 },
124}
125
126impl FrictionDecision {
127 /// The friction level this decision corresponds to. `Proceed`
128 /// maps to L0 — it is a useful value to surface on logs and
129 /// JSON so tooling can filter.
130 #[must_use]
131 pub const fn level(&self) -> FrictionLevel {
132 match self {
133 Self::Proceed => FrictionLevel::L0,
134 Self::Pause { level, .. }
135 | Self::TypedConfirm { level, .. }
136 | Self::WaitAndReread { level, .. }
137 | Self::HardStop { level, .. } => *level,
138 }
139 }
140
141 /// The required pause. `Proceed` and `HardStop` are zero —
142 /// `HardStop` because no pause redeems a refusal.
143 #[must_use]
144 pub const fn pause(&self) -> Duration {
145 match self {
146 Self::Proceed | Self::HardStop { .. } => Duration::ZERO,
147 Self::Pause { pause, .. }
148 | Self::TypedConfirm { pause, .. }
149 | Self::WaitAndReread { pause, .. } => *pause,
150 }
151 }
152
153 /// Whether this decision requires a typed confirmation.
154 /// True for L2 (`TypedConfirm`) and L3 (`WaitAndReread`); false
155 /// for L4 (`HardStop` — refusal cannot be typed past).
156 #[must_use]
157 pub const fn requires_typed_confirm(&self) -> bool {
158 matches!(self, Self::TypedConfirm { .. } | Self::WaitAndReread { .. })
159 }
160
161 /// The string the operator must type verbatim to clear this
162 /// decision's friction. `None` when no typing is required
163 /// (`Proceed`, `Pause`, `HardStop`).
164 ///
165 /// Returns a `Cow` because L2's word is a static
166 /// (`TYPED_CONFIRM_WORD`) while L3's phrase is owned by the
167 /// decision itself and varies with engine state.
168 #[must_use]
169 pub fn confirm_word(&self) -> Option<std::borrow::Cow<'_, str>> {
170 match self {
171 Self::TypedConfirm { .. } => Some(std::borrow::Cow::Borrowed(TYPED_CONFIRM_WORD)),
172 Self::WaitAndReread { phrase, .. } => Some(std::borrow::Cow::Borrowed(phrase.as_str())),
173 _ => None,
174 }
175 }
176
177 /// True for L4 refusals only. The dispatcher consults this to
178 /// decide whether a command is allowed to be carried as a
179 /// `pending_command` (it is not — L4 drops the command
180 /// entirely, leaving only `Reduces` commands to flow).
181 #[must_use]
182 pub const fn is_refusal(&self) -> bool {
183 matches!(self, Self::HardStop { .. })
184 }
185
186 /// The halt reason the engine reported, for `HardStop` only.
187 /// Lets callers render "engine halted: global_halt" rather
188 /// than a bare refusal.
189 #[must_use]
190 pub fn refusal_reason(&self) -> Option<&str> {
191 match self {
192 Self::HardStop { reason, .. } => Some(reason.as_str()),
193 _ => None,
194 }
195 }
196}
197
198/// Compute the friction decision for a command's risk direction
199/// given the operator's current behavioural label.
200///
201/// Honoring the invariant is this function's entire job:
202/// `Reduces` and `Neutral` always return `Proceed`. Only
203/// `Increases` reads the label.
204///
205/// This form caps at L2 — it does not see engine risk context.
206/// Use [`decide_with_risk`] from the dispatcher (which does) to
207/// reach L3 (`WaitAndReread`) / L4 (`HardStop`).
208///
209/// The function is `const` so unit tests and compile-time asserts
210/// can call it freely.
211#[must_use]
212pub const fn decide(direction: RiskDirection, label: Label) -> FrictionDecision {
213 match direction {
214 // Risk-reducing actions are never gated. Ever. This is the
215 // line you don't cross. See `reduces_never_gated` below.
216 RiskDirection::Reduces | RiskDirection::Neutral => FrictionDecision::Proceed,
217 RiskDirection::Increases => {
218 let level = FrictionLevel::from_label(label);
219 decision_for_level_const(level)
220 }
221 }
222}
223
224/// Compute the friction decision including the M2 §3 L3/L4
225/// escalations, given engine risk context.
226///
227/// - `Reduces` / `Neutral` → `Proceed` unconditionally (the
228/// load-bearing invariant, checked in `reduces_never_gated`).
229/// - `Increases` + non-TILT → same as [`decide`].
230/// - `Increases` + TILT + `risk.halted` → [`FrictionDecision::HardStop`].
231/// - `Increases` + TILT + near guardrail → [`FrictionDecision::WaitAndReread`].
232/// - `Increases` + TILT with no risk signal → L2 typed-confirm,
233/// same as [`decide`]; no surprise escalation.
234///
235/// The L4 `reason` is computed from `halt_label`, which the
236/// dispatcher derives by walking the engine-side halt flags in
237/// priority order (`stop_failure_halt` > `global_halt` > `halted`).
238/// Keeping it a `&str` parameter (rather than re-walking the
239/// `Risk` struct here) lets this crate stay off the
240/// `zero-engine-client` dependency.
241#[must_use]
242pub fn decide_with_risk(
243 direction: RiskDirection,
244 label: Label,
245 risk: RiskContext,
246 halt_reason: Option<&str>,
247 reread_phrase: Option<String>,
248) -> FrictionDecision {
249 match direction {
250 RiskDirection::Reduces | RiskDirection::Neutral => FrictionDecision::Proceed,
251 RiskDirection::Increases => {
252 let level = FrictionLevel::from_label_and_risk(label, risk);
253 decision_for_level(level, halt_reason, reread_phrase)
254 }
255 }
256}
257
258const fn decision_for_level_const(level: FrictionLevel) -> FrictionDecision {
259 match level {
260 FrictionLevel::L0 => FrictionDecision::Proceed,
261 FrictionLevel::L1 => FrictionDecision::Pause {
262 pause: level.pause(),
263 level,
264 },
265 // The const form is only reached by `decide`, which caps
266 // at L2; L3/L4 arms are defensive and would map to the
267 // same TypedConfirm shape the pre-M2 code emitted. They
268 // are not reachable via `decide` — only via
269 // `decide_with_risk`, which takes the owned-string path.
270 FrictionLevel::L2 | FrictionLevel::L3 | FrictionLevel::L4 => {
271 FrictionDecision::TypedConfirm {
272 pause: level.pause(),
273 level,
274 }
275 }
276 }
277}
278
279fn decision_for_level(
280 level: FrictionLevel,
281 halt_reason: Option<&str>,
282 reread_phrase: Option<String>,
283) -> FrictionDecision {
284 match level {
285 FrictionLevel::L0 => FrictionDecision::Proceed,
286 FrictionLevel::L1 => FrictionDecision::Pause {
287 pause: level.pause(),
288 level,
289 },
290 FrictionLevel::L2 => FrictionDecision::TypedConfirm {
291 pause: level.pause(),
292 level,
293 },
294 FrictionLevel::L3 => FrictionDecision::WaitAndReread {
295 pause: level.pause(),
296 level,
297 // If the dispatcher could not synthesise a dynamic
298 // disclosure phrase (missing drawdown numbers, older
299 // engine), fall back to the fixed phrase. Either way
300 // the operator types something concrete; we never
301 // reach this branch with an empty string.
302 phrase: reread_phrase.unwrap_or_else(|| FALLBACK_REREAD_PHRASE.to_string()),
303 },
304 FrictionLevel::L4 => FrictionDecision::HardStop {
305 level,
306 reason: halt_reason.map_or_else(|| "engine halted".to_string(), ToOwned::to_owned),
307 },
308 }
309}
310
311mod duration_seconds {
312 //! Human-facing JSON surface: serialise `Duration` as whole
313 //! seconds. The CLI never emits sub-second friction pauses
314 //! and operators read the JSON; fractional seconds are noise.
315
316 use serde::{Deserialize, Deserializer, Serializer};
317 use std::time::Duration;
318
319 pub fn serialize<S: Serializer>(d: &Duration, ser: S) -> Result<S::Ok, S::Error> {
320 ser.serialize_u64(d.as_secs())
321 }
322
323 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
324 let secs = u64::deserialize(de)?;
325 Ok(Duration::from_secs(secs))
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 // -------------------------------------------------------------
334 // The load-bearing invariant: Reduces and Neutral never gate.
335 // -------------------------------------------------------------
336
337 #[test]
338 fn reduces_never_gated_regardless_of_label() {
339 for label in [
340 Label::Fresh,
341 Label::Steady,
342 Label::Elevated,
343 Label::Fatigued,
344 Label::Tilt,
345 Label::Recovery,
346 ] {
347 assert_eq!(
348 decide(RiskDirection::Reduces, label),
349 FrictionDecision::Proceed,
350 "Reduces must never gate — label={label:?}"
351 );
352 }
353 }
354
355 #[test]
356 fn neutral_never_gated_regardless_of_label() {
357 for label in [
358 Label::Fresh,
359 Label::Steady,
360 Label::Elevated,
361 Label::Fatigued,
362 Label::Tilt,
363 Label::Recovery,
364 ] {
365 assert_eq!(
366 decide(RiskDirection::Neutral, label),
367 FrictionDecision::Proceed,
368 "Neutral must never gate — label={label:?}"
369 );
370 }
371 }
372
373 // -------------------------------------------------------------
374 // Increases picks the right level per label.
375 // -------------------------------------------------------------
376
377 #[test]
378 fn increases_fresh_steady_recovery_proceed() {
379 for label in [Label::Fresh, Label::Steady, Label::Recovery] {
380 assert_eq!(
381 decide(RiskDirection::Increases, label),
382 FrictionDecision::Proceed
383 );
384 }
385 }
386
387 #[test]
388 fn increases_elevated_requires_three_second_pause() {
389 let d = decide(RiskDirection::Increases, Label::Elevated);
390 assert_eq!(d.level(), FrictionLevel::L1);
391 assert_eq!(d.pause(), Duration::from_secs(3));
392 assert!(!d.requires_typed_confirm());
393 }
394
395 #[test]
396 fn increases_fatigued_requires_three_second_pause() {
397 let d = decide(RiskDirection::Increases, Label::Fatigued);
398 assert_eq!(d.level(), FrictionLevel::L1);
399 assert_eq!(d.pause(), Duration::from_secs(3));
400 }
401
402 #[test]
403 fn increases_tilt_requires_typed_confirm() {
404 let d = decide(RiskDirection::Increases, Label::Tilt);
405 assert_eq!(d.level(), FrictionLevel::L2);
406 assert_eq!(d.pause(), Duration::from_secs(10));
407 assert!(d.requires_typed_confirm());
408 assert_eq!(d.confirm_word().as_deref(), Some("execute"));
409 }
410
411 // -------------------------------------------------------------
412 // M2 §3: decide_with_risk escalates to L3 / L4.
413 // -------------------------------------------------------------
414
415 #[test]
416 fn decide_with_risk_reduces_always_proceeds_even_when_halted() {
417 // The load-bearing invariant: even if the engine is
418 // halted and the operator is tilted, a `Reduces` command
419 // (e.g. `/kill`) must pass through unchanged. This is the
420 // 2 AM failure mode we refuse to enable.
421 let ctx = RiskContext {
422 guardrail_proximity_pct: Some(0.1),
423 halted: true,
424 };
425 let d = decide_with_risk(
426 RiskDirection::Reduces,
427 Label::Tilt,
428 ctx,
429 Some("global_halt"),
430 None,
431 );
432 assert_eq!(d, FrictionDecision::Proceed);
433 }
434
435 #[test]
436 fn decide_with_risk_neutral_always_proceeds_even_when_halted() {
437 let ctx = RiskContext {
438 guardrail_proximity_pct: None,
439 halted: true,
440 };
441 let d = decide_with_risk(
442 RiskDirection::Neutral,
443 Label::Tilt,
444 ctx,
445 Some("global_halt"),
446 None,
447 );
448 assert_eq!(d, FrictionDecision::Proceed);
449 }
450
451 #[test]
452 fn decide_with_risk_tilt_plus_proximity_emits_wait_and_reread() {
453 let ctx = RiskContext {
454 guardrail_proximity_pct: Some(0.5),
455 halted: false,
456 };
457 let d = decide_with_risk(
458 RiskDirection::Increases,
459 Label::Tilt,
460 ctx,
461 None,
462 Some("drawdown 4.2% — within 0.5pp of 4.7% hard alert".into()),
463 );
464 assert_eq!(d.level(), FrictionLevel::L3);
465 assert_eq!(d.pause(), Duration::from_secs(30));
466 assert!(d.requires_typed_confirm());
467 assert_eq!(
468 d.confirm_word().as_deref(),
469 Some("drawdown 4.2% — within 0.5pp of 4.7% hard alert"),
470 "L3 phrase must be the dynamic disclosure, not `execute`"
471 );
472 assert!(!d.is_refusal());
473 }
474
475 #[test]
476 fn decide_with_risk_l3_falls_back_to_fixed_phrase_when_none_supplied() {
477 let ctx = RiskContext {
478 guardrail_proximity_pct: Some(0.5),
479 halted: false,
480 };
481 let d = decide_with_risk(RiskDirection::Increases, Label::Tilt, ctx, None, None);
482 assert_eq!(d.level(), FrictionLevel::L3);
483 assert_eq!(d.confirm_word().as_deref(), Some(FALLBACK_REREAD_PHRASE));
484 }
485
486 #[test]
487 fn decide_with_risk_tilt_plus_halt_emits_hard_stop() {
488 let ctx = RiskContext {
489 guardrail_proximity_pct: None,
490 halted: true,
491 };
492 let d = decide_with_risk(
493 RiskDirection::Increases,
494 Label::Tilt,
495 ctx,
496 Some("global_halt"),
497 None,
498 );
499 assert_eq!(d.level(), FrictionLevel::L4);
500 assert_eq!(d.pause(), Duration::ZERO);
501 assert!(!d.requires_typed_confirm());
502 assert_eq!(d.confirm_word(), None);
503 assert!(d.is_refusal());
504 assert_eq!(d.refusal_reason(), Some("global_halt"));
505 }
506
507 #[test]
508 fn decide_with_risk_hard_stop_without_reason_renders_fallback() {
509 let ctx = RiskContext {
510 guardrail_proximity_pct: None,
511 halted: true,
512 };
513 let d = decide_with_risk(RiskDirection::Increases, Label::Tilt, ctx, None, None);
514 assert_eq!(d.refusal_reason(), Some("engine halted"));
515 }
516
517 #[test]
518 fn decide_with_risk_no_escalation_signal_matches_decide() {
519 // Without guardrail proximity or halt, `decide_with_risk`
520 // must return the same shape as `decide`. This is the
521 // "no surprise escalation" guarantee that lets
522 // non-engine callers keep using `decide`.
523 for label in [
524 Label::Fresh,
525 Label::Steady,
526 Label::Elevated,
527 Label::Fatigued,
528 Label::Tilt,
529 Label::Recovery,
530 ] {
531 for dir in [
532 RiskDirection::Reduces,
533 RiskDirection::Neutral,
534 RiskDirection::Increases,
535 ] {
536 let plain = decide(dir, label);
537 let enriched = decide_with_risk(dir, label, RiskContext::default(), None, None);
538 assert_eq!(
539 plain, enriched,
540 "decide/decide_with_risk must agree when risk context is default \
541 (dir={dir:?}, label={label:?})"
542 );
543 }
544 }
545 }
546
547 // -------------------------------------------------------------
548 // Serialisation round-trip — JSON consumers depend on it.
549 // -------------------------------------------------------------
550
551 #[test]
552 fn decision_roundtrips_through_json() {
553 let d = decide(RiskDirection::Increases, Label::Tilt);
554 let s = serde_json::to_string(&d).expect("to-json");
555 assert!(s.contains("\"typed_confirm\""));
556 let back: FrictionDecision = serde_json::from_str(&s).expect("from-json");
557 assert_eq!(d, back);
558 assert_eq!(back.confirm_word().as_deref(), Some("execute"));
559 }
560
561 #[test]
562 fn l3_l4_decisions_roundtrip_through_json() {
563 let ctx_l3 = RiskContext {
564 guardrail_proximity_pct: Some(0.5),
565 halted: false,
566 };
567 let l3 = decide_with_risk(
568 RiskDirection::Increases,
569 Label::Tilt,
570 ctx_l3,
571 None,
572 Some("dd 4.2% within 0.5pp of 4.7%".into()),
573 );
574 let s = serde_json::to_string(&l3).unwrap();
575 assert!(s.contains("\"wait_and_reread\""));
576 assert!(s.contains("dd 4.2%"));
577 let back: FrictionDecision = serde_json::from_str(&s).unwrap();
578 assert_eq!(l3, back);
579
580 let ctx_l4 = RiskContext {
581 guardrail_proximity_pct: None,
582 halted: true,
583 };
584 let l4 = decide_with_risk(
585 RiskDirection::Increases,
586 Label::Tilt,
587 ctx_l4,
588 Some("stop_failure_halt"),
589 None,
590 );
591 let s = serde_json::to_string(&l4).unwrap();
592 assert!(s.contains("\"hard_stop\""));
593 assert!(s.contains("stop_failure_halt"));
594 let back: FrictionDecision = serde_json::from_str(&s).unwrap();
595 assert_eq!(l4, back);
596 }
597}