empty-vs-error
Use whenever code READS something (query, fetch, groupBy, aggregate, probe) and RENDERS or SCORES the result — a panel, a stat tile, a health verdict, a score, a report section. A failed read must nev
SKILL.md.claude/skills/empty-vs-error/SKILL.md
GitHubEmpty vs Error vs Unmeasured
Three states, never two. A read has three outcomes and they are not interchangeable:
| State | Means | Must render as |
|---|---|---|
ERROR |
the read failed | "couldn't load — state unknown, not empty" |
UNMEASURED |
the read never ran, or the writer has never written | "not measured yet" + what would unlock it |
| measured zero | the read succeeded and the answer is genuinely 0 | a real zero, with its denominator |
Collapsing the first two into the third is the single highest-frequency defect shape in this repo.
The base rate
- 10 instances on ONE page — the
/brainnine-tab audit (PR #2090, 2026-09-02). Dominant of the five shapes found; 41 defects total. - It survived the fix, twice. #2090 split "read FAILED" from "empty" and stopped there, leaving an empty contradiction ledger scoring a hardcoded 7 of 100 — prod has never recorded a contradiction in any category, so that branch fired on every render (PR #2091).
- The identical shape sat in a second file the same wave.
resolutionRate = 1on an empty table was fixed inlib/brain/learning-velocity.tsand missed in the contradiction path. Two agents, one session. - Three more in one day (2026-07-30), all "a read with no writer":
apiRequestLogfiltered to/api/ai/chat(chat never passes through the request logger),ai_generationswherestatus='failed'(writers emitcomplete/error),error_logswherelevel='fatal'(writers emiterror/warn). Each rendered a fabricated all-clear.
Every one of these passed typecheck, lint, and a green suite.
Copy these — they already do it right
Do not invent a state model. Three verified reference implementations:
apps/statenour/components/brain/judgment-quality-panel.tsx:34— the minimal form.isLoadingskeleton, then:if (spcQ.isError || calQ.isError) { return <section …>Judgment-quality readings couldn't load — state unknown, not empty.</section>; }apps/statenour/components/brain/contradiction-resolution-panel.tsx:300— the full three-state form.EmptyStatetakes aprovenanceprop:
The vocabulary is three literal strings —provenance={contraQuery.isError ? "ERROR" : "UNMEASURED"}"ERROR","UNMEASURED", andprovenance="ZERO"(line 311) for a measured zero, which renders in its own branch (line 306) with anunlockstring saying what would make the number move. Its line 64 carries the incident comment — the code used to end?? (isError ? [] : null), so a failed query fell into the empty branch and rendered a green "Clean ledger".apps/statenour/lib/observability/fleet-truth.ts— the server-side vocabulary:fresh / stale / never_produced / unknown, with "a failed probe or fetch is UNKNOWN, never healthy" (line 13) andokdefined as every capability fresh — "unknown counts as NOT ok" (line 190). Everycatchpushesstate: "unknown", never a default.
Detection — scope to the DIFF, never the repo
Match the TYPE ANNOTATION, or you miss a third of them. The house
form is often .catch((): never[] => []), and a pattern that requires
() immediately before => silently skips every one. Measured on
6ade5771b across apps/statenour/{lib,app}:
| Pattern | Files |
|---|---|
dangerous fallbacks — => [] / => 0, any annotation |
147 |
| the same, matched untyped-only (the naive regex) | 98 |
=> null — usually honest, null is a legitimate "unknown" |
130 |
The 49-file gap between the first two rows is the cost of a detector that does not allow an annotation. This exact miss shipped in the first version of this skill and was caught in review — the counts it quoted came from a stricter regex than the one it told you to run.
Those totals are also why a repo-wide grep is not a gate: at 147 files this is a house idiom, not a defect list. Scope to your own change, or to one surface you are auditing:
# your diff only
git diff origin/main | grep -nE '\?\? *0|\?\? *\[\]|\.catch\(\([^)]*\)( *: *[^=]+?)? *=> *(\[\]|0)|useState\(\{[^}]*: *0'
# one surface, when auditing a page
git grep -nE '\.catch\(\([^)]*\)( *: *[^=]+?)? *=> *(\[\]|0)' -- apps/statenour/components/brain
# a read with no writer: prove the discriminator has a producer
git grep -n "status: *'failed'" # 0 hits today — writers emit complete/error
Positive-control any change to these patterns against all four forms
before trusting them — () => [], (): never[] => [], () => 0,
(): number => 0 must all match, and () => null must not.
Three specific traps behind these:
- A
.catch(() => [])INSIDE a service makes the caller'sisErrorpermanentlyfalse. The component cannot render honestly because the failure never reaches it. Fix at the service, not the panel. - A narrower tRPC output annotation than the function it wraps silently deletes fields — no error anywhere.
- A hardcoded floor in a scorer (
+7,rate = 1,?? 100) is this bug wearing a number. An empty population has no rate.
Fixing one is not fixing the class
The four rules that cost five PRs to learn:
- Cross-check the SHAPE across every file in the wave, not just the file it was reported in. Grep the fix's signature repo-wide before claiming the class closed.
- Audit the FIX as hostilely as the code it replaced. Twice the new defect was in code written minutes earlier while criticising that exact defect class.
- A partial fix that ships a test cements itself. #2091's own test
read
expect(view.score).toBe(7)under the comment "A measured empty ledger DOES still earn the 7". Second bug-encoding test of the wave —calibration.test.tshad already pinnedresolved === 0as correct and would have blocked the real fix. When a test resists a correct fix, read the test as a finding. - After deleting or renaming a field, grep for consumers AND for UI
branches that can now never fire — one fix created a dead alarm
(
truncatedhardcodedfalsebehind a live UI branch).
Verify the fix actually fires
A silenced alarm and a fixed alarm produce the same green suite. The test
must prove the honest branch RENDERS: mutate the fix out and watch it go
red (vi.clearAllMocks() does not drain a mockResolvedValueOnce
queue — a red you did not diagnose is not a passing canary). Assert
behaviour, not source text; not.toContain("catch") false-fails on a
reformat.
When NOT to use
Pure functions with no IO, and reads whose only consumer is another internal computation that already propagates failure. This is about the boundary where a read becomes a rendered or scored claim.
Related: statenour-verify (the corollary for report/diagnostic
sections, and the "prove the source receives rows" table) ·
base-rate-check (a zero also needs its denominator).