<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## 1. Summary
runs/ held two structurally incompatible shapes — DroneRunRecord (real run receipts) and DispatchReceipt (dispatch-tick ledgers) — sharing schemaVersion: 1 with no real discriminant, plus several more kind-tagged sidecars and two fixed-name scratch files nobody's loader validated. Every reader guessed which shape it was holding by probing for field presence (startedAt). This PR makes kind the real, checked-first discriminant everywhere runs/*.json is read (TypeScript and Python), bumps DispatchReceipt.schemaVersion to 2 so the two families get their own version spaces, and relocates the two non-kind-tagged scratch files out of the glob path.
## 2. Why it's needed
- drones report used to crash on a dispatch receipt's missing startedAt (patched at that one consumer, not at the source).
- Python analytics (build_pr_data.py, drone_charts.py, spend_audit.py, drone_impact.py — all via spend_ingest.load_run_receipts) counted dispatch receipts as phantom implementer runs, because kind inference only ever checked dispatch/dispatch-tick-outcome, and no Python consumer validated schemaVersion at all.
- While enumerating everything that actually shares runs/ (per the ticket's own instruction), I found the exact same miscount bug already live for two more kinds discovered along the way: farm-tick-receipt (drones farm's per-tick receipt) and stale-spec-report (drones refresh-specs's hand-off) — both carry a proper kind + schemaVersion but were never added to either language's sidecar list, so they were silently counted as implementer runs with no warning at all.
- Two more files (eligibility-verdict-cache.json, spec-freshness-checkpoint.json) carry neither kind nor schemaVersion, so every loader either had to special-case their filename or count them as corrupt — this is the concrete source of the reported "wall of unrecognized run receipt schema warnings" on a routine dispatch tick (both are written on nearly every dispatch/discovery/refresh-specs tick).
## 3. Changes
- src/telemetry.ts: new KNOWN_RUNS_DIR_SIDECAR_KINDS (dispatch, dispatch-tick-outcome, farm-tick-receipt, auto-resolve, stale-spec-report) and KNOWN_RUNS_DIR_SCRATCH_BASENAMES (the two fixed-name files). isRunReceipt now checks kind before schemaVersion — no consumer infers record type from startedAt presence anymore. Kept as a union separate from DroneRunKind (not folded into it) so scripts/test_week_cohort.py's regex-based DroneRunKind ⊇ _EXPLICIT_KINDS parity test doesn't misread a sidecar kind as a missing explicit run kind.
- src/receipt-loader.ts (shared TS loader for report.ts / enrich.ts / etc.): same kind-first reordering, plus the fixed-name scratch check.
- src/dispatcher.ts (narrow — receipt write/read only): DispatchReceipt.schemaVersion bumped 1 → 2 (both write sites); new exported DISPATCH_RECEIPT_SCHEMA_VERSIONS = [1, 2]. loadDispatchRunRecords rewritten to the same kind-first check — this is the function whose WARN the ticket's reported symptom traces to.
- src/heartbeat.ts: loadDispatchReceipt's version gate now accepts both 1 and 2 (old dispatch receipts on disk / in the S3 mirror predate the bump).
- src/discovery.ts: new eligibilityCacheDirFor(runsDir) — the eligibility-verdict cache moves to a sibling eligibility-cache/ directory.
- src/cli/spec-freshness.ts: DEFAULT_CHECKPOINT moves to a sibling spec-freshness/ directory (help-parity snapshot regenerated via UPDATE_HELP_PARITY=1).
- scripts/week_cohort.py: _SIDECAR_KINDS widened to the same five kinds; new is_known_scratch_basename + RUN_RECORD_SCHEMA_VERSION (Python counterparts of the TS constants).
- scripts/spend_ingest.py: load_run_receipts now rejects (loudly, counted, never crashes) any file that isn't a known sidecar/scratch and doesn't declare schemaVersion == 1 — this is the "no Python consumer validates schemaVersion" fix.
- docs/decisions/: new entry recording the (b)-over-(a) call (discriminant, not relocation) and the S3/cross-host blast-radius reasoning. New file only, per the append-only convention.
- Tests added in telemetry.test.ts, receipt-loader.test.ts, dispatcher.test.ts, heartbeat.test.ts, discovery.test.ts, scripts/test_week_cohort.py, scripts/test_spend_ingest.py. scripts/test_spend_audit.py's 4 hand-written receipt fixtures and test_spend_ingest.py's _write_receipt helper now stamp schemaVersion: 1 (payload override still wins) so they keep exercising a realistic receipt shape under the new gate.
### Auto-resolve records — deliberately NOT relocated
auto-resolve-*.json already carries kind: "auto-resolve" (just no schemaVersion), so the kind-first check already fully silences its warnings and prevents its miscount without moving it. I looked at relocating it too, but loadPriorAutoResolveAttempts also reads autoResolve.attempts embedded inside dispatch receipts sharing the same runsDir — cleanly splitting that dual-source read is a second, separable change I did not want to fold into this diff (dispatcher.ts is already one of the two largest modules in the repo). Documented in the decision file.
## 4. Breaking changes
None for existing data. A v1 run receipt on disk behaves identically after this change — RUN_RECORD_SCHEMA_VERSION stays 1 (unbumped) specifically so the months-of-history, S3-mirrored run-receipt corpus never has to migrate; pinned by telemetry.test.ts's "accepts an existing v1 receipt on disk unchanged" and dispatcher.test.ts's "a v1 legacy run receipt on disk (pre-AI-194) still loads unchanged". A pre-existing DispatchReceipt (schemaVersion 1) also still loads — every reader now accepts 1 or 2, pinned by heartbeat.test.ts. Pre-existing eligibility-verdict-cache.json / spec-freshness-checkpoint.json files left behind at the old runs/ location are still recognized (by filename) as known scratch by every loader, so they don't regress to WARN noise — they just won't be updated in place anymore (a cold eligibility cache costs one re-classification, not correctness).
## 5. Test plan
- pnpm typecheck — clean.
- pnpm exec vitest run — 4113 passed (up from 4094 on main; +19 new tests), 0 failed, 126 files.
- node --import tsx scripts/run-python-tests.mjs — 618 tests, OK (skipped=6), 0 failed.
- Verified both eval-checks from the task spec by hand: grep -rl "schemaVersion" scripts/*.py hits spend_ingest.py / week_cohort.py; grep dispatch src/telemetry.ts hits the new KNOWN_RUNS_DIR_SIDECAR_KINDS list.
## 6. Verification artifact
Before/after, real code, same synthetic runs/ corpus (5 real implementer receipts + 3 dispatch + 2 auto-resolve + 2 farm-tick-receipt + 1 stale-spec-report + 1 eligibility-cache + 1 spec-freshness-checkpoint = 15 files):
TypeScript (loadDispatchRunRecords, old logic copied verbatim from main vs. this branch):
=== BEFORE ===records returned: 8 [ 'farm-tick-receipt', 'farm-tick-receipt', 'run-0', 'run-1', 'run-2', 'run-3', 'run-4', 'stale-spec-report' ]
WARN lines: 4
[dispatch] skipped unrecognized run receipt schema: auto-resolve-...-bbbbbbb0.json
[dispatch] skipped unrecognized run receipt schema: auto-resolve-...-bbbbbbb1.json
[dispatch] skipped unrecognized run receipt schema: eligibility-verdict-cache.json
[dispatch] skipped unrecognized run receipt schema: spec-freshness-checkpoint.json
=== AFTER ===
records returned: 5 [ 'run-0', 'run-1', 'run-2', 'run-3', 'run-4' ]
WARN lines: 0
(Note farm-tick-receipt x2 and stale-spec-report were silently pushed into records under the old code — a *worse*, unwarned variant of the same bug, since they DO carry schemaVersion: 1 and only failed the kind === "dispatch" check.)
Python (spend_ingest.load_run_receipts, main vs. this branch, same corpus):
=== BEFORE ===total run-list entries: 12
kind breakdown: {'implementer': 12}
stderr WARN lines: 5
[spend-ingest] WARN unknown receipt kind 'auto-resolve' on '...'; falling back to title-derived kind='implementer'
(x2 auto-resolve, x2 farm-tick-receipt, x1 stale-spec-report)
=== AFTER ===
total run-list entries: 5
kind breakdown: {'implementer': 5}
stderr WARN lines: 0
Implementer run-count delta (the required quantification): in this representative corpus, the Python implementer count drops from 12 → 5 (−7, a 58% reduction) purely from no longer miscounting sidecar/scratch files as implementer runs — the fix working as intended, not a regression. The real magnitude on any operator's live corpus scales with how many dispatch ticks, auto-resolve attempts, farm ticks, and refresh-specs runs have fired since runs/ was last pruned; this repo's own runs/ is empty in this environment (gitignored, operator-local per AGENTS.md) so a live before/after wasn't available here.
## 7. Impact estimate
Business value: Analytics currently over-report implementer runs by counting
dispatch receipts as implementer work, which corrupts the exact numbers this
project uses to justify itself. A bare-int scratch file has already crashed two
dashboard scripts and a TypeError has already taken down drones report. Each
was patched at the consumer, so the next reader inherits the same trap. Fixing
it at the source retires a whole class of crash and removes a permanent wall of
warnings that is training the operator to ignore warnings.
Pre-AI estimate: 3 points — one day to inventory every reader across two
languages and establish what is actually in runs/, one to introduce the
discriminant and version gate without breaking months of existing receipts, one
for the cross-language fixtures plus quantifying the analytics delta. The shape
decision is handed over here, which removes the design half of the first day.
## Review Round Completeness
- outcome: complete
- round: 2
- dispatched: 6
- reported: 6
- missing: (none)
- cause: complete
- head: 2d47281e65262e4472e1639e4d0e3bd82013b605
- run: run-8f5c464d-a309-47c9-8d76-fff1defcbe13
- review: 4938766023
<!-- drones:round-completeness head=2d47281e65262e4472e1639e4d0e3bd82013b605 run=run-8f5c464d-a309-47c9-8d76-fff1defcbe13 -->
GitHub review #4938766023 was published and all dispatched review dimensions reported against the stamped head. Thread-count signals (unreplied=0) are meaningful for this head only — a later push invalidates the stamp. This section is a harness-shaped, head-bound self-report (not an authenticated out-of-band attestation).
<!-- drones:linear-id AI-194 -->
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-04d97bda-9ae4-49fb-a983-e35a3fdf03a9?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-04d97bda-9ae4-49fb-a983-e35a3fdf03a9&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>