Skip to main content

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        | Command::Auth { .. } => ControlVerdict::NotAnIntervention,
171    }
172}
173
174/// Interrupting a turn always owes a Handoff: the next holder - a person
175/// taking over, or the Run itself after a resume - must find the complete
176/// scene rather than a truncated one.
177fn intervene(verb: Intervention, run: Option<RunId>) -> ControlVerdict {
178    ControlVerdict::Intervene {
179        verb,
180        run,
181        must_write_handoff: true,
182    }
183}
184
185/// Halting a scope stops future work rather than interrupting a turn in
186/// progress, so there is no scene to hand over.
187fn scope_intervention(verb: Intervention) -> ControlVerdict {
188    ControlVerdict::Intervene {
189        verb,
190        run: None,
191        must_write_handoff: false,
192    }
193}