tear_types/freio.rs
1//! freio — the operator's brake.
2//!
3//! Portuguese for exactly what it is. One gesture stops every pane an
4//! automation is driving, without killing anything, and leaves every pane
5//! the operator is typing in untouched.
6//!
7//! ## What it is NOT, and why
8//!
9//! **Not `SIGSTOP`.** Wrong target: the agent is not in the pane, it is on
10//! the other end of a socket calling `send_keys`. Stopping the shell does
11//! not stop the writes — they land in the kernel PTY buffer and execute
12//! the instant you continue it. You would brake the symptom and buffer the
13//! cause. It is also not cleanly undoable (a process stopped mid-DECSET
14//! leaves terminal modes a continue does not repair) and a daemon crash
15//! between stop and continue leaves stopped orphans. A panic button whose
16//! failure mode is "your shell is frozen forever" is not a panic button.
17//!
18//! **Not a bare `frozen: bool`.** That creates a SECOND authority over
19//! "may this pane accept input", beside `input_policy`. Two authorities
20//! over one question is exactly how you get a pane that reports `Free`
21//! while refusing input.
22//!
23//! **What it is:** a session-scoped typed hold, consulted *before* the
24//! policy lattice by one total function. Consulting it first is what makes
25//! it non-advisory — a pane explicitly pinned to `Free` still cannot
26//! escape a brake, because the brake is answered before the pin is ever
27//! read.
28//!
29//! ## Why session-scoped rather than daemon-global
30//!
31//! One home for the state, and a killed session takes its brake with it.
32//! The one-gesture ergonomics live in the VERB — `tear freio` fans out
33//! over every session — rather than in a daemon-global flag that could
34//! drift out of sync with the per-session records it is supposed to
35//! describe.
36
37use serde::{Deserialize, Serialize};
38
39/// Whether an operator has braked a session's automation.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(tag = "kind", rename_all = "snake_case")]
42pub enum Freio {
43 /// Not braked. What every pre-freio session record deserialises to, so
44 /// landing this type is a no-op on existing state.
45 #[default]
46 Released,
47 /// Every pane in this session whose [`crate::yurai::Yurai`] is
48 /// `Automation` refuses input.
49 ///
50 /// Human and Unknown panes are untouched — **the operator keeps
51 /// typing, always.** That is not a convenience; a brake that can lock
52 /// you out of your own terminal during the emergency you engaged it
53 /// for is worse than no brake.
54 ///
55 /// `at_unix` is stamped BY THE DAEMON. No wire request carries it, so
56 /// a backdated brake has no syntax.
57 Engaged { at_unix: u64 },
58}
59
60impl Freio {
61 #[must_use]
62 pub const fn is_engaged(self) -> bool {
63 matches!(self, Self::Engaged { .. })
64 }
65
66 /// When the brake was engaged, if it is.
67 #[must_use]
68 pub const fn engaged_at(self) -> Option<u64> {
69 match self {
70 Self::Engaged { at_unix } => Some(at_unix),
71 Self::Released => None,
72 }
73 }
74}
75
76/// What input a pane ACTUALLY accepts right now.
77///
78/// The single answer to a question that currently has two authorities: the
79/// `Locked` check inside `tear-core`'s `send_keys` and the `Leader` check
80/// in the daemon's serve loop. freio must not make that three, so it joins
81/// them rather than adding to them.
82#[derive(Copy, Clone, Debug, PartialEq, Eq)]
83pub enum Admission {
84 /// Anyone may write.
85 Accept,
86 Refuse(RefusalReason),
87 /// Only the connection whose client id matches.
88 ///
89 /// The DAEMON resolves this; `tear-core` structurally cannot, because
90 /// there is no client identity at the in-process trait surface. That
91 /// split is today's reality made explicit rather than commented.
92 OnlyLeader { id: u64 },
93}
94
95/// Why input was refused. Distinct variants because the operator-facing
96/// message differs: a policy refusal is a state they set, a freio refusal
97/// is a brake they can release.
98#[derive(Copy, Clone, Debug, PartialEq, Eq)]
99pub enum RefusalReason {
100 Policy,
101 Freio,
102}
103
104impl RefusalReason {
105 #[must_use]
106 pub const fn label(self) -> &'static str {
107 match self {
108 Self::Policy => "policy",
109 Self::Freio => "freio",
110 }
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn released_is_the_default_so_landing_freio_changes_nothing() {
120 assert_eq!(Freio::default(), Freio::Released);
121 assert!(!Freio::default().is_engaged());
122 assert_eq!(Freio::default().engaged_at(), None);
123 }
124
125 #[test]
126 fn an_engaged_brake_remembers_when() {
127 let f = Freio::Engaged { at_unix: 1_785_000_000 };
128 assert!(f.is_engaged());
129 assert_eq!(f.engaged_at(), Some(1_785_000_000));
130 }
131
132 /// The two refusals must stay distinguishable: one is a state the
133 /// operator set, the other is a brake they can release, and telling a
134 /// user the wrong one sends them to the wrong fix.
135 #[test]
136 fn a_freio_refusal_is_not_a_policy_refusal() {
137 assert_ne!(
138 Admission::Refuse(RefusalReason::Freio),
139 Admission::Refuse(RefusalReason::Policy)
140 );
141 assert_eq!(RefusalReason::Freio.label(), "freio");
142 }
143
144 /// ★ No wire syntax for a backdated brake.
145 ///
146 /// `at_unix` exists on the type because a client must be able to SEE
147 /// when a brake was engaged. It must never be settable by a peer — the
148 /// same discipline that made `SessionSource` derived rather than
149 /// declared. The request carries a bool; the daemon stamps the time.
150 #[test]
151 fn the_engaged_variant_is_daemon_minted_only() {
152 // A peer's request shape is `SetFreio { engaged: bool }`. This row
153 // pins the reason: if a request ever carried a `Freio` directly,
154 // this deserialisation would be a peer-supplied timestamp.
155 let json = r#"{"kind":"engaged","at_unix":1}"#;
156 let f: Freio = serde_json::from_str(json).expect("Freio is decodable");
157 assert_eq!(f.engaged_at(), Some(1));
158 // …which is exactly why no Request variant may carry one. Guarded
159 // by a source scan over wire.rs, not by this type.
160 }
161}