@openwop/openwop-conformance 1.1.0 → 1.2.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 (97) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +2 -2
  3. package/coverage.md +29 -17
  4. package/fixtures/conformance-agent-low-confidence.json +7 -4
  5. package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
  6. package/fixtures/conformance-agent-reasoning.json +23 -4
  7. package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
  8. package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
  9. package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
  10. package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
  11. package/fixtures/conformance-dispatch-input-mapping.json +49 -0
  12. package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
  13. package/fixtures/conformance-dispatch-output-mapping.json +49 -0
  14. package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
  15. package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
  16. package/fixtures.md +12 -2
  17. package/package.json +1 -1
  18. package/schemas/README.md +7 -0
  19. package/schemas/agent-ref.schema.json +1 -1
  20. package/schemas/ai-envelope.schema.json +106 -0
  21. package/schemas/capabilities.schema.json +300 -3
  22. package/schemas/core-conformance-mock-agent-config.schema.json +147 -0
  23. package/schemas/dispatch-config.schema.json +26 -0
  24. package/schemas/envelopes/clarification.request.schema.json +43 -0
  25. package/schemas/envelopes/error.schema.json +26 -0
  26. package/schemas/envelopes/schema.request.schema.json +22 -0
  27. package/schemas/envelopes/schema.response.schema.json +22 -0
  28. package/schemas/node-pack-manifest.schema.json +5 -0
  29. package/schemas/pack-lockfile.schema.json +16 -0
  30. package/schemas/run-event-payloads.schema.json +18 -2
  31. package/schemas/run-event.schema.json +2 -1
  32. package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
  33. package/src/lib/behavior-gate.ts +44 -5
  34. package/src/lib/env.ts +27 -0
  35. package/src/lib/webhook-receiver.ts +137 -0
  36. package/src/lib/workflow-chain-expansion.ts +213 -0
  37. package/src/scenarios/agentPackCatalog.test.ts +216 -0
  38. package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
  39. package/src/scenarios/agentReasoningEvents.test.ts +58 -7
  40. package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
  41. package/src/scenarios/ai-envelope-shape.test.ts +362 -0
  42. package/src/scenarios/aiEnvelope.capBreached.test.ts +173 -0
  43. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +150 -0
  44. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +69 -0
  45. package/src/scenarios/aiEnvelope.redaction.test.ts +73 -0
  46. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +87 -0
  47. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +143 -0
  48. package/src/scenarios/aiEnvelope.universalKinds.test.ts +176 -0
  49. package/src/scenarios/append-ordering.test.ts +44 -0
  50. package/src/scenarios/artifact-auth.test.ts +58 -0
  51. package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
  52. package/src/scenarios/blob-presign-expiry.test.ts +66 -0
  53. package/src/scenarios/blob-roundtrip.test.ts +48 -0
  54. package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
  55. package/src/scenarios/cache-ttl-expiry.test.ts +47 -0
  56. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +98 -0
  57. package/src/scenarios/dispatch-input-mapping.test.ts +94 -0
  58. package/src/scenarios/dispatch-output-mapping.test.ts +65 -0
  59. package/src/scenarios/fs-path-traversal.test.ts +124 -0
  60. package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
  61. package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
  62. package/src/scenarios/kv-atomic-increment.test.ts +74 -0
  63. package/src/scenarios/kv-cas.test.ts +75 -0
  64. package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
  65. package/src/scenarios/kv-ttl-expiry.test.ts +47 -0
  66. package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
  67. package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
  68. package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
  69. package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
  70. package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
  71. package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
  72. package/src/scenarios/mcp-tool-roundtrip.test.ts +13 -6
  73. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  74. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  75. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  76. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  77. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  78. package/src/scenarios/pause-resume.test.ts +43 -0
  79. package/src/scenarios/queue-ack-nack-dlq.test.ts +67 -0
  80. package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
  81. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +48 -0
  82. package/src/scenarios/registry-public.test.ts +91 -0
  83. package/src/scenarios/search-bm25-roundtrip.test.ts +47 -0
  84. package/src/scenarios/spec-corpus-validity.test.ts +28 -7
  85. package/src/scenarios/sql-injection-rejection.test.ts +84 -0
  86. package/src/scenarios/sql-transaction-atomicity.test.ts +66 -0
  87. package/src/scenarios/stream-subscribe-from-beginning.test.ts +66 -0
  88. package/src/scenarios/subworkflow-input-mapping.test.ts +100 -0
  89. package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
  90. package/src/scenarios/table-cursor-pagination.test.ts +47 -0
  91. package/src/scenarios/table-schema-enforcement.test.ts +47 -0
  92. package/src/scenarios/vector-knn-roundtrip.test.ts +48 -0
  93. package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
  94. package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
  95. package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
  96. package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
  97. package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * fs-path-traversal — RFC 0014 §C invariant verification.
