@openwop/openwop-conformance 2.34.1 → 2.35.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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.34.1",
5
- "corpusTag": "v2.34.0",
4
+ "version": "2.35.0",
5
+ "corpusTag": "v2.35.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "d26d906b044e53fbb6ba742be90c853b9dcf14af4005bb6376e354d618fbdd5a",
13
- "api/v2/asyncapi.yaml": "eda3679b6902a3ab931e2dab55a04f6b6bb2b9b664748de38d8a104e53e93c76",
14
- "api/v2/openapi.yaml": "313fc72043a3730fbb98fdf47f4539773564bf9c5bed54f1f180efb50cf57410",
12
+ "api/seams-v2.yaml": "3ce63e8b7bd3b93b7bae997821d4f7f591da89c1045e76c28e7c3bd81561cae9",
13
+ "api/v2/asyncapi.yaml": "91a2b046c8cc3d24d4fca8e74da170f972e03115fa5a467f4f1a7db7113427a4",
14
+ "api/v2/openapi.yaml": "ddc0f56e92487f650fd8e5828cf61451617d7eefe425bd47d88d72675e008ab1",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -28,7 +28,7 @@
28
28
  "schemas/ai-envelope.schema.json": "78167a5767809286071bfcfcdfeceed0ed04c8b9f8c13079019d70eff4f351c1",
29
29
  "schemas/annotation-create.schema.json": "567e8327777968e0ddab8e1547b4acd7beda734cf9d1fc6cde17037d8d9540d4",
30
30
  "schemas/annotation.schema.json": "b26d22f9a97e1ae7c195e8799964aca804e78452a079ca3a0d61ea013907ce91",
31
- "schemas/artifact-type-pack-manifest.schema.json": "13932ea58e819e01b3a0cc7a65590b70683dd5fe9208fc06c64c339943d3c4ce",
31
+ "schemas/artifact-type-pack-manifest.schema.json": "67895666b7201bb82e846f3e1fb86ddd07aa3a6a89ece9f98462a7bc8f35ee96",
32
32
  "schemas/audit-verify-result.schema.json": "e8fd013fb610ba5db27ab0113ec2eb18e7da5f9310dd3de2dd3dc87a259c21fe",
33
33
  "schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
34
34
  "schemas/capabilities.schema.json": "b413e0235cad412081df09234237e0f322a85903b7411b91aaff54032e946671",
@@ -112,10 +112,10 @@
112
112
  "schemas/v2/ai-envelope.schema.json": "0dc7dfbf032a37239ffd81fc2d67f999a40782ff5074866a56e414164cdee809",
113
113
  "schemas/v2/annotation-create.schema.json": "ec32c22a243b343eeb6956a23be2d76ba5b50c8018ec3ec9d6efa5a1d9b0c792",
114
114
  "schemas/v2/annotation.schema.json": "eb4813cdaa8e325412cfcaeeee5c8a28f3751c946dec64e546c7ba0ad3aad8d4",
115
- "schemas/v2/artifact-type-pack-manifest.schema.json": "f77d8dcc3112afec8b2a8e67e56752d5811deb2c2455b0d6d4c6489eb4c4a829",
115
+ "schemas/v2/artifact-type-pack-manifest.schema.json": "abb60653f2f29925fb430fa04fa03fa67e14af47c1f8c5c07db324576fc17fa9",
116
116
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
- "schemas/v2/capabilities.schema.json": "e62d44f23c21c8f64b273fcdb083636e801c0de976cbd1f93333f99928dd1025",
118
+ "schemas/v2/capabilities.schema.json": "304cdcebb291b633abaac9faf91bfb62377125e7d53434cc78f1789d4315e52f",
119
119
  "schemas/v2/certification-bundle.schema.json": "46a5a07670f2457bf1b7af8f6df1f4f76edb3ff7e64d1b6ed85738270c95668e",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -201,7 +201,7 @@
201
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
202
202
  "spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
203
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
204
- "spec/v1/core-standard-manifest.json": "0c1aaddf25eefbbe473d543067838a4a8eed8d3e5c00b55ce0ca98eabbc1ce66",
204
+ "spec/v1/core-standard-manifest.json": "ea55ec7d5a86d574479c545f3cb62ea6689ae91e4411c4ba6b27c22ca4d1f980",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -214,29 +214,29 @@
214
214
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
215
215
  "spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
216
216
  "spec/v2/README.md": "8dbfc17b10f373dba95bdd2e8fec0580f35b380a834d304be3e24e7498170185",
217
- "spec/v2/core/capabilities.md": "4c4e5577caf4ae20c84fe59d580de4c6599e76783471d3b59f9730fc3807de71",
218
- "spec/v2/core/conformance.md": "e5cb403f6ea6633eebc8ee4fa7bde5bf023bf4aee7c959b849e7105d37ab1f8b",
217
+ "spec/v2/core/capabilities.md": "8ffa639da1504d57df235e8685095d78fe9591ee3ef7e99fea359e9df99a54b4",
218
+ "spec/v2/core/conformance.md": "f224d49c5376ad39ea8a972cf623d5212a5fe140fd46ee83f234f92f6548ee0e",
219
219
  "spec/v2/core/connection-packs.md": "466bdb9dde79d85615ad8281dacfb7bf923ca22d7fb3ed219749e59574c753ed",
