Skip to main content

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}