@openwop/openwop-conformance 2.37.0 → 2.38.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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/dist/cli.js +8 -18
  4. package/dist/lib/certification-bundle-v3.js +46 -18
  5. package/dist/lib/jcs.js +274 -0
  6. package/dist/lib/requirement-ledger.js +44 -3
  7. package/dist/lib/scenario-disposition.js +37 -8
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +323 -57
  11. package/scenario-majors.json +7 -3
  12. package/schemas/CORPUS-STAMP.json +30 -30
  13. package/src/cli.ts +8 -16
  14. package/src/lib/certification-bundle-v3.ts +42 -17
  15. package/src/lib/front-mux.ts +44 -2
  16. package/src/lib/jcs.ts +229 -0
  17. package/src/lib/llm-cache-key-recipe.ts +10 -20
  18. package/src/lib/requirement-ledger.ts +82 -4
  19. package/src/lib/scenario-disposition.ts +41 -3
  20. package/src/lib/scoped-receiver.ts +223 -0
  21. package/src/lib/triggerBridge.ts +49 -0
  22. package/src/scenarios/auth-subject-link.test.ts +18 -1
  23. package/src/scenarios/jcs-vectors.test.ts +108 -0
  24. package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
  25. package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
  26. package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
  27. package/src/scenarios/v2-a2a-operation-map.test.ts +28 -0
  28. package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
  29. package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
  30. package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
  31. package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
  32. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
  33. package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
  34. package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
  35. package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
  36. package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
  37. package/src/setup.ts +24 -4
  38. package/vectors/jcs-v1.json +294 -0
  39. package/vectors/semantic-request-digest-v2.json +52 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.38.0] — 2026-09-24 — two RFCs add legs, and six rows that could not pass on a conforming host now can