220
220
  "spec/v2/core/conversation.md": "e425887c4ba199b8cdc5c99e792689f7bd7d46de492f28670de2e402fa6d9506",
221
221
  "spec/v2/core/errors.md": "0dff4a0ae102a86f6a17a9a3b16a203292588a0f422ed19d1c0d9213995aa01d",
222
- "spec/v2/core/events.md": "c98742f5a310eee8287ca8135493502152e88b479200a6544bb3340fed1e4efe",
222
+ "spec/v2/core/events.md": "e90f860b48b87b5fbd3824029009c3044a7446715484c55d68cc4ec005a03a25",
223
223
  "spec/v2/core/form-content-packs.md": "af3517dd7125106088249c0e32220b0b9fc3e9304c10378a6327cc8ad84b6bf1",
224
224
  "spec/v2/core/headers.md": "14cdf4ae9ee7d80eebfd426d7c3552e577baea0300915cd4bb3a68e8588195a8",
225
225
  "spec/v2/core/host-services.md": "3753f1e034d325bb4bd5751ae6d321fbb16700d4e28032ab4e273d25eb134fc4",
226
226
  "spec/v2/core/idempotency.md": "e4edae1834101fd331379d84de77eaa9cead49383529c31f8bcd18322f67c965",
227
227
  "spec/v2/core/identity.md": "e44f34f7c2db1f12babb19ca8ee1e5b1401a89f38fd5c90d0c4b74c95f6e5d42",
228
228
  "spec/v2/core/interop.md": "6bd581556b9ab1a1509c01b9a1ec2bcb9e4a8300e68fb01570704417e70f20b9",
229
- "spec/v2/core/interrupt.md": "3a926d84b942d4ee7daccc4443c93dc29f6a69f2936ba813182f2c1336db2cbb",
229
+ "spec/v2/core/interrupt.md": "18bcdfee975b3d9c336bc9b1845176f5c158274f7b66bb1870c2d7b49d9cfcdc",
230
230
  "spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
231
231
  "spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
232
232
  "spec/v2/core/persistence.md": "9785e536362ec2000dab7fee9847543240d40a907745efc70cf2c8b4c1b8344e",
233
- "spec/v2/core/replay.md": "b980a66e2543107240e3dc4f58131b9f4ff52478737e79ade6d5777b26ad1119",
234
- "spec/v2/core/runs.md": "136319842bacbbb300178980e4b83444917d5b193cc5fdd92e39a4902ba8d3c0",
233
+ "spec/v2/core/replay.md": "02d2757bd263a3e7e06dd6af0da9d58dff37202941f84b6fc097c083a8186146",
234
+ "spec/v2/core/runs.md": "05fba9b83bbfcca4bc531f9623bff7bf79d5f587170ed5e8b26cd536a950b2eb",
235
235
  "spec/v2/core/security-defaults.md": "2a54ceaa4cef02c26ee0fbcf44f822ceeea79537c7d5a51ed300f21aa4fea576",
236
236
  "spec/v2/core/versioning.md": "56c1e273c790bcb0a2c3c760ffc7595afc9f5d4a745965e57f36aefcd456da76",
237
- "spec/v2/core/webhooks.md": "46c1311c1897236f9bf9db5025c8af31dd84cd19208da3e5e9ff69eec9436965",
237
+ "spec/v2/core/webhooks.md": "dca6ec657a7489d6469c2acb20f656e0bf9542dfc9144520f5441d83575c8025",
238
238
  "spec/v2/core/workflow-chain-packs.md": "df78b76dd79f2267fb6b72c08e0911e5f9d5bb1925c6efdd6deda62fe8a51dc8",
239
- "spec/v2/declaration.json": "0f35205af96f29cacced435d1b606e9e8292c74a5509444bec78f17d315d63bf",
239
+ "spec/v2/declaration.json": "3a5960e97782d64ace047e5a1edd4ae5a7b956bdc94ff7bcfd9f4c270740340c",
240
240
  "spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
241
241
  "spec/v2/errors.json": "296117235d21ce4a393371c2f80caf03a4ae93d1f80a89f87cd3f56902cf5b01",
242
242
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -265,7 +265,7 @@
265
265
  "spec/v2/facets/auth.schema.json": "73440c944ad12c41aab0618aeb799a90a0b59e97fc685527f248afb7d015381a",
266
266
  "spec/v2/facets/envelopeStrictness.schema.json": "28be0a27f880bd45ec1222a16155f5e9167bbca57bf0322970fe1eb2654562aa",
267
267
  "spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
268
- "spec/v2/facets/interrupt.schema.json": "c76c58c0abd99de619f8c8afad246568f5bc9cce3b7a0d097d16d87eba84be3a",
268
+ "spec/v2/facets/interrupt.schema.json": "3cc228b9bee48922b22bd6b0bee1a3244dc83d377a616cfb523435368da11ebb",
269
269
  "spec/v2/facets/mcp.schema.json": "f56609fe24ba6160ad29ae31aa16043354ff005e5cb6a0f3871c4d226087cc08",
