Execution · Orchestration · Scoring — the three concerns
Status: SHIPPED (S1
ad7cdc2+ S291f7c55). Doc-first SSOT for a fundamental separation of concerns in the control plane. Successor to run-as-primitive: that maderunthe execution primitive andscorecard = run × N; this untangles the three concerns that were smeared across two services (RunService,ScorecardService). Goal is architectural cleanliness — concern isolation + a clean collaboration model.The three concerns are now separated:
- Execution =
execute-case.tsexecuteCase(deps, owner, job) → CaseResult— pure: repo-token + dispatch (+ completing a job-deferred trace collection,traceRef— see streaming-case-pipeline D4). No settle/offload/notify (S2 strippedsettleout).runno longer cares about "after".- Scoring =
scoring-service.tsScoringService— judge application over results, independent of how they were produced (S1). Live batch and ingest share it. Aggregation stays pure in@everdict/domain.- Orchestration =
RunService(single: admit → executeCase → settle → offload → webhook → notify) andScorecardService(batch: fan-out executeCase + per-case settle/child-run →ScoringService→ suite aggregate → store). These drive execution and own delivery/accounting.Deliberately NOT done (evaluated, deemed unnecessary — separation is already achieved, these would be DRY gold-plating):
materializeRun(the RunRecord create/update lives in each orchestrator managing its own record lifecycle — not duplication of a concern); a separateBatchDriverclass (ScorecardService.trackalready is the batch orchestrator, now delegating execution→executeCaseand scoring→ScoringService).
Problem — 3 concerns tangled into 2 services (feels "artificial")
There are three genuinely distinct concerns:
| Concern | Essence | Owner |
|---|---|---|
| Execution | run one case → a result (trace/snapshot). Nothing after. | run |
| Orchestration | decide what to run · fan-out · collect · admit/settle · deliver (202/poll/webhook) · notify · progress | the orchestrator |
| Scoring | over results/traces: grade · judge · aggregate (summary) · compare (diff) · rank (leaderboard) | the evaluator |
Today they are collapsed into two services, both of which drive execution — which is exactly why "there are two objects for the same execution" feels wrong:
RunServiceis not pure execution.track()does, after dispatch:budget.settle→offloadSnapshot→store.update→onComplete(Mattermost) →fireWebhook. A "run" should not care about the after — settle, offload, notify, webhook are delivery/accounting = orchestration. EvenexecuteCasedoesbudget.settle.ScorecardServicedoes all three at once.track()interleaves execution driving (runSuite), scoring (applyJudges), aggregation (summarizeScorecard/scorecardModels), progress steps, persistence, and notify — one ~600-line service.- Consequence: scoring can't be used without the batch execution path, and execution can't be driven without an orchestrator dragging delivery concerns. Separable things are forced to cohabit → the "artificial" feeling.
The proof that scoring is separable: ingest
POST /scorecards/ingest{,/pull} produces a full scorecard without executing anything — it takes external
traces and runs applyJudges / summarize. So scoring is already an independent function over
traces; it only looks coupled because it lives as methods on ScorecardService next to the execution path.
Ingest is the existence proof for Concern 3.
Two execution layers (don't confuse them)
- In-sandbox (
@everdict/application-executionrunCase): drive the harness-under-test via a Driver →CaseResult, inside the isolated agent job. Untouched by this refactor. - Control plane (this doc): dispatch a job to a backend, get the
CaseResultback, record it. This is where the three concerns tangle. To avoid a name clash withrunner.runCase, the control-plane unit ismaterializeRun.
Principles
run= pure execution. Dispatch a case →CaseResult→ aRunRecord. It does not settle budget, offload, notify, webhook, judge, or aggregate. It knows nothing about "after".- Scoring is a pure-ish function over results (given a
JudgeRunner), independent of how the results were produced. One scorer serves live batches and ingest. - The orchestrator drives. Admission, concurrency, fan-out, budget settle, delivery (202/webhook), notify, progress — all live here, wrapping pure execution and pure scoring.
scorecardis a scoring artifact over a set of runs, not a second execution object.
Target model
Execution (run) Orchestration Scoring (evaluator)
─────────────── ───────────── ───────────────────
executeCase(job) → CaseResult RunService (single): ScoringService.score(
· repoToken · dispatch admit → materializeRun results, {judges}, ctx)
· (NO settle/notify) → settle → offload · applyJudges (JudgeRunner)
→ webhook → notify (202)
materializeRun(record, job) → scored results
= executeCase + record BatchDriver (scorecard): @everdict/domain (pure):
(create→exec→update) admit/settle per case summarize · diff · leaderboard
returns RunRecord → fan-out materializeRun @everdict/graders (pure): grade
"ignores the rest" → collect → steps
→ hand results to ScoringService
▲ │ ▲ ▲
└──────── orchestrator drives runs ─┘ └── results → scorer ────┘
scorecard = ScoringService(scored) over the runs a BatchDriver produced (ingest = scorer over fetched traces)
Module layout (apps/api/src)
| Module | Concern | Responsibility (as shipped) |
|---|---|---|
execute-case.ts | Execution | executeCase(deps, owner, job) → CaseResult — repo-token resolve+attach + dispatch only. No settle/offload/notify. |
scoring-service.ts | Scoring | ScoringService — applyJudges + collectJudgeModels over results. Used by batch and ingest. Aggregation stays pure in @everdict/domain. |
run-service.ts | Orchestration (single) | admit → create → executeCase (async) → settle → offload → webhook → notify. 202. |
scorecard-service.ts | Orchestration (batch) + composition | submit = fan-out executeCase per case (+ per-case settle + child-run lifecycle) → ScoringService (judges) → @everdict/domain (summarize/models) → store. ingest = fetch traces → ScoringService → store. Now clearly scoring/aggregation-focused, delegating execution + scoring out. |
notification-service.ts | Orchestration (delivery) | already separate; a completion hook (run + scorecard). |
No
materialize-run.ts/batch-driver.tswere created — see the status block. The RunRecord create/update in each orchestrator is that orchestrator managing its own record lifecycle, andScorecardService.trackalready is the batch orchestrator. Extracting them would be DRY gold-plating, not concern separation.
What moves where
- out of
executeCase:budget.settle→ into orchestration (RunService / BatchDriver settle after reading the result's cost).executeCasebecomes pure "get aCaseResultfor a job". - out of
RunService.track: nothing leaves the service, but it is re-expressed asadmit → materializeRun → settle → offload → webhook → notifyso the execution part is the sharedmaterializeRunand the rest is visibly orchestration. - out of
ScorecardService:applyJudges→ScoringService.ScorecardService.trackkeeps only: fan-out (via BatchDriver/materializeRun), progress steps, calling the scorer, aggregating (suite), storing. - unchanged:
@everdict/graders,@everdict/domain(already pure),@everdict/application-execution(in-sandbox), API response shapes,runIds/child-run behavior, ingest's embed-only, MCP/HTTP surface.
Migration slices
- S1 — extract
ScoringService✅ad7cdc2.applyJudges/collectJudgeModels→ScoringService;ScorecardServicebuilds one from its deps and delegates; live batch + ingest share it. 146 existing + 4 new tests. - S2 —
run= pure execution ✅91f7c55. Strippedsettle(+costOf/budget/tenant) fromexecuteCase;RunService.trackand the scorecard batch closure settle after execution. 310 api tests green. - S3 — docs + skill ✅ (this change).
materializeRun/BatchDriverevaluated and deferred as DRY gold-plating (see status block) — the three concerns are already separated by S1+S2.
Sanctioned service→service seams
Peer resource services never call each other (rule api-layer); the named exceptions are registered here:
- Orchestration →
executeCase/ScoringService— the core decomposition above. JudgePreviewService→RunService.submit(viacodeJudgeRunSubmitter,apps/api/src/composition/run.ts) — the code-judge dry-run promotion:POST /judges/tryon akind:"code"judge submits the sandboxed wrapper job as a real standalone run (trigger: "judge-preview", inlineharnessSpec) and returns{ runId }, so progress/logs/verdict ride the run surfaces instead of an invisible blocking dispatch. This is single-run delivery by design (one interactive dry-run, a person watching) — exactly whatRunService.submitowns, so the batch prohibition below does not apply. Seedocs/judges.md§Dry-run.
Invariants / non-goals
- Do NOT route the batch through
RunService.submit. That bundles single-run delivery (202/webhook/per-run notify/submit-admit) which must not fire per case. The shared unit isexecuteCase(pure execution), not the single-run orchestrator. (See run-as-primitive §"Why not go through RunService".) - In-sandbox
@everdict/application-executionuntouched. This is a control-plane decomposition only. - No API/MCP/web shape changes.
GET /scorecards/:idstill returns a hydrated scorecard;POST /runsetc. unchanged. This is an internal seam refactor. - Ingest stays embed-only (no dispatched runs) — it scores fetched traces via the same
ScoringService.
Skills/docs updated
.claude/skills/api-layer— the execution/orchestration/scoring seam (executeCasepure ·ScoringService· services orchestrate).docs/api.md/docs/scorecards.md— surface unchanged (internal seam refactor); no client-visible change.
Verification (CLOSED)
- api 310 unit/integration tests green (in-memory + fake
SqlClient).ScoringServicehas its own 4 unit tests;executeCasereduced to pure-execution tests; self-hosted settle-skip guarded at the service level. - Live-verified against real Postgres 16 (docker): migrations apply cleanly and the child-run store path
(
PgRunStorefilter,PgScorecardStore.runIdsjsonb) round-trips on real PG. - web
next buildgreen. Legacy swept: stale post-S2 comment fixed,resolveRepoTokenmade module-internal. - Nothing open.
materializeRun/BatchDriverare documented non-goals (DRY gold-plating), not TODOs.