wire/control.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The control surface: which frames intervene in work already running, and
7//! what an intervention owes when it does.
8//!
9//! A human's Steer arrives here, not through an Inbox;
10//! an Agent's Steer arrives as a high-priority Signal. Both land in the same
11//! place - the result envelope - so the model only ever learns one shape.
12//!
13//! This module decides and returns; it holds no Ledger handle and writes
14//! nothing. The obligation it reports is discharged by the assembly layer.
15
16use kernel::{AxCode, AxError, RunId};
17
18use crate::command::Command;
19
20/// The verbs the control surface shows. Three interventions plus `Release`,
21/// the return path that `Halt` needs in order not to be a trap.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Intervention {
24 Steer,
25 Cancel,
26 Halt,
27 Release,
28}
29
30impl Intervention {
31 /// The verb as the interface spells it. This is also the microcopy
32 /// authority: one verb, one word, everywhere.
33 #[must_use]
34 pub fn as_str(self) -> &'static str {
35 match self {
36 Self::Steer => "Steer",
37 Self::Cancel => "Cancel",
38 Self::Halt => "Halt",
39 Self::Release => "Release",
40 }
41 }
42}
43
44/// What the control surface makes of one Command.
45#[derive(Debug)]
46pub enum ControlVerdict {
47 Intervene {
48 verb: Intervention,
49 /// The Run being interrupted, when the verb names one. `Halt` and
50 /// `Release` act on a scope and leave this empty.
51 run: Option<RunId>,
52 /// Whether the assembly layer owes a `handoff_written` before the
53 /// intervention is complete.
54 must_write_handoff: bool,
55 },
56 NotAnIntervention,
57 Refuse(AxError),
58}
59
60/// Classifies one Command.
61///
62/// Pure and exhaustive: every one of the Commands is answered, so
63/// a new Command cannot be added without deciding whether it interrupts
64/// anything. That question is the reason this module is not part of the
65/// listener - the listener could not answer it, and would not know it had
66/// been asked.
67#[must_use]
68pub fn classify(command: &Command) -> ControlVerdict {
69 match *command {
70 Command::Steer { run, ref text, .. } => {
71 if text.trim().is_empty() {
72 return ControlVerdict::Refuse(
73 AxError::failure(
74 AxCode::WireMismatch,
75 "steer a Run",
76 "the steer carries no text",
77 )
78 .with_recovery("say what should change, or use Cancel to stop the Run"),
79 );
80 }
81 intervene(Intervention::Steer, Some(run))
82 }
83 Command::Cancel { run, .. } => intervene(Intervention::Cancel, Some(run)),
84 Command::Halt { .. } => scope_intervention(Intervention::Halt),
85 Command::Release { .. } => scope_intervention(Intervention::Release),
86 Command::Dispatch { .. }
87 | Command::Wake { .. }
88 | Command::ProbeEndpoint { .. }
89 | Command::ConfigureBuilding { .. }
90 | Command::AttachEndpoint { .. }
91 | Command::SelectModel { .. }
92 // Starting a session reaches no run in flight: the assembly
93 // refuses it with `E_BUSY` while one is going, and that refusal
94 // belongs where the run is, not here.
95 | Command::OpenSession { .. }
96 | Command::CreateBuilding { .. }
97 // Removing a building with a run going is refused where the
98 // runs are, so this verb never reaches one.
99 | Command::RemoveBuilding { .. }
100 | Command::PutSecret { .. }
101 // Opening a file manager reaches nothing a run is doing.
102 | Command::Reveal { .. }
103 // Putting a discarded file back only creates a file that is
104 // absent: memory refuses a link on the path and any file already
105 // at the target, so a run writing there is never overwritten.
106 | Command::RestoreDiscard { .. }
107 // Installing a tool and looking at this machine again are both
108 // about the machine rather than about the city: neither reaches
109 // a run, and neither leaves a scene for anybody to hand over.
110 | Command::DoctorInstall { .. }
111 | Command::DoctorRefresh { .. }
112 | Command::BatchByBuilding { .. }
113 | Command::Approve { .. }
114 // Giving a question to a resident changes who answers it. The
115 // run that asked stays stopped either way, so nothing is being
116 // interrupted and there is no scene to hand over.
117 | Command::HandOff { .. }
118 | Command::SetAutonomy { .. }
119 // Pausing a standing goal stops the city taking new work; it
120 // does not reach into a run that is already going, and a verb
121 // that owed a Handoff would be claiming it had.
122 | Command::Pursue { .. }
123 // Writing a document that governs the city changes what the
124 // next run is given, and reaches into no run that is already
125 // going: the frozen prefix of a live run was assembled before
126 // this frame arrived.
127 | Command::PutDocument { .. }
128 | Command::PutIdentity { .. }
129 // A building's rules and the city's own layer are read by the
130 // next dispatch; a live run holds the policy it was frozen with.
131 | Command::PutRules(_)
132 // Taking a file back is refused while a run works in that
133 // building, so it reaches no run that is going.
134 | Command::RestoreFile { .. }
135 // Where the person stands in the guide is the page's place, not
136 // anything a run reads.
137 | Command::PutGuide { .. }
138 | Command::ConfigureCity(_)
139 // A building's own spine documents take the same reading, and
140 // one more: they have a second writer, so the frame carries the
141 // text it started from and a file that moved is refused.
142 | Command::PutSpine { .. }
143 // Saving any document, and landing what a person accepted of a
144 // proposal, takes the same reading: a file changes on disk under
145 // a baseline, and no run that is going is reached by it.
146 | Command::PutRange(_)
147 | Command::DecideProposals(_)
148 // What a person settled about their own reading of the city
149 // reaches no run at all, and a skill written onto a shelf is
150 // admitted by the run after this one.
151 | Command::PutPreferences { .. }
152 | Command::PutShelved { .. }
153 // Connecting an outside application changes what tools the next
154 // run is offered and reaches nothing a run is already doing: a
155 // live run holds the tool table it was assembled with, and an
156 // application connected halfway through it joins the one after.
157 | Command::ConnectToolkit { .. }
158 // A session's name is what a page shows. A policy change is a
159 // ledger line the run under way reads at its own next safe
160 // point, so it interrupts no turn and owes no handoff.
161 | Command::NameSession(_)
162 | Command::ChangeRunPolicy(_)
163 // The remote door decides who reaches the city from outside; it
164 // reaches no run. Closing it ends remote sessions, which are
165 // connections, not turns.
166 | Command::OpenRemoteDoor { .. }
167 | Command::ReplaceCityKey { .. }
168 | Command::ConfirmRemoteDoor { .. }
169 | Command::CloseRemoteDoor { .. }
170 // A host privacy control is a setting of the machine, outside
171 // every run.
172 | Command::PrivacyOperation { .. }
173 // A run that already redeemed a key holds the value it read, and
174 // the next one finds the vault without it; no turn is cut.
175 | Command::ForgetSecret(_)
176 // Closing the city ends every run, but not by cutting a turn here:
177 // the assembly's ordered close stops them, and owes each its
178 // handoff there. Adding an agent, starting its login and revoking
179 // a browser reach no run.
180 | Command::CloseCity(_)
181 | Command::AddAgent(_)
182 | Command::AgentLogin(_)
183 | Command::ForgetDevice(_)
184 | Command::Auth { .. } => ControlVerdict::NotAnIntervention,
185 }
186}
187
188/// Interrupting a turn always owes a Handoff: the next holder - a person
189/// taking over, or the Run itself after a resume - must find the complete
190/// scene rather than a truncated one.
191fn intervene(verb: Intervention, run: Option<RunId>) -> ControlVerdict {
192 ControlVerdict::Intervene {
193 verb,
194 run,
195 must_write_handoff: true,
196 }
197}
198
199/// Halting a scope stops future work rather than interrupting a turn in
200/// progress, so there is no scene to hand over.
201fn scope_intervention(verb: Intervention) -> ControlVerdict {
202 ControlVerdict::Intervene {
203 verb,
204 run: None,
205 must_write_handoff: false,
206 }
207}