@jinn-network/task-execution-testing 0.0.0 → 0.1.0-canary.sha.786221faf848b3c859ddbc76fec09338df64d83b

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 (44) hide show
  1. package/README.md +63 -1
  2. package/dist/backend-contract.d.ts +9 -0
  3. package/dist/backend-contract.js +394 -0
  4. package/dist/backend-local/backend-contract.d.ts +26 -0
  5. package/dist/backend-local/backend-contract.js +130 -0
  6. package/dist/backend-local/canonical.d.ts +14 -0
  7. package/dist/backend-local/canonical.js +48 -0
  8. package/dist/backend-local/fake-launcher.d.ts +46 -0
  9. package/dist/backend-local/fake-launcher.js +49 -0
  10. package/dist/backend-local/fixtures.d.ts +86 -0
  11. package/dist/backend-local/fixtures.js +29 -0
  12. package/dist/backend-local/index.d.ts +14 -0
  13. package/dist/backend-local/index.js +19 -0
  14. package/dist/backend-local/journal-fixtures.d.ts +57 -0
  15. package/dist/backend-local/journal-fixtures.js +29 -0
  16. package/dist/backend-local/launcher-contract.d.ts +49 -0
  17. package/dist/backend-local/launcher-contract.js +143 -0
  18. package/dist/backend-local/supervisor-contract.d.ts +23 -0
  19. package/dist/backend-local/supervisor-contract.js +56 -0
  20. package/dist/backend-local/workspace-contract.d.ts +22 -0
  21. package/dist/backend-local/workspace-contract.js +182 -0
  22. package/dist/fake-backend.d.ts +64 -0
  23. package/dist/fake-backend.js +406 -0
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.js +8 -0
  26. package/dist/protocol-conformance.d.ts +8 -0
  27. package/dist/protocol-conformance.js +299 -0
  28. package/fixtures/backend-local/cancellation-races.json +41 -0
  29. package/fixtures/backend-local/evidence-join.json +35 -0
  30. package/fixtures/backend-local/expected-digests.json +27 -0
  31. package/fixtures/backend-local/journals/contradictory-terminals.json +49 -0
  32. package/fixtures/backend-local/journals/dangling-intents.json +30 -0
  33. package/fixtures/backend-local/journals/duplicate-nonces.json +38 -0
  34. package/fixtures/backend-local/journals/rebuild-identity.json +36 -0
  35. package/fixtures/backend-local/journals/seq-resumption.json +47 -0
  36. package/fixtures/backend-local/journals/submission-segment-survival.json +18 -0
  37. package/fixtures/backend-local/journals/torn-tail.json +38 -0
  38. package/fixtures/backend-local/journals/valid.json +54 -0
  39. package/fixtures/backend-local/reconciliation-table.json +126 -0
  40. package/fixtures/backend-local/result-interpretation.json +55 -0
  41. package/fixtures/backend-local/shim-contract.json +47 -0
  42. package/fixtures/backend-local/workspace.json +56 -0
  43. package/fixtures/manifest.sha256.json +66 -0
  44. package/package.json +63 -5
