Harness as Code Architecture Hardening: Sandbox, Grammar, Boundaries, Evidence
Traceability
- Spec ID:
SPEC-2026-08-15-harness-as-code-architecture-hardening - Story: none (review-driven follow-up on
feat/harness-as-code-package) - Status: Implemented
Intent
The source-level review of feat/harness-as-code-package raised four High findings beyond the four merge blockers. The blockers are closed by Harness Execution Closure, which explicitly deferred these four items as non-goals:
- Execution boundary — the compare pipeline claims "network deny", but only the Quick Start example and export probe run under
node --permissionwith a scrubbed environment; thenpm testvalidation step inherits the full hostprocess.envwith no permission flags. - Adapter facts in the core DSL —
binding strength(other thanunsupported) andruntime executionclaims are author declarations about adapter implementation facts; today they are silently ignored or trusted. - Package boundary — the main
@qoder-ai/harnessentry re-exports exec/compare/highlight, so a consumer that only parses.harnessmust install the Qoder SDK and Shiki;harness-uiimports concrete executors. - Second evidence platform —
HarnessRevision/ receipts / run traces / compare verdicts have no bridge to the existingHarnessComponentSnapshotV1/NormalizedToolActivityV1/ Inspector chain.
The outcome of this spec: safety claims become recorded evidence rather than prose, the DSL cannot state facts it does not own, the dependency direction core ← adapters ← devtools is enforced by tests, and harness runs land in the same evidence chain the Inspector already reads.
Non-goals
- Publishing new npm packages (
@qoder-ai/harness-core,adapter-qoder, …). This spec fixes the dependency direction inside the existing package; physical package splitting is a later, mechanical step. - A container/VM sandbox implementation. This spec defines the
TrialSandboxseam and ships the trusted-fixture policy; an isolated-container policy is a separate deliverable behind the same interface. - Changing verdict statistics (matched pairs,
insufficient_evidence, cost-per-trial) — already implemented by the execution-closure spec. - Making Qoder/Pi adapters materialize MCP, multi-session agents, or programmatic workflows.
- Migrating existing Inspector reports or session-analysis storage formats.
- A
HarnessLockartifact between IR and Revision (still deferred).
High 1 — Execution boundary: TrialSandbox and SandboxReceipt
Problem
packages/harness/src/compare/grader.ts hardens the Quick Start example and the export probe (node --permission --allow-fs-read=<trialRoot> plus safeExampleEnvironment(), a 7-key env allowlist), but the package-tests check calls runCommand(npmTest.command, …) with no env option, and runCommand in packages/harness/src/compare/process.ts defaults to env: process.env. Package scripts run with full host credentials, network, and filesystem. The agent-facing allowlist in compare/permissions.ts bounds the tool surface, not the process capability surface. The honest current claim is "bounded tool surface over a trusted fixture", not "network denied".
Design
Promote the execution boundary to a first-class seam owned by the compare runner, not scattered options inside the grader:
Grader / Runner
│ never spawns directly
▼
TrialSandbox (interface)
└── run(command, args, { cwd, timeoutMs }): Promise<CommandResult>
└── describe(): SandboxReceipt
Policies:
trusted-fixture → env allowlist + node --permission where applicable
(future) isolated → disposable container / clean fs + network namespaceSandboxReceipt is persisted into the trial evidence and surfaced in the verdict:
interface SandboxReceipt {
policy: "trusted-fixture" | "isolated";
envPolicy: "allowlist" | "inherited";
envKeys: string[]; // exact keys passed through
networkPolicy: "denied" | "unverified"; // never claim more than enforced
fsScope: "trial-root" | "host";
permissionFlags: string[]; // e.g. ["--permission", "--allow-fs-read=…"]
}Rules:
- The trusted-fixture policy runs every subprocess (including
npm test) with the env allowlist.npm/nodeoperational keys (PATH,TEMP/TMP/TMPDIR,SystemRoot,ComSpec,PATHEXT,HOMEfor npm cache resolution, and explicitnpm_config_*needed for offline install) are the whitelist; everything else — tokens, cloud credentials, proxy settings — is dropped. node --permissionis applied where the command is a directnodeinvocation;npm testcannot carry it (npm spawns arbitrary scripts), so its receipt recordsnetworkPolicy: "unverified"— which is the honest fact.- The verdict gains a
sandboxfield carrying the receipt. Report rendering must label anynetworkPolicy: "unverified"run as "trusted-fixture only — network not denied".README.mdand package README wording is corrected to match. runCommandloses itsprocess.envdefault:envbecomes a required option, so a future call site cannot silently reintroduce inheritance.
Acceptance
- AC1.1 Every subprocess spawned during a compare trial goes through a
TrialSandbox; a directrunCommandcall from grader/runner without an explicitenvfails to compile. - AC1.2 Under the trusted-fixture policy, the env passed to
npm testcontains only allowlisted keys; a canary variable (e.g.AWS_SECRET_ACCESS_KEY,GITHUB_TOKEN) set in the parent env is absent from the child env. - AC1.3 Each trial directory contains
sandbox-receipt.json; parsing a verdict whose trials lack a sandbox receipt fails schema validation. - AC1.4 The verdict HTML/CLI rendering shows the sandbox policy and labels
networkPolicy: "unverified"runs as trusted-fixture only; no rendered output uses the phrase "network denied" for such runs. - AC1.5 The Quick Start example and export probe keep their existing
node --permissionhardening and now also produce receipt entries withpermissionFlagsrecorded.
High 2 — Remove adapter facts from the core DSL
Problem
The grammar (packages/harness/src/language/harness.langium) lets authors declare binding … \{ mechanism <QualifiedName> strength <Strength> \} and runtime … \{ execution programmatic.<lang> \}. Since the execution-closure work, materializeAgainstAdapter (resolver/resolve.ts) only honors strength unsupported as an author veto; any other declared strength is ignored and the adapter descriptor decides. That makes strength enforced a silent no-op — the worst kind of grammar surface. Similarly, whether a runtime can execute programmatic.deno is an adapter observation, not an authoring decision, yet the resolver trusts the DSL's execution claim (resolve.ts workflow checks).
Ownership model (from the review, already partially realized):
Harness DSL → declares what a run needs (requirements, veto)
Adapter descriptor → declares what a runtime can provide (facts)
Receipt → records what this run actually got (observations)Design
- Binding becomes veto-only. Grammar keeps
binding <capability> for <runtimes> \{ unsupported? notes? \}.strengthandmechanismare removed from the binding rule. During a deprecation window the validator reports a compile error (not a silent drop) forstrength/mechanismwith a fix-it message pointing at the adapter descriptor; the tokens are removed from the grammar in the following minor version. executionmoves to the descriptor.AdapterRealizationDescriptor(resolver/adapter-descriptor.ts) gainsprogrammaticLanguages: readonly string[]alongsideworkflowModes. The resolver's programmatic-workflow gate reads the descriptor, notruntime.execution. The DSL keeps only the requirement side: a workflow may declareprogram deno "./loop.ts", and resolution fails closed when no descriptor supports that language (behavior already tested for unsupported adapters; the source of truth moves).- Descriptor registry. A pure-data registry (
resolver/adapter-registry.ts) maps adapter package id → descriptor, loadable without importing any host SDK.QoderSdkAdapter.describe()/PiSdkAdapter.describe()return the same frozen descriptor objects the registry ships, anddoStartasserts registry/instance descriptor equality so the two cannot drift. RuntimeDeclaration.adapterstays: choosing which adapter package to bind is a deployment decision the author legitimately owns.
Affected v0.2 example files and skills/ DSL snippets are migrated in the same change.
Acceptance
- AC2.1 Compiling a
.harnesssource containingstrength wiredorstrength enforcedin a binding produces a compile error whose message names the adapter descriptor as the owner of realization strength. - AC2.2
binding <cap> for <rt> \{ unsupported \}still vetoes the capability on that runtime (existing behavior preserved; regression-tested). - AC2.3 A programmatic workflow resolves only when the registry descriptor for the target adapter lists the language in
programmaticLanguages; a DSLruntime … executionclaim alone can no longer satisfy the gate. During the deprecation window,executionin a runtime block compiles with a deprecation diagnostic and has no resolution effect. - AC2.4
resolveHarnesscan produce a revision for the built-in Qoder and Pi targets in a process that has never loaded@qoder-ai/qoder-agent-sdkor@earendil-works/pi-coding-agent(registry is pure data). - AC2.5 For each shipped adapter, the registry descriptor deep-equals the live
describe()output (drift test), anddoStartfails closed on mismatch.
High 3 — Enforce the core ← adapters ← devtools dependency direction
Problem
compiler/, resolver/, ir/ already avoid host SDK imports, and package.json already exposes ./ir, ./exec, ./compare, ./compare/verdict, ./highlight subpaths. Four leaks defeat the layering:
src/index.tsre-exports./exec/index.js,./compare/index.js, and./highlight/shiki.js, so the main entry's module graph pulls in the Qoder SDK,node:child_process, and Shiki.@qoder-ai/qoder-agent-sdkandshikiare harddependencies.packages/harness-ui/src/run.tsimportsQoderSdkExecutor/PiSdkExecutoras values from the main entry, hard-binding the AG-UI layer to both hosts.- Adapter descriptors live in the same modules as adapter implementations (resolved by High 2's registry).
resolver/source-lock.tsimportsnode:fs/promises, so the core entry is not browser-safe.
Design
Fix the direction inside the existing package; do not split npm packages yet.
"." core only: grammar, compile, IR, revision, canonical hash,
resolver, adapter-descriptor types + registry (browser-safe)
"./lock" source locking (node:fs) [moved out of core]
"./exec" adapter contract + Qoder/Pi adapters (host SDKs via dynamic import)
"./compare" runner, grader, sandbox, verdict (node:child_process)
"./compare/verdict" verdict parsing only (pure, browser-safe — Studio reads it)
"./highlight" shikisrc/index.tsdrops theexec/compare/highlightre-exports and thesource-lockvalue exports; consumers use subpaths../lockis added as a new subpath export forlockCapabilitySources/verifyRevisionSourceLocks.@qoder-ai/qoder-agent-sdkmoves fromdependenciesto an optional peerDependency (mirroring the existing Pi arrangement);exec/qoder-sdk.tsalready loads it by module id at runtime.shikistays a dependency but is reachable only via./highlight.harness-uibecomes injection-only: it keeps the existingHarnessUiExecutorFactoryseam and stops importing concrete executors; the built-in Qoder/Pi factory moves toharness-studio's server (which already owns host wiring) and to theharness-uiCLI entry, both of which import@qoder-ai/harness/execexplicitly.- An import-graph invariant test (see Test Evidence) makes the direction survivable. Per the repository test convention it must assert on the resolved module graph of the built entry points, not grep source text.
Acceptance
- AC3.1 Resolving the module graph of the built
.entry (dist/index.js) reaches no module fromdist/exec/,dist/compare/(except nothing),dist/highlight/,shiki,@qoder-ai/qoder-agent-sdk,@earendil-works/pi-coding-agent,node:child_process, ornode:fs. - AC3.2 The graph of
./compare/verdictis equally pure (Studio can bundle it for the browser). - AC3.3
npm installof@qoder-ai/harnesswithout the Qoder SDK peer succeeds, andcompileHarness+resolveHarnesswork; loading./execand constructing a Qoder adapter without the peer fails with the existing actionable module-load error. - AC3.4 The module graph of
@qoder-ai/harness-ui's main entry contains no concrete executor module;handleAguiRunrequires an injectedHarnessUiExecutorFactoryand its tests pass with a fake factory only. - AC3.5
harness-studiocontinues to run end-to-end (its server provides the factory), verified by the existing studio Playwright suite.
High 4 — One evidence chain: bridge runs into the Inspector platform
Problem
Two disjoint evidence platforms exist. Existing chain (canonical owners per ADR-0002 federation): HarnessComponentSnapshotV1 (scripts/harness-component-snapshot/), NormalizedToolActivityV1 + report model (scripts/harness-inspector/), session platform adapters (scripts/session-analysis/platforms/\{claude,codex,cursor,qoder\}.mjs), commit-session-link. New chain: HarnessRevision, materialization receipt, HarnessRunEvent, per-trial trace.jsonl / runtime-receipt.json, compare verdict.json, Studio. No bridge code exists in either direction, and a compare evidence directory is not self-contained (trials record revisionId but the directory holds no revision or resolution report).
Design
Converge at the persistence layer; do not merge schemas.
HarnessRevision + MaterializationReceipt (config provenance; owner: packages/harness)
↓ run
HarnessRunEvent (live streaming only; owner: packages/harness)
↓ persisted as
trace.jsonl ──ingested by──▶ NormalizedToolActivityV1 (owner: session-analysis/inspector)
↓
Inspector / Controlled Eval reports- Self-contained evidence directory. The compare runner writes
revision.json(the full deep-frozen revision),resolution-report.json, andmaterialization-receipt.jsonat the variant level; each trial keepstrace.jsonl,runtime-receipt.json,sandbox-receipt.json(High 1), andpermission-decisions.json.parseHarnessCompareVerdictgains a directory-level validation mode that fails when the revision file is absent or itsrevisionIddisagrees with the trials. - New platform adapter.
scripts/session-analysis/platforms/harness-run.mjsingests a harness evidence directory the same wayclaude.mjsingests Claude session files: it mapstrace.jsonlevents toNormalizedToolActivityV1(tool calls, families, segments, timeline) and exposes the run as one more provider. The Inspector then reports harness runs with zero new report surface. - Cross-reference, not schema merge.
HarnessRevisiongains an optionalcomponentSnapshotRef(snapshot id + digest) andHarnessComponentSnapshotV1consumers may recordrevisionIdin their population references. The snapshot answers "what exists in the project"; the revision answers "what this run was configured with". - Studio positioning. Studio remains the authoring/run workspace; live views consume
HarnessRunEventover SSE, historical views read the persisted evidence directory. Studio grows no second persistence format. - A short ADR under
docs/adrs/records the canonical owners of the two contracts and the bridge direction, extending the ADR-0002 federation, so a third platform does not appear later.
Acceptance
- AC4.1 After a compare run, the evidence directory contains
revision.json,resolution-report.json, andmaterialization-receipt.jsonper variant; directory-level verdict validation fails on a missing or mismatchedrevision.json. - AC4.2 The
harness-runplatform adapter converts a fixturetrace.jsonlinto aNormalizedToolActivityV1whose call counts, tool families, and timeline match the fixture events (asserted on the parsed structure, not on rendered text). - AC4.3 An Inspector report generated over a workspace containing a harness evidence directory lists the run as a session from provider
harness-run, linked to therevisionId. - AC4.4 A revision carrying
componentSnapshotRefround-trips through canonical hashing and revision-integrity checks (the ref is part of the hashed body). - AC4.5 The ADR exists, names both contracts, their owners, and the bridge direction, and is reachable from
docs/adrs/README.md.
Plan and Tasks
Order chosen so grammar breaks land early and evidence contracts freeze last:
- High 3 first (mechanical, unblocks High 2):
- move source-lock exports to
./lock; trimsrc/index.ts; add subpath; demote Qoder SDK to optional peer; adjustharness-uito injection-only; move built-in factory to studio server +harness-uiCLI. - add the import-graph invariant test.
- files:
packages/harness/src/index.ts,packages/harness/package.json,packages/harness-ui/src/run.ts,packages/harness-ui/src/cli.ts,packages/harness-studio/src/server/server.ts.
- move source-lock exports to
- High 2 (breaking grammar change, cheapest now):
- grammar: veto-only binding, deprecation diagnostics for
strength/mechanism/execution; regenerate Langium artifacts (npm run langium:generate,check:generatedstays green). programmaticLanguageson the descriptor; registry module; drift test; migrate examples andskills/snippets.- files:
packages/harness/src/language/harness.langium,language/harness-validator.ts,resolver/adapter-descriptor.ts,resolver/adapter-registry.ts(new),resolver/resolve.ts,exec/qoder-sdk.ts,exec/pi-sdk.ts,examples/,skills/.
- grammar: veto-only binding, deprecation diagnostics for
- High 1 (verdict schema addition — before evidence freeze):
TrialSandboxinterface + trusted-fixture policy; makeenvrequired onrunCommand; thread sandbox through grader/runner;sandbox-receipt.json; verdict schema + rendering labels; README wording fix.- files:
packages/harness/src/compare/sandbox.ts(new),process.ts,grader.ts,runner.ts,verdict.ts,aggregate.ts, report templates,packages/harness/README.md, rootREADME.md(compare claims paragraph).
- High 4 last (consumes contracts from 1–3):
- self-contained evidence directory + directory validation;
harness-runplatform adapter;componentSnapshotRef; Studio historical view reads the directory; ADR. - files:
packages/harness/src/compare/runner.ts,verdict.ts,packages/harness/src/ir/index.ts+ir/revision.ts,scripts/session-analysis/platforms/harness-run.mjs(new),scripts/harness-inspector/(provider registration),docs/adrs/(new ADR),docs/adrs/README.md.
- self-contained evidence directory + directory validation;
Each step remains independently reviewable so the cross-platform Node matrix, package tests, and the Studio browser suite can serve as merge evidence.
Decision rationale:
- Dependency direction before grammar work: the registry (High 2) must live in a core that provably does not load host SDKs, so the graph invariant comes first.
- Deprecation-diagnostic window instead of instant token removal: existing
.harnesssources in examples, skills, and early adopters get one actionable compile error before the tokens disappear; silent acceptance is the failure mode being removed, so the diagnostic is an error, not a warning. - Bridge at
NormalizedToolActivityV1rather than a new shared schema: the Inspector's platform-adapter seam already absorbs four providers; adding a fifth is the lowest-cost, lowest-risk convergence and keeps ADR-0002 federation intact.
Test and Review Evidence
Repository conventions apply: assert on behavior and parsed structures, never on source text patterns; no repo-wide literal scans.
| AC | Evidence |
|---|---|
| AC1.1 | TypeScript compile: env required on runCommand; grader/runner call-site test with a fake sandbox recording every spawn |
| AC1.2 | Trial test sets GITHUB_TOKEN in parent env, asserts child env (captured by fake sandbox / echo probe) lacks it and matches the allowlist exactly |
| AC1.3 | Verdict schema test: verdict without sandbox receipts fails parseHarnessCompareVerdict |
| AC1.4 | Render test parses the generated report model (not HTML text) and asserts the policy label field for an unverified receipt |
| AC2.1–AC2.3 | Compiler tests: fixture sources with strength wired, veto binding, programmatic workflow with/without registry support; assert diagnostics and resolution results |
| AC2.4 | Test spawns a child Node process that imports only the core entry, resolves the Qoder target, and asserts require.cache/loaded-modules contains no host SDK |
| AC2.5 | Deep-equal test between registry descriptor and describe() per adapter |
| AC3.1–AC3.2 | Import-graph test: resolve the built entry's ESM graph (e.g. node --experimental-import-meta-resolve walker or es-module-lexer walk over dist/) and assert the forbidden-module set is unreachable |
| AC3.3 | CI job (or test with temporarily hidden peer) exercising compile+resolve without the Qoder SDK installed |
| AC3.4 | harness-ui tests run with fake factory only; module-graph assertion on its entry |
| AC3.5 | Existing packages/harness-studio Playwright suite |
| AC4.1 | Runner integration test over a fake executor; assert directory contents and directory-level validation failure on deleted revision.json |
| AC4.2 | Fixture trace.jsonl → adapter → assert NormalizedToolActivityV1 fields |
| AC4.3 | Inspector report-model test over a workspace fixture containing a harness evidence directory |
| AC4.4 | Revision canonical-hash round-trip test including componentSnapshotRef |
| AC4.5 | Doc-link test (npx vitest run test/skills-docs/doc-link-graph.test.mjs) after adding the ADR |
Risk notes:
- R1 (High 1): over-tight env allowlist can break
npm teston Windows (SystemRoot,ComSpec,PATHEXT) or on npm cache resolution (HOME,LOCALAPPDATA). Mitigation: allowlist is platform-aware and CI runs the compare suite on the existing four-platform matrix. - R2 (High 2): grammar break invalidates existing
.harnessfiles. Mitigation: deprecation diagnostic with fix-it text; examples and skills migrate in the same commit;check:generatedguards Langium artifacts. - R3 (High 3): demoting the Qoder SDK to an optional peer changes install behavior for current consumers. Mitigation:
./execkeeps the existing actionable module-load error; README installation snippet updated in the same PR. - R4 (High 4):
trace.jsonlevent vocabulary may not cover everythingNormalizedToolActivityV1expects. Mitigation: the adapter maps only what the trace proves and leaves absent fields empty rather than inventing them; gaps are recorded in the adapter's fixture tests. - R5 (cross-cutting, deferred policy decision): whether
npm testvalidation must eventually require the isolated-container policy before a compare verdict may renderaccept. This implementation preserves the specified trusted-fixture behavior and labels network denial as unverified.
Implementation Evidence
npm test: 95 files and 1325 tests passed, including the new harness-run provider and Inspector integration coverage.- Harness packages: build plus 127 harness, 21 harness-ui, and 29 harness-studio tests passed.
npm run harness-studio:test:browser: the Studio Playwright interaction test passed in Chromium.npm run pack:verify: npm and runtime package contents passed verification.- Cold install: the packed
@qoder-ai/harnessinstalled with--omit=peer; core compile and resolve succeeded without the Qoder SDK. npm run preview:/healthand/canvas-module.jsboth returned successfully fromhttp://127.0.0.1:58575.npx vitest run test/skills-docs/doc-link-graph.test.mjs, generated Langium checks, module-graph invariants, andgit diff --checkpassed.