@openwop/openwop-conformance 2.0.0-rc.44 → 2.0.0-rc.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
112
112
 
113
113
  ## What's Covered
114
114
 
115
- The current suite has 504 scenario files under `src/scenarios/`.
115
+ The current suite has 505 scenario files under `src/scenarios/`.
116
116
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
117
117
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
118
118
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
457
457
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
458
458
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
459
459
 
460
- Current source tree: 504 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
460
+ Current source tree: 505 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
461
461
 
462
462
  ## Remaining Gaps
463
463
 
package/dist/cli.js CHANGED
@@ -607,15 +607,24 @@ async function runCertify(args, baseUrl, apiKey) {
607
607
  const doc3 = document;
608
608
  const protocolVersions = Array.isArray(doc3['protocolVersions']) ? doc3['protocolVersions'] : [String(doc3['protocolVersion'] ?? '')];
609
609
  const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? doc3['preferredVersion'] : (protocolVersions[0] ?? '');
610
- // witnessCount: executed-pass rows on the profile's floor (PROFILE_FLOOR_SCENARIOS required + requiredAnyPrefix).
610
+ // witnessCount: witnessed executed-pass rows on the profile's floor. At major
611
+ // 2 the verdict already counted them against the declaration's floor
612
+ // (`witnessedPasses`); the v1 hand table below knows no v2 file and printed
613
+ // 0 for every v2 profile until rc.45 — the third unjoined floor site.
614
+ const verdictFor = (profile) => derived.verdicts.find((v) => v.profile === profile);
611
615
  const witnessCountFor = (profile) => {
616
+ if (target.major === 2)
617
+ return verdictFor(profile)?.witnessedPasses ?? 0;
612
618
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
613
619
  if (!floor)
614
620
  return 0;
615
621
  const onFloor = (scenario) => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
616
622
  return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
617
623
  };
618
- const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
624
+ // `certified` IS the verdict (RFC 0148 §A; RFC 0168 §E.1 adds the bundle-wide
625
+ // blocked rule). Until rc.45 it was `!notHeld && !rejected && blocked === 0`
626
+ // and never read `certifiable` — an empty v2 floor certified on no evidence.
627
+ const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
619
628
  let relaxations;
620
629
  if (process.env['OPENWOP_HOST_RELAXATIONS']) {
621
630
  try {
@@ -93,6 +93,16 @@ let v2Floors = null;
93
93
  export function setV2ProfileFloors(floors) {
94
94
  v2Floors = floors;
95
95
  }
96
+ /**
97
+ * True while a major-2 floor map is installed. The v1 hand table
98
+ * (`PROFILE_FLOOR_SCENARIOS`) MUST NOT be consulted for any per-profile fact
99
+ * (`discoveryOnly`, `runtimeDerived`, the floor itself, the witness count) while
100
+ * this is true — every one of those reads is a floor site, and rc.41 found two
101
+ * of four unjoined; rc.45 found the other two.
102
+ */
103
+ export function v2FloorsActive() {
104
+ return v2Floors !== null;
105
+ }
96
106
  export function requirementsFor(profile, document) {
97
107
  if (v2Floors !== null) {
98
108
  const files = v2Floors[profile];
@@ -30,7 +30,7 @@ import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
30
30
  import { targetMajor } from './seams.js';
31
31
  import { PKG_ROOT_PATH } from './paths.js';
32
32
  import { v2ProfileFloorFiles } from './requirement-registry.js';
33
- import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
33
+ import { requirementIdForScenario, requirementIdForPrefix, requirementsFor, v2FloorsActive } from './requirement-registry.js';
34
34
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
35
35
  import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
36
36
  import { CERTIFIABLE } from './requirement-ledger.js';
@@ -220,11 +220,18 @@ document) {
220
220
  rows.push(row);
221
221
  perFile.set(file, row);
222
222
  }
223
- // Prefix requirements: derived from the matching files.
223
+ // Prefix requirements: derived from the matching files. These are the v1 hand
224
+ // table's `requiredAnyPrefix` groups (`interrupt-`); at major 2 the floors are
225
+ // the declaration's and have no prefix groups — and the v1 `interrupt-*` files
226
+ // never run at major 2, so until rc.45 every major-2 bundle carried one
227
+ // `openwop.floor.any.interrupt-` row recorded `blocked` ("no interrupt-*
228
+ // scenario ran"), which by RFC 0168 §E.1 denied certification to every
229
+ // profile on every major-2 bundle. The fifth unjoined floor site.
224
230
  const prefixIds = new Set();
225
- for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS))
226
- for (const p of floor.requiredAnyPrefix ?? [])
227
- prefixIds.add(p);
231
+ if (!v2FloorsActive())
232
+ for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS))
233
+ for (const p of floor.requiredAnyPrefix ?? [])
234
+ prefixIds.add(p);
228
235
  for (const prefix of [...prefixIds].sort()) {
229
236
  const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
230
237
  const id = requirementIdForPrefix(prefix);
@@ -293,12 +300,18 @@ document) {
293
300
  if (ids === null) {
294
301
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
295
302
  const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
296
- verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
303
+ verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false, witnessedPasses: 0 });
297
304
  continue;
