fallow_output/audit_decision_surface.rs
1//! Decision-surface output contracts.
2
3use serde::Serialize;
4
5/// Wire version for the `fallow decision-surface --format json` envelope.
6pub const DECISION_SURFACE_SCHEMA_VERSION: u32 = 1;
7
8/// The exactly-three shippable decision categories (the SOLID-3). No cut category
9/// (abstraction / deletion / convention / irreversibility) is representable: this
10/// enum is the structural guarantee that confirmed-noise categories never ship.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(rename_all = "kebab-case")]
14pub enum DecisionCategory {
15 /// A new dependency edge between modules or zones that did not depend before.
16 CouplingBoundary,
17 /// A new exported contract, or a changed contract consumed outside the diff.
18 PublicApiContract,
19 /// A new third-party dependency, or a declared one moved across a major
20 /// version (new maintenance + security surface, or a behavior change nobody
21 /// in the diff wrote).
22 Dependency,
23}
24
25/// Every shippable decision category.
26pub const ALL_CATEGORIES: [DecisionCategory; 3] = [
27 DecisionCategory::CouplingBoundary,
28 DecisionCategory::PublicApiContract,
29 DecisionCategory::Dependency,
30];
31
32impl DecisionCategory {
33 /// Stable lowercase tag used to namespace `signal_id` hashes and suppression
34 /// comments.
35 #[must_use]
36 pub const fn tag(self) -> &'static str {
37 match self {
38 Self::CouplingBoundary => "coupling-boundary",
39 Self::PublicApiContract => "public-api-contract",
40 Self::Dependency => "dependency",
41 }
42 }
43
44 /// Per-category reversibility weight used by the CLI ranker.
45 #[must_use]
46 pub const fn reversibility_weight(self) -> u64 {
47 match self {
48 Self::Dependency => 5,
49 Self::PublicApiContract => 3,
50 Self::CouplingBoundary => 2,
51 }
52 }
53}
54
55/// One consequential structural decision, framed as a judgment question for a
56/// human with taste, anchored to a fallow-emitted signal.
57#[derive(Debug, Clone, Serialize)]
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59pub struct Decision {
60 /// Deterministic anchor to the fallow-emitted candidate this decision frames.
61 /// `accept_signal_id` rejects any id not in the emitted set.
62 pub signal_id: String,
63 /// One of the SOLID-3 categories.
64 pub category: DecisionCategory,
65 /// The decision framed as a judgment question for the human.
66 pub question: String,
67 /// Root-relative file the decision is anchored at.
68 pub anchor_file: String,
69 /// 1-based anchor line, when the underlying signal carries one (0 = file head).
70 pub anchor_line: u32,
71 /// The raw fallow-emitted candidate key the `signal_id` hashes.
72 pub signal_key: String,
73 /// The `signal_id` this decision WOULD have had before any rename in this
74 /// change (the anchor file's pre-rename path). Present only when the anchor was
75 /// renamed. A review-memory layer carries a dismissal across a `git mv`: if
76 /// `previous_signal_id` was dismissed in an earlier PR, treat this decision as
77 /// dismissed too. Keeps `signal_id` itself exact + deterministic.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub previous_signal_id: Option<String>,
80 /// Blast radius: count of modules affected beyond the diff by this decision.
81 pub blast: u64,
82 /// `blast * reversibility_weight`: the rank key (sorted descending).
83 pub consequence: u64,
84 /// The routed expert(s) to ask, from ownership routing. Empty when no
85 /// ownership signal is available for the anchor file.
86 pub expert: Vec<String>,
87 /// Whether the anchor file's only qualified owner is one person.
88 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
89 pub bus_factor_one: bool,
90 /// Honest per-decision count: in-repo modules OUTSIDE the diff that already
91 /// depend on this decision's anchor. This is the DISPLAY number (taste
92 /// ownership: the human reads reversibility from the count itself), distinct
93 /// from `blast` (the project-wide proxy used only for ranking). Never a door
94 /// label. Internal-only by construction, so it cannot see a published library's
95 /// external consumers; the public-API trade-off clause names that risk in prose.
96 pub internal_consumer_count: u64,
97 /// The named structural sacrifice this change makes, stated as a fact, never a
98 /// recommendation (e.g. "Couples `app` to `infra`; 4 in-repo modules already
99 /// depend on this anchor."). A sibling fact to `question`; it never tells the
100 /// human what to choose.
101 pub tradeoff: String,
102}
103
104/// A note for decisions collapsed below the cap.
105#[derive(Debug, Clone, Serialize)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub struct TruncationNote {
108 /// How many decisions were collapsed below the cap.
109 pub collapsed: usize,
110 /// Human-readable collapse reason.
111 pub reason: String,
112}
113
114/// The ranked, capped decision surface plus the set of signal_ids the
115/// deterministic layer emitted (the anti-hallucination allowlist).
116#[derive(Debug, Clone, Default, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct DecisionSurface {
119 /// Ranked decisions, highest consequence first.
120 pub decisions: Vec<Decision>,
121 /// Present when more than the cap were extracted.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub truncated: Option<TruncationNote>,
124 /// Every signal_id the deterministic layer emitted, INCLUDING those whose
125 /// decision was collapsed below the cap or suppressed. The anti-hallucination
126 /// allowlist: an agent decision whose id is absent is rejected.
127 pub emitted_signal_ids: Vec<String>,
128}
129
130impl DecisionSurface {
131 /// Accept an agent-proposed `signal_id` only if fallow emitted it.
132 #[must_use]
133 pub fn accept_signal_id(&self, signal_id: &str) -> bool {
134 self.emitted_signal_ids.iter().any(|id| id == signal_id)
135 }
136}
137
138/// Independently-versioned wire-version newtype. Serializes as the integer
139/// [`DECISION_SURFACE_SCHEMA_VERSION`].
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct DecisionSurfaceSchemaVersion(pub u32);
143
144impl Default for DecisionSurfaceSchemaVersion {
145 fn default() -> Self {
146 Self(DECISION_SURFACE_SCHEMA_VERSION)
147 }
148}
149
150/// A structured action attached to a surfaced decision (the agent-actionable
151/// surface). Mirrors the typed-action shape the rest of fallow emits.
152#[derive(Debug, Clone, Serialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154pub struct DecisionAction {
155 /// Stable action discriminator.
156 #[serde(rename = "type")]
157 pub action_type: DecisionActionType,
158 /// Human-readable description of the action.
159 pub description: String,
160 /// Runnable command or paste-ready suppression comment.
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub command: Option<String>,
163 /// Whether fallow can carry the action out automatically. Always `false`:
164 /// a decision is a human judgment, never auto-applied.
165 pub auto_fixable: bool,
166}
167
168/// The discriminated action kinds a decision can carry.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "kebab-case")]
172pub enum DecisionActionType {
173 /// Route the decision to the named expert(s) for a judgment call.
174 AskExpert,
175 /// Suppress the decision with a `// fallow-ignore` comment.
176 Suppress,
177}
178
179/// One decision plus its structured `actions[]`.
180#[derive(Debug, Clone, Serialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182pub struct DecisionWithActions {
183 /// The underlying decision.
184 #[serde(flatten)]
185 pub decision: Decision,
186 /// Structured actions: route to the expert, or suppress.
187 pub actions: Vec<DecisionAction>,
188}
189
190/// The separable `decision-surface` envelope: the single call that puts taste-
191/// decisions in front of a human, callable WITHOUT the full pipeline (the
192/// `decision_surface` MCP tool's output). Carries `kind`/`schema_version` plus
193/// structured `actions[]` per decision.
194#[derive(Debug, Clone, Serialize)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[cfg_attr(
197 feature = "schema",
198 schemars(title = "fallow decision-surface --format json")
199)]
200pub struct DecisionSurfaceOutput {
201 /// Independently-versioned schema version.
202 pub schema_version: DecisionSurfaceSchemaVersion,
203 /// Fallow CLI version that produced this output.
204 pub version: String,
205 /// Command discriminator singleton: always `"decision-surface"`.
206 pub command: String,
207 /// The ranked, capped decisions, each with structured actions.
208 pub decisions: Vec<DecisionWithActions>,
209 /// Present when more than the cap were extracted.
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub truncated: Option<TruncationNote>,
212 /// Count of fallow-emitted signal_ids (the anti-hallucination allowlist size).
213 pub signal_count: usize,
214}
215
216/// Build the suppression comment a decision's `suppress` action pastes in.
217#[must_use]
218pub fn suppress_comment(category: DecisionCategory) -> String {
219 format!(
220 "// fallow-ignore-next-line decision-surface {}",
221 category.tag()
222 )
223}
224
225/// Attach structured actions to one decision. A `dependency` decision anchors
226/// on a `package.json`, which cannot carry a comment, so it gets no `suppress`
227/// action: handing an agent a `//` line to paste into JSON would corrupt the
228/// manifest.
229#[must_use]
230pub fn decision_actions(decision: &Decision) -> Vec<DecisionAction> {
231 let mut actions = Vec::new();
232 if !decision.expert.is_empty() {
233 actions.push(DecisionAction {
234 action_type: DecisionActionType::AskExpert,
235 description: format!("Ask {} to make this call", decision.expert.join(", ")),
236 command: None,
237 auto_fixable: false,
238 });
239 }
240 if decision.category != DecisionCategory::Dependency {
241 actions.push(DecisionAction {
242 action_type: DecisionActionType::Suppress,
243 description: "Suppress this decision if it is settled".to_string(),
244 command: Some(suppress_comment(decision.category)),
245 auto_fixable: false,
246 });
247 }
248 actions
249}
250
251/// Project a [`DecisionSurface`] into the separable, action-bearing envelope.
252#[must_use]
253pub fn build_decision_surface_output(surface: &DecisionSurface) -> DecisionSurfaceOutput {
254 debug_assert!(
255 surface
256 .decisions
257 .iter()
258 .all(|d| surface.accept_signal_id(&d.signal_id)
259 && ALL_CATEGORIES.contains(&d.category)),
260 "a surfaced decision has an unanchored signal_id or an out-of-SOLID-3 category"
261 );
262 let decisions = surface
263 .decisions
264 .iter()
265 .map(|decision| DecisionWithActions {
266 actions: decision_actions(decision),
267 decision: decision.clone(),
268 })
269 .collect();
270 DecisionSurfaceOutput {
271 schema_version: DecisionSurfaceSchemaVersion::default(),
272 version: env!("CARGO_PKG_VERSION").to_string(),
273 command: "decision-surface".to_string(),
274 decisions,
275 truncated: surface.truncated.clone(),
276 signal_count: surface.emitted_signal_ids.len(),
277 }
278}