## Why this exists
#2821's review found a bug that lived entirely in a lane **builder**
while every test drove the **guard** that consumed it. That is a
structural blind spot, not a one-off: injecting a resolved value into a
synchronous guard makes the guard testable and the resolver invisible.
So I audited every lane helper I added this session. Three had **no
direct coverage at all** — `archivedColumnsForTask`,
`wipColumnsForTask`, `preWipColumnsForTask`. Their callers were tested;
the functions were not.
## They shared the defect that review named
Each read `resolved.length > 0 ? resolved : legacyId`, which conflates
two different boards:
- a **v1 upgrade** — `synthesizeDefaultColumns` emits `traits: []` on
every column, so the legacy id is the only vocabulary that exists, and
falling back is correct;
- a **v2 board that expresses traits** and declares no lane of that role
— where the legacy id names a column the board may still *have* and
deliberately did not give the role. Falling back there widens the guard
onto a role the board explicitly withheld.
`declaresAnyLifecycleTrait` separates them, matching the shape #2821's
review established for `resolveNodeOverrideLanes`.
## The fixture trap, which is the part worth reading
**My first fixture could not see the bug.** It traited the role under
test — and where the role *is* traited, the two shapes agree: both
return the traited lane. Mutating a helper back to the old shape left
all 15 cases green.
The shapes diverge only when the resolved set is **empty while traits
are expressed**. Each helper now has that case explicitly, with a
fixture that traits something *other* than the role under test.
**Mutation-verified per helper:** all three reverted independently now
fail. Before the extra case, none did.
This is the second time this session a fixture built with the production
path normalised away the very thing under test. Worth stating as a rule:
a renamed-lane fixture proves the resolver reads traits; only a
*traits-expressed-but-role-absent* fixture proves what it does when the
answer is legitimately nothing.
## Verification
- `task-lifecycle-lanes.test.ts` → 18 passed (was 15, none covering
these three)
- consumer suites (`github-issue-comment`, `planning-board-tools`,
`register-git-github.review-lanes`) → 64 passed together
- `pnpm test:gate` → 161 + 487 + 13 + 71
- `--strict` → 0; `tsc --noEmit` and `pnpm lint` → 0 errors
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>