package/README.md CHANGED
@@ -1 +1,63 @@
1
- Placeholder. Real versions come from stack-npm-publish.yml.
1
+ # @jinn-network/task-execution-testing
2
+
3
+ The Jinn Task Execution Protocol (TEP) v1 conformance kit. Ships design §24's Layers 1 and 2:
4
+ protocol conformance over the golden + adversarial fixtures, the backend contract sanity suite,
5
+ and the in-memory fake backend that both are proven against first. Depends on
6
+ `@jinn-network/task-execution-protocol` and `@jinn-network/task-execution-backend`.
7
+
8
+ See the design: `docs/superpowers/specs/2026-07-27-task-execution-protocol-and-stack-design.md`
9
+ §24 (conformance and fixture strategy). Carried-amendment implementation notes:
10
+ `docs/superpowers/specs/2026-07-28-tep-v1-implementation-addendum.md`.
11
+
12
+ ## What ships here
13
+
14
+ - **`createInMemoryBackend()` / `InMemoryTaskExecutionBackend`** — a durable-in-memory
15
+ `TaskExecutionBackend` reference implementation (single-party Attempt minting, §9.2). This is
16
+ the kit's own proof surface: both conformance layers below are proven against it *before* any
17
+ real binding exists (design §26).
18
+ - **`TestableBackend`** — the frozen `TaskExecutionBackend` contract plus a minimal, documented
19
+ test-only seam (`drive`, `recordDelivery`, `simulateReconciliation`) that lets the Layer-2
20
+ suite drive lifecycle facts and force `recover` outcomes without widening the frozen §22
21
+ contract itself. A binding wanting to run the Layer-2 suite against itself implements this
22
+ seam alongside the real contract.
23
+ - **`describeProtocolConformance()`** — a vitest `describe` block asserting the §24 Layer-1
24
+ rules (schema validation of all five families, producer-/consumer-side seal and digest rules,
25
+ reference and cardinality rules, extension preservation, observation ordering and fold
26
+ correctness, Delivery binding, and the full adversarial minimum set) over the golden
27
+ local+marketplace scenario pair and the adversarial fixtures
28
+ `@jinn-network/task-execution-protocol` ships.
29
+ - **`describeTaskExecutionBackendContract(makeBackend)`** — the csi-sanity-style Layer-2 suite:
30
+ byte-exact idempotent submit, honest `observe`, `recover` across all three reconciliation
31
+ outcomes, cancel races including cancel-after-terminal, unsupported-requirement rejection
32
+ naming the field, failure-category mapping (operational error vs. Attempt outcome), result
33
+ retrieval on terminal Attempts, and concurrent Attempts within declared bounds.
34
+ - **`describeLocalBackendContract(makeBackend)`** from the `./backend-local` subpath — registers
35
+ that unchanged Layer-2 suite against the real local assembly, then adds the one-live-writer,
36
+ `attempts` 1..1, seal-once recovery, and evidence-finalization/indexing posture checks.
37
+
38
+ ## Using the kit from a binding
39
+
40
+ ```ts
41
+ import { describeProtocolConformance, describeTaskExecutionBackendContract } from "@jinn-network/task-execution-testing";
42
+
43
+ describeProtocolConformance();
44
+ describeTaskExecutionBackendContract(() => createMyBinding());
45
+ ```
46
+
47
+ Both are ordinary vitest `describe` blocks — run them from any `.test.ts` file under the
48
+ consumer's own vitest configuration. `vitest` is an optional peer dependency of this package: a
49
+ consumer already running vitest need not install anything extra.
50
+
51
+ ## The kit-precedes-bindings rule (design §26)
52
+
53
+ The in-memory fake is proven against `describeTaskExecutionBackendContract` first, in this
54
+ package, before any real binding is written. A binding is conformant once it passes the same
55
+ suite against itself.
56
+
57
+ ## Two honestly-Layer-3 boundaries
58
+
59
+ Two adversarial cases are *not* caught by Layer-1 protocol structure alone — dispatch-context
60
+ grafting into a re-sealed crate, and capability-grant misuse via a leaked-Task resubmission. The
61
+ kit documents this boundary explicitly (§24) rather than faking a catch: both require the
62
+ Layer-3 `dispatch-binding` verification check, which compares a crate's captured inputs against
63
+ the backend's own dispatch record — out of this package's scope.
@@ -0,0 +1,9 @@
1
+ import type { TestableBackend } from "./fake-backend.js";
2
+ export type DescribeTaskExecutionBackendContract = typeof describeTaskExecutionBackendContract;
3
+ /**
4
+ * The §24 Layer-2 csi-sanity-style backend contract suite: any `TaskExecutionBackend`
5
+ * implementation satisfying the documented `TestableBackend` seam (drive, recordDelivery,
6
+ * simulateReconciliation — test-only, not part of the frozen §22 contract) can run this suite.
7
+ * Proven first against the in-memory fake (design §26); bindings run it against themselves.
8
+ */
9
+ export declare function describeTaskExecutionBackendContract(makeBackend: () => TestableBackend): void;
@@ -0,0 +1,394 @@
1
+ import { TaskExecutionError } from "@jinn-network/task-execution-backend";
2
+ import { documentDigest, sealDelivery, sealSubmission, sealTask } from "@jinn-network/task-execution-protocol";
3
+ import { describe, expect, test } from "vitest";
4
+ let counter = 0;
5
+ function unique(prefix) {
6
+ counter += 1;
7
+ return `${prefix}-${counter}`;
8
+ }
9
+ const PROFILE = {
10
+ uri: "https://spec.jinn.network/task-profiles/repository-work/1.0",
11
+ digest: { sha256: "3917f0428b2626fd2cc93675172731cc000b69d7d783f9adaf5159be56fd10a6" },
12
+ };
13
+ function taskBytes() {
14
+ return sealTask({
15
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
16
+ profile: PROFILE,
17
+ instructions: "Backend-contract sanity fixture.",
18
+ outputs: [{ name: "patch", mediaType: "text/x-diff", required: true }],
19
+ });
20
+ }
21
+ function submissionBytes(taskDigest, overrides = {}) {
22
+ return sealSubmission({
23
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
24
+ submission: `urn:uuid:${crypto.randomUUID()}`,
25
+ task: { digest: { sha256: taskDigest.replace(/^sha256:/, "") } },
26
+ requester: `urn:uuid:${crypto.randomUUID()}`,
27
+ idempotencyKey: unique("backend-contract"),
28
+ nonce: unique("nonce"),
29
+ deadline: "2099-01-01T00:00:00Z",
30
+ ...overrides,
31
+ });
32
+ }
33
+ function deliveryBytesFor(taskDigest, attempt) {
34
+ return sealDelivery({
35
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
36
+ attempt,
37
+ task: taskDigest,
38
+ outputs: [{ name: "patch", mediaType: "text/x-diff", digest: { sha256: "b".repeat(64) } }],
39
+ outcome: "fulfilled",
40
+ createdAt: "2026-07-28T00:05:00Z",
41
+ });
42
+ }
43
+ // `foldObservations` self-filters to the authoritative source pinned by `attempt-engaged`
44
+ // (§10.1/§10.4): a driven observation must carry that same envelope `source` to count toward
45
+ // derived state, so every fixture below takes the attempt's real authoritative `source` as a
46
+ // parameter rather than inventing its own producer identity.
47
+ function terminalObservation(attempt, source, state, blame) {
48
+ return {
49
+ specversion: "1.0",
50
+ id: unique("evt"),
51
+ source,
52
+ subject: attempt,
53
+ time: new Date().toISOString(),
54
+ datacontenttype: "application/json",
55
+ sequence: "9999999999999999",
56
+ type: "network.jinn.task-execution.attempt-terminal.v1",
57
+ data: { state, ...(blame !== undefined ? { blame } : {}) },
58
+ };
59
+ }
60
+ function startedObservation(attempt, source) {
61
+ return {
62
+ specversion: "1.0",
63
+ id: unique("evt"),
64
+ source,
65
+ subject: attempt,
66
+ time: new Date().toISOString(),
67
+ datacontenttype: "application/json",
68
+ sequence: "0000000000000050",
69
+ type: "network.jinn.task-execution.attempt-started.v1",
70
+ data: { startedAt: new Date().toISOString() },
71
+ };
72
+ }
73
+ async function submitAccepted(backend, overrides = {}) {
74
+ const task = taskBytes();
75
+ const taskDigest = documentDigest(task);
76
+ const submission = submissionBytes(taskDigest, overrides);
77
+ const ack = await backend.submit(task, submission);
78
+ if (!ack.accepted)
79
+ throw new Error(`expected acceptance, got ${JSON.stringify(ack.error)}`);
80
+ const snapshot = await backend.observe(ack.submission);
81
+ const engaged = snapshot.observations.find((observation) => observation.type === "network.jinn.task-execution.attempt-engaged.v1");
82
+ if (engaged === undefined)
83
+ throw new Error("expected an attempt-engaged observation on submit");
84
+ return { attempt: snapshot.descriptor.attempt, taskDigest, source: engaged.source };
85
+ }
86
+ // TEP Addendum 2026-07-28-b (two-party engagement, program §7.2/§7.22): builds a well-formed
87
+ // engagement whose dispatch-context content agrees with the Submission it accompanies.
88
+ function engagementFor(taskDigest, submission, nonce, attemptUri) {
89
+ return {
90
+ attemptUri,
91
+ dispatchContext: { taskDigest, submission, nonce, attempt: attemptUri },
92
+ };
93
+ }
94
+ /**
95
+ * The §24 Layer-2 csi-sanity-style backend contract suite: any `TaskExecutionBackend`
96
+ * implementation satisfying the documented `TestableBackend` seam (drive, recordDelivery,
97
+ * simulateReconciliation — test-only, not part of the frozen §22 contract) can run this suite.
98
+ * Proven first against the in-memory fake (design §26); bindings run it against themselves.
99
+ */
100
+ export function describeTaskExecutionBackendContract(makeBackend) {
101
+ describe("TEP Layer 2: backend contract sanity suite", () => {
102
+ test("submit is byte-exact idempotent: same-key/same-bytes returns the same acknowledgement", async () => {
103
+ const backend = makeBackend();
104
+ const task = taskBytes();
105
+ const submission = submissionBytes(documentDigest(task));
106
+ const first = await backend.submit(task, submission);
107
+ const second = await backend.submit(task, submission);
108
+ expect(second).toEqual(first);
109
+ });
110
+ test("submit rejects same-key/different-bytes as a typed submission-conflict", async () => {
111
+ const backend = makeBackend();
112
+ const task = taskBytes();
113
+ const taskDigest = documentDigest(task);
114
+ const requester = `urn:uuid:${crypto.randomUUID()}`;
115
+ const key = unique("conflict-key");
116
+ const first = submissionBytes(taskDigest, { requester, idempotencyKey: key });
117
+ const second = submissionBytes(taskDigest, { requester, idempotencyKey: key, nonce: unique("different-nonce") });
118
+ const firstAck = await backend.submit(task, first);
119
+ expect(firstAck.accepted).toBe(true);
120
+ const secondAck = await backend.submit(task, second);
121
+ expect(secondAck.accepted).toBe(false);
122
+ if (secondAck.accepted)
123
+ throw new Error("unreachable");
124
+ expect(secondAck.error).toBeInstanceOf(TaskExecutionError);
125
+ expect(secondAck.error.category).toBe("submission-conflict");
126
+ });
127
+ test("observe is honest: distinguishes pending, running, and a failed terminal (never infers success from liveness)", async () => {
128
+ const backend = makeBackend();
129
+ const { attempt, source } = await submitAccepted(backend);
130
+ const pending = await backend.observe(attempt);
131
+ expect(pending.descriptor.derived.state).toBe("pending");
132
+ expect(pending.descriptor.derived.terminal).toBe(false);
133
+ await backend.drive(attempt, [startedObservation(attempt, source)]);
134
+ const running = await backend.observe(attempt);
135
+ expect(running.descriptor.derived.state).toBe("running");
136
+ expect(running.descriptor.derived.terminal).toBe(false);
137
+ await backend.drive(attempt, [terminalObservation(attempt, source, "failed", "task")]);
138
+ const failed = await backend.observe(attempt);
139
+ expect(failed.descriptor.derived.state).toBe("failed");
140
+ expect(failed.descriptor.derived.terminal).toBe(true);
141
+ expect(failed.descriptor.derived.blame).toBe("task");
142
+ });
143
+ test("recover classifies matching, absent, and contradictory, and fail-loud never guesses on contradictory", async () => {
144
+ const backend = makeBackend();
145
+ const { attempt } = await submitAccepted(backend);
146
+ const matching = await backend.recover(attempt);
147
+ expect(matching.classification).toBe("matching");
148
+ backend.simulateReconciliation(attempt, { classification: "absent", detail: "no durable record found" });
149
+ const absent = await backend.recover(attempt);
150
+ expect(absent.classification).toBe("absent");
151
+ backend.simulateReconciliation(attempt, {
152
+ classification: "contradictory",
153
+ detail: "durable record disagrees with backend-native state",
154
+ });
155
+ const contradictory = await backend.recover(attempt);
156
+ expect(contradictory.classification).toBe("contradictory");
157
+ expect(contradictory.detail).toBeDefined();
158
+ });
159
+ test("cancel is idempotent and terminal-state-aware, including cancel-after-terminal (cancel races)", async () => {
160
+ const backend = makeBackend();
161
+ if (backend.cancel === undefined)
162
+ return; // optional capability (§14)
163
+ const cancel = backend.cancel.bind(backend);
164
+ const { attempt, source } = await submitAccepted(backend);
165
+ const beforeTerminal = await cancel(attempt, "no longer needed");
166
+ expect(beforeTerminal.requested).toBe(true);
167
+ expect(beforeTerminal.terminalState).toBeUndefined();
168
+ await backend.drive(attempt, [terminalObservation(attempt, source, "delivered")]);
169
+ const afterTerminalFirst = await cancel(attempt, "race: caller A");
170
+ const afterTerminalSecond = await cancel(attempt, "race: caller B");
171
+ expect(afterTerminalFirst).toEqual({ requested: false, terminalState: "delivered" });
172
+ expect(afterTerminalSecond).toEqual(afterTerminalFirst);
173
+ });
174
+ test("submit names the field it rejects: an undeclared run-pinning key is unsupported-requirement", async () => {
175
+ const backend = makeBackend();
176
+ const capabilities = await backend.capabilities();
177
+ const declared = new Set(capabilities.runPinning.keys.map((entry) => entry.key));
178
+ const undeclaredKey = ["harness", "model", "loadout", "isolationPolicy"].find((key) => !declared.has(key))
179
+ ?? "x-conformance-kit/definitely-unsupported-key";
180
+ const task = taskBytes();
181
+ const taskDigest = documentDigest(task);
182
+ const submission = submissionBytes(taskDigest, { requirements: { [undeclaredKey]: { id: "unsupported-value" } } });
183
+ const ack = await backend.submit(task, submission);
184
+ expect(ack.accepted).toBe(false);
185
+ if (ack.accepted)
186
+ throw new Error("unreachable");
187
+ expect(ack.error).toBeInstanceOf(TaskExecutionError);
188
+ expect(ack.error.category).toBe("unsupported-requirement");
189
+ });
190
+ test("failure-category mapping: an operational rejection is a TaskExecutionError, distinct from a failed Attempt terminal", async () => {
191
+ const backend = makeBackend();
192
+ // A structurally malformed Task (missing the required outputs[] field, never run through
193
+ // the sealer's own validation) is an operational rejection, not an Attempt outcome.
194
+ const malformedTaskBytes = new TextEncoder().encode(JSON.stringify({
195
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
196
+ profile: PROFILE,
197
+ instructions: "missing the required outputs[] field",
198
+ }));
199
+ const malformedAck = await backend.submit(malformedTaskBytes, submissionBytes(`sha256:${"0".repeat(64)}`));
200
+ expect(malformedAck.accepted).toBe(false);
201
+ if (malformedAck.accepted)
202
+ throw new Error("unreachable");
203
+ expect(malformedAck.error).toBeInstanceOf(TaskExecutionError);
204
+ expect(malformedAck.error.category).toBe("invalid-document");
205
+ // observe() on a reference the backend never engaged is an operational rejection too.
206
+ await expect(backend.observe("urn:uuid:00000000-0000-5000-8000-000000000000")).rejects.toBeInstanceOf(TaskExecutionError);
207
+ await expect(backend.observe("urn:uuid:00000000-0000-5000-8000-000000000000")).rejects.toMatchObject({
208
+ category: "attempt-not-found",
209
+ });
210
+ // By contrast, a *failed* Attempt terminal is a successful, honest observe() — a work
211
+ // outcome, never an operational error (§13).
212
+ const { attempt, source } = await submitAccepted(backend);
213
+ await backend.drive(attempt, [terminalObservation(attempt, source, "failed", "task")]);
214
+ const snapshot = await backend.observe(attempt);
215
+ expect(snapshot.descriptor.derived.state).toBe("failed");
216
+ });
217
+ test("result retrieval on terminal Attempts: deliveries + fetchDelivery return exact bytes; result-unavailable is loud otherwise", async () => {
218
+ const backend = makeBackend();
219
+ const { attempt, taskDigest, source } = await submitAccepted(backend);
220
+ const delivery = deliveryBytesFor(taskDigest, attempt);
221
+ await backend.recordDelivery(attempt, delivery);
222
+ await backend.drive(attempt, [terminalObservation(attempt, source, "delivered")]);
223
+ const refs = await backend.deliveries(attempt);
224
+ expect(refs).toHaveLength(1);
225
+ const fetched = await backend.fetchDelivery(refs[0]);
226
+ expect(fetched).toEqual(delivery);
227
+ await expect(backend.fetchDelivery({ attempt, digest: `sha256:${"c".repeat(64)}` })).rejects.toMatchObject({ category: "result-unavailable" });
228
+ });
229
+ test("idempotency scope is delimited: concatenation-colliding (requester, idempotencyKey) pairs never conflict or capture each other (§12.2)", async () => {
230
+ const backend = makeBackend();
231
+ const task = taskBytes();
232
+ const taskDigest = documentDigest(task);
233
+ // Chosen so naive concatenation ("requester" + "idempotencyKey") collides: "ab" + "c" ===
234
+ // "a" + "bc" === "abc". A scope key with no delimiter between the two fields would treat
235
+ // these as the same scope — exactly the cross-requester capture §12.2 forbids.
236
+ const first = submissionBytes(taskDigest, { requester: "ab", idempotencyKey: "c" });
237
+ const second = submissionBytes(taskDigest, { requester: "a", idempotencyKey: "bc" });
238
+ const firstAck = await backend.submit(task, first);
239
+ const secondAck = await backend.submit(task, second);
240
+ expect(firstAck.accepted).toBe(true);
241
+ expect(secondAck.accepted).toBe(true);
242
+ if (!firstAck.accepted || !secondAck.accepted)
243
+ throw new Error("unreachable");
244
+ expect(firstAck.submission).not.toBe(secondAck.submission);
245
+ const firstSnapshot = await backend.observe(firstAck.submission);
246
+ const secondSnapshot = await backend.observe(secondAck.submission);
247
+ expect(firstSnapshot.descriptor.attempt).not.toBe(secondSnapshot.descriptor.attempt);
248
+ });
249
+ test("the same idempotencyKey under two different requesters yields two distinct accepted Submissions (no conflict, no capture, §12.2)", async () => {
250
+ const backend = makeBackend();
251
+ const task = taskBytes();
252
+ const taskDigest = documentDigest(task);
253
+ const sharedKey = unique("shared-key");
254
+ const requesterA = `urn:uuid:${crypto.randomUUID()}`;
255
+ const requesterB = `urn:uuid:${crypto.randomUUID()}`;
256
+ const firstAck = await backend.submit(task, submissionBytes(taskDigest, { requester: requesterA, idempotencyKey: sharedKey }));
257
+ const secondAck = await backend.submit(task, submissionBytes(taskDigest, { requester: requesterB, idempotencyKey: sharedKey }));
258
+ expect(firstAck.accepted).toBe(true);
259
+ expect(secondAck.accepted).toBe(true);
260
+ if (!firstAck.accepted || !secondAck.accepted)
261
+ throw new Error("unreachable");
262
+ expect(firstAck.submission).not.toBe(secondAck.submission);
263
+ });
264
+ test("submit rejects when the Submission's task digest does not match the provided Task bytes (invalid-reference, §8)", async () => {
265
+ const backend = makeBackend();
266
+ const task = taskBytes();
267
+ const wrongDigest = `sha256:${"0".repeat(64)}`;
268
+ const submission = submissionBytes(wrongDigest);
269
+ const ack = await backend.submit(task, submission);
270
+ expect(ack.accepted).toBe(false);
271
+ if (ack.accepted)
272
+ throw new Error("unreachable");
273
+ expect(ack.error).toBeInstanceOf(TaskExecutionError);
274
+ expect(ack.error.category).toBe("invalid-reference");
275
+ expect(ack.error.retryable).toBe(false);
276
+ });
277
+ test("submit rejects supplied evaluationRequirements this backend does not declare/interpret (unsupported-requirement, §8)", async () => {
278
+ const backend = makeBackend();
279
+ const task = taskBytes();
280
+ const taskDigest = documentDigest(task);
281
+ const submission = submissionBytes(taskDigest, { evaluationRequirements: { minConfidenceBps: 900 } });
282
+ const ack = await backend.submit(task, submission);
283
+ expect(ack.accepted).toBe(false);
284
+ if (ack.accepted)
285
+ throw new Error("unreachable");
286
+ expect(ack.error).toBeInstanceOf(TaskExecutionError);
287
+ expect(ack.error.category).toBe("unsupported-requirement");
288
+ });
289
+ test("submit rejects supplied capabilityGrants this backend does not declare/interpret (unsupported-requirement, §8)", async () => {
290
+ const backend = makeBackend();
291
+ const task = taskBytes();
292
+ const taskDigest = documentDigest(task);
293
+ const submission = submissionBytes(taskDigest, { capabilityGrants: { "x-conformance-kit/grant": true } });
294
+ const ack = await backend.submit(task, submission);
295
+ expect(ack.accepted).toBe(false);
296
+ if (ack.accepted)
297
+ throw new Error("unreachable");
298
+ expect(ack.error).toBeInstanceOf(TaskExecutionError);
299
+ expect(ack.error.category).toBe("unsupported-requirement");
300
+ });
301
+ test("submit rejects a requested attempts bound outside this backend's declared range (unsupported-requirement, §8)", async () => {
302
+ const backend = makeBackend();
303
+ const capabilities = await backend.capabilities();
304
+ const declaredMaxTotal = capabilities.attempts.maxTotal;
305
+ if (declaredMaxTotal === undefined) {
306
+ throw new Error("fixture expects this backend to declare attempts.maxTotal bounds");
307
+ }
308
+ const task = taskBytes();
309
+ const taskDigest = documentDigest(task);
310
+ const outOfRange = declaredMaxTotal[1] + 1;
311
+ const submission = submissionBytes(taskDigest, { attempts: { maxTotal: outOfRange } });
312
+ const ack = await backend.submit(task, submission);
313
+ expect(ack.accepted).toBe(false);
314
+ if (ack.accepted)
315
+ throw new Error("unreachable");
316
+ expect(ack.error).toBeInstanceOf(TaskExecutionError);
317
+ expect(ack.error.category).toBe("unsupported-requirement");
318
+ });
319
+ test("concurrent Attempts on one Task, under separate Submissions, are legal within declared bounds (§9.2)", async () => {
320
+ const backend = makeBackend();
321
+ const task = taskBytes();
322
+ const taskDigest = documentDigest(task);
323
+ const bounds = { attempts: { maxConcurrent: 1, maxTotal: 1 } };
324
+ const firstAck = await backend.submit(task, submissionBytes(taskDigest, bounds));
325
+ const secondAck = await backend.submit(task, submissionBytes(taskDigest, bounds));
326
+ if (!firstAck.accepted || !secondAck.accepted)
327
+ throw new Error("unreachable");
328
+ const first = await backend.observe(firstAck.submission);
329
+ const second = await backend.observe(secondAck.submission);
330
+ expect(first.descriptor.attempt).not.toBe(second.descriptor.attempt);
331
+ expect(first.descriptor.derived.terminal).toBe(false);
332
+ expect(second.descriptor.derived.terminal).toBe(false);
333
+ });
334
+ test("two-party engagement: submit adopts the caller-supplied attemptUri instead of minting one (TEP Addendum 2026-07-28-b)", async () => {
335
+ const backend = makeBackend();
336
+ const task = taskBytes();
337
+ const taskDigest = documentDigest(task);
338
+ const submissionUri = `urn:uuid:${crypto.randomUUID()}`;
339
+ const nonce = unique("engaged-nonce");
340
+ const submission = submissionBytes(taskDigest, { submission: submissionUri, nonce });
341
+ const attemptUri = `urn:uuid:${crypto.randomUUID()}`;
342
+ const ack = await backend.submit(task, submission, engagementFor(taskDigest, submissionUri, nonce, attemptUri));
343
+ expect(ack.accepted).toBe(true);
344
+ if (!ack.accepted)
345
+ throw new Error("unreachable");
346
+ const snapshot = await backend.observe(ack.submission);
347
+ expect(snapshot.descriptor.attempt).toBe(attemptUri);
348
+ });
349
+ test("two-party engagement: a malformed attemptUri is rejected as invalid-document, never adopted (TEP Addendum 2026-07-28-b)", async () => {
350
+ const backend = makeBackend();
351
+ const task = taskBytes();
352
+ const taskDigest = documentDigest(task);
353
+ const submissionUri = `urn:uuid:${crypto.randomUUID()}`;
354
+ const nonce = unique("malformed-nonce");
355
+ const submission = submissionBytes(taskDigest, { submission: submissionUri, nonce });
356
+ const malformedAttemptUri = "urn:uuid:not-a-real-uuid";
357
+ const ack = await backend.submit(task, submission, engagementFor(taskDigest, submissionUri, nonce, malformedAttemptUri));
358
+ expect(ack.accepted).toBe(false);
359
+ if (ack.accepted)
360
+ throw new Error("unreachable");
361
+ expect(ack.error).toBeInstanceOf(TaskExecutionError);
362
+ expect(ack.error.category).toBe("invalid-document");
363
+ });
364
+ test("two-party engagement absent: submit mints a random Attempt URI, single-party semantics unchanged (TEP Addendum 2026-07-28-b)", async () => {
365
+ const backend = makeBackend();
366
+ const { attempt } = await submitAccepted(backend);
367
+ expect(attempt).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
368
+ });
369
+ test("two-party engagement scopes the attempts honor-or-reject to the single caller-identified attempt (marketplace-binding-design Finding F4)", async () => {
370
+ const backend = makeBackend();
371
+ const capabilities = await backend.capabilities();
372
+ const declaredMaxTotal = capabilities.attempts.maxTotal;
373
+ if (declaredMaxTotal === undefined) {
374
+ throw new Error("fixture expects this backend to declare attempts.maxTotal bounds");
375
+ }
376
+ // Out of the backend's own declared range — rejected in single-party mode (see the
377
+ // "outside this backend's declared range" test above), but a chain enforces `maxClaims`
378
+ // in two-party mode, not this backend, so it must be honored here.
379
+ const outOfRange = declaredMaxTotal[1] + 1;
380
+ const task = taskBytes();
381
+ const taskDigest = documentDigest(task);
382
+ const submissionUri = `urn:uuid:${crypto.randomUUID()}`;
383
+ const nonce = unique("scoped-nonce");
384
+ const submission = submissionBytes(taskDigest, {
385
+ submission: submissionUri,
386
+ nonce,
387
+ attempts: { maxTotal: outOfRange },
388
+ });
389
+ const attemptUri = `urn:uuid:${crypto.randomUUID()}`;
390
+ const ack = await backend.submit(task, submission, engagementFor(taskDigest, submissionUri, nonce, attemptUri));
391
+ expect(ack.accepted).toBe(true);
392
+ });
393
+ });
394
+ }
@@ -0,0 +1,26 @@
1
+ import type { LocalTaskExecutionBackend } from "@jinn-network/task-execution-backend-local";
2
+ import type { TestableBackend } from "../fake-backend.js";
3
+ export interface LocalBackendConformanceSubject extends TestableBackend, Pick<LocalTaskExecutionBackend, "close" | "deliveryCheckpointPath" | "drain"> {
4
+ /** Local backend extension: drain workers/inflight before releasing the writer lock. */
5
+ shutdown(): Promise<void>;
6
+ }
7
+ export interface LocalBackendContractFactory {
8
+ (): LocalBackendConformanceSubject;
9
+ readonly lockedPair: () => {
10
+ readonly first: LocalBackendConformanceSubject;
11
+ readonly second: LocalBackendConformanceSubject;
12
+ };
13
+ readonly evidenceScenario: (mode: "success" | "finalization-failure") => {
14
+ readonly backend: LocalBackendConformanceSubject;
15
+ readonly indexingCalls: () => number;
16
+ };
17
+ readonly sealOnceScenario: () => {
18
+ readonly backend: LocalBackendConformanceSubject;
19
+ readonly restart: () => LocalBackendConformanceSubject;
20
+ };
21
+ }
22
+ /**
23
+ * The exact local binding suite. It registers the unchanged TEP core kit first, then exercises
24
+ * the binding-specific one-writer, one-attempt, seal-once, and evidence-capture postures.
25
+ */
26
+ export declare function describeLocalBackendContract(makeBackend: LocalBackendContractFactory): void;
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { TaskExecutionError } from "@jinn-network/task-execution-backend";
3
+ import { documentDigest, sealDelivery, sealSubmission, sealTask, } from "@jinn-network/task-execution-protocol";
4
+ import { describeTaskExecutionBackendContract, } from "../backend-contract.js";
5
+ import { describe, expect, test } from "vitest";
6
+ const PROFILE = {
7
+ uri: "https://spec.jinn.network/task-profiles/repository-work/1.0",
8
+ digest: {
9
+ sha256: "3917f0428b2626fd2cc93675172731cc000b69d7d783f9adaf5159be56fd10a6",
10
+ },
11
+ };
12
+ function taskBytes() {
13
+ return sealTask({
14
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
15
+ profile: PROFILE,
16
+ instructions: "Local backend conformance fixture.",
17
+ outputs: [{ name: "patch", mediaType: "text/x-diff", required: true }],
18
+ });
19
+ }
20
+ function submissionBytes(task, overrides = {}) {
21
+ return sealSubmission({
22
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
23
+ submission: `urn:uuid:${crypto.randomUUID()}`,
24
+ task: { digest: { sha256: documentDigest(task).slice("sha256:".length) } },
25
+ requester: `urn:uuid:${crypto.randomUUID()}`,
26
+ idempotencyKey: crypto.randomUUID(),
27
+ nonce: crypto.randomUUID(),
28
+ deadline: "2099-01-01T00:00:00Z",
29
+ ...overrides,
30
+ });
31
+ }
32
+ async function acceptedAttempt(backend, task, submission) {
33
+ const ack = await backend.submit(task, submission);
34
+ if (!ack.accepted)
35
+ throw new Error(`expected accepted: ${ack.error.category}`);
36
+ return (await backend.observe(ack.submission)).descriptor.attempt;
37
+ }
38
+ /**
39
+ * The exact local binding suite. It registers the unchanged TEP core kit first, then exercises
40
+ * the binding-specific one-writer, one-attempt, seal-once, and evidence-capture postures.
41
+ */
42
+ export function describeLocalBackendContract(makeBackend) {
43
+ describeTaskExecutionBackendContract(makeBackend);
44
+ describe("Local backend conformance (design §16)", () => {
45
+ test("two live instances on one root fail the second submit/recover as backend-unavailable", async () => {
46
+ const { first, second } = makeBackend.lockedPair();
47
+ const task = taskBytes();
48
+ const submission = submissionBytes(task);
49
+ const firstAck = await first.submit(task, submission);
50
+ if (!firstAck.accepted)
51
+ throw new Error("expected first instance acceptance");
52
+ const anotherTask = taskBytes();
53
+ const secondAck = await second.submit(anotherTask, submissionBytes(anotherTask));
54
+ expect(secondAck.accepted).toBe(false);
55
+ if (secondAck.accepted)
56
+ throw new Error("unreachable");
57
+ expect(secondAck.error).toBeInstanceOf(TaskExecutionError);
58
+ expect(secondAck.error.category).toBe("backend-unavailable");
59
+ await expect(second.recover(firstAck.submission)).rejects.toMatchObject({
60
+ category: "backend-unavailable",
61
+ });
62
+ });
63
+ test("single-party attempts bounds outside 1..1 are unsupported-requirement", async () => {
64
+ const backend = makeBackend();
65
+ const task = taskBytes();
66
+ const ack = await backend.submit(task, submissionBytes(task, { attempts: { maxTotal: 2 } }));
67
+ expect(ack.accepted).toBe(false);
68
+ if (ack.accepted)
69
+ throw new Error("unreachable");
70
+ expect(ack.error.category).toBe("unsupported-requirement");
71
+ });
72
+ test("seal-once recovery reuses the exact checkpoint after the scripted post-checkpoint crash", async () => {
73
+ const scenario = makeBackend.sealOnceScenario();
74
+ const task = taskBytes();
75
+ const attempt = await acceptedAttempt(scenario.backend, task, submissionBytes(task));
76
+ const delivery = sealDelivery({
77
+ protocol: "https://spec.jinn.network/profiles/task-execution/v1",
78
+ attempt,
79
+ task: documentDigest(task),
80
+ outputs: [],
81
+ outcome: "fulfilled",
82
+ createdAt: "2026-07-28T00:05:00Z",
83
+ });
84
+ await expect(scenario.backend.recordDelivery(attempt, delivery)).rejects.toThrow("scripted crash after checkpoint");
85
+ await scenario.backend.shutdown();
86
+ const recovered = scenario.restart();
87
+ expect(await recovered.recover(attempt)).toEqual({ classification: "matching" });
88
+ const refs = await recovered.deliveries(attempt);
89
+ expect(refs).toHaveLength(1);
90
+ expect(refs[0]?.digest).toBe(documentDigest(delivery));
91
+ expect(await recovered.fetchDelivery(refs[0])).toEqual(delivery);
92
+ });
93
+ test("capture always finalizes before delivered and indexing never gates Delivery", async () => {
94
+ const scenario = makeBackend.evidenceScenario("success");
95
+ const task = taskBytes();
96
+ const submission = submissionBytes(task);
97
+ const ack = await scenario.backend.submit(task, submission);
98
+ if (!ack.accepted)
99
+ throw new Error(`expected accepted: ${ack.error.category}`);
100
+ await scenario.backend.drain();
101
+ const snapshot = await scenario.backend.observe(ack.submission);
102
+ expect(snapshot.descriptor.derived.state).toBe("delivered");
103
+ const types = snapshot.observations.map(({ type }) => type);
104
+ expect(types.indexOf("network.jinn.task-execution.execution-observed.v1"))
105
+ .toBeLessThan(types.indexOf("network.jinn.task-execution.delivery-recorded.v1"));
106
+ expect(types.indexOf("network.jinn.task-execution.delivery-recorded.v1"))
107
+ .toBeLessThan(types.indexOf("network.jinn.task-execution.attempt-terminal.v1"));
108
+ expect(scenario.indexingCalls()).toBe(0);
109
+ const refs = await scenario.backend.deliveries(snapshot.descriptor.attempt);
110
+ const delivery = JSON.parse(new TextDecoder().decode(await scenario.backend.fetchDelivery(refs[0])));
111
+ expect(delivery.evidenceRecords).toHaveLength(1);
112
+ expect(delivery.executionIds).toHaveLength(1);
113
+ });
114
+ test("capture always finalization failure is failed[infrastructure] with no Delivery", async () => {
115
+ const { backend } = makeBackend.evidenceScenario("finalization-failure");
116
+ const task = taskBytes();
117
+ const ack = await backend.submit(task, submissionBytes(task));
118
+ if (!ack.accepted)
119
+ throw new Error(`expected accepted: ${ack.error.category}`);
120
+ await backend.drain();
121
+ const snapshot = await backend.observe(ack.submission);
122
+ expect(snapshot.descriptor.derived).toMatchObject({
123
+ state: "failed",
124
+ terminal: true,
125
+ blame: "infrastructure",
126
+ });
127
+ expect(await backend.deliveries(snapshot.descriptor.attempt)).toEqual([]);
128
+ });
129
+ });
130
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Test-infra-only canonicalization for PINNING fixture digests (design §16, program §7.14). This
3
+ * is NOT one of the tree's per-package sealing implementations (Global Constraints — those live
4
+ * in each of the four backend-local packages' own `order.ts`/`canonical-json.ts`, copied
5
+ * verbatim and re-implemented per package); it exists only so this kit's fixtures can carry a
6
+ * golden sha256 digest that a real implementation (Milestone A5 onward) is expected to
7
+ * reproduce once it seals the same logical record through its OWN canonical serializer. The
8
+ * sorting rule is identical (UTF-16 code-unit order, never `localeCompare`) so the pinned
9
+ * digests are meaningful golden values, not incidental to this file's own ordering choice.
10
+ */
11
+ /** UTF-16 code-unit order — never `localeCompare` (see any package's `order.ts`). */
12
+ export declare function compareCodeUnitStrings(left: string, right: string): number;
13
+ /** Deterministic canonical-JSON string for a fixture record (sorted keys, code-unit order). */
14
+ export declare function serializeCanonicalFixture(value: unknown): string;