Skip to main content

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}