4
+
5
+ - **Two rows the 2026-09-24 public v2-reference cut could not explain now say why, and one leg gets the time a tunnel needs.** `v2-mrtr-rounds-ceiling`'s `.refused` leg recorded only `expected 400 to be 422` on that cut, but passes on every loopback run of the host. Its failure message now carries the host's error code, how many `tools/call` the suite's MCP fake served, and the response body. Those facts separate "the host refused a round the fake answered" from "a round the tunnel failed to carry". `v2-oauth-client-pkce-state-iss`'s state leg gets a 120 s per-test timeout instead of 30 s: it makes eight sequential round trips through the AS double's tunnel, and on that cut it alone timed out while its shorter sibling legs passed. No assertion, requirement or window changes.
6
+ - **`v2-a2ui-v09-surface`'s two §C.11 fork legs could never pass on a conforming host.** Both recorded a surface on the suspended `conformance-approval` run and then forked at `fromSeq: sequence + 1`. The surface is the last event of that run, so `sequence + 1` names no event, and `spec/v2/core/runs.md` §Fork requires `422 fork_point_invalid` for a `fromSeq` not in the source log. The two ids affected were `openwop.it.v2-a2ui-v09-surface.recorded-as-recorded` and `openwop.requirement.0209.legacy-readable`. Each leg now records one more envelope: an `updateDataModel` on the same live surface (version 2), or a second version-1 surface (legacy). It then forks at the sequence the host reported for that envelope, so the surface is fixed history (`sequence < fromSeq`) and the fork point exists. What the fork must carry is unchanged. New suite self-test `src/lib/fork-point-recorded.test.ts` refuses any scenario whose `fromSeq` is arithmetic on another sequence (`fromSeq: x + 1`). It is sabotage-proved: restoring the old file makes it fail and name both lines (`:220`, `:237`). No other scenario has the pattern.
7
+ - **RFC 0212 — canonical JSON is JCS over I-JSON, and the certification digest no longer depends on the machine's locale.** New `src/lib/jcs.ts` is the suite's one canonicalizer: `canonicalJSON` (RFC 8785, refuses non-finite numbers, non-JSON values and lone surrogates) and `parseIJson` (refuses duplicate names and integer literals beyond ±(2^53 − 1), which `JSON.parse` would silently resolve). `certification-bundle-v3.ts` re-exports it and sorts `witnessSha256` rows by UTF-16 code units (was `localeCompare`); `cli.ts` reads the discovery document and a `--verify` bundle through `parseIJson` and drops its private copy; `llm-cache-key-recipe.ts` sorts `tools[]` by code units. New `vectors/jcs-v1.json`, server-free scenario `jcs-vectors` (both majors), coherence test `v2-bundle-witness-preimage` (every committed v3 bundle re-derives from the prose preimage; count asserted), and `semantic-request-digest-v2.json` gains `tools-code-unit-order` / `-reversed`. No committed bundle's digest changed. **Behavior change:** `--certify` now refuses (exit 2) a host whose discovery document is not I-JSON (a duplicate member name or an integer literal beyond ±(2^53 − 1)), and `verifyBundleV3` turns a non-I-JSON value into a `witness-digest` / `discovery-digest` / `signature-invalid` rejection instead of hashing coerced bytes (self-tested, sabotage-checked).
8
+ - **A public front that carries a PATH no longer sends every delivery to "addressed elsewhere".** `lib/front-mux.ts` (2.37.0) nonce-paths each suite fixture behind the operator's single front, and `lib/scoped-receiver.ts` (#1526) put the webhook receiver on it. Both matched only request paths STARTING with `/fx/`. A tunnel forwards the whole path, and `cut-public.sh` fronts the receiver at `${RX_URL}/hook`, so the pinned listener saw `/hook/fx/<nonce>` and answered every delivery as a stranger's. Measured on the 2026-09-24 public v2-reference cut on suite 2.37.1 (reported by openwop-75): `0171.webhook-delivery-shape`, `0173.webhook-durable-delivery` (+ `.dead-letter`) and `0187.bound-id-kinds.webhook-emitted` failed, deterministically on any path-bearing front and invisibly on loopback, where there is no front. `routeFronted` now strips the front's own path before matching (`frontPath` / `withoutFrontPath`), which also gives the pinned owner its own fronted traffic without the prefix; `scoped-receiver`'s `foreign()` count reads the same stripped path. New host-free legs use a path-bearing front for the webhook receiver (own delivery, sibling routing) and the A2A peer (a scenario-owned fake and the owner's own card); removing the strip reds all three.
9
+ - **`0206.delivery-extended-locale` could never execute: `v2-content-locale-keys` gated on a `supported` field the v2 `content` record cannot carry.** The gate was `content === null || content['supported'] !== true`. The v2 `content` record in `schemas/v2/capabilities.schema.json` is `additionalProperties: false` with no `supported` property — at major 2 presence of the record is the claim (RFC 0169 §A.2) — so on every schema-valid host — MyndHyve's committed record (`content: {baseLocale, supportedLocales}`, suite 2.35.1, which predates the scenario) included — the row would record `inapplicable` for a reason that was not true, even on a host advertising `es-419`. The gate is now presence (`familyAdvertised`); the rest of the scenario is unchanged. New suite self-test `src/lib/v2-family-gate-no-supported.test.ts` refuses any `v2-*` scenario comparing `supported` to a boolean, pins the premise (no v2 capability record declares `supported`), and is sabotage-proved: restoring the old line reds it naming `v2-content-locale-keys.test.ts:55`. A sweep of every `v2-*` scenario and the libraries they call found no second instance — the other `supported` reads are error-body arrays (`details.supported`, `error.data.supported`) and `lib/toolCatalog.ts` already resolves through `familyAdvertised` at major 2. On a current bundle the row stays `inapplicable` (no committed host advertises a tag outside the RFC 0103 subset), now for the stated reason.
10
+ - **Suite `2.38.0`**: 558 scenario files. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
11
+
12
+ ## [2.37.1] — 2026-09-24 — records that say why, and identities a host must remember minted fresh per run
13
+
14
+ - **`0213.in-flight-loser-outcome` no longer blocks a host that replays every loser.** `v2-idempotency-in-flight` ended with `softSkip('blocked', …)` when all five same-key creates succeeded. RFC 0213 §B explicitly permits that outcome: a loser MAY wait and receive the winner's final outcome, marked `OpenWOP-Idempotent-Replay: true`. The file's own header already promised `partial-witness`. Blocking denied certification (RFC 0168 §E.1) to every host fast enough, or serial enough, that no loser was ever in flight. It was measured on the v2-reference host with the published 2.37.0 (loopback rehearsal: all 5 answers were successes, 4 of them marked replays). The note is now `inapplicable`, which at major 2 records the row as a partial witness after the leg's assertions. Certification counts it, and the acceptance predicate still refuses it, because the 409 branch was never observed.
15
+ - **A failing assertion reaches the record with its message and its case name, and the per-`it` row of a multi-leg requirement survives.** MEASURED, `v2-run-bulk-cancel.test.ts` on a tier-2 host, both rows from ONE run: `openwop.floor.v2-run-bulk-cancel` `executed-fail`, 10 assertions, detail "one or more assertions in the file failed"; `openwop.requirement.0170.run-bulk-cancel` `executed-pass`, 3 assertions. No case name, no message, nothing else in the record for that file — the host operator could not diagnose it and hand-probed production. Two independent faults. (1) `fileDisposition`'s `executed-fail` detail was that FIXED STRING; it is now `failureDetail(...)` — the failing case titles (first three, then `+N more`) plus the first failure's message — and `resolveItRecord` names the leg in the per-`it` row's detail too. `setup.ts` collects `task.name` + the first error in `afterEach`. (2) The file has two `it`s that both hand `req()` one module-level `const ID`; leg 1 passed with 3 assertions and leg 2 failed on its 7th, and leg 2's row hit `recordRequirement`'s one-disposition-per-run throw, which `setup.ts`'s "never fail a test for bookkeeping" `catch` swallowed — so the verdict reached neither the in-memory ledger nor the JSONL sink. `recordRequirement` gains `fold`, used only by the per-`it` path: the surviving row is the LEAST certifiable leg (the rank `readLedgerFile` already applies across workers), carrying that leg's detail, with the legs' assertion counts summed; a scenario that classifies ITSELF twice still throws. 27 scenario files share one explicit id across several `it`s, so this was never one file's bug — `scripts/check-req-only.mjs` rule (d) now resolves a `const` handed to `req()` (it compared only call-site literals and was blind to the form nearly every scenario uses) and no longer flags the shared-id pattern the fold makes safe. **Why 7 of the 10 assertions looked unattributed, stated rather than gated:** they were not. All ten carry `req(ID, …)`, and attribution is per-`it`, not per-assertion — `req()` sets one `explicitId` that `setup.ts` takes once per test, and `assertionCount` counts `expect` calls whatever their message. The 3-vs-10 was the discarded leg, not a missing id. (The suite does hold 1,281 `expect`s with no message at all, against 4,824 carrying `req(...)`; they cost the reader a sentence, never the row its id, and the case name now carries alongside vitest's own diff. The reasoning is recorded in `check-req-only.mjs` rather than turned into a 1,281-site sweep.) Sabotage, host-free: one assertion flipped in `v2-payload-seats-0186.test.ts` (3 `it`s, one shared `const ID`, same shape as the bulk-cancel file) reproduces the reported pair exactly at `origin/main` — scenario row `executed-fail` / "one or more assertions in the file failed", requirement row `executed-pass`, failing leg absent — and after the fix the requirement row is `executed-fail` naming `"§A.3 ApprovalData.onTimeout is a closed enum and the def stays closed"` with the full `req()` message, and the file row names the case and quotes it.
16
+ - **Four webhook scenarios stop registering one byte-identical destination, and a zero that is provably unmeasured stops convicting the host.** `v2-bound-id-kinds`, `v2-webhook-delivery-shape`, `v2-webhook-durable-delivery` and `webhook-signed-delivery` each bound `OPENWOP_WEBHOOK_RECEIVER_PORT` and registered `resolveRegistrationUrl(...)`, which returns `OPENWOP_WEBHOOK_RECEIVER_URL` VERBATIM — so on a tunnelled cut all four subscriptions pointed at ONE URL. A webhook subscription is durable host-side state that keeps delivering, and RETRYING, after the file that made it has finished, so whichever receiver held the port read the leftovers as its own traffic — and `v2-webhook-durable-delivery` answers 500 BY DESIGN. Both symptoms were already recorded in the tree as workarounds: `v2-webhook-delivery-shape`'s receiver carried "the tunnel forwards to the PINNED port — held by the other receiver, which answers 500 by design — so this file's `deliveries` stays empty, its legs soft-skip, and the rows resolve `executed-pass`", and `v2-webhook-durable-delivery` filters by subscription because "a tier-2 host measured 6 attempts against a maxAttempts of 5". New `src/lib/scoped-receiver.ts` composes the two halves the tree already had rather than reimplementing either: ROUTING is openwop#1520's `front-mux` (each receiver registers its handler under its nonce and calls `routeFronted` first, so several can be alive behind one front), IDENTITY is the nonce made UNCONDITIONAL — `frontedEndpoint` omits it for the pinned-port owner, which is enough for a live listener and not for a subscription that outlives its exercise. A retry for a finished exercise now addresses a nonce nobody serves, is answered 404, and is counted `foreign()` instead of absorbed. `unservedDestination()` does the same for a leg that needs the mint and no delivery (`v2-bound-id-kinds` leg 1). Zeroes: where other traffic DID reach the listener, the path from host to process demonstrably works and the absence is of this exercise's IDENTITY, so the leg records `blocked` with `noDeliveryCause(...)` naming the address, the nonce and the foreign count — `blocked` denies certification exactly as a failure does (RFC 0168 §E.1), and a genuinely mis-wired front produces no traffic at all and still fails. `src/lib/scoped-receiver.test.ts` pins it host-free (8 cases: distinct destinations, the nonce surviving a front, a sibling's traffic routed to the sibling, a finished exercise's retry refused, a nonce-less stranger refused, own traffic delivered prefix-stripped, and both readings of a zero); removing the nonce from the destination reds 3 of them. The `receiverBinding` source guard in `webhook-receiver.test.ts` is rewritten to accept either shape and gains two cases: the shared helper must itself bind through `receiverBinding()`, and no listed scenario may mint its destination with `resolveRegistrationUrl`. Not witnessed against a host: no tunnelled cut was available, and no timeout or window was widened.
17
+ - **Two RFC 0213 requirement ids were cited from one `it()`, so the winner id was recorded on no host.** `v2-idempotency-in-flight.test.ts` cited `0213.in-flight-one-winner` and `0213.in-flight-loser-outcome` from a single `it`. The ledger keys on the id and `setup.ts` takes ONE `explicitId` per test, so only the last one cited got a row: the winner clause — "of concurrent same-key requests a host MUST process exactly one" — was measured on every host that ran the file and recorded on none of them, and nothing said so. Found by `check-req-only.mjs` rule (d) the moment it learned to resolve a `const` handed to `req()`, which is the same sharpening this cycle made for the attribution fix; the rule had compared only call-site literals, so the violation was invisible when the file was written. Split into two `it`s over ONE race: the record is in flight only while the winning create is being handled, so driving the race twice would measure two unrelated races and halve the chance that either overlaps, so it is driven once and memoised and both legs await the same result (the `v2-subject-link-record` pattern). No assertion, gate or timeout changed. The no-overlap soft-skip now lands on the loser id it is ABOUT, so the winner clause keeps its verdict instead of losing it to that return — a strictly better row on a host with no overlap, which is every host that answers create in milliseconds. That soft-skip's DISPOSITION is openwop#1525's, taken verbatim on merge: `inapplicable` (→ `executed-pass` + `partial-witness`), not `blocked`, because §B permits a loser to wait and receive a marked final outcome and `blocked` would deny certification to a host that did nothing wrong. The two changes compose — #1525 fixed what the row says, this one fixed which id says it.
18
+ - **A third fixed dedup key, and the source guard that would have caught it.** `trigger-stream-cdc-sources.test.ts` handed the bridge seam the literal `'events:3:99001'` and then asserted `deliveredCount === 1 || outcome === 'delivered'` — the identical defect as the bullet below, one file over, and it survived that sweep because NOTHING WAS CHECKING. §F.5 reuses §C-1's dedup window verbatim and that window is a >=24h FLOOR, so the second run of the file against the same host, any time that day, hands the bridge broker coordinates it has already delivered; a conformant host collapses the exercise into the first run's outcome and the assertion convicts it. Cold host passes, warm host fails. New `freshStreamDedupKey()` mints the OFFSET and keeps `(topic, partition)` — not `freshDedupKey`, because §F.5 says a stream event's key SHOULD derive from `(topic, partition, offset)` and the leg's own `req()` message asserts over exactly that keying; an opaque token would make the message describe something the call no longer does. The repetition §C-1 is about happens INSIDE the seam's `scenario: 'dedup'`, so the clause is untouched. New `src/lib/trigger-dedup-key.test.ts` pins it host-free and guards the CALL SITES, which is where a regression would reappear: no file that drives `driveDelivery` may spell a `dedupKey` as a literal. Sabotage, both spellings: reintroducing the literal as `dedupKey: 'events:3:99001'` (the original defect) and as `const dedupKey = 'events:3:99001'` each red the guard, naming the file and line. The first draft of that guard matched only the property form and the `const` sabotage PASSED it — the regex is one alternation over `[:=]` for that reason, recorded here because a guard that misses the spelling a reader would reach for is not a guard. Files that mention `dedupKey` without driving the seam are excluded deliberately: in `trigger-bridge-shape` it is a field in an offline AJV sample, durable nowhere.
19
+ - **The identity enumeration, re-run against the merged tree, and what it did NOT find.** Every other class was already per-exercise or is covered elsewhere, so no second mechanism is added for any of them: Layer-1 `Idempotency-Key`s are minted fresh at all four call sites (`idempotency`, `idempotencyRetry`, `v2-idempotency-key-grammar`, `pause-resume`); the whole host-surface family (`kv`, `blob`, `table`, `search`, `vector`, `sql`, `queueBus`) already mints its key / table / index / namespace / stream per exercise; `v2-subject-link-record` already mints its `externalId`; `replay-fanout-suppression` binds an EPHEMERAL port rather than the pinned one, so its destination is distinct by construction; and the scenario-owned A2A peers and MCP servers are `front-mux`'s (openwop#1520), which nonce-paths each fake behind the one public front. One row was rehearsed rather than fixed: `v2-webhook-message-id-stable` came back `executed-pass` on a loopback run against the PUBLISHED 2.37.0 with its `0201.message-id-stable` requirement row classified — no unclassified return. One run is not proof and the single scenario is being re-run before the public cut, but it is consistent with openwop#1513 and #1520 having already covered it, so NO mechanism is added for it here: not reproduced on 2.37.0, plausibly covered by `front-mux` / `effect-receiver`. **Owed, not fixed here:** `lib/oauth-as-double.ts` derives its ISSUER identity from `resolvePublicFront(frontEnv, local)`, and three files — `v2-oauth-mcp-reach-discovery`, `v2-oauth-client-pkce-state-iss`, `v2-credential-interrupt` — start a double on the same default `OPENWOP_OAUTH_AS_URL`, so on a tunnelled cut all three advertise ONE issuer and only the holder of the pinned port is reached. That is the front-mux shape for a fake front-mux does not cover, and it sits on the public-front routing another session is changing in the same cycle (openwop#1524, the per-instance `kid` in `lib/oidc-issuer.ts`), so it is stated rather than touched. Unreproduced: no tunnelled cut was available, so it is read off the code.
20
+ - **Two more fixed identities against state a host is REQUIRED to remember.** `trigger-bridge-delivery` handed the bridge the literal `'conformance-dedup-key'`, and `trigger-bridge.md` §C-1 makes the dedup window a >=24h FLOOR — so the second run of the file against the same host, any time that day, hands it a key already delivered; a conformant host collapses the exercise into the first run's outcome, emits zero `delivered` attempts under this run's id, and the `=== 1` convicts it. Cold host passes, warm host fails. `freshDedupKey()` mints one per exercise; the repetition §C-1 is about happens inside `driveDelivery`'s `scenario: 'dedup'`, so the clause is untouched. `auth-subject-link` provisioned the literal `externalId` `'idp-op-8f3a'` into the OPERATOR's SCIM directory and then DEACTIVATED it, so a second run asserts "authenticates before deactivation" against a subject the host is correct to refuse; minted per exercise now. The SCIM leg is opt-in on two env vars that no cut sets, so it is read off the code and unwitnessed — stated rather than claimed.
21
+ - **Version moved ahead of publication.** `@openwop/openwop-conformance` and its exact-pinned peer `@openwop/spec-artifacts` move to `2.37.1` because `2.37.0` is published. Published on the `v2.37.1` corpus tag.
22
+
3
23
  ## [2.37.0] — 2026-09-23 — rows that measured nothing now measure: a suite fake reachable through the public front, one effect identity per leg, the MRTR key that can tell
