docs(workflow-learnings): a sixth shape — the resolved value arrives after a memo has answered (#2998)

## The shape

Three defects this session, all the same, none visible to any instrument
here:

A lane value resolved **asynchronously** (the board fetches workflow
traits after first paint) is read inside a `useMemo`/`useCallback` whose
dependency list omits it. The first computation runs with the flags
`undefined`, the role helpers correctly fall back to legacy ids, and on
a **renamed** board that answer is wrong. When the flags arrive nothing
in the dep list changed, so the memo never recomputes.

| defect | severity |
|---|---|
| blocker fan-out trait index (#2993) | permanent — empty index for the
mount |
| card live elapsed-time indicator (#2996) | permanent — never
subscribes |
| near-duplicate chip (#2997) | bounded — self-heals on the next task
refresh |

A legacy board hides all three: there the fallback already answers
correctly on the first paint, so the stale list costs nothing. **Every
instance is renamed-board-only**, which is why they accumulated — and
this repo has no `react-hooks/exhaustive-deps` rule, so the class is
invisible to lint.

## Two properties decide severity, both readable off the dep list

1. **Does any dependency refresh quickly?** `allTasks`, a live clock, a
task identity — any of them rebuilds the closure on the next update,
making the wrong answer a bounded window. The chip keys on `allTasks`
and recovers; the indicator keys on `task.column`, which never changes,
so it never does.
2. **Is the value covered transitively?** A dependency that itself lists
the flags gets a new identity when they arrive, and that propagates.

## A gate was built and rejected — the part worth writing down

The scanner reports **19 sites; two were real.** Property 2 is why:
transitive coverage is invisible to any purely syntactic check and would
need a real dependency graph.

`TaskCard`'s context-menu memo omits all three role flags and is
**nonetheless correct** — it depends on `taskActionMenuModel.actions`,
and that model lists `taskColumnFlags`, so the whole chain recomputes. I
checked that before filing it, which is the only reason this PR isn't a
bug report about missing Archive/Revert menu entries.

Freezing 19 would have baselined mostly noise and trained everyone to
skip the report — the exact failure this document already records for
`sortTasksForDisplayColumn`, where an annotation saying "ignore these"
hid a real defect for days. **A good investigative tool is not
automatically a good ratchet**, and the next person deserves to know the
turn was considered rather than missed.

The triage that does work is cheap: run the scan, then ask the two
questions above. Nine of nineteen survive question 1; hand-checking
those is an afternoon, not a project.

Docs only — no code, no baselines.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 00:00:06 -07:00
committed by GitHub
parent 5adf0d955a
commit e78bf20d55

View File

@@ -273,6 +273,45 @@ What actually finds these:
A false positive is loud and gets fixed. A false negative prints a baseline and reads as coverage.
### A sixth shape: the resolved value arrives, and a memo has already answered
Three defects in one session, all the same shape and none visible to any instrument here.
A lane value resolved asynchronously — the board fetches workflow traits after first paint — is read
inside a `useMemo`/`useCallback` whose dependency list omits it. The first computation runs with the
flags `undefined`, the role helpers correctly fall back to the legacy ids, and on a RENAMED board
that answer is wrong. When the flags arrive nothing in the dependency list has changed, so the memo
never recomputes and the pre-load answer stands.
A legacy board hides it completely: there the fallback already answers correctly on the first paint,
so the stale list costs nothing. **Every instance is renamed-board-only**, which is why they
accumulated. This repo also has no `react-hooks/exhaustive-deps` rule, so the class is invisible to
lint, and a disable directive for that rule fails CI.
Found: the blocker fan-out map (empty trait index, permanent), the card's live elapsed-time indicator
(never subscribed, permanent), and the near-duplicate chip (stale closure, bounded).
**Two properties decide severity, and both are checkable by reading the dependency list:**
1. **Does any dependency refresh quickly?** A live clock, `allTasks`, a task identity — any of these
rebuilds the closure on the next update, so the wrong answer is a bounded window rather than
permanent. The near-duplicate chip keys on `allTasks` and self-heals on the next task refresh; the
time indicator keys on `task.column`, which never changes, so it never recovers.
2. **Is the value covered TRANSITIVELY?** A dependency that itself lists the flags gets a new
identity when they arrive, which propagates. `TaskCard`'s context-menu memo omits all three role
flags and is nonetheless correct, because it depends on `taskActionMenuModel.actions` and that
model lists `taskColumnFlags`. Reading the dependency list alone reports this as a defect.
**A gate for this was built and rejected.** A scanner for "memoized hook reads a lane value absent
from its deps" reports 19 sites; two were real. Property 2 is the reason — transitive coverage is
invisible to any purely syntactic check, and would need a real dependency graph to resolve. Freezing
19 would have baselined mostly noise and trained everyone to skip the report, which is the failure
this document already records for `sortTasksForDisplayColumn`. The scanner is a good investigative
tool and a bad ratchet; the distinction is worth keeping.
The triage that does work is cheap: run the scan, then for each hit ask the two questions above. Nine
of the nineteen survive question 1; hand-checking those is an afternoon, not a project.
### The probe harness lies more often than the gate does
Probing four gates with unimagined shapes in one session produced **two rounds of silently invalid