@openwop/openwop-conformance 2.35.0 → 2.36.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 (123) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -3
  3. package/coverage.md +13 -1
  4. package/dist/lib/certification-bundle-v3.js +21 -4
  5. package/dist/spec-artifacts.lock.json +2 -2
  6. package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
  7. package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
  8. package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
  9. package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
  10. package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
  11. package/fixtures/a2ui-v09/negative-image-component.json +145 -0
  12. package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
  13. package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
  14. package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
  15. package/fixtures/conformance-artifact-emit.json +28 -0
  16. package/fixtures/conformance-clarification-nested.json +53 -0
  17. package/fixtures/conformance-clarification-sensitive.json +49 -0
  18. package/fixtures/conformance-credential.json +25 -0
  19. package/fixtures/conformance-mcp-client.json +28 -0
  20. package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
  21. package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
  22. package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
  23. package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
  24. package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
  25. package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
  26. package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
  27. package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
  28. package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
  29. package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
  30. package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
  31. package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
  32. package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
  33. package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
  34. package/fixtures/node-pack-runtime/negative-headers.json +37 -0
  35. package/fixtures/node-pack-runtime/negative-http.json +31 -0
  36. package/fixtures/node-pack-runtime/negative-icons.json +37 -0
  37. package/fixtures/node-pack-runtime/negative-meta.json +36 -0
  38. package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
  39. package/fixtures/node-pack-runtime/negative-packages.json +41 -0
  40. package/fixtures/node-pack-runtime/negative-sse.json +31 -0
  41. package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
  42. package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
  43. package/fixtures/node-pack-runtime/negative-variables.json +38 -0
  44. package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
  45. package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
  46. package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
  47. package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
  48. package/fixtures/oauth-providers/synthetic.json +36 -4
  49. package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
  50. package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
  51. package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
  52. package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
  53. package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
  54. package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
  55. package/fixtures.md +139 -0
  56. package/package.json +2 -2
  57. package/requirements.json +4480 -97
  58. package/scenario-majors.json +93 -6
  59. package/schemas/CORPUS-STAMP.json +103 -89
  60. package/src/lib/behavior-gate.ts +7 -1
  61. package/src/lib/certification-bundle-v3.ts +18 -4
  62. package/src/lib/localized-content.ts +53 -0
  63. package/src/lib/mcp-fake-server.ts +105 -4
  64. package/src/lib/node-pack-runtime.ts +41 -0
  65. package/src/lib/oauth-as-double.ts +316 -0
  66. package/src/lib/protected-resource.ts +88 -0
  67. package/src/lib/standard-webhooks.ts +120 -0
  68. package/src/lib/toolCatalog.ts +54 -4
  69. package/src/lib/trace-context.ts +98 -0
  70. package/src/lib/v2.ts +6 -0
  71. package/src/lib/webhook-receiver.ts +201 -1
  72. package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
  73. package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
  74. package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
  75. package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
  76. package/src/scenarios/debugBundle.test.ts +41 -0
  77. package/src/scenarios/fixtures-valid.test.ts +72 -0
  78. package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
  79. package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
  80. package/src/scenarios/localized-content-delivery.test.ts +26 -15
  81. package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
  82. package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
  83. package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
  84. package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
  85. package/src/scenarios/tool-catalog-projection.test.ts +48 -12
  86. package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
  87. package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
  88. package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
  89. package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
  90. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
  91. package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
  92. package/src/scenarios/v2-auth-challenge.test.ts +119 -0
  93. package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
  94. package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
  95. package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
  96. package/src/scenarios/v2-configurable-closed.test.ts +1 -1
  97. package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
  98. package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
  99. package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
  100. package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
  101. package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
  102. package/src/scenarios/v2-implementation-informational.test.ts +33 -0
  103. package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
  104. package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
  105. package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
  106. package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
  107. package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
  108. package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
  109. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
  110. package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
  111. package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
  112. package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
  113. package/src/scenarios/v2-run-cancel.test.ts +1 -1
  114. package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
  115. package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
  116. package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
  117. package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
  118. package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
  119. package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
  120. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
  121. package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
  122. package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
  123. package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * RFC 0200 §A — the host serves RFC 9728 Protected Resource Metadata, derived from the
