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;