4
24
 
5
25
  - **Every synthetic OIDC issuer publishes its own `kid`, so two scenarios at one issuer URL no longer collide in a host's JWKS cache.** `lib/oidc-issuer.ts` generated a fresh key per `createSyntheticOIDCIssuer` instance but named it `openwop-conformance-key-${rotationCounter}` with a per-instance counter — so EVERY instance published `openwop-conformance-key-1`. `v2-oidc-id-token-audience` and `v2-lane-exp-only-bound` both publish at `OPENWOP_TEST_OIDC_ISSUER_URL`; a host that caches JWKS by `kid` (RFC 7517 §4.5: a kid identifies one key) verified the second scenario's tokens against the first scenario's cached key and refused its VALID control as `invalid_signature`. Reported and measured by openwop-app-ce on openwop-app, suite 2.36.1: `v2-lane-exp-only-bound` 4/4 red in a full run (control included), 4/4 green filtered — an order-dependent red that reads as a host failure on RFC 0210's rows. The kid is now `openwop-conformance-key-<per-instance hex>-<rotation>`. New self-test: a kid-caching verifier (re-fetch only on an unknown kid) must accept both instances' valid tokens at one URL; reverting to the shared kid reds it.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  # --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
12
12
  # default resolver refuses. npm 10.9 fails outright with
