Run as the core primitive — scorecard = orchestration over runs
Successor: execution-model.md — this document made run the primitive of the EVAL axis; that one generalizes it to every execution (agent activations, sandbox commands) and adds the run group + detachable second phase.
Status: Steps 0–2 SHIPPED. Doc-first SSOT for the "promote run to a real primitive" refactor. Decided direction with the user: run is the execution primitive; scorecard is a thin orchestration/aggregation layer composed OVER runs. Like scheduled-evals / self-hosted-runner: strict generalization, additive — Step 0 changes no existing behavior; Steps 1–2 dedup a real duplication and add child-run fan-out without removing any public capability.
- Step 0 (
d0cdd44):runtimeonPOST /runs/submit_run/ web form →case.placement.target.- Step 1 (
e41c467):executeCasecore (repo-token resolve+attach → dispatch → self-hosted-aware settle); run + scorecard both consume it.admitstays at call sites (run = submit-time 402 gate; scorecard = per-case).- Step 2 (
0472df3db +6957251service):RunRecord.parentScorecardId/trigger+ScorecardRecord.runIds(mig0029);RunStore.listhides children by default (scorecardIdopt fetches them);ScorecardService.runStore?fans out one childRunRecordper case (absent ⇒ unchanged embed-only).Follow-up SHIPPED — web activity-console reframe (
f4dcbaeAPI trigger +bff4992web): standalone runs now carrytrigger(web/mcp/api; scorecard children ="scorecard"), and the run list is reframed from an eval-results table into an activity console — dropped the noise-y per-run score column, added source (trigger)
- cost (usage) columns, live
AutoRefreshwhile any run is active, children hidden (server-side default).Follow-ups SHIPPED:
- #1 case→child-run drill-down (
2f7aac2):RunService.list(tenant,{scorecardId})+GET /runs?scorecardId=
- MCP
list_runs scorecard_id; scorecard detail links each case → its child run (best-effort; old/ingest = no link).- #2 unified activity feed (
1a47bfc): the activity page merges standalone runs + scorecard batches into oneupdatedAt-sorted timeline (ActivityFeedwidget, type badge, each → its detail)./scorecardslist unchanged.- #3 embed→runIds dedup + hydrate (
ba98a8c): dispatched scorecards storerunIdsonly (not the heavyscorecardembed);trackwrites final (post-judge/offload) results back to child runs, andScorecardService.gethydratesscorecardfrom them → response shape, web, and diff are all unchanged.no-runStore/ ingest / old records keep the embed (get returns it as-is).- #4 activity console shows every execution (children un-hidden, grouped). The run list was the ONE surface still hiding scorecard children — which contradicts its purpose as the low-level execution/operations view (which runtime a case landed on, live status; the substrate future replay/live-connection features attach to). Decided with the user: the run list is the execution lens, the scorecard is the eval lens — the same child runs belong in both.
RunStore.listgains anincludeChildrenopt (default stays standalone — the queue, the dashboard's recent-runs, and boot recovery all rely on the parentless default; only the/runspage opts in).GET /runs?scope=all+ MCPlist_runs scope:"all"expose it; the web/runspage requestsscope=allandRunsTablegroups children under their scorecard (a header row with the batch's rollup status + case count, then indented case rows) so a 100-case batch reads as one block, not a flood. The original flooding mitigation (below) is preserved as the default, now overridable for the console.Fully done + verified (CLOSED). Ingest paths (
/scorecards/ingest{,/pull}) keep embed-only (no dispatched run) by design. Verification: api 310 unit/integration tests green; live-verified against real Postgres 16 (docker) — migration0029applies cleanly andPgRunStorechild-run filter (parent_scorecard_id IS NULLvs= $scorecardId) +PgScorecardStore.runIdsjsonb round-trip work on real PG;next build(web) green.
Problem — the primitive is weaker than the composite (inverted)
An agent (Claude Code) reaching for Everdict via MCP thinks in one verb: "run this harness on this runtime."
That is a run. A scorecard is just that verb applied over a dataset (run × N). So the natural dependency is
run ⊂ scorecard. Today it is inverted in two ways:
-
Execution lifecycle is duplicated across two parallel services.
RunService(packages/application-control/src/run/run-service.ts) andScorecardService(packages/application-control/src/scorecard/scorecard-service.ts) each independently wire budget admit/settle, repo-token resolution, artifact offload, judge-model injection, completion notification, and provenance. The scorecard fan-out (ScorecardService.track's inlinedispatchclosure, ~line 318) is a near-verbatim copy ofRunService.submit's dispatch path. Scorecard does not go throughRunServiceand does not createRunRecords — the per-case executions live embedded inScorecard.results[]. -
The API/MCP surface is inverted.
POST /scorecardsacceptsruntime?;POST /runsand MCPsubmit_rundo not.submit_run(apps/api/src/mcp.ts~line 117) builds a fixedEvalCasewith noplacement. So the exact "run harness X on runtime Y" use case is expressible only on the composite (scorecard), not on the primitive (run) — the primitive can't do the one thing a primitive should own.
Consequences observed elsewhere:
- The web Runs list (
apps/web/src/app/[workspace]/runs) has no clear reason to exist — it reads as a worse copy of the scorecard list (per-run scores are statistical noise), because run is framed as an eval-result surface instead of the operational/activity substrate it actually is. - Two services drift: any lifecycle fix (a new budget rule, a new provenance field) must be applied twice.
Key insight from the code — the mechanism already exists
ScorecardService.track already targets a runtime per case by writing placement.target:
// scorecard-service.ts (~line 340)
const cases = runtime
? dataset.cases.map((c) => ({ ...c, placement: { ...c.placement, target: runtime } }))
: dataset.cases;
RuntimeDispatcher then routes on placement.target. Both RunService and ScorecardService are injected the
same dispatcher — ModelResolvingDispatcher(RuntimeDispatcher(scheduler, …)) (main.ts ~line 211, comment:
"run/judge/scorecard share this one dispatcher"). EvalCase.placement.target is already in the core schema
(PlacementSchema, packages/contracts/src/execution/eval-case.ts).
So run can already hit any runtime today — the caller just has to set case.placement.target. The gap is
purely ergonomic surface: no top-level runtime convenience on the run API, and submit_run never sets it.
This makes Step 0 tiny.
Target architecture
run(case, harness, runtime) ─────────────▶ RunRecord
owns (per-case execution lifecycle):
dispatch · budget admit/settle · repo-token resolve · artifact offload
· per-trace judge · notify · provenance · usage
scorecard(dataset, harness, runtime, judges) = run × N + aggregation
owns (ONLY batch-level concerns):
dataset resolution · fan-out orchestration · cross-case summary (mean/passRate)
· baseline↔candidate diff · steps timeline (per-case progress)
Precision on the user's "everything scorecard does should run through run": the execution and per-case scoring
decompose into run; the aggregation and comparison (summarizeScorecard, scorecardModels, diffScorecards)
are inherently batch-level and stay in scorecard, now composed over run records rather than a private executor.
- Moves INTO the run core (today duplicated in scorecard): budget admit/settle, repo-token, artifact offload, per-trace judge, notify, provenance, usage derivation.
- Stays in scorecard: dataset resolution, fan-out, cross-case
summary,models,diff,stepstimeline.
Step 0 — expose runtime on the run surface (additive, reversible, unblocks the MCP use case)
Mirror scorecard's exact placement.target mechanism on the run path. No refactor, no migration.
SubmitBodySchema(server.ts) andSubmitInput(run-service.ts): add optionalruntime?: string.RunService.submit: ifinput.runtimeis set, inject it into the case before dispatch —case.placement = { ...case.placement, target: input.runtime }(verbatim mirror of scorecard).- MCP
submit_run(mcp.ts): add optionalruntimeinput; pass through. (Also consider addingenv/gradersoverrides later, but out of scope here.) - Web:
submit-run-formgains a runtime picker (datalist of the tenant's runtimes, like the scorecard form).
Tests (regression-first per commit policy): a submit with runtime: "self:<id>" sets placement.target
and the dispatcher receives it; omitted ⇒ unchanged (no placement mutation). BFF↔MCP parity test for submit_run
runtime.
Outcome: run becomes a legitimate primitive today; the Claude-Code-via-MCP scenario ("run harness on a
specific runtime") is first-class without waiting for Steps 1–2.
Step 1 — extract a shared execute-case lifecycle core
Factor the per-case execution lifecycle out of both services into one unit (proposed packages/application-control/src/execution/execute-case.ts,
or a small class CaseExecutor). Responsibilities (the currently-duplicated concerns):
executeCase(job, ctx): Promise<CaseResult>
1. budget.admit(tenant) (throws → caller maps to 402 / batch-fail)
2. resolve repo-token (owner + case.env.source.connectionId) → transient job.repoToken
3. enrich job: tenant, submittedBy, harnessSpec, judge model
4. dispatcher.dispatch(job) → CaseResult
5. budget.settle (skip when provenance.ranOn === "self-hosted")
6. (optional, per-case) judge + artifact offload
7. return CaseResult
RunService.submitcallsexecuteCaseonce, wraps the result into aRunRecord, runs notify.ScorecardServicefan-out (runSuite'sdispatch) callsexecuteCaseper case.- This is a pure dedup — no behavior change, no schema change. Ship it with the existing test suites of both services still green (they become the regression guard).
Step 2 — scorecard fans out child runs (the real refactor)
Scorecard stops embedding results and instead creates one child RunRecord per case through the run core, then
aggregates by reference.
Schema changes
RunRecord (packages/db/src/results/run-store.ts) — additive:
parentScorecardId?: string— set when this run is a scorecard child (null for standalone runs).trigger: "standalone" | "scorecard" | "schedule" | "mcp" | "front-door"— provenance of why the run exists (feeds the web activity view's source column from the prior design turn).notify: "default" | "silent"— child runs aresilentso the batch fires one completion notification, not N.
ScorecardRecord (packages/db/src/results/scorecard-store.ts) — the heavy scorecard: Scorecard (embedded
CaseResult[]) is replaced/augmented by runIds: string[] (references). summary/models/steps stay. get
can hydrate the full Scorecard by joining the referenced runs; list stays cheap (summary only, unchanged).
Behavior
ScorecardService.trackfan-out creates aRunRecord{parentScorecardId=<id>, trigger:"scorecard", notify:"silent"}per case via the shared core, collects their ids, then aggregates (summarizeScorecardover the hydrated results,scorecardModels, judges as today). Thestepstimeline is unchanged (per-case progress).- Drill-down for free: scorecard detail → click a case → the same run detail page (full trace/snapshot/usage/ provenance). Today per-case has no addressable entity.
List-flooding & notification mitigations (the two real costs)
- Flooding: a 500-case scorecard creates 500 child
RunRecords. The web activity/run list would drown. →RunStore.listgains a default filterparentScorecardId IS NULL(standalone only); the activity view shows standalone runs + scorecard groups, not the children. Children are reachable only by drilling into their scorecard (or an explicit?scorecardId=filter). This composes with the prior turn's "reframe run list as an activity console" decision. - Notification fan-out: child runs are
notify:"silent"→ no per-case Mattermost ping. OnlyScorecardService.onCompletefires (once). This is almost certainly why the two services were split originally (to avoid per-case webhook/budget ceremony); we preserve the intent by making silence explicit rather than by duplicating the service.
Migration plan (Postgres)
- Next number:
0029(latest is0028_add_scorecard_models.sql). 0029_run_scorecard_children.sql— additive columns onruns:parent_scorecard_id text null,trigger text not null default 'standalone',notify text not null default 'default'; index onparent_scorecard_id. Onscorecards:run_ids jsonb null(nullable — old rows keep embeddedscorecard).- Backward-compat:
ScorecardStore.getreadsrun_idsif present, else falls back to the embeddedscorecardcolumn (old rows render unchanged). No backfill required.RunStore.listdefaultparent_scorecard_id IS NULLkeeps existing standalone-run lists identical. - In-memory stores mirror the same fields (tests run on
InMemory*).
Web impact
- Runs list → activity console (prior design turn,
docs/memory): columns shift from per-run scores to source (trigger) · submitter · usage/cost · elapsed, status filter, auto-refresh whilerunning. Default shows standalone + scorecard groups; children hidden. - Scorecard detail: per-case rows link to the child run detail page.
Testing strategy
- Step 0: unit (placement injection) + BFF↔MCP parity for
runtime. - Step 1: existing
run-service.test.ts+scorecard-service.test.tsare the regression guard (behavior must be byte-identical); add a focusedexecute-caseunit test. - Step 2: scorecard produces N child runs with the right tags;
listhides children; single completion notification;gethydrates fromrunIds; old embedded-scorecard rows still render (migration back-compat test).
Skills/docs to update in the same PR (CLAUDE.md invariant)
.claude/skills/api-layerreference — run/scorecard now shareexecute-case; scorecard creates child runs..claude/skills/foundation— the run⊂scorecard primitive ladder (if it documents the run/scorecard relationship).docs/api.md,docs/scorecards.md—runtimeonPOST /runs; scorecard = child runs +runIds.
Commit plan (Conventional Commits, scoped; each fix/feat ships its regression test)
feat(api): add a runtime knob to POST /runs·submit_run (inject placement.target, symmetric with scorecard)— Step 0.refactor(api): extract the per-case execution lifecycle into an execute-case core (dedup run/scorecard)— Step 1.feat(api): scorecard fans out into child RunRecords + runIds references (mig 0029, hide children in the activity list)— Step 2.
Open questions / decisions to confirm
- Embed vs reference for
ScorecardRecord: keep embeddedscorecardfor a transition window (dual-write) or cut straight torunIds? Proposal: dual-read (writerunIds, fall back to embedded on read) → no backfill. triggertaxonomy: is"schedule"distinct from"scorecard"(a scheduled scorecard's children are both)? Proposal:trigger= the direct creator (scorecard), a separate optionalviacaptures the outer cause.- Ingest path:
POST /scorecards/ingest{,/pull}has no dispatched run (traces come from outside). Those keep embeddingCaseResults (no child runs) —runIdsstays null. Confirm this asymmetry is acceptable.