3
+ * lanes it already advertises (suite 2.36.0, target major 2).
4
+ *
5
+ * **Gate:** an `auth.lanes[]` member with `lane ∈ {oauth2, oidc}`. A host with neither
6
+ * records `inapplicable` — §A.1 does not bind it. A host WITH one that answers `404` at
7
+ * the derived URL records an `executed-fail`: the lane is the advertisement, the document
8
+ * is owed, and a soft-skip here would let the advertisement cost nothing.
9
+ *
10
+ * Three legs, one requirement id each:
11
+ *
12
+ * 1. `prm-served` — the URL RFC 9728 §3/§3.1 forms from the resource identifier answers
13
+ * `200` with a JSON object, fetched with NO credential (§A.2: the document is served
14
+ * without authentication).
15
+ * 2. `prm-consistent` — it is a PROJECTION, not a second declaration: `resource` is
16
+ * identical to the resource identifier used to form the URL; `authorization_servers`
17
+ * is set-equal to the URL-form issuers of those lanes; `scopes_supported`, when
18
+ * present, is a non-empty array of strings; and neither binding claim is `true`
19
+ * unless EVERY such lane requires that binding — a bearer lane advertised as
20
+ * sender-constrained is `sender-constraint-no-bearer-downgrade`.
21
+ * 3. `mcp-mount-prm` — §A.4, the MCP mount as its own resource. Gated further on a
22
+ * mount that REQUIRES OAuth-lane authentication, which the suite can only establish
23
+ * when every advertised lane is `oauth2`/`oidc`: a host whose api-key lane also
24
+ * opens the mount records `inapplicable` with that reason rather than passing a leg
25
+ * whose premise does not hold.
26
+ *
27
+ * How each FAILS (the sabotage run before citing a row):
28
+ * (a) the host serves nothing at the derived URL → leg 1 (404)
29
+ * (b) a sub-path host serves only the ROOT well-known → leg 1 (404 at the inserted path)
30
+ * (c) `authorization_servers` adds an issuer no lane names → leg 2
31
+ * (d) `resource` carries a trailing slash / another origin → leg 2
32
+ * (e) `dpop_bound_access_tokens_required: true` on a bearer lane → leg 2
33
+ *
34
+ * @see spec/v2/core/identity.md §2.5
35
+ * @see RFCS/0200-host-as-oauth-protected-resource.md §A
36
+ * @see SECURITY/invariants.yaml id: auth-challenge-no-oracle (the §B.3 sibling)
37
+ */
38
+
39
+ import { describe, it, expect } from 'vitest';
40
+ import { driver } from '../lib/driver.js';
41
+ import { softSkip } from '../lib/soft-skip.js';
42
+ import { req } from '../lib/requirement-ids.js';
43
+ import { allLanes, parseChallenge, prmGate, prmUrlFor, urlIssuers } from '../lib/protected-resource.js';
44
+
45
+ const DOC = 'spec/v2/core/identity.md §2.5 (RFC 0200 §A)';
46
+ const SERVED = 'openwop.requirement.0200.prm-served';
47
+ const CONSISTENT = 'openwop.requirement.0200.prm-consistent';
48
+ const MOUNT = 'openwop.requirement.0200.mcp-mount-prm';
49
+
50
+ describe('RFC 0200 §A — v2-protected-resource-metadata (gated on an advertised oauth2/oidc lane)', () => {
51
+ it('serves RFC 9728 metadata, unauthenticated, at the URI derived from its resource identifier', async () => {
52
+ const g = await prmGate();
53
+ if (!g.ok) return softSkip(g.kind, g.reason);
54
+ const res = await driver.get(g.prmUrl, { authenticated: false });
55
+ expect(
56
+ res.status,
57
+ req(SERVED, `${DOC} §A.1–§A.2`, `a host advertising an oauth2/oidc lane MUST serve RFC 9728 metadata at ${g.prmUrl} — served without authentication (got ${res.status}; a 404 is a failure, not a skip: the lane is advertised, so the document is owed)`),
58
+ ).toBe(200);
59
+ expect(
60
+ res.json !== null && typeof res.json === 'object' && !Array.isArray(res.json),
61
+ req(SERVED, `${DOC} §A.2`, `the metadata MUST be a JSON object (got ${typeof res.json}: ${res.text.slice(0, 120)})`),
62
+ ).toBe(true);
63
+ }, 30_000);
64
+
65
+ it('the metadata is a projection of auth.lanes[], not a second declaration', async () => {
66
+ const g = await prmGate();
67
+ if (!g.ok) return softSkip(g.kind, g.reason);
68
+ const res = await driver.get(g.prmUrl, { authenticated: false });
69
+ if (res.status !== 200 || res.json === null || typeof res.json !== 'object') {
70
+ // The served leg already recorded the failure; this leg has nothing to project from.
71
+ return softSkip('blocked', `the metadata did not parse (status ${res.status}) — the prm-served leg carries that verdict`);
72
+ }
73
+ const prm = res.json as Record<string, unknown>;
74
+
75
+ expect(
76
+ prm['resource'],
77
+ req(CONSISTENT, `${DOC} §A.3`, `\`resource\` MUST be identical to the resource identifier the URL was formed from (RFC 9728 §3.3): expected ${g.resource}`),
78
+ ).toBe(g.resource);
79
+
80
+ const expected = urlIssuers(g.lanes);
81
+ const got = [...new Set((Array.isArray(prm['authorization_servers']) ? (prm['authorization_servers'] as unknown[]) : []).map(String))].sort();
82
+ expect(
83
+ got,
84
+ req(CONSISTENT, `${DOC} §A.3`, `\`authorization_servers\` MUST list exactly the URL-form issuers of the oauth2/oidc lanes — an issuer the lanes do not name is a second declaration, and a lane issuer left out hides an authorization server the caller must reach (lanes name [${expected.join(', ')}])`),
85
+ ).toEqual(expected);
86
+
87
+ if ('scopes_supported' in prm) {
88
+ const scopes = prm['scopes_supported'];
89
+ expect(
90
+ Array.isArray(scopes) && scopes.length > 0 && scopes.every((s) => typeof s === 'string' && s.length > 0),
91
+ req(CONSISTENT, `${DOC} §A.3`, `\`scopes_supported\`, when present, MUST list the scopes the host enforces — a non-empty array of strings (got ${JSON.stringify(scopes)})`),
92
+ ).toBe(true);
93
+ }
94
+
95
+ // §A.3: a binding claim MAY be true only where EVERY oauth2/oidc lane's
96
+ // minimumAssurance requires that binding. A bearer lane advertised as
97
+ // sender-constrained is the `sender-constraint-no-bearer-downgrade` violation, and it
98
+ // is exactly the claim a host is tempted to make because it reads as "more secure".
99
+ const everyConstrained = g.lanes.every((l) => l.minimumAssurance === 'sender-constrained' || l.minimumAssurance === 'key-bound');
100
+ const proofs = new Set(g.lanes.flatMap((l) => (Array.isArray(l.delegationProofs) ? l.delegationProofs.map(String) : [])));
101
+ if (prm['dpop_bound_access_tokens_required'] === true) {
102
+ expect(
103
+ everyConstrained && proofs.has('dpop'),
104
+ req(CONSISTENT, `${DOC} §A.3`, 'dpop_bound_access_tokens_required MAY be true only where EVERY oauth2/oidc lane requires that binding (minimumAssurance above bearer with dpop in delegationProofs) — invariant sender-constraint-no-bearer-downgrade'),
105
+ ).toBe(true);
106
+ }
107
+ if (prm['tls_client_certificate_bound_access_tokens'] === true) {
108
+ expect(
109
+ everyConstrained && proofs.has('mtls-key-binding'),
110
+ req(CONSISTENT, `${DOC} §A.3`, 'tls_client_certificate_bound_access_tokens MAY be true only where EVERY oauth2/oidc lane requires mTLS key binding — invariant sender-constraint-no-bearer-downgrade'),
111
+ ).toBe(true);
112
+ }
113
+ }, 30_000);
114
+
115
+ it('an MCP mount that requires OAuth-lane authentication implements one of upstream\'s two discovery mechanisms', async () => {
116
+ const g = await prmGate();
117
+ if (!g.ok) return softSkip(g.kind, g.reason);
118
+ const mcp = g.doc['mcp'] as { serverUrls?: unknown; serverMount?: unknown } | undefined;
119
+ const urls = Array.isArray(mcp?.serverUrls) ? (mcp.serverUrls as unknown[]).map(String) : [];
120
+ if (urls.length === 0) return softSkip('inapplicable', 'no mcp.serverUrls[] — this host mounts no MCP server, so §A.4 does not bind it');
121
+ // §A.4 binds a mount that REQUIRES OAuth-lane authentication. From outside, that holds
122
+ // only when every advertised lane is oauth2/oidc: otherwise an api-key credential opens
123
+ // the mount and the mount's resource server is not an OAuth one. Recording the honest
124
+ // `inapplicable` is the point — a leg that passed on a mount an api-key opens would be
125
+ // measuring nothing.
126
+ const every = allLanes(g.doc);
127
+ const onlyOauth = every.length > 0 && every.every((l) => l.lane === 'oauth2' || l.lane === 'oidc');
128
+ if (!onlyOauth) {
129
+ return softSkip('inapplicable', `the mount does not require an oauth2/oidc lane: this host also advertises [${every.map((l) => String(l.lane)).join(', ')}], any of which opens the mount, so §A.4's premise does not hold`);
130
+ }
131
+ const mount = urls[0] as string;
132
+ const unauth = await driver.post(mount, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { authenticated: false, headers: { 'MCP-Protocol-Version': '2026-07-28' } });
133
+ const challenge = parseChallenge(unauth.headers.get('www-authenticate'));
134
+ const viaChallenge = unauth.status === 401 && challenge !== null && challenge.scheme === 'bearer' && typeof challenge.params['resource_metadata'] === 'string';
135
+ let viaWellKnown = false;
136
+ if (!viaChallenge) {
137
+ const doc = await driver.get(prmUrlFor(mount), { authenticated: false });
138
+ viaWellKnown = doc.status === 200 && (doc.json as { resource?: unknown } | null)?.resource === mount;
139
+ }
140
+ expect(
141
+ viaChallenge || viaWellKnown,
142
+ req(MOUNT, 'spec/v1/mcp-integration.md §E; spec/v2/interop-map.json mcp.authorization (RFC 0200 §A.4)', `a mount requiring OAuth-lane authentication MUST implement one of upstream's two discovery mechanisms for the mount URL as resource — resource_metadata in its 401 challenge, or PRM at ${prmUrlFor(mount)} with resource === ${mount} (mount answered ${unauth.status}, WWW-Authenticate ${unauth.headers.get('www-authenticate') ?? 'absent'})`),
143
+ ).toBe(true);
144
+ }, 30_000);
145
+ });
@@ -92,6 +92,6 @@ describe('v2 run-cancel (runs.md §Cancel)', () => {
92
92
  const final = await waitFor(c.runId, (x) => x === 'cancelled', 5_000);
93
93
  expect(final, req(ID, DOC, `the snapshot MUST read cancelled within 5 s of an accepted cancel (last: ${final ?? 'unreadable'})`)).toBe('cancelled');
94
94
  const types = await eventTypes(c.runId);
95
- expect(types?.includes('run.cancelled') ?? false, req(ID, 'spec/v2/core/events.md §run.cancelled', `the log MUST carry run.cancelled once the cascade completes (types: ${(types ?? []).join(', ')})`)).toBe(true);
95
+ expect(types?.includes('run.cancelled') ?? false, req(ID, 'spec/v2/core/runs.md §Cancel', `the log MUST carry run.cancelled once the cascade completes (types: ${(types ?? []).join(', ')})`)).toBe(true);
96
96
  }, 30_000);
