Dashboard
Dashboard data binding
Every visible data point to its admin API path and field.
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" |