Files
fusion/packages/engine/src
gsxdsm b133d521c4 U4 vertical slice: recovery-policy reconciler + ratified safety invariant (measured: engine ~780, real cost is a settings migration) (#2478)
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)
2026-07-27 15:06:19 -07:00
..
…
…
2026-07-26 14:42:06 -07:00