@openwop/openwop-conformance 2.45.8 → 2.45.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.10] — 2026-10-03 — RFC 0232 Active: a seam-free path for the run-less trigger leg
4
+
5
+ - **`trigger-bridge-delivery` leg 4 splits in two (RFC 0232, decided question 1 (a)).** Both ids stay in the `openwop-trigger-bridge` floor; the old title-derived id is aliased to the first.
6
+ - `openwop.requirement.0083.trigger-delivery.runless-attempt-content-free`: on the seam, as before. New: where the host advertises `inboundSigning` and `triggerBridge.deadLetter`, the leg posts a body carrying a canary with a bad signature to a `required` subscription and reads `GET /v1/trigger-subscriptions/{id}/dead-letters`. The record must be present with `reason: "verification_failed"`, carry no canary, signature or signing key, carry a schema-valid dead-lettered `attempt`, carry no `stateChange`, and have `expiresAt − deadLetteredAt` equal to `retentionDays`. Both paths run where both are offered.
7
+ - `openwop.requirement.0083.trigger-delivery.runless-state-change-content-free`: seam-only. **Without the seam it records `inapplicable`** (was: the combined leg recorded seam-absent). No wire surface causes a subscription state change.
8
+ - **New scenario `trigger-dead-letter-read`** (major 1; `inapplicable` unless the host advertises `triggerBridge.deadLetter` and `inboundSigning`): `openwop.requirement.0232.trigger-dead-letters.paging` (limit clamped, `nextCursor` continues), `.cursor-bound` (another subscription's cursor `400 validation_error`), `.tenant-bound` (another tenant's read `404`, as for an unknown id; needs `OPENWOP_TEST_TENANT_B_API_KEY`, `blocked` without it).
9
+ - **Sabotage.** `lib/trigger-dead-letter-witness.test.ts`, eleven cases against the pure judge and the committed page schema. Each defect fails only its rule. No host serves the read yet, so there is no live proof.
10
+ - **Host impact.** A host serving the seams: unchanged, except leg 4 is two rows. A host with no seams that advertises `openwop-trigger-bridge` (openwop-app's production): leg 4b records `inapplicable`, and leg 4a stays `blocked` until the host advertises and serves `triggerBridge.deadLetter`.
11
+ - **The 2.45.10 cycle opened** with RFC 0232's filing; its gap register changes the packed `spec/v1/gaps.json`.
12
+
13
+ ## [2.45.9] — 2026-10-02 — a trigger leg that observed nothing is inapplicable, not blocked
14
+
15
+ - **Fix: `trigger-stream-cdc-sources` no longer records `blocked` on a host that serves neither `stream` nor `change`.** The ingest leg skipped both sources on such a host and then passed with zero assertions and no reason. RFC 0148 §A records that as `blocked`, and a `blocked` row denies a major-1 bundle (RFC 0168 §E.1). It now records `inapplicable`, with the reason. The dedup leg's pre-RFC 0127 branch said `blocked` for the same condition and now says `inapplicable`. An unwired seam is still `blocked`, and a host that advertises either source and refuses it still fails.
16
+ - **Found by** openwop-app's 2.45.8 seam-free run, ahead of its RFC 0230 certified cut. My own harness run with the seams on recorded the same row, so the seams play no part in it.
17
+ - **Proof.** On openwop-app's in-process harness, the row was `blocked` before the change and is `inapplicable` with the reason after it. All nine tests pass.
18
+
3
19
  ## [2.45.8] — 2026-10-02 — RFC 0229 Accepted on MyndHyve's production cut; a v2 witness for tableStorage schema enforcement
4
20
 
5
21
  - **The 2.45.8 cycle opens.** RFC 0229's and RFC 0228's gap registers change the packed `spec/v1/gaps.json`.
package/README.md CHANGED
@@ -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 591 scenario files under `src/scenarios/`.
138
+ The current suite has 592 scenario files under `src/scenarios/`.
139
139
  - 2026-09-29 (suite 2.45.0, openwop#1763): NEW `v2-cors-preflight.test.ts` — `headers.md` §Cross-origin preflight. For every operation it preflights with `Origin`, the method, and the operation's declared request headers (plus `authorization` / `content-type` where they apply, from `spec/v2/path-manifest.json`), and reads the answer as the Fetch standard's CORS-preflight check does. Self-gated: `inapplicable` when no operation's preflight grants `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`).
140
140
  - 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.
141
141
  - 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.
@@ -482,7 +482,7 @@ Server-required (added in 1.7.0):
482
482
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
483
483
  | **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. |
484
484
 
485
- Current source tree: 591 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
+ Current source tree: 592 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
486
486
 
487
487
  ## Remaining Gaps
488
488
 
package/coverage.md CHANGED
@@ -251,6 +251,7 @@ Every OpenAPI operation should have:
251
251
  | `createAnnotation` | `feedback-record-and-list.test.ts`, `feedback-on-terminal-run.test.ts`, `feedback-correction-redaction.test.ts` (RFC 0056); gated on `capabilities.feedback.supported`, soft-skip on `501` | `feedback-unsupported-501.test.ts` (501 when unadvertised), `feedback-cross-tenant-isolation.test.ts`, `feedback-fork-not-copied.test.ts` | Capability-gated; full cross-tenant proof needs a multi-tenant auth seam (soft-skips, like `kv-cross-tenant-isolation`). |
252
252
  | `listAnnotations` | `feedback-record-and-list.test.ts`, `feedback-cross-tenant-isolation.test.ts` (RFC 0056) | `feedback-correction-redaction.test.ts` (redacted listing), `feedback-fork-not-copied.test.ts` (fork list empty) | Gated on `capabilities.feedback.supported`. |
253
253
  | `createTriggerSubscription` | `trigger-ingestion.test.ts` (RFC 0099) — always-on schema legs (`TriggerEvent` / `TriggerSubscriptionRegistration` shape, the §F.1 one-of, `AttachmentRef.ref`, content-free `trigger.delivery.attempted`) + a capability-gated behavioral leg (`triggerBridge.ingestion`) driving the `POST /v1/host/sample/trigger-bridge/ingest` seam for SSRF refusal + content-redaction | `trigger-ingestion.test.ts` covers the `trigger-ingestion-ssrf` + `trigger-ingestion-content-redaction` SECURITY invariants (schema-layer always-on + gated behavioral) | Gated on `capabilities.triggerBridge.ingestion`; soft-skips when unadvertised or seam unwired. Reference-host wiring at `Active → Accepted` (openwop-app ADR 0033 external-event-triggers item). |
254
+ | `listTriggerDeadLetters` | `trigger-bridge-delivery.test.ts` leg 4a (RFC 0232 §C: the refused post's record is content-free, schema-valid, carries no `stateChange`, and matches `retentionDays`) + `trigger-dead-letter-read.test.ts` (§B paging, cursor and tenant rules); both gated on `triggerBridge.deadLetter` with `inboundSigning` |
254
255
  | `getA2ATaskState` | `a2a-task-roundtrip.test.ts` (RFC 0100) — always-on `A2ATaskState` + `a2a` capability shape legs + a capability-gated durable-`tasks/get`-after-disconnect leg (`a2a.durableTasks`) driving the `/v1/host/sample/a2a/tasks/*` seam | `a2a-task-roundtrip.test.ts` covers the `a2a-push-egress-ssrf` SECURITY invariant (schema-layer always-on `PushConfig` + gated push-SSRF behavioral) | Host-extension read seam (non-normative; the normative read stays the A2A `tasks/get` JSON-RPC method). Gated on `a2a.durableTasks` / `a2a.pushNotifications`; soft-skips otherwise. Reference host at `Active → Accepted`. |
255
256
  | `diffRun` | `run-diff.test.ts` (RFC 0054); soft-skips on 404 when the endpoint is unimplemented | Self-diff `divergedAtSeq: null`/empty (determinism floor), two-fixture divergence with `eventDiffs[0].seq === divergedAtSeq`, response-shape + `stateDiff` redaction-safety, `400` (missing `against`) + `404` (nonexistent `against`) | Add a bespoke deterministically-divergent fork fixture for `divergedAtSeq === N`-at-a-chosen-seq; full cross-principal `403` needs a multi-principal harness. |
256
257
  | `resolveInterruptByRun` | `interrupt-approval.test.ts`, `interrupt-clarification.test.ts`, `approval-payload.test.ts`, `interruptRace.test.ts` | Invalid action, unknown node, race cases | Add auth-required and quorum profile scenarios. |
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.8",
4
- "stampSha256": "59536621c519684607bda9bfb0f0ddbb5a31cdf2b3f9131a856a9dfde93a6f39"
3
+ "version": "2.45.10",
4
+ "stampSha256": "cfba8c0d8fc447d57a70ace7ffcb054ba3ee9402be6268f1b40c99e0517791e8"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.8",
3
+ "version": "2.45.10",
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.45.8"
59
+ "@openwop/spec-artifacts": "2.45.10"
60
60
  }
61
61
  }
@@ -8,6 +8,7 @@
8
8
  "openwop.it.era-key-stamped-v1.a-run-the-host-just-created-carries-eventlogschemaversion": "openwop.requirement.version-negotiation.era-key-stamped",
9
9
  "openwop.it.pause-resume.pause-on-an-already-paused-run-is-a-no-op-200-202-idempotent": "openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless",
10
10
  "openwop.it.trigger-bridge-delivery.de-dups-by-dedupkey-retries-to-dead-letter-and-links-delivery-run-causation": "openwop.it.trigger-bridge-delivery.de-dups-by-dedupkey-a-repeated-delivery-is-effectively-once",
11
+ "openwop.it.trigger-bridge-delivery.keeps-the-run-less-trigger-events-content-free": "openwop.requirement.0083.trigger-delivery.runless-attempt-content-free",
11
12
  "openwop.it.v2-advertised-fixtures-exist.a-sampled-advertised-fixture-is-actually-creatable-not-just-listed": "openwop.requirement.0168.advertised-fixtures-exist",
12
13
  "openwop.it.v2-advertised-path-space-served.a-host-advertising-major-2-reaches-the-surfaces-it-already-serves-under-v1": "openwop.requirement.0172.advertised-path-space-served",
13
14
  "openwop.it.v2-alias-covers-reword.the-corpus-gate-script-exits-0": "openwop.requirement.0168.alias-covers-reword",
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
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": 666,
6
- "tests": 2545,
7
- "withStableId": 2545,
5
+ "files": 667,
6
+ "tests": 2549,
7
+ "withStableId": 2549,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2421
9
+ "explicitIds": 2426
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26311,7 +26311,7 @@
26311
26311
  {
26312
26312
  "id": "openwop.it.trigger-bridge-delivery.de-dups-by-dedupkey-a-repeated-delivery-is-effectively-once",
26313
26313
  "file": "trigger-bridge-delivery.test.ts",
26314
- "line": 158,
26314
+ "line": 171,
26315
26315
  "title": "de-dups by dedupKey: a repeated delivery is effectively-once",
26316
26316
  "explicitId": "openwop.requirement.0083.trigger-delivery.dedup",
26317
26317
  "citations": [
@@ -26347,7 +26347,7 @@
26347
26347
  {
26348
26348
  "id": "openwop.it.trigger-bridge-delivery.dead-letters-an-exhausted-or-refused-delivery-without-starting-a-run",
26349
26349
  "file": "trigger-bridge-delivery.test.ts",
26350
- "line": 182,
26350
+ "line": 195,
26351
26351
  "title": "dead-letters an exhausted or refused delivery without starting a run",
26352
26352
  "explicitId": "openwop.requirement.0083.trigger-delivery.dead-letter",
26353
26353
  "citations": [
@@ -26380,7 +26380,7 @@
26380
26380
  {
26381
26381
  "id": "openwop.it.trigger-bridge-delivery.links-delivery-run-causation-on-run-started",
26382
26382
  "file": "trigger-bridge-delivery.test.ts",
26383
- "line": 205,
26383
+ "line": 218,
26384
26384
  "title": "links delivery→run causation on run.started",
26385
26385
  "explicitId": "openwop.requirement.0083.trigger-delivery.causation",
26386
26386
  "citations": [
@@ -26414,12 +26414,41 @@
26414
26414
  ]
26415
26415
  },
26416
26416
  {
26417
- "id": "openwop.it.trigger-bridge-delivery.keeps-the-run-less-trigger-events-content-free",
26417
+ "id": "openwop.it.trigger-bridge-delivery.keeps-a-dead-lettered-run-less-attempt-content-free",
26418
26418
  "file": "trigger-bridge-delivery.test.ts",
26419
- "line": 234,
26420
- "title": "keeps the run-less trigger events content-free",
26421
- "explicitId": null,
26422
- "citations": []
26419
+ "line": 247,
26420
+ "title": "keeps a dead-lettered run-less attempt content-free",
26421
+ "explicitId": "openwop.requirement.0083.trigger-delivery.runless-attempt-content-free",
26422
+ "citations": [
26423
+ {
26424
+ "section": "trigger-bridge.md §F.6",
26425
+ "requirement": null,
26426
+ "interpolated": true
26427
+ },
26428
+ {
26429
+ "section": "trigger-bridge.md §F.6",
26430
+ "requirement": null,
26431
+ "interpolated": true
26432
+ },
26433
+ {
26434
+ "section": "trigger-bridge.md §C (RFC 0232 §B–§C)",
26435
+ "requirement": null,
26436
+ "interpolated": true
26437
+ }
26438
+ ]
26439
+ },
26440
+ {
26441
+ "id": "openwop.it.trigger-bridge-delivery.keeps-a-run-less-subscription-state-change-content-free",
26442
+ "file": "trigger-bridge-delivery.test.ts",
26443
+ "line": 278,
26444
+ "title": "keeps a run-less subscription state change content-free",
26445
+ "explicitId": "openwop.requirement.0083.trigger-delivery.runless-state-change-content-free",
26446
+ "citations": [
26447
+ {
26448
+ "section": "trigger-bridge.md §B",
26449
+ "requirement": "exhaustion MUST record ≥1 trigger.subscription.state.changed"
26450
+ }
26451
+ ]
26423
26452
  },
26424
26453
  {
26425
26454
  "id": "openwop.it.trigger-bridge-shape.a-conforming-subscription-validates",
@@ -26584,6 +26613,92 @@
26584
26613
  }
26585
26614
  ]
26586
26615
  },
26616
+ {
26617
+ "id": "openwop.it.trigger-dead-letter-read.clamps-limit-and-continues-a-short-page-with-nextcursor",
26618
+ "file": "trigger-dead-letter-read.test.ts",
26619
+ "line": 68,
26620
+ "title": "clamps limit and continues a short page with nextCursor",
26621
+ "explicitId": "openwop.requirement.0232.trigger-dead-letters.paging",
26622
+ "citations": [
26623
+ {
26624
+ "section": "trigger-bridge.md §C",
26625
+ "requirement": null,
26626
+ "interpolated": true
26627
+ },
26628
+ {
26629
+ "section": "trigger-bridge.md §C",
26630
+ "requirement": "a page MUST NOT exceed limit"
26631
+ },
26632
+ {
26633
+ "section": "trigger-bridge.md §C",
26634
+ "requirement": "with two dead letters and limit 1, the page MUST carry nextCursor"
26635
+ },
26636
+ {
26637
+ "section": "trigger-bridge.md §C",
26638
+ "requirement": null,
26639
+ "interpolated": true
26640
+ },
26641
+ {
26642
+ "section": "trigger-bridge.md §C",
26643
+ "requirement": "the next page MUST carry the second dead letter"
26644
+ },
26645
+ {
26646
+ "section": "trigger-bridge.md §C",
26647
+ "requirement": null,
26648
+ "interpolated": true
26649
+ }
26650
+ ]
26651
+ },
26652
+ {
26653
+ "id": "openwop.it.trigger-dead-letter-read.refuses-a-cursor-minted-for-another-subscription",
26654
+ "file": "trigger-dead-letter-read.test.ts",
26655
+ "line": 84,
26656
+ "title": "refuses a cursor minted for another subscription",
26657
+ "explicitId": "openwop.requirement.0232.trigger-dead-letters.cursor-bound",
26658
+ "citations": [
26659
+ {
26660
+ "section": "trigger-bridge.md §C",
26661
+ "requirement": null,
26662
+ "interpolated": true
26663
+ },
26664
+ {
26665
+ "section": "trigger-bridge.md §C",
26666
+ "requirement": null,
26667
+ "interpolated": true
26668
+ },
26669
+ {
26670
+ "section": "trigger-bridge.md §C",
26671
+ "requirement": "the refusal MUST carry validation_error"
26672
+ }
26673
+ ]
26674
+ },
26675
+ {
26676
+ "id": "openwop.it.trigger-dead-letter-read.answers-another-tenant-s-read-of-a-subscription-404-as-for-an-id-never-minted",
26677
+ "file": "trigger-dead-letter-read.test.ts",
26678
+ "line": 97,
26679
+ "title": "answers another tenant's read of a subscription 404, as for an id never minted",
26680
+ "explicitId": "openwop.requirement.0232.trigger-dead-letters.tenant-bound",
26681
+ "citations": [
26682
+ {
26683
+ "section": "trigger-bridge.md §C",
26684
+ "requirement": null,
26685
+ "interpolated": true
26686
+ },
26687
+ {
26688
+ "section": "trigger-bridge.md §C",
26689
+ "requirement": "a foreign read MUST NOT disclose a record"
26690
+ },
26691
+ {
26692
+ "section": "trigger-bridge.md §C",
26693
+ "requirement": null,
26694
+ "interpolated": true
26695
+ },
26696
+ {
26697
+ "section": "trigger-bridge.md §C",
26698
+ "requirement": "a foreign id MUST be answered exactly as an unknown one"
26699
+ }
26700
+ ]
26701
+ },
26587
26702
  {
26588
26703
  "id": "openwop.it.trigger-ingestion.the-canonical-email-triggerevent-fixture-validates",
26589
26704
  "file": "trigger-ingestion.test.ts",
@@ -26916,7 +27031,7 @@
26916
27031
  {
26917
27032
  "id": "openwop.it.trigger-stream-cdc-sources.stream-dedup-the-same-broker-coordinates-delivered-twice-are-effectively-once-c",
26918
27033
  "file": "trigger-stream-cdc-sources.test.ts",
26919
- "line": 201,
27034
+ "line": 210,
26920
27035
  "title": "stream dedup — the same broker coordinates delivered twice are effectively-once (§C-1 floor)",
26921
27036
  "explicitId": "openwop.it.trigger-stream-cdc-sources.stream-dedup-the-same-broker-coordinates-delivered-twice-are-effectively-once-c",
26922
27037
  "citations": [
@@ -1,8 +1,8 @@
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": 591,
5
- "v1": 454,
4
+ "files": 592,
5
+ "v1": 455,
6
6
  "v2": 152
7
7
  },
8
8
  "majors": {
@@ -1263,6 +1263,9 @@
1263
1263
  "trigger-bridge-shape.test.ts": [
1264
1264
  1
1265
1265
  ],
1266
+ "trigger-dead-letter-read.test.ts": [
1267
+ 1
1268
+ ],
1266
1269
  "trigger-ingestion.test.ts": [
1267
1270
  1
1268
1271
  ],
@@ -1,19 +1,19 @@
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.45.8",
5
- "corpusTag": "v2.45.8",
4
+ "version": "2.45.10",
5
+ "corpusTag": "v2.45.10",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
- "api/openapi.yaml": "ad37a5a5c732ad46100fc03c3f4243fdd4f2976e330f02a8791b6b5b10a8a0c1",
10
+ "api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "9d9fd7ed63a5514f48eb525b85400bdf3672c228b378fd976142514296cbf7b8",
13
- "api/v2/asyncapi.yaml": "e68b4dbe4a0426d8887e3d38dcc67fdbe1a8a4e25360abc44e30fca2323e0d77",
14
- "api/v2/openapi.yaml": "eec20ade7ef2d68836e0f91fb756afa63a27dd89c2b8f8daae26c2dbb16be91c",
12
+ "api/seams-v2.yaml": "448d60e189059fe6bd4953b8c47f6941f89053707f45621d4e32b8bef0773670",
13
+ "api/v2/asyncapi.yaml": "8db5aa748cd4f6481055d48d0a6d66d596f20e712d2004aa86de79b4c34e9bd9",
14
+ "api/v2/openapi.yaml": "e3b1ed5954b8e9acd6c68cd2a924603afcebb686d07fb5a26e929b01b7f6599b",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
- "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
16
+ "schemas/README.md": "bd1c00fae7b6fda506a75aff841bbdc438d4ce092642a72041203bee999b88de",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
18
18
  "schemas/a2ui-surface-delta-frame.schema.json": "970d434e4fdcb925de64aaace91eb83bfbcfd8f76757a9ce9f339eda5fb3f4e8",
19
19
  "schemas/agent-deployment-transition.schema.json": "1ac987fed0f380fa5b4e9a40e8e13316e5ae42d03a0ff42db4be6273e1bfe9f4",
@@ -31,7 +31,7 @@
31
31
  "schemas/artifact-type-pack-manifest.schema.json": "60342fcb15eb7f3ab46271f0641409a30d3a4a8f318b1020c53070886b191252",
32
32
  "schemas/audit-verify-result.schema.json": "0d3a3088ac1c32f9df66d346c33d7adbd8037940dba040e14ea847565a1a14bd",
33
33
  "schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
34
- "schemas/capabilities.schema.json": "a7e5910bf10e6d8238e909fee5b81e405846f91688cccf1bb0d13fb081336a33",
34
+ "schemas/capabilities.schema.json": "195a9bdfaeb8f3d425eaee3400bb3edca90a510f4c34b2fcb81a5685ada307ad",
35
35
  "schemas/certification-bundle-v2.schema.json": "2f85530dd04824a15810c94e96869362dee1e78396eca21fdd18e5501416bb7d",
36
36
  "schemas/channel-presence-payload.schema.json": "f9d0ef4219814c2b765baf3e68cbe5eea2cb42379f516a93a0459dcd5f7db47e",
37
37
  "schemas/channel-written-payload.schema.json": "54723a56bc5a35232f64544ca686ac1ac959a5fb05b32b2401facca6b3ef51a3",
@@ -82,7 +82,7 @@
82
82
  "schemas/residency.schema.json": "b45fdc91ae88091251914dbd6787a1e2ce687580b91d31a759217b7a1f0010c0",
83
83
  "schemas/run-ancestry-response.schema.json": "963a5763b03216aa72fc2854560c778e53994f8cb06ea9a4053e541273c87f5c",
84
84
  "schemas/run-diff-response.schema.json": "d9d196730b5d5d8a04a591d21a8fc6b44be7fa134f11827ff7e4f1a86d47e83d",
85
- "schemas/run-event-payloads.schema.json": "2094f19da1189f93392df6e728d7e17ad5bb1257b5fc44bb5e2036537658f12c",
85
+ "schemas/run-event-payloads.schema.json": "0812a7e89c37990f4e3d26a63b9c821ef1612893164f0378554bb702672f8513",
86
86
  "schemas/run-event.schema.json": "c70c06932b9831295042836213e617c8e946f20ddd49cf4e889b74d89d2175a8",
87
87
  "schemas/run-options.schema.json": "f8241ece6ddc6527de9160d811d42c231b4f77b935f2e107b3c05608545c59ab",
88
88
  "schemas/run-orchestrator-decided-event.schema.json": "17a43a3c44680a24d411141357a867b3caed6107d56de88fe2e062eaf93fa2c1",
@@ -94,9 +94,10 @@
94
94
  "schemas/subject.schema.json": "89679e35c7ec586adf6dd4adaff0bfa5453648d067fb2a43d7a2f7b2d016e10c",
95
95
  "schemas/suspend-request.schema.json": "7a90b622d5c619e3ffb5da696d272d6505e2780ab92ab631ac1b900f5daea908",
96
96
  "schemas/tool-descriptor.schema.json": "7f6adbae63003c1f1311f6a436f7f286542be62aa19a27b46730f2c2810857f7",
97
+ "schemas/trigger-dead-letter-page.schema.json": "1a471a975d66839f0a79f0a6d733de92cdbe2ac7111ff867d084b7795a9bc842",
97
98
  "schemas/trigger-event.schema.json": "59d63b79dca3593f45d281776cbfe43ae4d85ee25519b82ad59f162a9efb592c",
98
99
  "schemas/trigger-subscription-registration.schema.json": "2ca680ca6ffdb3cfc5de7bb8b8db8c3ec8eab4b88b7c9f7fe5edaecc7cf61ad0",
99
- "schemas/trigger-subscription.schema.json": "4f7b067f20032073b140c4bb15dc9a9914c4f6421a252ff1ff261cbdd8edf270",
100
+ "schemas/trigger-subscription.schema.json": "e8cddab88f73a7a79bb3b78a60a480a00e16966b76f0e18c21a2aef09b6420ac",
100
101
  "schemas/ui-plugin-message.schema.json": "346638babf1f8ea7b161cde75c0038b518cd2cc5bf0631772e20a9b74c96b5dd",
101
102
  "schemas/v2/a2a-task-state.schema.json": "1810363e2d9bddb5997923bdac4044cc17c6f46ec5a40515d2ff21d9c82c1ad9",
102
103
  "schemas/v2/a2ui-surface-delta-frame.schema.json": "6889c5da3fe7e76cb614038c0eea6774776fa640448558b258eb87fb50cb9b64",
@@ -116,7 +117,7 @@
116
117
  "schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
117
118
  "schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
118
119
  "schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
119
- "schemas/v2/capabilities.schema.json": "8d151a098a3cf7285fcadec8c1f192bed963ed6da3146e62d25155eb18afba9a",
120
+ "schemas/v2/capabilities.schema.json": "4503d383c2b5efff532a79dabc79d17b5f6494c88d5b385bd3ca5f1a4e3f7647",
120
121
  "schemas/v2/certification-bundle.schema.json": "82ce31a1ed6c7e21b0ba88ff62a7b8b768fc3b176b1d06fce023203830b94725",
121
122
  "schemas/v2/channel-presence-payload.schema.json": "4eeb224910e6f7eee3ab5e7c3899559c25c83a26d0022e0ad195c4672611977a",
122
123
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -171,7 +172,7 @@
171
172
  "schemas/v2/residency.schema.json": "3fd23c9732beb140504e81560fc426eb6764d547e56da5fb92c5a5c7bf04db3a",
172
173
  "schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
173
174
  "schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
174
- "schemas/v2/run-event-payloads.schema.json": "b2aa55840c0817c703417fd746ba85f72be7268b92f450143dfc1faf2f8fa119",
175
+ "schemas/v2/run-event-payloads.schema.json": "fc1af4245dec98f7be86d20ad339e3409d0b2aaaa301dace083f734999c470b7",
175
176
  "schemas/v2/run-event.schema.json": "2f0d7416bd376c56affe97b6b2b49981532e8e9ff45c0a162cf806c78b3e7fb7",
176
177
  "schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
177
178
  "schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
@@ -185,9 +186,10 @@
185
186
  "schemas/v2/subject.schema.json": "094e981fb20052a911393e219ea5f2065d8b8417b65080a70c5ea99fd960a8fe",
186
187
  "schemas/v2/suspend-request.schema.json": "368d9932efb08f85b4520cac44b3c553876209f55bada93b5fcac93cc890b411",
187
188
  "schemas/v2/tool-descriptor.schema.json": "8c98fb5e93bc649dff859d3d1dde8075db929efd6c49c496252d0a88cccc73e1",
189
+ "schemas/v2/trigger-dead-letter-page.schema.json": "571f8c39b64571bff20d391a6e6e829d4ce470ab790b2b7f810cc4aa40edcbc1",
188
190
  "schemas/v2/trigger-event.schema.json": "710ecca8b3ecdad3a532a96768177f062192b24a589a4f798c7eb24bab64b30a",
189
191
  "schemas/v2/trigger-subscription-registration.schema.json": "5f7959f3e13f49399783e47f73101d6af95e5abccea1af2f0014ccbe3e51006c",
190
- "schemas/v2/trigger-subscription.schema.json": "1f5c286f68577d531ef9445ab2493f8d69ac9ee42313d28d758a0f00dd0fab36",
192
+ "schemas/v2/trigger-subscription.schema.json": "06abe021712a4bfc5a107a067053cd3cc9704f47ae7066d8449a508c368a0ab2",
191
193
  "schemas/v2/ui-plugin-message.schema.json": "879d65867c01656b3962d64283e66364dcb8f626ad26c5fcf2d2d55a79ae0ce9",
192
194
  "schemas/v2/webhook-dead-letter-page.schema.json": "543f5ccc26d8f777684a037172ab7b9fdf70735e5394eeb42e5fa6569503f528",
193
195
  "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
@@ -204,17 +206,17 @@
204
206
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
207
  "spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
206
208
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "e7939af18e6c8d189f60e47df9fadd2da93c4ae96109a023283687dc7ba83b9f",
209
+ "spec/v1/core-standard-manifest.json": "819828fb2f978fa9e4a994652bbe16c797d36a8c4393d57d1432c8a53f2120ab",
208
210
  "spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
209
211
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
212
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
213
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
214
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "fa1b564fe59db6a708b9488d15aab445919fcc3621e8cf8121901b90b7b4c234",
215
+ "spec/v1/gaps.json": "7eab313ec80c40285e3290113f5d3fea90ea6f8bc385ef6fd15d415f52540035",
214
216
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
217
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
216
218
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
217
- "spec/v1/operation-path-manifest.json": "fada0342815fb129c1ae61f1829bd6e34417717408ce86fcc54280f373c19e74",
219
+ "spec/v1/operation-path-manifest.json": "f988f7ebf58ff192e999e2a71192066052f837acc03c91d047d6050ca21a83b0",
218
220
  "spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
219
221
  "spec/v2/README.md": "ddea90d7cf14aefffe1a43eeeecd4222dadad1d1e5ac64dceb073990eb93d9ef",
220
222
  "spec/v2/core/artifact-type-packs.md": "f734740cc3963f86c9f273a7dace26291bba2855161ddd3540618131a2c8d13e",
@@ -226,7 +228,7 @@
226
228
  "spec/v2/core/events.md": "c1a20e3aa6736119ea07aa2cf66faa808a1874d2cde4933033b65506d1742883",
227
229
  "spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
228
230
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
229
- "spec/v2/core/headers.md": "21d6c8220c3ac75b10e31c4cf6179abb539e2558af5006e40228d8c383ed5c92",
231
+ "spec/v2/core/headers.md": "1e601c31d2c8bcb7628b7efe2b65fb53d9c79db2a60a4235c333cf85290bb0c0",
230
232
  "spec/v2/core/host-services.md": "73f26ea32891044131d63f9861d2cbddccb5d08bf76e29e47afef9bb5b646791",
231
233
  "spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
232
234
  "spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
@@ -245,11 +247,11 @@
245
247
  "spec/v2/core/storage.md": "dc94b985847c1341c7a064f04a6010f0fdbe6c3ab3e555d4362878c81d764c18",
246
248
  "spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
247
249
  "spec/v2/core/versioning.md": "b7acb0d8b77425cb53ea700e47ba790bf7506de600c398690e6ec5bb9c7a220e",
248
- "spec/v2/core/webhooks.md": "b0c08a3511b9319da7b0f7d83cddb15758acfc92e4c58c3604ac0f6b1ab13242",
250
+ "spec/v2/core/webhooks.md": "daf6ec03092f4b3fde1eb74a5e2e801ec48db5139a13fcc0fd7fb4a004635ae3",
249
251
  "spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
250
252
  "spec/v2/corrections.json": "3a8d93e33865b692d26a3b6bfd3819766e206b2e0c2a69669baa4bf833d05a49",
251
253
  "spec/v2/corrections.schema.json": "46bbffb954d13bd9995aebc912ceeb2544f7064331abf25fd6ab85c81a5cec94",
252
- "spec/v2/declaration.json": "45ca70a0d74fdade13192565cb3b35e446ffab537f2caaf23da73a4050b3c6ac",
254
+ "spec/v2/declaration.json": "8e5eab46ea448725b806a2395f80f5c36fa582a308f80eb09931a633a595eae1",
253
255
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
254
256
  "spec/v2/errors.json": "83f048b0a06952b4af6f125fb66c4212acab51056b411390f1010acd6ab9cdc0",
255
257
  "spec/v2/errors.schema.json": "47404ad53e6147cf9017b813483712466573e419fb33e6c4b6c12d3e7deba1bc",
@@ -295,17 +297,17 @@
295
297
  "spec/v2/generated/error-codes.md": "f171c0ca1be40221f9b4fbfcbce73c728c315a1e8feb8dd6bf738d6ca9e29526",
296
298
  "spec/v2/harness-trust-anchors.json": "6007b92eae16a070fbc3777424efb0abfe865fda89d096963109b96282b91205",
297
299
  "spec/v2/harness-trust-anchors.schema.json": "372b5d92337c893d3449eff3f6bfd009ac09fdac285296d8bdbe0a08fff9e34c",
298
- "spec/v2/id-field-bindings.json": "9f6fd280250447e26a6704811c352bb8f2ff6c4bd242befe3da0ba38962370e9",
300
+ "spec/v2/id-field-bindings.json": "cc315f9a7aa5ec552860c1a790e105502fb737dc10b57aa5641bdd7405d6b638",
299
301
  "spec/v2/interop-map.json": "02255dafe7eb55f61ca7f9ae7c21f079d1848a6756991343088975ac9d184b8d",
300
302
  "spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
301
303
  "spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
302
304
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
303
- "spec/v2/path-manifest.json": "27c2307b84376b2f22bea4e2213fc7e33ef3bf8fd1d18039f7955edb00dc9c9a",
305
+ "spec/v2/path-manifest.json": "36363105280139dad095dc4f64b5b272121e9759cdcf6de011358dd1c1a90540",
304
306
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
305
307
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
306
- "spec/v2/release.json": "a9e139b57c3bcf8e888dfe58aef34a9b72813d2c30132364df3e21fe3a27d628",
308
+ "spec/v2/release.json": "044530e75b4cdec6747c1bcebe799ebc62f1f6c2a1e722369b35b2854e54049f",
307
309
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
308
- "spec/v2/surface-baseline.json": "7d6134b5693b3fb25afa7e54715f00cf0bf419e0bcb5b8802571a263c4ef019d"
310
+ "spec/v2/surface-baseline.json": "99ddcf9eb0d06967879ce7f6e54c49fe19bfabc9ee50f00f78699b401108bfd5"
309
311
  },
310
- "corpusCommit": "c5b85d858212ed6497ae6b2ae7584e5e2d77d016"
312
+ "corpusCommit": "8817d7c027047bbf1aa8d9385122137396f1bdca"
311
313
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The trigger dead-letter read witness (RFC 0232), leg 4's normative-surface
3
+ * path in `trigger-bridge-delivery.test.ts`.
4
+ *
5
+ * A dead-lettered trigger delivery started no run, so it is on no run's log.
6
+ * On a host that serves no test seam, the only place the suite can see it is
7
+ * `GET /v1/trigger-subscriptions/{id}/dead-letters`. The suite causes one
8
+ * unaided: it posts a body carrying a canary with a bad signature to a
9
+ * `required` subscription (RFC 0230 leg 2), then reads the subscription's
10
+ * dead letters.
11
+ *
12
+ * Two halves: {@link readDeadLetters} observes and asserts nothing; {@link judge}
13
+ * is pure, so each defect it convicts is proven in `trigger-dead-letter-witness.test.ts`.
14
+ *
15
+ * Not witnessed here: a record's `stateChange`. No wire surface causes a
16
+ * subscription state change on a host without the seam (RFC 0232 gap G2), so
17
+ * that half of leg 4 stays seam-witnessed.
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import addFormats from 'ajv-formats';
25
+ import { driver } from './driver.js';
26
+ import { SCHEMAS_DIR } from './paths.js';
27
+
28
+ export interface DeadLetterFacet {
29
+ readonly retentionDays: number;
30
+ readonly maxPageSize: number;
31
+ }
32
+
33
+ /** The advertised `triggerBridge.deadLetter` facet, or null when the host does not advertise it. */
34
+ export function deadLetterFacet(discovery: unknown): DeadLetterFacet | null {
35
+ const d = discovery as { triggerBridge?: unknown; capabilities?: { triggerBridge?: unknown } } | null | undefined;
36
+ const tb = (d?.triggerBridge ?? d?.capabilities?.triggerBridge) as { deadLetter?: unknown } | undefined;
37
+ const f = tb?.deadLetter as { retentionDays?: unknown; maxPageSize?: unknown } | undefined;
38
+ if (f === undefined || f === null || typeof f !== 'object') return null;
39
+ if (typeof f.retentionDays !== 'number' || typeof f.maxPageSize !== 'number') return null;
40
+ return { retentionDays: f.retentionDays, maxPageSize: f.maxPageSize };
41
+ }
42
+
43
+ /** A canary no conforming record can contain: it only ever travels in the refused body. */
44
+ export function freshCanary(): string {
45
+ return `openwop-canary-${randomBytes(9).toString('hex')}`;
46
+ }
47
+
48
+ export interface DeadLetterRead {
49
+ readonly status: number;
50
+ readonly json: unknown;
51
+ }
52
+
53
+ /** GET one page of a subscription's dead letters. Asserts nothing. */
54
+ export async function readDeadLetters(subscriptionId: string, query: { limit?: number; cursor?: string } = {}): Promise<DeadLetterRead> {
55
+ const qs = new URLSearchParams();
56
+ if (query.limit !== undefined) qs.set('limit', String(query.limit));
57
+ if (query.cursor !== undefined) qs.set('cursor', query.cursor);
58
+ const suffix = qs.toString() === '' ? '' : `?${qs.toString()}`;
59
+ const res = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(subscriptionId)}/dead-letters${suffix}`);
60
+ return { status: res.status, json: res.json };
61
+ }
62
+
63
+ let pageValidator: ((v: unknown) => boolean) & { errors?: unknown } | undefined;
64
+ /** Validate a page against `trigger-dead-letter-page.schema.json` (v1), resolving the event payload `$ref`s. */
65
+ export function validatePage(page: unknown): { ok: boolean; errors: string } {
66
+ if (pageValidator === undefined) {
67
+ const ajv = addFormats(new Ajv2020({ strict: false }));
68
+ const load = (n: string): Record<string, unknown> => JSON.parse(readFileSync(join(SCHEMAS_DIR, n), 'utf8')) as Record<string, unknown>;
69
+ const payloads = load('run-event-payloads.schema.json');
70
+ // Both carry `https://openwop.dev/spec/v1/…` ids, so the page's relative
71
+ // `run-event-payloads.schema.json#/$defs/…` refs resolve to this schema.
72
+ ajv.addSchema(payloads);
73
+ pageValidator = ajv.compile(load('trigger-dead-letter-page.schema.json'));
74
+ }
75
+ const ok = pageValidator(page) === true;
76
+ return { ok, errors: ok ? '' : JSON.stringify(pageValidator.errors ?? []).slice(0, 400) };
77
+ }
78
+
79
+ /** What the suite posted, so the judge can look for it in the record. */
80
+ export interface RefusedPost {
81
+ readonly subscriptionId: string;
82
+ readonly canary: string;
83
+ readonly signature: string;
84
+ readonly signingSecret: string;
85
+ readonly retentionDays: number;
86
+ }
87
+
88
+ export type DeadLetterRule = 'page-valid' | 'present' | 'content-free' | 'attempt-shape' | 'no-state-change' | 'retention';
89
+
90
+ export interface DeadLetterFinding {
91
+ readonly rule: DeadLetterRule;
92
+ readonly ok: boolean;
93
+ readonly message: string;
94
+ }
95
+
96
+ const DAY_MS = 86_400_000;
97
+ /** One minute of slack either way: hosts round timestamps. */
98
+ const RETENTION_SLACK_MS = 60_000;
99
+
100
+ /**
101
+ * Judge one read against the refused post that should have produced a record.
102
+ * Pure. `validate` defaults to {@link validatePage}; the unit tests pass it in.
103
+ */
104
+ export function judge(read: DeadLetterRead, post: RefusedPost, validate: (page: unknown) => { ok: boolean; errors: string } = validatePage): DeadLetterFinding[] {
105
+ const out: DeadLetterFinding[] = [];
106
+ if (read.status !== 200) {
107
+ out.push({ rule: 'page-valid', ok: false, message: `a host advertising triggerBridge.deadLetter MUST serve the read (answered ${read.status})` });
108
+ return out;
109
+ }
110
+ const v = validate(read.json);
111
+ out.push({ rule: 'page-valid', ok: v.ok, message: `the page MUST validate against trigger-dead-letter-page.schema.json${v.ok ? '' : `: ${v.errors}`}` });
112
+
113
+ const deliveries = ((read.json as { deliveries?: unknown } | undefined)?.deliveries ?? []) as Array<Record<string, unknown>>;
114
+ const mine = Array.isArray(deliveries)
115
+ ? deliveries.filter((r) => r !== null && typeof r === 'object' && r['reason'] === 'verification_failed' && r['subscriptionId'] === post.subscriptionId)
116
+ : [];
117
+ out.push({ rule: 'present', ok: mine.length >= 1, message: `the refused delivery MUST appear with reason "verification_failed" (saw ${Array.isArray(deliveries) ? deliveries.length : 0} record(s), ${mine.length} matching)` });
118
+
119
+ // Content-free: the canary, the signature and the secret appear nowhere in
120
+ // ANY record on the page, not just the matched one — a host that leaked them
121
+ // into a record it mis-labelled is still leaking them.
122
+ const text = JSON.stringify(read.json ?? null);
123
+ const leaked = [['the canary from the refused body', post.canary], ['the signature', post.signature], ['the signing secret', post.signingSecret]]
124
+ .filter(([, needle]) => needle !== '' && text.includes(needle as string)).map(([what]) => what);
125
+ const secretKey = post.signingSecret.replace(/^whsec_/, '');
126
+ if (secretKey !== '' && secretKey !== post.signingSecret && text.includes(secretKey)) leaked.push('the signing key');
127
+ out.push({ rule: 'content-free', ok: leaked.length === 0, message: `a record MUST NOT carry inbound content or credentials (found ${leaked.join(', ') || 'none'})` });
128
+
129
+ const rec = mine[0];
130
+ if (rec !== undefined) {
131
+ const attempt = rec['attempt'] as Record<string, unknown> | undefined;
132
+ const attemptOk = attempt !== undefined && attempt !== null && typeof attempt === 'object'
133
+ && attempt['outcome'] === 'dead-lettered' && attempt['subscriptionId'] === post.subscriptionId;
134
+ out.push({ rule: 'attempt-shape', ok: attemptOk, message: `attempt MUST be the dead-lettered trigger.delivery.attempted payload for this subscription (got ${JSON.stringify(attempt ?? null).slice(0, 200)})` });
135
+ out.push({ rule: 'no-state-change', ok: !('stateChange' in rec), message: 'a delivery refused by verification MUST NOT carry a stateChange (a refused event MUST NOT change the subscription\'s state)' });
136
+ const from = Date.parse(String(rec['deadLetteredAt']));
137
+ const to = Date.parse(String(rec['expiresAt']));
138
+ const want = post.retentionDays * DAY_MS;
139
+ const retentionOk = Number.isFinite(from) && Number.isFinite(to) && Math.abs((to - from) - want) <= RETENTION_SLACK_MS;
140
+ out.push({ rule: 'retention', ok: retentionOk, message: `expiresAt − deadLetteredAt MUST match retentionDays (${post.retentionDays}); got ${Number.isFinite(to - from) ? `${((to - from) / DAY_MS).toFixed(4)} days` : 'unparseable timestamps'}` });
141
+ }
142
+ return out;
143
+ }
@@ -6,15 +6,17 @@
6
6
  * under `OPENWOP_REQUIRE_BEHAVIOR=true`. The always-on wire-shape coverage lives
7
7
  * in `trigger-bridge-shape.test.ts`.
8
8
  *
9
- * Four legs, each its own requirement (RFC 0230 split the one combined `it`):
9
+ * Five legs, each its own requirement (RFC 0230 split the one combined `it`;
10
+ * RFC 0232 split the fourth):
10
11
  *
11
12
  * 1. DEDUP (§C-1) — a repeated delivery is effectively-once.
12
13
  * 2. DEAD-LETTER (§C-2 + RFC 0053) — an exhausted/refused delivery starts no run.
13
14
  * 3. CAUSATION (§C / RFC 0040) — a delivered run carries the delivery as
14
15
  * `run.started.causationId`.
15
- * 4. RUN-LESS CONTENT-FREENESS (SR-1) — the run-less `trigger.*` events
16
- * (a dead-lettered attempt, `trigger.subscription.state.changed`) carry no
17
- * inbound content.
16
+ * 4a. RUN-LESS ATTEMPT (SR-1) — a dead-lettered `trigger.delivery.attempted`
17
+ * carries no inbound content.
18
+ * 4b. RUN-LESS STATE CHANGE (SR-1) — a `trigger.subscription.state.changed`
19
+ * carries no inbound content.
18
20
  *
19
21
  * TWO WITNESS PATHS, and a host is measured on EVERY path it offers (2.45.8).
20
22
  * The SEAM path drives `POST /v1/host/sample/trigger-bridge/deliver` and reads
@@ -31,18 +33,24 @@
31
33
  * certify, and an uncertified bundle supplies no acceptance evidence
32
34
  * (RFC 0174 §B.1).
33
35
  *
34
- * Leg 4 has no normative-surface path — the run-less events are on no run's
35
- * log — so it stays seam-witnessed and records seam-absent otherwise; it
36
- * remains in the floor (the floor never narrows).
36
+ * The run-less events are on no run's log. Leg 4a's normative-surface path is
37
+ * RFC 0232's read: on a host advertising `inboundSigning` AND
38
+ * `triggerBridge.deadLetter`, it posts a canary with a bad signature and reads
39
+ * the subscription's dead letters (`lib/trigger-dead-letter-witness.ts`).
40
+ * Leg 4b has no such path: no wire surface causes a subscription state change
41
+ * (a refused post must not change state; nothing pauses a subscription), so it
42
+ * is seam-witnessed and, on a host without the seam, records `inapplicable`
43
+ * (RFC 0232 Unresolved question 1, decided (a)). Both stay in the floor.
37
44
  *
38
45
  * Spec references:
39
46
  * - spec/v1/trigger-bridge.md (§C, §F.2, §F.6)
40
- * - RFCS/0083-durable-trigger-and-channel-bridge-profile.md, RFCS/0230-inbound-webhook-ingest-contract.md
47
+ * - RFCS/0083-durable-trigger-and-channel-bridge-profile.md, RFCS/0230-inbound-webhook-ingest-contract.md,
48
+ * RFCS/0232-trigger-dead-letter-read.md
41
49
  * - spec/v1/profiles.md (§openwop-trigger-bridge)
42
50
  */
43
51
 
44
52
  import { describe, it, expect } from 'vitest';
45
- import { seamAbsent } from '../lib/soft-skip.js';
53
+ import { seamAbsent, softSkip } from '../lib/soft-skip.js';
46
54
  import { behaviorGate } from '../lib/behavior-gate.js';
47
55
  import {
48
56
  isTriggerBridgeProfileAdvertised,
@@ -59,11 +67,16 @@ import { queryTestEvents, requireEvents, isEventLogSeamAvailable, resetTestSeam
59
67
  import { req } from '../lib/requirement-ids.js';
60
68
  import { noteObservation } from '../lib/row-observation.js';
61
69
  import { driver } from '../lib/driver.js';
70
+ import { deadLetterFacet, freshCanary, judge as judgeDeadLetters, readDeadLetters } from '../lib/trigger-dead-letter-witness.js';
62
71
 
63
72
  const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
64
73
  const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
65
74
  const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
66
- const R_RUNLESS = 'openwop.requirement.0083.trigger-delivery.runless-content-free';
75
+ const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
76
+ const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
77
+
78
+ /** The bad signature every refused-post probe sends. */
79
+ const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
67
80
 
68
81
  const CONTENT_FREE_FORBIDDEN = ['body', 'headers', 'payload', 'secret', 'credentials', 'token', 'apiKey'];
69
82
 
@@ -191,7 +204,7 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
191
204
  const sub = await registerSignedWebhook('required');
192
205
  if ('reason' in sub) throw new Error(req(R_DEAD_LETTER, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
193
206
  const body = JSON.stringify({ conformance: 'dead-letter' });
194
- const bad = await signedIngest(sub, body, { signature: 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' });
207
+ const bad = await signedIngest(sub, body, { signature: BAD_SIGNATURE });
195
208
  expect(bad.status, req(R_DEAD_LETTER, 'trigger-bridge.md §F.6', `a bad signature under required verification MUST answer 401 (got ${bad.status})`)).toBe(401);
196
209
  expect(bad.json?.runId, req(R_DEAD_LETTER, 'trigger-bridge.md §F.2', 'a required-verification failure MUST NOT start a run')).toBeUndefined();
197
210
  const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(sub.subscriptionId)}`);
@@ -231,17 +244,53 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
231
244
  notePaths(paths.seam, true, deliveredEvent === undefined ? '; on the normative-surface path the delivered attempt event is not on the run log, so equality was not checked there' : '');
232
245
  });
233
246
 
234
- it('keeps the run-less trigger events content-free', async () => {
247
+ it('keeps a dead-lettered run-less attempt content-free', async () => {
235
248
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
236
- // No normative-surface path: a dead-lettered attempt and a state change are
237
- // on no run's log, so only the seam can read them (RFC 0230 §Conformance).
238
- if (!(await isEventLogSeamAvailable())) return seamAbsent('the run-less trigger events are readable only through the event-log seam, which is absent');
249
+ const seam = await isEventLogSeamAvailable();
250
+ const disco = await driver.get('/.well-known/openwop');
251
+ const facet = disco.status === 200 ? deadLetterFacet(disco.json) : null;
252
+ const normative = facet !== null && (await inboundSigningAdvertised());
253
+ if (!seam && !normative) return seamAbsent('a dead-lettered attempt is on no run\'s log; it is readable through the event-log seam or the RFC 0232 read (triggerBridge.deadLetter with inboundSigning), and the host offers neither');
254
+
255
+ if (seam) {
256
+ const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
257
+ if (exhaust === null) return seamAbsent(SEAM_UNWIRED);
258
+ const exKey = exhaust.runId ?? '__exhaust__';
259
+ const attempts = requireEvents(await queryTestEvents(exKey, { type: 'trigger.delivery.attempted' }), 'trigger.delivery.attempted (exhaust)');
260
+ for (const e of attempts) expectContentFree(R_RUNLESS_ATTEMPT, e.payload, 'trigger.delivery.attempted');
261
+ await resetTestSeam();
262
+ }
263
+ if (!normative) return notePaths(true, false);
264
+
265
+ // Normative-surface path (RFC 0232 §E): cause a dead letter unaided, then read it.
266
+ const sub = await registerSignedWebhook('required');
267
+ if ('reason' in sub) throw new Error(req(R_RUNLESS_ATTEMPT, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
268
+ const canary = freshCanary();
269
+ const bad = await signedIngest(sub, JSON.stringify({ conformance: 'runless-attempt', canary }), { signature: BAD_SIGNATURE });
270
+ expect(bad.status, req(R_RUNLESS_ATTEMPT, 'trigger-bridge.md §F.6', `a bad signature under required verification MUST answer 401 (got ${bad.status})`)).toBe(401);
271
+ const read = await readDeadLetters(sub.subscriptionId);
272
+ for (const f of judgeDeadLetters(read, { subscriptionId: sub.subscriptionId, canary, signature: BAD_SIGNATURE, signingSecret: sub.signingSecret, retentionDays: facet!.retentionDays })) {
273
+ expect(f.ok, req(R_RUNLESS_ATTEMPT, 'trigger-bridge.md §C (RFC 0232 §B–§C)', f.message)).toBe(true);
274
+ }
275
+ notePaths(seam, true, ' (RFC 0232 dead-letter read)');
276
+ });
277
+
278
+ it('keeps a run-less subscription state change content-free', async () => {
279
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
280
+ // Seam-only. No wire surface causes a subscription state change: a refused
281
+ // post MUST NOT change state, retry exhaustion needs a delivery the host
282
+ // fails repeatedly, and nothing pauses a subscription (RFC 0232 G2). On a
283
+ // host without the seam no party can cause the condition, so the row is
284
+ // `inapplicable`, not a defect (RFC 0232 Unresolved question 1, decided (a)).
285
+ if (!(await isEventLogSeamAvailable())) {
286
+ return softSkip('inapplicable', 'no wire surface causes a trigger subscription state change on a host without the event-log seam (RFC 0232 §E, gap G2)');
287
+ }
239
288
  const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
240
- if (exhaust === null) return seamAbsent('host advertises openwop-trigger-bridge but the delivery seam is unwired');
289
+ if (exhaust === null) return seamAbsent(SEAM_UNWIRED);
241
290
  const exKey = exhaust.runId ?? '__exhaust__';
242
- for (const type of ['trigger.delivery.attempted', 'trigger.subscription.state.changed'] as const) {
243
- for (const e of requireEvents(await queryTestEvents(exKey, { type }), `${type} (exhaust)`)) expectContentFree(R_RUNLESS, e.payload, type);
244
- }
291
+ const changes = requireEvents(await queryTestEvents(exKey, { type: 'trigger.subscription.state.changed' }), 'trigger.subscription.state.changed (exhaust)');
292
+ expect(changes.length >= 1, req(R_RUNLESS_STATE, 'trigger-bridge.md §B', 'exhaustion MUST record ≥1 trigger.subscription.state.changed')).toBe(true);
293
+ for (const e of changes) expectContentFree(R_RUNLESS_STATE, e.payload, 'trigger.subscription.state.changed');
245
294
  await resetTestSeam();
246
295
  });
247
296
  });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Trigger dead-letter read — the §B rules (RFC 0232) — behavioral.
3
+ *
4
+ * Gated on `triggerBridge.deadLetter` together with
5
+ * `triggerBridge.ingestion.inboundSigning` (the suite causes a dead letter
6
+ * unaided by posting a bad signature to a `required` subscription). A host that
7
+ * advertises neither records `inapplicable`.
8
+ *
9
+ * The record's content rules (§C) are leg 4a of `trigger-bridge-delivery`,
10
+ * which is in the `openwop-trigger-bridge` floor. These are the read's own
11
+ * rules, each its own requirement:
12
+ *
13
+ * 1. PAGING — `limit` is clamped, and a page that stops short carries
14
+ * `nextCursor`.
15
+ * 2. CURSOR — a cursor minted for one subscription is refused on another,
16
+ * `400 validation_error`.
17
+ * 3. TENANT — another tenant's credential reading this subscription's id gets
18
+ * `404 not_found`, exactly as for an id the host never minted. Needs
19
+ * `OPENWOP_TEST_TENANT_B_API_KEY`; without it the row is `blocked`.
20
+ *
21
+ * Spec references:
22
+ * - spec/v1/trigger-bridge.md §C (Run-less transitions are recorded)
23
+ * - spec/v2/core/webhooks.md §Inbound triggers
24
+ * - RFCS/0232-trigger-dead-letter-read.md §B
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { softSkip } from '../lib/soft-skip.js';
29
+ import { driver } from '../lib/driver.js';
30
+ import { loadEnv } from '../lib/env.js';
31
+ import { readErrorCode } from '../lib/error-envelope.js';
32
+ import { req } from '../lib/requirement-ids.js';
33
+ import { inboundSigningAdvertised, registerSignedWebhook, signedIngest, type SignedWebhookSubscription } from '../lib/triggerBridge.js';
34
+ import { deadLetterFacet, readDeadLetters, type DeadLetterFacet } from '../lib/trigger-dead-letter-witness.js';
35
+
36
+ const R_PAGING = 'openwop.requirement.0232.trigger-dead-letters.paging';
37
+ const R_CURSOR = 'openwop.requirement.0232.trigger-dead-letters.cursor-bound';
38
+ const R_TENANT = 'openwop.requirement.0232.trigger-dead-letters.tenant-bound';
39
+
40
+ const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
41
+
42
+ /** The facet, or null with the reason the row is `inapplicable`. */
43
+ async function gate(): Promise<{ facet: DeadLetterFacet } | { reason: string }> {
44
+ const disco = await driver.get('/.well-known/openwop');
45
+ const facet = disco.status === 200 ? deadLetterFacet(disco.json) : null;
46
+ if (facet === null) return { reason: 'the host does not advertise triggerBridge.deadLetter' };
47
+ if (!(await inboundSigningAdvertised())) return { reason: 'triggerBridge.deadLetter is advertised without inboundSigning, so the suite cannot cause a dead letter unaided' };
48
+ return { facet };
49
+ }
50
+
51
+ /** A `required` subscription with `n` refused posts dead-lettered on it. */
52
+ async function withRefused(id: string, n: number): Promise<SignedWebhookSubscription> {
53
+ const sub = await registerSignedWebhook('required');
54
+ if ('reason' in sub) throw new Error(req(id, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
55
+ for (let i = 0; i < n; i++) {
56
+ const bad = await signedIngest(sub, JSON.stringify({ conformance: 'dead-letter-read', i }), { signature: BAD_SIGNATURE });
57
+ expect(bad.status, req(id, 'trigger-bridge.md §F.6', `a bad signature under required verification MUST answer 401 (got ${bad.status})`)).toBe(401);
58
+ }
59
+ return sub;
60
+ }
61
+
62
+ function deliveriesOf(json: unknown): unknown[] {
63
+ const d = (json as { deliveries?: unknown } | undefined)?.deliveries;
64
+ return Array.isArray(d) ? d : [];
65
+ }
66
+
67
+ describe('trigger-dead-letter-read (RFC 0232 §B)', () => {
68
+ it('clamps limit and continues a short page with nextCursor', async () => {
69
+ const g = await gate();
70
+ if ('reason' in g) return softSkip('inapplicable', g.reason);
71
+ const sub = await withRefused(R_PAGING, 2);
72
+ const first = await readDeadLetters(sub.subscriptionId, { limit: 1 });
73
+ expect(first.status, req(R_PAGING, 'trigger-bridge.md §C', `a host advertising triggerBridge.deadLetter MUST serve the read (got ${first.status})`)).toBe(200);
74
+ expect(deliveriesOf(first.json).length, req(R_PAGING, 'trigger-bridge.md §C', 'a page MUST NOT exceed limit')).toBe(1);
75
+ const cursor = (first.json as { nextCursor?: unknown } | undefined)?.nextCursor;
76
+ expect(typeof cursor === 'string' && cursor.length > 0, req(R_PAGING, 'trigger-bridge.md §C', 'with two dead letters and limit 1, the page MUST carry nextCursor')).toBe(true);
77
+ const second = await readDeadLetters(sub.subscriptionId, { limit: 1, cursor: cursor as string });
78
+ expect(second.status, req(R_PAGING, 'trigger-bridge.md §C', `the page's own cursor MUST be accepted (got ${second.status})`)).toBe(200);
79
+ expect(deliveriesOf(second.json).length, req(R_PAGING, 'trigger-bridge.md §C', 'the next page MUST carry the second dead letter')).toBe(1);
80
+ const big = await readDeadLetters(sub.subscriptionId, { limit: g.facet.maxPageSize + 1 });
81
+ expect(big.status, req(R_PAGING, 'trigger-bridge.md §C', `a limit above maxPageSize MUST be clamped, not refused (got ${big.status})`)).toBe(200);
82
+ });
83
+
84
+ it('refuses a cursor minted for another subscription', async () => {
85
+ const g = await gate();
86
+ if ('reason' in g) return softSkip('inapplicable', g.reason);
87
+ const a = await withRefused(R_CURSOR, 2);
88
+ const page = await readDeadLetters(a.subscriptionId, { limit: 1 });
89
+ const cursor = (page.json as { nextCursor?: unknown } | undefined)?.nextCursor;
90
+ expect(typeof cursor === 'string' && cursor.length > 0, req(R_CURSOR, 'trigger-bridge.md §C', `with two dead letters and limit 1, the page MUST carry nextCursor (read answered ${page.status})`)).toBe(true);
91
+ const b = await withRefused(R_CURSOR, 1);
92
+ const cross = await readDeadLetters(b.subscriptionId, { cursor: cursor as string });
93
+ expect(cross.status, req(R_CURSOR, 'trigger-bridge.md §C', `a cursor minted for another subscription MUST be refused 400 (got ${cross.status})`)).toBe(400);
94
+ expect(readErrorCode(cross.json), req(R_CURSOR, 'trigger-bridge.md §C', 'the refusal MUST carry validation_error')).toBe('validation_error');
95
+ });
96
+
97
+ it("answers another tenant's read of a subscription 404, as for an id never minted", async () => {
98
+ const g = await gate();
99
+ if ('reason' in g) return softSkip('inapplicable', g.reason);
100
+ const other = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
101
+ if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant read cannot run');
102
+ if (other.trim() === loadEnv().apiKey) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the second credential must resolve to a DIFFERENT tenant, or the leg measures nothing');
103
+ const sub = await withRefused(R_TENANT, 1);
104
+ const path = (id: string): string => `/v1/trigger-subscriptions/${encodeURIComponent(id)}/dead-letters`;
105
+ const foreign = await driver.get(path(sub.subscriptionId), { headers: { Authorization: `Bearer ${other}` } });
106
+ expect(foreign.status, req(R_TENANT, 'trigger-bridge.md §C', `another tenant's read MUST answer 404 (got ${foreign.status})`)).toBe(404);
107
+ expect(JSON.stringify(foreign.json ?? null).includes('verification_failed'), req(R_TENANT, 'trigger-bridge.md §C', 'a foreign read MUST NOT disclose a record')).toBe(false);
108
+ const unknown = await driver.get(path(`${sub.subscriptionId}-never-minted`));
109
+ expect(unknown.status, req(R_TENANT, 'trigger-bridge.md §C', `an id the host never minted MUST answer 404 (got ${unknown.status})`)).toBe(404);
110
+ expect(readErrorCode(foreign.json), req(R_TENANT, 'trigger-bridge.md §C', 'a foreign id MUST be answered exactly as an unknown one')).toBe(readErrorCode(unknown.json));
111
+ });
112
+ });
@@ -166,9 +166,18 @@ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (ca
166
166
  },
167
167
  ];
168
168
 
169
+ // Post every source first, so a host that serves neither is recorded before any assertion.
170
+ const served: { body: Record<string, unknown>; res: Awaited<ReturnType<typeof driver.post>> }[] = [];
169
171
  for (const body of bodies) {
170
172
  const res = await driver.post('/v1/host/sample/trigger-bridge/ingest', body);
171
- if ((res.status === 404 || res.status === 405 || res.status === 400 || res.status === 422) && !advertisesNew) continue; // pre-0127 host — soft-skip this source
173
+ if ((res.status === 404 || res.status === 405 || res.status === 400 || res.status === 422) && !advertisesNew) continue; // pre-0127 host: this source is not served
174
+ served.push({ body, res });
175
+ }
176
+ // Until 2.45.9 a host that advertises neither source and refuses both passed this leg with zero
177
+ // assertions and no reason, which RFC 0148 §A records as `blocked` and which denies a major-1 bundle.
178
+ if (served.length === 0) return softSkip('inapplicable', 'triggerBridge.ingestion.externalSources lists neither stream nor change, and the host refused both (a pre-RFC 0127 host)');
179
+
180
+ for (const { body, res } of served) {
172
181
  expect(
173
182
  res.status < 400,
174
183
  req('openwop.it.trigger-stream-cdc-sources.a-stream-and-a-change-event-each-ingest-to-a-run-with-a-schema-valid-envelope-an',
@@ -220,7 +229,7 @@ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (ca
220
229
  const dedupKey = freshStreamDedupKey();
221
230
  const first = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'stream' });
222
231
  if (first === null) return softSkip('blocked', 'precondition not met — `first === null` returned early (delivery seam unwired — soft-skip) (seam, prior step, or fixture unavailable)'); // delivery seam unwired — soft-skip
223
- if (first.outcome === undefined && !external.includes('stream')) return softSkip('blocked', 'precondition not met — `first.outcome === undefined && !external.includes(\'stream\')` returned early (pre-0127 host — soft-skip) (seam, prior step, or fixture unavailable)'); // pre-0127 host — soft-skip
232
+ if (first.outcome === undefined && !external.includes('stream')) return softSkip('inapplicable', 'triggerBridge.ingestion.externalSources does not list stream, and the delivery seam reported no outcome for a stream source (a pre-RFC 0127 host)');
224
233
  expect(
225
234
  first.deliveredCount === 1 || first.outcome === 'delivered',
226
235
  req('openwop.it.trigger-stream-cdc-sources.stream-dedup-the-same-broker-coordinates-delivered-twice-are-effectively-once-c',