Skip to main content

zero_commands/
risk.rs

1//! Risk direction + friction gate invariant (ADR-014).
2//!
3//! Every operator-initiated command carries a [`RiskDirection`].
4//! Risk-reducing actions (`/kill`, `/flatten-all`, `/close`,
5//! `/pause-entries`, `/break`) are always instant and friction-exempt.
6//! Risk-increasing actions (opening positions, composition changes)
7//! pass through [`FrictionGate`], which is parameterized so that
8//! only `Increases` can ever be wrapped. Attempting to apply the
9//! gate to a `Reduces` or `Neutral` command is a compile error.
10//!
11//! See also `zero-operator-state::friction::FrictionGate`, which
12//! uses the same sealed-trait pattern on the state-vector side.
13
14use serde::{Deserialize, Serialize};
15
16/// The direction a command moves risk.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum RiskDirection {
20    /// The command can open, enlarge, or resume risk.
21    Increases,
22    /// The command closes, shrinks, or pauses risk.
23    Reduces,
24    /// The command changes nothing that affects exposure (reads,
25    /// mode switches, log clears).
26    Neutral,
27}
28
29/// Sealed marker trait — only implemented by [`Increases`] below.
30/// External crates cannot implement it, which keeps the invariant
31/// "only risk-increasing commands are friction-gated" enforceable
32/// at compile time.
33pub trait Gateable: sealed::Sealed + Copy + 'static {
34    /// Runtime echo of the compile-time direction, for logging.
35    const DIRECTION: RiskDirection;
36}
37
38/// Phantom marker for compile-time direction checking.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Increases;
41
42impl sealed::Sealed for Increases {}
43impl Gateable for Increases {
44    const DIRECTION: RiskDirection = RiskDirection::Increases;
45}
46
47mod sealed {
48    pub trait Sealed {}
49}
50
51/// Compile-time-checked friction wrapper. Construct via
52/// [`FrictionGate::new`], which accepts only [`Increases`]-typed
53/// commands; the function signature prevents callers from
54/// accidentally friction-wrapping a risk-reducing action.
55///
56/// # Risk-asymmetry invariant (compile-time)
57///
58/// The point of the type parameter is to make this line
59/// *unable to compile*:
60///
61/// ```compile_fail
62/// use zero_commands::risk::FrictionGate;
63///
64/// // A local `Reduces` phantom. Not `Gateable` — there is no
65/// // way for an external crate to make it `Gateable`, because
66/// // `Gateable` is sealed to this crate (see `sealed` module).
67/// #[derive(Clone, Copy)]
68/// struct Reduces;
69///
70/// // This must be a compile error, not a runtime one. If it
71/// // compiles, the type-level guarantee is gone and a
72/// // risk-reducing command could be wrapped in friction.
73/// let _: FrictionGate<Reduces> = FrictionGate::new();
74/// ```
75///
76/// The control is also positive: a `FrictionGate<Increases>`
77/// is constructible and reports its direction honestly.
78///
79/// ```
80/// use zero_commands::risk::{FrictionGate, Increases, RiskDirection};
81/// let g = FrictionGate::<Increases>::new();
82/// assert_eq!(g.direction(), RiskDirection::Increases);
83/// ```
84#[derive(Debug, Clone, Copy)]
85pub struct FrictionGate<D: Gateable> {
86    _direction: std::marker::PhantomData<D>,
87}
88
89impl Default for FrictionGate<Increases> {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl FrictionGate<Increases> {
96    #[must_use]
97    pub const fn new() -> Self {
98        Self {
99            _direction: std::marker::PhantomData,
100        }
101    }
102
103    /// The direction this gate operates on. Useful for logging
104    /// when a friction pause is shown to the operator.
105    #[must_use]
106    pub const fn direction(&self) -> RiskDirection {
107        Increases::DIRECTION
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::{FrictionGate, Increases, RiskDirection};
114
115    #[test]
116    fn gate_reports_direction() {
117        let g = FrictionGate::<Increases>::new();
118        assert_eq!(g.direction(), RiskDirection::Increases);
119    }
120
121    // The compile-fail + positive-construction contracts live
122    // as doctests on `FrictionGate` itself so they run under
123    // `cargo test --doc` (a `cfg(test)` private doctest would
124    // never be collected by the doc-test harness).
125}