Skip to main content

fallow_cli/
audit_decision_surface.rs

1//! Decision-surface extractor (stage 6 / 6.G): THE product.
2//!
3//! The apex of the review brief. A change embeds many decisions; almost all are
4//! mechanical and a few are consequential enough to need human taste. This
5//! extractor lifts the consequential STRUCTURAL decisions out of the scattered
6//! diff, frames each as a judgment question, ranks by consequence (blast x
7//! reversibility), caps the surface to a working-memory-sized handful (4 plus or
8//! minus 1), collapses the mechanical remainder, and pairs each decision with the
9//! routed expert ("who to ask").
10//!
11//! ## The SOLID-3 (the ONLY categories that ship)
12//!
13//! Per the verdict (`.plans/agentic-review-e0-verdict.md`) the decision
14//! categories are NOT uniformly reliable on a syntactic engine (ADR-001). Exactly
15//! three are validated and shippable, each backed by a deterministic signal
16//! fallow already emits:
17//!
18//! 1. **coupling/boundary** (`boundary_introduced`): a new cross-zone edge.
19//! 2. **public-API/contract** (`public_api_added` + coordination gaps): a
20//!    new exports-aware public surface, or a changed contract consumed by modules
21//!    outside the diff.
22//! 3. **dependency**: a new `package.json` dependency entry (the arm is present;
23//!    its candidate source is a dependency delta not yet threaded on the brief
24//!    path, so it produces decisions only once that delta lands, never a
25//!    fabricated signal).
26//!
27//! The four CUT categories (abstraction-with-1-implementor, deletion-still-
28//! reachable, convention-divergence, irreversibility/migration) are CONFIRMED
29//! NOISE and MUST NOT ship. `DecisionCategory` has exactly three discriminants,
30//! so a cut category is not even representable: the type system is the guarantee.
31//!
32//! ## The trust mechanism (anti-hallucination)
33//!
34//! Post-validation closes on EXTRACTION, not on framing. Every decision carries a
35//! `signal_id` deterministically derived from the fallow-emitted candidate key it
36//! frames (a delta key or a coordination-gap key). The deterministic layer keeps
37//! the SET of signal_ids it emitted; `DecisionSurface::accept_signal_id` returns
38//! true iff an id is in that set. An agent-proposed decision whose `signal_id` was
39//! never emitted is REJECTED. The agent proposes; the graph disposes.
40
41use serde::Serialize;
42use xxhash_rust::xxh3::xxh3_64;
43
44use crate::audit::routing::RoutingFacts;
45use crate::audit_brief::ReviewDeltas;
46
47/// Default decision-surface cap (the working-memory limit). The surface holds at
48/// most this many ranked decisions; the rest collapse into a truncation note.
49pub const DEFAULT_DECISION_CAP: usize = 4;
50/// Lower bound on the configurable cap (4 minus 1).
51pub const MIN_DECISION_CAP: usize = 3;
52/// Upper bound on the configurable cap (4 plus 1).
53pub const MAX_DECISION_CAP: usize = 5;
54
55/// The exactly-three shippable decision categories (the SOLID-3). No cut category
56/// (abstraction / deletion / convention / irreversibility) is representable: this
57/// enum is the structural guarantee that confirmed-noise categories never ship.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "kebab-case")]
61pub enum DecisionCategory {
62    /// A new dependency edge between modules or zones that did not depend before.
63    CouplingBoundary,
64    /// A new exported contract, or a changed contract consumed outside the diff.
65    PublicApiContract,
66    /// A new third-party dependency (new maintenance + security surface).
67    ///
68    /// The arm is part of the SOLID-3 surface, but its candidate source (a
69    /// `package.json` dependency delta) is not yet threaded onto the brief path,
70    /// so the extractor never constructs it from a live signal today. Reserved,
71    /// not dead: it ships the moment the dependency delta lands, with no category
72    /// reframe. (The variant is referenced by the schema + `reversibility_weight`
73    /// + the no-cut-category test, so it is exercised, not orphaned.)
74    Dependency,
75}
76
77/// Every shippable decision category. EXACTLY the SOLID-3: a cut-noise category
78/// (abstraction / deletion / convention / irreversibility) is not in this list
79/// and cannot be, because [`DecisionCategory`] has no such discriminant. The
80/// no-cut-category invariant test iterates this.
81pub const ALL_CATEGORIES: [DecisionCategory; 3] = [
82    DecisionCategory::CouplingBoundary,
83    DecisionCategory::PublicApiContract,
84    DecisionCategory::Dependency,
85];
86
87impl DecisionCategory {
88    /// Stable lowercase tag used to namespace the `signal_id` hash and as the
89    /// suppression token (`// fallow-ignore-* decision-surface` is the family
90    /// token; the category tag scopes a more specific suppression if desired).
91    #[must_use]
92    pub const fn tag(self) -> &'static str {
93        match self {
94            Self::CouplingBoundary => "coupling-boundary",
95            Self::PublicApiContract => "public-api-contract",
96            Self::Dependency => "dependency",
97        }
98    }
99
100    /// Per-category reversibility weight (higher = harder to undo, ranks higher).
101    /// A new dependency is the least reversible (new supply-chain surface you now
102    /// own); a public contract is a maintained promise; a coupling edge is the
103    /// most removable. Used in `consequence = blast * reversibility_weight`.
104    const fn reversibility_weight(self) -> u64 {
105        match self {
106            Self::Dependency => 5,
107            Self::PublicApiContract => 3,
108            Self::CouplingBoundary => 2,
109        }
110    }
111}
112
113/// One consequential structural decision, framed as a judgment question for a
114/// human with taste, anchored to a fallow-emitted signal.
115#[derive(Debug, Clone, Serialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub struct Decision {
118    /// Deterministic anchor to the fallow-emitted candidate this decision frames.
119    /// `accept_signal_id` rejects any id not in the emitted set.
120    pub signal_id: String,
121    /// One of the SOLID-3 categories.
122    pub category: DecisionCategory,
123    /// The decision framed as a judgment question for the human.
124    pub question: String,
125    /// Root-relative file the decision is anchored at (for suppression + routing).
126    pub anchor_file: String,
127    /// 1-based anchor line, when the underlying signal carries one (0 = file head).
128    pub anchor_line: u32,
129    /// The raw fallow-emitted candidate key the `signal_id` hashes (the evidence).
130    pub signal_key: String,
131    /// The `signal_id` this decision WOULD have had before any rename in this
132    /// change (the anchor file's pre-rename path). Present only when the anchor was
133    /// renamed. A review-memory layer carries a dismissal across a `git mv`: if
134    /// `previous_signal_id` was dismissed in an earlier PR, treat this decision as
135    /// dismissed too. Keeps `signal_id` itself exact + deterministic.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub previous_signal_id: Option<String>,
138    /// Blast radius: count of modules affected beyond the diff by this decision.
139    pub blast: u64,
140    /// `blast * reversibility_weight`: the rank key (sorted descending).
141    pub consequence: u64,
142    /// The routed expert(s) to ask, from ownership routing. Empty when no
143    /// ownership signal is available for the anchor file.
144    pub expert: Vec<String>,
145    /// Whether the anchor file's only qualified owner is one person (bus-factor-1).
146    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
147    pub bus_factor_one: bool,
148    /// Honest per-decision count: in-repo modules OUTSIDE the diff that already
149    /// depend on this decision's anchor. This is the DISPLAY number (taste
150    /// ownership: the human reads reversibility from the count itself), distinct
151    /// from `blast` (the project-wide proxy used only for ranking). Never a door
152    /// label. Internal-only by construction, so it cannot see a published library's
153    /// external consumers; the public-API trade-off clause names that risk in prose.
154    pub internal_consumer_count: u64,
155    /// The named structural sacrifice this change makes, stated as a fact, never a
156    /// recommendation (e.g. "Couples `app` to `infra`; 4 in-repo modules already
157    /// depend on this anchor."). A sibling fact to `question`; it never tells the
158    /// human what to choose.
159    pub tradeoff: String,
160}
161
162/// A note for the decisions collapsed below the cap.
163#[derive(Debug, Clone, Serialize)]
164#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
165pub struct TruncationNote {
166    /// How many decisions were collapsed below the cap.
167    pub collapsed: usize,
168    /// Human-readable collapse reason.
169    pub reason: String,
170}
171
172/// The ranked, capped decision surface plus the set of signal_ids the
173/// deterministic layer emitted (the anti-hallucination allowlist).
174#[derive(Debug, Clone, Default, Serialize)]
175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
176pub struct DecisionSurface {
177    /// Up to `cap` ranked decisions, highest consequence first.
178    pub decisions: Vec<Decision>,
179    /// Present when more than `cap` decisions were extracted.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub truncated: Option<TruncationNote>,
182    /// Every signal_id the deterministic layer emitted, INCLUDING those whose
183    /// decision was collapsed below the cap or suppressed. The anti-hallucination
184    /// allowlist: an agent decision whose id is absent is rejected.
185    pub emitted_signal_ids: Vec<String>,
186}
187
188impl DecisionSurface {
189    /// The trust gate: accept an (agent-proposed) `signal_id` iff the deterministic
190    /// layer emitted it. An injected decision with no signal anchor is rejected.
191    #[must_use]
192    pub fn accept_signal_id(&self, signal_id: &str) -> bool {
193        self.emitted_signal_ids.iter().any(|id| id == signal_id)
194    }
195}
196
197/// Derive a deterministic, content-addressed `signal_id` from a category tag plus
198/// the fallow-emitted candidate key. The tag namespaces the key so a boundary key
199/// and a public-API key sharing text never collide. Pure: same inputs always
200/// yield the same id (byte-identical across runs).
201#[must_use]
202pub fn derive_signal_id(category: DecisionCategory, candidate_key: &str) -> String {
203    let mut bytes = Vec::with_capacity(category.tag().len() + 1 + candidate_key.len());
204    bytes.extend_from_slice(category.tag().as_bytes());
205    bytes.push(0);
206    bytes.extend_from_slice(candidate_key.as_bytes());
207    format!("sig:{:016x}", xxh3_64(&bytes))
208}
209
210/// A representative boundary violation used to anchor a coupling/boundary
211/// decision to a file + line. Decoupled from the `fallow_types` finding type so
212/// the extractor unit-tests without constructing full findings.
213#[derive(Debug, Clone)]
214pub struct BoundaryAnchor {
215    /// The R2 zone-pair key (`"<from_zone>->-<to_zone>"`), matching
216    /// `ReviewDeltas::boundary_introduced`.
217    pub zone_pair_key: String,
218    /// Root-relative path of the importing file (the decision anchor).
219    pub from_file: String,
220    /// The `from_zone` of the edge (for the framed question).
221    pub from_zone: String,
222    /// The `to_zone` of the edge (for the framed question).
223    pub to_zone: String,
224    /// 1-based line of the offending import (the suppression anchor).
225    pub line: u32,
226}
227
228/// A coordination gap projected onto the public-API/contract decision shape: a
229/// changed contract consumed by a module outside the diff.
230#[derive(Debug, Clone)]
231pub struct CoordinationAnchor {
232    /// Root-relative path of the changed file whose contract is consumed elsewhere.
233    pub changed_file: String,
234    /// The consumed symbol names (the contract).
235    pub consumed_symbols: Vec<String>,
236    /// Count of distinct non-diff consumers of this changed file's contract.
237    pub consumer_count: u64,
238    /// 1-based line of the contract symbol's declaration in `changed_file`, so the
239    /// decision deep-links / inline-anchors to the exact export. `0` when the line
240    /// could not be resolved (graph not retained or file unreadable).
241    pub line: u32,
242}
243
244/// All inputs the extractor needs, gathered from the assembled brief data.
245pub struct DecisionInputs<'a> {
246    /// Diff-aware deltas (boundary + public-API). The candidate source.
247    pub deltas: &'a ReviewDeltas,
248    /// Boundary anchors keyed by zone-pair, one representative per introduced edge.
249    pub boundary_anchors: &'a [BoundaryAnchor],
250    /// Coordination gaps projected to the contract decision shape.
251    pub coordination: &'a [CoordinationAnchor],
252    /// 1-based line of the first widened public-API export's declaration, so the
253    /// public-API-surface decision anchors to a real line. `0` when unresolved.
254    pub public_api_anchor_line: u32,
255    /// Project-wide fan-in beyond the diff (impact-closure `affected_not_shown`).
256    /// Used as the blast magnitude for boundary + public-API-surface decisions.
257    pub affected_not_shown: u64,
258    /// Ownership routing (routed expert per file).
259    pub routing: &'a RoutingFacts,
260    /// Per-anchor-file head source, for suppression checks. `None` for a file
261    /// whose head content could not be read (the decision is then not suppressed).
262    pub head_source: &'a dyn Fn(&str) -> Option<String>,
263    /// Resolve a head (post-rename) root-relative path to its pre-rename path, from
264    /// the diff's rename pairs. `None` when the file was not renamed. Lets each
265    /// decision carry a `previous_signal_id` so review memory survives a `git mv`.
266    pub rename_old_path: &'a dyn Fn(&str) -> Option<String>,
267    /// Honest per-anchor in-repo out-of-diff consumer count, precomputed from the
268    /// retained graph's reverse-deps before it was dropped. `0` for an anchor with
269    /// no recorded importers (a genuinely new file). The display number; distinct
270    /// from `affected_not_shown` (the project-wide ranking proxy).
271    pub internal_consumers: &'a dyn Fn(&str) -> u64,
272    /// The decision cap (default 4, clamped to [3, 5] by the caller).
273    pub cap: usize,
274}
275
276/// Resolve the routed expert(s) + bus-factor flag for a decision's anchor file.
277fn route_for(routing: &RoutingFacts, anchor_file: &str) -> (Vec<String>, bool) {
278    routing
279        .units
280        .iter()
281        .find(|unit| unit.file == anchor_file)
282        .map_or((Vec::new(), false), |unit| {
283            (unit.expert.clone(), unit.bus_factor_one)
284        })
285}
286
287/// Whether the head source of `anchor_file` suppresses a decision of `category`
288/// at (1-based) `line`. Honors a file-level `fallow-ignore-file` and a
289/// line-level `fallow-ignore-next-line` immediately above the anchor line, in
290/// both the category-scoped (`decision-surface` / category tag) and bare forms.
291fn is_decision_suppressed(
292    head_source: Option<&str>,
293    category: DecisionCategory,
294    line: u32,
295) -> bool {
296    let Some(source) = head_source else {
297        return false;
298    };
299    let lines: Vec<&str> = source.lines().collect();
300    let token_matches = |comment: &str| {
301        if !comment.contains("fallow-ignore") {
302            return false;
303        }
304        // A bare ignore (no kind) suppresses; a kinded ignore must name the
305        // decision-surface family or this decision's category tag.
306        let after = comment
307            .split_once("fallow-ignore-file")
308            .or_else(|| comment.split_once("fallow-ignore-next-line"))
309            .map(|(_, rest)| rest.trim());
310        match after {
311            None => false,
312            Some("") => true,
313            Some(rest) => {
314                rest.contains("decision-surface")
315                    || rest.contains("decision-surfaces")
316                    || rest.contains(category.tag())
317            }
318        }
319    };
320
321    // File-level: any line carrying a file-level ignore.
322    if lines
323        .iter()
324        .any(|l| l.contains("fallow-ignore-file") && token_matches(l))
325    {
326        return true;
327    }
328    // Line-level: the comment sits immediately above the 1-based anchor line.
329    if line >= 2
330        && let Some(prev) = lines.get((line - 2) as usize)
331        && prev.contains("fallow-ignore-next-line")
332        && token_matches(prev)
333    {
334        return true;
335    }
336    false
337}
338
339/// Frame a coupling/boundary decision as a judgment question.
340fn boundary_question(from_zone: &str, to_zone: &str) -> String {
341    format!(
342        "`{from_zone}` now imports `{to_zone}` for the first time. Intended coupling, or should this edge not exist?"
343    )
344}
345
346/// Frame the (batch-consolidated, R1) public-API-surface decision.
347fn public_api_question(count: usize) -> String {
348    format!(
349        "This change widens the public API surface by {count} export{}. These become maintained contracts. Intended surface, or should they stay internal?",
350        if count == 1 { "" } else { "s" }
351    )
352}
353
354/// Frame a coordination-gap (contract consumed outside the diff) decision.
355fn coordination_question(changed_file: &str, symbols: &[String], consumers: u64) -> String {
356    format!(
357        "`{changed_file}` changes a contract ({}) consumed by {consumers} module{} NOT in this diff. Coordinate the change, or is the contract stable?",
358        symbols.join(", "),
359        if consumers == 1 { "" } else { "s" }
360    )
361}
362
363/// Pluralize "module" against a count.
364fn modules_word(n: u64) -> &'static str {
365    if n == 1 { "module" } else { "modules" }
366}
367
368/// Subject-verb agreement for the per-clause count: a singular subject takes the
369/// "-s" verb form ("1 module depends"), plural drops it ("2 modules depend").
370fn agrees(verb_plural: &str, n: u64) -> String {
371    if n == 1 {
372        format!("{verb_plural}s")
373    } else {
374        verb_plural.to_string()
375    }
376}
377
378/// The named structural sacrifice for a coupling/boundary decision, as a FACT.
379/// `consumers` is the honest in-repo out-of-diff count for the anchor.
380fn boundary_tradeoff(from_zone: &str, to_zone: &str, consumers: u64) -> String {
381    format!(
382        "Couples `{from_zone}` to `{to_zone}`; {consumers} in-repo {} already {} on this anchor.",
383        modules_word(consumers),
384        agrees("depend", consumers)
385    )
386}
387
388/// The named structural sacrifice for the public-API-surface decision, as a FACT.
389/// The internal count is internal-only, so the clause also names the external
390/// contract risk in prose (it cannot count a published library's downstream).
391fn public_api_tradeoff(count: usize, consumers: u64) -> String {
392    format!(
393        "Adds {count} maintained contract{}; {consumers} in-repo {} already {} this surface, and any external consumers become a contract you cannot remove without a breaking change.",
394        if count == 1 { "" } else { "s" },
395        modules_word(consumers),
396        agrees("consume", consumers)
397    )
398}
399
400/// The named structural sacrifice for a coordination-gap decision, as a FACT.
401fn coordination_tradeoff(consumers: u64) -> String {
402    format!(
403        "{consumers} {} outside the diff {} this contract; changing its shape requires coordinating them.",
404        modules_word(consumers),
405        agrees("consume", consumers)
406    )
407}
408
409/// The per-decision fields for [`build_decision`], distinct from the shared
410/// run context carried in [`DecisionInputs`].
411struct DecisionSpec {
412    category: DecisionCategory,
413    candidate_key: String,
414    question: String,
415    anchor_file: String,
416    anchor_line: u32,
417    blast: u64,
418    /// Honest per-decision in-repo out-of-diff consumer count (display number).
419    internal_consumer_count: u64,
420    /// The named-sacrifice clause, stated as a fact.
421    tradeoff: String,
422}
423
424/// Build one decision, resolving its routed expert and suppression state.
425fn build_decision(spec: DecisionSpec, inputs: &DecisionInputs<'_>) -> Decision {
426    let DecisionSpec {
427        category,
428        candidate_key,
429        question,
430        anchor_file,
431        anchor_line,
432        blast,
433        internal_consumer_count,
434        tradeoff,
435    } = spec;
436    let signal_id = derive_signal_id(category, &candidate_key);
437    // Rename-durable review memory: if any path embedded in the candidate key was
438    // renamed, derive the signal_id this decision WOULD have had under the old
439    // path so the cloud can carry a prior dismissal across the move.
440    let previous_signal_id = remap_key_paths(&candidate_key, inputs.rename_old_path)
441        .map(|old_key| derive_signal_id(category, &old_key));
442    let (expert, bus_factor_one) = route_for(inputs.routing, &anchor_file);
443    let consequence = blast.saturating_mul(category.reversibility_weight());
444    Decision {
445        signal_id,
446        category,
447        question,
448        anchor_file,
449        anchor_line,
450        signal_key: candidate_key,
451        previous_signal_id,
452        blast,
453        consequence,
454        expert,
455        bus_factor_one,
456        internal_consumer_count,
457        tradeoff,
458    }
459}
460
461/// Rebuild a candidate key with every embedded rel path swapped to its pre-rename
462/// form via `rename_old_path`. The key embeds paths as `contract:<path>` or as
463/// `|`-joined `<path>::<name>` components (boundary zone-pair keys carry no path).
464/// Returns the rebuilt, re-sorted key iff at least one path moved, else `None`.
465fn remap_key_paths(key: &str, rename_old_path: &dyn Fn(&str) -> Option<String>) -> Option<String> {
466    let mut moved = false;
467    let mut parts: Vec<String> = key
468        .split('|')
469        .map(|segment| {
470            if let Some(path) = segment.strip_prefix("contract:")
471                && let Some(old) = rename_old_path(path)
472            {
473                moved = true;
474                return format!("contract:{old}");
475            } else if let Some((path, name)) = segment.split_once("::")
476                && let Some(old) = rename_old_path(path)
477            {
478                moved = true;
479                return format!("{old}::{name}");
480            }
481            segment.to_string()
482        })
483        .collect();
484    if !moved {
485        return None;
486    }
487    // The public-API key is the SORTED added-key set joined; re-sort so the rebuilt
488    // key matches what the pre-rename change would have emitted.
489    parts.sort();
490    Some(parts.join("|"))
491}
492
493/// Classify the candidate signals into framed decisions (pre-rank, pre-cap).
494fn classify_candidates(inputs: &DecisionInputs<'_>) -> Vec<Decision> {
495    let mut decisions: Vec<Decision> = Vec::new();
496
497    // (1) Coupling/boundary: one decision per introduced zone-pair edge (R2).
498    for key in &inputs.deltas.boundary_introduced {
499        let anchor = inputs
500            .boundary_anchors
501            .iter()
502            .find(|a| &a.zone_pair_key == key);
503        let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
504            || (String::new(), 0, key.clone(), String::new()),
505            |a| {
506                (
507                    a.from_file.clone(),
508                    a.line,
509                    a.from_zone.clone(),
510                    a.to_zone.clone(),
511                )
512            },
513        );
514        let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
515        decisions.push(build_decision(
516            DecisionSpec {
517                category: DecisionCategory::CouplingBoundary,
518                candidate_key: key.clone(),
519                question: boundary_question(&from_zone, &to_zone),
520                tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
521                anchor_file,
522                anchor_line,
523                blast: inputs.affected_not_shown,
524                internal_consumer_count,
525            },
526            inputs,
527        ));
528    }
529
530    // (2a) Public-API surface: R1 batch-consolidate to ONE decision per change.
531    if !inputs.deltas.public_api_added.is_empty() {
532        // The candidate key is the full sorted added-key set joined: one stable
533        // id per change, never one-per-symbol (kills the 111-export noise).
534        let key = inputs.deltas.public_api_added.join("|");
535        let anchor_file = inputs
536            .deltas
537            .public_api_added
538            .first()
539            .and_then(|k| k.split("::").next())
540            .map(str::to_string)
541            .unwrap_or_default();
542        let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
543        decisions.push(build_decision(
544            DecisionSpec {
545                category: DecisionCategory::PublicApiContract,
546                candidate_key: key,
547                question: public_api_question(inputs.deltas.public_api_added.len()),
548                tradeoff: public_api_tradeoff(
549                    inputs.deltas.public_api_added.len(),
550                    internal_consumer_count,
551                ),
552                anchor_file,
553                anchor_line: inputs.public_api_anchor_line,
554                blast: inputs.affected_not_shown,
555                internal_consumer_count,
556            },
557            inputs,
558        ));
559    }
560
561    // (2b) Coordination gaps: a changed contract consumed outside the diff. One
562    // decision per (changed file) contract, keyed on the changed file path.
563    for gap in inputs.coordination {
564        let key = format!("contract:{}", gap.changed_file);
565        decisions.push(build_decision(
566            DecisionSpec {
567                category: DecisionCategory::PublicApiContract,
568                candidate_key: key,
569                question: coordination_question(
570                    &gap.changed_file,
571                    &gap.consumed_symbols,
572                    gap.consumer_count,
573                ),
574                tradeoff: coordination_tradeoff(gap.consumer_count),
575                anchor_file: gap.changed_file.clone(),
576                anchor_line: gap.line,
577                blast: gap.consumer_count,
578                // The coordination arm already carries the honest per-decision
579                // count; no precomputed-map lookup needed.
580                internal_consumer_count: gap.consumer_count,
581            },
582            inputs,
583        ));
584    }
585
586    decisions
587}
588
589/// Extract the full decision surface from the assembled brief inputs: classify
590/// the SOLID-3 candidates, anchor each `signal_id`, rank by consequence, cap to
591/// the working-memory limit, collapse the rest, and drop suppressed decisions.
592///
593/// The emitted-signal-id allowlist is built over EVERY classified decision
594/// (before the cap and before suppression drops), so `accept_signal_id` still
595/// recognizes a collapsed-or-suppressed decision's anchor as fallow-emitted.
596#[must_use]
597pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
598    let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
599
600    let mut classified = classify_candidates(inputs);
601
602    // The allowlist: every signal_id the deterministic layer emitted.
603    let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
604
605    // Drop suppressed decisions (suppression parity): a `// fallow-ignore` on the
606    // anchor hides the decision. Done BEFORE the cap so a suppressed decision does
607    // not consume a slot. The signal_id stays on the allowlist (anchor is still a
608    // real fallow signal), so an agent re-proposing it is not "hallucinating".
609    classified.retain(|d| {
610        let source = (inputs.head_source)(&d.anchor_file);
611        !is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
612    });
613
614    // Rank by consequence desc; stable, deterministic tiebreak on signal_id.
615    classified.sort_by(|a, b| {
616        b.consequence
617            .cmp(&a.consequence)
618            .then_with(|| a.signal_id.cmp(&b.signal_id))
619    });
620
621    let total = classified.len();
622    let truncated = if total > cap {
623        let collapsed = total - cap;
624        classified.truncate(cap);
625        Some(TruncationNote {
626            collapsed,
627            reason: format!(
628                "{collapsed} more structural decision{} collapsed below the cap of {cap}",
629                if collapsed == 1 { "" } else { "s" }
630            ),
631        })
632    } else {
633        None
634    };
635
636    DecisionSurface {
637        decisions: classified,
638        truncated,
639        emitted_signal_ids,
640    }
641}
642
643/// Independently-versioned wire version for the separable `decision-surface`
644/// envelope (the `decision_surface` MCP tool's output + `fallow decision-surface
645/// --format json`). Bumped on its own cadence, distinct from the brief schema.
646pub const DECISION_SURFACE_SCHEMA_VERSION: u32 = 1;
647
648/// Independently-versioned wire-version newtype. Serializes as the integer
649/// [`DECISION_SURFACE_SCHEMA_VERSION`].
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652pub struct DecisionSurfaceSchemaVersion(pub u32);
653
654impl Default for DecisionSurfaceSchemaVersion {
655    fn default() -> Self {
656        Self(DECISION_SURFACE_SCHEMA_VERSION)
657    }
658}
659
660/// A structured action attached to a surfaced decision (the agent-actionable
661/// surface). Mirrors the typed-action shape the rest of fallow emits.
662#[derive(Debug, Clone, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664pub struct DecisionAction {
665    /// Stable action discriminator (`ask-expert`, `suppress`).
666    #[serde(rename = "type")]
667    pub action_type: DecisionActionType,
668    /// Human-readable description of the action.
669    pub description: String,
670    /// A runnable command or a paste-ready suppression comment, when applicable.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub command: Option<String>,
673    /// Whether fallow can carry the action out automatically. Always `false`:
674    /// a decision is a human judgment, never auto-applied.
675    pub auto_fixable: bool,
676}
677
678/// The discriminated action kinds a decision can carry.
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
681#[serde(rename_all = "kebab-case")]
682pub enum DecisionActionType {
683    /// Route the decision to the named expert(s) for a judgment call.
684    AskExpert,
685    /// Suppress the decision with a `// fallow-ignore` comment.
686    Suppress,
687}
688
689/// One decision plus its structured `actions[]`, for the separable envelope.
690#[derive(Debug, Clone, Serialize)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct DecisionWithActions {
693    /// The underlying decision (flattened onto this object).
694    #[serde(flatten)]
695    pub decision: Decision,
696    /// Structured actions: route to the expert, or suppress.
697    pub actions: Vec<DecisionAction>,
698}
699
700/// The separable `decision-surface` envelope: the single call that puts taste-
701/// decisions in front of a human, callable WITHOUT the full pipeline (the
702/// `decision_surface` MCP tool's output). Carries `kind`/`schema_version` plus
703/// structured `actions[]` per decision.
704#[derive(Debug, Clone, Serialize)]
705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
706#[cfg_attr(
707    feature = "schema",
708    schemars(title = "fallow decision-surface --format json")
709)]
710pub struct DecisionSurfaceOutput {
711    /// Independently-versioned schema version.
712    pub schema_version: DecisionSurfaceSchemaVersion,
713    /// Fallow CLI version that produced this output.
714    pub version: String,
715    /// Command discriminator singleton: always `"decision-surface"`.
716    pub command: String,
717    /// The ranked, capped decisions, each with structured actions.
718    pub decisions: Vec<DecisionWithActions>,
719    /// Present when more than `cap` decisions were extracted.
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub truncated: Option<TruncationNote>,
722    /// Count of fallow-emitted signal_ids (the anti-hallucination allowlist size).
723    pub signal_count: usize,
724}
725
726/// Build the suppression comment a decision's `suppress` action pastes in.
727#[must_use]
728fn suppress_comment(category: DecisionCategory) -> String {
729    format!(
730        "// fallow-ignore-next-line decision-surface {}",
731        category.tag()
732    )
733}
734
735/// Attach the structured actions (route-to-expert + suppress) to one decision.
736fn decision_actions(decision: &Decision) -> Vec<DecisionAction> {
737    let mut actions = Vec::new();
738    if !decision.expert.is_empty() {
739        actions.push(DecisionAction {
740            action_type: DecisionActionType::AskExpert,
741            description: format!("Ask {} to make this call", decision.expert.join(", ")),
742            command: None,
743            auto_fixable: false,
744        });
745    }
746    actions.push(DecisionAction {
747        action_type: DecisionActionType::Suppress,
748        description: "Suppress this decision if it is settled".to_string(),
749        command: Some(suppress_comment(decision.category)),
750        auto_fixable: false,
751    });
752    actions
753}
754
755/// Project a [`DecisionSurface`] into the separable, action-bearing envelope.
756///
757/// Invariant (the trust mechanism, enforced here in debug builds): every decision
758/// the deterministic layer surfaces MUST carry a `signal_id` it emitted, so the
759/// separable envelope can never leak an unanchored decision. A category outside
760/// the SOLID-3 is structurally impossible ([`ALL_CATEGORIES`] is the full set).
761#[must_use]
762pub fn build_decision_surface_output(surface: &DecisionSurface) -> DecisionSurfaceOutput {
763    debug_assert!(
764        surface
765            .decisions
766            .iter()
767            .all(|d| surface.accept_signal_id(&d.signal_id)
768                && ALL_CATEGORIES.contains(&d.category)),
769        "a surfaced decision has an unanchored signal_id or an out-of-SOLID-3 category"
770    );
771    let decisions = surface
772        .decisions
773        .iter()
774        .map(|decision| DecisionWithActions {
775            actions: decision_actions(decision),
776            decision: decision.clone(),
777        })
778        .collect();
779    DecisionSurfaceOutput {
780        schema_version: DecisionSurfaceSchemaVersion::default(),
781        version: env!("CARGO_PKG_VERSION").to_string(),
782        command: "decision-surface".to_string(),
783        decisions,
784        truncated: surface.truncated.clone(),
785        signal_count: surface.emitted_signal_ids.len(),
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::audit::routing::RoutingUnit;
793
794    fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
795        ReviewDeltas {
796            boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
797            cycle_introduced: Vec::new(),
798            public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
799        }
800    }
801
802    fn no_source(_: &str) -> Option<String> {
803        None
804    }
805
806    fn no_consumers(_: &str) -> u64 {
807        0
808    }
809
810    fn inputs<'a>(
811        deltas: &'a ReviewDeltas,
812        boundary_anchors: &'a [BoundaryAnchor],
813        coordination: &'a [CoordinationAnchor],
814        routing: &'a RoutingFacts,
815        head_source: &'a dyn Fn(&str) -> Option<String>,
816        cap: usize,
817    ) -> DecisionInputs<'a> {
818        DecisionInputs {
819            deltas,
820            boundary_anchors,
821            coordination,
822            public_api_anchor_line: 0,
823            affected_not_shown: 3,
824            routing,
825            head_source,
826            rename_old_path: &no_source,
827            internal_consumers: &no_consumers,
828            cap,
829        }
830    }
831
832    fn empty_routing() -> RoutingFacts {
833        RoutingFacts::default()
834    }
835
836    // (d) None of the four cut categories can ever appear: the enum has exactly
837    // three discriminants, so this is a compile-time + runtime guarantee.
838    #[test]
839    fn only_three_categories_exist_no_cut_category_representable() {
840        let all = [
841            DecisionCategory::CouplingBoundary,
842            DecisionCategory::PublicApiContract,
843            DecisionCategory::Dependency,
844        ];
845        assert_eq!(all.len(), 3);
846        // Serialized tags never include a cut-category name.
847        for c in all {
848            let tag = c.tag();
849            for cut in ["abstraction", "deletion", "convention", "irreversib"] {
850                assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
851            }
852        }
853    }
854
855    // (a) Every surfaced decision has a signal_id fallow emitted.
856    #[test]
857    fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
858        let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
859        let anchors = vec![BoundaryAnchor {
860            zone_pair_key: "ui->-db".to_string(),
861            from_file: "src/ui/page.ts".to_string(),
862            from_zone: "ui".to_string(),
863            to_zone: "db".to_string(),
864            line: 4,
865        }];
866        let routing = empty_routing();
867        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
868        assert!(!surface.decisions.is_empty());
869        for decision in &surface.decisions {
870            assert!(
871                surface.accept_signal_id(&decision.signal_id),
872                "decision {} has an unanchored signal_id",
873                decision.question
874            );
875        }
876    }
877
878    // (b) An injected decision with no signal anchor is REJECTED.
879    #[test]
880    fn injected_unanchored_signal_id_is_rejected() {
881        let d = deltas(&["ui->-db"], &[]);
882        let anchors = vec![BoundaryAnchor {
883            zone_pair_key: "ui->-db".to_string(),
884            from_file: "src/ui/page.ts".to_string(),
885            from_zone: "ui".to_string(),
886            to_zone: "db".to_string(),
887            line: 1,
888        }];
889        let routing = empty_routing();
890        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
891        // A fabricated id the deterministic layer never emitted.
892        assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
893        assert!(!surface.accept_signal_id("sig:0000000000000000"));
894        // The real one is accepted.
895        let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
896        assert!(surface.accept_signal_id(&real));
897    }
898
899    // (c) A >cap input is capped to 4 plus/minus 1 with a truncation reason.
900    #[test]
901    fn over_cap_input_is_capped_with_truncation_reason() {
902        // 6 boundary edges; default cap 4.
903        let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
904        let routing = empty_routing();
905        let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
906        assert_eq!(surface.decisions.len(), 4, "capped to default 4");
907        let note = surface.truncated.expect("truncation note present");
908        assert_eq!(note.collapsed, 2);
909        assert!(note.reason.contains("collapsed"));
910        assert!(note.reason.contains('2'));
911    }
912
913    #[test]
914    fn cap_is_clamped_to_the_4_plus_minus_1_band() {
915        let d = deltas(
916            &[
917                "a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
918            ],
919            &[],
920        );
921        let routing = empty_routing();
922        // cap=10 clamps to MAX (5).
923        let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
924        assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
925        // cap=1 clamps to MIN (3).
926        let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
927        assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
928    }
929
930    // (e) A `// fallow-ignore` suppresses a flagged decision.
931    #[test]
932    fn fallow_ignore_suppresses_a_flagged_decision() {
933        let d = deltas(&["ui->-db"], &[]);
934        let anchors = vec![BoundaryAnchor {
935            zone_pair_key: "ui->-db".to_string(),
936            from_file: "src/ui/page.ts".to_string(),
937            from_zone: "ui".to_string(),
938            to_zone: "db".to_string(),
939            line: 3,
940        }];
941        let routing = empty_routing();
942
943        // No suppression: one decision surfaces.
944        let unsuppressed =
945            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
946        assert_eq!(unsuppressed.decisions.len(), 1);
947
948        // File-level suppression hides it.
949        let file_src = |f: &str| {
950            (f == "src/ui/page.ts").then(|| {
951                "// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
952            })
953        };
954        let suppressed =
955            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
956        assert!(
957            suppressed.decisions.is_empty(),
958            "file-level ignore hides it"
959        );
960        // But the signal id stays on the allowlist (the anchor is still real).
961        let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
962        assert!(suppressed.accept_signal_id(&id));
963
964        // Line-level suppression immediately above the anchor line also hides it.
965        let line_src = |f: &str| {
966            (f == "src/ui/page.ts").then(|| {
967                "line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
968                    .to_string()
969            })
970        };
971        let line_suppressed =
972            extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
973        assert!(
974            line_suppressed.decisions.is_empty(),
975            "line-level ignore hides it"
976        );
977    }
978
979    #[test]
980    fn bare_blanket_ignore_suppresses_without_a_kind() {
981        let d = deltas(&["ui->-db"], &[]);
982        let anchors = vec![BoundaryAnchor {
983            zone_pair_key: "ui->-db".to_string(),
984            from_file: "src/ui/page.ts".to_string(),
985            from_zone: "ui".to_string(),
986            to_zone: "db".to_string(),
987            line: 2,
988        }];
989        let routing = empty_routing();
990        let bare = |f: &str| {
991            (f == "src/ui/page.ts")
992                .then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
993        };
994        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
995        assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
996    }
997
998    #[test]
999    fn unrelated_kind_ignore_does_not_suppress() {
1000        let d = deltas(&["ui->-db"], &[]);
1001        let anchors = vec![BoundaryAnchor {
1002            zone_pair_key: "ui->-db".to_string(),
1003            from_file: "src/ui/page.ts".to_string(),
1004            from_zone: "ui".to_string(),
1005            to_zone: "db".to_string(),
1006            line: 2,
1007        }];
1008        let routing = empty_routing();
1009        let other = |f: &str| {
1010            (f == "src/ui/page.ts").then(|| {
1011                "// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
1012            })
1013        };
1014        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
1015        assert_eq!(
1016            surface.decisions.len(),
1017            1,
1018            "an ignore naming a different kind must not suppress a decision"
1019        );
1020    }
1021
1022    #[test]
1023    fn routed_expert_is_paired_with_a_decision() {
1024        let d = deltas(&["ui->-db"], &[]);
1025        let anchors = vec![BoundaryAnchor {
1026            zone_pair_key: "ui->-db".to_string(),
1027            from_file: "src/ui/page.ts".to_string(),
1028            from_zone: "ui".to_string(),
1029            to_zone: "db".to_string(),
1030            line: 1,
1031        }];
1032        let routing = RoutingFacts {
1033            units: vec![RoutingUnit {
1034                file: "src/ui/page.ts".to_string(),
1035                expert: vec!["@team/ui".to_string()],
1036                bus_factor_one: true,
1037            }],
1038        };
1039        let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
1040        assert_eq!(surface.decisions.len(), 1);
1041        assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
1042        assert!(surface.decisions[0].bus_factor_one);
1043    }
1044
1045    #[test]
1046    fn public_api_is_batch_consolidated_to_one_decision_r1() {
1047        // 111 added export keys collapse to ONE public-API decision (R1).
1048        let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
1049        let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
1050        let d = deltas(&[], &key_refs);
1051        let routing = empty_routing();
1052        let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
1053        let public_api_count = surface
1054            .decisions
1055            .iter()
1056            .filter(|dec| dec.category == DecisionCategory::PublicApiContract)
1057            .count();
1058        assert_eq!(
1059            public_api_count, 1,
1060            "R1: one public-API decision per change"
1061        );
1062        assert!(surface.decisions[0].question.contains("111"));
1063    }
1064
1065    #[test]
1066    fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
1067        // A public-API delta whose anchor has 7 in-repo out-of-diff consumers must
1068        // surface that honest number on the decision AND name it as a fact in the
1069        // trade-off clause, distinct from the project-wide ranking proxy (`blast`).
1070        let d = deltas(&[], &["src/ui/index.ts::Widget"]);
1071        let routing = empty_routing();
1072        let seven = |_: &str| 7u64;
1073        let surface = extract_decision_surface(&DecisionInputs {
1074            deltas: &d,
1075            boundary_anchors: &[],
1076            coordination: &[],
1077            public_api_anchor_line: 0,
1078            // The project-wide proxy must NOT become the display number.
1079            affected_not_shown: 99,
1080            routing: &routing,
1081            head_source: &no_source,
1082            rename_old_path: &no_source,
1083            internal_consumers: &seven,
1084            cap: 4,
1085        });
1086        let dec = surface
1087            .decisions
1088            .iter()
1089            .find(|dec| dec.category == DecisionCategory::PublicApiContract)
1090            .expect("a public-API decision");
1091        assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
1092        assert_ne!(
1093            dec.internal_consumer_count, dec.blast,
1094            "display number must stay distinct from the ranking proxy"
1095        );
1096        assert!(
1097            dec.tradeoff.contains("7 in-repo"),
1098            "trade-off clause states the count as a fact: {}",
1099            dec.tradeoff
1100        );
1101        assert!(
1102            dec.question.ends_with('?'),
1103            "the decision stays a question (taste ownership)"
1104        );
1105    }
1106
1107    #[test]
1108    fn coordination_gap_becomes_a_public_api_contract_decision() {
1109        let d = deltas(&[], &[]);
1110        let coordination = vec![CoordinationAnchor {
1111            changed_file: "src/core.ts".to_string(),
1112            consumed_symbols: vec!["compute".to_string()],
1113            consumer_count: 4,
1114            line: 7,
1115        }];
1116        let routing = empty_routing();
1117        let surface =
1118            extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
1119        assert_eq!(surface.decisions.len(), 1);
1120        assert_eq!(
1121            surface.decisions[0].category,
1122            DecisionCategory::PublicApiContract
1123        );
1124        assert_eq!(surface.decisions[0].blast, 4);
1125        // The contract symbol's declaration line flows onto the decision so a PR
1126        // review can anchor an inline comment to the exact export.
1127        assert_eq!(surface.decisions[0].anchor_line, 7);
1128        // No rename in this change -> no previous_signal_id (the default).
1129        assert!(surface.decisions[0].previous_signal_id.is_none());
1130    }
1131
1132    #[test]
1133    fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
1134        // A coordination decision on a file renamed src/old.ts -> src/new.ts. The
1135        // signal_id keys on the NEW path; previous_signal_id keys on the OLD path,
1136        // so a cloud memory layer carries a prior dismissal across the `git mv`.
1137        let d = deltas(&[], &[]);
1138        let coordination = vec![CoordinationAnchor {
1139            changed_file: "src/new.ts".to_string(),
1140            consumed_symbols: vec!["compute".to_string()],
1141            consumer_count: 2,
1142            line: 0,
1143        }];
1144        let routing = empty_routing();
1145        let rename = |rel: &str| -> Option<String> {
1146            (rel == "src/new.ts").then(|| "src/old.ts".to_string())
1147        };
1148        let surface = extract_decision_surface(&DecisionInputs {
1149            deltas: &d,
1150            boundary_anchors: &[],
1151            coordination: &coordination,
1152            public_api_anchor_line: 0,
1153            affected_not_shown: 2,
1154            routing: &routing,
1155            head_source: &no_source,
1156            rename_old_path: &rename,
1157            internal_consumers: &no_consumers,
1158            cap: 4,
1159        });
1160        assert_eq!(surface.decisions.len(), 1);
1161        let decision = &surface.decisions[0];
1162        assert_eq!(
1163            decision.signal_id,
1164            derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
1165        );
1166        assert_eq!(
1167            decision.previous_signal_id,
1168            Some(derive_signal_id(
1169                DecisionCategory::PublicApiContract,
1170                "contract:src/old.ts"
1171            ))
1172        );
1173    }
1174
1175    #[test]
1176    fn signal_id_is_deterministic_and_namespaced_by_category() {
1177        let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
1178        let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
1179        assert_eq!(a, b, "deterministic");
1180        let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
1181        assert_ne!(a, c, "category namespaces the hash");
1182        assert!(a.starts_with("sig:"));
1183    }
1184
1185    #[test]
1186    fn consequence_ranks_less_reversible_categories_higher() {
1187        // Same blast: dependency > public-api > coupling on reversibility weight.
1188        let dep = DecisionCategory::Dependency.reversibility_weight();
1189        let api = DecisionCategory::PublicApiContract.reversibility_weight();
1190        let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
1191        assert!(dep > api && api > coupling);
1192    }
1193}