3
+ *
4
+ * Status: ACTIVE. RFC 0014 promoted to `Active` 2026-05-17. The
5
+ * `capabilities.fs` block has landed in `schemas/capabilities.schema.json`
6
+ * and the invariant row `fs-path-traversal` is in `SECURITY/invariants.yaml`.
7
+ *
8
+ * Capability-gated: skips when the host does not advertise
9
+ * `capabilities.fs.supported = true`.
10
+ *
11
+ * What this scenario asserts:
12
+ * 1. Advertisement shape — `capabilities.fs` is either absent or a
13
+ * well-formed object; when `supported: true`, `sandboxRoot` is
14
+ * non-empty (RFC 0014 §A).
15
+ * 2. Path-traversal MUST-NOT — when the host exposes the optional
16
+ * `POST /v1/host/sample/fs/read` test seam, absolute paths outside
17
+ * the sandbox AND relative `../` escapes MUST be rejected with a
18
+ * 4xx envelope whose `error.code` is in the canonical rejection set.
19
+ *
20
+ * Hosts without the test seam soft-skip the path-escape steps and still
21
+ * assert the advertisement shape. This lets the suite pass against the
22
+ * stricter hosts that don't expose any unauthenticated fs probe.
23
+ *
24
+ * @see RFCS/0014-host-fs-capability.md
25
+ * @see SECURITY/invariants.yaml id: fs-path-traversal
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { driver } from '../lib/driver.js';
30
+
31
+ interface DiscoveryFs {
32
+ supported?: boolean;
33
+ sandboxRoot?: string;
34
+ maxFileSizeBytes?: number;
35
+ }
36
+
37
+ interface DiscoveryDoc {
38
+ capabilities?: {
39
+ fs?: DiscoveryFs;
40
+ };
41
+ }
42
+
43
+ async function readFs(): Promise<DiscoveryFs | null> {
44
+ const res = await driver.get('/.well-known/openwop');
45
+ const body = res.json as DiscoveryDoc | undefined;
46
+ return body?.capabilities?.fs ?? null;
47
+ }
48
+
49
+ const PATH_REJECTION_CODES: ReadonlySet<string> = new Set([
50
+ 'path_outside_sandbox',
51
+ 'fs_path_traversal',
52
+ 'invalid_path',
53
+ ]);
54
+
55
+ describe('fs-path-traversal: advertisement shape (RFC 0014 §A)', () => {
56
+ it('capabilities.fs is either absent or well-formed', async () => {
57
+ const fs = await readFs();
58
+ if (fs === null) return; // host doesn't advertise fs at all
59
+ expect(
60
+ typeof fs.supported,
61
+ driver.describe(
62
+ 'capabilities.schema.json §fs',
63
+ 'capabilities.fs.supported MUST be a boolean when fs is advertised',
64
+ ),
65
+ ).toBe('boolean');
66
+ });
67
+
68
+ it('fs.sandboxRoot is set + non-empty when fs.supported=true', async () => {
69
+ const fs = await readFs();
70
+ if (!fs?.supported) return;
71
+ expect(
72
+ typeof fs.sandboxRoot,
73
+ driver.describe(
74
+ 'RFC 0014 §A',
75
+ 'capabilities.fs.sandboxRoot MUST be present when fs.supported=true',
76
+ ),
77
+ ).toBe('string');
78
+ expect(
79
+ (fs.sandboxRoot ?? '').length,
80
+ driver.describe('RFC 0014 §A', 'capabilities.fs.sandboxRoot MUST NOT be empty'),
81
+ ).toBeGreaterThan(0);
82
+ });
83
+ });
84
+
85
+ describe('fs-path-traversal: MUST-NOT escape sandboxRoot (RFC 0014 §C)', () => {
86
+ it('absolute path outside sandbox is rejected', async () => {
87
+ const fs = await readFs();
88
+ if (!fs?.supported) return;
89
+ const res = await driver.post('/v1/host/sample/fs/read', { path: '/etc/passwd' });
90
+ // 404 from a host that hasn't wired the test seam is a soft-skip.
91
+ if (res.status === 404) return;
92
+ expect(
93
+ res.status,
94
+ driver.describe(
95
+ 'SECURITY/invariants.yaml fs-path-traversal',
96
+ 'absolute paths outside sandboxRoot MUST be rejected with a 4xx envelope',
97
+ ),
98
+ ).toBeGreaterThanOrEqual(400);
99
+ const code = (res.json as { error?: { code?: string } } | undefined)?.error?.code;
100
+ expect(
101
+ code !== undefined && PATH_REJECTION_CODES.has(code),
102
+ driver.describe(
103
+ 'SECURITY/invariants.yaml fs-path-traversal',
104
+ `error.code MUST be one of {${[...PATH_REJECTION_CODES].join(', ')}}, got: ${code ?? '(absent)'}`,
105
+ ),
106
+ ).toBe(true);
107
+ });
108
+
109
+ it('relative ../ path escape is rejected', async () => {
110
+ const fs = await readFs();
111
+ if (!fs?.supported) return;
112
+ const res = await driver.post('/v1/host/sample/fs/read', { path: '../../etc/passwd' });
113
+ if (res.status === 404) return;
114
+ expect(res.status).toBeGreaterThanOrEqual(400);
115
+ const code = (res.json as { error?: { code?: string } } | undefined)?.error?.code;
116
+ expect(
117
+ code !== undefined && PATH_REJECTION_CODES.has(code),
118
+ driver.describe(
119
+ 'SECURITY/invariants.yaml fs-path-traversal',
120
+ `error.code MUST be one of {${[...PATH_REJECTION_CODES].join(', ')}}, got: ${code ?? '(absent)'}`,
121
+ ),
122
+ ).toBe(true);
123
+ });
124
+ });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * core.openwop.http.idempotency-key — determinism contract
3
+ *
4
+ * Verifies the normative MUST from `idempotency.md §"Idempotency-Key"`
5
+ * and the pack's documented purpose (retry-safety): identical
6
+ * `(runId, nodeId, payload)` → identical key, so the remote endpoint
7
+ * can dedupe across retries.
8
+ *
9
+ * Hardens against the 1.1.0 / 1.1.1 defect where the default `uuid`
10
+ * mode returned a fresh `randomUUID()` per call, defeating retry-safety
11
+ * (each retry produced a different key → remote treated every attempt
12
+ * as a new request). Removed in 1.1.2 as a safety-fix per
13
+ * `COMPATIBILITY.md §3` — see CHANGELOG `[Unreleased]` §"core.openwop.http@1.1.2".
14
+ *
15
+ * Server-free. Loads the pack module via dynamic import and asserts:
16
+ *
17
+ * 1. Default mode (`composite`) produces a deterministic key for
18
+ * identical inputs.
19
+ * 2. Payload sensitivity — same run+node, different payload → different key.
20
+ * 3. Run isolation — same node+payload, different run → different key.
21
+ * 4. Node isolation — same run+payload, different node → different key.
22
+ * 5. `hash` mode is deterministic in the payload alone (run+node ignored).
23
+ * 6. Output shape matches the canonical `^openwop-[0-9a-f]{16}$` pattern
24
+ * (per `idempotency-key.output.json` 1.1.2).
25
+ * 7. Removed `uuid` mode rejects with `CONFIG_INVALID` — the safety-fix
26
+ * surfaces loudly rather than silently producing weak keys.
27
+ * 8. Pack output matches the canonical SHA-256 formula computed
28
+ * independently in this test — third-party pack reimplementations
29
+ * can run the same scenario against their runtime to verify
30
+ * cross-impl agreement.
31
+ *
32
+ * Skip-conditions: none. The scenario validates the spec corpus +
33
+ * reference pack source directly; runs in any environment with the
34
+ * repo checked out.
35
+ *
36
+ * @see spec/v1/idempotency.md §"Idempotency-Key"
37
+ * @see packs/core.openwop.http/schemas/idempotency-key.config.json
38
+ * @see packs/core.openwop.http/schemas/idempotency-key.output.json
39
+ * @see SECURITY/invariants.yaml#idempotency-key-deterministic
40
+ */
41
+
42
+ import { describe, it, expect, beforeAll } from 'vitest';
43
+ import { createHash } from 'node:crypto';
44
+ import { existsSync } from 'node:fs';
45
+ import { dirname, resolve } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+
48
+ const __dirname = dirname(fileURLToPath(import.meta.url));
49
+ const PACK_PATH = resolve(__dirname, '../../../packs/core.openwop.http/index.mjs');
50
+
51
+ interface IdempotencyKeyCtx {
52
+ config?: { mode?: string };
53
+ inputs?: { payload?: unknown; runId?: string; nodeId?: string };
54
+ runId?: string;
55
+ nodeId?: string;
56
+ }
57
+
58
+ interface IdempotencyKeyResult {
59
+ status: 'success';
60
+ outputs: { key: string };
61
+ }
62
+
63
+ type IdempotencyKeyFn = (ctx: IdempotencyKeyCtx) => Promise<IdempotencyKeyResult>;
64
+
65
+ const KEY_PATTERN = /^openwop-[0-9a-f]{16}$/;
66
+
67
+ /**
68
+ * Canonical formula per `idempotency-key.{config,output}.json` (1.1.2):
69
+ * sha256( runId || \0 || nodeId || \0 || JSON.stringify(payload) )
70
+ * key = 'openwop-' + digest.hex().slice(0, 16)
71
+ *
72
+ * Mirrors `deriveIdempotencyKey` in `packs/core.openwop.http/index.mjs`
73
+ * (the same formula `core.openwop.http.fetch` uses internally). Inlined
74
+ * here so the test verifies the spec contract independent of the pack
75
+ * runtime — a third-party reimplementation that follows the spec MUST
76
+ * produce identical keys for these vectors.
77
+ */
78
+ function canonicalCompositeKey(runId: string, nodeId: string, payload: unknown): string {
79
+ const h = createHash('sha256');
80
+ h.update(runId, 'utf8');
81
+ h.update('\0', 'utf8');
82
+ h.update(nodeId, 'utf8');
83
+ h.update('\0', 'utf8');
84
+ if (payload !== undefined && payload !== null) {
85
+ h.update(JSON.stringify(payload), 'utf8');
86
+ }
87
+ return 'openwop-' + h.digest('hex').slice(0, 16);
88
+ }
89
+
90
+ function canonicalHashKey(payload: unknown): string {
91
+ const h = createHash('sha256').update(JSON.stringify(payload ?? null), 'utf8').digest('hex');
92
+ return 'openwop-' + h.slice(0, 16);
93
+ }
94
+
95
+ describe('category: core.openwop.http.idempotency-key — determinism contract', () => {
96
+ let idempotencyKey: IdempotencyKeyFn;
97
+ let packAvailable: boolean;
98
+
99
+ beforeAll(async () => {
100
+ packAvailable = existsSync(PACK_PATH);
101
+ if (!packAvailable) return;
102
+ const mod = (await import(PACK_PATH)) as { idempotencyKey?: IdempotencyKeyFn };
103
+ if (typeof mod.idempotencyKey !== 'function') {
104
+ throw new Error(
105
+ `expected packs/core.openwop.http/index.mjs to export idempotencyKey; got ${typeof mod.idempotencyKey}`,
106
+ );
107
+ }
108
+ idempotencyKey = mod.idempotencyKey;
109
+ });
110
+
111
+ it('skips cleanly when packs/ is not bundled', () => {
112
+ // This scenario reads the in-tree pack source. When the conformance
113
+ // suite is consumed as a published npm package, packs/ isn't shipped
114
+ // — the scenario soft-skips rather than failing.
115
+ if (!packAvailable) {
116
+ console.warn(`[idempotency-key-determinism] packs/core.openwop.http/index.mjs not present; skipping`);
117
+ expect(packAvailable).toBe(false);
118
+ return;
119
+ }
120
+ expect(packAvailable).toBe(true);
121
+ });
122
+
123
+ it('default mode (composite) — identical (runId, nodeId, payload) produces identical keys', async () => {
124
+ if (!packAvailable) return;
125
+ const ctx: IdempotencyKeyCtx = {
126
+ runId: 'run-1',
127
+ nodeId: 'node-1',
128
+ inputs: { payload: { hello: 'world' } },
129
+ };
130
+ const a = await idempotencyKey(ctx);
131
+ const b = await idempotencyKey(ctx);
132
+ expect(a.outputs.key, 'idempotency.md §Idempotency-Key: same logical request MUST produce same key').toBe(b.outputs.key);
133
+ expect(a.outputs.key).toMatch(KEY_PATTERN);
134
+ });
135
+
136
+ it('payload sensitivity — different payload produces different key', async () => {
137
+ if (!packAvailable) return;
138
+ const baseCtx = { runId: 'run-1', nodeId: 'node-1' };
139
+ const a = await idempotencyKey({ ...baseCtx, inputs: { payload: { hello: 'world' } } });
140
+ const b = await idempotencyKey({ ...baseCtx, inputs: { payload: { hello: 'CHANGED' } } });
141
+ expect(a.outputs.key).not.toBe(b.outputs.key);
142
+ });
143
+
144
+ it('run isolation — different runId produces different key', async () => {
145
+ if (!packAvailable) return;
146
+ const payload = { hello: 'world' };
147
+ const a = await idempotencyKey({ runId: 'run-1', nodeId: 'node-1', inputs: { payload } });
148
+ const b = await idempotencyKey({ runId: 'run-2', nodeId: 'node-1', inputs: { payload } });
149
+ expect(a.outputs.key).not.toBe(b.outputs.key);
150
+ });
151
+
152
+ it('node isolation — different nodeId produces different key', async () => {
153
+ if (!packAvailable) return;
154
+ const payload = { hello: 'world' };
155
+ const a = await idempotencyKey({ runId: 'run-1', nodeId: 'node-A', inputs: { payload } });
156
+ const b = await idempotencyKey({ runId: 'run-1', nodeId: 'node-B', inputs: { payload } });
157
+ expect(a.outputs.key).not.toBe(b.outputs.key);
158
+ });
159
+
160
+ it('hash mode is deterministic in the payload alone (run/node ignored)', async () => {
161
+ if (!packAvailable) return;
162
+ const payload = { request: 'payload-here', id: 42 };
163
+ const a = await idempotencyKey({ config: { mode: 'hash' }, runId: 'run-1', nodeId: 'node-1', inputs: { payload } });
164
+ const b = await idempotencyKey({ config: { mode: 'hash' }, runId: 'run-2', nodeId: 'node-2', inputs: { payload } });
165
+ expect(a.outputs.key, 'hash mode MUST ignore run/node — useful for global remote-dedup caches').toBe(b.outputs.key);
166
+ expect(a.outputs.key).toMatch(KEY_PATTERN);
167
+ });
168
+
169
+ it('output shape — every emitted key matches openwop-<sha256-prefix-16>', async () => {
170
+ if (!packAvailable) return;
171
+ const samples: IdempotencyKeyCtx[] = [
172
+ { runId: 'r', nodeId: 'n', inputs: { payload: null } },
173
+ { runId: 'r', nodeId: 'n', inputs: { payload: 'string-payload' } },
174
+ { runId: 'r', nodeId: 'n', inputs: { payload: [1, 2, 3] } },
175
+ { runId: 'r', nodeId: 'n', inputs: { payload: { nested: { value: true } } } },
176
+ { config: { mode: 'hash' }, inputs: { payload: 'x' } },
177
+ ];
178
+ for (const ctx of samples) {
179
+ const result = await idempotencyKey(ctx);
180
+ expect(result.outputs.key, `key shape for ctx=${JSON.stringify(ctx)}`).toMatch(KEY_PATTERN);
181
+ }
182
+ });
183
+
184
+ it('uuid mode rejects with CONFIG_INVALID (safety-fix per 1.1.2)', async () => {
185
+ if (!packAvailable) return;
186
+ // The removed mode names the safety-fix in its error message so
187
+ // pinned-version callers diagnosing a regression find the fix.
188
+ let caught: unknown;
189
+ try {
190
+ await idempotencyKey({ config: { mode: 'uuid' }, inputs: { payload: 'x' } });
191
+ } catch (err) {
192
+ caught = err;
193
+ }
194
+ expect(caught, 'mode: uuid MUST be rejected — the 1.1.0/1.1.1 non-deterministic default was removed').toBeInstanceOf(Error);
195
+ expect((caught as Error & { code?: string }).code).toBe('CONFIG_INVALID');
196
+ expect((caught as Error).message).toMatch(/uuid.*removed|safety-fix/i);
197
+ });
198
+
199
+ it('cross-impl invariant — pack output equals canonical SHA-256 formula', async () => {
200
+ if (!packAvailable) return;
201
+ // Five fixed vectors. A third-party core.openwop.http reimplementation
202
+ // can run the same scenario against its runtime — agreement on these
203
+ // vectors proves wire-level interop on the Idempotency-Key shape.
204
+ const vectors = [
205
+ { runId: 'run-A', nodeId: 'node-1', payload: { task: 'create-charge', amount: 100 } },
206
+ { runId: 'run-A', nodeId: 'node-2', payload: { task: 'create-charge', amount: 100 } },
207
+ { runId: 'run-B', nodeId: 'node-1', payload: { task: 'create-charge', amount: 100 } },
208
+ { runId: 'r', nodeId: 'n', payload: null },
209
+ { runId: 'r', nodeId: 'n', payload: 'just-a-string' },
210
+ ];
211
+ for (const v of vectors) {
212
+ const packed = await idempotencyKey({
213
+ runId: v.runId,
214
+ nodeId: v.nodeId,
215
+ inputs: { payload: v.payload },
216
+ });
217
+ const expected = canonicalCompositeKey(v.runId, v.nodeId, v.payload);
218
+ expect(
219
+ packed.outputs.key,
220
+ `vector ${JSON.stringify(v)} — pack output MUST match canonical sha256(runId\\0nodeId\\0JSON(payload))`,
221
+ ).toBe(expected);
222
+ }
223
+
224
+ // Hash-mode vectors: same formula independent of run/node.
225
+ for (const payload of [null, 'x', { nested: 1 }, [1, 2]]) {
226
+ const packed = await idempotencyKey({ config: { mode: 'hash' }, inputs: { payload } });
227
+ expect(packed.outputs.key).toBe(canonicalHashKey(payload));
228
+ }
229
+ });
230
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * CF-3 close-out — interrupt token matrix coverage per
3
+ * `plans/openwop-protocol-gap-closure-plan.md` Workstream 2.
4
+ *
5
+ * Verifies the negative + replay paths on `GET /v1/interrupts/{token}`
6
+ * and `POST /v1/interrupts/{token}` that complement the existing
7
+ * positive-path coverage in `interrupt-external-event-correlation.test.ts`:
8
+ *
9
+ * 1. Malformed token (random bytes) — inspect MUST return 400 or 404.
10
+ * 2. Unknown token (well-formed but no interrupt) — inspect MUST
11
+ * return 404 with `not_found` or similar.
12
+ * 3. Already-resolved token — resolve once (positive path),
13
+ * then replay the same `POST` — MUST return 409 or 404 (host
14
+ * MAY treat already-resolved as gone OR as conflict).
15
+ * 4. Wrong action — `POST` with a payload whose `action` field is
16
+ * NOT in the interrupt's allowed-actions list MUST return 400
17
+ * `validation_error`.
18
+ * 5. Cross-run-id leak — synthesize a token with `runId=other-run`
19
+ * embedded but valid HMAC envelope — MUST return 401 or 404
20
+ * (NEVER 200 from a different run's scope).
21
+ *
22
+ * Gating identical to interrupt-external-event-correlation: skips when
23
+ * the `conformance-external-event` fixture isn't advertised.
24
+ *
25
+ * @see spec/v1/interrupt.md §"Signed-token callback"
26
+ * @see spec/v1/rest-endpoints.md §"GET /v1/interrupts/{token}" + §"POST /v1/interrupts/{token}"
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest';
30
+ import { driver } from '../lib/driver.js';
31
+ import { pollUntilStatus } from '../lib/polling.js';
32
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
33
+
34
+ const FIXTURE = 'conformance-external-event';
35
+ const SKIP = !isFixtureAdvertised(FIXTURE);
36
+
37
+ function randomBytesB64(length: number): string {
38
+ return Buffer.from(
39
+ Array.from({ length }, () => Math.floor(Math.random() * 256)),
40
+ ).toString('base64url');
41
+ }
42
+
43
+ describe.skipIf(SKIP)('interrupt-token-matrix: GET /v1/interrupts/{token} negative paths', () => {
44
+ it('malformed token returns 400 or 404 (NEVER 200)', async () => {
45
+ const malformed = '!!!not-a-valid-token!!!';
46
+ const res = await driver.get(`/v1/interrupts/${encodeURIComponent(malformed)}`);
47
+ expect([400, 404]).toContain(res.status);
48
+ expect(res.status, driver.describe(
49
+ 'rest-endpoints.md GET /v1/interrupts/{token}',
50
+ 'malformed interrupt token MUST NOT return 200',
51
+ )).not.toBe(200);
52
+ });
53
+
54
+ it('well-formed but unknown token returns 404', async () => {
55
+ // Plausibly-shaped opaque token that the host has no record of.
56
+ const unknown = `tok_${randomBytesB64(32)}`;
57
+ const res = await driver.get(`/v1/interrupts/${encodeURIComponent(unknown)}`);
58
+ expect(res.status, driver.describe(
59
+ 'rest-endpoints.md GET /v1/interrupts/{token}',
60
+ 'unknown interrupt token MUST return 404',
61
+ )).toBe(404);
62
+ });
63
+ });
64
+
65
+ describe.skipIf(SKIP)('interrupt-token-matrix: POST /v1/interrupts/{token} negative paths', () => {
66
+ it('replay after successful resolve returns 409 or 404', async () => {
67
+ // Drive a run to suspension; capture the real token; resolve once;
68
+ // replay the same POST and assert it doesn't succeed twice.
69
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
70
+ expect(create.status).toBe(201);
71
+ const runId = (create.json as { runId: string }).runId;
72
+
73
+ await pollUntilStatus(runId, 'waiting-external-event', { timeoutMs: 10_000 }).catch(() => null);
74
+
75
+ const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
76
+ const interrupts =
77
+ ((snap.json as { interrupts?: Array<{ token?: string }> }).interrupts ?? [])
78
+ .filter((i) => typeof i.token === 'string');
79
+ const token = interrupts[0]?.token;
80
+ if (typeof token !== 'string') {
81
+ // eslint-disable-next-line no-console
82
+ console.warn('[interrupt-token-matrix] host did not surface an interrupt token; skipping replay subtest');
83
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
84
+ reason: 'conformance-cleanup',
85
+ });
86
+ return;
87
+ }
88
+
89
+ const resolve1 = await driver.post(`/v1/interrupts/${encodeURIComponent(token)}`, {
90
+ correlation: { orderId: 'order-token-matrix', status: 'accepted' },
91
+ });
92
+ if (resolve1.status !== 200 && resolve1.status !== 202) {
93
+ // eslint-disable-next-line no-console
94
+ console.warn(
95
+ `[interrupt-token-matrix] first resolve returned ${resolve1.status}; can't exercise replay path. Skipping.`,
96
+ );
97
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
98
+ reason: 'conformance-cleanup',
99
+ });
100
+ return;
101
+ }
102
+
103
+ const resolve2 = await driver.post(`/v1/interrupts/${encodeURIComponent(token)}`, {
104
+ correlation: { orderId: 'order-token-matrix', status: 'accepted' },
105
+ });
106
+ expect([404, 409, 410], driver.describe(
107
+ 'rest-endpoints.md POST /v1/interrupts/{token}',
108
+ 'replay of an already-resolved interrupt token MUST NOT return 2xx (host MAY 404/409/410)',
109
+ )).toContain(resolve2.status);
110
+
111
+ await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
112
+ reason: 'conformance-cleanup',
113
+ });
114
+ });
115
+
116
+ it('unknown token returns 404 on POST', async () => {
117
+ const unknown = `tok_${randomBytesB64(32)}`;
118
+ const res = await driver.post(`/v1/interrupts/${encodeURIComponent(unknown)}`, {
119
+ correlation: { orderId: 'noop', status: 'whatever' },
120
+ });
121
+ expect(res.status, driver.describe(
122
+ 'rest-endpoints.md POST /v1/interrupts/{token}',
123
+ 'POST on an unknown interrupt token MUST return 404',
124
+ )).toBe(404);
125
+ });
126
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * kv-atomic-increment — RFC 0015 §B point 4 (atomic increment).
3
+ *
4
+ * Status: ACTIVE (advertisement + behavioral). Behavioral half drives N
5
+ * concurrent +1 increments through the reference-host test seam and asserts
6
+ * the final value equals N. Hosts that don't expose the seam soft-skip.
7
+ *
8
+ * @see RFCS/0015-host-kv-storage-capability.md
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { driver } from '../lib/driver.js';
13
+
14
+ interface DiscoveryDoc {
15
+ capabilities?: Record<string, unknown>;
16
+ }
17
+
18
+ async function readCap(): Promise<Record<string, unknown> | null> {
19
+ const res = await driver.get('/.well-known/openwop');
20
+ const body = res.json as DiscoveryDoc | undefined;
21
+ const top = body?.capabilities as Record<string, unknown> | undefined;
22
+ const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["kvStorage"] : undefined;
23
+ return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
24
+ }
25
+
26
+ async function call(op: string, args: Record<string, unknown>) {
27
+ return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'kv', op, args });
28
+ }
29
+
30
+ describe('kv-atomic-increment: advertisement shape (RFC 0015)', () => {
31
+ it('capabilities.kvStorage is either absent or a well-formed object', async () => {
32
+ const cap = await readCap();
33
+ if (cap === null) return;
34
+ expect(
35
+ typeof cap.supported,
36
+ driver.describe(
37
+ 'capabilities.schema.json §kvStorage',
38
+ 'capabilities.kvStorage.supported MUST be a boolean when present',
39
+ ),
40
+ ).toBe('boolean');
41
+ });
42
+
43
+ it('atomicIncrement is a boolean when set', async () => {
44
+ const cap = await readCap();
45
+ if (!cap || cap.supported !== true) return;
46
+ const sub = cap.atomicIncrement;
47
+ if (sub === undefined) return;
48
+ expect(typeof sub, driver.describe('RFC 0015 §A', 'atomicIncrement MUST be boolean when present')).toBe('boolean');
49
+ });
50
+ });
51
+
52
+ describe('kv-atomic-increment: behavioral (RFC 0015 §B point 4)', () => {
53
+ it('N concurrent +1 increments converge to exactly N', async () => {
54
+ const cap = await readCap();
55
+ if (!cap || cap.supported !== true || cap.atomicIncrement !== true) return;
56
+ const key = `atomic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
57
+ const probe = await call('atomicIncrement', { key, delta: 0 });
58
+ if (probe.status === 404) return; // seam not exposed
59
+
60
+ const N = 50;
61
+ const results = await Promise.all(
62
+ Array.from({ length: N }, () => call('atomicIncrement', { key, delta: 1 })),
63
+ );
64
+ for (const r of results) {
65
+ expect(r.status, 'each increment MUST succeed').toBe(200);
66
+ }
67
+ const finalRes = await call('get', { key });
68
+ const finalBody = finalRes.json as { value?: unknown };
69
+ expect(
70
+ finalBody.value,
71
+ driver.describe('RFC 0015 §B point 4', `${N} concurrent increments MUST converge to exactly ${N}`),
72
+ ).toBe(N);
73
+ });
74
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * kv-cas — RFC 0015 §B point 5 (compare-and-swap atomicity).
3
+ *
4
+ * Status: ACTIVE (advertisement + behavioral). Asserts that a matching
5
+ * `expect` swaps and a stale `expect` rejects with `swapped:false` and
6
+ * returns the current actual value.
7
+ *
8
+ * @see RFCS/0015-host-kv-storage-capability.md
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { driver } from '../lib/driver.js';
13
+
14
+ interface DiscoveryDoc {
15
+ capabilities?: Record<string, unknown>;
16
+ }
17
+
18
+ async function readCap(): Promise<Record<string, unknown> | null> {
19
+ const res = await driver.get('/.well-known/openwop');
20
+ const body = res.json as DiscoveryDoc | undefined;
21
+ const top = body?.capabilities as Record<string, unknown> | undefined;
22
+ const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["kvStorage"] : undefined;
23
+ return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
24
+ }
25
+
26
+ async function call(op: string, args: Record<string, unknown>) {
27
+ return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'kv', op, args });
28
+ }
29
+
30
+ describe('kv-cas: advertisement shape (RFC 0015)', () => {
31
+ it('capabilities.kvStorage is either absent or a well-formed object', async () => {
32
+ const cap = await readCap();
33
+ if (cap === null) return;
34
+ expect(
35
+ typeof cap.supported,
36
+ driver.describe(
37
+ 'capabilities.schema.json §kvStorage',
38
+ 'capabilities.kvStorage.supported MUST be a boolean when present',
39
+ ),
40
+ ).toBe('boolean');
41
+ });
42
+
43
+ it('compareAndSwap is a boolean when set', async () => {
44
+ const cap = await readCap();
45
+ if (!cap || cap.supported !== true) return;
46
+ const sub = cap.compareAndSwap;
47
+ if (sub === undefined) return;
48
+ expect(typeof sub, driver.describe('RFC 0015 §A', 'compareAndSwap MUST be boolean when present')).toBe('boolean');
49
+ });
50
+ });
51
+
52
+ describe('kv-cas: behavioral (RFC 0015 §B point 5)', () => {
53
+ it('CAS with matching expect succeeds; stale expect fails with swapped:false', async () => {
54
+ const cap = await readCap();
55
+ if (!cap || cap.supported !== true || cap.compareAndSwap !== true) return;
56
+ const key = `cas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
57
+
58
+ const setRes = await call('set', { key, value: 'v1' });
59
+ if (setRes.status === 404) return;
60
+ expect(setRes.status).toBe(200);
61
+
62
+ // Matching expect → swaps.
63
+ const okRes = await call('cas', { key, expect: 'v1', set: 'v2' });
64
+ expect(okRes.status, 'matching CAS MUST 200').toBe(200);
65
+ const okBody = okRes.json as { swapped?: boolean };
66
+ expect(okBody.swapped, 'matching expect MUST swap').toBe(true);
67
+
68
+ // Stale expect → no swap.
69
+ const staleRes = await call('cas', { key, expect: 'v1', set: 'v3' });
70
+ expect(staleRes.status, 'stale CAS MUST 200 (CAS is non-throwing)').toBe(200);
71
+ const staleBody = staleRes.json as { swapped?: boolean; actual?: unknown };
72
+ expect(staleBody.swapped, 'stale expect MUST NOT swap').toBe(false);
73
+ expect(staleBody.actual, 'stale CAS MUST surface current value').toBe('v2');
74
+ });
75
+ });