@agent-delivery-harness/conformance 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +159 -0
  4. package/fixtures/repo-config-adapter.ts +250 -0
  5. package/package.json +24 -0
  6. package/src/generate.ts +1013 -0
  7. package/src/index.ts +36 -0
  8. package/src/json-schema.ts +222 -0
  9. package/src/runner.ts +687 -0
  10. package/vectors/context/environment.json +21 -0
  11. package/vectors/context/repo-config.json +22 -0
  12. package/vectors/kit.json +1250 -0
  13. package/vectors/vectors/accept/a-completed-order-differs.json +112 -0
  14. package/vectors/vectors/accept/a-cost-fractional.json +116 -0
  15. package/vectors/vectors/accept/a-cost-tokens.json +120 -0
  16. package/vectors/vectors/accept/a-deferred-expansion.json +124 -0
  17. package/vectors/vectors/accept/a-idempotent-resubmission.json +115 -0
  18. package/vectors/vectors/accept/a-minimal-green.json +97 -0
  19. package/vectors/vectors/accept/a-resolved-and-advisory.json +129 -0
  20. package/vectors/vectors/accept/a-three-reviewers.json +113 -0
  21. package/vectors/vectors/reject/env-1-unregistered-provider.json +114 -0
  22. package/vectors/vectors/reject/env-10-artifact-duplicate.json +103 -0
  23. package/vectors/vectors/reject/env-10-artifact-missing-file.json +104 -0
  24. package/vectors/vectors/reject/env-10-artifact-traversal.json +99 -0
  25. package/vectors/vectors/reject/env-11-artifact-digest-mismatch.json +98 -0
  26. package/vectors/vectors/reject/env-12-unknown-attestation-level.json +114 -0
  27. package/vectors/vectors/reject/env-13-nonempty-signatures.json +118 -0
  28. package/vectors/vectors/reject/env-14-duplicate-claim.json +149 -0
  29. package/vectors/vectors/reject/env-14-no-claims.json +78 -0
  30. package/vectors/vectors/reject/env-14-unconfigured-obligation.json +114 -0
  31. package/vectors/vectors/reject/env-14-unsupported-payload-spec.json +114 -0
  32. package/vectors/vectors/reject/env-2-run-id-1.json +114 -0
  33. package/vectors/vectors/reject/env-2-run-id-2.json +114 -0
  34. package/vectors/vectors/reject/env-2-run-id-3.json +114 -0
  35. package/vectors/vectors/reject/env-2-run-id-4.json +114 -0
  36. package/vectors/vectors/reject/env-2-run-id-5.json +114 -0
  37. package/vectors/vectors/reject/env-4-unsupported-vcs.json +114 -0
  38. package/vectors/vectors/reject/env-5-malformed-object-id.json +114 -0
  39. package/vectors/vectors/reject/env-6-missing-identity.json +111 -0
  40. package/vectors/vectors/reject/env-6-unknown-identity-version.json +114 -0
  41. package/vectors/vectors/reject/env-8-repository-required.json +116 -0
  42. package/vectors/vectors/reject/env-9-empty-run-history.json +107 -0
  43. package/vectors/vectors/reject/env-9-final-pass-mismatch.json +110 -0
  44. package/vectors/vectors/reject/env-9-final-tree-mismatch.json +110 -0
  45. package/vectors/vectors/reject/gen-1-legacy-count-field.json +115 -0
  46. package/vectors/vectors/reject/gen-1-unknown-envelope-member.json +117 -0
  47. package/vectors/vectors/reject/gen-2-unsupported-spec.json +114 -0
  48. package/vectors/vectors/reject/rg-1-edited-after-final-pass.json +114 -0
  49. package/vectors/vectors/reject/rg-1-not-finalized.json +114 -0
  50. package/vectors/vectors/reject/rg-1-verdict-not-green.json +114 -0
  51. package/vectors/vectors/reject/rg-10-cost-by-reviewer-exceeds-total.json +115 -0
  52. package/vectors/vectors/reject/rg-10-cost-empty-reported-by.json +111 -0
  53. package/vectors/vectors/reject/rg-10-cost-empty-unit.json +111 -0
  54. package/vectors/vectors/reject/rg-10-cost-missing-unit.json +110 -0
  55. package/vectors/vectors/reject/rg-10-cost-negative-by-reviewer.json +114 -0
  56. package/vectors/vectors/reject/rg-10-cost-negative-total.json +111 -0
  57. package/vectors/vectors/reject/rg-10-cost-null-total.json +111 -0
  58. package/vectors/vectors/reject/rg-10-cost-unknown-reviewer-in-breakdown.json +114 -0
  59. package/vectors/vectors/reject/rg-2-duplicate-selected.json +99 -0
  60. package/vectors/vectors/reject/rg-3-completed-missing.json +96 -0
  61. package/vectors/vectors/reject/rg-3-completed-unrelated.json +99 -0
  62. package/vectors/vectors/reject/rg-3-failed-reviewer.json +100 -0
  63. package/vectors/vectors/reject/rg-3-timed-out-reviewer.json +100 -0
  64. package/vectors/vectors/reject/rg-4-approval-extra.json +104 -0
  65. package/vectors/vectors/reject/rg-4-approval-malformed.json +98 -0
  66. package/vectors/vectors/reject/rg-4-approval-missing.json +100 -0
  67. package/vectors/vectors/reject/rg-4-approval-not-approved.json +98 -0
  68. package/vectors/vectors/reject/rg-4-approval-wrong-candidate.json +98 -0
  69. package/vectors/vectors/reject/rg-4-approval-wrong-pass.json +98 -0
  70. package/vectors/vectors/reject/rg-4-approval-wrong-provider.json +98 -0
  71. package/vectors/vectors/reject/rg-4-approval-wrong-reviewer.json +98 -0
  72. package/vectors/vectors/reject/rg-4-approval-wrong-run.json +98 -0
  73. package/vectors/vectors/reject/rg-4-approval-wrong-workspace.json +98 -0
  74. package/vectors/vectors/reject/rg-5-duplicate-finding-id.json +131 -0
  75. package/vectors/vectors/reject/rg-5-unknown-disposition.json +123 -0
  76. package/vectors/vectors/reject/rg-5-unknown-scope.json +123 -0
  77. package/vectors/vectors/reject/rg-5-unknown-severity.json +123 -0
  78. package/vectors/vectors/reject/rg-6-actionable-ignored.json +123 -0
  79. package/vectors/vectors/reject/rg-6-actionable-unresolved.json +123 -0
  80. package/vectors/vectors/reject/rg-6-blocking-finding.json +123 -0
  81. package/vectors/vectors/reject/rg-7-defer-adjacent.json +126 -0
  82. package/vectors/vectors/reject/rg-7-defer-blocking.json +126 -0
  83. package/vectors/vectors/reject/rg-7-defer-in-contract.json +126 -0
  84. package/vectors/vectors/reject/rg-7-defer-lowercase-issue-id.json +126 -0
  85. package/vectors/vectors/reject/rg-7-defer-missing-issue-id.json +125 -0
  86. package/vectors/vectors/reject/rg-7-defer-non-actionable.json +126 -0
  87. package/vectors/vectors/reject/rg-7-defer-p0.json +126 -0
  88. package/vectors/vectors/reject/rg-7-defer-p1.json +126 -0
  89. package/vectors/vectors/reject/rg-7-defer-placeholder-issue-id.json +126 -0
  90. package/vectors/vectors/reject/rg-7-issue-id-on-resolved.json +124 -0
  91. package/vectors/vectors/reject/rg-8-counts-contradict-findings.json +126 -0
  92. package/vectors/vectors/reject/rg-8-deferral-count-disagrees.json +124 -0
  93. package/vectors/vectors/reject/rg-8-deferral-ids-disagree.json +126 -0
  94. package/vectors/vectors/reject/rg-9-iteration-count-mismatch.json +114 -0
  95. package/vectors/vectors/reject/sub-1-base-tip-moved.json +131 -0
  96. package/vectors/vectors/reject/sub-1-deliverable-changed.json +131 -0
  97. package/vectors/vectors/reject/sub-1-merge-base-moved.json +131 -0
  98. package/vectors/vectors/reject/sub-1-raw-tree-changed.json +131 -0
  99. package/vectors/vectors/reject/sub-2-unprepared.json +117 -0
  100. package/vectors/vectors/reject/sub-3-manifest-outside-run-root.json +117 -0
  101. package/vectors/vectors/reject/sub-4-record-conflict.json +220 -0