298
305
  }
299
306
  const unclassified = [];
300
307
  const blocking = [];
301
308
  let witnessedPasses = 0;
309
+ // At major 2 the floor is the declaration's (`v2ProfileFloorFiles`), and
310
+ // the v1 hand table says nothing about these profiles: not their floor, not
311
+ // `discoveryOnly`, not `runtimeDerived`. Reading it here was the fourth
312
+ // unjoined floor site — `openwop-discovery-core` is `discoveryOnly` in v1
313
+ // terms, so at major 2 it certified whatever its v2 floor file said.
314
+ const atMajor2 = v2FloorsActive();
302
315
  for (const id of ids) {
303
316
  const r = rowById.get(id);
304
317
  const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
@@ -320,25 +333,34 @@ document) {
320
333
  if (r === undefined || silent || vacuous)
321
334
  unclassified.push(id);
322
335
  // Unclassified always blocks: a requirement nobody recorded cannot certify.
323
- if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition))
336
+ // A `skipped` floor row at major 2 is an opt-in the host withheld — the
337
+ // suite was not allowed to look. That is not evidence the requirement
338
+ // holds; it blocks the floor (a v1 floor keeps the CERTIFIABLE reading).
339
+ if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition) || (atMajor2 && r.disposition === 'skipped'))
324
340
  blocking.push(id);
325
341
  }
326
342
  // discoveryOnly floors have ids.length === 0 and certify by design here (the
327
343
  // requirement-ledger's verifyProfileRequirements is stricter; the runner
328
- // consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
329
- const discoveryOnly = PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
330
- const runtimeDerived = PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
344
+ // consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately). Neither flag
345
+ // exists at major 2.
346
+ const discoveryOnly = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
347
+ const runtimeDerived = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
331
348
  // A runtime-derived profile is HELD only when every floor row is a witnessed
332
349
  // pass ("derivable from which scenarios pass" — profiles.md). Anything else
333
350
  // means the host does not hold it: not a rejection, not a blocked claim.
334
351
  const held = ids.length > 0 && witnessedPasses === ids.length;
352
+ // Major 2: a floor certifies only on a witnessed pass. `inapplicable` rows
353
+ // are honest per file (capabilities.md §2 requires the omission) but a floor
354
+ // that is inapplicable end to end has witnessed nothing and certifies nothing.
355
+ const certifiableAt2 = ids.length > 0 && blocking.length === 0 && witnessedPasses >= 1;
335
356
  verdicts.push({
336
357
  profile,
337
358
  unclassified: runtimeDerived && !held ? [] : unclassified,
338
359
  blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
339
- certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
360
+ certifiable: atMajor2 ? certifiableAt2 : runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
340
361
  runtimeDerived,
341
362
  held,
363
+ witnessedPasses,
342
364
  });
