Docs only — no code. Companion to #2462. ## `docs/solutions/architecture-patterns/workflow-node-column-placement-and-graph-entry-contract.md` Why a workflow node's `column` is a lifecycle contract rather than a display choice: it decides **who can drive the node**, **whether the card holds a WIP slot**, and **whether anything can move it onward**. Contents: - The graph **entry contract** (`resolveColumnResumeNode`, shipped in #2462) with the resume table. - The **plan-in-place chain** — triage → finalize → continuation seed → drain → resume → capacity suspend → release — annotated with the check each link performs. Notably `todo`, not `triage`: an intake column has no releaser, so a card parked there waits for a human. - Why the pre-release gate must be narrow (column match **and** enablement). - The measured failure table from three reverted placement attempts. - Why removing a column is a lifecycle-vocabulary refactor, not a workflow edit: **82 guards** that silently stop matching, **43 writes** to a column that no longer exists, **59 dashboard literals**. A guard that never fires doesn't fail a test — it disables a recovery path. ## `docs/plans/2026-07-26-001-refactor-workflow-owned-lifecycle-plan.md` The program that finishes the job, in four movements: 1. Resolve lifecycle columns from the workflow instead of ~207 string literals. 2. Move every lane — planning, execution, review, merge — behind graph nodes; lane services keep substrate only (storage, leases, timers, supervision, capacity, recovery, audit). 3. A **post-commit event seam**: transitions commit transactionally, *then* emit; subscribers react and may enqueue durable work items, but no subscriber performs a transition. Enforced by test — dropping every subscriber must change no lifecycle outcome. 4. Only then merge Todo into a single Planning column. Phased so each phase lands green independently, with the IR change deliberately **last** (KTD-7). Changing the workflow first makes the suite green over dead guards — that's how the earlier attempts hid their own breakage. The merge lane **adopts** the existing design in `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md` (slices S02–S08, still `draft-stack-handoff`) rather than authoring a competing one, with a note to re-validate against current `main` since it was drafted seven weeks ago. Scale is stated honestly: ~48k lines across the four lane services, with the executor unit explicitly landing across several commits rather than one sweep. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.3 KiB
category, module, date, problem_type, component, severity, applies_when, tags, related_components
| category | module | date | problem_type | component | severity | applies_when | tags | related_components | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| architecture-patterns | @fusion/core, @fusion/engine | 2026-07-26 | architecture_pattern | workflow-graph | high |
|
|
|
The graph entry contract, and why it decides where planning can live
A workflow node's column is a lifecycle contract, not a display choice: it decides who drives the
node, whether the card holds a WIP slot, and whether anything can move the card onward.
The entry contract
A graph run with no durable continuation resumes at the card's own column — never at start.
(resolveColumnResumeNode, workflow-graph-executor.ts.)
ir.columns is ordered, and that order is the lifecycle order. A run enters at the first node, in
forward pipeline order from start, whose column is not behind the card's current column. Rework
back-edges and failure edges are excluded, so the entry point is always the main path and never a
remediation node that merely happens to sit in that column.
| Card is in | Resumes at | Why it matters |
|---|---|---|
triage |
start |
nothing is behind it |
todo |
plan |
it still needs a spec |
in-progress |
parse |
never re-plans, never moves backward out of WIP |
in-review |
browser-verification |
first review node — gates are not skipped |
Before this contract, every continuation-less run — self-healing's graph re-entry, a fresh dispatch,
an operator drag into a processing column — replayed from the first column and dragged the card
backward through columns it had already left, firing abort-on-exit on its live session. That
backward drag is the single reason planning nodes used to be pinned to the implementation column.
Plan-in-place (now the default for every coding workflow)
The whole specification phase — plan, plan-review, plan-replan — runs in the planning lane
(todo), so a card under specification never holds an implementation slot. The chain, with the check
each link performs:
triage specifies the card (writes PROMPT.md) → finalize moves it to `todo`
→ onSpecifyComplete seeds a plan-review continuation
... only if planReviewNode.column === task.column (seedPreReleasePlanReviewContinuation)
→ drainWorkflowContinuations claims runnable `waitReason:"planning"` items
→ executor.execute() resumes the graph AT plan-review
→ on success the next node is in `in-progress`; the boundary SUSPENDS with a
`waitReason:"capacity"` continuation instead of moving
→ isUnplannedForExecution sees that continuation and lets the scheduler release
→ executor resumes at `parse`
todo, not triage. The planning lane spans both columns, but triage is an intake column with
no releaser — a card parked there waits for a human. todo carries hold + the capacity sweep, and
it is where triage's finalize actually leaves the card. Every plan-in-place check keys on the card's
own column, so the node must be where the card rests.
The release gate is narrow on purpose
isUnplannedForExecution applies its pre-release plan-review gate only when both:
planReviewNode.column === task.column— the plan-in-place shape. Keying on merely "not a WIP column" gates every held card against an upstream node their graph never routes through, so they can never produce the continuation the gate waits for.- The plan-review group is enabled for that task. A task with the gate toggled off has no gate to enforce, and holding it deadlocks — nothing will ever record the evidence.
Consequences to know
- A
todocard is releasable only once Plan Review passed (or the graph suspended at the capacity boundary). Scheduler/release test fixtures must model a card that cleared the gate — add apassedplan-reviewentry toworkflowStepResults. A held unreviewed card is the gate working; that path belongs topre-release-plan-review.test.ts. - Pre-existing cards sitting in Todo with a real spec and no continuation are re-seeded automatically by FN-8592's stranded-hold-continuation sweep.
- The trace no longer starts at
startfor a card past intake. Assertions onvisitedNodeIdsshould expect the card's column entry point. - Coding (Ideas) no longer has a private planning shape. Its planning-node re-home is deleted — the default graph it clones is already plan-in-place. It now differs only in intake column and its reduced node set.
Removing a column is a lifecycle-vocabulary refactor, not a workflow edit
Merging Todo into Planning (one column carrying intake + hold) was implemented and reverted. The
IR change is ten lines and the merge gate stays green — which is exactly the trap. The engine names
todo literally in ~207 production sites, of which:
- 82 are guards (
column === "todo"/column !== "todo") that would silently stop matching. A guard that never fires does not fail a test; it disables a recovery path in production. - 43 are writes (
moveTask(id, "todo", ...)) targeting a column the workflow no longer declares. - 59 more live in the dashboard.
Converting them means resolving the hold column by trait at each call site (resolveReboundTarget,
columnsWithFlag(ir, "hold")), and many of those sites have no IR in scope — so it needs plumbing,
not find-and-replace. Do it as its own change, with reconcileUndeclaredTaskColumns (already
shipped) handling the rows left behind in the removed column.
Don't couple UI badges to columns
The dashboard's optional-gate badge was lane-gated on triage/todo, which silently hid the Plan
Review badge whenever the node ran elsewhere. A gate's running state is the signal; the card's
column is not a second opinion on it. Only gate a badge on a column when the gate genuinely moves the
card there (Code Review / Browser Verification → in-review).