13
13
  # "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
14
- npm install --legacy-peer-deps @openwop/openwop-conformance@2.37.0 @openwop/spec-artifacts@2.37.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.38.0 @openwop/spec-artifacts@2.38.0
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 557 scenario files under `src/scenarios/`.
138
+ The current suite has 558 scenario files under `src/scenarios/`.
139
139
  - 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
140
140
  - 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.
141
141
  - 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.
@@ -481,7 +481,7 @@ Server-required (added in 1.7.0):
481
481
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
482
482
  | **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. |
483
483
 
484
- Current source tree: 557 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
484
+ Current source tree: 558 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
485
 
486
486
  ## Remaining Gaps
487
487
 
package/dist/cli.js CHANGED
@@ -45,6 +45,7 @@ import { deriveRung, emittedByNewerSuite } from './lib/durability-evidence.js';
45
45
  import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
46
46
  import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
47
47
  import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
48
+ import { canonicalJSON, parseIJson } from './lib/jcs.js';
48
49
  import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
49
50
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
50
51
  import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2ProfileIds } from './lib/v2-profiles.js';
@@ -301,21 +302,6 @@ function suiteVersion() {
301
302
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
302
303
  return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
303
304
  }
304
- /**
305
- * Deterministic canonical-JSON serialization (RFC 8785 spirit): object keys
306
- * sorted lexicographically at every level, arrays preserved in order. Used to
307
- * compute `discovery.sha256` so a verifier can re-derive the same digest from
308
- * a live `/.well-known/openwop` fetch regardless of incidental key order.
309
- */
310
- function canonicalJSON(value) {
311
- if (value === null || typeof value !== 'object')
312
- return JSON.stringify(value);
313
- if (Array.isArray(value))
314
- return `[${value.map(canonicalJSON).join(',')}]`;
315
- const obj = value;
316
- const keys = Object.keys(obj).sort();
317
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`).join(',')}}`;
318
- }
319
305
  /**
320
306
  * The full set of profiles a discovery document derives — the closed
321
307
  * `deriveProfiles` catalog plus the two operational annexes
@@ -474,7 +460,11 @@ async function runCertify(args, baseUrl, apiKey) {
474
460
  process.stderr.write(`openwop-conformance --certify: GET ${discoveryUrl} returned HTTP ${resp.status}.\n`);
475
461
  process.exit(2);
476
462
  }
477
- document = (await resp.json());
463
+ // RFC 0212 §B — read the TEXT through the I-JSON parser: `resp.json()`
464
+ // keeps the last of two duplicate names and rounds an out-of-range integer,
465
+ // and `discovery.sha256` would then attest to a document other verifiers
466
+ // read differently.
467
+ document = parseIJson(await resp.text());
478
468
  }
479
469
  catch (err) {
480
470
  process.stderr.write(`openwop-conformance --certify: failed to fetch ${discoveryUrl}: ${String(err)}\n`);
@@ -887,10 +877,10 @@ async function main() {
887
877
  }
888
878
  let bundle;
889
879
  try {
890
- bundle = JSON.parse(readFileSync(args.verifyPath, 'utf8'));
880
+ bundle = parseIJson(readFileSync(args.verifyPath, 'utf8'));
891
881
  }
892
882
  catch (e) {
893
- process.stderr.write(`openwop-conformance --verify: ${args.verifyPath} is not readable JSON — ${e.message}\n`);
883
+ process.stderr.write(`openwop-conformance --verify: ${args.verifyPath} is not readable I-JSON (RFC 0212 §B) — ${e.message}\n`);
894
884
  process.exit(3);
895
885
  }
896
886
  const hostKey = args.verifyHostKeyPath !== undefined ? readFileSync(args.verifyHostKeyPath, 'utf8') : undefined;
@@ -21,16 +21,14 @@ import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify a
21
21
  import { profileDerivable } from './profiles.js';
22
22
  import { checkRungClaim } from './durability-evidence.js';
23
23
  import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
24
+ import { canonicalJSON, codeUnitCompare, JcsRefusal } from './jcs.js';
24
25
  export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
25
- /** Deterministic JSON: keys sorted at every level, no whitespace. */
26
- export function canonicalJSON(value) {
27
- if (Array.isArray(value))
28
- return `[${value.map(canonicalJSON).join(',')}]`;
29
- if (value !== null && typeof value === 'object') {
30
- return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`).join(',')}}`;
31
- }
32
- return JSON.stringify(value);
33
- }
26
+ /**
27
+ * RFC 0212 §A — the bytes every attestation and digest here covers are RFC 8785
28
+ * (JCS), and a non-I-JSON value is refused, never coerced. Re-exported so
29
+ * existing importers keep one name for one algorithm.
30
+ */
31
+ export { canonicalJSON } from './jcs.js';
34
32
  /**
35
33
  * RFC 0148 §C — the digest over the reporter record (the requirement rows),
36
34
  * and, from 2.35.0, the operator's declared relaxations when there are any.
@@ -44,9 +42,16 @@ export function canonicalJSON(value) {
44
42
  * committed bundles digest unchanged, pinned in durability-evidence.test.ts),
45
43
  * and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
46
44
  * 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
45
+ *
46
+ * RFC 0212 §C — rows sort by `id` in UTF-16 code-unit order. This was
47
+ * `localeCompare` in the process default locale: a bundle cut on a machine
48
+ * whose locale is Czech, Slovak, Lithuanian or Hawaiian digested differently
49
+ * from the same bundle anywhere else (Czech sorts `ch` after `h`). Every
50
+ * committed bundle's ids are `[a-z0-9.-]`, where the two orders coincide, so no
51
+ * stored digest changes (v2-bundle-witness-preimage.test.ts pins that).
47
52
  */