97
97
  });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `spec/v2/core/replay.md` §Endpoint — the fork prefix boundary is EXCLUSIVE
2
+ * `spec/v2/core/replay.md` §The surface — the fork prefix boundary is EXCLUSIVE
3
3
  * (suite 2.0.0, target major 2; unaided; gated on `replay`; two runs created).
4
4
  *
5
5
  * "Events with `sequence < fromSeq` are fixed history; events `>= fromSeq` are
@@ -99,7 +99,7 @@
99
99
  * The leg runs LAST, after the boundary assertion, so a host that fails this
100
100
  * stricter new check still reports the boundary verdict the file was cut for.
101
101
  *
102
- * @see spec/v2/core/replay.md §Endpoint
102
+ * @see spec/v2/core/replay.md §The surface
103
103
  * @see spec/v2/core/replay.md §Byte-equivalence of the prefix
104
104
  * @see spec/v2/core/runs.md §Fork
105
105
  * @see spec/v2/core/runs.md §Diff and ancestry
@@ -114,7 +114,7 @@ import { softSkip } from '../lib/soft-skip.js';
114
114
  import { req } from '../lib/requirement-ids.js';
115
115
 
116
116
  const ID = 'openwop.requirement.0170.fork-prefix-boundary';
117
- const DOC = 'spec/v2/core/replay.md §Endpoint';
117
+ const DOC = 'spec/v2/core/replay.md §The surface';
118
118
  const BYTES = 'spec/v2/core/replay.md §Byte-equivalence of the prefix';
119
119
  const MULTI = 'conformance-multi-node';
120
120
  const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
