0115 — One eval catalog: register every eval, gate it from one place

Context

PropFlow has a lot of evals and no idea how many. Measured on origin/main today, not estimated:

Surface Count
evals/promptfooconfig-*.yaml on disk 51
…run by the per-PR sweep (evals/run-all.sh) 38
…run by nightly.yml 40
…run by both 29
nightly-only — invisible to the PR gate 11
…run by neither — zero runners 2
Threshold values inline in nightly.yml 40 (55.0 → 97.0)
Threshold entries in evals/domain-baselines.json 5
GitHub workflows that run some eval 16
npm eval/harness scripts ~30
Registry / manifest of the above 0

Two independent audits of the above (2026-07-27) corrected the framing twice, which is itself the argument for a registry:

Nothing in the repo knows those rows describe one population. The consequences are not hypothetical — they are the four findings of the 2026-07-27 investigation into seven "red" Clara domains:

  1. Two gates disagree about the same domain — for THIRTY domains, not four. leasing-concerns is GREEN at a 78.0 floor in nightly.yml and RED at an implicit 100% bar in the sweep, because domain-baselines.json has no entry and the sweep's documented default for an unlisted domain is STRICT. The catalog's inForce audit puts the real count at 30: four are number-vs-number (leasing-response 81/78, tenant-resident-services-gauntlet 84/74, turnover-walk-editing-gauntlet 79/86, renewal-scoping 95/60) and 26 more are measured-nightly-floor vs implicit-strict-sweep. An earlier draft of this ADR said four, having counted only the first kind.
  2. A manifest promises a tolerance the gate cannot deliver. evals/tracked-gaps.json declares three tolerated gaps under leasing-concerns and its own _meta says the floors in domain-baselines.json tolerate them. That file has no leasing-concerns key. The sweep prints those gaps as "tolerated" in the same summary where the domain is red because of them.
  3. A model migration silently orphaned an entire suite. Every provider entry is a hardcoded model string (66 of them: 41 claude-sonnet-4-6, 23 claude-haiku-4-5-20251001, 2 undated claude-haiku-4-5). When #4688 flipped AGENT_MODEL to claude-opus-5, every text/SMS eval kept grading Sonnet. Nothing failed, nothing warned; the leasing suite simply stopped describing production. Two config headers went on asserting "Provider matches production EXACTLY" for another two days (three more asserted the same thing in different words).
  4. A floor set from one sample. leasing-on-behalf carries a 95% Sonnet floor over 17 rows, recorded with no measured baseline and no guard band. Observed 13/17 and 14/17 on byte-identical inputs — one flipped row moves it while the floor permits zero misses. It cannot be cleared by prompt work.

There is also no way to answer "what evals do we have, and which are green?" /admin/dev/evals has eight hand-maintained tabs; its tab list is mirrored between page.tsx and EvalsShell.tsx by hand. Live-bench harnesses (Morpheus / Maestro, the maintenance Temporal lane, voice-harness, pipeline-lab) are not represented at all.

This is the same class of problem the Metrics Platform (METRIC_CATALOG) and the Tools Platform (src/lib/tools/) already solved: one TypeScript-enforced catalog, one UI, drift guards that fail CI when an entry is added without being wired. Evals never got that treatment.

Decision

Every eval in the repo is registered in one catalog, src/lib/evals/catalog.ts, and every gate, schedule, and UI surface derives from it. An eval that is not in the catalog does not exist; a catalog entry that is not wired fails CI.

What the catalog does NOT do

It does not merge the runners. Three genuinely different classes exist and keeping them separate is deliberate:

class What it is Cost / isolation
prompt promptfoo, LLM-judged, no side effects ~$0.15, parallel-safe
deterministic goldens / fixtures / replay corpora, no judge free, every PR
live-bench real AppFolio / DDB / Temporal / Twilio writes expensive, serialized behind a bench lock or a task-queue override

A bench mutex has no business near a $0.15 prompt eval. The catalog unifies the contract — pass bar, result shape, model resolution, cadence, ownership — not the execution.

The one contract

Each entry declares:

