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, 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}