review: the reviewer set is prose-enforced while the gate is seam-enforced #123
Labels
No labels
already-shipped
bug
documentation
duplicate
enhancement
external-review
good first issue
help wanted
in progress
invalid
needs-decision
proposal
question
security
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: crenshawdev/cadence-archived#123
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The review gate level is enforced by a seam; the review reviewer identity is enforced by prose. A model can silently substitute a same-model
claude-subagentfor the configured cross-model provider on ablockingtrigger, and nothing refuses it or records the substitution.Observed
2026-08-13, during
/cad-verify 1route_failures, applying an approved fix:review.reviewersis["openai"](set globally).route.mjs resolve --role cad-reviewerreturnedreview.risk_surface: "blocking"— correct.risk_surfacefire was then dispatched tocadence:cad-reviewer(a Claude subagent).references/review-triggers.mdstep 3, "Resolve the reviewer set", was never executed.Why it happens
route.mjs resolvereturns four knobs —model,effort,review(the trigger→gate map),verify. It does not returnreview.reviewers, or any resolved reviewer set. Reviewer identity lives in a separate config key that only a prose instruction (references/review-triggers.mdstep 3) tells the model to read.So the two halves of one decision have different enforcement classes:
route.mjs resolve→review{}review.reviewers[]Why it matters
The trace bracket for a
claude-subagentfire (trace append --family lifecycle --event dispatch --plan cad-reviewer --role cad-reviewer) is identical whether or not that reviewer was the configured one. The cross-model arm deliberately writes no bracket at all. So after the fact:/cad-reportandtrace rendercannot distinguish "fired at the configured cross-model reviewer" from "fired at a same-model subagent instead".blockingat every stakes level.This is the same defect class as CFG-01/CFG-02, one layer up: the config states one thing and the enforcement is prose, so inspection and enforcement can diverge. Phase 1 of v3.2.0 closed exactly this for
config.json; the review seam still has it.Proposed fix
route.mjs resolvereturns the resolved reviewer set. Do step 3's availability resolution (review.providers.<name>.tiers[<trigger.tier>]non-null,claude-subagentalways available, empty→["claude-subagent"]fallback) inside the seam, and return it as areviewersfield alongsidereview. Prose then reads a resolved value instead of re-deriving one.fallback: "openai tier flagship unassigned") so a fallback is visible rather than inferred.openai/gpt-5.6-sol,claude-subagent/cad-reviewer) to the lifecycle event, and give the cross-model arm some event so a fire that ran only cross-model is not invisible. Today that arm has no bracket by design — which also means a cross-model review that never happened leaves no trace either.(3) is what makes the failure detectable after the fact; (1) is what makes it unlikely. Both are worth having — the current state has neither.
Notes
references/review-triggers.mdalready says "never silently skip ablockingtrigger" for the empty-set fallback case. This is the adjacent hole: not skipping it, but running it at the wrong reviewer..planning/CAPTURE.mdunder Todos.Fixed in v3.2.0 (PR #124). RVW-02, phase 3. The reviewer set is resolved in the seam beside the gate, with its fallback and cause stated in the return, and reviewer identity rides the lifecycle event. A cross-model fire leaves an event of its own, so a substituted subagent is visible afterwards instead of being indistinguishable from the review that was configured.
Verified through the phase UAT walk. The milestone audit traced 12/12 requirements with 0 broken and 36/36 acceptance criteria covered.