@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.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.
- package/README.md +2 -2
- package/dist/cli.js +209 -14
- package/dist/lib/certification-bundle-v3.js +142 -0
- package/dist/lib/requirement-ids.js +23 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +5 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +2701 -4
- package/scenario-majors.json +152 -2
- package/schemas/CORPUS-STAMP.json +17 -17
- package/src/cli.ts +190 -19
- package/src/lib/certification-bundle-v3.ts +175 -0
- package/src/lib/driver.ts +9 -1
- package/src/lib/era2-seed.ts +197 -0
- package/src/lib/requirement-ids.ts +21 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +5 -1
- package/src/lib/seams.ts +45 -0
- package/src/lib/v2.ts +62 -0
- package/src/scenarios/v2-approver-enforced.test.ts +97 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
- package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
- package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
- package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
- package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
- package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
- package/src/scenarios/v2-configurable-closed.test.ts +67 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
- package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
- package/src/scenarios/v2-era-key.test.ts +86 -0
- package/src/scenarios/v2-error-registry.test.ts +89 -0
- package/src/scenarios/v2-event-type-closed.test.ts +97 -0
- package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
- package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
- package/src/scenarios/v2-header-scheme.test.ts +67 -0
- package/src/scenarios/v2-id-grammar.test.ts +77 -0
- package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
- package/src/scenarios/v2-min-client-version.test.ts +48 -0
- package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
- package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
- package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
- package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
- package/src/scenarios/v2-pack-isolation.test.ts +147 -0
- package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
- package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
- package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
- package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
- package/src/scenarios/v2-provider-conflict.test.ts +115 -0
- package/src/scenarios/v2-refresh-sla.test.ts +78 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
- package/src/scenarios/v2-revocation-honored.test.ts +66 -0
- package/src/scenarios/v2-subject-link-record.test.ts +102 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
- package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
- package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
- package/src/setup.ts +40 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §D.3 — `negotiation-decided-emitted` (suite 2.0.0, target major 2; gated on a2a/mcp + seams).
|
|
3
|
+
*
|
|
4
|
+
* Every negotiation outcome — including the refused one — MUST emit a
|
|
5
|
+
* content-free `negotiation.decided` event on the host's own event log
|
|
6
|
+
* (`schemas/v2/run-event-payloads.schema.json#/$defs/negotiationDecided`:
|
|
7
|
+
* `{ protocol, outcome, version?, floor?, peerDigest?, reason?, at }`, the peer
|
|
8
|
+
* as a SHA-256 digest of its origin, never in clear). The event on the host's
|
|
9
|
+
* log is the NORMATIVE witness of the silent-downgrade invariants; the seams
|
|
10
|
+
* profile drives the exchange (`spec/v2/core/interop.md` §The audit event;
|
|
11
|
+
* RFC 0175 §D.3/§D.5, row C8.5).
|
|
12
|
+
*
|
|
13
|
+
* How the exchange is driven: exactly as the v1 versioned-composition
|
|
14
|
+
* scenarios do (`a2a-version-negotiation.test.ts`, `mcp-version-negotiation.test.ts`)
|
|
15
|
+
* — the host's real client path is pointed at the suite's fake peer / server
|
|
16
|
+
* through the §22/§23 invoke seams (`/conformance/seams/sample/{a2a,mcp}/invoke`
|
|
17
|
+
* under the v2 seams profile). The seam's `200` answers with the run whose log
|
|
18
|
+
* carries the event (`runId`); the event is then read on the normative surface
|
|
19
|
+
* (`GET /runs/{runId}/events/poll`). A seam answer without a `runId` leaves the
|
|
20
|
+
* log unaddressable and records `blocked`.
|
|
21
|
+
*
|
|
22
|
+
* Callback-shaped (the host calls the suite's peer): unwitnessable when the
|
|
23
|
+
* host is in a separate network namespace — `../lib/host-callback.ts`.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v2/core/interop.md §Negotiation is a protocol
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
33
|
+
import addFormats from 'ajv-formats';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
36
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
|
|
39
|
+
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
40
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
41
|
+
import { req } from '../lib/requirement-ids.js';
|
|
42
|
+
|
|
43
|
+
export const REQUIRES_HOST_CALLBACK = "the host's A2A/MCP client calls the suite's fake peer/server through the invoke seams; the negotiation.decided event is then read on the host's own log";
|
|
44
|
+
|
|
45
|
+
const EVENT = 'negotiation.decided';
|
|
46
|
+
|
|
47
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
48
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Ajv over the `negotiationDecided` payload definition (self-contained — no $refs). */
|
|
52
|
+
function payloadValidator(): ((p: unknown) => { ok: boolean; errors: string }) | null {
|
|
53
|
+
const path = join(SCHEMAS_DIR, 'v2', 'run-event-payloads.schema.json');
|
|
54
|
+
let def: unknown;
|
|
55
|
+
try {
|
|
56
|
+
def = (JSON.parse(readFileSync(path, 'utf8')) as { $defs?: Record<string, unknown> }).$defs?.['negotiationDecided'];
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
if (!def || typeof def !== 'object') return null;
|
|
59
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
60
|
+
addFormats(ajv);
|
|
61
|
+
const validate = ajv.compile(def as Record<string, unknown>);
|
|
62
|
+
return (p) => ({ ok: validate(p) as boolean, errors: ajv.errorsText(validate.errors, { separator: '; ' }) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface EventRow { type?: unknown; payload?: Record<string, unknown>; sequence?: unknown }
|
|
66
|
+
async function eventsOf(runId: string): Promise<EventRow[]> {
|
|
67
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
68
|
+
return res.status === 200 ? ((res.json as { events?: EventRow[] } | null)?.events ?? []) : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drive one exchange; returns the seam response or null with the reason recorded. */
|
|
72
|
+
async function drive(protocol: 'a2a' | 'mcp', peerUrl: string): Promise<{ status: number; body: Record<string, unknown> } | null> {
|
|
73
|
+
const path = `${SEAMS_PREFIX}/sample/${protocol}/invoke`;
|
|
74
|
+
const res = await driver.post(path, protocol === 'a2a' ? { peerUrl } : { serverUrl: peerUrl });
|
|
75
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) {
|
|
76
|
+
seamAbsent(`host advertises ${protocol} but the invoke seam ${path} answered ${res.status} — the exchange cannot be driven (host-sample-test-seams.md §22/§23)`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { status: res.status, body: (res.json ?? {}) as Record<string, unknown> };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function originDigest(url: string): string {
|
|
83
|
+
return createHash('sha256').update(new URL(url).origin, 'utf8').digest('hex');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
87
|
+
const doc = await discovery();
|
|
88
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
|
+
const facet = await familyAdvertised(protocol);
|
|
90
|
+
if (!facet) return softSkip('inapplicable', `${protocol} facet not advertised — no negotiation to audit`);
|
|
91
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('blocked', `the exchange is driven through the seams profile (RFC 0175 §D.3) — conformance.seamsProfile is not openwop-conformance-seams-v2`);
|
|
92
|
+
const peer = protocol === 'a2a' ? getA2AFakePeer() : getMcpFakeServer();
|
|
93
|
+
if (peer === null) return softSkip('blocked', `the suite ${protocol === 'a2a' ? 'A2A fake peer (OPENWOP_A2A_FAKE_PEER=true)' : 'MCP fake server (OPENWOP_MCP_FAKE_SERVER=true)'} is not started in this run`);
|
|
94
|
+
peer.reset();
|
|
95
|
+
const validate = payloadValidator();
|
|
96
|
+
if (!validate) return softSkip('blocked', 'run-event-payloads.schema.json#/$defs/negotiationDecided not readable from SCHEMAS_DIR');
|
|
97
|
+
|
|
98
|
+
const driven = await drive(protocol, peer.endpoint());
|
|
99
|
+
if (!driven) return softSkip('blocked', 'invoke seam unavailable (reason recorded above)');
|
|
100
|
+
const runId = driven.body['runId'];
|
|
101
|
+
if (typeof runId !== 'string') {
|
|
102
|
+
return softSkip('blocked', `the ${protocol} invoke seam answered ${driven.status} without a runId — the host log that carries ${EVENT} is not addressable from the suite (the seam MUST name the run whose log recorded the decision)`);
|
|
103
|
+
}
|
|
104
|
+
const events = await eventsOf(runId);
|
|
105
|
+
const decided = events.filter((e) => e.type === EVENT);
|
|
106
|
+
expect(
|
|
107
|
+
decided.length,
|
|
108
|
+
req(id, 'interop.md §The audit event', `every ${protocol} negotiation outcome MUST emit ${EVENT} on the host's own event log (RFC 0175 §D.3) — none found on run ${runId}`),
|
|
109
|
+
).toBeGreaterThan(0);
|
|
110
|
+
const outcomeExpected = driven.status < 400 ? 'accepted' : 'refused';
|
|
111
|
+
for (const e of decided) {
|
|
112
|
+
const p = e.payload ?? {};
|
|
113
|
+
const check = validate(p);
|
|
114
|
+
expect(check.ok, req(id, 'run-event-payloads.schema.json negotiationDecided', `the payload MUST validate: ${check.errors}`)).toBe(true);
|
|
115
|
+
expect(p['protocol'], req(id, 'run-event-payloads.schema.json negotiationDecided.protocol', `protocol MUST be ${protocol}`)).toBe(protocol);
|
|
116
|
+
if (typeof p['peerDigest'] === 'string') {
|
|
117
|
+
expect(
|
|
118
|
+
p['peerDigest'],
|
|
119
|
+
req(id, 'interop.md §The audit event', 'peerDigest MUST be the SHA-256 of the peer origin — never the origin in clear (RFC 0175 §D.3)'),
|
|
120
|
+
).toBe(originDigest(peer.endpoint()));
|
|
121
|
+
}
|
|
122
|
+
for (const v of Object.values(p)) {
|
|
123
|
+
expect(
|
|
124
|
+
typeof v === 'string' && v.includes(new URL(peer.endpoint()).host),
|
|
125
|
+
req(id, 'interop.md §The audit event', 'the event is content-free: the peer origin MUST NOT appear in clear in any field'),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const last = decided[decided.length - 1]!.payload ?? {};
|
|
130
|
+
expect(
|
|
131
|
+
last['outcome'],
|
|
132
|
+
req(id, 'run-event-payloads.schema.json negotiationDecided.outcome', `the seam answered ${driven.status}, so the recorded outcome MUST be ${outcomeExpected}`),
|
|
133
|
+
).toBe(outcomeExpected);
|
|
134
|
+
if (driven.status < 400) {
|
|
135
|
+
expect(
|
|
136
|
+
last['version'],
|
|
137
|
+
req(id, 'interop.md §The audit event', 'an accepted negotiation records the version the host actually used, which MUST equal the seam-reported negotiatedVersion'),
|
|
138
|
+
).toBe(driven.body['negotiatedVersion']);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('RFC 0175 §D.3 — negotiation-decided-emitted (gated on a2a/mcp + seams)', () => {
|
|
143
|
+
it('an A2A exchange leaves negotiation.decided on the host log', async () => {
|
|
144
|
+
await leg('a2a', 'openwop.requirement.0175.negotiation-decided-emitted');
|
|
145
|
+
});
|
|
146
|
+
it('an MCP exchange leaves negotiation.decided on the host log', async () => {
|
|
147
|
+
await leg('mcp', 'openwop.requirement.0175.negotiation-decided-emitted.mcp');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0175 §A.1 / §B.1 — `no-transport-advertisement` (suite 2.0.0, target major 2; unaided).
|
|
3
|
+
*
|
|
4
|
+
* REST and SSE are the wire. A v2 host MUST NOT advertise a transport list:
|
|
5
|
+
* `supportedTransports` does not exist in `schemas/v2/capabilities.schema.json`
|
|
6
|
+
* (row C8.2), and the `grpc` block is removed from the closed root because an
|
|
7
|
+
* unwitnessable family is not advertisable (row C8.3; RFC 0169 §A.1). A2A and
|
|
8
|
+
* MCP are compositions advertised by their own facets (`spec/v2/core/interop.md`
|
|
9
|
+
* §REST is the wire, §gRPC).
|
|
10
|
+
*
|
|
11
|
+
* Unaided: the v2 representation of `/.well-known/openwop` is enough. The
|
|
12
|
+
* schema check (closed root) is the belt; the named-key checks are the braces,
|
|
13
|
+
* so a host whose document fails validation for some other reason still gets
|
|
14
|
+
* the specific finding.
|
|
15
|
+
*
|
|
16
|
+
* @see spec/v2/core/interop.md §REST is the wire, §gRPC
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { req } from '../lib/requirement-ids.js';
|
|
23
|
+
|
|
24
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
25
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('RFC 0175 §A.1/§B.1 — no-transport-advertisement (unaided)', () => {
|
|
29
|
+
it('the v2 root carries no supportedTransports and no grpc block', async () => {
|
|
30
|
+
const doc = await discovery();
|
|
31
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
32
|
+
expect(
|
|
33
|
+
'supportedTransports' in doc,
|
|
34
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'interop.md §REST is the wire', 'a v2 host MUST NOT advertise a transport list — `supportedTransports` does not exist in v2 (RFC 0175 §B.1, row C8.2)'),
|
|
35
|
+
).toBe(false);
|
|
36
|
+
expect(
|
|
37
|
+
'grpc' in doc,
|
|
38
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'interop.md §gRPC', 'a v2 host MUST NOT advertise a `grpc` capability block — an unwitnessable family is not advertisable (RFC 0175 §A.1, row C8.3)'),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
// Neither may hide inside a nested family: an `a2a.transports` or
|
|
41
|
+
// `mcp.supportedTransports` would be the same claim one level down.
|
|
42
|
+
for (const family of ['a2a', 'mcp']) {
|
|
43
|
+
const rec = doc[family];
|
|
44
|
+
if (rec && typeof rec === 'object') {
|
|
45
|
+
expect(
|
|
46
|
+
'supportedTransports' in (rec as Record<string, unknown>),
|
|
47
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'interop.md §The facets', `${family} is a composition facet, not a transport advertisement — no supportedTransports inside it`),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const check = v2Validator('capabilities')(doc);
|
|
52
|
+
expect(
|
|
53
|
+
check.ok,
|
|
54
|
+
req('openwop.requirement.0175.no-transport-advertisement', 'capabilities.schema.json (closed root)', `the v2 discovery document MUST validate against the closed root — a transport list or a grpc block fails it: ${check.errors}`),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `owner-subject-required` (suite 2.0.0; RFC 0170 §A.1;
|
|
3
|
+
* `spec/v2/core/identity.md` §1.1 "Shape").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `RunSnapshot.owner` is
|
|
6
|
+
* `{ tenant, workspace?, subject }` with `subject` REQUIRED and validating
|
|
7
|
+
* against `schemas/v2/subject.schema.json`; `principal` / `principalKind` are
|
|
8
|
+
* removed. The run is created through `POST /runs` with the smallest valid body
|
|
9
|
+
* (the `conformance-noop` fixture); a refusal to create records `blocked` with
|
|
10
|
+
* the host's envelope.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
15
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
16
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
17
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
18
|
+
import { req } from '../lib/requirement-ids.js';
|
|
19
|
+
|
|
20
|
+
const DOC = 'spec/v2/core/identity.md §1.1';
|
|
21
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
22
|
+
|
|
23
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
24
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
27
|
+
try { return await fn(); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Created = { readonly ok: true; readonly runId: string } | { readonly ok: false; readonly reason: string };
|
|
31
|
+
|
|
32
|
+
async function createRun(): Promise<Created> {
|
|
33
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
34
|
+
if (res === null) return { ok: false, reason: 'POST /runs unreachable (fetch failed)' };
|
|
35
|
+
const runId = (res.json as { runId?: unknown } | undefined)?.runId;
|
|
36
|
+
if (res.status !== 201 || typeof runId !== 'string') {
|
|
37
|
+
return { ok: false, reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${res.status} ${readErrorCode(res.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, runId };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function snapshot(): Promise<{ owner: Record<string, unknown> } | { reason: string }> {
|
|
43
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
44
|
+
const created = await createRun();
|
|
45
|
+
if (!created.ok) return { reason: created.reason };
|
|
46
|
+
const res = await http(() => driver.get(`/runs/${encodeURIComponent(created.runId)}`));
|
|
47
|
+
if (res === null || res.status !== 200) return { reason: `GET /runs/{runId} answered ${res?.status ?? 'no response'} for the run just created` };
|
|
48
|
+
const owner = (res.json as { owner?: unknown } | undefined)?.owner;
|
|
49
|
+
if (owner === null || typeof owner !== 'object') return { reason: 'GET /runs/{runId} carries no `owner` object — RunSnapshot.owner is REQUIRED (schemas/v2/run-snapshot.schema.json)' };
|
|
50
|
+
return { owner: owner as Record<string, unknown> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('v2 owner-subject-required (RFC 0170 §A.1)', () => {
|
|
54
|
+
it('owner.subject is present and validates against subject.schema.json', async () => {
|
|
55
|
+
const s = await snapshot();
|
|
56
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
57
|
+
const validate = v2Validator('subject');
|
|
58
|
+
expect(s.owner['subject'], req('openwop.requirement.0170.owner-subject-required.subject', DOC, 'RunSnapshot.owner.subject is REQUIRED')).toBeDefined();
|
|
59
|
+
const r = validate(s.owner['subject']);
|
|
60
|
+
expect(r.ok, req('openwop.requirement.0170.owner-subject-required.subject', DOC, `owner.subject MUST validate against schemas/v2/subject.schema.json (issuer, subjectId, tenant, lane, kind; closed) (${r.errors})`)).toBe(true);
|
|
61
|
+
expect(typeof s.owner['tenant'], req('openwop.requirement.0170.owner-subject-required.subject', DOC, 'RunSnapshot.owner.tenant is REQUIRED')).toBe('string');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('owner carries no principal / principalKind', async () => {
|
|
65
|
+
const s = await snapshot();
|
|
66
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
67
|
+
expect(s.owner['principal'], req('openwop.requirement.0170.owner-subject-required.no-principal', DOC, '`principal` is removed from RunSnapshot.owner — subject.subjectId carries it')).toBeUndefined();
|
|
68
|
+
expect(s.owner['principalKind'], req('openwop.requirement.0170.owner-subject-required.no-principal', DOC, '`principalKind` is removed from RunSnapshot.owner — subject.kind carries it')).toBeUndefined();
|
|
69
|
+
const extra = Object.keys(s.owner).filter((k) => !['tenant', 'workspace', 'subject'].includes(k));
|
|
70
|
+
expect(extra, req('openwop.requirement.0170.owner-subject-required.no-principal', DOC, 'owner is the closed block { tenant, workspace?, subject }')).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §B — `pack-isolation` (suite 2.0.0, target major 2; gated on `packs` + `sandbox`).
|
|
3
|
+
*
|
|
4
|
+
* Isolation binds with pack EXECUTION: a host that executes third-party packs
|
|
5
|
+
* MUST enforce the eight `node-pack-sandbox-*` invariants and MUST advertise
|
|
6
|
+
* `sandbox.isolationModel ∈ wasm | process | container | vm`
|
|
7
|
+
* (`spec/v2/facets/sandbox.schema.json`); `node:vm` is not a value — it "is
|
|
8
|
+
* escapable by design" (RFC 0035 §130) and RFC 0173 §D.1 disposes of it
|
|
9
|
+
* (`spec/v2/core/security-defaults.md` §Sandbox isolation; RFC 0173 §B row C6.5).
|
|
10
|
+
*
|
|
11
|
+
* Legs:
|
|
12
|
+
* - unaided on the gate: the facet names a mechanism from the closed set and
|
|
13
|
+
* never `node:vm`;
|
|
14
|
+
* - the eight behavioural legs are seam-driven through the RFC 0035 §B
|
|
15
|
+
* misbehaving-pack registry (`host-sample-test-seams.md` §8,
|
|
16
|
+
* `POST /v1/host/sample/test/sandbox-invoke` → `/conformance/seams/sample/test/sandbox-invoke`
|
|
17
|
+
* under the v2 seams profile): fs read, fs write, env, network, process,
|
|
18
|
+
* timeout, memory cap, cross-pack context. Each is `blocked` unless the
|
|
19
|
+
* seams profile is advertised and the seam answers. `no-eval` stays
|
|
20
|
+
* reference-impl (`ext/sandbox-runtime-notes`, RFC 0173 §D.1) and is not a
|
|
21
|
+
* suite leg.
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v2/core/security-defaults.md §Sandbox isolation
|
|
24
|
+
* @see spec/v1/host-sample-test-seams.md §8
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
30
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
31
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
const ISOLATION_MODELS = ['wasm', 'process', 'container', 'vm'];
|
|
35
|
+
const INVOKE = `${SEAMS_PREFIX}/sample/test/sandbox-invoke`;
|
|
36
|
+
|
|
37
|
+
interface SandboxError { code?: string; details?: { escapeKind?: string; requestedCapability?: string; message?: string } }
|
|
38
|
+
interface InvokeResult { result?: unknown; error?: SandboxError }
|
|
39
|
+
|
|
40
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
41
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Gate for every leg: packs + sandbox advertised. Returns the sandbox facet or null (reason recorded). */
|
|
45
|
+
async function gated(): Promise<Record<string, unknown> | null> {
|
|
46
|
+
const doc = await discovery();
|
|
47
|
+
if (!doc) { softSkip('blocked', 'discovery unreachable'); return null; }
|
|
48
|
+
if (!(await gateFamily('packs'))) { softSkip('inapplicable', 'packs family not advertised — no pack execution, no isolation obligation (gate recorded under openwop.family.packs)'); return null; }
|
|
49
|
+
const sandbox = await gateFamily('sandbox');
|
|
50
|
+
if (!sandbox) { softSkip('inapplicable', 'sandbox family not advertised (gate recorded under openwop.family.sandbox)'); return null; }
|
|
51
|
+
return sandbox;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drive one misbehaving typeId through the sandbox seam; null (reason recorded) when the seam is absent. */
|
|
55
|
+
async function invoke(typeId: string, extra: Record<string, unknown> = {}): Promise<InvokeResult | null> {
|
|
56
|
+
const doc = await discovery();
|
|
57
|
+
if (!doc || !seamsProfileAdvertised(doc)) { softSkip('blocked', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
|
|
58
|
+
const res = await driver.post(INVOKE, { typeId, ...extra });
|
|
59
|
+
if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises packs + sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
|
|
60
|
+
expect(res.status, req('openwop.requirement.0173.pack-isolation.seam', 'host-sample-test-seams.md §8', `${INVOKE} MUST answer 200 { result } | 200 { error } for ${typeId}`)).toBe(200);
|
|
61
|
+
return (res.json ?? {}) as InvokeResult;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function expectEscape(id: string, r: InvokeResult, escapeKind: string, invariant: string): void {
|
|
65
|
+
expect(r.error?.code, req(id, 'security-defaults.md §Sandbox isolation', `${invariant}: the sandbox MUST refuse with sandbox_escape_attempt (RFC 0173 §B binds the invariant with pack execution)`)).toBe('sandbox_escape_attempt');
|
|
66
|
+
expect(r.error?.details?.escapeKind, req(id, 'host-sample-test-seams.md §8 SandboxError', `escapeKind MUST be ${escapeKind}`)).toBe(escapeKind);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('RFC 0173 §B — pack-isolation (gated on packs + sandbox)', () => {
|
|
70
|
+
it('sandbox.isolationModel names a real mechanism, never node:vm', async () => {
|
|
71
|
+
const sandbox = await gated();
|
|
72
|
+
if (!sandbox) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
73
|
+
const model = sandbox['isolationModel'];
|
|
74
|
+
expect(
|
|
75
|
+
model,
|
|
76
|
+
req('openwop.requirement.0173.pack-isolation', 'facets/sandbox.schema.json isolationModel', 'isolationModel MUST NOT be node:vm — it is escapable by design (RFC 0035 §130) and is not a v2 value (RFC 0173 §D.1)'),
|
|
77
|
+
).not.toBe('node:vm');
|
|
78
|
+
expect(
|
|
79
|
+
ISOLATION_MODELS,
|
|
80
|
+
req('openwop.requirement.0173.pack-isolation', 'facets/sandbox.schema.json isolationModel', `isolationModel MUST be one of ${ISOLATION_MODELS.join(' | ')} (got ${String(model)})`),
|
|
81
|
+
).toContain(model);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('node-pack-sandbox-fs-gated: a host filesystem read is refused', async () => {
|
|
85
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
86
|
+
const r = await invoke('misbehave.fs-escape-read');
|
|
87
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
88
|
+
expectEscape('openwop.requirement.0173.pack-isolation.fs-read', r, 'host-fs-escape', 'node-pack-sandbox-fs-gated');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('node-pack-sandbox-fs-gated: a host filesystem write is refused', async () => {
|
|
92
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
93
|
+
const r = await invoke('misbehave.fs-escape-write');
|
|
94
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
95
|
+
expectEscape('openwop.requirement.0173.pack-isolation.fs-write', r, 'host-fs-escape', 'node-pack-sandbox-fs-gated');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('node-pack-sandbox-no-env: the host environment does not leak', async () => {
|
|
99
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
100
|
+
const r = await invoke('misbehave.env-leak');
|
|
101
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
102
|
+
expectEscape('openwop.requirement.0173.pack-isolation.no-env', r, 'host-env-leak', 'node-pack-sandbox-no-env');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('node-pack-sandbox-network-gated: ungated network egress is refused', async () => {
|
|
106
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
107
|
+
const r = await invoke('misbehave.network-escape');
|
|
108
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
109
|
+
expectEscape('openwop.requirement.0173.pack-isolation.network', r, 'network-escape', 'node-pack-sandbox-network-gated');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('node-pack-sandbox-no-process: host process access is refused', async () => {
|
|
113
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
114
|
+
const r = await invoke('misbehave.process-escape');
|
|
115
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
116
|
+
expectEscape('openwop.requirement.0173.pack-isolation.no-process', r, 'host-process-escape', 'node-pack-sandbox-no-process');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('node-pack-sandbox-timeout: exceeding wallClockLimitMs fails with sandbox_timeout', async () => {
|
|
120
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
121
|
+
const r = await invoke('misbehave.timeout');
|
|
122
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
123
|
+
expect(r.error?.code, req('openwop.requirement.0173.pack-isolation.timeout', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-timeout: a wall-clock overrun MUST fail with sandbox_timeout')).toBe('sandbox_timeout');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('node-pack-sandbox-memory-cap: exceeding memoryLimitBytes fails with sandbox_memory_exceeded', async () => {
|
|
127
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
128
|
+
const r = await invoke('misbehave.memory-bomb');
|
|
129
|
+
if (!r) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
130
|
+
expect(r.error?.code, req('openwop.requirement.0173.pack-isolation.memory-cap', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-memory-cap: a memory overrun MUST fail with sandbox_memory_exceeded')).toBe('sandbox_memory_exceeded');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('node-pack-sandbox-isolated-context: a pack cannot mutate state another invocation sees', async () => {
|
|
134
|
+
if (!(await gated())) return softSkip('inapplicable', 'gate not met (reason recorded above)');
|
|
135
|
+
const first = await invoke('misbehave.cross-pack-mutate');
|
|
136
|
+
if (!first) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
137
|
+
const second = await invoke('misbehave.cross-pack-mutate');
|
|
138
|
+
if (!second) return softSkip('blocked', 'seam unavailable (reason recorded above)');
|
|
139
|
+
for (const r of [first, second]) {
|
|
140
|
+
expect(r.error, req('openwop.requirement.0173.pack-isolation.isolated-context', 'host-sample-test-seams.md §8', 'misbehave.cross-pack-mutate is not a failure mode — it MUST return a result')).toBeUndefined();
|
|
141
|
+
expect(
|
|
142
|
+
(r.result as { shared?: unknown } | undefined)?.shared,
|
|
143
|
+
req('openwop.requirement.0173.pack-isolation.isolated-context', 'security-defaults.md §Sandbox isolation', 'node-pack-sandbox-isolated-context: result.shared MUST equal 1 on every invocation — a mutation MUST NOT leak into a fresh context'),
|
|
144
|
+
).toBe(1);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `payload-registry-closed` (suite 2.0.0; RFC 0171 §A.4;
|
|
3
|
+
* `spec/v2/core/events.md` §"Payloads").
|
|
4
|
+
*
|
|
5
|
+
* Witness class: witnessable — unaided. `schemas/v2/run-event-payloads.schema.json`
|
|
6
|
+
* holds one closed `$defs` entry per payload and the NORMATIVE `_typeIndex`
|
|
7
|
+
* from v2 type to `$defs` key. A host MUST emit, for every registered type, a
|
|
8
|
+
* payload that validates against the entry `_typeIndex` names; a registered
|
|
9
|
+
* type with no index entry is a corpus defect and fails here too. Vendor
|
|
10
|
+
* events have no registry entry and are not checked. The payload validator is
|
|
11
|
+
* built locally (a `$ref` into the payload registry's `$defs`), because
|
|
12
|
+
* `v2Validator` resolves whole schema files only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
19
|
+
import addFormats from 'ajv-formats';
|
|
20
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
21
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
22
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
23
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
|
|
27
|
+
const DOC = 'spec/v2/core/events.md §Payloads';
|
|
28
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
29
|
+
const PAYLOADS_ID = 'https://openwop.dev/spec/v2/run-event-payloads.schema.json';
|
|
30
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
31
|
+
const TIMEOUT_MS = Number(process.env['OPENWOP_LIFECYCLE_TIMEOUT_MS'] ?? 10_000);
|
|
32
|
+
|
|
33
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
34
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
37
|
+
try { return await fn(); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let ajv: Ajv2020 | undefined;
|
|
41
|
+
function registry(): Ajv2020 {
|
|
42
|
+
if (ajv) return ajv;
|
|
43
|
+
ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
44
|
+
addFormats(ajv);
|
|
45
|
+
const walk = (d: string): string[] => readdirSync(d).flatMap((n) => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : p.endsWith('.schema.json') ? [p] : []; });
|
|
46
|
+
for (const p of walk(join(SCHEMAS_DIR, 'v2'))) { try { ajv.addSchema(JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>); } catch { /* a duplicate $id is registered once */ } }
|
|
47
|
+
return ajv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `_typeIndex.properties[type].$ref` → the `$defs` key. */
|
|
51
|
+
function typeIndex(): Map<string, string> {
|
|
52
|
+
const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event-payloads.schema.json'), 'utf8')) as { _typeIndex?: { properties?: Record<string, { $ref?: string }> } };
|
|
53
|
+
const out = new Map<string, string>();
|
|
54
|
+
for (const [type, ref] of Object.entries(schema._typeIndex?.properties ?? {})) {
|
|
55
|
+
const key = ref.$ref?.replace(/^#\/\$defs\//, '');
|
|
56
|
+
if (key !== undefined) out.set(type, key);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function protocolTypes(): Set<string> {
|
|
62
|
+
const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event.schema.json'), 'utf8')) as { properties: { type: { oneOf: Array<{ enum?: string[] }> } } };
|
|
63
|
+
return new Set(schema.properties.type.oneOf.flatMap((b) => b.enum ?? []));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const defValidators = new Map<string, ReturnType<Ajv2020['compile']>>();
|
|
67
|
+
function validatePayload(defKey: string, payload: unknown): { ok: boolean; errors: string } {
|
|
68
|
+
const a = registry();
|
|
69
|
+
let v = defValidators.get(defKey);
|
|
70
|
+
if (v === undefined) { v = a.compile({ $ref: `${PAYLOADS_ID}#/$defs/${defKey}` }); defValidators.set(defKey, v); }
|
|
71
|
+
return { ok: v(payload) as boolean, errors: a.errorsText(v.errors, { separator: '; ' }) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function terminalEvents(): Promise<unknown[] | { reason: string }> {
|
|
75
|
+
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
76
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
77
|
+
if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
78
|
+
const runId = (created.json as { runId?: unknown } | undefined)?.runId;
|
|
79
|
+
if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
|
|
80
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
83
|
+
if (snap !== null && TERMINAL.has(String((snap.json as { status?: unknown } | undefined)?.status))) break;
|
|
84
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
85
|
+
}
|
|
86
|
+
const poll = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
87
|
+
if (poll === null || poll.status !== 200) return { reason: `GET /runs/{runId}/events/poll answered ${poll?.status ?? 'no response'}` };
|
|
88
|
+
const events = (poll.json as { events?: unknown } | undefined)?.events;
|
|
89
|
+
if (!Array.isArray(events) || events.length === 0) return { reason: 'the poll returned no events for a run driven to terminal status — no payload is observable' };
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('v2 payload-registry-closed (RFC 0171 §A.4)', () => {
|
|
94
|
+
it('every registered event carries a payload that validates against its _typeIndex entry', async () => {
|
|
95
|
+
const events = await terminalEvents();
|
|
96
|
+
if (!Array.isArray(events)) return softSkip('blocked', events.reason);
|
|
97
|
+
const index = typeIndex();
|
|
98
|
+
const enumSet = protocolTypes();
|
|
99
|
+
const registered = events.filter((ev) => enumSet.has(String((ev as { type?: unknown }).type)));
|
|
100
|
+
if (registered.length === 0) return softSkip('blocked', 'the run emitted only vendor events — no registered payload to check');
|
|
101
|
+
for (const ev of registered) {
|
|
102
|
+
const type = String((ev as { type?: unknown }).type);
|
|
103
|
+
const key = index.get(type);
|
|
104
|
+
expect(key, req('openwop.requirement.0171.payload-registry-closed', DOC, `_typeIndex is normative: registered type ${type} MUST map to a $defs entry in run-event-payloads.schema.json`)).toBeDefined();
|
|
105
|
+
if (key === undefined) continue;
|
|
106
|
+
const r = validatePayload(key, (ev as { payload?: unknown }).payload);
|
|
107
|
+
expect(r.ok, req('openwop.requirement.0171.payload-registry-closed', DOC, `the payload of ${type} MUST validate against $defs/${key} (every entry additionalProperties: false) (${r.errors})`)).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|