270
270
  "spec/v2/facets/packs.schema.json": "d544a280419c0052ce0f3c3b378f7aa04085cadc31df52a3c8f43da600b131b1",
271
271
  "spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
@@ -279,8 +279,8 @@
279
279
  "spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
280
280
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
281
281
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
282
- "spec/v2/release.json": "eaf652e3a01f02a1d9c4eee7d7ea2c2a0396fe5694b5dce4cb01ca2af2891cc5",
282
+ "spec/v2/release.json": "5b9a956506b011835f1bd2fec54064d9cc8657d8a45a371314850da377d69069",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "ffc0f6bf6bfe378c1e69f94386373687b15cfdb6"
285
+ "corpusCommit": "a7ff2254a6ed9cbd47e327a95eb280e4aaf23699"
286
286
  }
package/src/cli.ts CHANGED
@@ -724,7 +724,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
724
724
  discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
725
725
  claimedProfiles: claimed3,
726
726
  results: { totals: totals3, requirements: rows3 },
727
- witnessSha256: witnessDigest(rows3),
727
+ witnessSha256: witnessDigest(rows3, relaxations),
728
728
  assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
729
729
  ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
730
730
  // RFC 0158 §D: claimed ONLY when these rows support it. The verifier
@@ -959,6 +959,8 @@ async function main(): Promise<never> {
959
959
  `certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
960
960
  // RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
961
961
  `rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
962
+ // 2.35.0: derived from the signed `skipped` rows; checked against the signed discovery document.
963
+ `opted out: ${verdict.optedOut.length > 0 ? verdict.optedOut.join(', ') : '(none)'}`,
962
964
  '',
963
965
  'What this command does NOT do:',
964
966
  ' · It does not re-run anything. A host that measured itself wrongly, and signed',
@@ -87,12 +87,26 @@ export function canonicalJSON(value: unknown): string {
87
87
  return JSON.stringify(value);
88
88
  }
89
89
 
90
- /** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
91
- export function witnessDigest(rows: readonly BundleV3Requirement[]): string {
90
+ /**
91
+ * RFC 0148 §C — the digest over the reporter record (the requirement rows),
92
+ * and, from 2.35.0, the operator's declared relaxations when there are any.
93
+ *
94
+ * Why relaxations are in here: the attestation covers exactly
95
+ * `{ witnessSha256, host.build, suite.version, discovery.sha256 }`, so a
96
+ * `host.relaxations[]` beside the rows was UNSIGNED — strip it after signing
97
+ * and the verifier re-derives the relaxed profile as certified, on a bundle
98
+ * that still verifies. Included ONLY WHEN NON-EMPTY: the preimage is the rows
99
+ * array exactly as before for every bundle that declares nothing (all
100
+ * committed bundles digest unchanged, pinned in durability-evidence.test.ts),
101
+ * and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
102
+ * 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
103
+ */
104
+ export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[]): string {
92
105
  const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
93
106
  // ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
94
107
  ...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
95
- return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
108
+ const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
109
+ return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
96
110
  }
97
111
 
98
112
  /** The bytes the attestation covers. */
@@ -138,6 +152,22 @@ export interface V3Verdict {
138
152
  * checked. Read it before quoting a verdict.
139
153
  */
140
154
  readonly derivabilityChecked: boolean;
155
+ /**
156
+ * 2.35.0 — the profiles and families the operator opted out of, derived from
157
+ * the SIGNED `skipped` rows `behaviorGate` writes (`openwop.profile.<name>`,
158
+ * where a family is `family.<key>`). Derived rather than read from a root
159
+ * member because a root member would sit outside the attestation.
160
+ */
161
+ readonly optedOut: string[];
162
+ }
163
+
164
+ const OPT_OUT_ROW = 'openwop.profile.';
165
+ /** The opt-outs a bundle's signed rows record. */
166
+ export function optedOutFromRows(rows: ReadonlyArray<{ readonly id: string; readonly result: string; readonly detail?: string }>): string[] {
167
+ return rows
168
+ .filter((r) => r.id.startsWith(OPT_OUT_ROW) && r.result === 'skipped' && (r.detail ?? '').includes('OPENWOP_OPTED_OUT_PROFILES'))
169
+ .map((r) => r.id.slice(OPT_OUT_ROW.length))
170
+ .sort();
141
171
  }
142
172
 
