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