Commit Graph

2653 Commits

Author SHA1 Message Date
gsxdsm
bdcfcbbc52 FN-8658: cover task update producer delivery paths
Expand task update lane coverage across every direct and failure-isolated producer.

- Inventory direct and safe task:updated producer routes.
- Exercise warm and cold lane metadata through public producer operations.
- Cover lifecycle, workflow integrity, and completion update paths.

Files changed:
 .../task-updated-lanes-emit-surfaces.test.ts       | 308 +++++++++++++++++----
 1 file changed, 256 insertions(+), 52 deletions(-)

Fusion-Task-Id: FN-8658

Fusion-Task-Lineage: 7d93593c-6f5a-4299-96f3-f4ee8f260e41

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 01:41:13 -07:00
gsxdsm
ebe514c3e4 FN-8677: propagate task update lanes before conversion
Propagate cache-warmed workflow lanes through task updates so synchronous engine consumers support renamed boards.

- Add task lane cache and attach resolved lanes to task:updated metadata.
- Update scheduler, triage, and notification consumers to use carried lanes with bridge-safe fallbacks.
- Cover lane propagation and renamed-lane event behavior with core and engine tests.

Files changed:
 .changeset/fn-8677-manual-merge-hold-lanes.md      |   7 ++
 .changeset/task-updated-carries-lanes.md           |   7 ++
 ...orkflow-ir-readers-always-return-the-default.md |  22 +++++
 .../sync-workflow-ir-second-blocker.test.ts        |  43 +++-----
 .../core/src/__tests__/task-lane-cache.test.ts     |  30 ++++++
 .../task-updated-lanes-emit-surfaces.test.ts       |  92 ++++++++++++++++++
 .../__tests__/task-updated-lanes-payload.test.ts   |  42 ++++++++
 packages/core/src/index.ts                         |   1 +
 packages/core/src/store.ts                         |  36 ++++++-
 packages/core/src/task-lane-cache.ts               |  63 ++++++++++++
 .../core/src/task-store/archive-lifecycle-2.ts     |   3 +
 packages/core/src/task-store/moves.ts              |   1 +
 packages/core/src/task-store/task-artifacts-ops.ts |   1 +
 packages/core/src/task-store/task-update.ts        |   1 +
 packages/core/src/task-store/update-task-deps.ts   |   4 +-
 .../core/src/task-store/workflow-definitions.ts    |  71 +++++---------
 .../__tests__/scheduler-task-updated-lanes.test.ts | 108 +++++++++++++++++++++
 .../task-updated-lanes-bridge-compat.test.ts       |  94 ++++++++++++++++++
 ...task-updated-lanes-engine-emit-surfaces.test.ts | 101 +++++++++++++++++++
 .../src/__tests__/triage-pause-abort.test.ts       |  22 +++++
 .../src/__tests__/triage-planning-wake.test.ts     |  25 +++++
 .../notification-renamed-lifecycle-columns.test.ts |  84 +++++++++++++++-
 .../__tests__/task-wedge-notification.test.ts      |  19 ++++
 .../src/notification/notification-service.ts       |  56 ++++-------
 packages/engine/src/scheduler.ts                   |  62 +++---------
 packages/engine/src/triage.ts                      | 105 ++++++--------------
 scripts/lib/inert-sync-lane-baseline.json          |   5 +-
 27 files changed, 858 insertions(+), 247 deletions(-)

Fusion-Task-Id: FN-8677

Fusion-Task-Lineage: d8fef9db-0f88-4dfd-9813-be25e10e3588

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 01:15:10 -07:00
gsxdsm
7334cffebd FN-8660: add credential instance selection persistence
Persist optional credential-instance selections across model configuration without changing runtime credential behavior.

- Add credential instance IDs to global, project, task, preset, and workflow IR model lanes.
- Preserve selected instance IDs through model resolution and task persistence.
- Add PostgreSQL migration coverage, unit tests, documentation, and a minor changeset.

Files changed:
 .../fn-8660-credential-instance-selection.md       |  7 ++
 docs/settings-reference.md                         | 17 +++++
 .../core/src/__tests__/model-resolution.test.ts    | 37 ++++++++++
 .../credential-instance-selection.pg.test.ts       | 81 +++++++++++++++++++++
 .../postgres/settings-persistence.pg.test.ts       | 83 ++++++++++++++++++++++
 .../src/__tests__/workflow-ir-settings.test.ts     | 66 +++++++++++++++++
 packages/core/src/builtin-workflow-settings.ts     | 41 +++++++++++
 packages/core/src/model-resolution.ts              | 57 ++++++++++++++-
 .../0039_fn_8660_credential_instance_selection.sql |  9 +++
 packages/core/src/postgres/schema-applier.ts       | 14 +++-
 packages/core/src/postgres/schema/project.ts       |  4 ++
 packages/core/src/settings-schema.ts               | 25 +++++++
 packages/core/src/store.ts                         |  2 +-
 .../core/src/task-store/archive-lifecycle-2.ts     |  8 +++
 .../core/src/task-store/branch-and-pr-entities.ts  |  2 +-
 packages/core/src/task-store/persistence.ts        |  8 +++
 packages/core/src/task-store/serialization.ts      |  6 ++
 packages/core/src/task-store/settings-ops.ts       | 30 ++++++++
 packages/core/src/task-store/task-creation.ts      | 18 ++++-
 packages/core/src/task-store/task-mutation-ops.ts  |  6 +-
 packages/core/src/task-store/task-row-mappers.ts   |  6 +-
 packages/core/src/task-store/task-update.ts        | 24 +++++++
 packages/core/src/types/archive-planning.ts        |  5 ++
 packages/core/src/types/settings-scope.ts          | 40 +++++++++++
 packages/core/src/types/task-core.ts               | 30 ++++++++
 packages/core/src/types/workflow-steps.ts          |  9 +++
 packages/core/src/workflow-ir.ts                   | 18 +++++
 packages/core/src/workflow-settings.ts             | 10 +++
 28 files changed, 650 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-8660

Fusion-Task-Lineage: a3f625eb-018c-4084-954e-488b1d37691e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 23:08:51 -07:00
gsxdsm
ee77a8d3fa FN-8659: preserve custom task specification sections
Preserve custom planning sections while reinjecting the original task description.

- Align unmarked original-description bodies before selecting a terminator.
- Anchor inserted descriptions before custom H2 sections and retain safe fallback behavior.
- Add regression coverage, workflow documentation, and a patch changeset.

Files changed:
 ...fn-8659-original-description-custom-sections.md |   7 ++
 docs/workflow-steps.md                             |   2 +
 .../__tests__/original-description-policy.test.ts  | 114 ++++++++++++++++-
 packages/core/src/original-description-policy.ts   | 135 ++++++++++++++++++---
 4 files changed, 235 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-8659
Fusion-Task-Lineage: 7f047a31-3750-4545-b743-bfac9546c55b
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 22:38:52 -07:00
gsxdsm
897cce9d94 FN-8656: resolve scheduler lanes for renamed holds
Resolve scheduler lane lookup against workflow-defined hold and terminal columns.

- Use asynchronous workflow lane resolution after the synchronous event prologue
- Preserve legacy lane fallback and recognize all terminal workflow columns
- Update scheduler regression coverage, sync-lane guardrails, and release notes

Files changed:
 .changeset/fn-8656-scheduler-renamed-hold-lanes.md |   7 ++
 .../sync-workflow-ir-callsite-allowlist.test.ts    |  10 +-
 .../scheduler-renamed-hold-events.test.ts          |  20 ++--
 ...ow-scheduler-parked-columns-live-e2e.pg.test.ts |  13 ++-
 ...-sync-role-conversion-inert-live-e2e.pg.test.ts |   8 +-
 packages/engine/src/scheduler.ts                   | 121 +++++++++------------
 scripts/check-inert-sync-lane-conversions.mjs      |   5 +
 scripts/lib/inert-sync-lane-baseline.json          |   3 +-
 8 files changed, 92 insertions(+), 95 deletions(-)

Fusion-Task-Id: FN-8656

Fusion-Task-Lineage: 389a95a1-289f-4dde-86b3-1e450f8d43db

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 22:19:32 -07:00
gsxdsm
182e3fdbe8 FN-8651: add named provider credential instances
Add provider-instance identity and auth.json storage for multiple credentials per provider.

- Export provider-instance key parsing, validation, and reserved metadata helpers.
- Resolve, list, mutate, and select named credential instances atomically in auth storage.
- Preserve legacy bare-key hydration and document the auth.json instance contract.
- Cover provider instance parsing, storage behavior, and concurrent writes.

Files changed:
 .changeset/fn-8651-provider-instances.md           |   7 +
 docs/secrets.md                                    |   8 +
 .../src/__tests__/oauth-credential-interop.test.ts |  16 ++
 packages/core/src/index.ts                         |  16 ++
 packages/core/src/oauth-credential-interop.ts      |  15 +-
 packages/core/src/provider-instance.test.ts        |  24 +++
 packages/core/src/provider-instance.ts             |  65 +++++++
 .../src/__tests__/auth-storage-concurrency.test.ts |  13 ++
 .../src/__tests__/auth-storage-instances.test.ts   |  75 ++++++++
 packages/engine/src/auth-storage.ts                | 214 +++++++++++++--------
 10 files changed, 367 insertions(+), 86 deletions(-)

Fusion-Task-Id: FN-8651

Fusion-Task-Lineage: cfabe496-60f9-4166-a09f-87ea2028cfd9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 21:56:30 -07:00
gsxdsm
e080bca464 fix: persist awaitingApprovalReason through updateTask
The field was defined in persistence and serialization, the executor's Plan
Review replan-cap park wrote it, the triage manual gate null-cleared it, and
the dashboard special-cases it (isReviewBudgetExhaustedApproval badge + detail
explanation) — but updateTask's field-by-field merge never applied the key, so
every writer silently dropped it. FN-8647's 15-cycle non-converging Plan Review
loop therefore parked with a generic 'needs approval' and no hint it was a cap
escalation.

Merge contract, pinned by tests with a measured revert proof (3/4 fail
pre-fix): set persists, explicit null clears, a status write that leaves
awaiting-approval without addressing the reason auto-clears it so an approved
or replanned card cannot carry a stale escalation reason into its next park,
and unrelated updates leave it untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:42:24 -07:00
gsxdsm
577dcb6c0c FN-8643: correct TaskWedgeNotifications FNXC timestamp
Correct the TaskWedgeNotifications migration-baseline stamp and remove its obsolete future-date allowance.

- Set the baseline FNXC annotation to its intended non-future timestamp.
- Remove the resolved migration from the future-date baseline allowlist.

Files changed:
 packages/core/src/postgres/migrations/0000_initial.sql | 2 +-
 scripts/lib/fnxc-future-dates-baseline.json            | 1 -
 2 files changed, 1 insertion(+), 2 deletions(-)

Fusion-Task-Id: FN-8643

Fusion-Task-Lineage: 0f27e9e6-0581-4e38-8005-e93f94ad4f78

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 19:32:55 -07:00
gsxdsm
48b00acc7b fix(core): legacy adoption preserves reviewing/landing — restart no longer pauses a live AI merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:50:33 -07:00
gsxdsm
95410b5de6 FN-8638: add Factory Light dashboard theme
Add a daylight industrial theme that persists across dashboard and desktop startup.

- Register Factory Light in persisted theme types, selectors, and bootstrap validators.
- Define Factory Light tokens and preview swatches for light and dark modes.
- Cover theme registration and rendered token contracts, and document the new option.

Files changed:
 .changeset/fn-8638-factory-light-theme.md          |   7 ++
 docs/dashboard-guide.md                            |   3 +-
 docs/settings-reference.md                         |   2 +-
 packages/core/src/types/execution-and-ui.ts        |   2 +
 .../app/__tests__/factory-light-theme.test.ts      | 106 +++++++++++++++++++++
 .../dashboard/app/components/ThemeSelector.css     |  14 +++
 .../components/__tests__/ThemeDropdown.test.tsx    |   2 +-
 .../components/__tests__/ThemeSelector.test.tsx    |   2 +-
 .../__tests__/CommandCenterControls.test.tsx       |   2 +-
 packages/dashboard/app/components/themeOptions.ts  |   1 +
 packages/dashboard/app/index.html                  |   2 +-
 packages/dashboard/app/public/theme-data.css       |  86 ++++++++++++++++-
 packages/desktop/src/renderer/index.html           |   1 +
 13 files changed, 223 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-8638

