0010 — Review loops back to design by default, to build on the fast path¶
Status: accepted · Date: 2026-08-14
Owes: nothing — mechanism (ADR-0020)
Context¶
The original specification states that Phase 3 loops back to Phase 1 when changes
are needed, and that Phase 2 re-enters from Phase 1 once details are clarified.
That gives the canonical cycle ③ → ① → ② → ③.
In practice, review findings vary enormously in ambiguity. "The retry policy should be bounded, but I'm not sure what the cap should be or how it interacts with the outbox" genuinely needs a design conversation. "You misspelled 'receive' in the header" does not — routing it through a full elicitation wastes the developer's time, which is the scarcest resource in the loop.
Decision¶
Both edges exist. ③ → ① is the default.
def next_phase_after_review(findings, *, strict_loopback: bool) -> Phase:
if not findings:
return Phase.DONE
if strict_loopback:
return Phase.DESIGN
if any(f.ambiguity is Ambiguity.NEEDS_CLARIFICATION for f in findings):
return Phase.DESIGN
return Phase.BUILD # fast path
This is domain/phase.py::next_phase_after_review(), called by review.triage.
In practice the empty case never reaches it: with no open findings the handler
enqueues review.deployment_choice instead, which asks the opt-in question from
ADR-0014 before anything is
DONE. A DESIGN result enqueues design.interview at cycle + 1; a BUILD
result carries the accepted spec forward to cycle + 1 and enqueues build.plan.
The fast path is taken only when every open finding is classified clear.
strict_loopback = true under [project] in vibey.toml is meant to disable it
entirely, restoring the original specification exactly. The key is parsed, but it
does not reach the router yet: review.triage reads strict_loopback from its job
payload, and review.collect enqueues the triage job without it, so the fast path
is always available today.
What makes a finding clear¶
All four must hold — this is deliberately strict, because the cost of wrongly skipping design is building the wrong thing again:
- The desired end state is stated unambiguously.
- It maps to an existing acceptance criterion, or the new criterion is obvious and testable.
- No new NFR or constraint is implied.
- It does not contradict a recorded
DecisionRecorded.
Classification happens in review.triage as a deterministic check over the
finding text and the recorded decisions (domain/review.py::check_clear_conditions,
called by triage_finding) — no model call. It is a heuristic, and a partial one:
condition 1 fails on findings under three words or containing hedging phrases
("maybe", "not sure", "rethink", …); condition 3 fails on phrases that imply a new
NFR ("requests per second", "migrate to", …); condition 4 fails when the text names
a recorded decision's rejected alternative together with "switch"; condition 2 is
not checked yet (the accepted spec is passed in but unused).
Severity is classified the same way, by keyword (classify_finding_severity), and
sets the effort of the next cycle's first job: MAX when any finding is
critical, else HIGH, STANDARD, or LOW (domain/effort.py::triage_required_effort).
Rationale¶
The default is the specified behavior because ambiguity is the common case and re-interviewing is cheap relative to building the wrong thing twice. The fast path exists because a system that treats a typo and an architectural rethink identically will be experienced as bureaucratic and worked around.
The asymmetry is deliberate: the failure mode of wrongly routing to DESIGN is a few wasted minutes; the failure mode of wrongly taking the fast path is a wasted build cycle. So the bar for the fast path is four conjunctive conditions and the default is always the safe edge.
Consequences¶
Good. Trivial changes round-trip in minutes. Substantive changes get the design conversation they need. The behavior is configurable for teams who want the original strictness.
Bad. Triage misclassification sends work down the wrong path. Mitigated by
the four-condition bar, by running the follow-up work for critical findings at
MAX effort, and by the fact that
a fast-path build that turns out ambiguous can still transition ② → ① when an
item is blocked_on_ambiguity — the mistake is recoverable, one phase later.
That recovery edge is guarded in domain/phase.py (BUILD -> DESIGN requires an
item blocked on ambiguity), but no BUILD handler marks an item that way yet, so
today the recovery happens at the next review.
Bad. A keyword heuristic misses ambiguity phrased any other way, so the fast path is taken more often than the four conditions intend. Replacing the heuristic with a real judgment behind the same function signature is open work.
Alternatives rejected¶
- Always
③ → ①(the literal specification). Kept asstrict_loopback; rejected as the default because it routes a typo through an interview. - Always
③ → ②. Builds the wrong thing twice whenever a finding hides a design question. - Let the reviewing engine choose the edge. Puts cycle routing in a model with no stated bar. The four conjunctive conditions are the bar, and they are testable in the domain.