---
title: "Dashboard data binding"
description: "Every visible data point to its admin API path and field."
source: "dashboard/DATA-BINDING.md"
---

> Part of [Dashboard](/dashboard/llms.txt). Every page: [llms.txt](/llms.txt).

# Dashboard data binding

Every visible data point → admin API (`docs/api/openapi-admin.yaml`, base `/v1`) path and field.
The browser calls `/api/proxy/<path>`; the Worker adds auth and forwards to `${BACKEND_URL}/<path>`
(`BACKEND_URL` may be a bare origin; `/v1` is appended). Every row below is verified against the real backend
(`backend/`, PR #13) by `scripts/integration.sh` and a browser pass; the mock (`pnpm dev:mock`) serves the same shapes.

**Errors** (all screens): `ErrorEnvelope.error.code` picks an operator-facing text (`src/i18n/en.ts` → `errors.codes`,
`src/api/client.ts#describeError`), with the server message as fallback; `details` fills in specifics (who decided,
allowed transitions, spend vs cap…). 422 `details.fields` (`{ <field path>: message }`) is shown inline next to the
field (`fieldErrors()`; config paths like `thresholds.0.review_floor` are mapped to the language's slider).
Handled codes: 409 `already_decided`, `illegal_decision`, `already_resolved`, `illegal_transition`, `generation_paused`
(+ `details.paused_reason`), `deletion_in_progress`, `not_failed`, `round_busy`, `round1_not_assessed`, `not_unreliable`,
`not_assessed`, `verdict_given` (+ `details.verdict_at`); 410 `audio_purged`; 413 `audio_too_large`; 415 `unsupported_media_type`; 422 `validation_failed`, `license_not_allowed`,
`audio_too_long` (+ `details.duration_ms`, `max_ms`; also as a round's `error` after transcription); 404 `article_not_found`,
`audio_unavailable`; `round_busy` reads `details.round`, `status`, `exemplar_pending`;
401/403 → access denied state; 404 → not-found state; 5xx/network → retryable error state.

Local (non-API) state: review undo window (5 s, client only), mock scenario cookie, table selection,
unsaved drafts, filter/tab state in the URL. Operator identity: decoded `email` claim of
`Cf-Access-Jwt-Assertion` via the `getSession` server function (not an API call).

## Global shell

| Element | Source |
|---|---|
| Nav badge "Review queue" | `GET /admin/overview` → `review.open` |
| Nav badge "Reports" | `GET /admin/overview` → `reports.open` |
| Nav badge "Sources" (red) | `GET /admin/overview` → `sources.failing` |
| Operator email | `getSession()` server fn → JWT payload `email` (prod), `admin-key` (dev), `mock-operator@localhost` (mock) |
| Environment pill | `getSession()` → `environment` (`MOCK`, `ACCESS_TEAM_DOMAIN` vars) |
| User search | navigates to `/users/$id` → `GET /admin/users/{user_id}` |
| Topic names / colors everywhere | `GET /admin/topics` (`id`, `color_token`, `names`, `glyphs`); `src/lib/topics.ts` mirrors the backend seed while loading |

## 1. Overview `/`

Auto-refresh: `refetchInterval` 60 s; "Updated x s ago" = TanStack Query `dataUpdatedAt`.

| Element | Path | Field |
|---|---|---|
| Inventory heatmap cell value / color | `GET /admin/overview` | `inventory[].published`, `.target` (color = published/target, `src/lib/heatmap.ts`) |
| Cell tooltip | same | `inventory[].in_pipeline`, `.unseen_available` |
| "n of 30 cells below target", "n articles short" | same | derived from `inventory[]` |
| Cell click | → `/articles?language&topic&difficulty` | `GET /admin/articles?language&topic&difficulty` |
| Paused banner (reason pill + text) | same | `pipeline.paused`, `pipeline.paused_reason` (`manual` / `cost_cap` / `provider_errors`) |
| Banner → Resume | `GET` + `PUT /admin/generation/config` | whole config with `paused: false`, `paused_reason: null` |
| Banner hint "resuming re-pauses" (cost_cap) / "Raise the cap" / "Open AI providers" links | same | `pipeline.spend_today_usd` ≥ `pipeline.daily_cost_cap_usd` (`src/lib/spend.ts`) |
| Spend today vs cap meter (Cost card) | same | `pipeline.spend_today_usd`, `pipeline.daily_cost_cap_usd` (tone: ok < 80 % ≤ warn < 100 % ≤ bad) |
| Generated 24 h / 7 d | same | `pipeline.generated_24h`, `pipeline.generated_7d` |
| Auto-publish 7 d (vs 85 % target, ADR-002 D5) | same | `pipeline.auto_publish_rate_7d` |
| Failed jobs 24 h / Job queue | same | `pipeline.failed_jobs_24h`, `pipeline.queue_depth` |
| Review open / audit / reported | same | `review.open`, `review.audit_open`, `review.reported_open` |
| Median age | same | `review.median_age_hours` |
| Inflow 24 h (+ alarm > 20/day) | same | `review.inflow_24h` |
| Inflow sparkline (14 d) | same | `review_inflow_daily[]` |
| Cost per published article / 24 h / 7 d | same | `cost.per_published_article_7d`, `cost.usd_24h`, `cost.usd_7d` |
| Provider health table | same | `providers[].capability`, `.active_model_ref`, `.error_rate_24h`, `.fallbacks_24h` |
| Open reports / rights claims | same | `reports.open`, `reports.rights_open` |
| Sources enabled / failing | same | `sources.enabled`, `sources.failing` |

## 2. Review queue `/review`

| Element | Path | Field |
|---|---|---|
| Tabs + counts | `GET /admin/review?kind=` | `counts{needs_review,audit,reported}` (minus locally pending decisions) |
| List item title / lang / topic / tier / QA | same | `items[].article.title`, `.language`, `.topic_id`, `.difficulty`, `.qa_score` |
| Flag chips | same | `items[].reasons[]`: check ids (`argument_clarity`, `audit_sample`) or report reasons as `report:<reason>` (label via `reasonLabel`) |
| Waiting time | same | `items[].created_at` |
| Header pills | same | `article.status`, `.length`, `.source.kind`, `.source.license` |
| Source text (licensed only) vs draft | same | `article.source_text`, `article.source.url`, `article.paragraphs[]`, `article.reading_hint` |
| Highlighted paragraphs | same | paragraphs containing a failed check's `evidence` |
| QA report: score, decision, threshold, safety, duplicate | same | `article.quality_report.qa_score`, `.decision`, `.threshold_used`, `.safety_passed`, `.duplicate_of` |
| QA checks list | same | `quality_report.checks[]` `.id`, `.kind`, `.passed`, `.score`, `.detail`, `.evidence`; `.model_ref`, `.created_at` |
| Key points editor | same | `article.key_points[]` (edits sent as `edits.key_points` with `edit_and_publish`) |
| Provenance | same | `article.provenance.generator_model_ref`, `.prompt_version`, `.cost_usd`, `article.pair_id`, `article.source.attribution` |
| Phone preview | `GET /admin/articles/{id}/preview` | public `Article`: `title`, `byline`, `topic.name`, `est_read_seconds`, `paragraphs`, `reading_hint`, `source{name,url,license}` |
| Publish / Keep / Edit & publish / Regenerate / Reject / Retire | `POST /admin/review/{item_id}/decide` (sent after the 5 s undo window, always `keepalive`) | `decision`, `edits` (ArticleDraftInput), `reason` (RejectReason), `note`; 409 `already_decided` → toast with `details.decision`/`actor`, queue refetched |

## 3. Articles `/articles`, `/articles/$id`

| Element | Path | Field |
|---|---|---|
| Table rows | `GET /admin/articles?status&language&topic&difficulty&source_kind&q&sort&cursor&limit=25` | `items[]`: `title`, `status`, `language`, `topic_id`, `difficulty`, `length{unit,value}`, `source.kind`, `source.license`, `qa_score`, `health.score`, `health.sessions`, `updated_at` |
| Pagination | same | `next_cursor` (previous = client-side cursor stack) |
| Sort select | same | `sort` enum |
| Bulk retire | `POST /admin/articles/{id}/transition` ×n | `{action:"retire", note}` (skips statuses not in D3) |
| Detail header + actions | `GET /admin/articles/{id}`; actions `POST …/transition` | `id`, `version`, `status`, …; legal actions from ADR-002 D3 (`src/lib/lifecycle.ts`) |
| Content editor (title, paragraphs, reading hint, byline, key points + Σ weights) | `GET` / `PATCH /admin/articles/{id}` | `title`, `paragraphs`, `reading_hint`, `byline`, `key_points[]` |
| Save & publish now (override) | `PATCH /admin/articles/{id}` | `publish_now: true`, `note` (required in UI) |
| Details card | `GET /admin/articles/{id}` | `source.*`, `provenance.*`, `pair_id` |
| Preview tab (+ version select) | `GET /admin/articles/{id}/preview?version=` | public `Article` |
| Quality tab: QA report | `GET /admin/articles/{id}` | `quality_report` |
| Quality tab: health stats | same | `health.score`, `.sessions`, `.reports`, `.completion_rate`, `.avg_key_info`, `.helpful_rate`, `.computed_at` |
| Quality tab: health trend | same | `health.trend[]` |
| Versions list | `GET /admin/articles/{id}/versions` | `items[].version`, `.created_at`, `.actor`, `.change_summary`, `.qa_score` |
| Version diff | same | `items[].snapshot` |
| History tab | `GET /admin/audit-log?entity=article:{id}` | `items[].at`, `.actor`, `.action`, `.before`, `.after`, `.note` (`entity` = `type:id`, exact match); review decisions are logged on `review_item:{id}` and do not appear here |

## 4. Sources `/sources`, `/sources/$id`

| Element | Path | Field |
|---|---|---|
| Table: name, host, type, lang, license, schedule | `GET /admin/sources` | `items[].name`, `.url`, `.type`, `.language`, `.license`, `.owner_cleared`, `.schedule_cron` (label via `src/lib/cron.ts`) |
| Last run (+ error) | same | `last_run.at`, `.status`, `.fetched`, `.new_items`, `.error` |
| Yield / published / rejected | same | `stats.yield`, `.articles_published`, `.articles_rejected` |
| Enabled toggle (pausing confirms) | `PATCH /admin/sources/{id}` | `{enabled}` |
| License select (disallowed shown disabled), attribution preview, share-alike / attribution notes | `GET /admin/licenses` | `items[].id`, `.name`, `.allowed`, `.attribution_template`, `.requires_attribution`, `.share_alike` |
| Topic hint checkboxes | `GET /admin/topics` | `id`, `names` |
| Create / edit form (cron presets + custom) | `POST /admin/sources`, `PATCH /admin/sources/{id}` | `SourceInput`; 422 `error.message`/`details` inline |
| Fetch now (+ live job toast) | `POST /admin/sources/{id}/fetch` then `GET /admin/jobs/{id}` polled | `Job.id`, `.status`, `.result.fetched`, `.result.new_items`, `.error` |
| Detail header / config / stats | `GET /admin/sources/{id}` | all `Source` fields |
| Archive (confirm) | `DELETE /admin/sources/{id}` | 204 |
| Items table (→ produced articles) | `GET /admin/sources/{id}/items?cursor=` | `items[].title`, `.url`, `.author`, `.published_at`, `.retrieved_at`, `.text_length`, `.status`, `.article_ids`; `next_cursor` |

## 6. AI providers `/providers`

| Element | Path | Field |
|---|---|---|
| Chain editor per capability (reorder, enable, model, timeout, options JSON; first enabled = active) | `GET` / `PUT /admin/ai-providers` | `{asr,llm,content_llm,qa_llm,tts,embeddings}.chain[].provider`, `.model`, `.enabled`, `.timeout_ms`, `.options` |
| Price table editor | same | `price_table[].provider`, `.model`, `.input_per_1k`, `.output_per_1k`, `.per_minute` |
| "Last saved … by …" | same | `updated_at`, `updated_by` |
| Usage by capability (calls, errors, error rate, fallbacks, p50, cost) + cost bars | `GET /admin/ai-providers/usage?range=24h\|7d\|30d` | `by_capability[]` |
| Usage by provider | same | `by_provider[]` `.provider`, `.model`, `.capability`, `.calls`, `.errors`, `.cost_usd` (merged by provider+model client-side) |
| Daily cost chart | same | `daily[].date`, `.cost_usd` |
| Cost per published article | same | `cost_per_published_article_usd` |
| Test prompt | `POST /admin/ai-providers/test` | request `capability`, `input`, `language`; response `model_ref`, `latency_ms`, `cost_usd`, `output` |

## 5. Generation `/generation`

| Element | Path | Field |
|---|---|---|
| Paused pill, Pause (confirms) | `GET` / `PUT /admin/generation/config` | `paused: true` (→ `paused_reason: "manual"`) |
| Paused banner (reason + Resume) | same | `paused`, `paused_reason`; resume = `PUT` with `paused: false` |
| Spend guard: daily cost cap, max jobs per run (saved with the config; 422 inline) | same | `daily_cost_cap_usd`, `max_jobs_per_run` |
| Spend today vs cap meter | `GET /admin/overview` | `pipeline.spend_today_usd` (cap = the draft's `daily_cost_cap_usd`) |
| Prompt version, batch max, default target, retire health below | same | `prompt_version`, `batch_max`, `default_target`, `retire_health_below` |
| Audit sample rate slider (0–20 %) | same | `audit_sample_rate` (0–1) |
| Source mix slider | same | `mix.ai_original` |
| Pair generation toggle | same | `pair_generation` |
| "Last saved … by …" | same | `updated_at`, `updated_by` |
| Targets grid (blank = inherit default, ↺ = remove override) | same | `targets[]{language,topic_id,difficulty,target}`; placeholder `default_target` |
| "n pub" beside each cell | `GET /admin/overview` | `inventory[].published` |
| Threshold sliders per language | `/admin/generation/config` | `thresholds[].auto_publish`, `.review_floor` |
| Expected auto/review/reject share, review items/day, distribution histogram | `GET /admin/quality?range=30d&language=` | `aggregate.qa_score_distribution[].buckets`, `aggregate.generated`; math in `src/lib/thresholds.ts` |
| Save bar → confirm → save | `PUT /admin/generation/config` | whole `GenerationConfig` with the server's current `paused`/`paused_reason`; 422 `details.fields` inline (`thresholds.<i>.review_floor` → that language's slider) |
| Topic briefs table | `GET /admin/generation/briefs` | `items[]`: `language`, `topic_id`, `title`, `angle`, `difficulty`, `enabled`, `uses`, `last_used_at`, `avg_qa_score` |
| Create / edit / enable / delete brief | `POST` / `PATCH` / `DELETE /admin/generation/briefs[/{id}]` | `TopicBriefInput` |
| Run-now dialog | `POST /admin/generation/run` | `mode`, `language`, `topic_id`, `difficulty`, `brief_id`, `source_item_id`, `count`, `pair` → 202 `jobs[]` |
| Run-now: "stopped by the spend guard" result | same | 202 `stopped.paused_reason`, `stopped.planned` vs `jobs.length` |
| Run-now: paused error | same | 409 `generation_paused` + `details.paused_reason`, `spend_today_usd`, `daily_cost_cap_usd`; 422 `details.fields` inline per field |
| Jobs table (+ type/status filters, pager, live poll 5 s) | `GET /admin/jobs?type&status&cursor` (opaque cursor) | `items[]`: `id`, `type`, `status`, `input`, `result`, `error`, `attempts`, `cost_usd`, `created_at`, `started_at`, `finished_at`, `actor`; `next_cursor` |
| Retry | `POST /admin/jobs/{id}/retry` | returned `Job` |

## 7. Quality `/quality`

| Element | Path | Field |
|---|---|---|
| Range / language controls | `GET /admin/quality?range=7d\|30d\|90d&language=` | — |
| KPI row | same | `aggregate.generated`, `.auto_published`, `.auto_publish_rate`, `.needs_review`, `.rejected`, `.median_queue_hours` |
| Auto-publish rate trend (target 85 %) | same | `aggregate.daily[]` |
| Reject reasons bars | same | `aggregate.reject_reasons[]` |
| Failed checks bars | same | `aggregate.failed_checks[]` |
| Health histogram | same | `aggregate.health_histogram[]` |
| Audit agreement | same | `aggregate.audits{sampled,agreed,overturned}` |
| Helpfulness by article (→ `/articles/$id`) | same | `aggregate.helpfulness_by_article[]` |
| Threshold history | same | `aggregate.threshold_history[]` |
| ADR-002 D5 inflow hint | same | `needs_review` ÷ days > 20 |

## 8. Reports `/reports`

| Element | Path | Field |
|---|---|---|
| Status tabs (open / resolved / all) | `GET /admin/reports?status=` | `items[]` |
| Columns | same | `created_at`, `kind`, `reason`, `comment`, `article_id` (→ `/articles/$id`), `assessment_id`, `user_id` (→ `/users/$id`), `status`, `resolution` |
| Resolve dialog: article status, "Decide in review queue" link | `GET /admin/reports` + `GET /admin/articles/{id}` | report `review_item_id` (falls back to the article's), article `status`, `title` |
| Resolve | `POST /admin/reports/{id}/resolve` | `resolution`, `note`; 409 `already_resolved` (+ `details.resolution`) → toast; 422 inline |

## 9. Users `/users`, `/users/$id`

| Element | Path | Field |
|---|---|---|
| Waitlist CSV export (confirm: contains emails) | `GET /admin/waitlist/export` | `text/csv` |
| Header / profile | `GET /admin/users/{user_id}` | `display_name`, `user_id`, `auth_provider`, `ui_locale`, `practice_languages`, `created_at` |
| Entitlement card | same | `entitlement.status`, `.product_id`, `.expires_at`, `.will_renew`, other keys verbatim |
| Activity | same | `stats.completed_sessions`, `.streak`, `.level`, `.last_active_at` |
| Open reports | same | `open_reports` |
| Deletion jobs (polls 3 s while running) | same | `deletion_jobs[]` (`Job`) incl. `result.deleted.{sessions,attempts,audio_objects,transcripts,assessments}` |
| Delete training data (typed confirmation = user id) | `POST /admin/users/{user_id}/delete-data` | returned `Job.id`; 409 `deletion_in_progress` → toast |

## 10. Audit log `/audit`

| Element | Path | Field |
|---|---|---|
| Actor / entity filters (in URL) | `GET /admin/audit-log?actor&entity&cursor` | — |
| Rows | same | `items[]`: `at`, `actor`, `action`, `entity_type`, `entity_id` (links for article/source/user), `note` |
| Change diff | same | `before`, `after` (changed keys only) |
| Pager | same | `next_cursor` |

## 11. Playground `/playground`, `/playground/$id` (ADR-002 D4, issue #33)

Polling: the run polls every 2 s while `status == "processing"` or any round is `transcribing`/`assessing`, any result
is `pending`/`running` or any exemplar is `pending` (`src/lib/playground.ts#isRunBusy`); the history polls every 5 s while
a row is `processing`. Mutations that return a `PlaygroundRun` write it straight into the cache. Lanes are always shown
in label order (`lanesByLabel`): lane ids are opaque and random. While blind, the API releases a round's results
together (every lane `running` with nothing inside, `finished_at` null), so the lanes read "Waiting for all lanes"
instead of per-lane progress. Playground cost appears only on the run; overview and provider usage never include it.

The recording is local state until it is submitted: MediaRecorder (`audio/mp4;codecs="mp4a.40.2"` when supported, else
`audio/webm;codecs=opus`, `src/lib/playground.ts#pickRecordingType`) or a chosen file; the timer, pause, level meter
(Web Audio analyser), auto-stop at 3:00 / 2:00 (`ROUND_CAP_MS`, ADR-001 D2) and the playback before submit are client
only. Submitting is the one binary call: the browser PUTs the blob, and the proxy passes the bytes through
(`src/server/proxy.ts`, `audio/*` only, ≤ 5 MB enforced while reading, sent upstream as a buffer); every other call is JSON.

| Element | Path | Field |
|---|---|---|
| History rows (newest first), language filter, pager | `GET /admin/playground/runs?language&cursor` | `items[]`: `article_title`, `id`, `article_id`, `article_version`, `language`, `lane_count`, `rounds_done`, `status`, `blind`/`revealed` (mode pill), `verdict.preferred_lane_id` ("tie" → Tie, else Winner picked) marked "picked blind" / "changed after reveal" / "after reveal" from `blind_verdict` (null, same lane, other lane), `created_by`, `created_at`; `next_cursor` |
| New run: article picker | `GET /admin/articles?status=published&language&q&limit=8` | `items[]`: `id`, `title`, `version`, `language` → `article_id`, `article_version` |
| New run: previous action | — | `previous_action` (MicroSkillId or null), labels `m.playground.microSkills` |
| New run: ASR model (default first enabled) | `GET /admin/ai-providers` | `asr.chain[]` → `asr` (`provider`, `model`, `timeout_ms`, `options`) |
| New run: lanes (prefilled, 2–6) | `GET /admin/ai-providers` | enabled `llm.chain[]` entries → `lanes[]` (`provider`, `model`, `options` as JSON) |
| New run: blind toggle, note | — | `blind` (default true), `note` |
| Create | `POST /admin/playground/runs` | 201 → navigate to `/playground/{id}`; 422 `details.fields` inline (`lanes`, `lanes[i].provider`/`.model`/`.options`/`.timeout_ms`, `asr.*`, `previous_action`, `note`); 404 `article_not_found` under the picker |
| Run header | `GET /admin/playground/runs/{run_id}` | `article.title`, `id`, `created_by`, `created_at`, `status`, `article.language`, `blind` + `revealed_at` (mode pill), `asr.model_ref`, `previous_action`, `cost_usd` (revealed only; null → "unpriced"), `note` |
| Article to read | same | `article.id`, `.version`, `.title`, `.paragraphs[]`, `.reading_hint` |
| Round tabs (dot = status) | same | `rounds[].round`, `.status`; round 2 opens when a lane has a `done` round-1 result |
| Recording meta | same | `rounds[].duration_ms`, `.content_type`, `.duration_source` (`container` the file, `client` the browser, `client_checked` checked against the transcript), `.uploaded_at`, `.audio_available` |
| Playback (recording) | `GET /admin/playground/runs/{run_id}/rounds/{round}/audio` | `url` (refetched 30 s before `expires_at`, and when the player fails to load it); 410 `audio_purged` (or `audio_available` false with `uploaded_at` set) → "removed after 30 days"; 404 `audio_unavailable` → "no recording yet" |
| Upload / replace recording | `PUT /admin/playground/runs/{run_id}/rounds/{round}/audio` | binary body, `Content-Type`, `X-Client-Duration-Ms` (always sent; the server ignores it for MP4/WAV); 202 → run; 409 `round_busy` / `round1_not_assessed`, 413, 415, 422 `audio_too_long` inline under the take. Replace is disabled while that round (round 1: also round 2) is transcribing, assessing or has a model answer `pending`; replacing round 1 when round 2 exists asks first (it resets round 2). Once the run has a `verdict`, a round with `uploaded_at` is locked (409 `verdict_given`): no Replace, a note "this run has a verdict; start a new run to try again" and the shortcut below; the first round-2 upload stays open |
| New run with the same article and lanes (header once there is a verdict, and in the lock note) | `GET /admin/playground/runs/{run_id}` → `/playground?new=true&from={run_id}` | prefills `article_id`/`article_version` (`article.*`), `previous_action`, `asr` (`asr.model_ref`, added to the choices if the chain no longer has it), `lanes[]` (`lanes[].model_ref` split at the first `/`, `options`) in label order; blind back on |
| Transcribing / failed banner | run | `rounds[].status` (`transcribing`, `failed` + `rounds[].error.code`/`.message`; `audio_too_long` with `details.duration_ms`/`max_ms` from the post-transcription check) |
| Unreliable banner + Assess anyway | run; `POST …/rounds/{round}/assess-anyway` | `rounds[].unreliable_reason` (labels `m.playground.unreliableReasons`), `.assessed_anyway`; 409 `not_unreliable` → toast |
| Shared transcript (sticky), seek on timestamps | run | `rounds[].transcript.segments[]` (`start_ms`, `end_ms`, `text`), `.asr_ref`, `.speech_ratio`, `.length` |
| Evidence highlight | run | the clicked lane's `assessment.evidence[]` (`start_ms`/`end_ms` → overlapping segments, `transcript_excerpt` → `<mark>`) |
| Lane header | run | `lanes[].label`, `.model_ref` (null → "Model hidden"), `.options`, result `status`, winner = `verdict.preferred_lane_id` |
| Lane: overall, band, confidence, summary | run | `lanes[].results[round].assessment`: `overall`, `band_label` (fallback `band`), `confidence`, `confidence_note`, `summary` |
| Lane: dimensions | same | `dimensions.{key_info,structure,efficiency,fluency}`: `score`, `note`, `evidence_ids[]`, `insufficient_evidence` |
| Lane: key points | same | `key_point_coverage[]`: `text`, `covered`, `evidence_id` |
| Lane: one action | same | `one_action`: `micro_skill_id`, `instruction`, `example_sentence`, `evidence_id` |
| Lane: previous action | same | `previous_action`, `achieved_previous_action` |
| Lane: evidence list | same | `evidence[]`: `id`, `kind`, `start_ms`, `end_ms`, `transcript_excerpt`, `note` |
| Lane: round-2 comparison | same | `comparison`: `verdict`, `verdict_label`, `overall_delta`, `key_info_delta`, `structure_delta`, `efficiency_delta`, `fluency_delta`, `duration_delta_ms`, `key_points_lost[]` |
| Lane: rubric | same | `rubric_version` |
| Lane: latency / tokens / cost (revealed only) | same | `latency_ms`, `usage.input_tokens`/`.output_tokens`, `cost_usd` |
| Lane: queued / scoring (revealed) or "waiting for all lanes" (blind) / skipped / failed + Retry | same; `POST …/lanes/{lane_id}/retry` `{round}` | result `status`; `error.details.kind` (fallback `error.code`); `error.message` only once revealed (a provider message can name the model); 409 `not_failed` → toast |
| Lane: Score round 2 | `POST …/lanes/{lane_id}/retry` `{round: 2}` | shown when the lane's round-2 result is `skipped`, round 2 isn't `unreliable` and the lane's round-1 result is `done` |
| Lane: model answer | `POST …/lanes/{lane_id}/exemplar` `{round}` | result `exemplar`: `status`, `label`, `text`, `audio_url` (an expired URL refetches the run), `tts_ref`; 409 `not_assessed` → toast |
| Verdict bar | `POST /admin/playground/runs/{run_id}/verdict` | `preferred_lane_id` (lane id or "tie"), `note`; shows `verdict.preferred_lane_id` (→ lane label), `.blind`, `.decided_by`, `.decided_at`, `.note`; when `blind_verdict` names another lane it is shown first ("Blind pick B → now Tie") and that lane gets a "Blind pick" pill; 422 `details.fields.preferred_lane_id` inline |
| Reveal models | `POST /admin/playground/runs/{run_id}/reveal` | → `revealed_at`, then `model_ref`, `options`, `latency_ms`, `usage`, `cost_usd` appear |
| Delete run | `DELETE /admin/playground/runs/{run_id}` | 204 → back to history |

Local states: mic permission denied / no microphone / MediaRecorder unavailable (upload only), offline (submit
disabled, take kept), unsent take (leave warning).

Mock (`src/mock/playground.ts`): six seeded runs, one per state (`pg_seed_ready`, `pg_seed_blind`, `pg_seed_attention`,
`pg_seed_unreliable`, `pg_seed_new`, `pg_seed_old` with purged audio and a blind pick changed after the reveal); lane ids
are random; WAV duration comes from the RIFF header; blind results are released together; progress is simulated (transcription ~2 s, each lane
1.5–6.5 s). Transcripts, assessments and model answers are invented from the article text (mock data). Triggers: a lane
model id containing `fail` / `timeout` fails its first attempt; `slow` takes 12–16 s; an ASR model id containing `fail`
fails transcription; `drift` makes a webm/mp3 transcript run past the client duration (post-transcription
`audio_too_long`); a take under 5 s is `too_short`; a provider without an enabled chain entry (`google` in the
fixtures) is "not configured" (422).

## Contract change requests

CR-1..19 from the first dashboard PR were applied to `docs/api/openapi-admin.yaml` (#12) and are implemented by the
backend (#13); the UI now reads them directly. CR-13 (mock default target 3 vs real 20) stays mock-only by design.

New requests found while integrating (none change the contract in this PR):

| # | Request | Why |
|---|---|---|
| CR-20 | List `409 illegal_decision` (review decide) and `409 not_failed` (job retry) in the spec's error-code summary | The backend returns both; the dashboard handles them |
| CR-21 | Document `ReviewItem.reasons` for reported items as `report:<reason>` (or emit the bare reason) | Spec says "check ids or report reasons"; backend sends `report:rights` |
| CR-22 | `cost.per_published_article_7d` / `cost_per_published_article_usd` should be `null` when no call was priced | Backend returns `0` without a price table, which reads as "free" |