343
365
  }
344
366
  return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.0-rc.44",
4
- "stampSha256": "ce4913c7555550d9aa319f3e3caa2a9deeb3c44cacfb040ea5d91c0719d65f9f"
3
+ "version": "2.0.0-rc.45",
4
+ "stampSha256": "3228773ffaa82d2502ca522050d29b33b0cfa3d70b3b2a66ca8dbe6d623e42b0"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.0-rc.44",
3
+ "version": "2.0.0-rc.45",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.0.0-rc.44"
59
+ "@openwop/spec-artifacts": "2.0.0-rc.45"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 551,
5
+ "files": 552,
6
6
  "tests": 2127,
7
7
  "withStableId": 2126,
8
8
  "interpolatedTitles": 1,
@@ -26502,7 +26502,7 @@
26502
26502
  {
26503
26503
  "id": "openwop.it.v2-effect-seam-manifest.get-host-effect-seams-validates-and-every-row-is-guarded",
26504
26504
  "file": "v2-effect-seam-manifest.test.ts",
26505
- "line": 51,
26505
+ "line": 53,
26506
26506
  "title": "GET /host/effect-seams validates and every row is guarded",
26507
26507
  "explicitId": "openwop.requirement.0173.effect-seam-manifest",
26508
26508
  "citations": [
@@ -26549,7 +26549,7 @@
26549
26549
  {
26550
26550
  "id": "openwop.it.v2-effect-seam-manifest.the-replay-facet-names-the-manifest-address-as-the-constant-host-effect-seams",
26551
26551
  "file": "v2-effect-seam-manifest.test.ts",
26552
- "line": 106,
26552
+ "line": 108,
26553
26553
  "title": "the replay facet names the manifest address as the constant /host/effect-seams",
26554
26554
  "explicitId": "openwop.requirement.0173.effect-seam-manifest.facet",
26555
26555
  "citations": [
@@ -26560,11 +26560,11 @@
26560
26560
  ]
26561
26561
  },
26562
26562
  {
26563
- "id": "openwop.it.v2-effect-seam-manifest.driving-one-seam-of-each-kind-observes-no-re-fire-on-replay",
26564
- "file": "v2-effect-seam-manifest.test.ts",
26565
- "line": 117,
26563
+ "id": "openwop.it.v2-effect-seam-no-refire.driving-one-seam-of-each-kind-observes-no-re-fire-on-replay",
26564
+ "file": "v2-effect-seam-no-refire.test.ts",
26565
+ "line": 42,
26566
26566
  "title": "driving one seam of each kind observes no re-fire on replay",
26567
- "explicitId": "openwop.requirement.0173.effect-seam-manifest.no-re-fire",
26567
+ "explicitId": "openwop.requirement.0173.effect-seam-no-refire",
26568
26568
  "citations": [
26569
26569
  {
26570
26570
  "section": "spec/v2/core/replay.md §The effect-seam manifest",
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
3
3
  "counts": {
4
- "files": 504,
4
+ "files": 505,
5
5
  "v1": 445,
6
- "v2": 60
6
+ "v2": 61
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1276,6 +1276,9 @@
1276
1276
  "v2-effect-seam-manifest.test.ts": [
1277
1277
  2
1278
1278
  ],
1279
+ "v2-effect-seam-no-refire.test.ts": [
1280
+ 2
1281
+ ],
1279
1282
  "v2-enum-growth-rule.test.ts": [
1280
1283
  2
1281
1284
  ],
@@ -1,7 +1,7 @@
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.0.0-rc.44",
4
+ "version": "2.0.0-rc.45",
5
5
  "corpusTag": null,
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
@@ -9,9 +9,9 @@
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "8e5c8fdba4f31a27b5b035df292de75278285db89b0c18b282d61095c2a13fbb",
13
- "api/v2/asyncapi.yaml": "69715abf7a6d0bef193043235fc9df02228f0c55072ce10095677108ea249b24",
14
- "api/v2/openapi.yaml": "6e75d0561b8e7f82fb6641b17b1e0eb247b9078d5ed21f8dbce0fea36af402a5",
12
+ "api/seams-v2.yaml": "d1d2b8bb291cb2f4675470d3fe89e36fd861e8bd31e2e227d3a075ba83c214c0",
13
+ "api/v2/asyncapi.yaml": "493f45912c431503c1eb1d4bbe5179820f71206270f786f3284b79be1a5e64a1",
14
+ "api/v2/openapi.yaml": "dbcb3ec60ca4f3c7b7e2d374dcb6e851a7edb5062e3ad8ac25b5f643e193775c",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -199,7 +199,7 @@
199
199
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
200
200
  "spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
201
201
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
202
- "spec/v1/core-standard-manifest.json": "1c7d01a8c22a9d071849726974dcc62b15ef492bb4ce7e12748466340a4a0fa9",
202
+ "spec/v1/core-standard-manifest.json": "c105757fc9d32b779b87639eaa6a83e95c5b6e855ca8fcf12f30ca0b400f3050",
203
203
  "spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
204
204
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
205
205
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -211,7 +211,7 @@
211
211
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
212
212
  "spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
213
213
  "spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
214
- "spec/v2/declaration.json": "52b8c24a2dd463d44086129bf792ad108746f5102778cbbf0ff469fa534eca48",
214
+ "spec/v2/declaration.json": "c15a6a8f08ebd326d1b60398d93d7f7d5abe5c1b8284874fdb13b59f44443210",
215
215
  "spec/v2/declaration.schema.json": "b0881a76228dfbd1b85a2ec7b7259a60197f768344535cdb475db6ff719b0e54",
216
216
  "spec/v2/errors.json": "c2dd3fa0401efa0dc78da3b21e32b58a89ed5114cebdde96d3eb999a702118f0",
217
217
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
@@ -229,8 +229,8 @@
229
229
  "spec/v2/id-field-bindings.json": "965814020c462ad13840b41b26f6830ac472734a1e2e5a67140fad92174c0370",
230
230
  "spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
231
231
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
232
- "spec/v2/profiles.json": "1f0ee40491131cf561ef19a0394c47ba476b4567514fe16befb6cada4a10a5a4",
233
- "spec/v2/release.json": "d21d0f15969459d1017987918c64694f92b0ccff3185a46b1626b435cab48022"
232
+ "spec/v2/profiles.json": "5019ac8209540bfbeee9c3232530d4e599be0ab926d817ff48edcec8419eb209",
233
+ "spec/v2/release.json": "fac42f53fe422b035cff2c0cb0a33165dbb5353fa834592ef89ff233a939798f"
234
234
  },
235
- "corpusCommit": "cc7bb617f49000f7ec9c94eef0fe0d18a2a6901a"
235
+ "corpusCommit": "6b79036187e096bc80a79964e1c290b0ff3c5361"
236
236
  }
package/src/cli.ts CHANGED
@@ -641,14 +641,22 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
641
641
  const doc3 = document as Record<string, unknown>;
642
642
  const protocolVersions = Array.isArray(doc3['protocolVersions']) ? (doc3['protocolVersions'] as string[]) : [String(doc3['protocolVersion'] ?? '')];
643
643
  const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? (doc3['preferredVersion'] as string) : (protocolVersions[0] ?? '');
644
- // witnessCount: executed-pass rows on the profile's floor (PROFILE_FLOOR_SCENARIOS required + requiredAnyPrefix).
644
+ // witnessCount: witnessed executed-pass rows on the profile's floor. At major
645
+ // 2 the verdict already counted them against the declaration's floor
646
+ // (`witnessedPasses`); the v1 hand table below knows no v2 file and printed
647
+ // 0 for every v2 profile until rc.45 — the third unjoined floor site.
648
+ const verdictFor = (profile: string) => derived.verdicts.find((v) => v.profile === profile);
645
649
  const witnessCountFor = (profile: string): number => {
650
+ if (target.major === 2) return verdictFor(profile)?.witnessedPasses ?? 0;
646
651
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
647
652
  if (!floor) return 0;
648
653
  const onFloor = (scenario: string): boolean => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
649
654
  return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
650
655
  };
651
- const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
656
+ // `certified` IS the verdict (RFC 0148 §A; RFC 0168 §E.1 adds the bundle-wide
657
+ // blocked rule). Until rc.45 it was `!notHeld && !rejected && blocked === 0`
658
+ // and never read `certifiable` — an empty v2 floor certified on no evidence.
659
+ const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
652
660
  let relaxations: BundleV3['host']['relaxations'];
653
661
  if (process.env['OPENWOP_HOST_RELAXATIONS']) { try { relaxations = JSON.parse(process.env['OPENWOP_HOST_RELAXATIONS']) as BundleV3['host']['relaxations']; } catch { process.stderr.write('openwop-conformance --certify: OPENWOP_HOST_RELAXATIONS is not JSON\n'); process.exit(2); } }
654
662
  const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
@@ -97,6 +97,17 @@ export function setV2ProfileFloors(floors: Readonly<Record<string, readonly stri
97
97
  v2Floors = floors;
98
98
  }
99
99
 
100
+ /**
101
+ * True while a major-2 floor map is installed. The v1 hand table
102
+ * (`PROFILE_FLOOR_SCENARIOS`) MUST NOT be consulted for any per-profile fact
103
+ * (`discoveryOnly`, `runtimeDerived`, the floor itself, the witness count) while
104
+ * this is true — every one of those reads is a floor site, and rc.41 found two
105
+ * of four unjoined; rc.45 found the other two.
106
+ */
107
+ export function v2FloorsActive(): boolean {
108
+ return v2Floors !== null;
109
+ }
110
+
100
111
  export function requirementsFor(profile: string, document?: Readonly<Record<string, unknown>>): readonly string[] | null {
101
112
  if (v2Floors !== null) {
102
113
  const files = v2Floors[profile];
@@ -31,7 +31,7 @@ import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
31
31
  import { targetMajor } from './seams.js';
32
32
  import { PKG_ROOT_PATH } from './paths.js';
33
33
  import { v2ProfileFloorFiles } from './requirement-registry.js';
34
- import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
34
+ import { requirementIdForScenario, requirementIdForPrefix, requirementsFor, v2FloorsActive } from './requirement-registry.js';
35
35
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
36
36
  import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
37
37
  import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
@@ -213,6 +213,13 @@ export interface DerivedProfileVerdict {
213
213
  readonly runtimeDerived: boolean;
214
214
  /** For runtime-derived profiles: every floor row is a witnessed executed-pass. */
215
215
  readonly held: boolean;
216
+ /**
217
+ * Floor rows that are `executed-pass` with `assertionCount > 0` — the number
218
+ * of things actually witnessed. At major 2 this IS `witnessCount` on the
219
+ * bundle, and a profile with zero of them is not certifiable however its
220
+ * other rows read (RFC 0148 §A: no certification without an execution witness).
221
+ */
222
+ readonly witnessedPasses: number;
216
223
  }
217
224
 
218
225
  export interface Derivation {
@@ -274,9 +281,15 @@ export function deriveRequirementDispositions(
274
281
  perFile.set(file, row);
275
282
  }
276
283
 
277
- // Prefix requirements: derived from the matching files.
284
+ // Prefix requirements: derived from the matching files. These are the v1 hand
285
+ // table's `requiredAnyPrefix` groups (`interrupt-`); at major 2 the floors are
286
+ // the declaration's and have no prefix groups — and the v1 `interrupt-*` files
287
+ // never run at major 2, so until rc.45 every major-2 bundle carried one
288
+ // `openwop.floor.any.interrupt-` row recorded `blocked` ("no interrupt-*
289
+ // scenario ran"), which by RFC 0168 §E.1 denied certification to every
290
+ // profile on every major-2 bundle. The fifth unjoined floor site.
278
291
  const prefixIds = new Set<string>();
279
- for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
292
+ if (!v2FloorsActive()) for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
280
293
  for (const prefix of [...prefixIds].sort()) {
281
294
  const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
282
295
  const id = requirementIdForPrefix(prefix);
@@ -339,12 +352,18 @@ export function deriveRequirementDispositions(
339
352
  if (ids === null) {
340
353
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
341
354
  const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
342
- verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
355
+ verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false, witnessedPasses: 0 });
343
356
  continue;
344
357
  }
345
358
  const unclassified: string[] = [];
346
359
  const blocking: string[] = [];
347
360
  let witnessedPasses = 0;
361
+ // At major 2 the floor is the declaration's (`v2ProfileFloorFiles`), and
362
+ // the v1 hand table says nothing about these profiles: not their floor, not
363
+ // `discoveryOnly`, not `runtimeDerived`. Reading it here was the fourth
364
+ // unjoined floor site — `openwop-discovery-core` is `discoveryOnly` in v1
365
+ // terms, so at major 2 it certified whatever its v2 floor file said.
366
+ const atMajor2 = v2FloorsActive();
348
367
  for (const id of ids) {
349
368
  const r = rowById.get(id);
350
369
  const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
@@ -365,24 +384,33 @@ export function deriveRequirementDispositions(
365
384
  const silent = !fromLedger && (ledgerPresent || r?.disposition === 'blocked');
366
385
  if (r === undefined || silent || vacuous) unclassified.push(id);
367
386
  // Unclassified always blocks: a requirement nobody recorded cannot certify.
368
- if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition)) blocking.push(id);
387
+ // A `skipped` floor row at major 2 is an opt-in the host withheld — the
388
+ // suite was not allowed to look. That is not evidence the requirement
389
+ // holds; it blocks the floor (a v1 floor keeps the CERTIFIABLE reading).
390
+ if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition) || (atMajor2 && r.disposition === 'skipped')) blocking.push(id);
369
391
  }
370
392
  // discoveryOnly floors have ids.length === 0 and certify by design here (the
371
393
  // requirement-ledger's verifyProfileRequirements is stricter; the runner
372
- // consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
373
- const discoveryOnly = PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
374
- const runtimeDerived = PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
394
+ // consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately). Neither flag
395
+ // exists at major 2.
396
+ const discoveryOnly = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
397
+ const runtimeDerived = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
375
398
  // A runtime-derived profile is HELD only when every floor row is a witnessed
376
399
  // pass ("derivable from which scenarios pass" — profiles.md). Anything else
377
400
  // means the host does not hold it: not a rejection, not a blocked claim.
378
401
  const held = ids.length > 0 && witnessedPasses === ids.length;
402
+ // Major 2: a floor certifies only on a witnessed pass. `inapplicable` rows
403
+ // are honest per file (capabilities.md §2 requires the omission) but a floor
404
+ // that is inapplicable end to end has witnessed nothing and certifies nothing.
405
+ const certifiableAt2 = ids.length > 0 && blocking.length === 0 && witnessedPasses >= 1;
379
406
  verdicts.push({
380
407
  profile,
381
408
  unclassified: runtimeDerived && !held ? [] : unclassified,
382
409
  blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
383
- certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
410
+ certifiable: atMajor2 ? certifiableAt2 : runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
384
411
  runtimeDerived,
385
412
  held,
413
+ witnessedPasses,
386
414
  });
387
415
  }
388
416
  return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
@@ -12,11 +12,14 @@
12
12
  * 1. the manifest validates; every row is guarded; at least one row exists
13
13
  * for every `kind` the host declares (the manifest is the host's own
14
14
  * enumeration — RFC 0140 G7 answered by making the host list it);
15
- * 2. the facet constant equals the served address;
16
- * 3. driving one seam of each kind through the suite's receiver and
17
- * observing no re-fire needs a seam that fires a named manifest row inside
18
- * a run none is catalogued (`host-sample-test-seams.md`,
19
- * `api/seams-v2.yaml`), so that leg records `blocked` naming it.
15
+ * 2. the facet constant equals the served address.
16
+ *
17
+ * Both legs are unaided (a `GET` and a schema), which is what lets this file sit
18
+ * on the `openwop-core-standard` floor as the `replay` family's witness. The
19
+ * seam-driven no-re-fire leg lived here until rc.45 and moved to
20
+ * `v2-effect-seam-no-refire` (seams-v2 floor): a file's disposition is
21
+ * worst-first, so one seam-gated leg made the whole manifest witness read
22
+ * `blocked` on any host without the seams surface.
20
23
  *
21
24
  * Completeness of the manifest (a seam outside it) is negative-existence and is
22
25
  * not asserted here (RFC 0173 falsifiability table, §C.1 row).
@@ -26,9 +29,8 @@
26
29
  */
27
30
 
28
31
  import { describe, it, expect } from 'vitest';
29
- import { driver, type OpenWOPResponse } from '../lib/driver.js';
32
+ import { driver } from '../lib/driver.js';
30
33
  import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
31
- import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
32
34
  import { softSkip } from '../lib/soft-skip.js';
33
35
  import { req } from '../lib/requirement-ids.js';
34
36
 
@@ -114,50 +116,4 @@ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
114
116
  ).toBe(MANIFEST_PATH);
115
117
  });