143
173
  export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3Verdict {
@@ -154,8 +184,8 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
154
184
  const count = (d: BundleV3Result): number => rows.filter((r) => r.result === d).length;
155
185
  const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
156
186
  for (const k of Object.keys(expected) as (keyof typeof expected)[]) if (bundle.results?.totals?.[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
157
- const digest = witnessDigest(rows);
158
- if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
187
+ const digest = witnessDigest(rows, bundle.host?.relaxations);
188
+ if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows and declared relaxations (${digest.slice(0, 12)})` });
159
189
  const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
160
190
  if (bundle.assertionCount !== assertions) rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
161
191
  const nonPass = rows.filter((r) => r.result !== 'executed-pass');
@@ -243,6 +273,20 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
243
273
  derivabilityChecked = false;
244
274
  } else {
245
275
  derivabilityChecked = true;
276
+ // RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
277
+ // profile." The suite refuses that at run time (behaviorGate throws),
278
+ // so a bundle that records both was edited after the run or emitted by
279
+ // something else. Both inputs are signed — the rows through
280
+ // witnessSha256, the document through discovery.sha256 — so the
281
+ // contradiction is checkable offline, and it is bundle-wide: an opt-out
282
+ // list that is false makes every `skipped` row in the bundle suspect.
283
+ const root = document as unknown as Record<string, unknown>;
284
+ for (const name of optedOutFromRows(rows)) {
285
+ const advertised = name.startsWith('family.')
286
+ ? (() => { const v = root[name.slice('family.'.length)]; return typeof v === 'object' && v !== null && !Array.isArray(v); })()
287
+ : profileDerivable(document as DiscoveryPayload, name, targetMajor);
288
+ if (advertised) rejections.push({ kind: 'opted-out-but-advertised', detail: `${name} is recorded as an operator opt-out, but the captured discovery document advertises it — a host MUST NOT both advertise and opt out of the same profile (RFC 0148 §B)` });
289
+ }
246
290
  for (const p of bundle.claimedProfiles ?? []) {
247
291
  // §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
248
292
  // names without certifying makes no claim for derivability to falsify.
@@ -268,5 +312,6 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
268
312
  verifierSignatureVerified,
269
313
  certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
270
314
  derivabilityChecked,
315
+ optedOut: optedOutFromRows(rows),
271
316
  };
272
317
  }
@@ -29,7 +29,7 @@
29
29
  * Readers are injected so the ordering itself is testable without a host.
30
30
  */
31
31
  export interface Observation { readonly readable: boolean; readonly runStarted: number; readonly nodeStarted: number; readonly restored: number }
32
- export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number }
32
+ export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number; readonly transportErrors: number }
33
33
  export interface WatchIo {
34
34
  readStatus(): Promise<string | null>;
35
35
  readLog(): Promise<Observation>;
@@ -37,17 +37,41 @@ export interface WatchIo {
37
37
  sleep(ms: number): Promise<void>;
38
38
  }
39
39
 
40
+ const UNREADABLE: Observation = { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
41
+
42
+ /**
43
+ * A TRANSPORT failure is an unreadable observation, never a verdict (2.34.2).
44
+ *
45
+ * The kill rows are the one place the suite causes the host to disappear, and a
46
+ * seam may kill at a point AFTER it has answered - `during-execution` dies at
47
+ * the first `node.started`, which can land seconds later. `waitBack()` already
48
+ * treated a refused connection as "down, keep waiting"; these two reads did
49
+ * not, so a process that died mid-watch threw `fetch failed` out of the
50
+ * scenario and failed the row - for recovering. Measured on a tier-1 host at
51
+ * its production image, behind a port proxy that accepts and then closes: four
52
+ * of five RFC 0158 rows `executed-fail` on `UND_ERR_SOCKET: other side closed`,
53
+ * twice. A status that cannot be read is `null`; a log that cannot be read is
54
+ * `readable: false` - both already mean "conclude nothing, poll again", and the
55
+ * budget still bounds the wait, so a host that never comes back still fails.
56
+ */
57
+ async function orNull<T>(read: () => Promise<T>, fallback: T): Promise<{ value: T; failed: boolean }> {
58
+ try { return { value: await read(), failed: false }; } catch { return { value: fallback, failed: true }; }
59
+ }
60
+
40
61
  export async function watchForResumption(io: WatchIo, budgetMs: number, resumed: (o: Observation) => boolean, pollMs = 500): Promise<Watch> {
41
62
  const t0 = io.now();
42
63
  let completedUnresumed = false;
64
+ let transportErrors = 0;
43
65
  for (;;) {
44
- const status = await io.readStatus(); // FIRST
45
- const last = await io.readLog(); // SECOND — never older than the status it is judged against
66
+ const st = await orNull(() => io.readStatus(), null); // FIRST
67
+ const lg = await orNull(() => io.readLog(), UNREADABLE); // SECOND — never older than the status it is judged against
68
+ if (st.failed || lg.failed) transportErrors++;
69
+ const status = st.value; const last = lg.value;
46
70
  const waitedMs = io.now() - t0;
47
71
  const isResumed = last.readable && resumed(last);
48
72
  if (status === 'completed' && last.readable && !isResumed) completedUnresumed = true;
49
- if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
50
- if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
73
+ if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs, transportErrors };
74
+ if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs, transportErrors };
51
75
  await io.sleep(pollMs);
52
76
  }
53
77
  }
@@ -104,12 +104,26 @@ export function resolveItRecord(
104
104
  gate: { disposition: 'inapplicable' | 'skipped'; detail?: string } | undefined,
105
105
  noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string; conclusive?: true } | null,
106
106
  firstError?: string,
107
+ /**
108
+ * 2.35.0, major 2 (`conformance.md` §Bundle v3): a requirement the test did
109
+ * not observe records `blocked` even when the test asserted setup facts
110
+ * first. At major 1 a `blocked` note after assertions stays a
111
+ * `partial-witness` pass unless the leg used `blockedDespiteAssertions`.
112
+ *
113
+ * Why the rule changed: a `partial-witness` row is refused by the acceptance
114
+ * predicate but COUNTED by certification, so a profile could certify on a
115
+ * requirement nobody observed. Swept before the change: all 24 major-2
116
+ * sites were "requirement unobserved" (unreachable, control silent, window
117
+ * closed), none an optional extra. Scoped to major 2 because the 146 v1-side
118
+ * sites were not measured and v1 bundles are read through its EOS.
119
+ */
120
+ blockedStands = false,
107
121
  ): { disposition: Disposition; detail?: string } {
108
122
  if (state === 'fail') return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
109
123
  if (state === 'pass' && assertionCalls > 0) {
110
124
  // `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
111
125
  // assertions are not the requirement, and the requirement went unobserved.
112
- if (noted !== null && noted.kind === 'blocked' && noted.conclusive === true) return { disposition: 'blocked', detail: noted.reason };
126
+ if (noted !== null && noted.kind === 'blocked' && (noted.conclusive === true || blockedStands)) return { disposition: 'blocked', detail: noted.reason };
113
127
  // A leg that asserted AND THEN soft-skipped is only a partial witness, and
114
128
  // the file-level record has always said so (`resolveFileRecord` below).
115
129
  // This `it`-level record dropped the note — and the `it`-level rows are the
@@ -148,12 +162,12 @@ export function fileDisposition(
148
162
  return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
149
163
  }
150
164
  if (assertionCount === 0 && gateReason === 'skipped') {
151
- return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
165
+ return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
152
166
  }
153
167
  return { disposition: 'executed-pass' };
154
168
  }
155
169
  if (gateReason === 'inapplicable') return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
156
- if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
170
+ if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
157
171
  return {
158
172
  disposition: 'blocked',
159
173
  detail:
@@ -0,0 +1,30 @@
1
+ /**
2
+ * RFC 0194 §A — the shape a run's log must have around its terminal event.
3
+ *
4
+ * Exactly one terminal run event, and after it no forward execution: no second
5
+ * terminal event, no run.started / run.resumed / run.resume-started /
6
+ * run.paused / run.restored-from-snapshot, no node.* and no interrupt.*.
7
+ * `compensation.*` and `run.dead-lettered` MAY follow (a compensating host
8
+ * unwinds after a cancelled parent — compensation.md §"Cancellation of the
9
+ * parent"); vendor-prefixed types are not constrained.
10
+ *
11
+ * Pure, so a scenario can add a leg under RFC 0194's ids cheaply, and so the
12
+ * rule is pinned without a host.
13
+ */
14
+ export const TERMINAL_RUN_EVENTS: ReadonlySet<string> = new Set(['run.completed', 'run.failed', 'run.cancelled']);
15
+ const FORWARD_RUN_EVENTS: ReadonlySet<string> = new Set(['run.started', 'run.resumed', 'run.resume-started', 'run.paused', 'run.restored-from-snapshot']);
16
+
17
+ /** The first violation of §A.1–§A.2 in `types` (log order), or null when the shape holds. */
18
+ export function terminalShapeViolation(types: readonly string[]): string | null {
19
+ const terminals = types.map((t, i) => [t, i] as const).filter(([t]) => TERMINAL_RUN_EVENTS.has(t));
20
+ if (terminals.length === 0) return 'the log carries no terminal run event (run.completed / run.failed / run.cancelled)';
21
+ if (terminals.length > 1) return `the log carries ${terminals.length} terminal run events (${terminals.map(([t, i]) => `${t}@${i}`).join(', ')}) — exactly one is allowed`;
22
+ const at = terminals[0]![1];
23
+ for (let i = at + 1; i < types.length; i++) {
24
+ const t = types[i]!;
25
+ if (FORWARD_RUN_EVENTS.has(t) || t.startsWith('node.') || t.startsWith('interrupt.')) {
26
+ return `${t} at index ${i} follows the terminal event ${types[at]} at index ${at} — only compensation.* and run.dead-lettered may`;
27
+ }
28
+ }
29
+ return null;
30
+ }
@@ -27,17 +27,45 @@
27
27
  * @see RFCS/0187-host-found-bindings.md §A
28
28
  */
29
29
  import { describe, it, expect } from 'vitest';
30
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
30
31
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
31
32
  import { v2Discovery, gateFamily } from '../lib/v2.js';
32
33
  import { readErrorCode } from '../lib/error-envelope.js';
33
34
  import { softSkip } from '../lib/soft-skip.js';
34
- import { resolveRegistrationUrl } from '../lib/webhook-receiver.js';
35
+ import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
36
+ import { scaledTimeoutMs } from '../lib/polling.js';
35
37
  import { req } from '../lib/requirement-ids.js';
36
38
  import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
37
39
 
38
40
  const DOC = 'spec/v2/core/identity.md §5 (RFC 0187 §A)';
39
41
  const WEBHOOK_ID = 'openwop.requirement.0187.bound-id-kinds.webhook';
40
42
  const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
43
+ /**
44
+ * 2.35.0 (openwop#1450). The mint leg above reads the id a host RETURNS; a
45
+ * subscriber identifies its deliveries by the id the host EMITS in the
46
+ * delivery headers. A tier-2 host bound the first and left the second bare,
47
+ * and this file stayed green — the defect surfaced only as a durability
48
+ * failure in another file, whose delivery filter keys on the header.
49
+ */
50
+ const WEBHOOK_EMITTED = 'openwop.requirement.0187.bound-id-kinds.webhook-emitted';
51
+
52
+ type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
53
+ /** The suite's receiver, bound the way every webhook scenario binds it (pinned port / public front honoured). */
54
+ async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
55
+ const deliveries: Delivery[] = [];
56
+ const server = createServer((request: IncomingMessage, res: ServerResponse) => {
57
+ const chunks: Buffer[] = [];
58
+ request.on('data', (c: Buffer) => chunks.push(c));
59
+ request.on('end', () => { deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers }); res.writeHead(204); res.end(); });
60
+ });
61
+ const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
62
+ const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
63
+ const binding = receiverBinding();
64
+ await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
65
+ const addr = server.address();
66
+ return { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/hook`, deliveries };
67
+ }
68
+ const header = (d: Delivery, name: string): string | undefined => { const v = d.headers[name]; return Array.isArray(v) ? v[0] : v; };
41
69
 
42
70
  async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
43
71
  try { return await fn(); } catch { return null; }
@@ -74,6 +102,11 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
74
102
  expect(reg.status, req(WEBHOOK_ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
75
103
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
76
104
  expectBound(webhookId, 'webhookId', 'POST /webhooks 201', WEBHOOK_ID);
105
+ // Never leave the subscription behind, whatever happens below (a leaked
106
+ // subscription is delivered to for the rest of the run and skews every
107
+ // later webhook file's attribution).
108
+ let deleted = false;
109
+ try {
77
110
  if (typeof webhookId !== 'string' || !BOUND_ID.test(webhookId)) return softSkip('blocked', `the mint answered ${JSON.stringify(webhookId)}, which is not a tenant-bound id — the projection and 403 legs have no bound id to drive (the grammar failure is recorded by the assertion above)`);
78
111
 
79
112
  // The 403 check §5 exists for: a bound id whose tenant is not the caller's.
@@ -86,10 +119,61 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
86
119
 
87
120
  // The accept side: the RFC 0184 projection on a tenant-bound parameter that is not runId.
88
121
  const del = await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
122
+ deleted = del?.status === 204;
89
123
  expect(
90
124
  del?.status ?? null,
91
125
  req(WEBHOOK_ID, DOC, `DELETE /webhooks/{webhookId} MUST accept the ~-projected spelling of the id it minted (RFC 0184 §A: every tenant-bound path parameter) — got ${del?.status ?? 'no response'} ${readErrorCode(del?.json) ?? ''}`.trim()),
92
126
  ).toBe(204);
127
+ } finally {
128
+ if (!deleted && typeof webhookId === 'string') await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
129
+ }
130
+ });
131
+
132
+ it('subscriptionId: the webhookId a host EMITS on a delivery is the one it minted', async () => {
133
+ const doc = await v2Discovery();
134
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
135
+ if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — nothing is delivered (gate recorded under openwop.family.webhooks)');
136
+ const rx = await startReceiver();
137
+ let webhookId: string | null = null;
138
+ try {
139
+ const registration = resolveRegistrationUrl(rx.url);
140
+ const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
141
+ if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
142
+ if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
143
+ return softSkip('blocked', `host SSRF guard rejected the suite receiver ${registration.url} (webhooks.md §SSRF requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
144
+ }
145
+ const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
146
+ if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
147
+ webhookId = minted;
148
+ const create = await http(() => driver.post('/runs', { workflowId: 'conformance-noop' }));
149
+ const runId = (create?.json as { runId?: unknown } | null)?.runId;
150
+ if (create === null || create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${create?.status ?? 'no response'} — no run, so no delivery to read`);
151
+ // Select the delivery by the RUN in the body, never by the header under
152
+ // test: filtering on the header is exactly how a bare header hid in the
153
+ // durability scenario (openwop#1450).
154
+ const ours = (): Delivery[] => rx.deliveries.filter((d) => { try { return (JSON.parse(d.body) as { runId?: unknown }).runId === runId; } catch { return false; } });
155
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
156
+ while (ours().length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
157
+ const delivery = ours()[0];
158
+ if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed`);
159
+ expect(
160
+ header(delivery, 'openwop-webhook-id'),
161
+ req(WEBHOOK_EMITTED, 'webhooks.md §Headers (RFC 0187 §A.1)', `the delivery's OpenWOP-Webhook-Id MUST equal the tenant-bound webhookId the host minted (${minted}) — a subscriber identifies its deliveries by the header, not by the 201 it received once`),
162
+ ).toBe(minted);
163
+ // The X-openwop-* family is required only of a host advertising BOTH
164
+ // majors (webhooks.md: dual emission through the overlap). A v2-only host
165
+ // sends none, and failing it for that would be the suite's error.
166
+ const both = Array.isArray(doc.protocolVersions) && doc.protocolVersions.some((v) => typeof v === 'string' && v.startsWith('1.'));
167
+ if (both) {
168
+ expect(
169
+ header(delivery, 'x-openwop-webhook-id'),
170
+ req(WEBHOOK_EMITTED, 'webhooks.md §Headers (dual emission, RFC 0176 §D.2)', `a host advertising both majors MUST send X-openwop-Webhook-Id with the same value as OpenWOP-Webhook-Id (${minted})`),
171
+ ).toBe(minted);
172
+ }
173
+ } finally {
174
+ if (webhookId !== null) await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId as string)}`));
175
+ await new Promise<void>((ok) => rx.server.close(() => ok()));
176
+ }
93
177
  });
94
178
 
95
179
  it('interruptId: the id a suspension puts on the wire is bound', async () => {
@@ -140,11 +224,17 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
140
224
  if (reg === null || reg.status !== 201) return softSkip('blocked', `POST /webhooks answered ${reg?.status ?? 'no response'} — no subscription to read a sink for`);
141
225
  const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
142
226
  if (typeof webhookId !== 'string') return softSkip('blocked', 'the mint returned no webhookId');
227
+ // This leg only READS the sink; it must not leave its subscription behind
228
+ // (measured: it left one on every run until 2.35.0).
229
+ try {
143
230
  const res = await http(() => driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`));
