Stacked on #2477. Base is
`feature/workflow-vocabulary-u4-delete-dep-blocked` — do not merge
before it.
The smallest end-to-end slice of the U4 reshape, built to **measure**
the real cost before committing to the full policy table. The survey's
~900-line reconciler figure was reasoned, not prototyped; this replaces
it with numbers.
**Everything here is additive and unwired. No behavior changes.**
## What lands
| | lines | what |
|---|---:|---|
| `WorkflowColumnRecovery` (IR) | 42 | one key — `stalenessMs` +
`onStale`. Optional and omitted when unset, so existing workflows
serialize byte-identically. |
| `recovery-reconciler.ts` | 176 | one engine: walks live cards,
resolves each card's policy from **its own** workflow (per task, shared
`irCache` — a 400-card board across three workflows reads three IRs),
returns decisions. Decision and application are separate so the safety
boundary is assertable without running an engine. |
| `recovery-policy-safety.test.ts` | 156 | one-time. The **ratified
invariant**. |
## Measured cost vs the ~900 estimate
**Engine + IR types = 222 lines** for one action (`surface`) and one
safeguard.
Extrapolating the rest — `rebound` (target resolution, attempt budgets,
backward-move proof, five more safeguards) ≈ +350, `archive` ≈ +50, the
`budgets`/`dependencies` keys ≈ +150 — lands near **780**.
So **~900 was a good estimate for the engine**, and the vertical slice
does not move it much. That is the answer to the question asked.
## But the estimate's real miss is not lines
**16 of the 34 POLICY sweeps read an operator setting today** — ~17
distinct policy-threshold keys, including `stalePausedTodoThresholdMs`,
`inReviewStalledThresholdMs`, `taskStuckTimeoutMs`,
`doneAutoArchiveDays`, `maxPostReviewFixes`.
Moving those sweeps into workflow policy is **not a code refactor — it
is a settings migration with operator-visible blast radius**, and it
needs three decisions the line estimate never surfaced:
1. Does workflow policy **override** the global setting, or defer to it?
2. What happens to **existing projects** that already configured those
settings?
3. Does an **unset** policy inherit the setting, or the built-in
default?
That is the gating question for the full table — not the reconciler's
size.
## Why the sweep is not retired here
Retiring `surfaceStalePausedTodos` requires builtin:coding to declare
the policy **and** `stalePausedTodoThresholdMs` to migrate — or the
behavior silently disappears for every existing project. That is the
settings migration above, and it belongs behind its own decision rather
than smuggled into a measurement slice.
The reconciler is therefore **unwired — deliberately dead code**, for
exactly as long as it takes to get that decision.
## The ratified safety invariant
The six safeguards (user pause, `autoMerge:false`, dependency, capacity,
merge-proof, at-most-once) live **outside** the policy table. A workflow
must never be able to author a safety invariant away.
Encoded two ways, because either alone is defeatable:
- **structural** — the policy exposes only an allow-listed key set;
adding a key requires editing the test and re-stating the safety
argument (the friction is the point);
- **behavioral** — a policy attempting every spelling of "ignore the
user pause" has no effect.
**Both halves mutation-verified**, because a safety test that cannot
fail is worse than none:
- making the reconciler honor a policy field that disables the
user-pause safeguard → **fails**
- adding an unreviewed key to the policy schema → **fails**
A third test asserts the reconciler still **acts** on an unpaused card,
so a reconciler that suppressed everything cannot pass by doing nothing.
## Scope limits stated rather than implied
Only the `surface` action is implemented, so only its relevant safeguard
is wired. `surface` mutates no lifecycle state; the other five gate
lifecycle-**mutating** actions that do not exist yet, and wiring them
now would be untestable dead code. A test records this so the absence
reads as deliberate and must be updated when `rebound` lands.
## Verification
- `tsc --noEmit` clean in core and engine; `pnpm lint` clean
- merge gate green (299 + 10 + 71)
- 23 safety tests green; `workflow-lifecycle-traits` green
No changeset: `@fusion/core` and `@fusion/engine` are private.
🤖 Generated with [Claude Code](https://claude.com/claude-code)