@openwop/openwop-conformance 1.0.0 → 1.1.1

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 (86) 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 +342 -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 +20 -4
  19. package/schemas/run-event.schema.json +2 -1
  20. package/schemas/security-advisory.schema.json +109 -0
  21. package/src/lib/a2a-fake-peer.ts +143 -56
  22. package/src/lib/behavior-gate.ts +107 -0
  23. package/src/lib/env.ts +37 -0
  24. package/src/lib/grpc-framing.test.ts +96 -0
  25. package/src/lib/grpc-framing.ts +76 -0
  26. package/src/lib/oidc-issuer.test.ts +328 -0
  27. package/src/lib/oidc-issuer.ts +241 -0
  28. package/src/lib/otel-collector-grpc.test.ts +191 -0
  29. package/src/lib/otel-collector.test.ts +303 -0
  30. package/src/lib/otel-collector.ts +318 -14
  31. package/src/lib/otlp-protobuf.test.ts +461 -0
  32. package/src/lib/otlp-protobuf.ts +529 -0
  33. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  34. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  37. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  38. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  39. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  40. package/src/scenarios/agentMetadata.test.ts +1 -0
  41. package/src/scenarios/agentPackExport.test.ts +1 -0
  42. package/src/scenarios/agentPackInstall.test.ts +1 -0
  43. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  44. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  45. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  46. package/src/scenarios/auth-mtls.test.ts +274 -0
  47. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  48. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  49. package/src/scenarios/bulk-cancel.test.ts +111 -0
  50. package/src/scenarios/configurable-schema.test.ts +48 -0
  51. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  52. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  53. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  54. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  55. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  56. package/src/scenarios/discovery.test.ts +183 -0
  57. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  58. package/src/scenarios/idempotency.test.ts +6 -0
  59. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  60. package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
  61. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  62. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  63. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  64. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  65. package/src/scenarios/metric-emission.test.ts +113 -0
  66. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  67. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  68. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  69. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  70. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  71. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  72. package/src/scenarios/pause-resume.test.ts +119 -0
  73. package/src/scenarios/production-backpressure.test.ts +342 -0
  74. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  75. package/src/scenarios/registry-public.test.ts +222 -0
  76. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  77. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  78. package/src/scenarios/restart-during-run.test.ts +177 -0
  79. package/src/scenarios/spec-corpus-validity.test.ts +59 -26
  80. package/src/scenarios/staleClaim.test.ts +3 -0
  81. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  82. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  83. package/src/scenarios/webhook-negative.test.ts +90 -0
  84. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  85. package/src/setup.ts +25 -1
  86. package/vitest.config.ts +5 -1
