U11: merge Todo into Planning on the default lineage (+ the migration mechanism, and a measured safety audit that cuts the work list 32%) (#2515)

**Merges Todo into Planning on the operator's real default workflow.**
Held from merge pending the `triage` literal audit below — see *Gating*.

## The board change

`builtin:coding` → `BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR` →
clones `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`. That IR now declares
**five** columns, and `plan`, `plan-review`, `plan-replan` and `start`
all live in the merged Planning column:

```
columns: todo="Planning", in-progress, in-review, done, archived
  start -> todo      plan       -> todo
  plan-review -> todo  plan-replan -> todo
  parse -> in-progress            (first implementation node)
```

The id stays `todo`, the display name becomes "Planning". That is the
cheaper half: `todo` was already the hold column, so every trait lookup,
task row, stored selection and the 121 `column === "todo"` guards keep
their meaning, and **no stored row needs re-homing**. Promoting `triage`
instead would have produced the same board while making those guards
workflow-*dependent* — live for Coding (Ideas), silently dead for
Coding.

`builtin:legacy-coding` keeps its six-column shape, per the operator's
decision. It exists to be the old thing.

## Entry contract, before and after each IR edit

| | result |
|---|---|
| before the default-lineage edit | **15 passed** |
| after the edit | **13 passed, 2 failed** |
| after reading both | **15 passed** |

Neither failure was routed around. One was a genuine expectation change
(two planning entry points became one); the other was my own
`mergeTodoIntoPlanning` helper throwing *"source IR is not the
split-column shape this merge transforms"* — because production **is**
the merged shape now. I **deleted** the helper rather than making it
tolerant: a transform that has silently become a no-op asserts nothing.

## The safety argument, proven not asserted

Entering at `start` is exactly what dragged cards backward in the three
earlier reverted attempts. `merged-planning-start-node-no-move.test.ts`
proves against the **real** boundary controller and **real** default IR
that entering `start` performs no move (`moveTask` is never *called*),
reaches no hold→wip capacity seam, and **still moves on a genuine
crossing** so the no-op is same-column rather than a disabled boundary.
Removing the controller's same-column short-circuit turns exactly the
two no-move tests red.

## The migration mechanism

A card can outlive its column. `resolveAllowedColumns` derives targets
from graph adjacency, and an undeclared source has none — so it returned
`[]` and **every** move was rejected with "Valid targets: none",
including the one that would rescue the card. An undeclared source now
resolves to the workflow's rebound target. Escape hatch, not relaxation:
declared columns are untouched, and it offers the rebound target *only*,
so a stranded card gets back **into** the lifecycle rather than a free
jump past review.

## A real regression this surfaced

`isDefaultWorkflowColumns` matched the legacy **six** ids as a set. The
merged default declares five, so the match stopped firing and the
default board fell through to neighbor-only adjacency, which **drops
legal moves and invents an illegal one**:

| edge | effect |
|---|---|
| `in-progress → done` | **dropped** — the mission-validation cross edge
|
| `in-review → todo` | **dropped** — review work back to planning |
| `todo/done → archived` | **dropped** — the FN-4892 direct-archival
edges |
| `done → in-review` | **invented** — a backward edge no rule allows |

Adjacency now derives from lifecycle **roles**. The load-bearing
assertion: the legacy six still reproduce `VALID_TRANSITIONS`
**verbatim**. Applied only when a workflow declares the full role set,
so custom boards keep neighbor adjacency.

## Failure accounting (core package, vs a 49-failure baseline)

| stage | failed | new |
|---|---:|---:|
| after the merge | 65 | 18 |
| after the escape hatch | 52 | 5 |
| after role-derived adjacency | 53 | 4 |

The 4 remaining are 3 `builtin-workflows` expectations encoding the
pre-merge shape and 1 create-intake expectation naming `triage` on
`builtin:coding`.

Two `schema-applier` and two `workflow-reconciliation-production-shape`
failures appeared in intermediate runs and are **not mine** — both files
pass in isolation (75/75 and 7/7). I re-ran each before attributing
them, which is why the earlier "priority" flag on the reconciliation
pair was withdrawn.

Gate: **309/309**. Lint clean.

## Gating: the `triage` audit
(`docs/solutions/architecture-patterns/u11-triage-literal-safety-audit.md`)

Program tracking cited **58** `triage` comparisons. Measured with the
same pattern:

| | count |
|---|---:|
| raw comparisons | 87 |
| inside comments | 1 |
| **not a lifecycle column at all** | **15** |
| column comparisons | 71 |
| OR-paired with `"todo"` in the same expression | 32 |
| **exclusive `triage` — the real work list** | **39** |

**15 do not compare a column.** `role === "triage"`, `surface ===
"triage"`, `sessionPurpose === "triage"`, `entry.agent === "triage"`
name the planning **agent**. Converting them would be actively wrong,
and the failure — a planning agent that can't resolve its prompt
template — would look nothing like a column bug.

**One site changes an operator-visible affordance**, which is why
per-site review beat a sweep:

`TaskCard.tsx:1927` — `taskColumnFlags?.intake === true && task.column
!== "triage"`. The literal is a **narrowing**, not a match. After the
merge a Planning card has `intake === true` and `column === "todo"`, so
the narrowing stops applying and **Start begins rendering on default
Planning cards where it previously did not.** A sweep would have
"converted" the literal and shipped the new affordance silently.

These guards do not go **dead**, they go **workflow-dependent** —
`triage` stays live for legacy-coding, Ideas, every linear built-in and
any user workflow (R11) — which is harder to detect than dead.

Work list and ownership are in the audit doc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-29 09:39:20 -07:00
committed by GitHub
parent 82baaa0b67
commit 67904f8a2c
13 changed files with 927 additions and 82 deletions

View File

@@ -0,0 +1,125 @@
---
category: architecture-patterns
module: "@fusion/core, @fusion/engine, @fusion/dashboard"
date: 2026-07-29
problem_type: migration_audit
component: workflow-columns
severity: high
applies_when:
- "Landing U11's deletion of `triage` from the default coding workflow"
- "Deciding whether a `column === \"triage\"` site is broken by that deletion"
- "Counting the remaining lifecycle-literal conversion surface"
tags:
- workflow-columns
- u11
- migration
- literal-conversion
related_components:
- workflow_graph
- triage
- self-healing
- dashboard
---
# The `triage` literal surface, measured per site
U11 merges Todo into Planning by keeping the id `todo` and **deleting `triage`** from the default
coding lineage. Every surviving `column === "triage"` comparison is therefore a candidate for
silent breakage — a guard that stops matching does not fail a test, it disables a path.
This audit exists because the headline count is misleading in both directions, and shipping the
deletion on top of an estimate is how a green merge produces a broken board.
## The count, and why the headline number is wrong
Program-level tracking cited **58** `triage` comparisons. Measured directly with the same pattern
across `packages/*/src` + `packages/dashboard/app`, excluding tests:
| | count | meaning |
|---|---:|---|
| Raw `=== "triage"` / `!== "triage"` | **87** | the grep everyone quotes |
| — inside comments | 1 | not code |
| — **not a column at all** | **15** | `role`, `agentType`, `sessionPurpose`, `surface`, `entry.agent` |
| **Column comparisons** | **71** | the only ones the deletion can reach |
| — OR-paired with `"todo"` in the SAME expression | **32** | a merged Planning card still matches |
| **Exclusive `triage`, needing individual proof** | **39** | the real work list |
Two things this changes:
1. **15 of the 87 are not lifecycle columns.** `agent-prompts.ts`'s `role === "triage"`,
`tool-availability.ts`'s `surface === "triage"`, `skill-resolver.ts`'s
`sessionPurpose === "triage"`, `TaskChatTab.tsx`'s `entry.agent === "triage"` — these name the
**planning agent**, not the planning column, and are unaffected by any IR change. Converting
them would be actively wrong. A file-level count cannot see this distinction.
2. **32 more are already safe** because the branch accepts `todo` in the same expression. A card
that used to be in `triage` is now in `todo`, so it still matches. These need no change and no
test.
So the surface that actually gates U11 is **39 sites**, not 58 and not 87.
## Why "not a column" is not a technicality
`triage` is overloaded in this codebase: it is a column id, an agent role, a session purpose, and
a prompt-template family. Only the first is affected by the IR. A conversion sweep driven by the
raw grep would rewrite the other three, and the resulting failure — a planning agent that no
longer resolves its own prompt template — would look nothing like a column bug.
## The safety rule
After the merge, a default-workflow card that used to rest in `triage` rests in `todo`. So:
- **SAFE** — the site's `triage` branch also accepts `todo`, or resolves by trait
(`flags.intake`/`flags.hold`), or compares a resolved entry column rather than a literal.
- **NEEDS PROOF** — the `triage` branch is exclusive. Then ask: *when this stops matching for a
default-workflow card, what does the operator lose?* If the answer is nothing, the site is dead
for that lineage and safe. If the answer is an affordance or a recovery path, it is breakage.
`triage` remains a **live column id** for `builtin:legacy-coding`, Coding (Ideas), every linear
built-in, and any user-authored workflow (R11). So these guards do not go dead — they go
**workflow-dependent**, which is harder to detect than dead. That is the reason for per-site proof
rather than a sweep.
## The 39, by owner
Ownership follows the program's file assignments; this audit does not claim them.
| File | sites | owner |
|---|---:|---|
| `engine/src/self-healing.ts` | 7 | capacity worker |
| `dashboard/src/routes/register-task-workflow-routes.ts` | 6 | U12 worker |
| `dashboard/app/components/TaskCard.tsx` | 6 | U12 worker |
| `core/src/task-store/task-creation.ts` | 3 | see note — already mitigated |
| `engine/src/replan-target.ts` | 3 | U7 worker (2 are comments) |
| `dashboard/app/components/ListView.tsx` | 3 | U12 worker (1 is a comment) |
| `dashboard/app/components/TaskDetailModal.tsx` | 2 | U12 worker |
| `dashboard/app/components/TaskContextMenu.tsx` | 2 | already trait-paired — safe |
| remaining 7 files | 1 each | scattered |
### Already resolved or safe on inspection
- **`TaskContextMenu.tsx:143`** — `column === "triage" || flags?.intake || flags?.hold`. Already
trait-paired (U10). The literal is a legacy fallback, not the decision.
- **`task-creation.ts:493/863`** — `isIntakeColumn` ORs the literal with the **resolved** entry
column, so a merged Planning card matches through the resolved half. The expression-level pairing
check misses this because it pairs on the literal `"todo"`, not on a resolved variable. Mitigated
further by the `resolveDefaultWorkflowIntakeColumn` fix already on this branch.
- **`replan-target.ts:100`, `ListView.tsx:659`** — prose inside comments describing the old
behavior. No code effect.
### Flagged as a real behavior change, not yet owned
- **`TaskCard.tsx:1927`** — `taskColumnFlags?.intake === true && task.column !== "triage"`. The
literal here is a **narrowing**: it suppresses the Start affordance on the legacy intake column.
After the merge a default Planning card has `intake === true` and `column === "todo"`, so the
narrowing stops applying and **Start begins rendering on default Planning cards where it
previously did not**. That is an operator-visible affordance change, and it is the kind that the
Surface Enumeration rule exists to catch. It needs an explicit decision, not a mechanical
conversion.
## Do not land the deletion on an estimate
The instruction that produced this audit was correct: verify per site, do not assume. The measured
result is that the work list is **32% smaller** than the tracked figure, that **15 sites must not
be converted at all**, and that at least one site changes an operator-visible affordance in a way
no column-conversion sweep would have surfaced.