116
118
 
117
- it('driving one seam of each kind observes no re-fire on replay', async () => {
118
- const doc = await discovery();
119
- if (!doc) return softSkip('blocked', 'discovery unreachable');
120
- const replay = await gateFamily('replay');
121
- if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
122
- if (!seamsProfileAdvertised(doc)) {
123
- return softSkip('blocked', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
124
- }
125
- // The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
126
- // the host's own Layer-2 ledger, not an externally reachable receiver: fire a
127
- // guarded row that states `branchReFires: false`, fork the run in `replay`
128
- // mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
129
- // fork issues no further attempt for that seam, so its effect ledger cannot
130
- // grow past the parent's.
131
- const manifest = await driver.get(MANIFEST_PATH);
132
- const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
133
- ? ((manifest.json as Manifest).seams ?? []) : [];
134
- const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
135
- if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
136
- const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
137
- if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
138
- return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
139
- }
140
- const firedBody = fired.json as { runId?: unknown } | null;
141
- if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
142
- return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
143
- }
144
- const parentId = firedBody.runId;
145
- const parentEffects = await driver.get(`/runs/${parentId}/effects`);
146
- if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
147
- const countOf = (r: OpenWOPResponse): number => {
148
- const b = r.json as { effects?: unknown } | null;
149
- return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
150
- };
151
- const forked = await driver.post(`/runs/${parentId}:fork`, { mode: 'replay' });
152
- const forkBody = forked.json as { runId?: unknown } | null;
153
- if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
154
- return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
155
- }
156
- const forkEffects = await driver.get(`/runs/${String(forkBody.runId)}/effects`);
157
- if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
158
- expect(
159
- countOf(forkEffects),
160
- req('openwop.requirement.0173.effect-seam-manifest.no-re-fire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
161
- ).toBeLessThanOrEqual(countOf(parentEffects));
162
- });
163
119
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * RFC 0173 §C.2 — `effect-seam-no-refire` (suite 2.0.0, target major 2; gated
3
+ * on `replay`, driven through the seams profile).
4
+ *
5
+ * A guarded manifest row that states `branchReFires: false` MUST NOT be fired
6
+ * again by a replay fork: the fork's Layer-2 effect ledger cannot grow past the
7
+ * parent's for that seam. The witness is the host's own ledger, reached by
8
+ * firing a named manifest row inside a run through the catalogued seam
9
+ * (`api/seams-v2.yaml` `fireEffectSeam`), forking in `replay` mode, and reading
10
+ * `GET /runs/{runId}/effects` on both.
11
+ *
12
+ * This leg lived in `v2-effect-seam-manifest` until rc.45. It is the one leg of
13
+ * that file that needs the seam, and a file's disposition is worst-first — so
14
+ * on a host without the seams surface the whole manifest file recorded
15
+ * `blocked`, and the manifest witness (unaided, a `GET` and a schema) could not
16
+ * sit on the `openwop-core-standard` floor without dragging a seam gate onto
17
+ * it. Split, the manifest file is a core-standard floor witness for the
18
+ * `replay` family and this file is a seams-v2 floor witness; each records its
19
+ * own truth.
20
+ *
21
+ * @see spec/v2/core/replay.md §The effect-seam manifest
22
+ * @see spec/v2/core/security-defaults.md §Replay suppression
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
27
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
28
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
29
+ import { softSkip } from '../lib/soft-skip.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+
32
+ const MANIFEST_PATH = '/host/effect-seams';
33
+
34
+ interface SeamRow { seam?: unknown; kind?: unknown; guarded?: unknown; guardedBy?: unknown; branchReFires?: unknown }
35
+ interface Manifest { manifestVersion?: unknown; seams?: SeamRow[] }
36
+
37
+ async function discovery(): Promise<Record<string, unknown> | null> {
38
+ try { return await v2Discovery(); } catch { return null; }
39
+ }
40
+
41
+ describe('RFC 0173 §C.2 — effect-seam-no-refire (gated on replay, seam-driven)', () => {
42
+ it('driving one seam of each kind observes no re-fire on replay', async () => {
43
+ const doc = await discovery();
44
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
45
+ const replay = await gateFamily('replay');
46
+ if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
47
+ if (!seamsProfileAdvertised(doc)) {
48
+ return softSkip('blocked', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
49
+ }
50
+ // The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
51
+ // the host's own Layer-2 ledger, not an externally reachable receiver: fire a
52
+ // guarded row that states `branchReFires: false`, fork the run in `replay`
53
+ // mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
54
+ // fork issues no further attempt for that seam, so its effect ledger cannot
55
+ // grow past the parent's.
56
+ const manifest = await driver.get(MANIFEST_PATH);
57
+ const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
58
+ ? ((manifest.json as Manifest).seams ?? []) : [];
59
+ const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
60
+ if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
61
+ const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
62
+ if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
63
+ return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
64
+ }
65
+ const firedBody = fired.json as { runId?: unknown } | null;
66
+ if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
67
+ return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
68
+ }
69
+ const parentId = firedBody.runId;
70
+ const parentEffects = await driver.get(`/runs/${encodeURIComponent(parentId)}/effects`);
71
+ if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
72
+ const countOf = (r: OpenWOPResponse): number => {
73
+ const b = r.json as { effects?: unknown } | null;
74
+ return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
75
+ };
76
+ const forked = await driver.post(`/runs/${encodeURIComponent(parentId)}:fork`, { mode: 'replay' });
77
+ const forkBody = forked.json as { runId?: unknown } | null;
78
+ if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
79
+ return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
80
+ }
81
+ const forkEffects = await driver.get(`/runs/${encodeURIComponent(String(forkBody.runId))}/effects`);
82
+ if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
83
+ expect(
84
+ countOf(forkEffects),
85
+ req('openwop.requirement.0173.effect-seam-no-refire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
86
+ ).toBeLessThanOrEqual(countOf(parentEffects));
87
+ });
88
+ });