package/src/runner.ts ADDED
@@ -0,0 +1,687 @@
1
+ /**
2
+ * The conformance kit harness.
3
+ *
4
+ * The kit is the validator's test suite, not an illustration of it: 89 golden
5
+ * vectors, each self-contained, each with an expectation a conforming validator
6
+ * must produce. This module runs them and reports what happened. It decides
7
+ * nothing about the spec — every judgement belongs to the kernel validator, and
8
+ * a vector that fails here is a validator defect or a kit disagreement, never
9
+ * something to soften.
10
+ *
11
+ * UNIT MODE. The validator is pure: it judges a manifest against a repository
12
+ * configuration and the candidate observation the caller supplies. Five vectors
13
+ * expect outcomes that belong to the recorder's surface — the run root it
14
+ * allocates, the record store a second submission collides with, and the
15
+ * artifact bytes it verifies through its fs port. That is a scope boundary, not
16
+ * a limit of what a manifest expresses, and integration mode is where those five
17
+ * are covered. In unit mode they are enumerated by name in
18
+ * `RECORDER_DEPENDENT_VECTORS` and skipped, loudly: a name that has vanished
19
+ * from the kit fails the run rather than quietly reducing coverage, which is the
20
+ * failure mode an unnamed skip list has.
21
+ *
22
+ * INTEGRATION MODE. The kit README's "Running a vector" protocol, performed for
23
+ * real: a run root is allocated for `provider.runId`, every artifact is
24
+ * materialized at its path with the vector's exact bytes, the manifest is placed
25
+ * inside that root, and the whole thing is submitted through the recorder. All
26
+ * 89 vectors are decided — nothing is skipped — and the assertions are wider
27
+ * than unit mode's, because there is now state to check. An accepted submission
28
+ * must have written exactly one record per claim, all stamped with the manifest
29
+ * digest; a rejected one must have written none at all (GEN-3). The two
30
+ * multi-step vectors get the extra protocol their `extra` member declares.
31
+ *
32
+ * WHAT INTEGRATION MODE STUBS, AND WHAT IT DOES NOT. Candidate capture is a
33
+ * port, and the vectors declare what it returns — that is the one substitution,
34
+ * and it is the one the kit's `environment.currentCandidate` exists to make.
35
+ * Everything else is the real thing: a real preparation receipt published into a
36
+ * real store, real bytes in a real run root, real records linked into place by
37
+ * the record store's own atomic publication.
38
+ *
39
+ * CONFIGURATION IS A PARAMETER. The kit ships its own repository configuration
40
+ * and the vectors are bound to it, so it is the default — but it arrives through
41
+ * `options.config`, never through an import inside a rule. That is what lets the
42
+ * same run be re-driven under the kit-variant configuration to show the
43
+ * validator's outcomes follow the spec and the vectors rather than one config's
44
+ * incidental values.
45
+ *
46
+ * EXPECTATION SEMANTICS (kit README). An accept vector must produce no codes at
47
+ * all. A reject vector must produce **every** code it lists — the listed codes
48
+ * are a floor, not a ceiling, because several vectors violate more than one rule
49
+ * by construction and SUB-5 requires reporting all violations. A code the
50
+ * registry does not know is a failure of this harness, not a tolerated extra:
51
+ * an unregistered code means the validator invented vocabulary.
52
+ */
53
+ import { readFileSync } from "node:fs";
54
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
55
+ import { tmpdir } from "node:os";
56
+ import path from "node:path";
57
+ import { fileURLToPath } from "node:url";
58
+ import {
59
+ createArtifactsPort,
60
+ isManifestRejectionCode,
61
+ publishPreparationReceipt,
62
+ submitManifest,
63
+ validateManifest,
64
+ type CandidateCapture,
65
+ type CapturedCandidate,
66
+ type HarnessConfig,
67
+ type SubmissionOutcome,
68
+ } from "@agent-delivery-harness/kernel";
69
+ import { loadKitRepoConfig } from "../fixtures/repo-config-adapter.ts";
70
+
71
+ // ── The kit's own shapes ───────────────────────────────────────────────────
72
+
73
+ export interface KitExpectation {
74
+ readonly result: "accepted" | "rejected";
75
+ readonly codes?: readonly string[];
76
+ readonly notes?: string;
77
+ }
78
+
79
+ export interface KitVector {
80
+ readonly vectorVersion: number;
81
+ readonly id: string;
82
+ readonly title: string;
83
+ readonly rules: readonly string[];
84
+ readonly provenance: string;
85
+ readonly expect: KitExpectation;
86
+ readonly environment?: Readonly<Record<string, unknown>>;
87
+ readonly extra?: Readonly<Record<string, unknown>>;
88
+ readonly artifacts: Readonly<Record<string, string>>;
89
+ readonly manifest: unknown;
90
+ }
91
+
92
+ export interface KitIndexEntry {
93
+ readonly id: string;
94
+ readonly file: string;
95
+ readonly title: string;
96
+ readonly rules: readonly string[];
97
+ readonly expect: KitExpectation;
98
+ }
99
+
100
+ export interface KitIndex {
101
+ readonly kit: string;
102
+ readonly spec: string;
103
+ readonly payloadSpecs: readonly string[];
104
+ readonly counts: { readonly total: number; readonly accept: number; readonly reject: number };
105
+ readonly vectors: readonly KitIndexEntry[];
106
+ }
107
+
108
+ export interface KitEnvironment {
109
+ readonly currentCandidate: unknown;
110
+ readonly workspaceId: string;
111
+ readonly prepared: boolean;
112
+ }
113
+
114
+ /** Environment members a vector may override. Anything else is a kit change this harness has not been taught. */
115
+ const ENVIRONMENT_OVERRIDES: readonly string[] = ["currentCandidate", "prepared", "manifestLocation"];
116
+
117
+ /** Overrides that describe recorder-owned state; legal only on a deferred vector. */
118
+ const RECORDER_ONLY_OVERRIDES: readonly string[] = ["manifestLocation"];
119
+
120
+ // ── The deferred five ──────────────────────────────────────────────────────
121
+
122
+ export interface DeferredVector {
123
+ readonly id: string;
124
+ /** What the vector needs that a pure validator does not have. */
125
+ readonly reason: string;
126
+ }
127
+
128
+ /**
129
+ * The vectors whose expectation lands on the recorder's surface. Each was
130
+ * classified by reading the vector: what it overrides, what protocol it declares
131
+ * in `extra`, and which code it expects. All five are covered in integration
132
+ * mode, where the recorder allocates a run root, materializes the bytes, and
133
+ * publishes records.
134
+ */
135
+ export const RECORDER_DEPENDENT_VECTORS: readonly DeferredVector[] = Object.freeze([
136
+ Object.freeze({
137
+ id: "a-idempotent-resubmission",
138
+ reason: "extra.submitTwice — the claim is about record ids being identical across two submissions, so it is a claim about records the recorder writes",
139
+ }),
140
+ Object.freeze({
141
+ id: "sub-4-record-conflict",
142
+ reason: "extra.submitFirst — record_conflict is a collision with an already-published record",
143
+ }),
144
+ Object.freeze({
145
+ id: "sub-3-manifest-outside-run-root",
146
+ reason: "environment.manifestLocation — manifest_outside_run_root needs a real recorder-allocated run root to be outside of",
147
+ }),
148
+ Object.freeze({
149
+ id: "env-10-artifact-missing-file",
150
+ reason: "artifact_digest_mismatch — the vector's point is an entry naming a file the run root does not contain, and ENV-11 is verified by the recorder's fs port against the files it materialized, not against a caller-assembled map of strings",
151
+ }),
152
+ Object.freeze({
153
+ id: "env-11-artifact-digest-mismatch",
154
+ reason: "artifact_digest_mismatch — the same boundary: ENV-11 compares each declared digest against the file's bytes at submission, which is the recorder's fs port doing a filesystem operation",
155
+ }),
156
+ ]);
157
+
158
+ const DEFERRED_IDS: ReadonlySet<string> = new Set(RECORDER_DEPENDENT_VECTORS.map((vector) => vector.id));
159
+
160
+ // ── Loading ────────────────────────────────────────────────────────────────
161
+
162
+ /** The vendored kit root, resolved from this module rather than from a cwd. */
163
+ export function kitRoot(): string {
164
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "vectors");
165
+ }
166
+
167
+ function readJson(file: string): unknown {
168
+ return JSON.parse(readFileSync(file, "utf8"));
169
+ }
170
+
171
+ export function loadKitIndex(root: string = kitRoot()): KitIndex {
172
+ return readJson(path.join(root, "kit.json")) as KitIndex;
173
+ }
174
+
175
+ export function loadKitEnvironment(root: string = kitRoot()): KitEnvironment {
176
+ return readJson(path.join(root, "context", "environment.json")) as KitEnvironment;
177
+ }
178
+
179
+ export function loadKitVectors(root: string = kitRoot()): readonly KitVector[] {
180
+ return loadKitIndex(root).vectors.map((entry) => readJson(path.join(root, entry.file)) as KitVector);
181
+ }
182
+
183
+ // ── Outcomes ───────────────────────────────────────────────────────────────
184
+
185
+ export type KitOutcomeStatus = "passed" | "failed" | "skipped";
186
+
187
+ export interface KitOutcome {
188
+ readonly id: string;
189
+ readonly status: KitOutcomeStatus;
190
+ /** Codes the validator produced, in emission order. Empty for an accepted submission. */
191
+ readonly codes: readonly string[];
192
+ /** Why the vector failed. Empty when it passed or was skipped. */
193
+ readonly failures: readonly string[];
194
+ /** Present on a skipped vector: why it is deferred. */
195
+ readonly reason?: string;
196
+ }
197
+
198
+ export interface KitRunResult {
199
+ readonly outcomes: readonly KitOutcome[];
200
+ readonly passed: readonly string[];
201
+ readonly failed: readonly KitOutcome[];
202
+ readonly skipped: readonly string[];
203
+ }
204
+
205
+ export interface ValidatorOutcome {
206
+ readonly accepted: boolean;
207
+ readonly codes: readonly string[];
208
+ }
209
+
210
+ /**
211
+ * The kit's expectation semantics, as a function, so the semantics themselves
212
+ * can be tested against synthetic outcomes rather than only observed through
213
+ * whichever vectors happen to exercise them.
214
+ */
215
+ export function compareOutcome(expect: KitExpectation, actual: ValidatorOutcome): readonly string[] {
216
+ const failures: string[] = [];
217
+
218
+ for (const code of actual.codes) {
219
+ if (!isManifestRejectionCode(code)) {
220
+ failures.push(`validator emitted "${code}", which is not in the rejection-code registry`);
221
+ }
222
+ }
223
+
224
+ if (expect.result === "accepted") {
225
+ if (!actual.accepted) {
226
+ failures.push(`expected acceptance, got rejection with [${actual.codes.join(", ")}]`);
227
+ } else if (actual.codes.length > 0) {
228
+ // An acceptance carrying codes is a contradiction in the validator, not a
229
+ // lenient pass: the kit says an accepted submission has no codes at all.
230
+ failures.push(`an accepted submission must carry no rejection codes, got [${actual.codes.join(", ")}]`);
231
+ }
232
+ return failures;
233
+ }
234
+
235
+ if (actual.accepted) {
236
+ failures.push(`expected rejection with [${(expect.codes ?? []).join(", ")}], got acceptance`);
237
+ return failures;
238
+ }
239
+
240
+ // The listed codes are the floor, not the ceiling.
241
+ for (const code of expect.codes ?? []) {
242
+ if (!actual.codes.includes(code)) {
243
+ failures.push(`expected code "${code}" is missing; got [${actual.codes.join(", ")}]`);
244
+ }
245
+ }
246
+ return failures;
247
+ }
248
+
249
+ // ── Running ────────────────────────────────────────────────────────────────
250
+
251
+ export interface KitRunOptions {
252
+ /** Repository configuration the vectors are judged under. Defaults to the kit's own, via the adapter. */
253
+ readonly config?: HarnessConfig;
254
+ /** Kit root override, for tests that drive a synthetic corpus. */
255
+ readonly root?: string;
256
+ }
257
+
258
+ /** Runs one vector through the validator and reports what the validator produced. */
259
+ export function evaluateVector(vector: KitVector, config: HarnessConfig, environment: KitEnvironment): ValidatorOutcome {
260
+ const overrides = vector.environment ?? {};
261
+ for (const key of Object.keys(overrides)) {
262
+ if (!ENVIRONMENT_OVERRIDES.includes(key)) {
263
+ throw new Error(`vector ${vector.id} overrides unknown environment member "${key}"`);
264
+ }
265
+ if (RECORDER_ONLY_OVERRIDES.includes(key) && !DEFERRED_IDS.has(vector.id)) {
266
+ throw new Error(`vector ${vector.id} overrides recorder-owned environment member "${key}" but is not deferred`);
267
+ }
268
+ }
269
+
270
+ const currentCandidate = Object.prototype.hasOwnProperty.call(overrides, "currentCandidate")
271
+ ? overrides["currentCandidate"]
272
+ : environment.currentCandidate;
273
+ const prepared = Object.prototype.hasOwnProperty.call(overrides, "prepared") ? overrides["prepared"] === true : environment.prepared;
274
+
275
+ const result = validateManifest(vector.manifest, {
276
+ config,
277
+ currentCandidate,
278
+ prepared,
279
+ artifactContents: new Map(Object.entries(vector.artifacts ?? {})),
280
+ });
281
+
282
+ return result.ok ? { accepted: true, codes: [] } : { accepted: false, codes: result.rejections.map((rejection) => rejection.code) };
283
+ }
284
+
285
+ export function runKitUnitMode(options: KitRunOptions = {}): KitRunResult {
286
+ const root = options.root ?? kitRoot();
287
+ const config = options.config ?? loadKitRepoConfig();
288
+ const environment = loadKitEnvironment(root);
289
+ const vectors = loadKitVectors(root);
290
+
291
+ const present = new Set(vectors.map((vector) => vector.id));
292
+ for (const deferred of RECORDER_DEPENDENT_VECTORS) {
293
+ if (!present.has(deferred.id)) {
294
+ throw new Error(`deferred vector "${deferred.id}" is not in the kit; a skip list may never outlive the vector it names`);
295
+ }
296
+ }
297
+
298
+ const outcomes: KitOutcome[] = [];
299
+ for (const vector of vectors) {
300
+ const deferred = RECORDER_DEPENDENT_VECTORS.find((entry) => entry.id === vector.id);
301
+ if (deferred !== undefined) {
302
+ outcomes.push({ id: vector.id, status: "skipped", codes: [], failures: [], reason: deferred.reason });
303
+ continue;
304
+ }
305
+ // A validator that throws is a failed vector, not a failed run: one
306
+ // vector's crash must not cost the report on the other 83.
307
+ let actual: ValidatorOutcome;
308
+ let failures: readonly string[];
309
+ try {
310
+ actual = evaluateVector(vector, config, environment);
311
+ failures = compareOutcome(vector.expect, actual);
312
+ } catch (error) {
313
+ actual = { accepted: false, codes: [] };
314
+ failures = [`validator threw: ${error instanceof Error ? error.message : String(error)}`];
315
+ }
316
+ outcomes.push({
317
+ id: vector.id,
318
+ status: failures.length === 0 ? "passed" : "failed",
319
+ codes: actual.codes,
320
+ failures,
321
+ });
322
+ }
323
+
324
+ return {
325
+ outcomes,
326
+ passed: outcomes.filter((outcome) => outcome.status === "passed").map((outcome) => outcome.id),
327
+ failed: outcomes.filter((outcome) => outcome.status === "failed"),
328
+ skipped: outcomes.filter((outcome) => outcome.status === "skipped").map((outcome) => outcome.id),
329
+ };
330
+ }
331
+
332
+ /** One line per failing vector, for a test failure message that names what broke. */
333
+ export function describeFailures(result: KitRunResult): string {
334
+ return result.failed.map((outcome) => `${outcome.id}: ${outcome.failures.join("; ")}`).join("\n");
335
+ }
336
+
337
+ // ── Integration mode ───────────────────────────────────────────────────────
338
+
339
+ /**
340
+ * The submission environment one vector runs in.
341
+ *
342
+ * Every vector gets its own, and that is not merely hygiene: run roots are
343
+ * keyed by provider and run id, and the whole corpus shares one run id, so a
344
+ * single shared base directory would have 89 vectors overwriting each other's
345
+ * artifacts. The base is an injected port parameter for exactly this reason.
346
+ */
347
+ interface VectorWorkspace {
348
+ /** The repository root: where wiring files live and where stores are resolved from. */
349
+ readonly rootDir: string;
350
+ /** The injected evidence/receipt storage root, so no git repository is needed. */
351
+ readonly storageRoot: string;
352
+ /** The base run roots are allocated under. */
353
+ readonly runRootBase: string;
354
+ /** Where a manifest goes when it must not be inside the run root. */
355
+ readonly outsideDir: string;
356
+ }
357
+
358
+ async function createWorkspace(base: string, config: HarnessConfig): Promise<VectorWorkspace> {
359
+ const rootDir = path.join(base, "workspace");
360
+ const workspace: VectorWorkspace = {
361
+ rootDir,
362
+ storageRoot: path.join(base, "store"),
363
+ runRootBase: path.join(base, "runs"),
364
+ outsideDir: path.join(base, "outside"),
365
+ };
366
+ await mkdir(workspace.rootDir, { recursive: true });
367
+ await mkdir(workspace.storageRoot, { recursive: true });
368
+ await mkdir(workspace.runRootBase, { recursive: true });
369
+ await mkdir(workspace.outsideDir, { recursive: true });
370
+
371
+ // The declared wiring files must exist before a receipt can be published: a
372
+ // wiring path that is not a readable file is a typed blocker, never a hashed
373
+ // absence. Their contents are irrelevant to every vector — what matters is
374
+ // that the fingerprint has something real to be over.
375
+ for (const repoPath of config.preparationWiringPaths) {
376
+ const target = path.resolve(workspace.rootDir, repoPath);
377
+ await mkdir(path.dirname(target), { recursive: true });
378
+ await writeFile(target, `// conformance wiring fixture: ${repoPath}\n`, "utf8");
379
+ }
380
+ return workspace;
381
+ }
382
+
383
+ /**
384
+ * The vector's declared `currentCandidate`, in the shape a capture returns.
385
+ *
386
+ * `mode` and the two observation lists are not vector data: the kit describes a
387
+ * candidate's *coordinates*, and a prepared workspace is clean by construction.
388
+ * A vector missing a coordinate is a kit change this harness has not been
389
+ * taught, so it throws rather than substituting a default that would quietly
390
+ * change what SUB-1 compares.
391
+ */
392
+ export function capturedFromKitCandidate(value: unknown, vectorId: string): CapturedCandidate {
393
+ const read = (holder: unknown, name: string): unknown =>
394
+ typeof holder === "object" && holder !== null && !Array.isArray(holder) ? (holder as Record<string, unknown>)[name] : undefined;
395
+ const readString = (holder: unknown, name: string, at: string): string => {
396
+ const member = read(holder, name);
397
+ if (typeof member !== "string") {
398
+ throw new Error(`vector ${vectorId}: currentCandidate.${at} is not a string; the kit's environment shape has changed`);
399
+ }
400
+ return member;
401
+ };
402
+
403
+ const deliverable = read(value, "deliverable");
404
+ const base = read(value, "base");
405
+ return {
406
+ vcs: readString(value, "vcs", "vcs") as CapturedCandidate["vcs"],
407
+ treeSha: readString(value, "treeSha", "treeSha"),
408
+ headSha: readString(value, "headSha", "headSha"),
409
+ deliverable: {
410
+ digest: readString(deliverable, "digest", "deliverable.digest"),
411
+ identity: readString(deliverable, "identity", "deliverable.identity"),
412
+ },
413
+ base: {
414
+ ref: readString(base, "ref", "base.ref"),
415
+ tipSha: readString(base, "tipSha", "base.tipSha"),
416
+ mergeBaseSha: readString(base, "mergeBaseSha", "base.mergeBaseSha"),
417
+ },
418
+ workspaceId: readString(value, "workspaceId", "workspaceId"),
419
+ mode: "clean",
420
+ statusEntries: [],
421
+ untrackedFiles: [],
422
+ };
423
+ }
424
+
425
+ /**
426
+ * The unprepared capture. `prepared: false` in the kit means the capture reports
427
+ * an unprepared state — not that a caller passed a flag — so this is what the
428
+ * port returns, and SUB-2's rejection follows from it rather than from a switch.
429
+ */
430
+ const UNPREPARED_CAPTURE: CandidateCapture = {
431
+ ok: false,
432
+ code: "candidate_unprepared",
433
+ blockers: [
434
+ {
435
+ code: "candidate_unprepared",
436
+ source: { kind: "candidate", id: "conformance" },
437
+ summary: "The workspace is not in a prepared state.",
438
+ remediations: [{ id: "prepare-the-candidate", kind: "manual_action", summary: "Stage or commit the work and prepare again." }],
439
+ },
440
+ ],
441
+ };
442
+
443
+ /** How many record files the store holds, across every gate and obligation. */
444
+ async function countRecords(storageRoot: string): Promise<number> {
445
+ try {
446
+ const entries = await readdir(path.join(storageRoot, "records"));
447
+ // Publisher temporaries are dot-prefixed and are not records.
448
+ return entries.filter((entry) => !entry.startsWith(".")).length;
449
+ } catch {
450
+ return 0;
451
+ }
452
+ }
453
+
454
+ interface SubmissionAttempt {
455
+ readonly outcome: SubmissionOutcome;
456
+ /** Records in the store before this submission ran. */
457
+ readonly recordsBefore: number;
458
+ readonly recordsAfter: number;
459
+ }
460
+
461
+ /** The kit's outcome view of a submission. A blocked submission has no codes to compare. */
462
+ function outcomeOf(outcome: SubmissionOutcome): ValidatorOutcome | null {
463
+ if (outcome.status === "accepted") return { accepted: true, codes: [] };
464
+ if (outcome.status === "rejected") return { accepted: false, codes: outcome.rejections.map((rejection) => rejection.code) };
465
+ return null;
466
+ }
467
+
468
+ function blockedCodes(outcome: SubmissionOutcome): readonly string[] {
469
+ return outcome.status === "blocked" ? outcome.blockers.map((blocker) => blocker.code) : [];
470
+ }
471
+
472
+ /**
473
+ * Runs one vector's submission protocol and reports what happened at each step.
474
+ *
475
+ * The materialization rules are the kit README's. An artifact path that is
476
+ * itself invalid is part of the vector's point, so it is *not* materialized —
477
+ * writing `../outside.json` would put a file outside the run root to prove that
478
+ * a path outside the run root is rejected, which proves nothing and litters the
479
+ * temp directory. The validator rejects those on shape before any file access.
480
+ */
481
+ async function submitVector(
482
+ vector: KitVector,
483
+ config: HarnessConfig,
484
+ environment: KitEnvironment,
485
+ base: string,
486
+ ): Promise<readonly SubmissionAttempt[]> {
487
+ const workspace = await createWorkspace(base, config);
488
+ const artifacts = createArtifactsPort({ runRootBase: workspace.runRootBase });
489
+
490
+ const overrides = vector.environment ?? {};
491
+ const currentCandidate = Object.prototype.hasOwnProperty.call(overrides, "currentCandidate")
492
+ ? overrides["currentCandidate"]
493
+ : environment.currentCandidate;
494
+ const prepared = Object.prototype.hasOwnProperty.call(overrides, "prepared") ? overrides["prepared"] === true : environment.prepared;
495
+ const outsideRunRoot = overrides["manifestLocation"] === "outside-run-root";
496
+
497
+ const captured = capturedFromKitCandidate(currentCandidate, vector.id);
498
+ const capture: CandidateCapture = prepared ? { ok: true, candidate: captured } : UNPREPARED_CAPTURE;
499
+
500
+ // A real receipt for the candidate the capture reports. Without one the
501
+ // recorder blocks before it judges anything, which is the ordering the
502
+ // receipt gate installs; publishing it here is what a prepare step does in
503
+ // production.
504
+ if (prepared) {
505
+ await publishPreparationReceipt(workspace.rootDir, { config, candidate: captured }, { storageRoot: workspace.storageRoot });
506
+ }
507
+
508
+ const provider = (vector.manifest as { provider?: { id?: unknown; runId?: unknown } }).provider ?? {};
509
+ const allocation =
510
+ typeof provider.id === "string" && typeof provider.runId === "string"
511
+ ? await artifacts.allocateRunRoot({ providerId: provider.id, runId: provider.runId })
512
+ : ({ ok: false } as const);
513
+
514
+ // A run id the port refuses is a run root that does not exist. The manifest
515
+ // still has to live somewhere, and outside is the truthful place for it.
516
+ const runRootPath = allocation.ok ? allocation.runRoot.path : workspace.outsideDir;
517
+
518
+ for (const [declaredPath, contents] of Object.entries(vector.artifacts ?? {})) {
519
+ const segments = declaredPath.split(/[/\\]/);
520
+ if (declaredPath.startsWith("/") || segments.includes("..") || segments.includes("")) continue;
521
+ const target = path.join(runRootPath, declaredPath);
522
+ await mkdir(path.dirname(target), { recursive: true });
523
+ await writeFile(target, contents, "utf8");
524
+ }
525
+
526
+ const manifestPath = path.join(outsideRunRoot ? workspace.outsideDir : runRootPath, "manifest.json");
527
+ await mkdir(path.dirname(manifestPath), { recursive: true });
528
+
529
+ const submissions: unknown[] = [];
530
+ const extra = vector.extra ?? {};
531
+ if (Object.prototype.hasOwnProperty.call(extra, "submitFirst")) submissions.push(extra["submitFirst"]);
532
+ submissions.push(vector.manifest);
533
+ if (extra["submitTwice"] === true) submissions.push(vector.manifest);
534
+
535
+ const attempts: SubmissionAttempt[] = [];
536
+ for (const manifest of submissions) {
537
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
538
+ const recordsBefore = await countRecords(workspace.storageRoot);
539
+ const outcome = await submitManifest(
540
+ { rootDir: workspace.rootDir, manifestPath, config },
541
+ { captureCandidate: async () => capture, artifacts, storageRoot: workspace.storageRoot },
542
+ );
543
+ attempts.push({ outcome, recordsBefore, recordsAfter: await countRecords(workspace.storageRoot) });
544
+ }
545
+ return attempts;
546
+ }
547
+
548
+ /**
549
+ * The assertions integration mode adds to the kit's expectation semantics.
550
+ *
551
+ * Unit mode can only compare codes. Here the submission left a store behind, so
552
+ * the claims the spec makes about that store are checkable: SUB-4's one record
553
+ * per claim, each stamped with the shared manifest digest, and GEN-3's promise
554
+ * that a rejected submission wrote nothing at all.
555
+ */
556
+ function verifyEffects(vector: KitVector, attempt: SubmissionAttempt): readonly string[] {
557
+ const failures: string[] = [];
558
+ const { outcome, recordsBefore, recordsAfter } = attempt;
559
+
560
+ if (outcome.status === "accepted") {
561
+ const claims = (vector.manifest as { claims?: readonly { obligation?: unknown }[] }).claims ?? [];
562
+ const obligations = claims.map((claim) => claim.obligation);
563
+ const written = outcome.records.map((record) => record.obligationId);
564
+ if (written.length !== obligations.length || obligations.some((id) => !written.includes(id as string))) {
565
+ failures.push(`expected one record per claim [${obligations.join(", ")}], got [${written.join(", ")}]`);
566
+ }
567
+ for (const record of outcome.records) {
568
+ if (record.record.resolution.kind !== "evidence" || record.record.resolution.manifestDigest !== outcome.manifestDigest) {
569
+ failures.push(`record for ${record.obligationId} is not stamped with the submission's manifest digest`);
570
+ }
571
+ }
572
+ if (recordsAfter < recordsBefore) failures.push("an accepted submission removed records from the store");
573
+ return failures;
574
+ }
575
+
576
+ if (outcome.status === "rejected" && recordsAfter !== recordsBefore) {
577
+ // GEN-3 and SUB-5 both say it: a rejection writes nothing.
578
+ failures.push(`a rejected submission changed the store from ${recordsBefore} to ${recordsAfter} records`);
579
+ }
580
+ return failures;
581
+ }
582
+
583
+ /** The extra protocol the two multi-step vectors declare, checked across attempts. */
584
+ function verifyMultiStep(vector: KitVector, attempts: readonly SubmissionAttempt[]): readonly string[] {
585
+ const failures: string[] = [];
586
+ const extra = vector.extra ?? {};
587
+
588
+ if (extra["submitTwice"] === true) {
589
+ const [first, second] = attempts;
590
+ if (first === undefined || second === undefined) return ["submitTwice expects two submissions"];
591
+ if (first.outcome.status !== "accepted" || second.outcome.status !== "accepted") {
592
+ return [`both submissions must succeed; got ${first.outcome.status} then ${second.outcome.status}`];
593
+ }
594
+ const firstIds = first.outcome.records.map((record) => record.recordId).sort();
595
+ const secondIds = second.outcome.records.map((record) => record.recordId).sort();
596
+ if (firstIds.join(",") !== secondIds.join(",")) {
597
+ failures.push(`record ids differ across identical submissions: [${firstIds.join(", ")}] then [${secondIds.join(", ")}]`);
598
+ }
599
+ if (second.outcome.records.some((record) => record.status !== "idempotent")) {
600
+ failures.push("the second identical submission published a new record instead of finding its own");
601
+ }
602
+ if (second.recordsAfter !== second.recordsBefore) {
603
+ failures.push("an idempotent resubmission changed how many records the store holds");
604
+ }
605
+ }
606
+
607
+ if (Object.prototype.hasOwnProperty.call(extra, "submitFirst")) {
608
+ const [first] = attempts;
609
+ if (first === undefined) return ["submitFirst expects a preceding submission"];
610
+ if (first.outcome.status !== "accepted") {
611
+ failures.push(`extra.submitFirst must be accepted before the vector's manifest; it was ${first.outcome.status}`);
612
+ }
613
+ }
614
+
615
+ return failures;
616
+ }
617
+
618
+ export interface KitIntegrationOptions extends KitRunOptions {
619
+ /** Where per-vector workspaces are created. Defaults to a fresh temp directory. */
620
+ readonly workDir?: string;
621
+ /** Keeps the workspaces after the run, for inspecting a failure. */
622
+ readonly keepWorkspaces?: boolean;
623
+ }
624
+
625
+ /**
626
+ * Runs the whole kit through the recorder. Nothing is skipped: the five vectors
627
+ * unit mode defers are exactly the ones this mode exists to decide.
628
+ */
629
+ export async function runKitIntegrationMode(options: KitIntegrationOptions = {}): Promise<KitRunResult> {
630
+ const root = options.root ?? kitRoot();
631
+ const config = options.config ?? loadKitRepoConfig();
632
+ const environment = loadKitEnvironment(root);
633
+ const vectors = loadKitVectors(root);
634
+
635
+ const base = options.workDir ?? (await mkdtemp(path.join(tmpdir(), "delivery-harness-kit-")));
636
+ const outcomes: KitOutcome[] = [];
637
+
638
+ try {
639
+ for (const [index, vector] of vectors.entries()) {
640
+ // Padded so the directory listing sorts the way the kit index reads.
641
+ const workspaceBase = path.join(base, `${String(index).padStart(3, "0")}-${vector.id}`);
642
+ let actual: ValidatorOutcome;
643
+ let failures: readonly string[];
644
+ try {
645
+ const attempts = await submitVector(vector, config, environment, workspaceBase);
646
+ const final = attempts.at(-1);
647
+ if (final === undefined) throw new Error("no submission was attempted");
648
+ const observed = outcomeOf(final.outcome);
649
+ if (observed === null) {
650
+ // A blocked submission is not a rejection with different words: it
651
+ // means the recorder could not judge the manifest at all, and no
652
+ // vector expects that. Reporting it as a failure with its blocker
653
+ // codes is what keeps it from being read as a lenient pass.
654
+ actual = { accepted: false, codes: [] };
655
+ failures = [`submission was blocked by [${blockedCodes(final.outcome).join(", ")}] rather than judged`];
656
+ } else {
657
+ actual = observed;
658
+ failures = [
659
+ ...compareOutcome(vector.expect, observed),
660
+ ...verifyEffects(vector, final),
661
+ ...verifyMultiStep(vector, attempts),
662
+ ];
663
+ }
664
+ } catch (error) {
665
+ actual = { accepted: false, codes: [] };
666
+ failures = [`submission threw: ${error instanceof Error ? error.message : String(error)}`];
667
+ }
668
+ outcomes.push({
669
+ id: vector.id,
670
+ status: failures.length === 0 ? "passed" : "failed",
671
+ codes: actual.codes,
672
+ failures,
673
+ });
674
+ }
675
+ } finally {
676
+ if (options.workDir === undefined && options.keepWorkspaces !== true) {
677
+ await rm(base, { recursive: true, force: true });
678
+ }
679
+ }
680
+
681
+ return {
682
+ outcomes,
683
+ passed: outcomes.filter((outcome) => outcome.status === "passed").map((outcome) => outcome.id),
684
+ failed: outcomes.filter((outcome) => outcome.status === "failed"),
685
+ skipped: [],
686
+ };
687
+ }