Harness Execution Closure: Revision, Materialization, Session, Evidence
Traceability
- Spec ID:
SPEC-2026-08-15-harness-execution-closure - Story: none (review-driven blocker fix on
feat/harness-as-code-package) - Status: Implemented
Intent
A source-level review of feat/harness-as-code-package accepted the v0.2 resource model but blocked the merge on four contracts that were not closed between Authored Harness → Locked Revision → Runtime Materialization → Execution Evidence.
This spec covers only those four blockers. The reviewer's larger restructuring asks — a separate HarnessLock artifact, splitting @qoder-ai/harness into core/adapters/devtools packages, moving binding strength out of the core DSL, and bridging HarnessRevision to HarnessComponentSnapshotV1 — are explicit non-goals here.
Non-goals
- Introducing a standalone
HarnessLockartifact between IR and Revision. - Splitting the package boundary along runtime dependencies.
- Removing
binding strength/runtime executionclaims from the core DSL grammar. - Making Qoder or Pi materialize MCP connections, multi-session agents, or programmatic workflows.
- Unifying Studio/Compare evidence with the existing Inspector normalized trace.
- Hardening the
harness-uibind address (the review's separate High item on non-loopback serving requiring explicit auth or an unsafe flag).
Blocker 1 — HarnessRevision must be an execution closure
Problem: execute(revision, bundle, task) re-reads workflows and capabilities from whatever bundle the caller passes, without checking them against the hashes recorded in the revision. A revision resolved from bundle A could drive execution content from bundle B while still reporting A's revisionId. The revision was also a plain mutable object, assertRevisionHost ignored target.adapter, and capability source paths were never content-locked.
Acceptance
- AC1.1
validateRevisionAgainstBundle(revision, bundle)recomputes the harness, workflow, and every resolved capability content hash from the bundle and throwsHarnessRevisionBundleMismatchErroron any drift, naming the drifted entities. - AC1.2
assertRevisionIntegrity(revision)recomputesrevisionIdfrom the revision body and throwsHarnessRevisionTamperedErrorwhen a field was mutated after resolution. - AC1.3
resolveHarnessreturns a deep-frozen revision; a mutation attempt does not change the observed value (and throws in strict mode). - AC1.4
assertRevisionAdapter(revision, adapterId)throwsHarnessAdapterMismatchErrorwhenrevision.target.adapternames a different adapter package. - AC1.5
doStarton both shipped adapters runs host, adapter, integrity, and bundle validation before the host SDK is loaded. - AC1.6 Source locking:
lockCapabilitySources(bundle, \{ root \})digests each declaredskill.sourcefile or directory tree; a source-backed revision is verified atdoStartand fails closed when the on-disk content drifted. - AC1.7 A revision cannot resolve while a resolved source-backed capability lacks exactly one matching source lock. Locks digest raw bytes and are verified against an explicit source root, independent from the task working directory.
- AC1.8 Every public materializer runs revision integrity, adapter, bundle, and source-lock preflight before writing output; a Pi package cannot carry bundle B content under bundle A's
revisionId. - AC1.9 The revision locks the adapter contract version, implementation version, and descriptor hash; execution fails before SDK load when any of those facts drift.
Blocker 2 — Declared run semantics must not exceed adapter materialization
Problem: materializeV02() mapped every non-unsupported binding to advisory / prompt-preamble, so require tool, connect mcp, and programmatic workflows all resolved successfully while the executor only emitted a prompt line — and Qoder/Pi passed no tools at all.
Acceptance
- AC2.1 Materialization is capability-kind aware and adapter-owned. An
AdapterRealizationDescriptordeclares skill delivery, the DSL-tool → host-tool exposure map, MCP support, drivable workflow modes, agent isolation, and consumed setting keys. - AC2.2 A
toolrequirement is never satisfied by prompt guidance. Without descriptor-declared exposure it realizesunsupportedand resolution fails; with exposure it realizeswiredthrough mechanismhost-tool:<name>. - AC2.3
connect mcpfails closed: no shipped adapter opens MCP connections in v0.2. - AC2.4 A programmatic workflow fails resolution against a descriptor that can only drive declarative workflows, instead of resolving into a silent no-op.
- AC2.5 The Qoder adapter really exposes the host tools its revision requires (visible
toolsplus the run-time receipt), so an exposed tool requirement has a runtime effect. - AC2.6 A multi-agent harness on a single-session adapter is recorded as an explicit workflow degradation with a run warning, not as satisfied orchestration.
- AC2.7
revision.permissionsis renamedrevision.requestedPermissions; aHarnessMaterializationReceiptrecords, per capability,materialized | degraded | unsupportedplus mechanism, and separates requested from enforced permissions and consumed from ignored settings. Executors attach the receipt toHarnessRunResult.materialization. - AC2.8 Public validators resolve against the selected built-in adapter descriptor. Published examples target only adapters that can realize their required capabilities.
Blocker 3 — Qoder adapter must be a real multi-turn session
Problem: every doPromptTurn called sdk.query() again with a fresh string prompt and no resume/session_id, so persistSession: true only suppressed the preamble; there was no proof turn 2 saw turn 1. There was also no in-flight turn mutex, doStop/doDestroy never closed or interrupted the query, and runOnce skipped doDestroy when doStop failed with a non-capability error.
Acceptance
- AC3.1
doStartopens exactly onequery(\{ prompt: AsyncIterable<SDKUserMessage> \}); each turn pushes a user message onto that queue and drains the shared stream to itsresultmessage. - AC3.2 A fake SDK that models the official Query lifecycle proves turn 2 can answer with information supplied only in turn 1, with a single
query()call for the whole session. - AC3.3 Concurrent
doPromptTurncalls are rejected withHarnessConcurrentTurnError; the contract's "turns are sequential" claim is enforced, not documented. - AC3.4
doStopends the queue and awaitsquery.close();doDestroyinterrupts best-effort and then closes. A per-turnabortSignalcallsquery.interrupt(). - AC3.5 The session exposes the host
sessionId; a turn that arrives after the host terminated the query reopens it withresume: sessionIdand is delivered once, instead of being reported as a turn nobody ran or replaying a fresh context. - AC3.6
runOncedestroys the session best-effort whendoStopthrows any error. - AC3.7 The Qoder session does not impose a one-turn default on the whole query. Tests assert that an omitted
maxTurnsstays unbounded while explicit limits remain visible in the runtime receipt.
Blocker 4 — Compare must not accept on insufficient evidence
Problem: decideVerdict() accepted on any pass-rate or 5-point score improvement with no minimum sample size, compared two independent aggregates instead of paired trials, excluded infrastructure errors from the denominator (so one lucky completed trial could beat 20 real ones), compared total cost across unequal completed counts, allowed a manifest to move harness and runtime profile at once, and re-validated persisted verdicts without recomputing aggregates from trial rows.
Acceptance
- AC4.1
CompareStatusgainsinsufficient_evidence. - AC4.2 A manifest must move exactly one treatment axis (harness or runtime profile); moving both is rejected at load. The verdict records the axis.
- AC4.3 Verdicts are decided on matched pairs (same trial index, both variants completed). Fewer than the required pairs →
insufficient_evidence. A single matched pair can neveraccept, whatever the configured policy. - AC4.4 An infrastructure error ratio above the policy threshold →
infrastructure_error. - AC4.5 Aggregates report
costPerAttemptedTrialUsd,costPerCompletedTrialUsd, andcostPerPassedTrialUsd; the cost guardrail compares cost per completed trial. - AC4.6
parseHarnessCompareVerdict()recomputescompletedTrials,passedTrials,infrastructureErrors,passRate,meanScore,totalCostUsd,totalCredits, the per-trial cost fields, and the matched-pair counts from the trial rows, and rejects any hand-edited aggregate that disagrees. - AC4.7 Persisted
statusandreasonare also recomputed from the validated policy and trial rows; a two-pair result cannot be relabelledacceptunder a five-pair policy.
Plan
src/ir/revision.ts— integrity, bundle validation, deep freeze, adapter assertion.src/resolver/source-lock.ts— mandatory capability source digests and explicit-root verification for source-backed revisions.src/resolver/adapter-descriptor.ts— adapter realization facts and the shipped descriptors.src/resolver/resolve.ts— kind-aware materialization,requestedPermissions, receipt input.ResolveOptions.adapteralso accepts a lookup keyed by the selected runtime, for callers that let the bundle choose its target;src/exec/built-in.tsexposesdescribeBuiltInAdapter.src/exec/*— descriptor-driven preflight, materialization receipt, Qoder Query session, turn mutex,runOnceteardown.src/compare/*— treatment axis, decision policy, paired verdict, recomputed parse.packages/harness-uiresolves against the descriptor of the adapter that will run the revision;packages/harness-studiorenders the treatment axis, matched-pair evidence with its threshold, and cost per completed trial, and stylesinsufficient_evidence.
Test evidence
The four tests the review asked for first, each failing before the change:
refuses execution against a bundle the revision was not resolved from(packages/harness/test/revision.test.ts).detects a mutated revision before the host SDK loads(packages/harness/test/revision.test.ts).answers the second turn from first-turn context inside one query(packages/harness/test/adapter.test.ts).reports insufficient_evidence when a single matched pair is all the evidence there is(packages/harness/test/compare.test.ts).
Follow-ups also covered: a programmatic workflow fails resolution on a declarative-only adapter and a tool requirement cannot be satisfied by prompt-only realization (packages/harness/test/resolve.test.ts); missing/incomplete locks, raw-byte drift, bundle swaps at the public Pi materializer, adapter contract drift, and locked-source drift all fail closed before SDK load or package output (packages/harness/test/revision.test.ts, packages/harness/test/resolve.test.ts); the Qoder adapter refuses a tool exposure it cannot reproduce (packages/harness/test/exec.test.ts); concurrent turns, stop/destroy close and interrupt, resume after a terminated query, an unbounded omitted maxTurns, and a non-capability stop failure still tearing the session down (packages/harness/test/adapter.test.ts); and a persisted verdict whose aggregate, status, reason, or policy the trial rows do not support (packages/harness/test/compare.test.ts, packages/harness-studio/test/compare-model.test.ts).
Verified on 2026-08-15:
- Harness focused tests: 101 passed; full Harness tests: 120 passed; Harness build and typecheck passed.
- Harness UI: 20 passed; Harness Studio: 29 passed; repository suite: 94 files / 1,324 tests passed; doc-link graph: 6 passed and regenerated without stale output.
- Root preview returned
okfrom/healthand served a 100,449-byte/canvas-module.js. - Harness Studio production build and Playwright browser test passed. The Compare fixture was also opened at 1440x1000: the derived verdict, two matched pairs, cost guardrail, and trial rows rendered with zero console or page errors.
- A live Qoder SDK smoke kept the nonce
cobalt-7319across twodoPromptTurn()calls in one adapter session and exposed one host session id.
Risk
- Resolution now fails closed for
require toolon adapters without exposure and forconnect mcp. Harnesses that previously resolved into a prompt-only no-op now need an adapter that really exposes the capability. This is the intended semantic correction. revision.permissions→revision.requestedPermissionsis a breaking IR field rename inside the unreleased v0.2 surface.