@@ -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,222 @@
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
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
30
+
31
+ const REGISTRY_BASE = 'https://packs.openwop.dev';
32
+ const ENABLED = process.env.OPENWOP_TEST_PUBLIC_REGISTRY === 'true';
33
+
34
+ const PACK_NAME_RE = /^(core|vendor|community|private)\.[a-z][a-z0-9_-]*(\.[a-z][a-zA-Z0-9_-]*)+$/;
35
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
36
+
37
+ async function get(path: string): Promise<{ status: number; json: unknown }> {
38
+ const res = await fetch(`${REGISTRY_BASE}${path}`, {
39
+ headers: { Accept: 'application/json' },
40
+ });
41
+ let json: unknown = undefined;
42
+ try {
43
+ json = await res.json();
44
+ } catch {
45
+ // body may not be JSON (e.g. tarball); caller handles.
46
+ }
47
+ return { status: res.status, json };
48
+ }
49
+
50
+ describe('registry-public: packs.openwop.dev discovery document', () => {
51
+ it('GET /.well-known/openwop-registry returns a valid discovery payload', async () => {
52
+ if (!ENABLED) {
53
+ // eslint-disable-next-line no-console
54
+ console.warn(
55
+ '[registry-public] skipped — set OPENWOP_TEST_PUBLIC_REGISTRY=true to enable',
56
+ );
57
+ return;
58
+ }
59
+
60
+ const res = await get('/.well-known/openwop-registry');
61
+ expect(res.status).toBe(200);
62
+
63
+ const body = res.json as {
64
+ registryVersion?: string;
65
+ protocolVersion?: string;
66
+ url?: string;
67
+ supportedNamespaces?: string[];
68
+ supportedSigningMethods?: string[];
69
+ endpoints?: Record<string, string>;
70
+ };
71
+
72
+ expect(body.registryVersion).toBe('1.0.0');
73
+ expect(body.protocolVersion).toBe('1.0');
74
+ expect(typeof body.url).toBe('string');
75
+ expect(Array.isArray(body.supportedNamespaces)).toBe(true);
76
+ expect(body.supportedNamespaces).toEqual(
77
+ expect.arrayContaining(['core', 'vendor', 'community']),
78
+ );
79
+ expect(Array.isArray(body.supportedSigningMethods)).toBe(true);
80
+ expect(body.supportedSigningMethods).toEqual(expect.arrayContaining(['ed25519']));
81
+
82
+ // The four canonical endpoint shapes from registry-operations.md
83
+ // (filesystem-backed registries serve packMetadata at a file path; see endpointAliases note).
84
+ expect(typeof body.endpoints?.registryIndex).toBe('string');
85
+ expect(typeof body.endpoints?.packMetadata).toBe('string');
86
+ expect(typeof body.endpoints?.versionManifest).toBe('string');
87
+ expect(typeof body.endpoints?.versionTarball).toBe('string');
88
+ });
89
+ });
90
+
91
+ describe('registry-public: packs.openwop.dev index', () => {
92
+ it('GET /v1/index.json returns a non-empty pack list with valid name + version shapes', async () => {
93
+ if (!ENABLED) return;
94
+
95
+ const res = await get('/v1/index.json');
96
+ expect(res.status).toBe(200);
97
+
98
+ const body = res.json as {
99
+ packs?: Array<{ name?: string; latestVersion?: string }>;
100
+ generated?: string;
101
+ };
102
+
103
+ expect(Array.isArray(body.packs)).toBe(true);
104
+ expect(body.packs?.length ?? 0).toBeGreaterThan(0);
105
+
106
+ for (const p of body.packs ?? []) {
107
+ expect(p.name, `pack name must match reverse-DNS pattern: ${p.name}`).toMatch(PACK_NAME_RE);
108
+ expect(p.latestVersion, `pack version must be semver: ${p.latestVersion}`).toMatch(SEMVER_RE);
109
+ }
110
+ });
111
+ });
112
+
113
+ describe('registry-public: spec-canonical pack manifests resolve', () => {
114
+ const KNOWN_PACKS = [
115
+ { name: 'core.openwop.examples', version: '1.0.0' },
116
+ { name: 'community.openwop-team.demo', version: '0.1.0' },
117
+ { name: 'vendor.openwop.rust-hello', version: '1.0.0' },
118
+ ];
119
+
120
+ for (const { name, version } of KNOWN_PACKS) {
121
+ it(`GET /v1/packs/${name}/-/${version}.json returns a valid manifest`, async () => {
122
+ if (!ENABLED) return;
123
+
124
+ const res = await get(`/v1/packs/${name}/-/${version}.json`);
125
+ expect(res.status).toBe(200);
126
+
127
+ const manifest = res.json as { name?: string; version?: string };
128
+ expect(manifest.name).toBe(name);
129
+ expect(manifest.version).toBe(version);
130
+ });
131
+ }
132
+ });
133
+
134
+ describe('registry-public: tarball + signature + Ed25519 verify roundtrip', () => {
135
+ // core.openwop.examples@1.0.0 is the canonical reference pack for this
136
+ // check: published since the registry MVP, signed with the
137
+ // `openwop-registry-root` key over the whole tarball (method='ed25519').
138
+ // The same recipe applies to any pack at the registry; this scenario
139
+ // exercises the worst-case full roundtrip so clients have a wire-level
140
+ // contract for verifying packs before installing them.
141
+ //
142
+ // What gets asserted, in order:
143
+ // 1. Version manifest, tarball, signature, and public key all 200.
144
+ // 2. SRI integrity in the manifest matches a fresh sha256 of the
145
+ // tarball bytes — protects against tarball tampering between
146
+ // publish + retrieval.
147
+ // 3. Detached Ed25519 signature verifies against the public key over
148
+ // the bytes the publisher signed (per signing.method).
149
+ const PACK_NAME = 'core.openwop.examples';
150
+ const PACK_VERSION = '1.0.0';
151
+
152
+ async function getBinary(path: string): Promise<{ status: number; bytes: Buffer }> {
153
+ const res = await fetch(`${REGISTRY_BASE}${path}`);
154
+ const ab = await res.arrayBuffer();
155
+ return { status: res.status, bytes: Buffer.from(ab) };
156
+ }
157
+
158
+ async function getText(path: string): Promise<{ status: number; body: string }> {
159
+ const res = await fetch(`${REGISTRY_BASE}${path}`);
160
+ const body = await res.text();
161
+ return { status: res.status, body };
162
+ }
163
+
164
+ it(`tarball + sig + public key all retrievable, SRI matches, Ed25519 verifies for ${PACK_NAME}@${PACK_VERSION}`, async () => {
165
+ if (!ENABLED) return;
166
+
167
+ // 1. Manifest (JSON).
168
+ const manifestRes = await get(`/v1/packs/${PACK_NAME}/-/${PACK_VERSION}.json`);
169
+ expect(manifestRes.status).toBe(200);
170
+ const manifest = manifestRes.json as {
171
+ signing?: { method?: string; keyId?: string; publicKeyUrl?: string };
172
+ integrity?: string;
173
+ };
174
+ expect(typeof manifest.signing?.method).toBe('string');
175
+ expect(typeof manifest.signing?.keyId).toBe('string');
176
+ expect(typeof manifest.integrity).toBe('string');
177
+
178
+ // 2. Tarball.
179
+ const tarball = await getBinary(`/v1/packs/${PACK_NAME}/-/${PACK_VERSION}.tgz`);
180
+ expect(tarball.status, 'tarball MUST be retrievable').toBe(200);
181
+ expect(tarball.bytes.byteLength, 'tarball MUST be non-empty').toBeGreaterThan(0);
182
+
183
+ // 3. Detached signature (64-byte raw Ed25519).
184
+ const sig = await getBinary(`/v1/packs/${PACK_NAME}/-/${PACK_VERSION}.sig`);
185
+ expect(sig.status, 'signature MUST be retrievable').toBe(200);
186
+ expect(sig.bytes.byteLength, 'Ed25519 detached signature MUST be 64 bytes').toBe(64);
187
+
188
+ // 4. Public key — fetch from the publisher-declared URL when present,
189
+ // else fall back to the canonical `/keys/<keyId>.pub` shape.
190
+ const keyUrl = manifest.signing!.publicKeyUrl ?? `/keys/${manifest.signing!.keyId}.pub`;
191
+ const keyRes = await getText(keyUrl);
192
+ expect(keyRes.status, `public key MUST be retrievable at ${keyUrl}`).toBe(200);
193
+ const publicKey = createPublicKey(keyRes.body);
194
+
195
+ // 5. SRI integrity check: `sha256-<base64>=` MUST match a fresh
196
+ // sha256 of the tarball bytes per registry-operations.md.
197
+ expect(manifest.integrity).toMatch(/^sha256-[A-Za-z0-9+/]+=*$/);
198
+ const expectedSri = `sha256-${createHash('sha256').update(tarball.bytes).digest('base64')}`;
199
+ expect(expectedSri, 'SRI integrity in manifest MUST match a fresh sha256 of the tarball').toBe(
200
+ manifest.integrity,
201
+ );
202
+
203
+ // 6. Ed25519 verification. Two canonical signing conventions per
204
+ // `node-packs.md` §"Signing recipe": `method=ed25519` signs the
205
+ // whole tarball; `method=manual` signs the pack.json bytes inside
206
+ // the tarball. core.openwop.examples uses `ed25519`.
207
+ const method = manifest.signing!.method;
208
+ expect(['ed25519', 'manual']).toContain(method);
209
+
210
+ // For `ed25519` the signed bytes are the tarball; for `manual` the
211
+ // signed bytes are pack.json extracted from the tarball. This
212
+ // scenario picks `core.openwop.examples` specifically because it's
213
+ // `method=ed25519` — the simpler path. Extending to `manual` would
214
+ // require the tarball extractor from registry/scripts/verify-
215
+ // signatures.mjs which is intentionally out of scope here.
216
+ if (method !== 'ed25519') return;
217
+
218
+ const verified = cryptoVerify(null, tarball.bytes, publicKey, sig.bytes);
219
+ expect(verified, `Ed25519 signature over ${PACK_NAME}@${PACK_VERSION}.tgz MUST verify against ${manifest.signing!.keyId}`)
220
+ .toBe(true);
221
+ });
222
+ });
@@ -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
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Replay retention-expiry scenario per `spec/v1/replay.md` §"Retention
3
+ * and garbage collection."
4
+ *
5
+ * Verifies the normative `replay.md:246` requirement:
6
+ *
7
+ * > If the source run still exists but the event range needed for
8
+ * > `fromSeq` has expired, the host MUST reject the fork with
9
+ * > `410 Gone` or `422 Unprocessable Entity` using the canonical
10
+ * > error envelope. The error `details` SHOULD include `sourceRunId`,
11
+ * > `fromSeq`, and the retention boundary when known.
12
+ *
13
+ * Forcing expiry is environmental (hosts don't standardize a force-
14
+ * expire endpoint — that's the same RFC 0009 Q#1 surface area). The
15
+ * scenario reads two operator-supplied env vars:
16
+ *
17
+ * - `OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID` — runId of a run whose
18
+ * events past `fromSeq` are known-expired on the host under test.
19
+ * - `OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ` — the `fromSeq` to
20
+ * fork against (defaults to 0; pre-expired ranges typically
21
+ * start from the earliest event).
22
+ *
23
+ * When neither is supplied, the scenario asserts only that the
24
+ * `replay` capability advertisement is well-formed and that
25
+ * `retention` metadata (when present) types correctly. The
26
+ * 410/422 envelope assertion soft-skips.
27
+ *
28
+ * @see spec/v1/replay.md §"Retention and garbage collection"
29
+ * @see RFCS/0009-production-profile-conformance.md §C (parallel
30
+ * retention-expiry pattern for run snapshots)
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { driver } from '../lib/driver.js';
35
+ import { behaviorGate } from '../lib/behavior-gate.js';
36
+
37
+ interface ReplayRetentionCaps {
38
+ windowSeconds?: number;
39
+ }
40
+
41
+ interface ReplayCaps {
42
+ supported?: boolean;
43
+ modes?: string[];
44
+ retention?: ReplayRetentionCaps;
45
+ }
46
+
47
+ const PROFILE = 'openwop-replay-fork';
48
+
49
+ async function readReplayCaps(): Promise<ReplayCaps | undefined> {
50
+ // Per existing replay-fork.test.ts convention: `replay` lives at the
51
+ // top level of the discovery body, not under capabilities.*.
52
+ const disco = await driver.get('/.well-known/openwop', { authenticated: false });
53
+ if (disco.status !== 200) return undefined;
54
+ return (disco.json as { replay?: ReplayCaps }).replay;
55
+ }
56
+
57
+ function isProfileAdvertised(replay: ReplayCaps | undefined): boolean {
58
+ return replay?.supported === true && Array.isArray(replay.modes) && replay.modes.length > 0;
59
+ }
60
+
61
+ describe('replay-retention-expiry: capability shape', () => {
62
+ it('host advertising replay surfaces well-formed retention metadata when present', async () => {
63
+ const replay = await readReplayCaps();
64
+
65
+ if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
66
+ return;
67
+ }
68
+
69
+ expect(replay?.supported, driver.describe(
70
+ 'replay.md §"Retention and garbage collection"',
71
+ 'replay.supported MUST be true when the host claims the openwop-replay-fork profile',
72
+ )).toBe(true);
73
+
74
+ expect(
75
+ Array.isArray(replay?.modes) && (replay?.modes?.length ?? 0) > 0,
76
+ driver.describe(
77
+ 'profiles.md §`openwop-replay-fork`',
78
+ 'replay.modes MUST be a non-empty array',
79
+ ),
80
+ ).toBe(true);
81
+
82
+ // retention metadata is OPTIONAL per replay.md (the spec requires
83
+ // hosts document retention; it doesn't yet require advertising
84
+ // the window in discovery). When advertised, type strictly.
85
+ if (replay?.retention?.windowSeconds !== undefined) {
86
+ expect(
87
+ Number.isInteger(replay.retention.windowSeconds) &&
88
+ replay.retention.windowSeconds >= 0,
89
+ driver.describe(
90
+ 'replay.md §"Retention and garbage collection"',
91
+ 'replay.retention.windowSeconds MUST be a non-negative integer when advertised',
92
+ ),
93
+ ).toBe(true);
94
+ }
95
+ });
96
+ });
97
+
98
+ describe('replay-retention-expiry: 410/422 on expired-range fork', () => {
99
+ it('POST /v1/runs/{expiredRunId}:fork returns 410 or 422 with canonical envelope', async () => {
100
+ const replay = await readReplayCaps();
101
+
102
+ if (!behaviorGate(PROFILE, isProfileAdvertised(replay))) {
103
+ return;
104
+ }
105
+
106
+ const expiredRunId = process.env.OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID;
107
+ if (!expiredRunId) {
108
+ // eslint-disable-next-line no-console
109
+ console.warn(
110
+ '[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_RUN_ID not supplied; skipping envelope assertion (operator must produce a known-expired run id and pass it via env)',
111
+ );
112
+ return;
113
+ }
114
+
115
+ const fromSeqEnv = process.env.OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ;
116
+ const fromSeq = fromSeqEnv ? Number.parseInt(fromSeqEnv, 10) : 0;
117
+ if (!Number.isFinite(fromSeq) || fromSeq < 0) {
118
+ // eslint-disable-next-line no-console
119
+ console.warn(
120
+ `[replay-retention-expiry] OPENWOP_TEST_EXPIRED_REPLAY_FROM_SEQ=${String(fromSeqEnv)} is not a non-negative integer; skipping`,
121
+ );
122
+ return;
123
+ }
124
+
125
+ // Pick a mode the host advertises. Per replay.md the envelope
126
+ // assertion applies regardless of mode — replay and branch both
127
+ // depend on the source event log past fromSeq.
128
+ const mode = replay?.modes?.[0] ?? 'replay';
129
+
130
+ const res = await driver.post(
131
+ `/v1/runs/${encodeURIComponent(expiredRunId)}:fork`,
132
+ { mode, fromSeq },
133
+ );
134
+
135
+ expect(
136
+ res.status === 410 || res.status === 422,
137
+ driver.describe(
138
+ 'replay.md §"Retention and garbage collection"',
139
+ 'fork against expired event range MUST return 410 Gone or 422 Unprocessable Entity',
140
+ ),
141
+ ).toBe(true);
142
+
143
+ const body = res.json as {
144
+ error?: string;
145
+ message?: string;
146
+ details?: {
147
+ sourceRunId?: string;
148
+ fromSeq?: number;
149
+ retentionBoundary?: string | number;
150
+ };
151
+ };
152
+
153
+ expect(typeof body.error, driver.describe(
154
+ 'replay.md §"Retention and garbage collection"',
155
+ 'expired-fork response MUST use the canonical error envelope ({error, message, details?})',
156
+ )).toBe('string');
157
+ expect((body.error ?? '').length).toBeGreaterThan(0);
158
+
159
+ expect(typeof body.message).toBe('string');
160
+ expect((body.message ?? '').length).toBeGreaterThan(0);
161
+
162
+ // details.{sourceRunId, fromSeq, retentionBoundary} are SHOULD —
163
+ // soft-check when present, MUST NOT mismatch when present.
164
+ if (body.details?.sourceRunId !== undefined) {
165
+ expect(body.details.sourceRunId, driver.describe(
166
+ 'replay.md §"Retention and garbage collection"',
167
+ 'details.sourceRunId (when present) MUST match the runId in the request path',
168
+ )).toBe(expiredRunId);
169
+ }
170
+
171
+ if (body.details?.fromSeq !== undefined) {
172
+ expect(body.details.fromSeq, driver.describe(
173
+ 'replay.md §"Retention and garbage collection"',
174
+ 'details.fromSeq (when present) MUST match the fromSeq supplied in the request body',
175
+ )).toBe(fromSeq);
176
+ }
177
+ });
178
+ });