@@ -168,7 +168,7 @@ async function settle(runId: string, ms = 20_000): Promise<string | null> {
168
168
  return status;
169
169
  }
170
170
 
171
- describe('v2 run-fork-prefix (replay.md §Endpoint — the boundary is exclusive)', () => {
171
+ describe('v2 run-fork-prefix (replay.md §The surface — the boundary is exclusive)', () => {
172
172
  it('a replay fork inherits exactly [0, fromSeq): the event at fromSeq is re-executed, never carried over', async () => {
173
173
  if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
174
174
  if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
@@ -101,7 +101,7 @@ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
101
101
  expect(rb?.status, req(ID, DOC, `the 202 MUST carry status running (got ${String(rb?.status)})`)).toBe('running');
102
102
  const types = await eventTypes(c.runId);
103
103
  const p = types?.indexOf('run.paused') ?? -1; const r = types?.indexOf('run.resumed') ?? -1;
104
- expect(p >= 0 && r > p, req(ID, 'spec/v2/core/events.md §run.paused', `the log MUST carry run.paused then run.resumed (types: ${(types ?? []).join(', ')})`)).toBe(true);
104
+ expect(p >= 0 && r > p, req(ID, 'spec/v2/core/runs.md §Pause and resume', `the log MUST carry run.paused then run.resumed (types: ${(types ?? []).join(', ')})`)).toBe(true);
105
105
  // An immediate pause cuts the executing attempt BETWEEN events: no terminal
106
106
  // node event for it, ever — a node.failed here makes a replay fold a
107
107
  // failure the source never had (runs.md §Pause and resume; replay.md).
@@ -0,0 +1,100 @@
1
+ /**
2
+ * RFC 0204 §D.11–§D.12 — the catalog's MCP annotation projection (suite 2.36.0,
3
+ * target major 2; gated on `toolCatalog`).
4
+ *
5
+ * `spec/v2/core/tool-catalog.md` §The descriptor: the host assigns
6
+ * `safetyTier`, `replayPolicy` and `egress` itself and never copies them from
7
+ * an MCP server's `annotations` (untrusted upstream); an unclassified
8
+ * `source: "mcp"` tool is `write`; and any `annotations` it publishes carry all
9
+ * four hints, each a fixed function of those host-assigned fields.
10
+ *
11
+ * annotations-derived every descriptor carrying `annotations` has all
12
+ * four hints and each equals the table's value;
13
+ * `inapplicable` when no descriptor carries any (a
14
+ * catalog without annotations is untouched by §D.12,
15
+ * and passing it would be vacuous);
16
+ * mcp-unclassified-write the suite's fake MCP server lists
17
+ * `conformance_readonly_claim_<nonce>` annotated
18
+ * `readOnlyHint: true`; wherever a host projects it
19
+ * (any `conformance_readonly_claim_*` descriptor with
20
+ * `source: "mcp"` — a host MAY answer from a list it
21
+ * cached from an earlier suite server within `ttlMs`),
22
+ * it MUST be `safetyTier: "write"`. `inapplicable`
23
+ * when the host does not project the server.
24
+ *
25
+ * Sabotage, each run once against the v2 reference host: emit
26
+ * `readOnlyHint: true` on a `write` tool; omit `openWorldHint`; copy the
27
+ * server's `readOnlyHint` into `safetyTier: "read"` — each turns its row red.
28
+ *
29
+ * @see spec/v2/core/tool-catalog.md §The descriptor
30
+ * @see SECURITY/invariants.yaml tool-annotations-untrusted
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { driver } from '../lib/driver.js';
35
+ import { familyAdvertised } from '../lib/v2.js';
36
+ import { getMcpFakeServer, READONLY_CLAIM_PREFIX } from '../lib/mcp-fake-server.js';
37
+ import { softSkip } from '../lib/soft-skip.js';
38
+ import { req } from '../lib/requirement-ids.js';
39
+ import { expectedAnnotations, type ToolDescriptor } from '../lib/toolCatalog.js';
40
+
41
+ export const REQUIRES_HOST_CALLBACK = "the host projects the suite's fake MCP server into GET /tools (the unclassified-MCP leg)";
42
+
43
+ const DOC = 'spec/v2/core/tool-catalog.md §The descriptor';
44
+ const ID_DERIVED = 'openwop.requirement.0204.annotations-derived';
45
+ const ID_UNCLASSIFIED = 'openwop.requirement.0204.mcp-unclassified-write';
46
+ /**
47
+ * Recorded `inapplicable`, not through `gateFamily`: under strict mode
48
+ * `gateFamily` FAILS a host that neither advertises nor opts out, and a new
49
+ * gate on an existing family would de-certify every strict v2 cut that does not
50
+ * advertise `toolCatalog` until its operator adds an opt-out — a certification
51
+ * change this RFC does not make.
52
+ */
53
+ const NOT_ADVERTISED = 'toolCatalog not advertised — the host publishes no catalog';
54
+ const HINTS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const;
55
+
56
+ async function readCatalog(): Promise<ToolDescriptor[] | { reason: string }> {
57
+ const res = await driver.get('/tools').catch(() => null);
58
+ if (res === null) return { reason: 'GET /tools unreachable (fetch failed)' };
59
+ if (res.status !== 200) return { reason: `the host advertises toolCatalog and GET /tools answered ${res.status}` };
60
+ const body = res.json as unknown;
61
+ const tools = Array.isArray(body) ? body : (body as { tools?: unknown } | null)?.tools;
62
+ return Array.isArray(tools) ? (tools as ToolDescriptor[]) : { reason: 'GET /tools returned neither a ToolDescriptor[] nor { tools }' };
63
+ }
64
+
65
+ describe('RFC 0204 §D — tool annotations are derived, never copied (gated on toolCatalog)', () => {
66
+ it('every published annotations object is the table function of the descriptor', async () => {
67
+ if (!(await familyAdvertised('toolCatalog'))) return softSkip('inapplicable', NOT_ADVERTISED);
68
+ const tools = await readCatalog();
69
+ if (!Array.isArray(tools)) return softSkip('blocked', tools.reason);
70
+ const annotated = tools.filter((t) => t['annotations'] !== undefined);
71
+ if (annotated.length === 0) return softSkip('inapplicable', `none of the ${tools.length} descriptors carries annotations — §D.12 binds only a host that publishes them`);
72
+ for (const t of annotated) {
73
+ const a = (t['annotations'] ?? {}) as Record<string, unknown>;
74
+ const want = expectedAnnotations(t);
75
+ for (const h of HINTS) {
76
+ expect(typeof a[h], req(ID_DERIVED, DOC, `${String(t.toolId)}: annotations MUST carry all four hints — ${h} is ${a[h] === undefined ? 'absent (a consumer would fall through to the MCP default)' : `not a boolean (${JSON.stringify(a[h])})`}`)).toBe('boolean');
77
+ expect(a[h], req(ID_DERIVED, DOC, `${String(t.toolId)}: ${h} MUST be derived from safetyTier=${String(t.safetyTier)} replayPolicy=${String(t['replayPolicy'] ?? '(absent)')} egress=${String(t['egress'] ?? '(absent)')} — want ${String(want[h])}`)).toBe(want[h]);
78
+ }
79
+ }
80
+ });
81
+
82
+ it('an MCP tool the host has not classified is safetyTier write, whatever the server claims', async () => {
83
+ const fam = await familyAdvertised('toolCatalog');
84
+ if (!fam) return softSkip('inapplicable', NOT_ADVERTISED);
85
+ const sources = Array.isArray(fam['sources']) ? (fam['sources'] as unknown[]) : [];
86
+ if (!sources.includes('mcp')) return softSkip('inapplicable', 'toolCatalog.sources does not include mcp — the host projects no MCP server');
87
+ const fake = getMcpFakeServer();
88
+ // No suite server ⇒ nothing the host could project: the RFC's `inapplicable`
89
+ // case, not `blocked` — a tier-2 host that projects its own MCP servers and
90
+ // cuts without OPENWOP_MCP_FAKE_SERVER is not made uncertifiable by this row.
91
+ if (fake === null) return softSkip('inapplicable', 'the suite MCP fake server (OPENWOP_MCP_FAKE_SERVER=true) is not started in this run, so there is no suite server for the host to project');
92
+ const tools = await readCatalog();
93
+ if (!Array.isArray(tools)) return softSkip('blocked', tools.reason);
94
+ const claims = tools.filter((t) => t.source === 'mcp' && [t.toolId, t['title']].some((v) => typeof v === 'string' && v.includes(READONLY_CLAIM_PREFIX)));
95
+ if (claims.length === 0) return softSkip('inapplicable', `no source:"mcp" descriptor names ${fake.readonlyClaimToolName()} — the host does not project the suite's fake server as a catalog source`);
96
+ for (const t of claims) {
97
+ expect(t.safetyTier, req(ID_UNCLASSIFIED, DOC, `${String(t.toolId)}: the server annotates it readOnlyHint: true, which is untrusted; a source:"mcp" tool the host has not classified MUST be safetyTier "write" (got ${String(t.safetyTier)})`)).toBe('write');
98
+ }
99
+ });
100
+ });
@@ -34,7 +34,7 @@
34
34
  * needs it, never to the file.
35
35
  *
36
36
  * @see spec/v2/core/persistence.md §The reader rule
37
- * @see spec/v2/core/events.md §Reading an era-2 log
37
+ * @see spec/v2/core/events.md §Era-2 logs
38
38
  */
39
39
 
40
40
  import { describe, it, expect } from 'vitest';
@@ -16,7 +16,7 @@
16
16
  * every leg records `blocked` naming it.
17
17
  *
18
18
  * @see spec/v2/core/persistence.md §The reader rule, §The seat
19
- * @see spec/v2/core/events.md §Reading an era-2 log
19
+ * @see spec/v2/core/events.md §Era-2 logs
20
20
  */
21
21
 
22
22
  import { describe, it, expect } from 'vitest';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * v2-webhook-egress-refusal — the webhook egress guard REFUSES, at major 2.
3
3
  *
4
- * `spec/v2/core/webhooks.md` §SSRF: "At registration a host MUST reject (`400
4
+ * `spec/v2/core/webhooks.md` §Egress: "At registration a host MUST reject (`400
5
5
  * webhook_url_rejected`) non-`https://` URLs, RFC 1918 and loopback and
6
6
  * link-local ranges, IPv6 ULA, cloud metadata hosts, and `localhost`."
7
7
  *
@@ -39,7 +39,7 @@
39
39
  * deleted at once. Delivery-time re-resolution is a separate MUST that needs a
40
40
  * resolver the suite controls; it is not claimed here.
41
41
  *
42
- * @see spec/v2/core/webhooks.md §SSRF
42
+ * @see spec/v2/core/webhooks.md §Egress
43
43
  * @see spec/v2/core/security-defaults.md §Relaxations
44
44
  */
45
45
 
@@ -51,7 +51,7 @@ import { softSkip } from '../lib/soft-skip.js';
51
51
  import { req } from '../lib/requirement-ids.js';
52
52
 
53
53
  const ID = 'openwop.requirement.0171.webhook-egress-refused';
54
- const DOC = 'spec/v2/core/webhooks.md §SSRF';
54
+ const DOC = 'spec/v2/core/webhooks.md §Egress';
55
55
  const EVENTS = ['run.completed'];
56
56
 
57
57
  /** One destination per class the sentence names. None is ever contacted. */
@@ -70,7 +70,7 @@ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
70
70
  // form let loopback, metadata and RFC 1918 through at registration and at
71
71
  // delivery. Measured on the steward's own reference host.
72
72
  // These are loopback / link-local / RFC 1918 addresses by any reading of
73
- // webhooks.md §SSRF, so they enforce the existing obligation — same id.
73
+ // webhooks.md §Egress, so they enforce the existing obligation — same id.
74
74
  { url: 'https://[::ffff:7f00:1]/openwop-egress-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
75
75
  { url: 'https://[::ffff:a9fe:a9fe]/latest/meta-data/', cls: 'cloud metadata as IPv4-mapped IPv6 (hex)' },
76
76
  { url: 'https://[::ffff:a00:1]/openwop-egress-probe', cls: 'RFC 1918 (10/8) as IPv4-mapped IPv6 (hex)' },
@@ -88,7 +88,7 @@ function declaredWebhookRelaxations(): string[] {
88
88
  }
89
89
  }
90
90
 
91
- describe('v2-webhook-egress-refusal (webhooks.md §SSRF — the guard refuses, at major 2)', () => {
91
+ describe('v2-webhook-egress-refusal (webhooks.md §Egress — the guard refuses, at major 2)', () => {
92
92
  it('a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected', async () => {
93
93
  const doc = await v2Discovery();
94
94
  if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
@@ -0,0 +1,124 @@
1
+ /**
2
+ * RFC 0201 §D — endpoint verification, for opted-in registrations only (suite
3
+ * 2.36.0, target major 2; gated on `webhooks.signatureAlgorithms` listing
4
+ * `standard-webhooks-1`).
5
+ *
6
+ * Before it answers `201` to a registration that lists `standard-webhooks-1`,
7
+ * the host sends ONE signed verification request and refuses the registration
8
+ * `400 webhook_endpoint_unverified`, persisting nothing, unless a `2xx` arrives
9
+ * within 10 s whose JSON `challenge` equals the one sent. The receiver is the
10
+ * suite's modal receiver, one path per behaviour:
11
+ *
12
+ * no-echo 200 `{}` → MUST be refused
13
+ * wrong-echo 200 `{ challenge: <other> }` → MUST be refused
14
+ * redirect 307 to redirect-target (echoes) → MUST be refused; the target MUST see nothing
15
+ * echo 200 `{ challenge }` → MUST answer 201
16
+ *
17
+ * Each verification request is checked against
18
+ * `schemas/v2/webhook-verification.schema.json`, verified with the supplied
19
+ * secret by the suite's Standard Webhooks verifier, and MUST carry no
20
+ * `OpenWOP-Event-Type` (it is not a delivery). A host MUST NOT retry it, so each
21
+ * refused path sees exactly one.
22
+ *
23
+ * "Persisting nothing" is checked on the wire, not by reading the host: after
24
+ * the refusals, one run is driven and the echo subscription's delivery is the
25
+ * sync point — by the time it lands, a refused path that had been persisted
26
+ * would have received the same event. That catches "201 then verify later"
27
+ * and "400 but keep the row".
28
+ *
29
+ * How it FAILS: a host that answers 201 without verifying (no-echo is 201); a
30
+ * host that follows the 307 (the target sees a hit); a host that verifies
31
+ * afterwards (a refused path receives a delivery); a host that retries the
32
+ * verification (two requests on one path).
33
+ *
34
+ * @see spec/v2/core/webhooks.md §Standard Webhooks
35
+ * @see RFCS/0201-standard-webhooks-signature-scheme.md §D
36
+ */
37
+
38
+ import { afterEach, describe, it, expect } from 'vitest';
39
+ import { req } from '../lib/requirement-ids.js';
40
+ import { readErrorCode } from '../lib/error-envelope.js';
41
+ import { softSkip } from '../lib/soft-skip.js';
42
+ import { v2Validator } from '../lib/v2.js';
43
+ import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks } from '../lib/webhook-receiver.js';
44
+ import {
45
+ STANDARD_WEBHOOKS_ALG,
46
+ deliveriesFor,
47
+ driveRun,
48
+ loopbackRefusal,
49
+ registerSw,
50
+ swGate,
51
+ unregisterAllSw,
52
+ waitFor,
53
+ } from '../lib/standard-webhooks.js';
54
+
55
+ const REFUSED = ['no-echo', 'wrong-echo', 'redirect'] as const;
56
+
57
+ let closeReceiver: (() => Promise<void>) | null = null;
58
+ afterEach(async () => {
59
+ await unregisterAllSw();
60
+ if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
61
+ });
62
+
63
+ describe('RFC 0201 §D — endpoint verification refuses without consent (opted-in registrations only)', () => {
64
+ it('no-echo, wrong-echo and a redirect are refused and persist nothing; an echo is accepted', async () => {
65
+ const g = await swGate();
66
+ if (!g.ok) return softSkip(g.kind, g.reason);
67
+ const rx = await startModalReceiver();
68
+ closeReceiver = rx.close;
69
+ const secret = mintWhsec();
70
+ const body = (mode: string): Record<string, unknown> => ({ url: rx.urlFor(mode).url, events: ['run.completed'], signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret });
71
+ const tunnelled = rx.urlFor('echo').tunnelled;
72
+
73
+ // Register the echo subscription first: its 201 is the positive control, and
74
+ // a loopback refusal here is `blocked` before anything is asserted.
75
+ const ok = await registerSw(body('echo'));
76
+ const blocked = loopbackRefusal(ok, tunnelled);
77
+ if (blocked) return softSkip('blocked', blocked);
78
+ const refused = new Map<string, { status: number; code: string | undefined; id: unknown }>();
79
+ for (const mode of REFUSED) {
80
+ const res = await registerSw(body(mode));
81
+ refused.set(mode, { status: res.status, code: readErrorCode(res.json), id: (res.json as { webhookId?: unknown } | null)?.webhookId });
82
+ }
83
+
84
+ const ID = 'openwop.requirement.0201.endpoint-verification';
85
+ expect(ok.status, req(ID, 'RFC 0201 §D.14', `an endpoint that echoes the challenge MUST be registered 201 (got ${ok.status} ${readErrorCode(ok.json) ?? ''})`)).toBe(201);
86
+ const echoId = (ok.json as { webhookId: string }).webhookId;
87
+ for (const [mode, r] of refused) {
88
+ expect(r.status, req(ID, 'RFC 0201 §D.14', `a ${mode} endpoint MUST be refused 400 before any 201 (got ${r.status})`)).toBe(400);
89
+ expect(r.code, req(ID, 'RFC 0201 §D.14', `a ${mode} refusal MUST carry webhook_endpoint_unverified`)).toBe('webhook_endpoint_unverified');
90
+ }
91
+
92
+ const validate = v2Validator('webhook-verification');
93
+ for (const mode of ['echo', ...REFUSED]) {
94
+ const vr = rx.hits.filter((h) => h.mode === mode && h.verification);
95
+ expect(vr.length, req(ID, 'RFC 0201 §D.13–§D.14', `the host MUST send exactly one verification request to the ${mode} endpoint and MUST NOT retry it (saw ${vr.length})`)).toBe(1);
96
+ const h = vr[0]!;
97
+ expect(h.method, req(ID, 'RFC 0201 §D.13', 'the verification request MUST be a POST')).toBe('POST');
98
+ const parsed = JSON.parse(h.body) as unknown;
99
+ const v = validate(parsed);
100
+ expect(v.ok, req(ID, 'RFC 0201 §D.13', `the verification body MUST validate against webhook-verification.schema.json (${v.errors})`)).toBe(true);
101
+ expect(hitHeader(h, 'openwop-event-type'), req(ID, 'RFC 0201 §D.13', 'the verification request is not a delivery and MUST carry no OpenWOP-Event-Type')).toBeUndefined();
102
+ const verdict = verifyStandardWebhooks(h.body, h.headers, secret);
103
+ expect(verdict.matched, req(ID, 'RFC 0201 §D.13', `the verification request MUST be signed as §C.9 with the supplied secret (verifier: ${verdict.reason ?? 'ok'})`)).toBeGreaterThan(0);
104
+ }
105
+ const challenges = rx.hits.filter((h) => h.verification).map((h) => (JSON.parse(h.body) as { challenge: string }).challenge);
106
+ expect(new Set(challenges).size, req(ID, 'RFC 0201 §D.13', 'each verification MUST carry a FRESH challenge')).toBe(challenges.length);
107
+ expect(
108
+ rx.hits.filter((h) => h.mode === 'redirect-target').length,
109
+ req(ID, 'RFC 0201 §D.13', 'the verification request MUST NOT follow a redirect — the 307 target MUST see nothing'),
110
+ ).toBe(0);
111
+
112
+ // Persisted nothing: drive a run and wait for the echo subscription's
113
+ // delivery; by then any persisted refused subscription would have one too.
114
+ const run = await driveRun();
115
+ expect(run.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
116
+ await waitFor(() => deliveriesFor(rx.hits, echoId, run.runId).length > 0, 15_000);
117
+ expect(deliveriesFor(rx.hits, echoId, run.runId).length, req(ID, 'webhooks.md §Durability', 'the verified subscription MUST receive run.completed — the sync point for the persisted-nothing check')).toBeGreaterThan(0);
118
+ await new Promise((r) => setTimeout(r, 1_000));
119
+ for (const mode of REFUSED) {
120
+ const late = rx.hits.filter((h) => h.mode === mode && !h.verification);
121
+ expect(late.length, req(ID, 'RFC 0201 §D.14', `a refused (${mode}) registration MUST persist no subscription — it received ${late.length} later request(s)`)).toBe(0);
122
+ }
123
+ }, 60_000);
124
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * RFC 0201 §C.10 — the signed delivery id is stable across retries and
3
+ * distinct across deliveries (suite 2.36.0, target major 2; gated on
4
+ * `webhooks.signatureAlgorithms` listing `standard-webhooks-1`).
5
+ *
6
+ * `webhook-id` is the receiver's idempotency key, and because it is inside the
7
+ * Standard Webhooks signed bytes it is an AUTHENTICATED one. That only helps if
8
+ * the host keeps it still: a per-attempt id makes a durable host's retries look
9
+ * like new events, and a constant id makes new events look like retries.
10
+ *
11
+ * How the receiver is driven: `fail2` answers `500` to the first two attempts
12
+ * for each `(OpenWOP-Webhook-Id, runId, sequence)` and `204` afterwards, so
13
+ * every delivery is attempted at least twice (retries are a v2 MUST,
14
+ * webhooks.md §Durability). Two opted-in subscriptions to the SAME URL, each on
15
+ * `run.started` + `run.completed`, give four distinct deliveries of one run:
16
+ * two by event, two by subscription.
17
+ *
18
+ * Observe first, then assert (the 2.34.1 lesson): if no delivery key was seen
19
+ * twice inside the window, the leg returns `blocked` before any obligation is
20
+ * asserted, so an unmeasured window can never fold into a partial-witness pass.
21
+ *
22
+ * How it FAILS: a per-attempt `randomUUID()` id (first half); a constant id or
23
+ * the subscription id (the distinctness half); an id containing `.` (grammar).
24
+ * The restart half of §C.10 (an attempt after a host restart) needs the RFC
25
+ * 0158 kill hook and is NOT claimed by this file.
26
+ *
27
+ * @see spec/v2/core/webhooks.md §Standard Webhooks
28
+ * @see RFCS/0201-standard-webhooks-signature-scheme.md §C.10
29
+ */
30
+
31
+ import { afterEach, describe, it, expect } from 'vitest';
32
+ import { req } from '../lib/requirement-ids.js';
33
+ import { readErrorCode } from '../lib/error-envelope.js';
34
+ import { softSkip } from '../lib/soft-skip.js';
35
+ import { hitHeader, mintWhsec, startModalReceiver, STANDARD_WEBHOOKS_ID, type ModalHit } from '../lib/webhook-receiver.js';
36
+ import { retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
37
+ import {
38
+ STANDARD_WEBHOOKS_ALG,
39
+ deliveriesFor,
40
+ driveRun,
41
+ loopbackRefusal,
42
+ registerSw,
43
+ swGate,
44
+ unregisterAllSw,
45
+ waitFor,
46
+ } from '../lib/standard-webhooks.js';
47
+
48
+ const EVENTS = ['run.started', 'run.completed'];
49
+ const CAP_MS = retryWaitCapMs();
50
+
51
+ let closeReceiver: (() => Promise<void>) | null = null;
52
+ afterEach(async () => {
53
+ await unregisterAllSw();
54
+ if (closeReceiver) { const c = closeReceiver; closeReceiver = null; await c(); }
55
+ });
56
+
57
+ function keyOf(h: ModalHit): string {
58
+ let runId = '';
59
+ let seq = '';
60
+ try {
61
+ const b = JSON.parse(h.body) as { runId?: unknown; event?: { sequence?: unknown } };
62
+ runId = String(b.runId ?? '');
63
+ seq = String(b.event?.sequence ?? '');
64
+ } catch { /* keyed on the header alone */ }
65
+ return `${hitHeader(h, 'openwop-webhook-id') ?? ''}|${runId}|${seq}`;
66
+ }
67
+
68
+ describe('RFC 0201 §C.10 — webhook-id is stable across retries, distinct across deliveries', () => {
69
+ it('every attempt of one delivery carries one webhook-id, and distinct deliveries carry distinct ids', async () => {
70
+ const g = await swGate();
71
+ if (!g.ok) return softSkip(g.kind, g.reason);
72
+ const rx = await startModalReceiver();
73
+ closeReceiver = rx.close;
74
+ const target = rx.urlFor('fail2');
75
+ const subs: string[] = [];
76
+ for (let i = 0; i < 2; i++) {
77
+ const reg = await registerSw({ url: target.url, events: EVENTS, signatureAlgorithms: ['v1', STANDARD_WEBHOOKS_ALG], secret: mintWhsec() });
78
+ const blocked = loopbackRefusal(reg, target.tunnelled);
79
+ if (blocked) return softSkip('blocked', blocked);
80
+ if (reg.status !== 201) return softSkip('blocked', `the opt-in registration answered ${reg.status} ${readErrorCode(reg.json) ?? ''} — no subscription to observe (the registration rules are v2-webhook-standard-webhooks-delivery's rows)`);
81
+ subs.push((reg.json as { webhookId: string }).webhookId);
82
+ }
83
+ const run = await driveRun();
84
+ if (run.runId === null) return softSkip('blocked', `POST /runs answered ${run.status} — no delivery to observe`);
85
+ const ours = (): ModalHit[] => subs.flatMap((s) => deliveriesFor(rx.hits, s, run.runId));
86
+ const byKey = (): Map<string, ModalHit[]> => {
87
+ const m = new Map<string, ModalHit[]>();
88
+ for (const h of ours()) m.set(keyOf(h), [...(m.get(keyOf(h)) ?? []), h]);
89
+ return m;
90
+ };
91
+ const expectedKeys = subs.length * EVENTS.length;
92
+ const window = retryWaitFor((g.facet['retryPolicy'] as { backoff?: string } | undefined) ?? null, CAP_MS);
93
+ await waitFor(() => {
94
+ const m = byKey();
95
+ return m.size >= expectedKeys && [...m.values()].every((a) => a.length >= 2);
96
+ }, window);
97
+ const keys = byKey();
98
+ const retried = [...keys.values()].filter((a) => a.length >= 2);
99
+ // OBSERVE FIRST: nothing is asserted until the window question is answered.
100
+ if (retried.length === 0) {
101
+ return softSkip('blocked', `no delivery was attempted twice inside the ${window}ms window (${ours().length} attempt(s) over ${keys.size} key(s)); the receiver answers 500 twice per key, so a retrying host shows two — unmeasured, not unmet (RFC 0148 §A)`);
102
+ }
103
+ const ID = 'openwop.requirement.0201.message-id-stable';
104
+ for (const [key, attempts] of retried.map((a) => [keyOf(a[0]!), a] as const)) {
105
+ const ids = new Set(attempts.map((a) => hitHeader(a, 'webhook-id') ?? '<absent>'));
106
+ expect(
107
+ [...ids],
108
+ req(ID, 'RFC 0201 §C.10', `webhook-id MUST be identical on every attempt of one (webhookId, runId, sequence) — ${attempts.length} attempts of ${key} carried ${ids.size} value(s)`),
109
+ ).toHaveLength(1);
110
+ }
111
+ const perKey = [...keys.entries()].map(([key, a]) => ({ key, id: hitHeader(a[0]!, 'webhook-id') ?? '' }));
112
+ for (const { key, id } of perKey) {
113
+ expect(STANDARD_WEBHOOKS_ID.test(id), req(ID, 'RFC 0201 §C.10', `webhook-id MUST match ^[A-Za-z0-9_-]{16,128}$ (${key}: ${JSON.stringify(id)})`)).toBe(true);
114
+ }
115
+ expect(keys.size, req(ID, 'RFC 0201 §C.10', `two opted-in subscriptions on ${EVENTS.join(' + ')} MUST yield ${expectedKeys} distinct deliveries of one run`)).toBeGreaterThanOrEqual(2);
116
+ expect(
117
+ new Set(perKey.map((k) => k.id)).size,
118
+ req(ID, 'RFC 0201 §C.10', `webhook-id MUST differ between distinct deliveries, by event and by subscription — ${perKey.length} deliveries carried ${new Set(perKey.map((k) => k.id)).size} distinct id(s)`),
119
+ ).toBe(perKey.length);
120
+ for (const { id } of perKey) {
121
+ expect(subs.includes(id), req(ID, 'RFC 0201 §C.11', 'webhook-id MUST NOT be the subscription id')).toBe(false);
122
+ }
123
+ }, CAP_MS + 45_000);
124
+ });