batch(docs): fold the three workflow-learnings / annotation PRs into one (#2942)

## Family batch — replaces #2926, #2892, #2887

Per the consolidation directive: the u9/e2e **docs family**, folded into
one branch and one CI run. Three PRs, five commits, **five files,
comment and markdown only**.

| folded PR | commits |
|---|---|
| #2892 `docs/union-vs-per-task` | the project union and the per-task
answer are not ranked; date correction |
| #2926 `docs/date-my-measured-claims` | date the measured claims (one
was wrong); date the grep-vs-AST measurement in the SQL gate header |
| #2887 `docs/archived-state-literals` | mark the three archived STATE
literals as deliberate |

Cherry-picked in original order with authorship preserved; all five
applied clean, no conflicts.

## Scope is provably comment-only

```
docs/solutions/workflow-learnings/lifecycle-conversions-that-score-as-wins.md
docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
packages/core/src/task-store/async-maintenance.ts        ← FNXC DELIBERATE-LITERAL annotation
packages/core/src/task-store/workflow-definitions.ts     ← FNXC DELIBERATE-LITERAL annotation
scripts/check-sql-column-literals.mjs                    ← header prose only
```

Every added line in `packages/` and `scripts/` is inside a comment —
checked by filtering the diff for declarations, conditionals and
returns, which returns nothing. The two core files gain
`DELIBERATE-LITERAL` markers explaining that `'archived'` is a **state**
marker there, not a lane: the sweep collects rows Fusion itself archived
or soft-deleted, so widening to the resolved archived set would pull
live cards into a cleanup pass.

## Verification (scoped, per the directive — not the full suite)

- `pnpm lint` — clean
- `check-sql-column-literals` — exit 0 (the file it annotates)
- `check:lifecycle-columns` — exit 0 (the markers it adds are
census-visible)
- `sync-workflow-ir-callsite-allowlist.test.ts` — 3/3

## A correction worth recording

Mid-fold I saw a changeset, `self-healing.ts` and a test file in `git
diff origin/main..HEAD` and nearly reported the batch as impure. They
were **main's own commits** — `origin/main` advanced between branch
creation and the diff, so the comparison was against a stale base.
Rebasing onto current `main` reduced it to the five files above. Worth
flagging for anyone else folding a family today: with `main` moving this
fast, diff the branch **after** rebasing or the file list will lie to
you.

## Closing the originals

#2926, #2892 and #2887 are superseded by this and are being closed. I
hold no PRs of my own in this family — all mine merged — so this fold is
on behalf of the family rather than a rollup of my own work.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 19:12:26 -07:00
committed by GitHub
parent b4ed12e9c8
commit 7b68f20501
5 changed files with 125 additions and 7 deletions

View File

