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}