The rules that follow

  1. One threshold. nightly.yml's 41 inline numbers and domain-baselines.json's 5 entries collapse into gate. Both gates read the catalog. Two files can no longer disagree.
  2. Guard bands are mandatory for a rate gate. domain-baselines.json's own _meta.howToSet already says minPassRate = measured − guardBand; the catalog enforces it in the type, so a floor 0.26 points under a measured rate is not expressible.
  3. A policy floor is distinguishable from a measured one. EvalPolicyBar requires a rationale. Conflating the two produced the leasing-on-behalf defect: a floor of 95 recorded with no measured baseline and no guard band, under a note reading "Sonnet meets the strict bar", which every later reader took for a measurement. Sonnet measures 76.5–82.4%.
  4. Tiers resolve, models don't get hardcoded. An eval says which surface it grades; the provider comes from the tier. A future AGENT_MODEL flip re-points its evals or fails loudly — it cannot silently orphan them again.
  5. Coverage is total. Every promptfooconfig-*.yaml on disk has an entry; every entry points at a file that exists; no threshold may be written inline in a workflow. Drift-guarded.
  6. state: 'retired' is a deletion order, not a parking space. Consistent with the repo's ONE SOURCE OF TRUTH rule, an eval that is dead gets deleted with its config, dataset, bridge and thresholds in the same PR.

The Overview surface

/admin/dev/evals gains Overview as its first tab — the honest high-level answer to "what do we have and what's failing", modeled on /admin/dev/status's card grid and reusing its StatusLevel vocabulary (healthy / degraded / down / unknown). One card per catalog entry, grouped by class, showing last result, pass rate vs. its gate, cadence, and staleness. unknown is a first-class state: an eval that has never run must read as "no signal", never as green — the same discipline the sweep's own workflow header states ("a skip is NOT a pass, and must never look like one").

Entity classification (ADR-0027)

Entity Class Naming Derived-from / rebuilt-by / drift-tolerance
EvalSpec canonical bare name Source-of-truth literal in src/lib/evals/catalog.ts; no spine trace (not human-anchored)
EvalOverviewCard derived *Card (matches StatusCard) from: EvalSpec + the latest run artifact for that key · Rebuilt by: GET /api/admin/evals/overview per request · Drift tolerance: none (computed live)

Consequences

Easier. One place to answer "what evals exist". One place to change a pass bar. A model migration can be audited against the catalog instead of by grepping 66 hardcoded strings. New evals get a home and a gate by construction. The Overview tab makes a red domain visible to someone who is not running the sweep.

Harder. Adding an eval becomes a two-file change (config + catalog entry) instead of one, and the drift guard will block the second file being forgotten — deliberately the same friction the metric catalog imposes. Migrating nightly.yml to generated thresholds touches a workflow that is currently hand-tuned in 41 places, and that migration must be done without changing any effective threshold in the same PR that moves them.

Follow-up implied. (a) The two-gate collapse, which is the change that actually fixes four of the seven reds. (b) A promptfoo provider that can call Claude 5 — the pinned 0.121.19 sends temperature unconditionally and Claude 5 400s on it, so tier: 'AGENT_MODEL' cannot be executed until that is solved, only declared. (c) Re-measuring leasing's baseline before it can be given an honest floor. (d) Deleting what the catalog exposes as dead.

Explicitly not committed to. Merging runners, a bespoke eval-execution engine, or moving the live-bench harnesses off their current isolation mechanisms.

Alternatives considered

Leave it; fix the seven reds individually. Rejected: it is the fourth time this class of drift has been repaired by hand, and the repair does not survive the next surface being added. The investigation this ADR comes from found the same root cause behind four separate "failures".

One mega-runner for all evals. Rejected: it would put a bench mutex and a Temporal queue override in the path of a stateless prompt eval, and would make the cheap per-PR lane as fragile as the most expensive harness.

Generate the catalog from disk (glob the configs, infer the rest). Rejected: the fields that matter — surface, tier, gate, owner — are judgments, not facts recoverable from a YAML file. Inference is how tracked-gaps.json and domain-baselines.json drifted apart in the first place.

Put the thresholds in DynamoDB so they can be tuned without a deploy. Rejected for now: a pass bar is a reviewable decision and belongs in the diff. Revisit only if threshold churn becomes the bottleneck.