Fusion-Task-Lineage: 3b78bc31-0f03-4299-8f5f-1a69ac7c604a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 17:13:53 -07:00
gsxdsm
91c3854607 fix(fnxc): the last future-dated stamp keeping main red (#3269)
**`check-fnxc-future-dates` exits 1 on `origin/main`.** This is the last
stamp causing it.

```
packages/core/src/task-store/lifecycle-ops.ts: 1 future-dated FNXC stamp, baseline allows 0
  FNXC:Diagnostics 2026-08-01-00:50   (today is 2026-07-31)
```

Corrected to `2026-07-31-00:50`. One character.
`check-fnxc-future-dates` now exits 0; `tsc --noEmit` clean.

## Why this was left behind

Four PRs converged on this red main — #3262, #3263, #3265, and my own
#3266 (closed as superseded). Between them they covered the census rise
and the boundary-work stamps. **None touched `lifecycle-ops.ts`**, so
the gate stayed red after the others landed.

That is the predictable failure of parallel work on one symptom:
everyone fixes the part they saw first, and the residue survives because
each author checked "is main green now?" against their own branch rather
than against main.

## I claimed before working this time

```
node scripts/check-file-claimed.mjs packages/core/src/task-store/lifecycle-ops.ts
  → UNCLAIMED
```

Then pushed the branch before editing. I did the opposite on #3266 —
built it, then discovered #3265 already covered it — which was the sixth
duplication of the phase and my third. The tool answers in one command;
the discipline is running it *first*.

## Verification

- `check-fnxc-future-dates` — **exit 0** (was exit 1 on main)
- `census --strict` — exit 0 (already green; #3265's marker landed)
- `tsc --noEmit` (core) — 0 errors
- one-character diff, no behaviour change

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:48:09 -07:00
gsxdsm
e52da740a5 fix(core): stale-orphan-dir skip logs at debug, not warn — steady-state per-sweep noise
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:32:47 -07:00
gsxdsm
a20ddf6ed6 fix(core): refine + duplicate create into the resolved intake lane, not the deleted triage column
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 15:08:20 -07:00
gsxdsm
bcaa48390b FN-8627: add Sage color theme
Add the Sage palette across persisted dashboard and desktop theme selection paths.

- Register Sage in core, dashboard bootstrap, desktop, and selector metadata.
- Add dark and light Sage tokens plus independently resolvable swatches.
- Cover registration, token, selector, and documentation updates.

Files changed:
 .changeset/fn-8627-sage-theme.md                   |   7 ++
 docs/dashboard-guide.md                            |   3 +-
 packages/core/src/types/execution-and-ui.ts        |   2 +
 .../dashboard/app/__tests__/sage-theme.test.ts     | 101 +++++++++++++++++++++
 .../dashboard/app/components/ThemeSelector.css     |  14 +++
 .../components/__tests__/ThemeDropdown.test.tsx    |   2 +-
 .../components/__tests__/ThemeSelector.test.tsx    |   2 +-
 .../__tests__/CommandCenterControls.test.tsx       |   2 +-
 packages/dashboard/app/components/themeOptions.ts  |   1 +
 packages/dashboard/app/index.html                  |   2 +-
 packages/dashboard/app/public/theme-data.css       |  86 +++++++++++++++++-
 packages/desktop/src/renderer/index.html           |   1 +
 12 files changed, 217 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-8627

Fusion-Task-Lineage: fd4353b3-1e0c-4c7e-84dd-bcad2815178c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 14:58:53 -07:00
gsxdsm
24ef266e48 FN-8628: add Factory Dark dashboard theme
Add a low-light industrial dashboard color theme with first-paint support and release documentation.

- Register Factory Dark across persisted theme types, selector metadata, and desktop/dashboard bootstrap validators.
- Define dark and light Factory Dark tokens, swatches, and selector styling.
- Cover theme registration, tokens, bootstrap behavior, and UI theme-option counts.
- Add a minor @runfusion/fusion changeset and document the theme.

Files changed:
 .changeset/fn-8628-factory-dark-theme.md           |   7 ++
 docs/dashboard-guide.md                            |   3 +-
 docs/settings-reference.md                         |   2 +-
 packages/core/src/types/execution-and-ui.ts        |   2 +
 .../app/__tests__/factory-dark-theme.test.ts       | 106 +++++++++++++++++++++
 .../dashboard/app/components/ThemeSelector.css     |  14 +++
 .../components/__tests__/ThemeDropdown.test.tsx    |   2 +-
 .../components/__tests__/ThemeSelector.test.tsx    |   2 +-
 .../__tests__/CommandCenterControls.test.tsx       |   2 +-
 packages/dashboard/app/components/themeOptions.ts  |   1 +
 packages/dashboard/app/index.html                  |   2 +-
 packages/dashboard/app/public/theme-data.css       |  86 ++++++++++++++++-
 packages/desktop/src/renderer/index.html           |   1 +
 13 files changed, 223 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-8628

Fusion-Task-Lineage: 6f3c7cd9-0130-482d-8aa8-ca47d48b134f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 13:56:55 -07:00
gsxdsm
fb8d37e30d test(core): pin the last two uncovered lane reads (mission archive, lineage gate) (#3235)
## What

Pins the **last two uncovered lane reads** in `packages/core`.
Test-only. This closes the per-site core audit.

| site | what it decides |
|---|---|
| `async-mission-store.ts:1179` | is an ARCHIVED card valid terminal
evidence for mission repair? |
| `task-id-integrity.ts:502` | does an archived child still count as a
LIVE lineage child? |

## Measured

```
mission-store:  39 passed clean;  1 failed | 38 passed blinded
lineage:         3 passed clean;  1 failed |  2 passed blinded
lint clean; fnxc-future-dates: none added; census unchanged
```

Both blinds confirmed applied with `git diff --stat` before each run.

## The third adjacent-pair split

`:1179` is the **archived** half of a pair whose **complete** half
(`:1178`, *one line above*) was already covered by a test in the same
file, written for exactly this concern. Terminal evidence is "done OR
supported archived state," so an archived card is equally valid repair
evidence — but on a board whose archive lane is `vaulted` the archived
half could not see it, and reconciliation threw `TASK_NOT_TERMINAL` for
a card that was genuinely filed away. Same refusal the covered case
fixed, reached through the other door.

That is now the third confirmed instance in core (after `team-analytics`
in #3227 and the scheduler pair earlier). **Being adjacent to a covered
resolver is not coverage**, and it is the most reliable place to look.

## What breaks without the lineage read

An archived child is filed away, not live, so it must not hold the
delete gate shut. Renamed, it still counted as live and
`TaskHasLineageChildrenError` blocked the parent's delete **forever** —
the operator archived the child *precisely* to clear the way, and the
gate could not see that they had.

## A fixture detail I got wrong first

My first mission fixture created a live card in a `vaulted` column and
failed with `deleted or archived without a valid retained tombstone and
archive snapshot` — nothing to do with the lane read.

The `archived` verdict requires **all three** of `deletedAt !== null`,
an archive-snapshot row, and `isArchived(column)`. A live card merely
sitting in an archive-trait column is `invalid-deleted`, not `archived`.
The test now archives for real and *then* renames the recorded lane,
which isolates the third condition — the only one under test. Recorded
in the file so the next person does not re-derive it.

## Paired positives

Both files pin the complement: a WORKING child still counts as live.
Recognising the renamed archive lane must not degrade into "no child is
ever live" — that would silently **disable** the lineage gate and let a
parent be deleted out from under real descendants, which is worse than
the bug being fixed.

## Core audit complete

**14 sites blinded individually: 9 already covered, 5 uncovered, all 5
now pinned** (#3233, #3234, this PR).

Every `resolveProjectColumnsForRoles` call site in `packages/engine` and
`packages/core` has now been blinded. Remaining unaudited: `dashboard`
(2 files) and `cli` (1) — I claim nothing about those.
2026-07-31 13:00:22 -07:00
gsxdsm
623581837a fix(engine): mock provider sends 0-based steps — test mode full-task runs complete again (#3231)
Found by a live browser E2E of the coding workflow in test mode: every
scripted full-task run failed at `steps#0:step-execute` with `Step 4 out
of range (task has 4 steps)`, rebounding through recovery forever.

**Root cause:** `fn_task_update.step` has been **0-based since FN-6607**
(executor.ts FNXC:StepNumbering — the old `step - 1` conversion made
Step 0 impossible to mark). `mock-provider.ts` still sent `index + 1`,
so test mode marked steps 1..N instead of 0..N-1: Step 0 (Preflight)
never completed and step N threw out-of-range. Test mode's full-task
path has been broken since June.

**Also fixes the test that pinned the bug:** `mock-provider.test.ts`
expected `{ step: 1 }` for a fixture whose first unfinished step is
index 0 — the expectation encoded the 1-based off-by-one.

Verified: 12/12 mock-provider tests; the live E2E instance completes the
task after this patch (see follow-up screenshot in the session).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:54:58 -07:00
gsxdsm
7f3acf8929 test(core): pin both create-time duplicate guards' lane exclusions (#3234)
## What

Pins **both** create-time duplicate guards in
`branch-and-pr-entities.ts`. Test-only.

| site | method | excludes |
|---|---|---|
| `:445` | `findRecentTasksByContentFingerprint` | ARCHIVED (unless
`includeArchived`) |
| `:484` | `findRecentTasksBySourceParentTaskId` | COMPLETE and ARCHIVED
|

Blinding either back to its literals left the entire 16-file
lane-detector set green. **No test in `packages/core` reaches either
method.**

## Measured

```
converted:     Tests 8 passed (8)
blinded :445   Tests 1 failed | 7 passed (8)    <- only the fingerprint case
blinded :484   Tests 2 failed | 6 passed (8)    <- only the sibling cases
lint clean; fnxc-future-dates: none added; census unchanged
```

**Each blind fails exactly its own cases.** That matters: it proves the
two resolvers are pinned *independently*, rather than one broad test
appearing to cover both. Blinding `:445` leaves every sibling case green
and vice versa — so neither is riding on the other's coverage.

## They fail in opposite directions

This is why both belong in one file:

- **Fingerprint guard** — a renamed board leaves archived cards in the
candidate set, so filing a new task is **refused as a duplicate** of one
the operator already archived. The create is blocked and the thing
blocking it is invisible.
- **Sibling guard** — a renamed board leaves finished siblings in the
"recent live siblings" set, so completed work keeps counting as active.

One over-includes into a *refusal*, the other over-includes into
*phantom activity*. Neither raises an error.

## Positives pinned too

A LIVE fingerprint match is still a duplicate candidate;
`includeArchived: true` opts the renamed archived lane back in; a
WORKING sibling is still live. Excluding the finished lanes must not
degrade into excluding everything, or the guards stop guarding — the
failure mode a lane-widening change invites.

## A fixture detail that would have made this vacuous

Both queries cut off at `Date.now() - windowMs`, with `windowMs` capped
at 24h. The sibling harness I copied from seeds a **fixed past
timestamp**, which falls outside that window — every case would then
pass on an empty result, including the ones that are supposed to fail
under blinding. Fixtures are seeded at current time instead, and the
reason is recorded in the file so nobody "tidies" it back to a frozen
date.

## Progress

3 of the 5 uncovered core sites are now pinned (`store.ts:1135` in
#3233, these two here). Remaining and unclaimed:
`async-mission-store.ts:1179` and `task-id-integrity.ts:502`.
2026-07-31 12:52:36 -07:00
gsxdsm
2780a8ae7b test(core): pin the open-undo query's finished-lane exclusion (#3233)
## What

Pins the **open-undo query's finished-lane exclusion** in
`packages/core/src/store.ts`. Test-only.

`findOpenRevertTaskForSource` answers *"is there an OPEN undo task for
this source?"* — the question behind the dashboard's Undo affordance. It
answers by **excluding the finished lanes**, so a prior undo that
already landed does not keep rendering as open.

Blinding that exclusion back to `ne(column,"archived"),
ne(column,"done")` left the entire 16-file lane-detector set green. **No
test in `packages/core` reaches this method at all.** The dashboard-side
twin (`taskRevert.ts`, #3129) is tested; the store-side query behind it
was not.

## Measured

| | default (control) | renamed complete | renamed archived | working
lane |
|---|---|---|---|---|
| converted | pass | pass | pass | pass |
| blinded to `["done","archived"]` | pass | **FAIL** | **FAIL** | pass |

```
converted: Test Files 1 passed (1) / Tests 4 passed (4)
blinded:   Test Files 1 failed (1) / Tests 2 failed | 2 passed (4)
lint clean; fnxc-future-dates: none added; census unchanged
```

Blind confirmed applied with `git diff --stat` before the run.

## What breaks without it

On a board whose complete lane is `shipped`, neither literal matches, so
a **done** undo task is never excluded and the query keeps returning it.
The card shows an undo already in flight *forever*, and the real
affordance is unreachable. Nothing errors — the button is just
permanently wrong, which is why it went unnoticed.

## Includes the paired positive

An undo still in a **working** lane IS reported as open. Excluding the
finished lanes must not degrade into excluding everything, or the
affordance breaks in the other direction and no undo is ever reported in
flight. Both new failing cases are renamed-lane cases; both survivors
are cases that should survive.

## Where this came from

Per-site blinding of all 14 remaining `resolveProjectColumnsForRoles`
call sites in `core`, run against a 16-file detector set. **9 covered, 5
uncovered:**

| site | verdict |
|---|---|
| `store.ts:1135` | **uncovered** → pinned here |
| `async-mission-store.ts:1179` (archived) | **uncovered** — its
neighbour `:1178` (complete) is covered |
| `branch-and-pr-entities.ts:445` | **uncovered** |
| `branch-and-pr-entities.ts:484` | **uncovered** |
| `task-id-integrity.ts:502` | **uncovered** |
| `reads.ts` ×3, analytics ×3, `eval-automation`, `task-artifacts-ops`,
`async-mission-store:1178` | covered |

The first run of that probe was **invalid and I nearly published it**:
it reported all 14 sites "COVERED" with *zero failing tests*. zsh does
not word-split unquoted parameter expansions, so `vitest run $DET`
passed 16 paths as one argument and vitest exited 1 with "No test files
found" — which my script read as a failing test. The re-run treats that
string as `INVALID` rather than a result. Third time this session a
wrong reading came from test *selection* rather than from blinding.

## Flagged, not guessed

The four remaining uncovered sites are named above rather than quietly
left; `async-mission-store` shows the same adjacent-pair split as
`team-analytics` in #3227, which is now the third confirmed instance of
that shape.
2026-07-31 12:46:56 -07:00
gsxdsm
dd09e57511 test(core): fix red main — assert the delete re-home against the resolver, not "triage" (#3229)
## What

**Fixes a red main.**
`workflow-reconciliation-production-shape.pg.test.ts` has been failing
with `expected 'todo' to be 'triage'`. Test-only.

Found while establishing a clean baseline for an unrelated coverage
audit — my tree was clean at `origin/main` (`76c73238a0`), so this is
not something I introduced. It is in the non-blocking suite, which is
why it has stayed red.

## It is not a regression — the test was the stale half

The delete path was deliberately fixed to re-home occupants using
`resolveEntryColumnId(resolveDefaultWorkflowIr())` instead of
`BUILTIN_CODING_WORKFLOW_IR`. This assertion was not updated with it.

The two IRs are **not the same board**:

| IR | entry column |
|---|---|
| `BUILTIN_CODING_WORKFLOW_IR` (`builtin:legacy-coding`) | `triage` |
| `resolveDefaultWorkflowIr()` (the catalog default) | `todo` |

Re-homing into `triage` put cards in a column the default board never
declares. It slipped past `moveTask`'s undeclared-target guard **only
because `triage` is a legacy id** and the recovery-rehome path exempts
those — so the guard that exists to stop exactly this could not see it.

So `todo` is the correct behaviour and the literal `"triage"` was what
needed fixing.

## Why it asserts a resolver rather than `"todo"`

Swapping one hardcoded id for another would be the identical trap one
rename later — the same class of defect this whole program exists to
remove. The expectation now derives from **the same two functions the
product path calls**, so it cannot drift out of sync with them again.

I also added the complement: the card must genuinely have **left** the
vanished column, not merely match whatever a resolver returns. Without
it, a resolver that started returning `custom-hold` would pass.

## Proven not appeasement

Reverting the product line to the legacy IR — the original defect —
fails this test:

```
AssertionError: expected 'triage' to be 'todo'
Test Files  1 failed (1) / Tests  1 failed | 6 passed (7)
```

That is the check that matters for a test edit that turns a red green.
It fails on the defect it describes.

## Measured

```
before: Tests 1 failed | 6 passed (7)
after:  Tests 7 passed (7)
16-file detector set: 181 passed (16 files)   [was 1 failed | 180 passed]
lint clean
```

## Note on the reading

I got this wrong twice before getting it right, and the record is worth
having. My first read was "the test is stale, `triage` was merged away."
My second was "the builtin IR still declares `triage`, so the
*behaviour* is the defect" — which the IR file superficially supports.
Only the third reading, of the FNXC note at the fix site, showed the
file I was reading is the **legacy** IR and not the default one. Two of
those three readings would have produced a confidently wrong PR; the
deciding evidence was the comment the fixing author left at the call
site, which is a good argument for writing them.
2026-07-31 12:31:04 -07:00
gsxdsm
32edd1421a test(core): pin team analytics' in-flight lane read — the other half of the pair (#3227)
## What

Pins `aggregateTeamAnalytics`' **in-flight lane read** (`activeLanes`) —
the unpinned half of an adjacent resolver pair. Test-only, no product
change.

`completeLanes` and `activeLanes` are declared **two lines apart**.
Every existing case in this file asserts only `totals.tasksCompleted`,
so the in-flight query `activeLanes` feeds was never observed.

Measured on main:

| blinded resolver | result |
|---|---|
| `completeLanes` → `["done"]` | **FAILS** the file (1 failed / 3
passed) — pinned |
| `activeLanes` → `["in-progress","in-review"]` | **entirely GREEN** (4
passed) — unpinned |

One resolver held, its neighbour not, in a file named
`team-analytics-renamed-lanes`. This is the half-covered-pair shape the
program keeps finding; being *next to* a covered resolver is not
coverage.

## How I found it

Rather than blind core's 17 files one at a time, I made
`resolveProjectColumnsForRoles` itself return legacy-only — its own
documented degrade path — which blinds **all 116 call sites in one
edit**. The full core suite then reported **24 failures across 16
files** out of 4,987 tests, which maps the covered areas in a single
run: the analytics renamed-lane pg tests, the archived-lane family,
eval-automation, mission-store, and the resolver's own tests.

That global probe finds *areas* that are covered, not *resolvers* — so
the pairs still needed individual blinding, which is what surfaced this
one. `workflow-analytics.ts` has the identical two-resolver shape and
**both halves are covered**; the gap is specific to `team-analytics.ts`.

## Two things have to be right, and both are now asserted

1. **The SQL must ASK for the board's real wip lane** — `activeLanes`.
2. **`buildTeamAnalytics` must RECOGNISE the row it gets back.** It
classifies via `isWipColumnRole(query.columnFlagsByName?.get(name),
name)`, which **without flags falls back to `name === "in-progress"`**
and drops a renamed lane it already fetched.

So supplying `columnFlagsByName` is part of the caller contract, not
test scaffolding: **widening the query alone would still report zero.**
A test that only widened the first half would pass while the feature
stayed broken.

## Measured

```
converted:            Test Files 1 passed (1) / Tests 7 passed (7)
blinded activeLanes:  Test Files 1 failed (1) / Tests 2 failed | 5 passed (7)
lint clean; fnxc-future-dates: none added; census unchanged
```

Blind confirmed applied with `git diff --stat` before each run.

## What breaks without it

A per-agent `tasksInProgress: 0` sitting beside a nonzero completed
count and real token spend — an agent that looks idle while it is
working. Same wrong-but-plausible shape this file's own header
describes: nothing errors, and a plausible-looking number is the least
likely defect for anyone to file.

## Flagged, not guessed

- `packages/core` is not my package; this is additive tests only. I
raised the same note on #3225.
- The global probe shows core has **substantial** renamed-lane coverage
— it is not the uniformly-unpinned surface I implied when I first
reported 17 unaudited files. Correcting that here rather than leaving
the stronger claim standing.
- Still unblinded individually: the resolver pairs in
`async-mission-store.ts` (1178/1179) and the archived reads in
`task-store/reads.ts` (396/615/793). Their *files* fail under the global
blind, so something covers each area — but that is not per-resolver
evidence, and I am not claiming it is.
2026-07-31 12:25:26 -07:00
gsxdsm
76c73238a0 test(core): pin the engine-downtime shift's wip read (428 tests could not see it) (#3225)
## What

Pins the **engine-downtime timing shift's wip read** in
`packages/core/src/store.ts`. Test-only — no product change. First
audited site in `core`.

`reconcileActiveTimingForEngineDowntime` (FN-7011/FN-7975) excludes
proven stopped-engine wall-clock from a card's active time. It finds the
cards to fix by querying the board's wip lane.

**Blinding that read back to `["in-progress"]` left every test that
touches the sweep green — 4 in this file plus 424 in the two engine
files that exercise it, 428 in total.**

## Why 428 tests were blind to it

The existing store double is 10 lines and contains **both** documented
anti-patterns, either one sufficient on its own:

1. **`listTasks: vi.fn(async () => tasks)` ignores its `column`
argument** — it returns the same rows whichever lane is requested. A
fake that ignores its own filter cannot see a filter bug, which is
exactly the bug this resolver exists to fix.
2. **No `listWorkflowDefinitions`** — `resolveProjectColumnsForRoles`
then returns the legacy ids and nothing else (an intentional degrade in
`project-lane-vocabulary.ts` so an unreadable workflow list cannot fail
a sweep). The resolved set and the literal set were *equal by
construction*.

The new double fixes both and changes nothing else. **The existing cases
keep the original double on purpose:** they are about heartbeat and
threshold arithmetic, not lanes, and rewriting them would put unrelated
churn in the same commit.

## Measured

| | default (control) | renamed | differential | non-wip card |
|---|---|---|---|---|
| converted | pass | pass | pass | pass |
| blinded to `["in-progress"]` | pass | **FAIL** | **FAIL** | pass |

```
converted: Test Files 1 passed (1) / Tests 8 passed (8)
blinded:   Test Files 1 failed (1) / Tests 2 failed | 6 passed (8)
engine neighbours (project-engine-unpause-active-timing + self-healing): 424 tests, green and unchanged
lint clean; fnxc-future-dates: none added; census unchanged
```

Blind confirmed applied with `git diff --stat` before each run, not
inferred from the tool's exit code.

## What breaks without it

On a board whose wip lane is `building`, the sweep queries
`in-progress`, finds **no tasks**, and shifts no anchor. Every card
silently absorbs the stopped-engine wall-clock the sweep exists to
exclude. The reported active time is simply wrong and nothing fails to
signal it — the same silent-wrong-number shape as the evaluator defect
in #3224.

## Also covers the complement

A held card *outside* the wip lane is **not** shifted. Widening a lane
read is the kind of change that can quietly turn a targeted sweep into a
board-wide rewrite; a card in `todo` has no stopped-engine time to
exclude, and there is now a case saying so.

## Scope note

`packages/core` is not my package. This is an additive test file with no
product change, so collision risk is low, but I am flagging it rather
than assuming: **16 of core's 17 files with resolver call sites remain
unaudited** and I claim nothing about them. The audit method and its
failure modes are documented in #3223 if core's owner wants to continue
it.
2026-07-31 12:14:40 -07:00
gsxdsm
78d87f0a10 test(core): pin the search archive-lane WIRING — the predicate was covered, the hand-off was not (#3220)
## The false-green

#3160 (mine) proved `liveSearchPredicate` honours a resolved archive
set: hand it `Set(["archived","filed"])` and `filed` appears in the
bound params. That contract is real and still correct.

**Nothing proved `reads.ts` passes one.** It is a unit test of the
collaborator, so blinding the resolver at the call site cannot fail it.
A conversion, a test that looks like it covers it, and no connection
between them.

## The measurement — and the instrument matters

| site | vs. the predicate unit test | vs. a test that drives `reads.ts`
|
|---|---|---|
| `reads.ts:396` cold-storage list | 0 failed | **1 failed — covered** |
| `reads.ts:615` incremental sync | 0 failed | 0 failed — **UNCOVERED**
|
| `reads.ts:793` search | 0 failed | 0 failed — **UNCOVERED** |

Against `search-excludes-renamed-archive-lane.test.ts` all three read as
uncovered — an artefact of asking a file that never executes `reads.ts`.
Against `cold-storage-renamed-archive-lane.test.ts`, which drives
`listTasksImpl` for real, 396 is covered and the other two genuinely are
not.

That is rule 2 of #3214 one level up: *the test must reach the site*,
and a unit test of the collaborator never does. Had I stopped at the
first instrument I would have reported three uncovered resolvers, one of
them wrongly.

## What 793 costs on a renamed board

`searchTasks` backs the **CREATE-time near-duplicate check**. Without
the resolved lanes threaded, search stops excluding the board's archive
lane, and creating a task can be refused as a duplicate of one the
operator archived long ago — with no way to see why, because the
matching card is not on the board. Precisely the symptom #3160 set out
to fix; this pins the wiring that delivers it.

## An assertion I got wrong, and the correction

I expected an unreadable workflow list to leave `archivedColumns`
**undefined** via the call-site `.catch(() => undefined)`. It does not:
`resolveProjectColumnsForRoles` catches internally and returns its
**legacy-seeded** set, so `Set(["archived"])` is threaded and the
`.catch` never fires on that path. Two layers fail soft and the inner
one wins.

The case now asserts the guarantee that actually holds either way —
**never an empty set** (which would exclude nothing and return archived
rows in every search), legacy id always excluded. Recorded at the site,
because the mechanism is not obvious from the call.

## Flagged, not papered over

**`reads.ts:615` is left uncovered on purpose.** It composes Drizzle
conditions and runs them against `layer.db` with no injectable seam, so
pinning it needs a real database and belongs with the `.pg` suites. A
test asserting "the query was built" rather than "the rows were
excluded" would satisfy the ratchet and prove nothing.

Also flagged from this sweep: `workflow-analytics.ts` and
`team-analytics.ts` (4 resolvers) are **unmeasurable in my environment**
— their renamed-lane coverage lives in `.pg` suites, and this worktree
has no TCP PostgreSQL (`pg_isready` reports a Unix socket; the harness
probes TCP, so `pgDescribe` correctly skips). Not claimed either way.

## Census

**Unchanged — `CONVERSION QUEUE EMPTY`, `AVAILABLE: 0`.** Converts
nothing; closes coverage on a conversion the census already counts as
done.

## Verification

```
as written                    Tests  4 passed (4)
BLIND reads.ts:793            Tests  1 failed | 3 passed (4)
restored                      Tests  4 passed (4)
```

Anti-vacuity case included: every other assertion reads a mock's
arguments and would pass if the search were never reached, so one case
pins that the primary search path actually ran. Typecheck clean.

No changeset: test-only, behavior-preserving, no published-package
surface.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:50:02 -07:00
gsxdsm
aa1655ccd9 fleet: reclassify the census tail — 10 → 2 guards, all reasoning already in the code (#3213)
## Census before / after

```
                        before   after
COLUMN guards (backlog)     10       2
DELIBERATE-LITERAL         138     148
```

Baseline re-recorded in the same commit; `--strict` green.

## This converts nothing — the tail was never backlog

All ten remaining guards already carried an explicit in-code decision.
**None carried the `DELIBERATE-LITERAL` marker the census reads**, so
each re-appeared to every fleet pass as if unexamined. That is the whole
defect this fixes.

| site | the reasoning already at the site |
| --- | --- |
| `audit-ops.ts`, `moves.ts` | the degraded fallback arm of an
**already-converted** site; the live arm uses the resolved lane set |
| `scheduler.ts` ×2 | *"LEFT COUNTED"* — an await behind the
`tracked.has` re-entrance guard lets two updates double-start a monitor;
the sibling is the measured-expensive `task:updated` emit path (26 sites
against 7) |
| `notification-service.ts` | this method and its only caller are
**sync**, reached from a listener the store invokes as `(task: Task):
void`; resolving makes the chain async and reorders notification
classification against every other `task:updated` handler |
| `lifecycle-ops.ts` | *"Recorded rather than converted"* — dead code |
| `task-id-integrity.ts` | sync, no store-scoped read; converting alone
would disagree with `getLiveTaskColumn` |
| `triage.ts` | *"LEFT COUNTED until then"* — wants a non-sync-resolved
lane answer |

## Marker placement is load-bearing, and I got it wrong twice

The census reads a node's **leading** comments. A marker in a nearby
block comment attaches to the wrong node and is **silently ignored** —
it reads as reviewed while the count still lists the site.

- `task-id-integrity.ts` — my first marker went into the block comment
above the `const`; the literal is in the `return`. Count stayed at 1
until I moved it.
- `ResearchTaskActionModal.tsx` — marker added, **measured that it did
not register**, reverted.

Every edit was verified by re-running the census, not assumed. That is
the only reason the count actually moved.

## Two sites deliberately left counted

- **`ResearchTaskActionModal.tsx`** — the literal sits mid-expression
inside a `.then()` chain, so no marker can attach. The census's own
guidance is to hoist it into a named helper; the site's note asks for
that to be someone's deliberate change rather than a drive-by, so it
stays counted and honest.
- **`self-healing.ts`** — the memo closure I converted and reverted in
#3049. Its note: a renamed board costs a duplicate log line, not a wrong
lifecycle decision.

## Correction I owe on the measurement itself

For many turns I reported "zero unclaimed guards". That came from a bug
in **my own** query — `byFile` is an array of `[file, count]` pairs and
I had switched to `Object.entries()`, which yields `[index, pair]`, so
`n > 0` was always false and the filter returned zero regardless of
state. It agreed with reality while open PRs held every file, which is
why it went unnoticed; it was still wrong, and a constant zero against a
falling backlog should have prompted me to check it sooner.

## Verification (measured)

- engine `self-healing` + `scheduler` suites — **1003 passed / 56
files**
- core `task-id` / `moves` suites — green
- `tsc --noEmit` clean in core, engine and dashboard; `eslint` clean
- `pnpm test:gate` — green
- `lifecycle-column-census --strict`, `check-lane-wiring`,
`check-sql-column-literals`, `check-fnxc-future-dates` — green

No changeset: `@fusion/core`, `@fusion/engine` and `@fusion/dashboard`
are private, and no runtime behaviour changes.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Clarified internal annotations for archived, in-progress, and
in-review workflow states.
* Documented fallback behavior and timing safeguards across lifecycle,
scheduling, notification, and triage flows.

* **Chores**
* Updated internal lifecycle tracking baselines to reflect current
annotations and state coverage.

* **Bug Fixes**
  * No user-visible behavior changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 11:15:40 -07:00
gsxdsm
8661b739ff fix(scheduler): a board with TWO complete columns left dependents waiting forever (#3210)
## The defect

On a board declaring more than one complete-trait column — a merged lane
and a shipped lane, say — a card landing in the **second** one was never
recognised as finished, so nothing unblocked its dependents. Silent: no
error, the dependent just waits.

Two problems, the same shape:

1. **`TaskMoveLanes` carried one id per role.** That is right for
*"where should this card go"* and wrong for *"is this column one of the
finished lanes"*, which is a **membership** question. The payload could
not express such a board at all.
2. **`mergeParkedColumns` rebuilt `terminal` as `new Set([complete,
archived])`** — discarding `base.terminal`, which the sync IR path had
already resolved correctly, and narrowing a membership set back to
first-match-per-role.

Point 2 contradicted the note sitting directly above it in the same
file:

> `terminal` is a MEMBERSHIP set, and it is not the same question as
`complete`/`archived`. […] A workflow may declare more than one
complete-trait column […] and `to === parked.complete` sees only the
first and silently skips the rest.

The reasoning was already written down. The overlay added later didn't
honour it.

## Fix

`TaskMoveLanes.terminal?: readonly string[]`, filled from
`columnsWithFlag(ir, "complete"|"archived")` rather than the first-match
`resolveLifecycleColumns`, and the merge is now a **union** of base,
payload, and the single lanes.

Optional, so all 12 emitters and every listener keep compiling — a
listener that ignores it is exactly as correct as before. Union is the
direction `scheduler.ts` already argues for at line ~422: a superset
costs one extra query; a subset **silently withholds work from a
finished card**.

`complete` deliberately stays first-match — a set would be the wrong
shape for a move *target*. Both questions now coexist rather than one
replacing the other.

## How it was found, and what it corrects

Supplying `task:moved` lanes fixed every *other* renamed-board case in
`scheduler-renamed-hold-events` — measured **10 passed / 1 failed** —
and left exactly this one broken. That same measurement is why I
narrowed my earlier claim on #3082: the other behaviours were never
broken in production, because all 12 emitters already carry lanes. This
is the residue that was genuinely broken.

## Tests — both with anti-vacuity controls

| control | result |
|---|---|
| revert `toTaskMoveLanes` | **2 of 4** core tests fail (the terminal
pair) |
| revert the scheduler union | the new engine test fails, **and only
it** (1 failed / 11 passed) |
| both restored | 4 passed, 12 passed |

The 2 core tests that pass either way are shape invariants asserted on
purpose (`complete` must stay first-match; a column-less IR returns
`undefined` rather than an invented lane) — flagging that so the control
isn't read as 4-of-4.

The pre-existing scheduler case emits **without** lanes, which no
production emitter does, so it exercises the sync fallback. The new one
emits `toTaskMoveLanes(ir)` — the shape that actually ships.

## Measured

| check | result |
|---|---|
| `@fusion/core` / `@fusion/engine` tsc | exit 0 / exit 0 |
| eslint | clean |
| `census --strict`, `check:fnxc-future-dates`, `check:changesets` |
exit 0 |
| every `TaskMoveLanes` consumer | 24 passed |
| `pnpm test:gate` | **exit 0 — 744 tests, up 12** |

## Census

No guard converted; this is a payload-shape fix. Backlog unchanged at
11, all deferred.
2026-07-31 10:47:15 -07:00
gsxdsm
bad39e2ca3 test(core): ledger the legacy-id collections that gate a live column — the class the census cannot count (#3209)
## What

A population ratchet over **legacy-id collections consulted against a
live column value** (`SOME_SET.has(task.column)`), recorded as 23 sites.

## Why

The census scans `===`/`!==` comparisons. A Set or array literal is a
**definition**, so no census run has ever pointed at one. Three
found-by-hand defects came from that blind spot:

| collection | symptom |
|---|---|
| `GITHUB_TRACKING_EDITABLE_COLUMNS` | operator could not toggle GitHub
tracking **at all** on a renamed board — no error, affordance absent
(#3149) |
| `TIME_INDICATOR_COLUMNS` | wrong elapsed-time indicator on cards |
| `BLOCKER_ESCALATION_COLUMNS` | escalation skipped renamed lanes |

#3149 enumerated the population by hand and concluded *"this is where
the remaining renamed-board defects actually live."* A number in a PR
body rots. This is that enumeration as a ratchet.

## Census

**Unchanged — `AVAILABLE: 0` before and after, 12 documented deferrals
both sides.** This PR converts nothing. It ratchets a class the census
*structurally cannot see*, which is the point: the backlog reading zero
has never meant the lane vocabulary is fully converted, only that the
measurable part is. Recording that plainly instead of claiming a delta
this change does not produce.

## What it claims, and what it deliberately does not

It claims the **population** is the recorded set. It does **not** claim
each site is correct — 20 of the 23 are #3149's assessment ("most are
already correct, either no-flags fallbacks or seed-then-add resolved
sets"), and I did not re-verify them. Blessing sites I have not read is
how a ledger becomes a list of things someone once glanced at. A new
entry fails the test and a human reads that **one** site; that is the
entire mechanism.

## My own detector's pick-work list was 100% false positives

Measured, and the reason this ships with **no candidate list**. The
heuristic "no role-helper call in the file" flagged three sites; all
three were fine:

- `agent-role-policy.ts:32` — a documented **FLAGGED, NOT FIXED**
deferral with its reasoning recorded
- `DocumentsView.tsx:88` — already converted, flags-first; the flags
arrive as a threaded **object**, so a scan for resolver *calls* cannot
see the conversion
- `agent-assignment.ts:118` — a `DELIBERATE-LITERAL` fallback behind an
injected `countsAsAssignmentLoad` callback, reviewed `2026-07-31-05:40`

That is the same failure `--triage`'s pick-work list had before #3194
fixed it, from the same cause: **inferring "unexamined" from the absence
of a pattern rather than from evidence.** A detector that cannot
distinguish "not yet looked at" from "looked at and settled" must not be
pointed at a work queue. It can still hold a population steady, which is
all this does.

## Verification

Mutation-verified in **both** directions — a ledger fails by missing
additions *or* by keeping ghosts:

```
### baseline                                        Tests  4 passed (4)
### MUTATION 1 — new unrecorded gating collection
+   "packages/engine/src/worktree-pool.ts :: NEW_LANE_GATE",
                                                    Tests  1 failed | 3 passed (4)
### MUTATION 2 — recorded site vanishes (ghost)
+   "packages/engine/src/worktree-pool.ts :: managedRenamed",
+   "packages/engine/src/worktree-pool.ts :: managed",
                                                    Tests  2 failed | 2 passed (4)
### restored                                        Tests  4 passed (4)
```

Two anti-vacuity cases guard the detector: it still finds the
collections whose defects motivated the file, and it does **not** claim
plain comparisons (asserted against `self-healing.ts`, dense with column
comparisons and no gating collection) — pulling those in would
double-count a class that already has a gate.

## Flagged, not guessed

- **Line numbers are excluded** from ledger entries — they drift with
unrelated edits and would fail this test for reasons that are not about
lane vocabulary.
- **Comments stripped before scanning:** `TaskDetailModal.tsx` and
`TaskCard.tsx` both quote their own collection by name in FNXC notes
explaining the bug it caused. Counting prose would fire the ledger on
the files that document the hazard most carefully.
- **Stated reach limits** (in-file): only *named* collections consulted
as `.has`/`.includes`; the argument must mention column/lane;
property-reached collections are missed. A miss is a site nobody is
watching — not a false green on a listed site.

No changeset: test-only, behavior-preserving, no published-package
surface.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:44:08 -07:00
gsxdsm
5c5f6d8155 fix(core): mark the two archived STATE sites at the site — converting them destroys live work (#3157)
The LANE/STATE triage (#3154) found that **two of the eight** Drizzle
`archived` sites are STATE markers that must never be resolved. That
classification lived only in `archived-column-gate-parity.test.ts`.

A coordinated three-encoding conversion **edits these files**. A
converter working file-by-file sees the same `eq(column, "archived")`
shape as the six LANE sites, with nothing in front of them to tell the
two apart.

So the markers go at the sites.

## `task-mutation-ops.ts` — `cleanupArchivedTasksImpl`

Enumerates rows Fusion itself archived, then **removes their
directories**.

Widening it to the resolved archived-lane set would feed cards **merely
resting in a board's archived-trait lane** into a filesystem delete.
This is the only site in this family where a wrong conversion **destroys
work** rather than hiding an affordance.

## `async-self-healing.ts` — `listSoftDeletedColumnDriftCandidates`

Finds soft-deleted rows whose column **drifted** from the marker they
are supposed to carry. Resolving it would classify a soft-deleted row in
a renamed archive lane as drift and "repair" a row that is already
correct.

## Why this is defensive rather than cosmetic

The triage exists to make the conversion safe. A classification the
converter **cannot see while editing the file** does not do that — it
only helps someone who happens to read the gate's test file first, which
is not how a file-by-file sweep proceeds.

Both are marked DELIBERATE-LITERAL with the reason and a pointer to the
parity test holding the full eight-site split.

## Measured

- Comment-only.
- Parity test **2/2**; archive + soft-delete suites — **5 files / 15
tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict` and
`check-sql-column-literals` clean.
- **No census movement** — a DELIBERATE-LITERAL marker on a STATE site
is a classification, and these were never counted as lane debt.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:30:41 -07:00
gsxdsm
dd3bf6a764 docs(core): the archived TS remainder is EMPTY — enumerated, closing the triage (#3171)
#3156 sampled the TS inventory and said the conversion is *"the six LANE
Drizzle sites plus whatever small TS remainder is neither a fallback arm
nor a sentinel"*.

**That remainder is zero.** I left three sites unchecked when I wrote
it. All three are fallback arms:

| site | shape |
|---|---|
| `live-agent-count.ts:164` | `task.columnTerminalKind ?? (task.column
=== "done" ? … )` — the resolved value wins via `??` |
| `store.ts:1972` | `if (!lanes) return dep.column !== "done" && …` — an
explicit no-metadata branch |
| `branch-and-pr-entities.ts:525` | `lanes === undefined ? task.column
=== "archived" : task.column === lanes.archived` |

With the previously classified entries, **every** site in
`AUDITED_TS_SITES` is now accounted for as a fallback arm, a
STATE/sentinel comparison, or a converted guard's retained literal.
**None is an unconverted LANE guard.**

## So the cluster is done

"52 sites across three encodings" is fully triaged, and the convertible
work is the six Drizzle LANE sites plus the log-entry gate — all
additive, so the inventories never moved.

What the gate now protects is a population of **fallback arms and STATE
markers**, which is exactly what it should protect: each is the
documented answer for a caller that supplies no resolved set, or a
marker that must never be resolved.

A future **drop** in any of the three counts means someone removed a
fallback or converted a STATE site — both regressions. That is the check
this file was built to make, and is now the only check it needs to make.

## Enumerated, not sampled

I sampled this inventory twice and each pass changed the size estimate —
first "52 sites, real blast radius", then "six plus a small remainder".
A third estimate would have been worth less than a complete count, so
this pass covers every entry.

That is the honest close: the number stopped moving because I stopped
guessing at it.

## Measured

- Comment-only; parity test **2/2**.
- `tsc --noEmit -p packages/core` clean; census `--strict` clean. **No
census movement.**

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:30:30 -07:00
gsxdsm
230be28576 fix(core): the merge-queue enqueue guard was not debt — the code it guarded had no callers (#3205)
## The deferral note was right about the mechanism and wrong about the
remedy

`merge-queue-ops-2.ts` sat in the census as deferred debt behind this
note:

> Converting it properly means either making this path async or pushing
the trait read into SQL, both of which are store-architecture changes
rather than call-site conversions.

That is correct as far as it goes — the guard runs inside
`store.db.transactionImmediate`, so the only synchronous resolver
available (`resolveTaskWorkflowIrSync`) returns the DEFAULT workflow
under PostgreSQL and a "conversion" would be inert.

But it assumed the code needed converting. Measured across the tree:

```
=== every call site of .enqueueMergeQueueSyncInternal( ===
packages/core/src/store.ts:1775:  public enqueueMergeQueueSyncInternal(...)   <- the declaration itself
```

**Zero callers.** Every other occurrence of the name is a comment. The
live path is `enqueueMergeQueueAsync` (`task-artifacts-ops.ts:117`), and
that file already documented the deletion:

> Merge-queue enqueue is PostgreSQL-only via enqueueMergeQueueAsync …
The SQLite `enqueueMergeQueueSyncInternal` arm is deleted.

The arm was deleted; its declaration was not. The guard was unreachable
on the shipped backend.

## Change

- Deleted `enqueueMergeQueueSyncInternalImpl` (-85 lines) and its
`store.enqueueMergeQueueSyncInternal` entry point.
- Dropped the six imports that became unused
(`MergeQueueTaskNotFoundError`, `MergeQueueInvalidColumnError`,
`MergeQueueEntry`, `MergeQueueEnqueueOptions`, `normalizeTaskPriority`,
`MergeQueueRow`).
- Refreshed the three comments naming the removed symbol, so none points
at a deleted identifier. The
`handoffMergeQueueFailureInjectorForTesting` hook those comments sit on
is a **different** member and is untouched — it only mentioned the sync
arm as context.

## Census before / after

| | before | after |
|---|---|---|
| `packages/core/src/task-store/merge-queue-ops-2.ts` | 1 | **0 (entry
removed)** |

Baseline tightened by exactly one entry. **The 0 here is a deletion, not
a conversion** — recorded in the file's own FNXC note so the next worker
does not read it as a converted seam. This is the failure mode the
census warns about ("a count of 0 is the WORST case, not the best"), so
it is stated at the site rather than left to inference.

## Measured

| check | result |
|---|---|
| `census --strict` | exit 0 |
| `@fusion/core tsc --noEmit` | exit 0 |
| `eslint` (4 changed files) | clean |
| core merge-queue tests | **110 passed / 6 files**, incl.
`postgres/merge-queue-renamed-review-column.pg.test.ts` |
| `pnpm test:gate` | exit 0 (**732 tests**) |

No changeset: `@fusion/core` is private and this removes unreachable
code with no user-visible behavior.

## Flagged, not guessed

The other four deferral-note files remain deferred. I only reclassified
this one because its call-site count is a fact I could measure, not a
judgement. Whether `lifecycle-ops.ts:667` is likewise dead (it sits in
the legacy-SQLite polling-replica path) is a separate question I have
not measured, so I have not touched it.
2026-07-31 10:30:16 -07:00
gsxdsm
25fa5e7c44 fix(core): the log-entry archive gate, converted — the parity objection is met, not bypassed (#3165)
I converted this in #3110, the parity gate failed, and I reverted it.
**The gate was right** — and the reason was subtler than "one encoding
moved". Knowing it is what makes this conversion possible.

## Why the first attempt failed

My version hoisted the comparison onto a local:

```ts
const pgRowColumn = String(pgRow.column ?? "");
const rowIsArchivedLane = archivedLanes ? archivedLanes.has(pgRowColumn) : pgRowColumn === "archived";
```

That gate's TS scan keys on the **property** being named `column` —
deliberately, because the receiver is variously `task`, `row`, `dep`,
`t`. Losing the `.column` access dropped the TS count while SQL and raw
held steady, which it reads as divergence.

**Behaviourally identical, structurally invisible.** Same failure mode I
hit from the other direction in #3163, where I collapsed a Drizzle
fallback into a string array.

## The fix

Keep `pgRow.column === "archived"` **verbatim** as the fallback; add the
resolved path in front of it. No encoding's count moves, an unwired or
degraded caller behaves exactly as before, and the gate is **satisfied
rather than worked around** — the same additive shape as the six Drizzle
LANE sites (#3160, #3162, #3163).

## What it fixes

A LANE question: *"is this row in the board's archive lane, so logging
is read-only?"*

Against the literal, a card the operator filed away on a renamed board
kept **accepting log writes** — new activity accruing on closed work.
`deletedAt` covers the soft-delete half, which is why the gap is narrow
and why it stayed invisible: the common path is soft-delete.

## The recorded omission is retired properly

`log-entry-archived-lane-gate.test.ts` carried the renamed case as a
**deliberate omission** with its reason. It is now the first case in the
file, and the note explains why the earlier judgement changed rather
than quietly disappearing — a deferral that vanishes without explanation
is how the next reader loses the thread.

## Measured

- **3/3** in that file (renamed case added); parity test **2/2**,
inventories unmoved.
- **MUTATION**: dropping the resolved branch fails the renamed case and
leaves the legacy **control** and the live-lane **negative** green.
- log-entry / archived / audit suites — **3 files / 9 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals` clean.
- `check-fnxc-future-dates` is red on `main` from `task-update.ts`
(another lane's stamps), not from these files.

## Census

**Unchanged** — the literal remains the fallback arm, by design.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:27:31 -07:00
gsxdsm
762d232ad6 test(core): ratchet the sentinel-task-id argument at zero — the third inert-conversion mechanism (#3204)
## What

A test-only zero-population ratchet: no task-scoped lane resolver may be
called with a **string literal** where a row id belongs.

## Why

`resolveTaskLifecycleColumns(store, taskId)` and its siblings resolve
the workflow bound to *that task*. Hand one a literal and there is no
task to read a selection for, so the resolver falls back to the
**default board** and answers with full confidence. The call
type-checks, reads as a finished conversion, and is correct on every
board Fusion ships — because the default board is the answer it returns.

**This shipped.** `triage.ts`'s startup sweep called
`resolvePlannerLanes(this.store, "")` and built its swept-column set
from the result (#2806 measured it, #3201 fixed it). It was a
*sweep-wide* defect rather than a per-card one: it resolved once for the
whole board and could not be right for any workflow but the default, so
a card parked in a renamed hold column with a stale `planning` status
was never swept and held a planning admission slot permanently.

Note what this means for the other two inert mechanisms' fixes —
**making the resolver async would not repair it**, because the defect is
the argument, not the resolver.

## Why a guard and not just the existing E2E

`workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts` covers the **one**
triage site and lives in the `.pg` lane, so it is skipped whenever no
PostgreSQL is reachable — including the merge gate. The defect is the
*argument*, which makes it visible in source text with no database, no
running engine, and no knowledge of what the resolver does.

## Census

**Unchanged — 0 guards before, 0 after.** This PR converts nothing; it
is a ratchet over a class the census structurally cannot see (the census
scans column literals, not resolver arguments). Recording that plainly
rather than claiming a delta this change does not produce.

Population of the guarded class is **zero today** — the only textual
match in the tree is prose in `triage.ts` documenting its own fixed bug.
A zero-population ratchet is the instrument here, not a weakness: it
cannot fail until someone reintroduces the defect, and it costs one
source scan.

## Verification

**Mutation-verified, not asserted.** Re-adding the exact shipped shape
to a real production file:

```
+   "packages/engine/src/replan-target.ts:188 — resolvePlannerLanes",
 Tests  1 failed | 3 passed (4)
```

Restoring the file returns it to `4 passed`. Working tree left clean.

Three anti-vacuity cases carry the file, because a scan that reports
success by finding nothing is otherwise indistinguishable from a broken
scanner:
- the matcher **does** fire on the historical text
(`resolvePlannerLanes(this.store, "")`);
- it does **not** fire on the ordinary shapes that fill the codebase
(`task.id`, `taskId`, `row.id`) — a matcher flagging everything would
pass the case above while being unusable;
- the walker still reaches production source (>20 real
`resolveTaskLifecycleColumns` call sites), which is what makes the zero
a measurement rather than an empty scan.

## Flagged, not guessed

- **Comments are stripped before scanning**, and here that is required
rather than tidy: `triage.ts` quotes the offending call verbatim to
explain the hazard. Counting it would make the guard fire on the file
that correctly documents the defect, training readers to silence the
guard instead of heeding it.
- **`resolveReboundTarget(ir)` / `resolveLifecycleColumns(ir)` are
deliberately excluded** — they are IR-scoped and take no task id;
including them would flag correct code.
- **Known limit, stated in the file:** a sentinel arriving through a
*variable* (`const id = ""; resolve(store, id)`) is invisible to a text
scan. The literal form is what shipped and what the next person is most
likely to write; the variable form still needs the `.pg` E2E. Two
instruments, different reach — not full coverage of the class.

No changeset: test-only, behavior-preserving, no published-package
surface.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:27:18 -07:00
gsxdsm
d6079970e8 fix(self-healing): 18 recovery rebounds hardcoded todo and THREW on a renamed board (#3150, first slice) (#3152)
First slice of #3150. `self-healing.ts` held **26** `moveTask` calls
with a legacy literal target; this converts the **18 `todo` rebounds**.

## Why this is worse than a guard, and documented already

`task-store/moves.ts` records it from a previous incident:

> `moveTaskInternal` **REJECTS** a target the workflow does not declare
(`TransitionRejectionError: unknown-column`) … completion handoff did
not silently no-op — it **THREW**.

Every one of these 18 is a **recovery**. On a renamed board they threw
instead of rebounding, so the strand each sweep exists to clear survived
*and* the sweep reported failure. The reliability layer meant to be the
backstop was the layer that broke.

## Why the census never saw it

It counts **comparisons** against legacy ids. A move target is an
**argument**. That is the third blind spot of the same instrument, and
all three have now produced real defects found by hand:

| blind spot | found this session |
|---|---|
| definitions | `GITHUB_TRACKING_EDITABLE_COLUMNS` — tracking
unreachable on renamed boards (#3149) |
| collections | swept: 30 sites, 29 already correct, 1 defect (the
above) |
| **targets** | **this** — 26 in one file, 31 tree-wide |

## Why 18 sites at once is safe

`resolveReboundTargetForTask` **degrades to `"todo"`** when no workflow
resolves, and `self-healing.ts` already used it at line 745. On every
board we ship, the resolved answer *is* `todo` — so default behaviour is
unchanged **by construction**, not by inspection. The control case pins
exactly that, and it is the reason this can land as one change rather
than eighteen.

## Scope, and what I deliberately did not touch

Converted: the 18 `todo` rebounds.

**Not** converted: the `done`, `archived` and `in-review` targets. They
need different helpers and genuine reasoning about which lane a
completion or an archive belongs in — converting them by analogy is
exactly the half-conversion this program keeps paying for. Sites with no
resolver in scope are unchanged.

The audit behind the split is in the commit: of 26 sites, 5 had resolved
lanes in scope, 4 had an IR, 17 had nothing — and `lanesOfReclaim`
returns **Sets**, which is the wrong arity for a target (a move takes
exactly one column, per the `moves.ts` note).

## Verification

| | result |
|---|---|
| engine `tsc` | **0 errors** |
| **all 43 self-healing suites** | **843 passed** |
| census `--strict` | exit 0, **unchanged** — invisible to it |
| `check-inert-sync-lanes` | exit 0 |
| differential | restoring the literal → **1 failed \| 1 passed**,
renamed case only |

The new test drives a **public entry point**
(`reconcileInReviewUnmetDependencies`, the FN-6793 contract) rather than
calling the helper directly, so it covers the producer path too.

One harness note worth keeping: the first version of the test failed
**upstream** of the target, because the sweep selects rows via
`resolveProjectColumnsForRoles` — a *project-level* resolver reading
`listWorkflowDefinitions`, not the task's own selection. Without that
mocked, the renamed card was never considered and the failure looked
like the fix not working. That distinction (project-level vocabulary vs
per-task IR) will bite the next slices too.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Tasks now move to workflow-specific rebound, completion, and archive
columns instead of fixed default destinations.
* Retrying and recovering tasks works correctly on boards with renamed
lifecycle columns.
* Added safe fallback behavior for workflows without custom lifecycle
settings.
* **Tests**
* Added coverage to prevent legacy hardcoded task destinations and
verify renamed-column recovery scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:24:22 -07:00
gsxdsm
4c62124589 fix(core): the last four archived LANE sites — the ones that already held a store (#3163)
Completes the six **LANE** sites from the triage (#3154). #3160 and
#3162 did the two predicate builders that needed threading; these four
already had `store` in scope, so each is a resolve-and-spread at the
site.

## What each fixes on a renamed board

| site | defect |
|---|---|
| `store.ts` revert lookup | a done/archived prior undo attempt kept
surfacing as an **open** undo task — the store-side twin of the
dashboard defect fixed in #3129 |
| `branch-group-ops.ts` | the near-duplicate marker cleanup found **no
live rows at all**, so stale markers survived. Its own header says stale
markers alter operator decisions |
| `branch-and-pr-entities:438` | the CREATE-time fingerprint duplicate
guard kept archived cards in the candidate set — a new task could be
refused as a duplicate of one already filed away |
| `branch-and-pr-entities:470` | recent-sibling lookup counted finished
siblings as candidates |

## The parity gate caught my first version — and it was right

I collapsed the fingerprint fallback into a **string array**
(`["archived"]`) and pushed `ne(col, lane)` in a loop. Behaviourally
identical, and it **dropped the Drizzle encoding's literal count**,
because the gate scans for the `ne(..., "archived")` *expression shape*.
TS and raw held steady, so the encodings diverged — precisely what that
gate exists to catch, catching it.

The fix: keep every fallback as a literal `ne(..., "archived")`
**expression** rather than data.

That is what makes these conversions **additive** — the resolved path is
added, the literal stays, no encoding's count moves, and an unconverted
board builds byte-identical SQL. Same property as #3160/#3162, now with
a demonstrated failure mode for getting it wrong. Worth knowing for
whoever does the remaining TS remainder: *behaviourally identical* is
not sufficient; the shape has to survive too.

## Measured

- Parity test **2/2**, inventories unmoved — the point.
- archived / branch / near-duplicate / merge-blocker suites — **6 files
/ 46 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals`, `check-fnxc-future-dates` clean.

## Census

**Unchanged** — literals remain as fallback arms, by design.

## Where the cluster stands

All **six LANE** Drizzle sites are now converted (#3160, #3162, this).
The **two STATE** sites are marked in place and must never be converted
(#3157). What remains is the small TS remainder that is neither a
fallback arm nor a sentinel, identified in #3156.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:24:07 -07:00
gsxdsm
a6d67844b8 fix(triage): the "unconvertible" site was convertible — the blocker was two test harnesses (#3191)
#3141 measured this site as unconvertible, and I twice reported the
cause as a production constraint. It was not. This is the instrumented
answer to the probe I recommended there and then ran myself.

## The isolation

| configuration | result |
|---|---|
| flag only, no conversion | **8 passed** → the orphan arm is *not* the
cause |
| flag + conversion | **5 failed** → the conversion is |
| same, with a realistic mock store | **8 passed** → the mock was the
cause |

`triage-stuck-requeue-preserve-draft.test.ts` defined neither
`getTaskWorkflowSelection` nor its async twin — exactly like
`triage.test.ts` did before #3189. Both made
`resolveWorkflowIrForTaskWithProvenance` **throw** and take its catch
branch: the *"could not ask"* shape, which a production store never
presents.

So the 5 failures I deferred as a possible semantics change were the
same harness gap in a second file — confirmed, not argued.

## What changes

**`selectionAbsent`** marks the determinate case: the store *answered*
"no selection", so the workflow is the default and its IR is in hand.
Added as a **separate field, not a third `source` value** — `source ===
"default"` is compared in **31 places** in `self-healing.ts` meaning "be
conservative", and a new enum value would silently stop matching every
one of them while still compiling and still passing on a default board.

**`recoverApprovedTask`** now accepts a legacy `triage` row *explicitly*
(its workflow does not declare that column) instead of depending on
`resolvePlannerLanes` **failing** and falling back to legacy ids.
Correctness resting on a resolver's failure mode is what this removes.

## Measured

| | result |
|---|---|
| broad suite (triage / self-healing / recovery / planning) | **77
files, 1302 tests passed** |
| the three directly affected suites, post-rebase | **245 passed** |
| the flag is load-bearing | conversion **without** it: **18 failed \|
221 passed** |
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**The inert-sync-lane count is unchanged at 7 for `triage.ts`.** This
site was never among the counted guards, so this is **not** a ratchet
reduction — stating that rather than letting a conversion imply one. It
removes a real inert dependency the ratchet cannot see, which is the
blind-spot class this phase has been mapping.

## Why this took four attempts

I described this blocker at four levels: merged intake/hold, orphan-arm
scoping, identity verification (filed as **#3187**, closed as wrong),
and finally the harness. **The two I instrumented held; the two I
reasoned to did not.** The fix here is the probe I wrote down for
someone else — which is where it should have started.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:36:03 -07:00
gsxdsm
6bc90ccbe2 fix(core): allow-list the legacy workflow IR — it found a fourth bug my grep missed (#3185)
## The name is the defect

`BUILTIN_CODING_WORKFLOW_IR` reads like the default and **is** the
legacy workflow (`builtin:legacy-coding`). Post-U11 they differ by
exactly one column — `triage` — the one a caller most often wants
absent.

**Four bugs have come from reaching for it by name:**

1. two move-path resolvers disagreed on the no-selection default →
*"workflow move policy preflight is stale"* on every flag-on move
(recorded in `resolveDefaultWorkflowIr`'s own header)
2. the TUI board rendered a `triage` lane the default board lacks —
#3178
3. `deleteWorkflow` re-homed occupants into `triage` — #3183
4. **`board-workflows.ts`** described a *custom* workflow whose
definition failed to load using legacy columns — the #3178 symptom
through the dashboard route. **Fixed here.**

It type-checks, it is the obvious identifier, and on the five shared
columns it behaves correctly. The mistake only shows on the column that
differs.

## I said the sweep was complete last round. It wasn't.

My grep excluded paths and truncated at `head -10`; it missed two sites.
**The allow-list found both on its first run.**

That is the lesson the sibling sync-resolver ratchet already records —
*"FOUND BY THIS RATCHET, not by the grep that seeded the list"* — and I
had just quoted that file while repeating the mistake.

## One site is allow-listed rather than fixed, and I tried the fix first

`workflow-graph-executor.run()`'s default `ir` is unreachable in
production (both callers pass it explicitly). But
`workflow-graph-executor-parity.test.ts`, in the **engine-core gate
suite**, drives the method *without* the argument to assert the
historical seam sequence.

Switching it to the catalog default rewrites what "parity" means:
**measured, 6 gate tests fail** with `expected 'failure' to be
'success'`. Reverted, and recorded at the call site *and* in the
allow-list entry so nobody repeats the experiment.

That is what an allow-list is for: a legitimate narrow use next to a
plausible-looking wrong one.

## Guard construction

Follows the repo's existing call-site allow-lists (sync resolver, engine
blocking-shellout, detached-spawn script guard).

- **Comments stripped before scanning** — `activity-analytics.ts` and
`TaskContextMenu.tsx` name this constant in notes *about past bugs*
while correctly avoiding it. Counting prose would train readers to
allow-list mentions.
- **Anti-vacuity**: the scan still sees the catalog's own uses, so a
renamed constant or broken walker cannot make the guard pass by finding
nothing.
- **Stale-entry**: the list cannot rot into files that no longer touch
it — the decay every ledger in this repo has hit.

## Measured

- Guard **3/3**; `tsc --noEmit` clean in core, engine, dashboard.
- census `--strict`, `check-fnxc-future-dates` clean.

## Census

**No movement — that is the point.** This class has no column literal to
count, which is why the census never saw any of the four bugs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:45:31 -07:00
gsxdsm
757ce71731 fix(core): deleting a workflow re-homed its cards into triage, a lane the default board lacks (#3183)
A **third door** into the drift #3178 just fixed in the TUI. Found by
looking for siblings of that bug — **not** by the census, which
structurally cannot see this class: there is no column literal here to
count. The wrong answer comes from reading the wrong IR.

## The bug

`deleteWorkflow` clears each occupant's selection so they fall back to
the built-in default, then re-homes them to *"the default workflow's
entry column"* — its own comment's words.

It read that entry column from `BUILTIN_CODING_WORKFLOW_IR`, which is
`builtin:legacy-coding`, **not** the catalog default. Post-U11 the two
differ by exactly the column this reads:

```
default  todo, in-progress, in-review, done, archived
legacy   triage, todo, in-progress, in-review, done, archived
```

**Measured, not inferred:** `resolveEntryColumnId` answers `triage` for
the legacy IR and `todo` for the default.

## Why it got past the guard built for exactly this

`moveTask` rejects a target the workflow does not declare — **except**
under `recoveryRehome` with a **legacy id**, the #1411 escape hatch that
keeps a custom-workflow card rescuable.

`triage` *is* a legacy id. So the rehome slipped through the check that
exists to stop this, and left the card in a lane its new workflow has no
node for — the undeclared-column state other reconcilers exist to
repair.

## Measured

- 3 new cases; **MUTATION**: restoring the legacy constant fails the
anti-vacuity case.
- The first two cases pin the two IRs' entry columns as **facts in the
suite** rather than claims in a comment — that difference is the entire
reason the bug existed. They go quiet, correctly, if the IRs ever
converge again.
- The third pins the **call site**, because the first two would keep
passing against the unfixed code: they describe the IRs, not the caller.
That gap is how an anti-vacuity case earns its place.
- `src/__tests__/workflow*` — **30 files / 402 tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-fnxc-future-dates` clean.

## Census

**Unchanged — and that is the finding.** This defect has no literal to
count. `builtin-workflows.ts` already records the move-path resolvers as
fixed for the same drift, #3178 fixed the TUI, and this is the third
instance. The census measures *comparisons*; a surface that resolves the
**wrong workflow** produces identical-looking code and a wrong answer.

If there is appetite for a next sweep, that is where I would point it:
sites that resolve a workflow at all, rather than guards that compare a
column.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:34:04 -07:00
gsxdsm
2cb5cab595 chore(core): mark the mission-store dead-sync-path literal DELIBERATE (census 13→12) (#3179)
Comment-only. `tsc` 0 errors, `census --strict` and
`check-fnxc-future-dates` exit 0.

## Claimed with the new tool

First use of `scripts/check-file-claimed.mjs` (#3175) to pick work
instead of guessing:

```
CLAIMED    packages/engine/src/scheduler.ts        #3177, #3142
CLAIMED    packages/core/src/task-store/audit-ops.ts   #3165
UNCLAIMED  packages/core/src/mission-store.ts
UNCLAIMED  packages/core/src/task-store/task-id-integrity.ts
```

Two of the four files I would have reached for were already taken — by
PRs whose branch names give no hint they touch those paths. That is the
collision this phase paid for five times, answered in one command. I
took `mission-store.ts`; `task-id-integrity.ts` is still free.

## Census 13 → 12

Reclassification, not conversion — the line is unchanged.

## Verified the blocker rather than deferring to it

The site carries an audited note: the sync `MissionStore` reaches
`this.db.prepare`, and `getMissionStoreImpl` returns the
`AsyncDataLayer`-backed `AsyncMissionStore` under PostgreSQL, so the
class is unreachable in the shipped backend.

I checked that independently instead of accepting it —
`async-mission-store.ts:168` states the same routing from the other
side. **That check exists because of #3129**, where a note I had
accepted as settled ("blocked on a per-neighbour flag map that does not
exist") turned out to name the wrong variable, and the file was
convertible all along. I had publicly argued it should stay counted.

So the rule I am applying: a documented blocker gets marked only after
its named obstacle is confirmed from a second source. Here it held; on
`taskRevert.ts` it did not.

## Related, and still open

`merge-queue-ops-2.ts` carries a note of the same shape that does
**not** survive this check — it names two ways to convert (make the path
async, push the trait read down) and misses the one that worked twice
this phase: thread the resolved lanes in from a caller that already
awaited them, as #3112 and #3118 did for `executor.ts`. Its sibling
`taskStillInReview(projectId, reviewColumns)` already takes lanes from
its caller. Worth a real look rather than a marker.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:13:45 -07:00
gsxdsm
2a57820dd2 chore(gate): normalize the last future-dated stamp in task-update.ts (tightens the allowance 1 → 0) (#3168)
**Main is red on the FNXC gate again** — third occurrence of this class
today, third different file.

```
packages/core/src/task-store/task-update.ts: 2 future-dated FNXC stamp(s), baseline allows 1
```

Stamps dated **2026-08-01** while UTC is **2026-07-31-14:24**. Every
open PR inherits the failure; #3164 merged carrying it.

## The fix

Date only, to today. Clock times preserved exactly — they were real
times on the wrong day — and no comment text touched, so the record
reads identically, just in order:

```
-FNXC:StateMachine 2026-08-01-10:20 (PR #2793's finding — the INNER half, merged with #2821):
+FNXC:StateMachine 2026-07-31-10:20 (PR #2793's finding — the INNER half, merged with #2821):
```

Baseline **tightened** as a side effect (`1 → 0`): one future stamp was
grandfathered, normalizing the file cleared it too, and the gate refuses
a stale allowance on the way down. Re-recorded in the same commit.

## The recurrence is the point, not this fix

Three separate files have tripped this in one day — `scheduler.ts`, the
scheduler PG test, and now `task-update.ts` — plus the midnight-rollover
variant this morning that reddened everyone's baseline.

**Stamps are written from a local clock and validated against UTC.** A
worker behind UTC writes what is genuinely "today" for them and produces
a future stamp the moment UTC has already rolled. Nothing in the local
loop catches it: `pnpm lint` passes locally because the local date
agrees.

The durable fix is to generate the stamp from `date -u` rather than a
wall clock — one line in whatever produces these, and the class
disappears. I have patched the symptom three times today; someone should
take the cause. I have not done it myself because the stamps are
authored by hand across every worker's flow, so the change belongs
wherever that convention is documented, not in a file I happen to be
touching.

## Verification

`check-fnxc-future-dates` green (TZ=UTC CI=true) · `pnpm test:gate` 13 +
161 + 487 + 71 · lint · core typecheck clean · diff is date
substitutions only.
2026-07-31 07:56:09 -07:00
gsxdsm
b3d009edde fix: main is red on check-fnxc-future-dates — one stamp dated tomorrow (blocks every open PR) (#3166)
`check-fnxc-future-dates` runs in `pr-checks.yml`, so while `main` is
red **every open PR fails this check** regardless of what it touches.
Measured on a clean detached `origin/main`:

```
[check-fnxc-future-dates] FNXC stamp population changed:
  packages/core/src/task-store/task-update.ts: 2 future-dated FNXC stamp(s), baseline allows 1
    FNXC:StateMachine   2026-08-01  (dated after today)
    FNXC:WorkflowEvents 2026-08-01  (dated after today)
```

## One line, scoped by blame

Two stamps in the file are future-dated; only one is **new**:

| line | stamp | commit | action |
|---|---|---|---|
| 86 | `FNXC:StateMachine 2026-08-01-10:20` | `e5c9ea38709` (07-30) |
**baselined — left alone** |
| 964 | `FNXC:WorkflowEvents 2026-08-01-05:10` | `71f459c2a5d` (07-31) |
corrected → `2026-07-31-23:10` |

The baselined one is not what turned main red, and rewriting it would
register as a **drop** — which is exactly how I did collateral damage in
the #3124 cycle by rewriting two stamps I had not authored. Exact-match
replacement on the distinct new string; the older stamp is verified
still present afterwards.

## What is not changed

**The baseline file is untouched.** The fix is the stamp, not the
allowance — re-recording would clear the red while leaving tomorrow's
date in the tree, which is the false green this gate exists to prevent.

## Process note

I checked for an existing fix PR **before** writing this one. My #3143
was a duplicate of #3139 because I skipped that step on the last red,
and a red `main` is the single most likely thing for two lanes to notice
simultaneously.

## Verification

- `check-fnxc-future-dates` — **exit 1 on `origin/main`, exit 0 here**
- diff is one line; baseline file confirmed unmodified

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:30:21 -07:00
gsxdsm
27741d0e2f fix(core): an archived child kept blocking its parent's delete on a renamed board (#3162)
Second **LANE** site from the archived triage (#3154), same additive
shape as #3160.

## The bug

`liveLineageChildFilter` is the lineage-integrity gate (VAL-DATA-010)
behind `deleteTask` and `archiveTask`: a parent with **live** children
is refused with `TaskHasLineageChildrenError`.

It excluded children in the `archived` column **by id**. So on a board
that renames that lane, an archived child still counted as live and the
parent could not be deleted — with an error naming a child the operator
had **already filed away**.

## The fix is permissive, and that is the correct direction

The gate exists to protect **live** children; an archived child is not
one. Resolving makes fewer rows block, which is what the gate always
meant.

I am flagging this explicitly because *"a conversion makes a delete gate
stop firing"* deserves a second look. The second look is that it was
firing on rows it was never meant to protect.

## LANE, not STATE

The two STATE sites in this inventory are marked at their own sites
(#3157) and must never be resolved — one of them deletes directories.
This one asks about the board.

## Wired at every caller, not left as an optional seam

`findLiveLineageChildrenImpl` (has `store`) and both
`archive-lifecycle-2.ts` gates resolve and pass it.
`hasLiveLineageChildren` takes the same parameter so the two readers
**cannot disagree** about which children are live — the half-conversion
shape this program keeps finding, and the reason #3129's earlier attempt
was reverted for leaving a seam unsupplied.

## Parity gate satisfied

Same reason as #3160: the conversion is **additive** — it keeps the
literal as the fallback, so no encoding's literal count moves and a
caller supplying no set gets byte-identical SQL.

## Measured

- 4 new cases; parity test still **2/2**, inventories unmoved.
- **MUTATION**: dropping the resolved branch fails the renamed case and
leaves the legacy **control**, the **fail-soft** case, and the
parent/project-scope **negative** green.
- lineage / archive / soft-delete / archived suites — **6 files / 19
tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals`, `check-fnxc-future-dates` clean.

## Census

**Unchanged** — the literal remains the fallback arm, by design.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:18:54 -07:00
gsxdsm
5d5a3ddd60 fix(core): live search excluded the archived id, not the board's archive lane (#3160)
The first **LANE** site from the archived triage (#3154), converted —
and it establishes that this family does **not** need the single 52-site
commit the parity gate's message implies.

## The bug

`liveSearchPredicate` builds the "not archived" half of every task
search. Keyed on the literal, a card filed away on a renamed board
stayed in **every live search result** — including the CREATE-time
near-duplicate check, which calls `searchTasks()`.

So creating a task could be rejected as a duplicate of one the operator
had **already archived**, with nothing on screen explaining why.

## Why this site and not its neighbours

The triage classifies all eight Drizzle `archived` sites. Two are
**STATE** markers that must never be resolved —
`cleanupArchivedTasksImpl` deletes directories,
`listSoftDeletedColumnDriftCandidates` would "repair" already-correct
rows. Both are marked at their sites in #3157.

This one asks about the board, so it must resolve. That distinction is
the entire product of the triage, and it is why this is a one-site PR
rather than a sweep.

## The parity gate is satisfied — and the reason generalises

That gate exists because converting one encoding while the others
compare the raw string makes them **disagree**.

This conversion is **additive**: it adds a resolved path and keeps the
literal as the documented fallback. The SQL encoding's literal count
does not move, and no encoding shifts relative to another. A caller
supplying no set gets **byte-identical SQL**.

So the family can be converted **incrementally** — one site at a time,
each keeping its fallback — rather than in one coordinated 52-site
commit. The gate's rule is about not letting the encodings *diverge*,
not about batching.

That was the last thing making this cluster look unapproachable, and it
turns out not to be true.

## Threading was two layers, and the root already had the answer

`reads.ts` resolves archived lanes for its cold-storage decision a few
hundred lines above; the same call now serves both search paths. My
earlier scoping note (#3147) guessed "one parameter each" — it is the
builder plus its two entry points, with the resolution already present
at the root.

## Measured

- 4 new cases; parity test still **2/2** (inventories unmoved — that is
the point).
- **MUTATION**: dropping the resolved branch fails the renamed case and
leaves the legacy **control** and both negatives green.
- The `includeArchived: true` negative earns its place: resolving lanes
must not start excluding them from a search that explicitly asked for
archived rows.
- The predicate walker needed **cycle detection** — Drizzle's SQL graph
is circular (column → table → columns) and my first version blew the
stack on the first assertion.
- core search / archive / cold-storage / reads suites — **5 files / 15
tests pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-sql-column-literals`, `check-fnxc-future-dates` clean.

## Census

**Unchanged** — the literal remains as the fallback arm, by design. A
census drop here would mean the fallback had been removed, which is what
the parity gate is protecting against.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:13:01 -07:00
gsxdsm
71f459c2a5 fix(events): the last two live task:moved emitters carry the resolved lanes (#3135)
## The last two live emitters

#3109 attached lanes at `moves.ts`. #3120 attached them at the archive
and completion emits. Two live emitters were still sending `lanes:
undefined`:

```
task-update.ts:962        todo -> triage
update-task-deps.ts:502   emits to: task.column — the row's ACTUAL lane
```

A listener reads absence as "unknown" and falls back to
`resolveTaskParkedColumnsSync`, which returns the **default board** for
every task under PostgreSQL. So these two paths kept the pre-#3109
behaviour while the listener code reads as resolved at every site.

`update-task-deps.ts` is the sharper of the two: it emits `to:
task.column`, the row's real lane, so on a renamed board it sends e.g.
`"shipped"` to a listener comparing against `"done"`. The emitter had
already resolved the board and threw the answer away. Nothing errors;
the branch stops firing.

## Emitter coverage after this

```
LANES    moves.ts:1450               (#3109)
LANES    archive-lifecycle-2.ts:385  (#3120)
LANES    task-artifacts-ops.ts:578   (#3120)
LANES    task-update.ts:970          (this PR)
LANES    update-task-deps.ts:504     (this PR)
MISSING  lifecycle-ops.ts:668        deliberate — see below
MISSING  lifecycle-ops.ts:715        deliberate — see below
```

**Every emitter that can execute under the shipped backend now carries
lanes.**

## Flagged — the two I did NOT convert

`lifecycle-ops.ts:668` and `:715` stay lane-less on purpose. Both sit on
the polling-replica path that file already documents as
legacy-SQLite-only — it reaches `store.db`, which throws under
PostgreSQL — and the same note argues against spending a signature
change on dead code. I agreed rather than overrode it. If that path is
ever revived they must be attached, because absence resolves to the
default board rather than to nothing.

## Supersedes my own earlier PR

This replaces **#3119**, which carried four emitters. #3120 landed two
of them first, so I rebuilt against current `main` with only the
remainder rather than resolving a conflict into a half-redundant diff.
#3119 is closed with nothing lost.

## Census before / after

```
before:  COLUMN guards (the backlog):   17
after:   COLUMN guards (the backlog):   17
```

Unchanged — this converts no guards. It makes the resolved answer
*reach* guards that were already converted, which is the half that was
missing.

## Verification

Full `@fusion/core` suite **462 files / 4906 passed, 0 failed** ·
`test:gate` exit 0 · typecheck exit 0 · lifecycle-column census exit 0 ·
`pnpm lint` clean.

## Still open

`main` is **red on `check:inert-sync-lanes`** and nothing in CI runs it
— **#3127** fixes both halves. **#3122** restores 13 guards laundered
through `mergeParkedColumns`. **#3131** corrects a 44% under-report in
`--triage`.
2026-07-31 07:10:09 -07:00
gsxdsm
b9b7d14804 fix(core): a type that taught the wrong invariant — staleness signal column narrowed to legacy ids (#3159)
**Type-only. No runtime behaviour changes**, and I would rather say that
than let a green suite imply otherwise.

## The type described a guard that no longer exists

`TaskAgeStalenessSignal.column` was typed `"in-progress" | "in-review"`
and filled through a cast carrying this justification:

```ts
// The guard above proves `column` is one of these two legacy ids ... (#1403)
const activeColumn = task.column as "in-progress" | "in-review";
```

True when written. The guard now reads:

```ts
const wipColumn    = context.lifecycle?.wip    ?? "in-progress";
const reviewColumn = context.lifecycle?.review ?? "in-review";
if (task.column !== wipColumn && task.column !== reviewColumn) return undefined;
```

So on a renamed board it proves the column is `building` or `checking` —
and the cast asserted the **opposite** of what the guard established.
The runtime was always fine; the real id passed straight through.

## The damage is in what the type taught

A consumer writing `signal.column === "building"` got a **compile
error** saying the comparison was impossible. The type actively
instructed callers that `=== "in-progress"` is exhaustive — the exact
guard shape this program spends its time removing.

This is the second instance of the shape today. The first was
`dashboard/src/server.ts`:

```ts
moveTask(taskId: string, column: "todo", options?: …): Promise<unknown>;
```

which made the type system **reject** a resolved target (#3158). Neither
was a constraint anyone chose — both were inferred from a single legacy
call site and then hardened into an assertion about live data.

**A type narrowed to legacy ids is a lint against fixing the code**, and
it is invisible to every gate this program has: the census counts
comparisons, the move-target ratchet counts arguments, and neither looks
at type positions.

## The test is a characterization, and says so

No runtime test can differentiate a type-level fix — **`tsc` is what
differentiates it**. The added case pins a value that was already
correct, so a future narrowing has something to break against besides a
compile error nobody sees until they hit it. I have labelled it in the
file rather than presenting it as a regression test.

## Verification

| | result |
|---|---|
| `tsc` — core, engine, dashboard (app + src) | **0 errors** each |
| `task-age-staleness` | **17 passed** |
| all three staleness suites | **28 passed** |
| census `--strict` | exit 0 |

All three consumers of `.column` only display or compare it
(`taskAgeStalenessCopy.ts`, `TaskDetailModal`, a `TaskCard` memo
comparison), so nothing downstream narrows on the widened type.

## Scope note

I scanned for this class and the raw pattern is noisy — 192 candidates,
almost all object-literal **values** (`status: "archived"`), agent
roles, and unrelated `type: "done"` stream events. This one and
`server.ts` are the two I could confirm as genuine type-position
narrowings on a *task column*. I have not filed an issue for the class
because I cannot yet separate it from the noise reliably; if a cheap
discriminator turns up, it is worth a ratchet like the move-target one.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:06:48 -07:00
gsxdsm
db71b4fffb docs(core): scope the archived three-encoding decision — it is not 52-or-nothing (#3147)
The `archived` family is the largest unclaimed cluster (52 sites) and
its blocker is that **nobody has scoped it**. This scopes it. It
converts nothing.

## The two options both read as enormous because 52 sites are counted as
one lump

They are not one lump. The sites answer **two different questions**:

| question | renameable? |
|---|---|
| **LANE** — "is this row resting in the board's archive lane?" | yes —
must resolve |
| **STATE** — "did Fusion archive this row?" (the marker `archiveTask`
writes) | **no** |

`async-maintenance.ts` already draws that line and marks its own site
DELIBERATE-LITERAL:

> `'archived'` is the STATE marker here, not a lane. This sweep collects
rows Fusion itself archived or soft-deleted; a card merely sitting in a
workflow's archived-TRAIT lane is live work and must not be collected.
Widening to the resolved archived set would pull real cards into a
cleanup pass.

**Converting that site would be a bug, not progress.**
`async-archive-lineage.ts`'s soft-delete path is the same shape —
`column = 'archived', deleted_at IS NOT NULL` is the storage state it
has just written.

So the first question is a **triage**, not a conversion: which of the 52
are lane questions? Nobody has answered it, which is exactly why the
cost reads as unbounded.

## Measured: the SQL half, which the existing note calls the hard part

8 Drizzle sites across 7 files.

**Four already have `store` in scope** — they could take a resolved set
today with no signature change:

- `branch-group-ops.ts` — `clearNearDuplicateReferencesToImpl(store,
...)`
- `branch-and-pr-entities.ts` —
`findRecentTasksByContentFingerprintImpl(store, ...)` (2 sites)
- `task-mutation-ops.ts` — `cleanupArchivedTasksImpl(store)`

**Four need one parameter each**, the same optional-lane-set shape used
throughout this program:

- `async-lifecycle.ts` — `liveLineageChildFilter(parentId, projectId?)`
- `async-search.ts` — `liveSearchPredicate(includeArchived, projectId?)`
- `async-self-healing.ts` — `listSoftDeletedColumnDriftCandidates(db,
...)`
- `store.ts` — the revert-lookup conditions (already holds
`this.asyncLayer`)

That is not *"threading a resolver into the persistence layer"*. It is
four call sites that already have what they need, plus four
one-parameter widenings — **before** any triage removes the STATE sites
from the count entirely.

## What I did not do, and why

The triage itself: a per-site judgement about what each guard *means*.
That belongs to whoever owns this gate, not to a passing fleet lane —
and getting it wrong in the STATE direction pulls live cards into a
cleanup sweep, which is the one failure mode here that destroys work
rather than hiding an affordance.

What was cheap and missing was the **shape** of the problem.

## Measured

- Comment-only; parity test **2/2**.
- `tsc --noEmit -p packages/core` clean; census `--strict` clean.
- `check-fnxc-future-dates` is red from `main`'s own #3128 stamps —
**#3139** fixes that; this branch inherits and does not add to it.

## Census

No movement.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:55:03 -07:00
gsxdsm
110d6fd150 docs(core): the archived LANE-vs-STATE triage, done — 8 SQL sites classified with evidence (#3154)
#3147 scoped this cluster and said the first question is *"which of
these are LANE questions and which are STATE markers?"* — and that
nobody had answered it. **This answers it** for the Drizzle half, per
site, by reading what each query is for.

I claimed it because it has sat unclaimed for many rounds and `--claims`
reports `AVAILABLE: 0 files / 0 guards` — this is the only real work
left in the area. Nothing is converted here.

## LANE (6) — must resolve a renamed archive lane

| site | evidence |
|---|---|
| `store.ts` revert lookup | `ne(archived)` + `ne(done)` picking
**live** revert candidates |
| `branch-group-ops.ts:82` | near-duplicate marker cleanup over **live**
rows |
| `branch-and-pr-entities.ts:438` | content-fingerprint duplicate guard,
gated on `!includeArchived` |
| `branch-and-pr-entities.ts:470` | recent **sibling** lookup |
| `async-lifecycle.ts:68` | `liveLineageChildFilter` — the name is the
classification |
| `async-search.ts:82` | `liveSearchPredicate(includeArchived)` — same |

Four already hold `store` / `this.asyncLayer`. The two predicate
builders need one optional parameter each — the shape used throughout
this program.

## STATE (2) — converting these would be a **bug**

**`task-mutation-ops.ts:1072`** — `cleanupArchivedTasksImpl` selects
`eq(column, "archived")` and then `rm`s each row's files. Widening it to
the resolved archived set would feed cards **merely resting in a board's
archive lane** into a filesystem delete.

This is the most destructive site in the family, and it **looks
identical to the LANE sites at a glance** — same column, same operator,
same file neighbourhood. That is the whole argument for triaging before
converting.

**`async-self-healing.ts:61`** — soft-deleted rows whose column
*drifted* from the archive marker (`isNotNull(deletedAt) && ne(column,
"archived")`). Resolving it would classify a soft-deleted row sitting in
a renamed archive lane as drift and "repair" it.

## The raw-SQL half is already partly triaged in place

`async-maintenance.ts` is marked DELIBERATE-LITERAL as a STATE marker,
and `async-archive-lineage.ts`'s soft-delete path writes `column =
'archived', deleted_at IS NOT NULL` as the storage state it has just set
— STATE by construction.

## What this changes about the decision

Roughly **three quarters LANE, one quarter STATE** — and the STATE sites
are the ones that destroy data if converted.

That is why "convert all three encodings" cannot be a sweep, and why the
raw count of 52 made it look larger than it is: there are fewer sites to
convert than the headline, and the ones that must **not** be touched are
the part worth being careful about.

## Not converted here, deliberately

The gate requires all three encodings to move together, so the
conversion is one coordinated change with its inventories updated in the
same commit. This supplies the classification that change needs without
pre-empting it — and without me making a 52-site coordinated change at
the tail of a long session, which is exactly when I have made my worst
calls today.

## Measured

- Comment-only; parity test **2/2**.
- `tsc --noEmit -p packages/core` clean; census `--strict` clean. **No
census movement.**

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:43:18 -07:00
gsxdsm
ada62a7c4a census: --claims shows which remaining files an open PR already holds (two duplicate claims today) (#3124)
The census says **where** the work is but not **who has it**, and
duplicate claims are now the dominant coordination cost of this phase.
This adds an opt-in `--claims` report mapping each remaining file to the
open PRs already touching it.

## The problem is measured, not suspected

- **`self-healing.ts` took three overlapping conversions** from
different lanes while one branch was open (#3049, #3075, #3078). Each
forced a full rebuild of #3094, and every conflict was the same shape:
*same guard, two spellings, different variable names*. That PR's body
asks, in as many words, for one lane to own the file.
- **`executor.ts` took two independent conversions today** — #3112 and
#3118 — same four literals, same payload-lanes fix, two branches. Two
workers each read the census, saw the top cluster, and started. Neither
could see the other; I only caught it because both appeared in one `gh
pr list`.

The census is what sends everyone to the same file, so the claim signal
belongs here rather than in a side channel nobody reads. `--triage`
(#3097) already measured the underlying fact — 53 of 88 guards sat
inside an open PR — one step short of being actionable.

## Measured on current main (29 guards)

```
  CLAIMED by an open PR: 6 files holding 15 guards
       6  packages/engine/src/self-healing.ts  ← #3121 #3116
       4  packages/engine/src/executor.ts  ← #3118 #3112
       2  packages/engine/src/auto-merge-finalization.ts  ← #3107
       1  packages/core/src/task-store/task-artifacts-ops.ts  ← #3120 #3119 #3091
       …
  UNCLAIMED: 12 files holding 14 guards — start here
       2  packages/dashboard/app/utils/taskRevert.ts
       2  packages/engine/src/scheduler.ts
       …
```

It independently reproduces **both** collisions I found by hand today,
which is the strongest evidence I can offer that it works: `executor.ts
← #3118 #3112` and `self-healing.ts ← #3121 #3116`.

It also answers the standing fleet instruction empirically. "Claim the
largest unclaimed cluster" currently resolves to **12 files holding 14
guards, none larger than 2** — and one of those two (`scheduler.ts`) is
in the SYNC-RESOLVED list, where conversion is inert. That is a
materially different picture from the headline `29`.

## Design decisions

**Report-only and fail-soft**, on the same terms as `--triage`: opt-in,
printed beside the totals, changes no count and no exit code. It shells
to `gh`, so it is unavailable offline, in CI without a token, and in
sandboxes — all of which print a notice and continue. A gate must not
depend on network state; this is a work-selection aid, not a gate.

**The fail-soft path is loud on purpose**, and it is the case I care
most about. A claim report that silently degrades to "nothing is
claimed" is *worse than no report*, because it actively sends the reader
into work another lane holds — the exact failure the flag exists to
prevent. So when `gh` cannot answer it prints `POSSIBLY CLAIMED` and
suppresses the start-here list entirely rather than rendering it empty.

**Heuristic, and says so.** A PR touching a file is not proof it
converts *that file's* guards — it may edit an unrelated function. It
over-reports rather than misses, which is the safe direction: a false
claim costs one comment asking, a missed one costs a rebuilt branch.

**One bulk `gh pr list` call**, not a request per PR — the per-PR shape
was too slow to become habitual, and a report nobody runs is not a fix.

## Verification

- `lifecycle-column-census.test.ts` — **42 passed** (was 40)
- Differential: disabling the flag gives **2 failed | 40 passed**. Both
new tests fail on the defect they were written for.
- `--strict` and `check-fnxc-future-dates` — exit 0
- Tests stub `gh` on PATH, so no network call and no dependency on the
live PR list. The fixture reads the census's **own current top file**
rather than a hardcoded path, so it cannot rot as the backlog shrinks
(same self-maintaining discipline as #3106).

## What this does not do

It does not reserve anything — there is no lock, and two workers who
both run it can still collide if they start simultaneously. It reports
what is already visible in the PR list, which is enough to catch the
every-case-so-far pattern of *starting work on a file someone has held
for hours*. A real reservation would need shared mutable state, and I
would not add that without an owner asking for it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:50:13 -07:00
gsxdsm
c38d784b88 fix(events): carry lanes on the archive and completion task:moved emits (#3120)
Follow-up to **#3109** (merged). Independent of my other branches.

## The gap

#3109 put resolved lanes on `task:moved` and wired the main move path in
`moves.ts`. **Two other emit paths still fired lane-less** — and to a
listener, a lane-less emit is not a *missing* answer, it is the
**legacy** answer, which on a renamed board is wrong.

Concretely: `archiveTaskBackendImpl` emits its own `task:moved`. The
executor's archive branch releases the task's active-session registry
entry, and **that entry is what blocks a successor task from acquiring
the same path**. Fixing the listener alone (#3112) leaves the leak
reachable *through this emitter*, because the listener still falls back
to the literal when the payload carries nothing.

That's the part worth noting for the pattern generally: **a
payload-carrying design is only as good as its emitters.** Converting
consumers without sweeping producers leaves a hole that looks fixed at
the call site.

## Scope

| Emit path | Action |
|---|---|
| `archive-lifecycle-2.ts:373` (`archiveTaskBackendImpl`) | carries
lanes |
| `task-artifacts-ops.ts:549` (`moveToDoneImpl`) | carries lanes |
| `lifecycle-ops.ts:715` | **untouched** — fires from a watcher callback
|
| `task-update.ts:962` | **untouched** — sync path |

Both converted sites are already `async` and already import
`resolveWorkflowIrForTask`, so this costs one IR read on a transition
that has just done database work. Fail-soft to `undefined`, matching
`moves.ts` — "unknown", never a wrong answer.

The two untouched ones each need their own look rather than a blanket
sweep; flagged, not guessed.

## Verification

- `@fusion/core` archive/lifecycle suites — **156 green**
- **`pnpm test:gate` green**; `tsc` clean
- Changeset added; `check:changesets` passes

## Census

**No change**, and that's expected — this converts emit *payloads*, not
comparison literals. The effect is that guards already converted in
#3109/#3112 receive a correct answer on these paths instead of a legacy
fallback.

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Task move events now include accurate board lane information when
tasks are archived or marked complete.
* Improved handling for renamed workflows, ensuring task transitions use
the correct lane details.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:29:53 -07:00
gsxdsm
c5d5978a01 test(core): the emitter route does not generalise from task:moved to task:updated — measured (#3123)
#3109 solved the inert-guard class for `task:moved` by having the
**emitter** resolve lanes once and carry them on the payload. It is the
right fix there, and it retired several flags within a day — two of them
mine (#3118, #3121).

**The obvious next step is to do the same for `task:updated`**, which is
where every remaining sync-listener guard lives: `scheduler.ts`'s
mission-failure and PR-monitoring guards, and `triage.ts`'s
planning-evacuation guard. I went to do exactly that, and the cost
profile is opposite.

## Measured

| event | emit sites | files |
|---|---|---|
| `task:moved` | **7** | — |
| `task:updated` | **26** | 10 |

#3109 could argue its resolution away because a move *"is already async
and already post-commit, so the resolution costs one IR read on a
transition that has just done database work."*

`task:updated` fires on every **log append, comment, artifact write and
steering message**. `audit-ops.ts`'s logEntry **fast path** is one of
those emit sites — and that path exists specifically to avoid re-reading
the task. An IR read per emit there is a regression on the hottest write
path in the system.

## Partial coverage does not rescue it

The tempting narrower version is "add lanes only to the emit paths those
guards react to". `triage.ts` rules that out: its evacuation handler
reacts to **any** `task:updated` carrying a column, and explicitly
tolerates partial payloads (that is why it checks `typeof task.column
!== "string"`). It needs lanes on essentially every emit, or it keeps
its literal fallback regardless.

So those guards are **not one commit behind #3109**. Unblocking them
wants either a cached lane answer the emitter can attach for free, or
the sync reader this file already specifies.

## Pinned as a ratio, not prose

The new case asserts `task:updated` emits **more than twice**
`task:moved` — the shape of the argument rather than today's exact
numbers, which move with ordinary work. If the ratio ever converges, the
trade-off has genuinely changed and the note above it should be re-read.

That is deliberate: a comment stating "26 vs 7" would be wrong within a
week and would then argue for the opposite conclusion with full
confidence. This is the third time this session I have found a note that
was accurate when written and had quietly stopped being true.

## Measured

- 5 cases pass (4 pre-existing + 1 new).
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-fnxc-future-dates` clean.

## Census

**No movement — nothing converted.** This records why the cheap-looking
follow-on is not cheap, in the file that already owns this decision
(#3103).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:24:05 -07:00
gsxdsm
82329819f7 fleet: resolve the pre-archive unarchive target (census 69 → 68) (#3091)
## Census

| | column guards |
|---|---|
| before | **69** |
| after | **68** |

## How this was found

By finishing a triage I'd left incomplete. Of the 19 single-guard files,
I had actually examined six and flagged the rest partly on assumption —
so I went back and read them.

Five of the remaining ones turned out to be `archived` comparisons
**pinned by `archived-column-gate-parity.test.ts`** (`audit-ops`,
`task-id-integrity`, `mission-store`, `async-comments-attachments`, plus
`merge-queue-ops-2` for review). Converting any of those moves one of
three encodings that must move together.

**This one isn't pinned**, and that difference is the whole PR.

## What changed

```ts
if (!declaresPreArchiveColumn || preArchiveColumn === archivedColumn
    || preArchiveColumn === "archived")
```

Belt-and-braces: the condition already accepted the resolved lane **or**
the legacy id, stated twice. A set says it once, so the two halves can't
drift apart — the real risk with a duplicated condition, rather than the
census count.

## Why this isn't the split brain

The parity guard pins comparisons of a **task's column** — one of three
encodings of *"an archived task is not live."* This compares a **stored
`preArchiveColumn` value** against the board's archive lane: a different
question, about where to send a card on unarchive.

Verified rather than argued — that suite runs **green** here, and it
went **red** the last time I touched a pinned site (#3076, where I named
an arm and immediately reverted). It's a live check, not an assumption.

## Measured

| check | result |
|---|---|
| archive / artifact / unarchive suites | 7 files, **27 tests green** |
| `archived-column-gate-parity` | **2 passed** |
| five gates + strict census | green |
| core `tsc` | clean |

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:06:22 -07:00
gsxdsm
50f089a14b test(core): archived is renameable today — remove a wrong reason for the cheap parity option (#3113)
Follow-on from #3110, where the archived-gate parity test blocked my
conversion and laid out two ways forward. This removes a **wrong
reason** for picking the cheap one.

## The cheap option looks like it has already been taken

`archived-column-gate-parity.test.ts` offers: convert all three
encodings, **or** *"declare `archived` a non-renameable system column
and mark the sites deliberate"*.

The second is far cheaper — and the codebase reads as if it is already
true:

> `"Globally archived; hidden from the board. RESTRICTED (built-in
only)."` — `trait-types.ts`

At a glance that says a custom board cannot have an archive lane of its
own, which would make all 52 sites correct by construction and the whole
family **deliberate rather than debt**. That is a very attractive
conclusion for anyone facing a 52-site conversion.

## It does not mean that

The restriction is over trait **registration**. `trait-registry.ts`
rejects a **non-builtin** (plugin-defined) trait that declares
`archived` or `complete` (R22). It says nothing about which **column**
may carry the built-in trait, and a custom workflow may put it on a
column with any id.

**Verified, not argued:**

| input | result |
|---|---|
| `columnsWithFlag(ir, "archived")` where the lane is named `filed` |
`["filed"]` |
| `resolveColumnFlags` on that column | `{ archived: true,
hiddenFromBoard: true }` |
| `columnsWithFlag(ir, "complete")` where the lane is named `shipped` |
`["shipped"]` |

So option two is a **capability removal** — it would silently break any
board that has already renamed its archive lane — not the documentation
of a constraint that already exists.

## What this does and does not do

It **does not** decide between the two options; that is a product call
with real blast radius either way. It removes the reason someone would
most plausibly reach for after reading that flag comment, and it does so
with an executable check rather than a claim.

The `complete` case is pinned alongside it, because both restricted
flags behave identically — anyone reaching the same conclusion about
`complete` would be wrong for the same reason.

The first case also doubles as a **tripwire**: if `columnsWithFlag(ir,
"archived")` ever returns `[]` or `["archived"]` for that fixture, the
cheap option has effectively been taken and the parity test's framing
needs revisiting. That is the signal the case exists to give.

## Measured

- 4 new cases pass.
- The parity test still passes with its corrected framing — it excludes
`__tests__` from its scan, so the added prose cannot move its own
inventory. I checked that before editing it rather than after.
- `src/__tests__/{archived,trait,log-entry}*` — **3 files / 26 tests
pass**.
- `tsc --noEmit -p packages/core` clean; census `--strict`,
`check-fnxc-future-dates` clean.

## Census

**No movement — nothing converted.**

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 04:56:25 -07:00