@openwop/openwop-conformance 1.0.0 → 1.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 (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +293 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -0,0 +1,342 @@
1
+ /**
2
+ * RFC 0009 §B: production-profile backpressure envelope.
3
+ *
4
+ * Verifies that hosts claiming the `openwop-production` profile satisfy
5
+ * `spec/v1/production-profile.md` §Backpressure:
6
+ *
7
+ * 1. `capabilities.production.backpressure.supported: true` is
8
+ * advertised when the profile claim is `supported: true`.
9
+ * 2. When the host advertises an `inflightCap`, the suite saturates
10
+ * it with `inflightCap + 1` concurrent long-lived requests and
11
+ * asserts the 503 envelope shape: status 503 + `Retry-After`
12
+ * header + body `{error: "service_unavailable", message, details:
13
+ * {retryAfter: <integer>}}` where `details.retryAfter` equals the
14
+ * `Retry-After` header in seconds.
15
+ * 3. When `retryAfterSeconds` is also advertised, both equal that
16
+ * advertised value.
17
+ * 4. Discovery (`GET /.well-known/openwop`) is exempt from the
18
+ * inflight cap (health probes MUST answer even under load).
19
+ *
20
+ * Hosts that don't advertise `inflightCap` soft-skip the saturation
21
+ * step (the envelope assertion only fires if a 503 happens to be
22
+ * observed via other means). RFC 0009 unresolved question #3 — the
23
+ * alternative of probing via Little's Law is rejected by default;
24
+ * inflightCap advertisement is the chosen forcing mechanism.
25
+ *
26
+ * **Run with `--no-file-parallelism`** — saturating the host's
27
+ * inflight cap leaves no headroom for other scenarios that issue
28
+ * concurrent POSTs (e.g., `idempotencyRetry.test.ts`'s 5-retry burst).
29
+ * Conformance runs that include this scenario MUST pass `--no-file-
30
+ * parallelism` to vitest or scope each file to its own worker. The
31
+ * otel-emission scenario has the same constraint (`conformance/README.md`
32
+ * line ~69).
33
+ *
34
+ * @see RFCS/0009-production-profile-conformance.md §B
35
+ * @see spec/v1/production-profile.md §Backpressure
36
+ */
37
+
38
+ import { describe, it, expect } from 'vitest';
39
+ import { driver } from '../lib/driver.js';
40
+ import { loadEnv } from '../lib/env.js';
41
+ import { behaviorGate } from '../lib/behavior-gate.js';
42
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
43
+
44
+ interface BackpressureCaps {
45
+ supported?: boolean;
46
+ inflightCap?: number;
47
+ retryAfterSeconds?: number;
48
+ }
49
+
50
+ interface ProductionCaps {
51
+ supported?: boolean;
52
+ backpressure?: BackpressureCaps;
53
+ }
54
+
55
+ async function readProductionCaps(): Promise<ProductionCaps | undefined> {
56
+ const disco = await driver.get('/.well-known/openwop');
57
+ return (disco.json as { capabilities?: { production?: ProductionCaps } })
58
+ .capabilities?.production;
59
+ }
60
+
61
+ function isProfileAdvertised(prod: ProductionCaps | undefined): boolean {
62
+ return (
63
+ prod?.supported === true && prod?.backpressure?.supported === true
64
+ );
65
+ }
66
+
67
+ describe('production-backpressure: capability shape', () => {
68
+ it('host that claims openwop-production with backpressure advertises required fields', async () => {
69
+ const prod = await readProductionCaps();
70
+
71
+ if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
72
+ return;
73
+ }
74
+
75
+ expect(prod?.supported, driver.describe(
76
+ 'production-profile.md §Compatibility baseline',
77
+ 'capabilities.production.supported MUST be true when claiming the openwop-production profile',
78
+ )).toBe(true);
79
+
80
+ expect(prod?.backpressure?.supported, driver.describe(
81
+ 'production-profile.md §Backpressure',
82
+ 'capabilities.production.backpressure.supported MUST be true for production-profile claimants',
83
+ )).toBe(true);
84
+
85
+ if (prod?.backpressure?.inflightCap !== undefined) {
86
+ expect(
87
+ Number.isInteger(prod.backpressure.inflightCap) &&
88
+ prod.backpressure.inflightCap >= 1,
89
+ driver.describe(
90
+ 'capabilities.schema.json production.backpressure.inflightCap',
91
+ 'inflightCap MUST be an integer ≥ 1 when advertised',
92
+ ),
93
+ ).toBe(true);
94
+ }
95
+
96
+ if (prod?.backpressure?.retryAfterSeconds !== undefined) {
97
+ expect(
98
+ Number.isInteger(prod.backpressure.retryAfterSeconds) &&
99
+ prod.backpressure.retryAfterSeconds >= 0,
100
+ driver.describe(
101
+ 'capabilities.schema.json production.backpressure.retryAfterSeconds',
102
+ 'retryAfterSeconds MUST be a non-negative integer when advertised',
103
+ ),
104
+ ).toBe(true);
105
+ }
106
+ });
107
+ });
108
+
109
+ describe('production-backpressure: 503 envelope under saturation', () => {
110
+ it('saturating advertised inflightCap returns 503 + Retry-After + canonical envelope', async () => {
111
+ const prod = await readProductionCaps();
112
+
113
+ if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
114
+ return;
115
+ }
116
+
117
+ const cap = prod?.backpressure?.inflightCap;
118
+ if (cap === undefined) {
119
+ // eslint-disable-next-line no-console
120
+ console.warn(
121
+ '[production-backpressure] host does not advertise inflightCap; skipping saturation assertion',
122
+ );
123
+ return;
124
+ }
125
+
126
+ // Use the conformance-delay fixture to hold inflight slots via
127
+ // long-lived SSE connections, matching the Postgres host's
128
+ // internal backpressure test pattern.
129
+ const FIXTURE = 'conformance-delay';
130
+ if (!isFixtureAdvertised(FIXTURE)) {
131
+ // eslint-disable-next-line no-console
132
+ console.warn(
133
+ `[production-backpressure] ${FIXTURE} not advertised; skipping saturation (host doesn't seed the long-running fixture)`,
134
+ );
135
+ return;
136
+ }
137
+
138
+ // Create `cap` long-running runs and open SSE streams against each
139
+ // to hold the slots open. Raw fetch is used for SSE because the
140
+ // driver doesn't expose abort signals; abort is required to release
141
+ // the inflight slots in the finally block. The Accept header is
142
+ // required so hosts with /events content negotiation (e.g., the
143
+ // Postgres reference host) actually keep the connection open as a
144
+ // long-lived SSE stream; without it they return a one-shot JSON
145
+ // snapshot and the inflight slot drops immediately.
146
+ const env = loadEnv();
147
+ const authHeaders = {
148
+ Authorization: `Bearer ${env.apiKey}`,
149
+ Accept: 'text/event-stream',
150
+ };
151
+
152
+ const slotHolders: AbortController[] = [];
153
+ const slotPromises: Promise<unknown>[] = [];
154
+ // Track the saturating runs so we can explicitly cancel them in
155
+ // `finally`. Without this, aborting only the SSE stream leaves
156
+ // the runs alive on the host until their delay elapses; any
157
+ // neighbor test running in parallel (vitest's default mode) then
158
+ // hits the still-saturated inflight cap and fails with a 503 of
159
+ // its own. Per spec the saturating runs MAY survive their SSE
160
+ // client; the test-isolation contract is the caller's
161
+ // responsibility.
162
+ const saturatingRunIds: string[] = [];
163
+
164
+ let saturationEarlyExit = false;
165
+ for (let i = 0; i < cap; i++) {
166
+ const create = await driver.post('/v1/runs', {
167
+ workflowId: FIXTURE,
168
+ // Long enough that the saturation window holds during the
169
+ // cap+1 probe + envelope assertions (~500ms total) but short
170
+ // enough that any leaked run drains quickly if cancel fails.
171
+ inputs: { delayMs: 2_000 },
172
+ });
173
+ if (create.status === 503) {
174
+ // Inflight cap was already saturated by a parallel test
175
+ // (default vitest mode shares the host across scenarios).
176
+ // The test's docstring notes `--no-file-parallelism` is the
177
+ // canonical execution mode; outside that mode the saturation
178
+ // moment is unreachable. Soft-skip per the existing pattern
179
+ // (consistent with the cap+1 fallback below).
180
+ // eslint-disable-next-line no-console
181
+ console.warn(
182
+ `[production-backpressure] cap already saturated by parallel runs at i=${i} (likely running without --no-file-parallelism); soft-skipping envelope assertions`,
183
+ );
184
+ saturationEarlyExit = true;
185
+ break;
186
+ }
187
+ expect(create.status).toBe(201);
188
+ const runId = (create.json as { runId: string }).runId;
189
+ saturatingRunIds.push(runId);
190
+
191
+ const controller = new AbortController();
192
+ slotHolders.push(controller);
193
+ slotPromises.push(
194
+ fetch(`${env.baseUrl}/v1/runs/${encodeURIComponent(runId)}/events`, {
195
+ headers: authHeaders,
196
+ signal: controller.signal,
197
+ }).catch(() => undefined),
198
+ );
199
+ }
200
+ if (saturationEarlyExit) {
201
+ // Drain whatever we created so we don't leave residue for the
202
+ // next test. The finally block below handles bulk-cancel
203
+ // unconditionally; just bail before the envelope assertions.
204
+ for (const c of slotHolders) c.abort();
205
+ if (saturatingRunIds.length > 0) {
206
+ try {
207
+ await driver.post('/v1/runs:bulk-cancel', { runIds: saturatingRunIds });
208
+ } catch {
209
+ // Best-effort.
210
+ }
211
+ }
212
+ await Promise.allSettled(slotPromises);
213
+ return;
214
+ }
215
+
216
+ // Let SSE connections register.
217
+ await new Promise((r) => setTimeout(r, 200));
218
+
219
+ try {
220
+ // The `cap + 1`-th authenticated request MUST 503.
221
+ const blocked = await driver.post('/v1/runs', {
222
+ workflowId: 'conformance-noop',
223
+ });
224
+
225
+ // Soft-skip if the host didn't actually 503 (e.g., it accepts
226
+ // more concurrency than its advertised cap, or the SSE slots
227
+ // didn't register in time). The envelope assertion only fires
228
+ // when 503 was observed.
229
+ if (blocked.status !== 503) {
230
+ // eslint-disable-next-line no-console
231
+ console.warn(
232
+ `[production-backpressure] expected 503 at cap+1=${cap + 1}, got ${blocked.status}; saturation may need tuning`,
233
+ );
234
+ return;
235
+ }
236
+
237
+ const retryAfterHeader = blocked.headers.get('retry-after');
238
+ expect(retryAfterHeader, driver.describe(
239
+ 'production-profile.md §Backpressure',
240
+ '503 response MUST include a Retry-After header',
241
+ )).toBeTruthy();
242
+ expect((retryAfterHeader ?? '').length).toBeGreaterThan(0);
243
+
244
+ const body = blocked.json as {
245
+ error?: string;
246
+ message?: string;
247
+ details?: { retryAfter?: number };
248
+ };
249
+
250
+ expect(body.error, driver.describe(
251
+ 'production-profile.md §Backpressure',
252
+ '503 envelope MUST have error: "service_unavailable"',
253
+ )).toBe('service_unavailable');
254
+
255
+ expect(typeof body.message).toBe('string');
256
+ expect((body.message ?? '').length).toBeGreaterThan(0);
257
+
258
+ expect(typeof body.details?.retryAfter, driver.describe(
259
+ 'production-profile.md §Backpressure',
260
+ 'details.retryAfter MUST be present and numeric',
261
+ )).toBe('number');
262
+
263
+ // details.retryAfter MUST equal the Retry-After header in seconds.
264
+ const headerSeconds = Number.parseInt(retryAfterHeader ?? '', 10);
265
+ expect(body.details?.retryAfter, driver.describe(
266
+ 'production-profile.md §Backpressure',
267
+ 'details.retryAfter MUST equal Retry-After header in seconds',
268
+ )).toBe(headerSeconds);
269
+
270
+ // If retryAfterSeconds is advertised, both equal it.
271
+ const advertised = prod?.backpressure?.retryAfterSeconds;
272
+ if (advertised !== undefined) {
273
+ expect(headerSeconds, driver.describe(
274
+ 'capabilities.schema.json production.backpressure.retryAfterSeconds',
275
+ 'Retry-After MUST equal advertised retryAfterSeconds when both are present',
276
+ )).toBe(advertised);
277
+ }
278
+ } finally {
279
+ // Test isolation: explicitly cancel every saturating run so
280
+ // neighbor tests running in parallel see the inflight cap
281
+ // released immediately. The SSE-stream aborts come first so
282
+ // the host's SSE handlers exit; the bulk-cancel then drains
283
+ // the executor queue. POST /v1/runs:bulk-cancel is naturally
284
+ // idempotent so even if a run already terminated, the call
285
+ // returns `ok: true, status: "cancelled"` per
286
+ // rest-endpoints.md §"Bulk cancel".
287
+ for (const c of slotHolders) c.abort();
288
+ if (saturatingRunIds.length > 0) {
289
+ try {
290
+ await driver.post('/v1/runs:bulk-cancel', { runIds: saturatingRunIds });
291
+ } catch {
292
+ // Best-effort; a host that doesn't expose bulk-cancel
293
+ // gets per-id cancellation as the fallback.
294
+ for (const id of saturatingRunIds) {
295
+ try {
296
+ await driver.post(`/v1/runs/${encodeURIComponent(id)}/cancel`, {});
297
+ } catch {
298
+ // Swallow — the runs will drain naturally within
299
+ // the (now-shortened) 2-second delay window.
300
+ }
301
+ }
302
+ }
303
+ }
304
+ await Promise.allSettled(slotPromises);
305
+ }
306
+ });
307
+ });
308
+
309
+ describe('production-backpressure: discovery exempt from cap', () => {
310
+ it('GET /.well-known/openwop returns 200 even when inflight is saturated', async () => {
311
+ const prod = await readProductionCaps();
312
+
313
+ if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
314
+ return;
315
+ }
316
+
317
+ const cap = prod?.backpressure?.inflightCap;
318
+ if (cap === undefined) {
319
+ // Without advertised cap we can't saturate deterministically;
320
+ // fall back to a single discovery probe.
321
+ const probe = await driver.get('/.well-known/openwop');
322
+ expect(probe.status, driver.describe(
323
+ 'production-profile.md §Backpressure',
324
+ 'discovery MUST answer regardless of load',
325
+ )).toBe(200);
326
+ return;
327
+ }
328
+
329
+ // Issue many concurrent discovery probes; all MUST succeed.
330
+ const probes = await Promise.all(
331
+ Array.from({ length: Math.max(cap + 2, 4) }, () =>
332
+ driver.get('/.well-known/openwop'),
333
+ ),
334
+ );
335
+ for (const probe of probes) {
336
+ expect(probe.status, driver.describe(
337
+ 'production-profile.md §Backpressure',
338
+ 'discovery MUST bypass the inflight cap (health probes always answer)',
339
+ )).toBe(200);
340
+ }
341
+ });
342
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * RFC 0009 §C: production-profile event-retention expiry.
3
+ *
4
+ * Verifies that hosts claiming the `openwop-production` profile satisfy
5
+ * `spec/v1/production-profile.md` §"Event retention":
6
+ *
7
+ * 1. `capabilities.production.retention.supported: true` is advertised.
8
+ * 2. `capabilities.production.retention.minWindowSeconds >= 604800`
9
+ * (7 days) — the minimum retention window for public hosts.
10
+ * 3. `GET /v1/runs/{expiredRunId}` on an expired run returns `410 Gone`
11
+ * (preferred) or `404 Not Found` per spec, with the canonical
12
+ * error envelope `{error, message, details?}`.
13
+ *
14
+ * Forcing expiry is host-private — the RFC defers endpoint normation
15
+ * (unresolved question #1). The scenario reads two env vars supplied
16
+ * by the operator running the suite:
17
+ *
18
+ * - `OPENWOP_TEST_EXPIRED_RUN_ID` — id of a pre-expired run the
19
+ * host has on file. Used by both the soft-skip and active paths.
20
+ * - `OPENWOP_TEST_FORCE_EXPIRE_URL` — optional host-private endpoint
21
+ * the suite POSTs to in order to evict a freshly-created run.
22
+ * Honored only when `capabilities.production.retention.testForceExpire: true`.
23
+ *
24
+ * When neither path is available, the scenario asserts only the
25
+ * capability shape and soft-skips the envelope check.
26
+ *
27
+ * @see RFCS/0009-production-profile-conformance.md §C
28
+ * @see spec/v1/production-profile.md §"Event retention"
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { driver } from '../lib/driver.js';
33
+ import { behaviorGate } from '../lib/behavior-gate.js';
34
+
35
+ interface RetentionCaps {
36
+ supported?: boolean;
37
+ minWindowSeconds?: number;
38
+ testForceExpire?: boolean;
39
+ }
40
+
41
+ interface ProductionCaps {
42
+ supported?: boolean;
43
+ retention?: RetentionCaps;
44
+ }
45
+
46
+ async function readProductionCaps(): Promise<ProductionCaps | undefined> {
47
+ const disco = await driver.get('/.well-known/openwop');
48
+ return (disco.json as { capabilities?: { production?: ProductionCaps } })
49
+ .capabilities?.production;
50
+ }
51
+
52
+ function isProfileAdvertised(prod: ProductionCaps | undefined): boolean {
53
+ return prod?.supported === true && prod?.retention?.supported === true;
54
+ }
55
+
56
+ const SEVEN_DAYS_SECONDS = 604800;
57
+
58
+ describe('production-retention-expiry: capability shape', () => {
59
+ it('host claiming openwop-production with retention advertises required fields', async () => {
60
+ const prod = await readProductionCaps();
61
+
62
+ if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
63
+ return;
64
+ }
65
+
66
+ expect(prod?.retention?.supported, driver.describe(
67
+ 'production-profile.md §"Event retention"',
68
+ 'capabilities.production.retention.supported MUST be true for production-profile claimants',
69
+ )).toBe(true);
70
+
71
+ expect(prod?.retention?.minWindowSeconds, driver.describe(
72
+ 'production-profile.md §"Event retention"',
73
+ 'capabilities.production.retention.minWindowSeconds MUST be advertised when retention.supported is true',
74
+ )).toBeDefined();
75
+
76
+ expect(
77
+ Number.isInteger(prod?.retention?.minWindowSeconds) &&
78
+ (prod?.retention?.minWindowSeconds ?? 0) >= SEVEN_DAYS_SECONDS,
79
+ driver.describe(
80
+ 'production-profile.md §"Event retention"',
81
+ 'minWindowSeconds MUST be an integer ≥ 604800 (7 days) for public production-profile claimants',
82
+ ),
83
+ ).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe('production-retention-expiry: 410/404 envelope on expired run', () => {
88
+ it('GET /v1/runs/{expiredRunId} returns 410 or 404 with canonical envelope', async () => {
89
+ const prod = await readProductionCaps();
90
+
91
+ if (!behaviorGate('openwop-production', isProfileAdvertised(prod))) {
92
+ return;
93
+ }
94
+
95
+ let expiredRunId = process.env.OPENWOP_TEST_EXPIRED_RUN_ID;
96
+
97
+ // Active expiry path: when the host advertises a test force-expire
98
+ // hook, create a fresh run and call the operator-supplied endpoint
99
+ // to evict it. The endpoint shape is host-private (RFC 0009 Q#1).
100
+ const forceExpireUrl = process.env.OPENWOP_TEST_FORCE_EXPIRE_URL;
101
+ const forceExpireMethod = process.env.OPENWOP_TEST_FORCE_EXPIRE_METHOD ?? 'POST';
102
+
103
+ if (
104
+ prod?.retention?.testForceExpire === true &&
105
+ forceExpireUrl !== undefined &&
106
+ expiredRunId === undefined
107
+ ) {
108
+ // Create a throwaway run.
109
+ const create = await driver.post('/v1/runs', {
110
+ workflowId: 'conformance-noop',
111
+ });
112
+ if (create.status === 201) {
113
+ const newRunId = (create.json as { runId: string }).runId;
114
+ // Call the host-private force-expire endpoint. Operator wires
115
+ // this to whatever route the host exposes.
116
+ const url = forceExpireUrl.replace('{runId}', encodeURIComponent(newRunId));
117
+ const forced = await fetch(url, { method: forceExpireMethod });
118
+ if (forced.ok || forced.status === 204) {
119
+ expiredRunId = newRunId;
120
+ }
121
+ }
122
+ }
123
+
124
+ if (expiredRunId === undefined) {
125
+ // eslint-disable-next-line no-console
126
+ console.warn(
127
+ '[production-retention-expiry] no expired runId available (set OPENWOP_TEST_EXPIRED_RUN_ID or advertise testForceExpire + provide OPENWOP_TEST_FORCE_EXPIRE_URL); skipping envelope assertion',
128
+ );
129
+ return;
130
+ }
131
+
132
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(expiredRunId)}`);
133
+
134
+ expect(
135
+ res.status === 410 || res.status === 404,
136
+ driver.describe(
137
+ 'production-profile.md §"Event retention"',
138
+ 'expired run MUST return 410 Gone (preferred) or 404 Not Found',
139
+ ),
140
+ ).toBe(true);
141
+
142
+ const body = res.json as {
143
+ error?: string;
144
+ message?: string;
145
+ details?: { expiredAt?: string };
146
+ };
147
+
148
+ expect(typeof body.error, driver.describe(
149
+ 'production-profile.md §"Event retention"',
150
+ 'expired-run response MUST use the canonical error envelope ({error, message, details?})',
151
+ )).toBe('string');
152
+ expect((body.error ?? '').length).toBeGreaterThan(0);
153
+
154
+ expect(typeof body.message).toBe('string');
155
+ expect((body.message ?? '').length).toBeGreaterThan(0);
156
+
157
+ // When the host returns 410, details.expiredAt is RECOMMENDED.
158
+ // Soft-check: when present, MUST be a non-empty string.
159
+ if (res.status === 410 && body.details?.expiredAt !== undefined) {
160
+ expect(typeof body.details.expiredAt).toBe('string');
161
+ expect(body.details.expiredAt.length).toBeGreaterThan(0);
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Public-registry availability scenario — `packs.openwop.dev`.
3
+ *
4
+ * Unlike `pack-registry.test.ts` (which probes the host-under-test for an
5
+ * optional in-host registry), this scenario hits the *public, hosted*
6
+ * registry at `packs.openwop.dev` directly. Its purpose is to provide a
7
+ * single mechanical check that the public registry is up, serves the four
8
+ * documented endpoint shapes, and returns valid manifests for the
9
+ * spec-canonical packs currently published.
10
+ *
11
+ * Gating:
12
+ * This scenario is skipped by default — `@openwop/openwop-conformance`
13
+ * runs MUST NOT require outbound connectivity to `packs.openwop.dev`.
14
+ * Opt-in via `OPENWOP_TEST_PUBLIC_REGISTRY=true`.
15
+ *
16
+ * Why this lives in the conformance suite even though it's not a host
17
+ * conformance scenario:
18
+ * - It provides a one-command public-registry healthcheck for the
19
+ * project's own operations.
20
+ * - It documents (via assertions) the contract `packs.openwop.dev`
21
+ * promises to serve.
22
+ * - It reuses the same vitest scaffolding as the rest of the suite.
23
+ *
24
+ * @see spec/v1/registry-operations.md
25
+ * @see ROADMAP.md §"Hosted infrastructure"
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+
30
+ const REGISTRY_BASE = 'https://packs.openwop.dev';
31
+ const ENABLED = process.env.OPENWOP_TEST_PUBLIC_REGISTRY === 'true';
32
+
33
+ const PACK_NAME_RE = /^(core|vendor|community|private)\.[a-z][a-z0-9_-]*(\.[a-z][a-zA-Z0-9_-]*)+$/;
34
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
35
+
36
+ async function get(path: string): Promise<{ status: number; json: unknown }> {
37
+ const res = await fetch(`${REGISTRY_BASE}${path}`, {
38
+ headers: { Accept: 'application/json' },
39
+ });
40
+ let json: unknown = undefined;
41
+ try {
42
+ json = await res.json();
43
+ } catch {
44
+ // body may not be JSON (e.g. tarball); caller handles.
45
+ }
46
+ return { status: res.status, json };
47
+ }
48
+
49
+ describe('registry-public: packs.openwop.dev discovery document', () => {
50
+ it('GET /.well-known/openwop-registry returns a valid discovery payload', async () => {
51
+ if (!ENABLED) {
52
+ // eslint-disable-next-line no-console
53
+ console.warn(
54
+ '[registry-public] skipped — set OPENWOP_TEST_PUBLIC_REGISTRY=true to enable',
55
+ );
56
+ return;
57
+ }
58
+
59
+ const res = await get('/.well-known/openwop-registry');
60
+ expect(res.status).toBe(200);
61
+
62
+ const body = res.json as {
63
+ registryVersion?: string;
64
+ protocolVersion?: string;
65
+ url?: string;
66
+ supportedNamespaces?: string[];
67
+ supportedSigningMethods?: string[];
68
+ endpoints?: Record<string, string>;
69
+ };
70
+
71
+ expect(body.registryVersion).toBe('1.0.0');
72
+ expect(body.protocolVersion).toBe('1.0');
73
+ expect(typeof body.url).toBe('string');
74
+ expect(Array.isArray(body.supportedNamespaces)).toBe(true);
75
+ expect(body.supportedNamespaces).toEqual(
76
+ expect.arrayContaining(['core', 'vendor', 'community']),
77
+ );
78
+ expect(Array.isArray(body.supportedSigningMethods)).toBe(true);
79
+ expect(body.supportedSigningMethods).toEqual(expect.arrayContaining(['ed25519']));
80
+
81
+ // The four canonical endpoint shapes from registry-operations.md
82
+ // (filesystem-backed registries serve packMetadata at a file path; see endpointAliases note).
83
+ expect(typeof body.endpoints?.registryIndex).toBe('string');
84
+ expect(typeof body.endpoints?.packMetadata).toBe('string');
85
+ expect(typeof body.endpoints?.versionManifest).toBe('string');
86
+ expect(typeof body.endpoints?.versionTarball).toBe('string');
87
+ });
88
+ });
89
+
90
+ describe('registry-public: packs.openwop.dev index', () => {
91
+ it('GET /v1/index.json returns a non-empty pack list with valid name + version shapes', async () => {
92
+ if (!ENABLED) return;
93
+
94
+ const res = await get('/v1/index.json');
95
+ expect(res.status).toBe(200);
96
+
97
+ const body = res.json as {
98
+ packs?: Array<{ name?: string; latestVersion?: string }>;
99
+ generated?: string;
100
+ };
101
+
102
+ expect(Array.isArray(body.packs)).toBe(true);
103
+ expect(body.packs?.length ?? 0).toBeGreaterThan(0);
104
+
105
+ for (const p of body.packs ?? []) {
106
+ expect(p.name, `pack name must match reverse-DNS pattern: ${p.name}`).toMatch(PACK_NAME_RE);
107
+ expect(p.latestVersion, `pack version must be semver: ${p.latestVersion}`).toMatch(SEMVER_RE);
108
+ }
109
+ });
110
+ });
111
+
112
+ describe('registry-public: spec-canonical pack manifests resolve', () => {
113
+ const KNOWN_PACKS = [
114
+ { name: 'core.openwop.examples', version: '1.0.0' },
115
+ { name: 'community.openwop-team.demo', version: '0.1.0' },
116
+ { name: 'vendor.openwop.rust-hello', version: '1.0.0' },
117
+ ];
118
+
119
+ for (const { name, version } of KNOWN_PACKS) {
120
+ it(`GET /v1/packs/${name}/-/${version}.json returns a valid manifest`, async () => {
121
+ if (!ENABLED) return;
122
+
123
+ const res = await get(`/v1/packs/${name}/-/${version}.json`);
124
+ expect(res.status).toBe(200);
125
+
126
+ const manifest = res.json as { name?: string; version?: string };
127
+ expect(manifest.name).toBe(name);
128
+ expect(manifest.version).toBe(version);
129
+ });
130
+ }
131
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cross-host LLM cache-key parity (replay.md §"LLM cache-key recipe").
3
+ *
4
+ * Verifies that two OpenWOP-compliant hosts replaying the same LLM
5
+ * provider request compute the same cache key. The recipe is normative
6
+ * (replay.md §B): canonical JSON of `(provider, model, messages, tools,
7
+ * temperature, topP, topK, responseFormat)` → SHA-256 → lowercase hex.
8
+ *
9
+ * Status: PLACEHOLDER. As of 2026-05-11, neither reference host
10
+ * (`examples/hosts/in-memory/`, `examples/hosts/sqlite/`) implements
11
+ * LLM-calling nodes — both execute only `core.noop` / `core.delay` /
12
+ * `core.approvalGate` fixtures. This scenario lands as `it.todo()` so
13
+ * the contract surface is tracked; assertions land when the first
14
+ * reference host ships an LLM-call node.
15
+ *
16
+ * What the live scenario WILL exercise (when implemented):
17
+ * 1. Boot host A against `OPENWOP_BASE_URL`.
18
+ * 2. Boot host B against `OPENWOP_BASE_URL_B`.
19
+ * 3. Submit the same workflow + inputs (an LLM-calling fixture).
20
+ * 4. Read each host's emitted `node.completed.payload.cacheKey` (or
21
+ * equivalent debug-bundle surface).
22
+ * 5. Assert the two hex strings are equal.
23
+ *
24
+ * @see spec/v1/replay.md §"LLM cache-key recipe"
25
+ */
26
+
27
+ import { describe, it } from 'vitest';
28
+
29
+ describe('replay-llm-cache-key: cross-host determinism (placeholder)', () => {
30
+ it.todo(
31
+ 'two hosts replaying the same LLM provider request compute the same cache key (replay.md §D)',
32
+ );
33
+ it.todo('LLM cache key is computed via SHA-256 of canonical JSON per replay.md §B');
34
+ it.todo('cache key omits non-recipe fields (max_tokens, stop, stream, seed, etc.) per replay.md §A');
35
+ });