@@ -152,7 +152,8 @@ hands in.** If your fixture passes the lane name, you have tested the consumer.
computes that argument in production, and whether they compute it or spell it.
MEASURED, so nobody builds the wrong instrument: an AST probe for a call argument
`{ column: "<legacy id>" }` finds **79 sites** across `packages/`. It flags the two real `moves.ts`
`{ column: "<legacy id>" }` found **79 sites** across `packages/` when measured on 2026-07-30 (42 as
of 2026-07-31, after the analytics conversions landed — the ratio is the point, not the total). It flags the two real `moves.ts`
offenders, but most of the rest are legitimate — `set({ column: "archived" })` writing the archive
state, `listTasks({ column: "todo" })` filtering a query. A blocking gate on this shape needs a
curated list of consumers that interpret the column as a ROLE (as opposed to storing or filtering
@@ -167,7 +168,8 @@ a plausible "the census cannot see this" theory; each was measured.
**Membership tests with inline literals** — `["done","archived"].includes(task.column)`. The census
counts binary comparisons only, so this shape would be invisible. **Zero sites.** Nobody writes it.
**Named legacy-id collections** — `const X = ["done","archived"]` then `X.has(col)`. **48 declarations**,
**Named legacy-id collections** — `const X = ["done","archived"]` then `X.has(col)`. **48 declarations**
measured 2026-07-30, 49 on 2026-07-31;
and the raw count is misleading: on inspection they are all either
- the intentional fallback vocabulary the role helpers degrade to (`LEGACY_TERMINAL_COLUMNS`,
@@ -182,9 +184,16 @@ would have been the same mistake as gating on `{ column: "<literal>" }` call arg
mostly legitimate): a number that looks like a work list and is not.
**The rule:** measure a candidate surface, then inspect a sample, before reporting it OR instrumenting
it. A raw count is a hypothesis. The SQL surface survived this test — 14 sites, all genuinely
vocabulary-bound — and got a ratchet. These two did not, and got nothing, which is the correct
outcome and cost an hour to establish rather than a wrong gate to maintain.
it. A raw count is a hypothesis. The SQL surface survived this test and got a ratchet; these two did
not, and got nothing, which is the correct outcome and cost an hour to establish rather than a wrong
gate to maintain.
CORRECTION, 2026-07-31. This paragraph used to read "the SQL surface survived this test — 14 sites,
all genuinely vocabulary-bound". Both halves were wrong within a day. The population was 31 once the
scanner stopped missing Drizzle-templated queries, and hand-reading split it into roughly fourteen
lane-bound sites, eleven `archived` comparisons (three of them CORRECT as literals — they read a
state, not a lane), and one in dead code. Writing "all genuinely vocabulary-bound" was the same
mistake this section warns about, committed in the sentence that warns about it.
## Guards start catching other people's work, not just yours

View File

@@ -0,0 +1,80 @@
---
category: workflow-learnings
module: packages/core/src/project-lane-vocabulary
tags: [lifecycle-columns, resolved-lanes, arity, project-union, per-task]
problem_type: design-decision
applies_when: choosing between resolveProjectColumnsForRoles and a per-task lane resolution
---
# The project union and the per-task answer are not ranked
Recorded 2026-07-30, after the same reviewer finding arrived on five PRs in one day and got **four
different correct answers**. The instinct the program had built by then — "a union is the lazy option,
resolve per task" — is wrong often enough to be worth writing down, because acting on it shipped a
regression.
`resolveProjectColumnsForRoles(store, roles)` returns every column any workflow in the project gives
the role, unioned with the legacy ids. A per-task resolution answers for one card's own board. Neither
is the better tool.
## The question that decides it
Not "can I resolve per task?" — usually you can. It is:
> **What does over-inclusion cost here, and what does a missing selection cost?**
Those are the two failure directions, and they are asymmetric in a way that changes per call site.
## The four answers, with what made each one right
| site | answer | why |
|---|---|---|
| `awaitingPlanning` board enrichment | **union** | over-inclusion costs one extra file read; the per-card predicate still answers per card, so a widened candidate set cannot produce a wrong badge |
| glasses completion notifier | **narrow** | a notification is an outward action with no downstream narrowing — over-inclusion is a wrong notification reaching a user |
| `workflow-analytics` completions | **narrow, feasible** | the rows carry a workflow id (`GROUP BY`), so the keyed answer was available and the union was simply the wrong arity |
| `team-analytics` totals | **union, accepted** | per-task means one workflow read per task and moving an indexed `COUNT` into JS; the cost is a number an operator reads, recorded rather than paid |
| archived-document guards | **union, after trying narrow** | see below |
## The one that reversed the instinct
`comments-ops.ts` guards whether a card's documents are read-only. Both callers have `taskId`, so the
keyed answer looked available and the arity argument said take it.
Switching to `resolveWorkflowIrForTask` **broke a passing renamed-lane test**: a card in a renamed
archived lane stopped being read-only. The per-task resolver needs the task's own workflow
**selection**, and with none recorded it degrades to the built-in IR, whose archived lane is
`archived`. The project union needs no selection — which is exactly why it caught the renamed card.
The failure modes were not the same size:
- **union**: a live card loses document writes, and only where two workflows reuse one id with
different traits — a configuration nobody has reported.
- **per-task**: every renamed-lane card with no recorded selection silently becomes *writable while
archived* — the defect the guard exists to prevent, with a test proving it.
Reverted. The correct fix is **per-task with a union fallback when the selection is absent** — narrow
when the card can answer, broad when it cannot — which needs "no selection" distinguished from
"selection resolved to the default", i.e. the provenance form.
## Rules that fall out of it
1. **A union is not a shortcut.** It is the right answer wherever a card cannot be assumed to have a
resolvable workflow, because it does not depend on one.
2. **Ask what happens with no selection before converting.** `resolveWorkflowIrForTask` does not fail,
it *substitutes* the built-in board — so a per-task conversion silently answers with the wrong
vocabulary exactly where the renamed-board bug lives.
3. **Over-inclusion is free for a read, costly for an action, and misleading for a metric.** Query,
candidate filter, anything narrowed downstream: union. Notification, move, write gate: narrow.
Aggregate shown to a human: narrow if the rows are keyed, otherwise accept and record.
4. **Swapping one defect for a bigger one drops a guard count and looks like progress.** That is the
failure mode `lifecycle-conversions-that-score-as-wins.md` catalogues; this is the same trap
reached through a correct-sounding principle rather than a careless edit.
## Also unresolved, and related
`resolveProjectColumnsForRoles` seeds only the legacy ids for a project whose workflows declare **no**
lifecycle traits at all, so a hand-authored v2 board with renamed columns and no traits contributes
nothing and its cards are invisible to every sweep that queries by role. The three-state rule at
project scope. The fix cannot be a changed default for the reason above — sweeps want the widening and
the aggregators do not — so it wants an opt-in
(`{ untraitedProject: "declared-columns" }`). Recorded at three call sites in `self-healing.ts`.