Files
fusion/packages/engine
gsxdsm beb33b5dd1 P0 STALL 3: rescue cards stranded in a column their workflow no longer declares (fixes 8 red tests on main) (#2591)
Based on `main`. **Fixes STALL 3 — and it needs no data migration.**

## The stall

#2515 removed `triage` from the default lineage while leaving the id
legal for stored rows, and shipped **no migration**. Planning discovery
resolves a card's lanes from its own workflow, and for a default card
`intake` and `hold` **both** resolve to `todo` — so a card *sitting* in
`triage` matched neither branch and was admitted by nothing.

`triage` was the default intake column before #2515, so **every existing
project has cards there.**

Nothing else rescued them. #2515's escape hatch makes an undeclared
source column resolve to the workflow's rebound target, but every path
that *uses* it (executor, agent-heartbeat, merger) is triggered by
**active work**, and a parked card has none. The card sat until an
operator dragged it by hand.

## Proof this is a real regression, not a stale test

**8 tests in `triage.test.ts` were RED on clean `origin/main`** —
verified by swapping main's `triage.ts` into this tree and re-running.
**All 8 pass with this change.** The sharpest:

```
expected "specifyTask" to be called 4 times, but got 0 times
```

Discovery was admitting zero triage cards.

## The fix

A card resting on a legacy pre-implementation id that its own workflow
no longer declares is **unowned by construction** — no lane's rules
apply to it. Admitting it to **planning** heals it through the normal
path: it gets planned, and finalize releases it to the workflow's hold
column, **re-homing the row as a side effect of ordinary work**. No
migration, no backfill, no operator action.

## The narrowing is the load-bearing part

My first version rescued **any** undeclared column, and it was wrong. A
card can also sit in a column its workflow genuinely owns while the
**selection** fails to resolve — the resolved default IR then doesn't
declare that column either. That version re-specified a parked Coding
(Ideas) `ideas` card, breaking **FN-7596's manual-intake rule** (an
ideas card is promoted by an *operator*, never auto-planned).

`triage.test.ts` caught it. The rescue is now scoped to the legacy
planner ids, so a workflow-specific column name is never second-guessed.
That distinction — healing #2515's orphans vs. overruling a workflow
about its own board — is the whole design.

## A user-pause hole this would have opened

`couldBeCandidate` screens `paused` but not `userPaused`, so a row
carrying `userPaused` alone slipped through. Harmless before (an
undeclared-column card was admitted by nothing) and **reachable the
moment admission widens**. Planning a card mutates its lifecycle state,
which the ratified safeguard forbids for a user-paused card — so the
guard is now explicit rather than inherited. Covered by a test and
mutation-verified.

## Cost

Resolution now derives roles **and** declared column ids from one
`resolveWorkflowIrForTask` call, replacing
`resolveTaskLifecycleColumns`. Same call, same `irCache`, same bounded
concurrency window — **cost unchanged**, no added read.

## Verification

- **Mutation-verified three ways**, each failing a different test:
remove the rescue; widen it back to any undeclared column; drop the
user-pause guard
- 8 previously-red-on-main tests now green
- 263 triage/scheduler tests green, merge gate green (482 + 10 + 71),
tsc clean, lint clean

## What this does NOT do

It does not re-home rows that are past the planning stage. Admission
still requires `isTaskStillInPlanningStage`, so a card that advanced
past planning in an undeclared column stays with self-healing's
advanced-recovery sweep rather than being re-specified here. If such
rows exist and are also stranded, that is a separate sweep and a
separate PR.

No changeset: `@fusion/engine` is private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-29 19:02:55 -07:00
..
2026-07-26 18:11:47 -07:00