## main is red, and this is the fix
```
FAIL src/__tests__/scheduler-renamed-hold-events.test.ts
> dependency unblocking (failure mode is a card that waits forever)
> finds dependents resting in the renamed hold column when a blocker completes
AssertionError: expected [] to include 'drafting'
```
Not in the thin merge gate's `engine-core` allow-list, so CI stayed
green and only the non-blocking full suite sees it. The test file is
unchanged since #2518; #3051 converted the guard underneath it.
## What broke
#3051 turned the guard into `to === parked.complete || to ===
parked.archived`. `resolveLifecycleColumns` answers **first match per
role** — the right shape for a move *target*, the wrong shape for *"did
this card just reach a finished lane"*, which is a membership question.
Two consequences, both silent:
1. **A board with more than one complete-trait column reconciles
nothing** when a blocker finishes in the second one. The test file's own
header flags this path specifically: *"This one is NOT latency: a
dependent never gets unblocked, so it waits on a blocker that is already
done."*
2. The legacy `done`/`archived` ids stopped matching at all — the
failing assertion.
## Fix
`resolveTaskParkedColumnsSync` gains `terminal`, a membership set:
legacy `done`/`archived` seeded, then **every** complete- and
archived-trait column from the task's own IR.
Seeding legacy ids is safe in the direction that matters here. This is
an **inclusion**: a superset makes the reconciliation run on a move it
would otherwise ignore — one extra query, and it cannot wrongly withhold
work. Seeding a **refusal** is the bug (`node-override-guard.ts`
documents that one); this is not that.
Same sync IR path and same fail-soft legacy default as the single-column
answers, so event ordering and unresolvable-workflow behaviour are
unchanged — the constraint the sync resolver's own header sets.
The two sibling guards in the same listener (dispatch-oscillation reset
at what is now line 1097, and the scheduling wake at 1114) had the
identical arity defect and convert with it.
## Measured
| | result |
|---|---|
| before | `scheduler-renamed-hold-events`: **1 failed / 9 passed** |
| after | **11 passed** (one new case) |
| `src/__tests__/scheduler*` | **14 files / 143 tests pass** |
| `tsc --noEmit -p packages/engine` | clean |
| census `--strict` / `check-lane-wiring` / `check-fnxc-future-dates` |
clean, no baseline movement |
**Proved it is main's red, not my branch's:** I checked out
`origin/main:packages/engine/src/self-healing.ts` over my unrelated
fleet branch and re-ran — identical failure. Then branched this fix
straight off `origin/main`.
**Mutation-tested.** Restoring `to === parked.complete || to ===
parked.archived` fails **both** the pre-existing case and the new
second-complete-lane case. The new test is not vacuous: the second
complete lane is invisible to first-match resolution, so it cannot pass
against the old guard.
## Not done here
I did not convert the remaining `to === parked.review` / `from ===
parked.wip` single-column comparisons in this listener. Review is
genuinely two roles (`mergeBlocker` + `humanReview`) and wip has its own
limit-setting semantics — both want the same membership-vs-target
judgement applied deliberately rather than swept in behind a red-fix.
Flagged, not guessed.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>