144
231
  if (res === null) return softSkip('blocked', 'GET /webhooks/{webhookId}/dead-letters unreachable (fetch failed)');
145
232
  expect(res.status, req(PER_KIND, 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)')).toBe(200);
146
233
  const rows = ((res.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
147
234
  if (rows.length === 0) return softSkip('inapplicable', 'the subscription has no dead-lettered delivery in this run — the read is served and the shape is unwitnessed here; v2-webhook-durable-delivery drives an exhaustion and asserts the record');
148
235
  for (const r of rows) expectBound(r['deliveryId'], 'deliveryId', 'GET /webhooks/{webhookId}/dead-letters', PER_KIND);
236
+ } finally {
237
+ await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
238
+ }
149
239
  });
150
240
  });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * RFC 0196 §A.3 — a host that advertises `interrupt.callbackDelivery` holds
3
+ * `createRun.callbackUrl` to the `webhooks.md` §SSRF guard at create time
4
+ * (`spec/v2/core/interrupt.md` §Callback delivery). Target major 2.
5
+ *
6
+ * `callbackUrl` is an outbound request to a URL the CALLER chooses — the
7
+ * surface the SSRF guard exists for. Measured on three hosts before the RFC:
8
+ * one ignored it, one refused it, one delivered to it with no guard at all.
9
+ *
10
+ * Gated on the facet: a host that does not advertise delivery has no egress
11
+ * here to guard (it SHOULD refuse the member, which this file does not assert).
12
+ * No committed host advertises the facet today, so this row is witnessed on
13
+ * none; it is written so the first host that does is held to it.
14
+ *
15
+ * @see spec/v2/core/interrupt.md §Callback delivery
16
+ * @see RFCS/0196-callbackurl-and-embedded-ipv4-egress.md §A.3
17
+ */
18
+ import { describe, expect, it } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
21
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
22
+ import { readErrorCode } from '../lib/error-envelope.js';
23
+ import { softSkip } from '../lib/soft-skip.js';
24
+ import { req } from '../lib/requirement-ids.js';
25
+
26
+ const ID = 'openwop.requirement.0196.callback-url-guarded';
27
+ const DOC = 'spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)';
28
+ const FIXTURE = 'conformance-approval';
29
+ const PROBES = [
30
+ { url: 'https://127.0.0.1/openwop-callback-probe', cls: 'loopback (IPv4)' },
31
+ { url: 'https://169.254.169.254/latest/meta-data/', cls: 'cloud metadata' },
32
+ { url: 'https://[::ffff:7f00:1]/openwop-callback-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
33
+ ];
34
+
35
+ describe('v2 callbackUrl egress guard (RFC 0196 §A.3)', () => {
36
+ it('a host advertising callback delivery refuses a callbackUrl its egress guard would refuse', async () => {
37
+ if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
38
+ const fam = await gateFamily('interrupt');
39
+ if (!fam) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
40
+ if (fam['callbackDelivery'] !== true) return softSkip('inapplicable', 'the host does not advertise interrupt.callbackDelivery — it delivers nothing to callbackUrl, so there is no egress to guard (RFC 0196 §A.2: it SHOULD refuse the member)');
41
+ const workflowId = isFixtureAdvertised(FIXTURE) ? FIXTURE : 'conformance-noop';
42
+ const accepted: string[] = [];
43
+ const wrong: string[] = [];
44
+ for (const p of PROBES) {
45
+ const r = await driver.post('/runs', { workflowId, callbackUrl: p.url });
46
+ if (r.status === 201) { accepted.push(`${p.cls} (${p.url})`); const id = (r.json as { runId?: unknown } | null)?.runId; if (typeof id === 'string') await driver.post(`/runs/${encodeURIComponent(id)}/cancel`, {}).catch(() => undefined); continue; }
47
+ const field = (r.json as { details?: { field?: unknown } } | null)?.details?.field;
48
+ if (r.status !== 400 || readErrorCode(r.json) !== 'validation_error' || field !== 'callbackUrl') wrong.push(`${p.cls}: ${r.status} ${readErrorCode(r.json) ?? ''} field=${String(field)}`);
49
+ }
50
+ expect(accepted, req(ID, DOC, 'a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §SSRF registration guard would refuse')).toEqual([]);
51
+ expect(wrong, req(ID, DOC, 'the refusal MUST be 400 validation_error naming details.field "callbackUrl"')).toEqual([]);
52
+ });
53
+ });
@@ -127,6 +127,12 @@ type SeamState =
127
127
  * would terminate a host that never claimed the rung.
128
128
  */
129
129
  async function probeSeam(): Promise<SeamState> {
130
+ // 2.34.2: the rows before this one KILLED the host. A row whose watch ran out
131
+ // its budget can hand over a host that is still restarting, and a bare GET
132
+ // then threw `fetch failed` and failed a row that never touched the kill -
133
+ // four rows of one measured cut were contaminated by the first. Wait for the
134
+ // service to answer at all (immediately, when it is up), then probe.
135
+ await waitBack(scaledTimeoutMs(RESUME_WINDOW_MS));
130
136
  const r = await driver.get(KILL_SEAM);
131
137
  if (r.status === 404 || r.status === 405) {
132
138
  return { kind: 'absent', why: `no RFC 0158 durability seam at ${KILL_SEAM} (HTTP ${r.status}) — §E: the seam advertises nothing and a host that never runs the durability exercises exposes no such route, so this host claims no durable-execution rung` };
@@ -236,7 +242,10 @@ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared:
236
242
  // interval against a bound that does not govern the staged run.
237
243
  const governing = (fired as { recoveryBoundMs?: unknown } | null)?.recoveryBoundMs;
238
244
  if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true, recoveryClass };
239
- const r = await driver.get('/host/durability/bound');
245
+ // A seam may answer and THEN die (during-execution dies at the first
246
+ // node.started), so this read can meet a closed socket: wait the restart out
247
+ // once and read again rather than fail the row on transport (2.34.2).
248
+ const r = await driver.get('/host/durability/bound').catch(async () => { await waitBack(scaledTimeoutMs(RESUME_WINDOW_MS)); return driver.get('/host/durability/bound'); });
240
249
  const bound = (r.json as { bound?: unknown } | null)?.bound;
241
250
  return r.status === 200 && typeof bound === 'number' && Number.isFinite(bound) && bound > 0
242
251
  ? { ms: bound, declared: true, recoveryClass }
@@ -58,7 +58,7 @@ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean;
58
58
  { id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
59
59
  ],
60
60
  results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
61
- witnessSha256: witnessDigest(ROWS),
61
+ witnessSha256: witnessDigest(ROWS, opts.relaxations),
62
62
  assertionCount: ROWS.reduce((n, r) => n + (r.assertions ?? 0), 0),
63
63
  };
64
64
  return { ...unsigned, signature: signBundleV3(unsigned, hostPem, 'host-key-1') };
@@ -105,6 +105,18 @@ describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', ()
105
105
  honest.rejections.map((r) => r.kind),
106
106
  req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'a relaxation recorded against an unclaimed profile is not a rejection — recording is the obligation, claiming is the defect'),
107
107
  ).not.toContain('relaxed-profile-certified');
108
+
109
+ // 2.35.0: the declaration is SIGNED. `host.relaxations[]` sits outside the
110
+ // attestation, so until the witness digest covered it a relaxation could be
111
+ // deleted after signing and the relaxed profile re-derived as certified on a
112
+ // bundle that still verified. Strip it from the honest bundle and re-verify.
113
+ const signed = bundle({ relaxations: [RELAXED], certified: false });
114
+ const stripped = { ...signed, host: { ...signed.host } };
115
+ delete (stripped.host as { relaxations?: unknown }).relaxations;
116
+ expect(
117
+ verifyBundleV3(stripped).rejections.map((r) => r.kind),
118
+ req('openwop.requirement.0173.relaxation-recorded', 'conformance.md §Bundle v3', 'a relaxation removed after signing MUST be detected — the declaration is covered by witnessSha256, so the stripped bundle fails `witness-digest`'),
119
+ ).toContain('witness-digest');
108
120
  });
109
121
 
110
122
  // Until 2.33.0 every leg above used the FIXTURE profile `openwop-webhooks`, and