Skip to main content

kernel/
approval.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 Approval Inbox holds design questions and nothing else (9.2).
7//! The part `crates/kernel/spec/Approval.lean` specifies this module.
8//!
9//! An action is either allowed by a rule or refused by one, and both
10//! answers are the gates' (§8-27); what is left for a person is the
11//! question a run cannot answer by reading the rules. So this module
12//! carries one class of item, two autonomy states, and the rule for who
13//! may answer. There is no standing waiver here: a waiver waives an
14//! escalation, and the one door that escalates (`gate::attach`) asks
15//! for the person's own action rather than for a ruling in this inbox,
16//! so it files no item here.
17
18use serde::{Deserialize, Serialize};
19
20use crate::event::{RunId, Seq, TimeMs};
21use crate::locator::Locator;
22use crate::registry::ResidentId;
23
24/// Non-empty item identity, derived from the run that raises the item
25/// and that run's own position counter — never from a clock. Four lanes
26/// drive at once by default, so two runs reach their first question in
27/// one millisecond routinely; a clock-shaped identity makes those two
28/// items one key, the inbox keeps the later one, and the earlier one
29/// disappears from an append-only history that cannot afterwards tell a
30/// lost item from an item that was never raised.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33pub struct ApprovalId(String);
34
35impl ApprovalId {
36    /// Mints the identity of the question `run` may raise at `seq`.
37    ///
38    /// `seq` is the run's own monotonic position — the counter the run
39    /// already keeps to derive [`crate::IdemKey`], counted inside one run
40    /// and never across runs, so a position is only ever an identity
41    /// together with the run it was counted in. Replay re-derives the
42    /// identical id because neither input is sampled: this is what keeps
43    /// `just replay` self-proving over the approval records.
44    ///
45    /// The position is written zero-padded to the width of `u64::MAX`, so
46    /// the derived `Ord` on two ids of one run reads the order the run
47    /// raised them in.
48    pub fn of(run: &RunId, seq: Seq) -> ApprovalId {
49        ApprovalId(format!("ap-{run}-{:020}", seq.value()))
50    }
51
52    /// Mints the identity of the one question a drive's own sweep may
53    /// raise, which happens at most once per run.
54    ///
55    /// The sweep sits at the highest position, which no call position can
56    /// reach, because the sweep counts nothing and the run's call counter
57    /// must stay free to hand out every position it reaches.
58    pub fn of_sweep(run: &RunId) -> ApprovalId {
59        ApprovalId::of(run, Seq::new(u64::MAX))
60    }
61
62    /// Adopts an id that already exists — one echoed back over the wire,
63    /// one read from the ledger, one written into a fixture. Minting is
64    /// [`ApprovalId::of`]; this path only refuses the empty string.
65    pub fn new(raw: impl Into<String>) -> Option<ApprovalId> {
66        let raw = raw.into();
67        if raw.is_empty() {
68            None
69        } else {
70            Some(ApprovalId(raw))
71        }
72    }
73
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77}
78
79/// What kind of decision this is. Wire data — the cluster keys
80/// serialize — and one arm, because a run asks a person exactly one
81/// kind of thing: a question about the design it cannot settle by
82/// reading the rules.
83///
84/// The enum survives its own single arm on purpose. The cluster key is
85/// wire data, and a class named in the payload keeps the day a second
86/// kind of question appears a compile error at every reader rather than
87/// a silent change of meaning for a field that says one thing today.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91pub enum ApprovalClass {
92    Question,
93}
94
95/// The clustering key: class + free detail. One answer covers the
96/// cluster a person was shown, and it expires with the process.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
99pub struct ClusterKey {
100    pub class: ApprovalClass,
101    pub detail: String,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct ApprovalItem {
107    pub id: ApprovalId,
108    pub actor: String,
109    pub action_desc: String,
110    pub artifact: Locator,
111    pub cluster_key: ClusterKey,
112    pub created: TimeMs,
113    /// Whether the run that raised this question started from content
114    /// that came in from outside. Shown to the person answering, and
115    /// decided on by nobody: what taint refuses is an effect, at
116    /// [`crate::gate::undoable`], and a question is not an effect.
117    pub tainted: bool,
118}
119
120/// How a person answers one item.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124pub enum Ruling {
125    Allow,
126    Deny,
127}
128
129/// Who answers the Approval Inbox (9.2). Two states, because a question
130/// either waits for the person or goes straight to the resident they
131/// appointed; there is no third state in which nobody answers, since
132/// "nobody answered yet" is what an unanswered item already says.
133///
134/// Never touches gate decisions: the gates answer from the rules, and
135/// who reads the inbox cannot change what a rule says.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139pub enum Autonomy {
140    Owner,
141    Delegate(ResidentId),
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Answerer {
146    Human,
147    Resident(ResidentId),
148}
149
150/// Deliberately exhaustive.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum AnswerVerdict {
153    May,
154    /// Only the appointed delegate answers; nobody else's ruling counts.
155    NotTheDelegate,
156    /// Self-approval is no approval.
157    SelfApprovalBarred,
158}
159
160/// The answering rule. A person answers everything; a resident answers
161/// only as the appointed delegate, and never its own question.
162pub fn may_answer(autonomy: &Autonomy, item: &ApprovalItem, answerer: &Answerer) -> AnswerVerdict {
163    match answerer {
164        Answerer::Human => AnswerVerdict::May,
165        Answerer::Resident(resident) => {
166            let appointed = matches!(autonomy, Autonomy::Delegate(d) if d == resident);
167            if !appointed {
168                return AnswerVerdict::NotTheDelegate;
169            }
170            if item.actor == resident.as_str() {
171                return AnswerVerdict::SelfApprovalBarred;
172            }
173            AnswerVerdict::May
174        }
175    }
176}
177
178#[cfg(test)]
179#[allow(
180    clippy::unwrap_used,
181    clippy::expect_used,
182    clippy::panic,
183    clippy::indexing_slicing,
184    reason = "test code"
185)]
186mod tests;