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