48
53
  export function witnessDigest(rows, relaxations) {
49
- 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 }),
54
+ const canonicalRows = [...rows].sort((a, b) => codeUnitCompare(a.id, 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 }),
50
55
  // ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
51
56
  ...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
52
57
  const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
@@ -97,6 +102,24 @@ export function optedOutFromRows(rows) {
97
102
  }
98
103
  return [...names].sort();
99
104
  }
105
+ /**
106
+ * RFC 0212 §B — a verifier that meets non-I-JSON in a value it must
107
+ * re-canonicalize MUST fail verification. The canonicalizer throws; a verifier
108
+ * returns a verdict, so the refusal becomes a rejection of the named kind rather
109
+ * than an exception out of `verifyBundleV3` (a library caller that parsed the
110
+ * bundle with `JSON.parse` can still hand it a lone surrogate).
111
+ */
112
+ function refusedAs(rejections, kind, what, fn) {
113
+ try {
114
+ return fn();
115
+ }
116
+ catch (e) {
117
+ if (!(e instanceof JcsRefusal))
118
+ throw e;
119
+ rejections.push({ kind, detail: `${what} is not I-JSON, so it has no canonical bytes (RFC 0212 §B): ${e.message}` });
120
+ return undefined;
121
+ }
122
+ }
100
123
  export function verifyBundleV3(bundle, opts = {}) {
101
124
  const rejections = [];
102
125
  if (bundle.bundleVersion !== '3')
@@ -117,8 +140,8 @@ export function verifyBundleV3(bundle, opts = {}) {
117
140
  for (const k of Object.keys(expected))
118
141
  if (bundle.results?.totals?.[k] !== expected[k])
119
142
  rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
120
- const digest = witnessDigest(rows, bundle.host?.relaxations);
121
- if (bundle.witnessSha256 !== digest)
143
+ const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations));
144
+ if (digest !== undefined && bundle.witnessSha256 !== digest)
122
145
  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)})` });
123
146
  const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
124
147
  if (bundle.assertionCount !== assertions)
@@ -137,8 +160,9 @@ export function verifyBundleV3(bundle, opts = {}) {
137
160
  rejections.push({ kind: 'signature-over', detail: `signature.over must be ${JSON.stringify(SIGNATURE_OVER)}` });
138
161
  else if (opts.hostPublicKeyPem) {
139
162
  const key = createPublicKey(opts.hostPublicKeyPem);
140
- signatureVerified = edVerify(null, attestationPayload(bundle), key, fromBase64url(sig.sig));
141
- if (!signatureVerified)
163
+ const payload = refusedAs(rejections, 'signature-invalid', 'the attested members', () => attestationPayload(bundle));
164
+ signatureVerified = payload !== undefined && edVerify(null, payload, key, fromBase64url(sig.sig));
165
+ if (payload !== undefined && !signatureVerified)
142
166
  rejections.push({ kind: 'signature-invalid', detail: 'the attestation does not verify under the host key' });
143
167
  }
144
168
  // Independent tier
@@ -151,8 +175,9 @@ export function verifyBundleV3(bundle, opts = {}) {
151
175
  else if (vs.keyId === sig?.keyId || vs.keyId === bundle.host?.signingKeyId)
152
176
  rejections.push({ kind: 'independent-self-signed', detail: 'the verifier key must be distinct from the host key (RFC 0148 R5)' });
153
177
  else if (opts.verifierPublicKeyPem) {
154
- verifierSignatureVerified = edVerify(null, attestationPayload(bundle), createPublicKey(opts.verifierPublicKeyPem), fromBase64url(vs.sig));
155
- if (!verifierSignatureVerified)
178
+ const payload = refusedAs(rejections, 'verifier-signature-invalid', 'the attested members', () => attestationPayload(bundle));
179
+ verifierSignatureVerified = payload !== undefined && edVerify(null, payload, createPublicKey(opts.verifierPublicKeyPem), fromBase64url(vs.sig));
180
+ if (payload !== undefined && !verifierSignatureVerified)
156
181
  rejections.push({ kind: 'verifier-signature-invalid', detail: 'the verifier attestation does not verify' });
157
182
  }
158
183
  else
@@ -196,8 +221,11 @@ export function verifyBundleV3(bundle, opts = {}) {
196
221
  const document = bundle.discovery?.document;
197
222
  let derivabilityChecked = false;
198
223
  if (document !== undefined) {
199
- const digest = createHash('sha256').update(canonicalJSON(document)).digest('hex');
200
- if (digest !== bundle.discovery?.sha256) {
224
+ const digest = refusedAs(rejections, 'discovery-digest', 'discovery.document', () => createHash('sha256').update(canonicalJSON(document)).digest('hex'));
225
+ if (digest === undefined) {
226
+ // Already rejected as non-I-JSON; nothing derivable from a document with no canonical bytes.
227
+ }
228
+ else if (digest !== bundle.discovery?.sha256) {
201
229
  rejections.push({ kind: 'discovery-digest', detail: `discovery.document hashes to ${digest.slice(0, 12)} but discovery.sha256 is ${String(bundle.discovery?.sha256).slice(0, 12)} — the captured document is not the one the signature attests to` });
202
230
  }
203
231
  else {
@@ -0,0 +1,274 @@
1
+ /**
2
+ * RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
3
+ *
4
+ * Every signature and digest in the corpus is over these bytes: pack
5
+ * signatures (`ed25519-canonical-json`), the certification-bundle attestation,
6
+ * `discovery.sha256`, `witnessSha256`, and the RFC 0150 semantic request digest.
7
+ * `conformance/vectors/jcs-v1.json` is the normative test of this module and of
8
+ * any other-language implementation.
9
+ *
10
+ * Two entry points, because two of the refusals are invisible after a parse:
11
+ *
12
+ * - `canonicalJSON(value)` — the VALUE boundary. Refuses what a native value
13
+ * can still show: non-finite numbers, non-JSON values (undefined, functions,
14
+ * bigint, symbols, class instances such as Date) and lone surrogates.
15
+ * - `parseIJson(text)` — the TEXT boundary. `JSON.parse` keeps the last of two
16
+ * duplicate names and rounds `9007199254740993` to `…992` silently; both are
17
+ * refused here, before coercion. Read a document you will re-canonicalize
18
+ * through this, not `JSON.parse`.
19
+ *
20
+ * Why not `Object.keys(v).sort()` + `JSON.stringify` with no checks (the code
21
+ * this replaces): it is JCS for well-formed input — the default sort IS UTF-16
22
+ * code-unit order and ES number/string serialization IS JCS §3.2.2 — but it
23
+ * turns NaN into `null`, emits the text `undefined`, and signs a rounded
24
+ * integer. Each of those is a document two verifiers read differently with no
25
+ * error on either side.
26
+ */
27
+ export class JcsRefusal extends Error {
28
+ kind;
29
+ constructor(kind, message) {
30
+ super(`RFC 0212 §B refusal (${kind}): ${message}`);
31
+ this.name = 'JcsRefusal';
32
+ this.kind = kind;
33
+ }
34
+ }
35
+ /** A JSON number literal, matched in place (sticky) so parsing stays linear. */
36
+ const NUMBER = /-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/y;
37
+ /** ±(2^53 − 1), for the integer-literal refusal at the text boundary. */
38
+ const MAX_EXACT_BIG = 2n ** 53n - 1n;
39
+ /**
40
+ * RFC 8785 §3.2.3 — compare by UTF-16 code units. Explicit rather than the
41
+ * `Array.prototype.sort` default so the rule is visible at every call site, and
42
+ * never `localeCompare`: collation is locale-dependent (Czech sorts `ch` after
43
+ * `h`; English puts `a` before `A` and ignores `-`), so a digest computed with
44
+ * it depends on the machine that computed it.
45
+ */
46
+ export function codeUnitCompare(a, b) {
47
+ const n = Math.min(a.length, b.length);
48
+ for (let i = 0; i < n; i += 1) {
49
+ const d = a.charCodeAt(i) - b.charCodeAt(i);
50
+ if (d !== 0)
51
+ return d;
52
+ }
53
+ return a.length - b.length;
54
+ }
55
+ function assertWellFormed(s, where) {
56
+ for (let i = 0; i < s.length; i += 1) {
57
+ const u = s.charCodeAt(i);
58
+ if (u >= 0xd800 && u <= 0xdbff) {
59
+ const next = s.charCodeAt(i + 1);
60
+ if (next >= 0xdc00 && next <= 0xdfff) {
61
+ i += 1;
62
+ continue;
63
+ }
64
+ throw new JcsRefusal('lone-surrogate', `lone high surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
65
+ }
66
+ if (u >= 0xdc00 && u <= 0xdfff)
67
+ throw new JcsRefusal('lone-surrogate', `lone low surrogate U+${u.toString(16).toUpperCase()} in ${where}`);
68
+ }
69
+ }
70
+ function serializeNumber(n) {
71
+ if (!Number.isFinite(n))
72
+ throw new JcsRefusal('non-finite', `${String(n)} is not a JSON number`);
73
+ // No magnitude check here: a double is already exact, and JCS serializes it
74
+ // (RFC 8785 Appendix B includes 9007199254740994). The integer-range refusal
75
+ // is about a LITERAL that no double holds, which only the text shows — see
76
+ // `parseIJson`.
77
+ return Object.is(n, -0) ? '0' : String(n);
78
+ }
79
+ function isPlainObject(v) {
80
+ const proto = Object.getPrototypeOf(v);
81
+ return proto === Object.prototype || proto === null;
82
+ }
83
+ /** RFC 8785 serialization of an I-JSON value. Throws `JcsRefusal` instead of coercing. */
84
+ export function canonicalJSON(value) {
85
+ if (value === null)
86
+ return 'null';
87
+ switch (typeof value) {
88
+ case 'boolean': return value ? 'true' : 'false';
89
+ case 'number': return serializeNumber(value);
90
+ case 'string':
91
+ assertWellFormed(value, 'a string');
92
+ return JSON.stringify(value);
93
+ case 'object': break;
94
+ default: throw new JcsRefusal('not-json', `a ${typeof value} is not a JSON value`);
95
+ }
96
+ if (Array.isArray(value)) {
97
+ const parts = [];
98
+ for (let i = 0; i < value.length; i += 1) {
99
+ if (!(i in value))
100
+ throw new JcsRefusal('not-json', 'a sparse array hole is not a JSON value');
101
+ parts.push(canonicalJSON(value[i]));
102
+ }
103
+ return `[${parts.join(',')}]`;
104
+ }
105
+ if (!isPlainObject(value))
106
+ throw new JcsRefusal('not-json', `a ${value.constructor?.name ?? 'non-plain'} object is not a JSON value`);
107
+ const obj = value;
108
+ const keys = Object.keys(obj).sort(codeUnitCompare);
109
+ return `{${keys.map((k) => { assertWellFormed(k, 'a member name'); return `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`; }).join(',')}}`;
110
+ }
111
+ /**
112
+ * Parse JSON text, refusing what `JSON.parse` would silently accept or change:
113
+ * duplicate member names, integer literals outside ±(2^53 − 1), literals that
114
+ * overflow to ±Infinity, and lone surrogates. Members are defined, not
115
+ * assigned, so a member named `__proto__` is data, not a prototype write.
116
+ */
117
+ export function parseIJson(text) {
118
+ let i = 0;
119
+ const fail = (m) => { throw new JcsRefusal('not-json', `${m} at offset ${i}`); };
120
+ const ws = () => { while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r'))
121
+ i += 1; };
122
+ const str = () => {
123
+ if (text[i] !== '"')
124
+ fail('expected a string');
125
+ i += 1;
126
+ let out = '';
127
+ for (;;) {
128
+ if (i >= text.length)
129
+ fail('unterminated string');
130
+ const c = text[i];
131
+ i += 1;
132
+ if (c === '"')
133
+ break;
134
+ if (c === '\\') {
135
+ const e = text[i];
136
+ i += 1;
137
+ switch (e) {
138
+ case '"':
139
+ out += '"';
140
+ break;
141
+ case '\\':
142
+ out += '\\';
143
+ break;
144
+ case '/':
145
+ out += '/';
146
+ break;
147
+ case 'b':
148
+ out += '\b';
149
+ break;
150
+ case 'f':
151
+ out += '\f';
152
+ break;
153
+ case 'n':
154
+ out += '\n';
155
+ break;
156
+ case 'r':
157
+ out += '\r';
158
+ break;
159
+ case 't':
160
+ out += '\t';
161
+ break;
162
+ case 'u': {
163
+ const h = text.slice(i, i + 4);
164
+ if (!/^[0-9a-fA-F]{4}$/.test(h))
165
+ fail('bad \\u escape');
166
+ out += String.fromCharCode(parseInt(h, 16));
167
+ i += 4;
168
+ break;
169
+ }
170
+ default: fail('bad escape');
171
+ }
172
+ }
173
+ else {
174
+ if (c.charCodeAt(0) < 0x20)
175
+ fail('raw control character in a string');
176
+ out += c;
177
+ }
178
+ }
179
+ assertWellFormed(out, 'a string');
180
+ return out;
181
+ };
182
+ const num = () => {
183
+ NUMBER.lastIndex = i;
184
+ const m = NUMBER.exec(text);
185
+ if (m === null)
186
+ return fail('bad number');
187
+ i += m[0].length;
188
+ if (m[2] === undefined && m[3] === undefined) {
189
+ const b = BigInt(m[0]);
190
+ if (b > MAX_EXACT_BIG || b < -MAX_EXACT_BIG)
191
+ throw new JcsRefusal('integer-out-of-range', `integer literal ${m[0]} is outside ±(2^53 − 1)`);
192
+ }
193
+ const v = Number(m[0]);
194
+ if (!Number.isFinite(v))
195
+ throw new JcsRefusal('non-finite', `${m[0]} overflows to a non-finite number`);
196
+ return v;
197
+ };
198
+ const val = () => {
199
+ ws();
200
+ const c = text[i];
201
+ if (c === '{') {
202
+ i += 1;
203
+ const obj = {};
204
+ ws();
205
+ if (text[i] === '}') {
206
+ i += 1;
207
+ return obj;
208
+ }
209
+ for (;;) {
210
+ ws();
211
+ const k = str();
212
+ if (Object.prototype.hasOwnProperty.call(obj, k))
213
+ throw new JcsRefusal('duplicate-name', `duplicate member name ${JSON.stringify(k)}`);
214
+ ws();
215
+ if (text[i] !== ':')
216
+ fail('expected :');
217
+ i += 1;
218
+ Object.defineProperty(obj, k, { value: val(), enumerable: true, writable: true, configurable: true });
219
+ ws();
220
+ const d = text[i];
221
+ i += 1;
222
+ if (d === '}')
223
+ return obj;
224
+ if (d !== ',')
225
+ fail('expected , or }');
226
+ }
227
+ }
228
+ if (c === '[') {
229
+ i += 1;
230
+ const arr = [];
231
+ ws();
232
+ if (text[i] === ']') {
233
+ i += 1;
234
+ return arr;
235
+ }
236
+ for (;;) {
237
+ arr.push(val());
238
+ ws();
239
+ const d = text[i];
240
+ i += 1;
241
+ if (d === ']')
242
+ return arr;
243
+ if (d !== ',')
244
+ fail('expected , or ]');
245
+ }
246
+ }
247
+ if (c === '"')
248
+ return str();
249
+ if (text.startsWith('true', i)) {
250
+ i += 4;
251
+ return true;
252
+ }
253
+ if (text.startsWith('false', i)) {
254
+ i += 5;
255
+ return false;
256
+ }
257
+ if (text.startsWith('null', i)) {
258
+ i += 4;
259
+ return null;
260
+ }
261
+ if (c === '-' || (c !== undefined && c >= '0' && c <= '9'))
262
+ return num();
263
+ return fail('unexpected token');
264
+ };
265
+ const v = val();
266
+ ws();
267
+ if (i !== text.length)
268
+ fail('trailing data');
269
+ return v;
270
+ }
271
+ /** JCS bytes of a JSON text, with every §B refusal applied. */
272
+ export function canonicalizeText(text) {
273
+ return canonicalJSON(parseIJson(text));
274
+ }
@@ -47,10 +47,26 @@ const journal = [];
47
47
  * dispositions throws: RFC 0148 §A says **exactly one** disposition per
