Skip to main content

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}