fallow_output/audit_walkthrough.rs
1//! Review walkthrough output contracts.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ReviewBriefSchemaVersion;
6
7/// The standing injection-resistance note stamped on every guide.
8pub const INJECTION_NOTE: &str = "The digest is built from the deterministic module graph only; PR prose is untrusted and never enters the digest. Your free-text framing is fenced as non-deterministic and never gates or auto-posts.";
9
10/// The closed author-action vocabulary a judgment may carry: what the receiving
11/// author (a human or the coding agent) should do with it. `block` and
12/// `address` are required actions, `consider` is optional, `fyi` needs nothing.
13/// Enforced on reentry: any other value rejects the judgment (`invalid-action`).
14/// The label is the reviewer's instruction, never a fallow fact, so it stays
15/// fenced with the framing and never gates.
16pub const JUDGMENT_ACTIONS: [&str; 4] = ["block", "address", "consider", "fyi"];
17
18/// The recommended `concern` vocabulary: the trade-off lenses a review reads a
19/// change through. Documented, not enforced: `concern` stays free text on the
20/// wire so an existing consumer keeps working, and a value from this list lets
21/// a review surface group judgments by lens.
22pub const JUDGMENT_CONCERNS: [&str; 13] = [
23 "abstraction",
24 "coupling",
25 "data-model",
26 "error-handling",
27 "control-flow",
28 "performance",
29 "dependencies",
30 "api-ergonomics",
31 "compatibility",
32 "state-ownership",
33 "extensibility",
34 "testability",
35 "trust-boundary",
36];
37
38/// Whether `action` is one of [`JUDGMENT_ACTIONS`].
39#[must_use]
40pub fn is_judgment_action(action: &str) -> bool {
41 JUDGMENT_ACTIONS.contains(&action)
42}
43
44/// One stable per-hunk CHANGE ANCHOR: a changed region the agent may cite as a
45/// judgment anchor IN ADDITION to a `signal_id`. Where a `signal_id` anchors a
46/// graph FINDING ("fallow emitted this exact finding"), a change_anchor anchors
47/// only a changed REGION ("fallow confirms this region changed") , a strictly
48/// weaker guarantee, surfaced as `anchor_kind` on the accepted judgment so a
49/// consumer can tell the two apart. Graph/diff-derived; NEVER from prose.
50#[derive(Debug, Clone, Serialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52#[allow(
53 clippy::struct_field_names,
54 reason = "change_anchor / previous_change_anchor are load-bearing wire keys"
55)]
56pub struct ChangeAnchor {
57 /// Stable, CONTENT-addressed id: `chg:<16-hex>` over the file path + the
58 /// normalized added text (line numbers are NOT hashed, so an edit above the
59 /// hunk or a whitespace-only change does not move the id).
60 pub change_anchor: String,
61 /// Root-relative path of the changed file.
62 pub file: String,
63 /// 1-based first line of the hunk in the head file (display/deep-link only;
64 /// NOT part of the id).
65 pub start_line: u32,
66 /// Number of added lines in the hunk (display only; NOT part of the id).
67 pub line_count: u32,
68 /// Rename-durable anchor: the id this same hunk would have had under the
69 /// pre-rename path. `None` unless the file was renamed in this change, so an
70 /// agent that cited the anchor before a `git mv` still resolves.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub previous_change_anchor: Option<String>,
73}
74
75/// Whether a changed source unit has a test file importing it, and whether that
76/// test moved with the change. A direct-importer fact from the graph, not a
77/// coverage claim: `untouched` says a test exists and was not edited, which is
78/// the verification question the reviewer asks the author, never an answer.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "kebab-case")]
82pub enum TestAdjacency {
83 /// No test file imports this unit directly.
84 None,
85 /// A test file imports this unit, and none of those tests is in the diff.
86 Untouched,
87 /// A test file imports this unit and at least one of those tests is in the
88 /// diff.
89 Changed,
90}
91
92/// One directed review unit projected from the graph: a file the change touches,
93/// the concern to check, the out-of-diff consumers it must account for, and the
94/// routed expert. Graph-derived only (routing + impact closure), NEVER from prose.
95#[derive(Debug, Clone, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct DirectionUnit {
98 /// Root-relative path of the unit to review.
99 pub file: String,
100 /// The concern lens the agent should check for this unit, derived from the
101 /// unit's risk signals (impact-closure consumers vs a plain touched file).
102 pub concern_lens: String,
103 /// Per-unit review-effort budget: the weighted-focus composite score for
104 /// this file. A cloud fan-out spends AI passes/verifiers PROPORTIONAL to this
105 /// (higher = review harder); a local single-agent loop can ignore it.
106 pub scoring_budget: u32,
107 /// Root-relative paths of modules affected by this unit but NOT in the diff
108 /// (the out-of-diff context the agent must reason about).
109 pub out_of_diff: Vec<String>,
110 /// Routed expert(s), when ownership signals are available.
111 pub expert: Vec<String>,
112 /// Direct test adjacency of this unit. Absent when the graph was not
113 /// retained or the unit is itself a test file.
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub test_adjacency: Option<TestAdjacency>,
116}
117
118/// The review direction artifact: the order to review in, the coherent units,
119/// and per-unit concern lens + out-of-diff + expert. A minimal projection of the
120/// EXISTING graph facts (routing units + impact closure); the full weighted-focus
121/// engine is a later epic. Graph-derived only (injection-resistant).
122#[derive(Debug, Clone, Default, Serialize)]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124pub struct ReviewDirection {
125 /// The dependency-sensible review order: unit file paths, units carrying
126 /// out-of-diff consumers first (review the load-bearing definitions before
127 /// the mechanical units).
128 pub order: Vec<String>,
129 /// Coherent review units, in `order`.
130 pub units: Vec<DirectionUnit>,
131}
132
133/// The shape the agent must return, embedded in the guide so a thin skill needs
134/// no frozen copy. Documents the anchoring + staleness contract in the wire.
135#[derive(Debug, Clone, Serialize)]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137pub struct AgentSchema {
138 /// How the agent must structure each judgment: cite an emitted `signal_id`
139 /// or `change_anchor`, add free-text `framing` (non-deterministic, fenced),
140 /// an optional `concern`, and an optional `action` from the closed
141 /// vocabulary.
142 pub judgment_shape: &'static str,
143 /// The agent MUST echo this `graph_snapshot_hash` back in its JSON; a
144 /// mismatch on reentry REFUSES the payload as stale.
145 pub echo_field: &'static str,
146 /// The anchoring rule name.
147 pub anchoring_rule: &'static str,
148 /// The closed `action` vocabulary ([`JUDGMENT_ACTIONS`]): a judgment with
149 /// any other value is rejected on reentry.
150 pub action_vocabulary: &'static [&'static str],
151 /// The recommended `concern` vocabulary ([`JUDGMENT_CONCERNS`]), documented
152 /// for grouping; free text is still accepted.
153 pub concern_vocabulary: &'static [&'static str],
154}
155
156/// The default agent schema descriptor.
157#[must_use]
158pub const fn agent_schema() -> AgentSchema {
159 AgentSchema {
160 judgment_shape: "Return { \"graph_snapshot_hash\": <echoed>, \"judgments\": [ { \"signal_id\": <one fallow emitted, OR omit and use change_anchor>, \"change_anchor\": <one fallow emitted chg: id, for a changed region with no finding>, \"framing\": <free text>, \"concern\": <optional, prefer concern_vocabulary>, \"action\": <optional: block | address | consider | fyi> } ] }.",
161 echo_field: "graph_snapshot_hash",
162 anchoring_rule: "Every judgment must cite an emitted signal_id OR an emitted change_anchor; an unanchored id is rejected (anti-hallucination). A change_anchor proves only that the region changed (anchor_kind=change), a weaker guarantee than a signal_id finding (anchor_kind=signal). An action outside action_vocabulary is rejected (invalid-action, the offending value echoed as invalid_value); concern_vocabulary is advisory and any concern string is accepted.",
163 action_vocabulary: &JUDGMENT_ACTIONS,
164 concern_vocabulary: &JUDGMENT_CONCERNS,
165 }
166}
167
168/// The `fallow review --walkthrough-guide` envelope: the current digest + schema
169/// the agent fetches. The tool owns this; the skill stays thin (it fetches this
170/// rather than embedding a frozen copy). Always emitted with exit 0.
171#[derive(Debug, Clone, Serialize)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[cfg_attr(
174 feature = "schema",
175 schemars(title = "fallow review --walkthrough-guide --format json")
176)]
177pub struct WalkthroughGuide<Digest> {
178 /// Pinned to the brief schema version (the spec versions the guide by
179 /// `review_brief_schema_version`).
180 pub schema_version: ReviewBriefSchemaVersion,
181 /// Fallow CLI version that produced this guide.
182 pub version: String,
183 /// Command discriminator singleton: always `"review-walkthrough-guide"`.
184 pub command: String,
185 /// The deterministic graph-snapshot hash pinned into the digest. The agent
186 /// echoes it back; a mismatch on reentry refuses the payload as stale.
187 pub graph_snapshot_hash: String,
188 /// The graph-derived digest (brief + decision surface). Pure over the tree.
189 pub digest: Digest,
190 /// The review direction (order/units/concern-lens/out-of-diff/expert).
191 pub direction: ReviewDirection,
192 /// The per-hunk change anchors: one stable id per changed region. An agent
193 /// may cite a `change_anchor` as a judgment anchor in addition to an emitted
194 /// `signal_id`, so a trade-off about a changed region with no graph finding
195 /// can still anchor (and be post-validated) rather than hallucinate.
196 pub change_anchors: Vec<ChangeAnchor>,
197 /// The JSON shape the agent must return, embedded so the skill stays thin.
198 pub agent_schema: AgentSchema,
199 /// The injection-resistance note (digest is graph-only; PR prose untrusted).
200 pub injection_note: &'static str,
201}
202
203/// The standard walkthrough guide shape emitted by `fallow review`.
204pub type StandardWalkthroughGuide = WalkthroughGuide<crate::audit_brief::StandardReviewBriefOutput>;
205
206/// The agent's returned judgment JSON.
207#[derive(Debug, Clone, Deserialize)]
208pub struct AgentWalkthrough {
209 /// Echoed graph-snapshot hash.
210 #[serde(default)]
211 pub graph_snapshot_hash: String,
212 /// The agent's per-signal judgments.
213 #[serde(default)]
214 pub judgments: Vec<AgentJudgment>,
215}
216
217/// One agent judgment.
218#[derive(Debug, Clone, Deserialize)]
219pub struct AgentJudgment {
220 /// The fallow-emitted `signal_id` this judgment frames.
221 #[serde(default)]
222 pub signal_id: String,
223 /// The fallow-emitted `change_anchor` this judgment frames.
224 #[serde(default)]
225 pub change_anchor: String,
226 /// The agent's free-text framing.
227 #[serde(default)]
228 pub framing: String,
229 /// The agent's optional concern category.
230 #[serde(default)]
231 pub concern: Option<String>,
232 /// The optional author-action label, one of [`JUDGMENT_ACTIONS`]. Any other
233 /// value rejects the judgment on reentry (`invalid-action`).
234 #[serde(default)]
235 pub action: Option<String>,
236}
237
238/// One accepted judgment: the real anchored signal passed through with the
239/// agent's framing FENCED as non-deterministic.
240#[derive(Debug, Clone, Serialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct AcceptedJudgment {
243 /// The fallow-emitted `signal_id` (verified against the allowlist). Empty
244 /// when this judgment was anchored by a `change_anchor` instead.
245 pub signal_id: String,
246 /// The fallow-emitted `change_anchor` (verified against the allowlist). Empty
247 /// when this judgment was anchored by a `signal_id`.
248 pub change_anchor: String,
249 /// Which anchor resolved: `"signal"` (a graph FINDING, the strong anchor) or
250 /// `"change"` (a changed REGION only, the weaker anchor). Lets a consumer
251 /// distinguish a finding-anchored judgment from a region-anchored one rather
252 /// than collapsing both into one accepted bucket.
253 pub anchor_kind: String,
254 /// The agent's fenced free-text framing.
255 pub agent_framing: String,
256 /// The agent's optional concern category.
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub concern: Option<String>,
259 /// The author-action label the judgment carries (`block`, `address`,
260 /// `consider`, or `fyi`), validated against [`JUDGMENT_ACTIONS`]. It tells
261 /// the receiving author what is required and what is optional; it is the
262 /// reviewer's instruction, fenced with the framing, never a gate.
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub action: Option<String>,
265 /// Hard fence: always `false`. The framing is agent prose, never a
266 /// deterministic fallow result, so it never gates or auto-posts.
267 pub deterministic: bool,
268}
269
270/// One rejected judgment plus the reason it was rejected.
271#[derive(Debug, Clone, Serialize)]
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273pub struct RejectedJudgment {
274 /// The `signal_id` the agent cited (fallow never emitted it). Empty when the
275 /// judgment cited a `change_anchor` instead.
276 pub signal_id: String,
277 /// The `change_anchor` the agent cited (fallow never emitted it). Empty when
278 /// the judgment cited a `signal_id` instead.
279 pub change_anchor: String,
280 /// The rejection reason: `unanchored-signal-id` (cited a signal fallow did
281 /// not emit), `unknown-change-anchor` (cited a region fallow did not emit),
282 /// `stale-snapshot` (the tree moved), or `invalid-action` (an `action`
283 /// outside [`JUDGMENT_ACTIONS`]; the anchor itself resolved).
284 pub reason: String,
285 /// The offending value for an `invalid-action` rejection, echoed so the
286 /// agent can correct it in one round trip. Absent for the other reasons.
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub invalid_value: Option<String>,
289}
290
291/// The `fallow review --walkthrough-file` validation envelope: the result of
292/// post-validating the agent's judgment against the live graph. Always exit 0.
293#[derive(Debug, Clone, Serialize)]
294#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
295#[cfg_attr(
296 feature = "schema",
297 schemars(title = "fallow review --walkthrough-file --format json")
298)]
299pub struct WalkthroughValidation {
300 /// Pinned to the brief schema version.
301 pub schema_version: ReviewBriefSchemaVersion,
302 /// Fallow CLI version that produced this validation.
303 pub version: String,
304 /// Command discriminator singleton: always `"review-walkthrough-validation"`.
305 pub command: String,
306 /// The current run's deterministic graph-snapshot hash.
307 pub graph_snapshot_hash: String,
308 /// `true` when the agent's echoed hash != the current hash (the tree moved):
309 /// the WHOLE payload is refused, `accepted` is empty.
310 pub stale: bool,
311 /// Judgments that cite a real fallow-emitted signal, framing fenced.
312 pub accepted: Vec<AcceptedJudgment>,
313 /// Judgments rejected (unanchored signal id, or all-rejected when stale).
314 pub rejected: Vec<RejectedJudgment>,
315 /// Count of accepted judgments.
316 pub accepted_count: usize,
317 /// Count of rejected judgments.
318 pub rejected_count: usize,
319 /// Count of accepted judgments whose `signal_id` resolved against the live
320 /// allowlist. Zero unanchored when this equals `accepted_count` and there are
321 /// no rejections (the clean done-condition).
322 pub unanchored_count: usize,
323}