48
48
  * requirement, and a silent last-write-wins would let a later soft-skip
49
49
  * overwrite an earlier real failure — the failure mode in reverse.
50
+ *
51
+ * `extras.fold` is the one exception, for the per-`it` rows `setup.ts` records
52
+ * when several `it` legs witness ONE requirement id; see its docblock below.
53
+ */
54
+ /**
55
+ * How certifiable each disposition is, least first. `readLedgerFile` has always
56
+ * resolved a cross-worker disagreement this way — "one worker said it failed"
57
+ * outranks "another said it passed", and an unresolvable disagreement must never
58
+ * round toward certification. `fold` below applies the SAME rule in-worker.
50
59
  */
60
+ const CERTIFIABILITY_RANK = {
61
+ 'executed-fail': 0,
62
+ blocked: 1,
63
+ 'executed-pass': 2,
64
+ skipped: 3,
65
+ inapplicable: 4,
66
+ };
51
67
  export function recordRequirement(requirementId, disposition, detail, extras) {
52
68
  const prior = ledger.get(requirementId);
53
- if (prior !== undefined && prior.disposition !== disposition) {
69
+ if (prior !== undefined && prior.disposition !== disposition && extras?.fold !== true) {
54
70
  throw new Error(`RFC 0148 §A: ${requirementId} already recorded as '${prior.disposition}', now '${disposition}'. ` +
55
71
  'Exactly one disposition per requirement per run.');
56
72
  }
@@ -58,7 +74,7 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
58
74
  throw new Error(`RFC 0148 §A: ${requirementId} recorded as '${disposition}' without a reason. ` +
59
75
  'Anything other than executed-pass MUST say why, or the ledger records an outcome nobody can act on.');
60
76
  }
61
- const entry = {
77
+ let entry = {
62
78
  requirementId,
63
79
  disposition,
64
80
  ...(detail === undefined ? {} : { detail }),
@@ -66,6 +82,27 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
66
82
  ...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
67
83
  ...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
68
84
  };
85
+ if (prior !== undefined && extras?.fold === true) {
86
+ // The least-certifiable leg wins the disposition and keeps its own detail;
87
+ // a tie keeps whichever side actually said something. Counts sum, because
88
+ // both legs really did assert against the target for this one requirement.
89
+ const keepPrior = CERTIFIABILITY_RANK[prior.disposition] <= CERTIFIABILITY_RANK[disposition];
90
+ const winner = keepPrior ? prior : entry;
91
+ const loser = keepPrior ? entry : prior;
92
+ const count = (prior.assertionCount ?? 0) + (extras?.assertionCount ?? 0);
93
+ const keptDetail = winner.detail ?? loser.detail;
94
+ const hasCount = prior.assertionCount !== undefined || extras?.assertionCount !== undefined;
95
+ entry = {
96
+ requirementId,
97
+ disposition: winner.disposition,
98
+ ...(keptDetail === undefined ? {} : { detail: keptDetail }),
99
+ ...(hasCount ? { assertionCount: count } : {}),
100
+ ...(winner.scenarioFile === undefined ? {} : { scenarioFile: winner.scenarioFile }),
101
+ // `evidence` is only meaningful on a pass; a fold that lands anywhere
102
+ // else drops it, exactly as the constructor above does.
103
+ ...(winner.disposition === 'executed-pass' && winner.evidence !== undefined ? { evidence: winner.evidence } : {}),
104
+ };
105
+ }
69
106
  ledger.set(requirementId, entry);
70
107
  journal.push(entry);
71
108
  // File sink (RFC 0148 acceptance item 2, S6). The in-memory map lives in a
@@ -114,7 +151,11 @@ export function readLedgerFile(path) {
114
151
  if (typeof e.requirementId !== 'string' || !DISPOSITIONS.includes(e.disposition))
115
152
  continue;
116
153
  const prior = merged.get(e.requirementId);
117
- if (prior === undefined || rank[e.disposition] < rank[prior.disposition])
154
+ // `<=`, not `<`: a per-`it` FOLD (2.37.0) appends the cumulative row after
155
+ // the leg rows it folded, so on an equal disposition the LAST line is the
156
+ // one carrying the summed `assertionCount`. For genuinely duplicate lines
157
+ // the two are identical and the choice is a no-op.
158
+ if (prior === undefined || rank[e.disposition] <= rank[prior.disposition])
118
159
  merged.set(e.requirementId, e);
119
160
  }
120
161
  return [...merged.values()].sort((a, b) => a.requirementId.localeCompare(b.requirementId));