@openwop/openwop-conformance 2.45.11 → 2.45.13

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,29 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.13] — 2026-10-04 — RFC 0230, 0232 and 0233 Accepted
4
+
5
+ - **The 2.45.13 cycle opens.** Closing RFC 0232 G3 and RFC 0233 G3 changes the packed `spec/v1/gaps.json`. No scenario changes.
6
+
7
+ ## [2.45.12] — 2026-10-03 — RFC 0233: the provider-identity rules have a normative witness
8
+
9
+ - **`v2-provider-conflict` gains a normative-surface path (RFC 0233).** Where the host advertises `connections.providerRead` it reads `GET /connection-providers` and `GET /connection-providers/{providerId}` (`?pack=` for the qualified form). The conflict is caused operator-side by installing the `connection-pack-acme-widgets` / `-rival` fixture pair, and the registry describes the outcome. The seam path still runs where the seams are served.
10
+ - `openwop.requirement.0177.provider-conflict.fail-closed`: `acme-widgets` is held once by the first pack, and a `connection_provider_conflict` refusal names that holder.
11
+ - `openwop.requirement.0177.provider-conflict.qualified-form`: `?pack=<first>` resolves to it; `?pack=<rival>` answers `404 connection_provider_unresolved`; the bare id resolves to the first pack.
12
+ - New `openwop.requirement.0233.provider-registry.unique`: a schema-valid, closed registry with each bare id once, on any host with the facet.
13
+ - **Disposition change.** A host advertising `packsSupported` with neither `providerRead` nor the seams now records `inapplicable`, with the reason, where it recorded `blocked`. That case was a seam-only MUST, which `conformance.md` §Witness class forbids; RFC 0233 mints the path, and `witnessable-gated` means observed when the gating capability is advertised. Without the fixture installed, the two fixture legs are `inapplicable` and uniqueness still runs.
14
+ - **New fixture** `connection-pack-acme-widgets-rival` (catalogued in `fixtures.md`).
15
+ - **Sabotage.** `lib/provider-registry-witness.test.ts` runs 11 cases against `lib/provider-registry-double.ts`, each defect failing exactly its leg. The scenario file was also run against a standalone double: conforming passes in strict mode; missing refusal, rival resolving, duplicate id and unserved read each fail; not-installed and no-facet are `inapplicable` in strict mode with nothing failing.
16
+ - **Host impact.** openwop-app (`connections.packsSupported` at v2, no seams in production): the three `v2-provider-conflict` rows move from `blocked` to `inapplicable` until it advertises `providerRead`; with the facet and the fixture pair they become `executed-pass`. No other host advertises `connections` at v2.
17
+ - **Fix: `v2-advertised-path-space-served` no longer fails a host that has retired `/v1`.** The leg read any non-`404` answer under `/v1` as "served", so a retired host answering `410 Gone` on every `/v1` path (openwop-app's ADR 0669) failed for surfaces it serves at neither address. `410` now reads as not served (RFC 9110 §15.5.11), and the leg is `inapplicable` when `protocolVersions[]` has no `1.x` member, since `versioning.md` §1.2 is an overlap rule.
18
+ - **Found by** openwop-app's v1-cut rehearsal (suite 2.45.11, `OPENWOP_V1_RETIRED=true`): "/audit/verify (/v1 → 410, major 2 → 404); /host/events (/v1 → 410, major 2 → 404)".
19
+ - **Proof** against a standalone double in strict mode: a retired host and an overlap host answering `410` on `/v1` are `inapplicable`; an overlap host serving `/v1/agents` but not `/agents` still fails; a conforming overlap host passes.
20
+ - **The 2.45.12 cycle opened** with RFC 0233's filing.
21
+ - **Two RFC 0230 rows that had no test now have one, at both majors** (`trigger-bridge-delivery` and `v2-trigger-bridge-delivery`; gated like the other signed legs on `inboundSigning`, `familyAdvertised` at major 2).
22
+ - `openwop.requirement.0230.stale-timestamp-refused` (§C): under `required`, a post correctly signed over a `webhook-timestamp` 600 s in the past, and again 600 s in the future, must answer `401 signature_invalid` and start no run. The subscription must stay `active`, and a current signed post must then answer `202`.
23
+ - `openwop.requirement.0230.signing-secret-once` (§B, SR-1): a re-read of a subscription just registered carries no `signingSecret` key at any depth, not the secret's value, and no `whsec_` string. `inapplicable` where the host serves no re-read (`404`/`405`).
24
+ - **Sabotage.** The scratch double enforces the 300 s window and gains two defects, `skew-accepted` and `reread-leaks-secret`; `lib/trigger-delivery-witness.test.ts` runs 18 cases, and each defect fails only the legs that own the rule. Both scenario files were run against a standalone double (major 1 behind openwop-app's captured 451a665e8 discovery document): conforming, both new rows pass; each defect fails only its own row.
25
+ - **Host impact.** openwop-app at major 1 and 2 serves both rules on the signed path and is expected to pass; no host gains a `blocked` row. A host without `inboundSigning` records `inapplicable`.
26
+
3
27
  ## [2.45.11] — 2026-10-03 — the trigger bridge is witnessed at major 2
4
28
 
5
29
  - **New scenarios `v2-trigger-bridge-delivery` and `v2-trigger-dead-letter-read`** (major 2). They port the trigger delivery legs and RFC 0232's read rules to the v2 surface (`/trigger-subscriptions/…`, bound ids `~`-projected). They share their requirement ids with the v1 twins, as the v2 secrets witness does. Each runs only the normative-surface path: there is no trigger seam at major 2.
@@ -258,7 +258,9 @@ export function isMemory(c) {
258
258
  /**
259
259
  * `openwop-trigger-bridge` predicate (RFC 0083, widened by RFC 0099). Host
260
260
  * composes the durable inbound-work contract: advertises the `triggerBridge`,
261
- * has a `deadLetter` sink for exhausted deliveries, and has at least one
261
+ * has a `deadLetter` run sink (RFC 0053: where a trigger-started run that fails
262
+ * terminally lands; a dead-lettered DELIVERY starts no run and is read through
263
+ * `triggerBridge.deadLetter`, RFC 0232), and has at least one
262
264
  * durable inbound source (queue bus, durable webhooks, scheduling, OR — per
263
265
  * RFC 0099 — externally-ingested `email`/`form` via
264
266
  * `triggerBridge.ingestion.externalSources[]`). Capability families are
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.11",
4
- "stampSha256": "8610a1da9880acfbffcdfc96f015e5a0f7bf7bcd882819a614af305b6cfdb35b"
3
+ "version": "2.45.13",
4
+ "stampSha256": "91258015cffda15a23751823f2b90897caf00c0e9a97ad43da64e270c26d3a3d"
5
5
  }
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "core.openwop.connections.acme-widgets-rival",
3
+ "version": "1.0.0",
4
+ "kind": "connection",
5
+ "engines": {
6
+ "openwop": ">=2.0.0 <3.0.0"
7
+ },
8
+ "provider": {
9
+ "id": "acme-widgets",
10
+ "displayName": "Acme Widgets",
11
+ "category": "dev",
12
+ "auth": {
13
+ "kind": "oauth2",
14
+ "authFlow": "pkce",
15
+ "scopeModel": "groups",
16
+ "endpoints": {
17
+ "authorize": "https://auth.acme-widgets.example.com/oauth/authorize",
18
+ "token": "https://auth.acme-widgets.example.com/oauth/token"
19
+ },
20
+ "scopes": {
21
+ "read": [
22
+ {
23
+ "key": "widget.read",
24
+ "label": "Read widgets",
25
+ "scopes": [
26
+ "widget:read"
27
+ ]
28
+ }
29
+ ],
30
+ "write": [
31
+ {
32
+ "key": "widget.write",
33
+ "label": "Write widgets",
34
+ "scopes": [
35
+ "widget:write"
36
+ ]
37
+ }
38
+ ]
39
+ }
40
+ },
41
+ "reach": {
42
+ "mcp": {
43
+ "server": {
44
+ "url": "https://mcp.acme-widgets.example.com/mcp/",
45
+ "transport": "http"
46
+ }
47
+ }
48
+ },
49
+ "consumerNodes": [
50
+ "core.openwop.mcp.invoke-tool",
51
+ "core.openwop.mcp.read-resource"
52
+ ],
53
+ "docsUrl": "https://docs.acme-widgets.example.com/oauth"
54
+ }
55
+ }
@@ -2,7 +2,9 @@
2
2
  "name": "core.openwop.connections.acme-widgets",
3
3
  "version": "1.0.0",
4
4
  "kind": "connection",
5
- "engines": { "openwop": ">=1.0.0" },
5
+ "engines": {
6
+ "openwop": ">=2.0.0 <3.0.0"
7
+ },
6
8
  "provider": {
7
9
  "id": "acme-widgets",
8
10
  "displayName": "Acme Widgets",
@@ -17,15 +19,37 @@
17
19
  },
18
20
  "scopes": {
19
21
  "read": [
20
- { "key": "widget.read", "label": "Read widgets", "scopes": ["widget:read"] }
22
+ {
23
+ "key": "widget.read",
24
+ "label": "Read widgets",
25
+ "scopes": [
26
+ "widget:read"
27
+ ]
28
+ }
21
29
  ],
22
30
  "write": [
23
- { "key": "widget.write", "label": "Write widgets", "scopes": ["widget:write"] }
31
+ {
32
+ "key": "widget.write",
33
+ "label": "Write widgets",
34
+ "scopes": [
35
+ "widget:write"
36
+ ]
37
+ }
24
38
  ]
25
39
  }
26
40
  },
27
- "reach": { "mcp": { "server": { "url": "https://mcp.acme-widgets.example.com/mcp/", "transport": "http" } } },
28
- "consumerNodes": ["core.openwop.mcp.invoke-tool", "core.openwop.mcp.read-resource"],
41
+ "reach": {
42
+ "mcp": {
43
+ "server": {
44
+ "url": "https://mcp.acme-widgets.example.com/mcp/",
45
+ "transport": "http"
46
+ }
47
+ }
48
+ },
49
+ "consumerNodes": [
50
+ "core.openwop.mcp.invoke-tool",
51
+ "core.openwop.mcp.read-resource"
52
+ ],
29
53
  "docsUrl": "https://docs.acme-widgets.example.com/oauth"
30
54
  }
31
55
  }
package/fixtures.md CHANGED
@@ -823,6 +823,7 @@ The `fixtures/connection-packs/` sub-directory holds canonical connection-pack m
823
823
  | ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
824
824
  | `connection-pack-github` | `github` | Canonical positive manifest (oauth2 + pkce, read/write scope groups, MCP reach, the exempt `provider.auth.endpoints.token` endpoint URL). Drives manifest-valid, no-credential-material, reach-exclusive, provider-resolution, and write-reconsent scenarios. **v1 only for the resolution leg:** v1 settles a collision with a built-in by version precedence (`spec/v1/connection-packs.md:89`), so a host shipping a built-in `github` still installs this pack. |
825
825
  | `connection-pack-acme-widgets` | `acme-widgets` | Same shape as the `github` pack over a **deliberately fictional** provider. Drives `v2-provider-conflict` (RFC 0177 §D.1). The id is fictional so that no host ships it built-in: at v2 the later registration of a bare id MUST be refused, so a real-world id made the fixture uninstallable on any host that shipped it — and the qualified-form leg recorded `blocked` on a host whose only fault was obeying §D.1. Suite 2.0.4. |
826
+ | `connection-pack-acme-widgets-rival` | `acme-widgets` | RFC 0233 §D: a second well-formed pack (`core.openwop.connections.acme-widgets-rival`) claiming the same fictional bare id. An operator who installs `connection-pack-acme-widgets` also attempts this one after it, so the host's registry shows a `connection_provider_conflict` refusal that `v2-provider-conflict` reads through `GET /connection-providers` without a seam. Never advertised in discovery `fixtures` (those name workflows). Both acme-widgets packs pin `engines.openwop: ">=2.0.0 <3.0.0"`: a range that admits major 1 would get v1 version precedence on a dual-major host instead of the v2 refusal (openwop-app's review, 2026-10-03). |
826
827
  | `connection-pack-apihosts-valid` | `meta-ads` | Canonical positive `openapi`-reach manifest declaring a `provider.apiHosts` credential-egress allow-list (`["facebook.com"]`) — RFC 0120. Drives `connection-pack-apihosts` (the `apiHosts` schema + conditional-MUST + egress allow-list scenario). Negatives (IP/wildcard/port/single-label/uppercase entries, openapi-without-apiHosts) are inline mutations per suite convention. |
827
828
 
828
829
  Negative manifests (credential material, mixed kinds, dual reach) are inline test data in the scenario files per suite convention — a deliberately-invalid fixture file would fail the automatic `fixtures-valid.test.ts` sweep.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.11",
3
+ "version": "2.45.13",
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.11"
59
+ "@openwop/spec-artifacts": "2.45.13"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -3,10 +3,10 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 669,
6
- "tests": 2557,
7
- "withStableId": 2557,
6
+ "tests": 2562,
7
+ "withStableId": 2562,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2434
9
+ "explicitIds": 2439
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": 171,
26314
+ "line": 191,
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": 195,
26350
+ "line": 215,
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": [
@@ -26377,10 +26377,80 @@
26377
26377
  }
26378
26378
  ]
26379
26379
  },
26380
+ {
26381
+ "id": "openwop.it.trigger-bridge-delivery.refuses-a-webhook-timestamp-more-than-300-s-off-without-starting-a-run",
26382
+ "file": "trigger-bridge-delivery.test.ts",
26383
+ "line": 238,
26384
+ "title": "refuses a webhook-timestamp more than 300 s off without starting a run",
26385
+ "explicitId": "openwop.requirement.0230.stale-timestamp-refused",
26386
+ "citations": [
26387
+ {
26388
+ "section": "trigger-bridge.md §F.6",
26389
+ "requirement": null,
26390
+ "interpolated": true
26391
+ },
26392
+ {
26393
+ "section": "trigger-bridge.md §F.6",
26394
+ "requirement": null,
26395
+ "interpolated": true
26396
+ },
26397
+ {
26398
+ "section": "trigger-bridge.md §F.6",
26399
+ "requirement": null,
26400
+ "interpolated": true
26401
+ },
26402
+ {
26403
+ "section": "trigger-bridge.md §F.6",
26404
+ "requirement": null,
26405
+ "interpolated": true
26406
+ },
26407
+ {
26408
+ "section": "trigger-bridge.md §F.6 (RFC 0230 §C.1)",
26409
+ "requirement": null,
26410
+ "interpolated": true
26411
+ },
26412
+ {
26413
+ "section": "trigger-bridge.md §F.6",
26414
+ "requirement": null,
26415
+ "interpolated": true
26416
+ }
26417
+ ]
26418
+ },
26419
+ {
26420
+ "id": "openwop.it.trigger-bridge-delivery.returns-the-signing-secret-once-a-re-read-carries-no-whsec-value",
26421
+ "file": "trigger-bridge-delivery.test.ts",
26422
+ "line": 260,
26423
+ "title": "returns the signing secret once: a re-read carries no whsec_ value",
26424
+ "explicitId": "openwop.requirement.0230.signing-secret-once",
26425
+ "citations": [
26426
+ {
26427
+ "section": "trigger-bridge.md §F.6",
26428
+ "requirement": null,
26429
+ "interpolated": true
26430
+ },
26431
+ {
26432
+ "section": "trigger-bridge.md §F.6",
26433
+ "requirement": null,
26434
+ "interpolated": true
26435
+ },
26436
+ {
26437
+ "section": "trigger-bridge.md §F.6 (SR-1)",
26438
+ "requirement": "a re-read MUST NOT carry signingSecret"
26439
+ },
26440
+ {
26441
+ "section": "trigger-bridge.md §F.6 (SR-1)",
26442
+ "requirement": "a re-read MUST NOT carry the secret's value"
26443
+ },
26444
+ {
26445
+ "section": "trigger-bridge.md §F.6 (SR-1)",
26446
+ "requirement": "a re-read MUST NOT carry any whsec_ secret"
26447
+ }
26448
+ ]
26449
+ },
26380
26450
  {
26381
26451
  "id": "openwop.it.trigger-bridge-delivery.links-delivery-run-causation-on-run-started",
26382
26452
  "file": "trigger-bridge-delivery.test.ts",
26383
- "line": 218,
26453
+ "line": 277,
26384
26454
  "title": "links delivery→run causation on run.started",
26385
26455
  "explicitId": "openwop.requirement.0083.trigger-delivery.causation",
26386
26456
  "citations": [
@@ -26416,7 +26486,7 @@
26416
26486
  {
26417
26487
  "id": "openwop.it.trigger-bridge-delivery.keeps-a-dead-lettered-run-less-attempt-content-free",
26418
26488
  "file": "trigger-bridge-delivery.test.ts",
26419
- "line": 247,
26489
+ "line": 306,
26420
26490
  "title": "keeps a dead-lettered run-less attempt content-free",
26421
26491
  "explicitId": "openwop.requirement.0083.trigger-delivery.runless-attempt-content-free",
26422
26492
  "citations": [
@@ -26440,7 +26510,7 @@
26440
26510
  {
26441
26511
  "id": "openwop.it.trigger-bridge-delivery.keeps-a-run-less-subscription-state-change-content-free",
26442
26512
  "file": "trigger-bridge-delivery.test.ts",
26443
- "line": 278,
26513
+ "line": 337,
26444
26514
  "title": "keeps a run-less subscription state change content-free",
26445
26515
  "explicitId": "openwop.requirement.0083.trigger-delivery.runless-state-change-content-free",
26446
26516
  "citations": [
@@ -33471,40 +33541,42 @@
33471
33541
  {
33472
33542
  "id": "openwop.it.v2-provider-conflict.two-definitions-of-one-bare-provider-id-fail-closed-the-later-registration-is-re",
33473
33543
  "file": "v2-provider-conflict.test.ts",
33474
- "line": 99,
33544
+ "line": 130,
33475
33545
  "title": "two definitions of one bare provider id fail closed: the later registration is refused with connection_provider_conflict",
33476
33546
  "explicitId": "openwop.requirement.0177.provider-conflict.fail-closed",
33477
33547
  "citations": [
33478
33548
  {
33479
- "section": "connection-packs.md §\"Provider identity\" (RFC 0177 §D.1)",
33549
+ "section": null,
33480
33550
  "requirement": null,
33481
33551
  "interpolated": true
33482
- },
33483
- {
33484
- "section": "connection-packs.md §\"Provider identity\" (RFC 0177 §D.1)",
33485
- "requirement": "the later registration of a bare provider id MUST NOT install (no version precedence)"
33486
- },
33487
- {
33488
- "section": "connection-packs.md §\"Provider identity\" (RFC 0177 §D.1)",
33489
- "requirement": "the later registration MUST be refused with connection_provider_conflict"
33490
33552
  }
33491
33553
  ]
33492
33554
  },
33493
33555
  {
33494
33556
  "id": "openwop.it.v2-provider-conflict.the-qualified-form-packname-acme-widgets-resolves-to-the-named-pack-s-definition",
33495
33557
  "file": "v2-provider-conflict.test.ts",
33496
- "line": 120,
33558
+ "line": 140,
33497
33559
  "title": "the qualified form <packName>#acme-widgets resolves to the named pack's definition",
33498
33560
  "explicitId": "openwop.requirement.0177.provider-conflict.qualified-form",
33499
33561
  "citations": [
33500
33562
  {
33501
- "section": "connection-packs.md §\"The qualified form\" (RFC 0177 §D.1)",
33563
+ "section": null,
33502
33564
  "requirement": null,
33503
33565
  "interpolated": true
33504
- },
33566
+ }
33567
+ ]
33568
+ },
33569
+ {
33570
+ "id": "openwop.it.v2-provider-conflict.the-provider-registry-lists-each-bare-id-once-and-carries-no-endpoint-or-credent",
33571
+ "file": "v2-provider-conflict.test.ts",
33572
+ "line": 150,
33573
+ "title": "the provider registry lists each bare id once and carries no endpoint or credential (RFC 0233 §B)",
33574
+ "explicitId": "openwop.requirement.0233.provider-registry.unique",
33575
+ "citations": [
33505
33576
  {
33506
- "section": "connection-packs.md §\"The qualified form\" (RFC 0177 §D.1)",
33507
- "requirement": "a qualified reference resolves only to the named pack's definition (source: pack)"
33577
+ "section": null,
33578
+ "requirement": null,
33579
+ "interpolated": true
33508
33580
  }
33509
33581
  ]
33510
33582
  },
@@ -35270,7 +35342,7 @@
35270
35342
  {
35271
35343
  "id": "openwop.it.v2-trigger-bridge-delivery.de-dups-a-repeated-webhook-id-effectively-once",
35272
35344
  "file": "v2-trigger-bridge-delivery.test.ts",
35273
- "line": 59,
35345
+ "line": 68,
35274
35346
  "title": "de-dups a repeated webhook-id: effectively-once",
35275
35347
  "explicitId": "openwop.requirement.0083.trigger-delivery.dedup",
35276
35348
  "citations": [
@@ -35284,7 +35356,7 @@
35284
35356
  {
35285
35357
  "id": "openwop.it.v2-trigger-bridge-delivery.a-refused-post-starts-no-run-and-leaves-the-subscription-active",
35286
35358
  "file": "v2-trigger-bridge-delivery.test.ts",
35287
- "line": 69,
35359
+ "line": 78,
35288
35360
  "title": "a refused post starts no run and leaves the subscription active",
35289
35361
  "explicitId": "openwop.requirement.0083.trigger-delivery.dead-letter",
35290
35362
  "citations": [
@@ -35295,10 +35367,38 @@
35295
35367
  }
35296
35368
  ]
35297
35369
  },
35370
+ {
35371
+ "id": "openwop.it.v2-trigger-bridge-delivery.refuses-a-webhook-timestamp-more-than-300-s-off-without-starting-a-run",
35372
+ "file": "v2-trigger-bridge-delivery.test.ts",
35373
+ "line": 88,
35374
+ "title": "refuses a webhook-timestamp more than 300 s off without starting a run",
35375
+ "explicitId": "openwop.requirement.0230.stale-timestamp-refused",
35376
+ "citations": [
35377
+ {
35378
+ "section": null,
35379
+ "requirement": null,
35380
+ "interpolated": true
35381
+ }
35382
+ ]
35383
+ },
35384
+ {
35385
+ "id": "openwop.it.v2-trigger-bridge-delivery.returns-the-signing-secret-once-a-re-read-carries-no-whsec-value",
35386
+ "file": "v2-trigger-bridge-delivery.test.ts",
35387
+ "line": 98,
35388
+ "title": "returns the signing secret once: a re-read carries no whsec_ value",
35389
+ "explicitId": "openwop.requirement.0230.signing-secret-once",
35390
+ "citations": [
35391
+ {
35392
+ "section": null,
35393
+ "requirement": null,
35394
+ "interpolated": true
35395
+ }
35396
+ ]
35397
+ },
35298
35398
  {
35299
35399
  "id": "openwop.it.v2-trigger-bridge-delivery.links-delivery-to-run-run-started-carries-the-delivery-as-causationid",
35300
35400
  "file": "v2-trigger-bridge-delivery.test.ts",
35301
- "line": 79,
35401
+ "line": 108,
35302
35402
  "title": "links delivery to run: run.started carries the delivery as causationId",
35303
35403
  "explicitId": "openwop.requirement.0083.trigger-delivery.causation",
35304
35404
  "citations": [
@@ -35312,7 +35412,7 @@
35312
35412
  {
35313
35413
  "id": "openwop.it.v2-trigger-bridge-delivery.keeps-a-dead-lettered-run-less-attempt-content-free",
35314
35414
  "file": "v2-trigger-bridge-delivery.test.ts",
35315
- "line": 89,
35415
+ "line": 118,
35316
35416
  "title": "keeps a dead-lettered run-less attempt content-free",
35317
35417
  "explicitId": "openwop.requirement.0083.trigger-delivery.runless-attempt-content-free",
35318
35418
  "citations": [
@@ -35326,7 +35426,7 @@
35326
35426
  {
35327
35427
  "id": "openwop.it.v2-trigger-bridge-delivery.keeps-a-run-less-subscription-state-change-content-free",
35328
35428
  "file": "v2-trigger-bridge-delivery.test.ts",
35329
- "line": 99,
35429
+ "line": 128,
35330
35430
  "title": "keeps a run-less subscription state change content-free",
35331
35431
  "explicitId": "openwop.requirement.0083.trigger-delivery.runless-state-change-content-free",
35332
35432
  "citations": [
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.45.11",
5
- "corpusTag": "v2.45.11",
4
+ "version": "2.45.13",
5
+ "corpusTag": "v2.45.13",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "4d81cca7f95187c62e05ffe9ad738134c5253334f794da200182b0d6f82123a5",
13
- "api/v2/asyncapi.yaml": "6cc853cc6fd98213b9d3db9fa23f56ae40d974c8730a9a5288cf7187eb402d94",
14
- "api/v2/openapi.yaml": "7bf3b02498d378ae3fc242c79422e8843820b4a519ba8119aa7d7fcaaaf6dcbf",
12
+ "api/seams-v2.yaml": "e04f33a55093271aa1d8c1de7e9415c234e0422e1d51c78eaf89e4f45fface82",
13
+ "api/v2/asyncapi.yaml": "ae7e64de2b5bca421d98e244ddf4f25a20e22555945c39b2095661d074c994cb",
14
+ "api/v2/openapi.yaml": "eb07406e2e79a0f6096fbc50a509028568bff40edf11f6d736d97711b20a3db7",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "bd1c00fae7b6fda506a75aff841bbdc438d4ce092642a72041203bee999b88de",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -117,7 +117,7 @@
117
117
  "schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
118
118
  "schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
119
119
  "schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
120
- "schemas/v2/capabilities.schema.json": "4503d383c2b5efff532a79dabc79d17b5f6494c88d5b385bd3ca5f1a4e3f7647",
120
+ "schemas/v2/capabilities.schema.json": "c7a01b2f569139f99ba5e1a5329a42556fbd82bc743f8fd96963304395fc9ef0",
121
121
  "schemas/v2/certification-bundle.schema.json": "82ce31a1ed6c7e21b0ba88ff62a7b8b768fc3b176b1d06fce023203830b94725",
122
122
  "schemas/v2/channel-presence-payload.schema.json": "4eeb224910e6f7eee3ab5e7c3899559c25c83a26d0022e0ad195c4672611977a",
123
123
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -127,6 +127,7 @@
127
127
  "schemas/v2/compensation-projection.schema.json": "f46977c7076347b5473faa8c4bbe73d8321c33b33c98fff2f26ee7cbada4ed98",
128
128
  "schemas/v2/configurable.schema.json": "33a6884047e2490f723733bf8b74d3b9164e00374cfea6dc3eac5a70dc368861",
129
129
  "schemas/v2/connection-pack-manifest.schema.json": "8d019de6bd0c5b14ea3b227580734ec792521493b396a850f7f9217aa7a6a547",
130
+ "schemas/v2/connection-provider-registry.schema.json": "5b404da328a95c45808929e233ee766ebd635cf0df6f5d15d4c19354dfd7e75d",
130
131
  "schemas/v2/conversation-event.schema.json": "9ad4c57285818edbf38b17816de0c4162f777c835f59e68702d0367a81cbccf6",
131
132
  "schemas/v2/conversation-turn.schema.json": "75bb6a4a03072a958feb5b34e2a1f40471e9e3996a81fa430b65717f0773c9a4",
132
133
  "schemas/v2/credential-provenance.schema.json": "f86b09e94b1c524dec7d1c6353965665ae8891e9517faef6e742d639c5578c99",
@@ -206,13 +207,13 @@
206
207
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
207
208
  "spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
208
209
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
209
- "spec/v1/core-standard-manifest.json": "9cf3fe4d67b3e110f4666fe8d4c3a22e715a7b4ef88afb99470031303e364b8b",
210
+ "spec/v1/core-standard-manifest.json": "0e4fef9b14346bff71b5141679bc5a8b5325a0025c49155cdf17fa985c6d9ecf",
210
211
  "spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
211
212
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
212
213
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
213
214
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
214
215
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
215
- "spec/v1/gaps.json": "7eab313ec80c40285e3290113f5d3fea90ea6f8bc385ef6fd15d415f52540035",
216
+ "spec/v1/gaps.json": "54fd7e55136294a2f852f95c87209bb991947e5075e56ffa23679dcf2f5c50ff",
216
217
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
217
218
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
218
219
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -222,13 +223,13 @@
222
223
  "spec/v2/core/artifact-type-packs.md": "f734740cc3963f86c9f273a7dace26291bba2855161ddd3540618131a2c8d13e",
223
224
  "spec/v2/core/capabilities.md": "04feda1747ca8f97443176cbf82d7d69891322a7bbf9a9bed88f519b8fb574fe",
224
225
  "spec/v2/core/conformance.md": "3ca291e63bc70a6ef829ae8759ad0ded3741ea7aa507bbbdffc57de443ab2ae2",
225
- "spec/v2/core/connection-packs.md": "1ee129d792c537bfe82393ccb92555d8e6ddc342b1aa781a13f101b27ec38171",
226
+ "spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
226
227
  "spec/v2/core/conversation.md": "354fa57c73d11062e523a44aa75a6353c5c81aefaf306d30c42416a228c82c86",
227
228
  "spec/v2/core/errors.md": "91f46acc9c9a5532a98cf62c69285d68575c100ff6064f0b1bf94731a087e34a",
228
229
  "spec/v2/core/events.md": "c1a20e3aa6736119ea07aa2cf66faa808a1874d2cde4933033b65506d1742883",
229
230
  "spec/v2/core/execution.md": "8a0c9db1bbfb97c008ab9328b7333c1cc3318196e78bfbefb49e781bdf4f9d44",
230
231
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
231
- "spec/v2/core/headers.md": "1e601c31d2c8bcb7628b7efe2b65fb53d9c79db2a60a4235c333cf85290bb0c0",
232
+ "spec/v2/core/headers.md": "60aee0768bb1b15cf8ff783170ba6541c8c106da417c8a066c8f85cb6a5b1ba1",
232
233
  "spec/v2/core/host-services.md": "73f26ea32891044131d63f9861d2cbddccb5d08bf76e29e47afef9bb5b646791",
233
234
  "spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
234
235
  "spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
@@ -251,7 +252,7 @@
251
252
  "spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
252
253
  "spec/v2/corrections.json": "3a8d93e33865b692d26a3b6bfd3819766e206b2e0c2a69669baa4bf833d05a49",
253
254
  "spec/v2/corrections.schema.json": "46bbffb954d13bd9995aebc912ceeb2544f7064331abf25fd6ab85c81a5cec94",
254
- "spec/v2/declaration.json": "8e5eab46ea448725b806a2395f80f5c36fa582a308f80eb09931a633a595eae1",
255
+ "spec/v2/declaration.json": "e963d3aa73892ee08281227c8a01aa6c06f1b825cdcb26aab029b1ab3f4155bf",
255
256
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
256
257
  "spec/v2/errors.json": "83f048b0a06952b4af6f125fb66c4212acab51056b411390f1010acd6ab9cdc0",
257
258
  "spec/v2/errors.schema.json": "47404ad53e6147cf9017b813483712466573e419fb33e6c4b6c12d3e7deba1bc",
@@ -281,6 +282,7 @@
281
282
  "spec/v2/facets/aiProviders.schema.json": "4eb911c2403b8f618325194855a1326680fbda2549dfbfe86a70dd2743f8fb12",
282
283
  "spec/v2/facets/auditLogIntegrity.schema.json": "9a7f81320c8f940c6f4129bc7dd895a4063231c5458a717a08c2cbf9bb886c46",
283
284
  "spec/v2/facets/auth.schema.json": "7d98bc81a0f7d307460d039eebfebd21388091bfe1d538762583940f94c0bd2a",
285
+ "spec/v2/facets/connections.schema.json": "f998bb206a5814183bb4720cc34fa43b7ab64d61e39a79437b009c589ce8b78e",
284
286
  "spec/v2/facets/envelopeStrictness.schema.json": "28be0a27f880bd45ec1222a16155f5e9167bbca57bf0322970fe1eb2654562aa",
285
287
  "spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
286
288
  "spec/v2/facets/interrupt.schema.json": "2110ef32c3f880bf706475ef4cf8ddf97ff06871b8e6003a5edbcf4439f1fb70",
@@ -297,17 +299,17 @@
297
299
  "spec/v2/generated/error-codes.md": "f171c0ca1be40221f9b4fbfcbce73c728c315a1e8feb8dd6bf738d6ca9e29526",
298
300
  "spec/v2/harness-trust-anchors.json": "6007b92eae16a070fbc3777424efb0abfe865fda89d096963109b96282b91205",
299
301
  "spec/v2/harness-trust-anchors.schema.json": "372b5d92337c893d3449eff3f6bfd009ac09fdac285296d8bdbe0a08fff9e34c",
300
- "spec/v2/id-field-bindings.json": "cc315f9a7aa5ec552860c1a790e105502fb737dc10b57aa5641bdd7405d6b638",
302
+ "spec/v2/id-field-bindings.json": "aa3e7c3590a490a77286e3ae60c1ab7407e7d627e05f981c3e08f221af4bd5dc",
301
303
  "spec/v2/interop-map.json": "02255dafe7eb55f61ca7f9ae7c21f079d1848a6756991343088975ac9d184b8d",
302
304
  "spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
303
305
  "spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
304
306
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
305
- "spec/v2/path-manifest.json": "36363105280139dad095dc4f64b5b272121e9759cdcf6de011358dd1c1a90540",
307
+ "spec/v2/path-manifest.json": "b84c7561f50c7df0cb61b202bd6595acad3ee7edca7a45f711949bea3c69bd27",
306
308
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
307
309
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
308
- "spec/v2/release.json": "1bb3db70c49debd541741939c8dcfdf31c0076a54c5fb23e7c6564f89b10505c",
310
+ "spec/v2/release.json": "38fa895f3288a59e95ec9d37b7d493c897bc7a174e9cde0f82d65d0d235ddff5",
309
311
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
310
- "spec/v2/surface-baseline.json": "30b8f92461b67517b7a20c7125ce0c6ef1af834e24c09a70be43cda6a25ccd99"
312
+ "spec/v2/surface-baseline.json": "c53a82ff3aa935978d29ed7832ce4df54792c12a81c987cd524a9f8e3f85b099"
311
313
  },
312
- "corpusCommit": "bc768ee37bcf463c49186a7fec7a5c5ad06f2fff"
314
+ "corpusCommit": "9419dea220a38f0c498b48e894d43992f34ce5d5"
313
315
  }
@@ -277,7 +277,9 @@ export function isMemory(c: DiscoveryPayload): boolean {
277
277
  /**
278
278
  * `openwop-trigger-bridge` predicate (RFC 0083, widened by RFC 0099). Host
279
279
  * composes the durable inbound-work contract: advertises the `triggerBridge`,
280
- * has a `deadLetter` sink for exhausted deliveries, and has at least one
280
+ * has a `deadLetter` run sink (RFC 0053: where a trigger-started run that fails
281
+ * terminally lands; a dead-lettered DELIVERY starts no run and is read through
282
+ * `triggerBridge.deadLetter`, RFC 0232), and has at least one
281
283
  * durable inbound source (queue bus, durable webhooks, scheduling, OR — per
282
284
  * RFC 0099 — externally-ingested `email`/`form` via
283
285
  * `triggerBridge.ingestion.externalSources[]`). Capability families are
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A scratch double for RFC 0233's provider registry reads. Each self-test turns
3
+ * on ONE defect to show `provider-registry-witness.ts` convicts it.
4
+ *
5
+ * A test double, not a host: it executes nothing, and nothing it does is
6
+ * evidence about any implementation.
7
+ */
8
+
9
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
10
+ import type { AddressInfo } from 'node:net';
11
+ import { FIRST_PACK, FIXTURE_ID, RIVAL_PACK } from './provider-registry-witness.js';
12
+
13
+ export type RegistryDefect =
14
+ | 'none' | 'not-installed' | 'duplicate-id' | 'no-refusal' | 'wrong-holder' | 'first-refused'
15
+ | 'rival-resolves' | 'qualified-wrong-pack' | 'leaks-endpoint' | 'not-served';
16
+
17
+ export class ProviderRegistryDouble {
18
+ defect: RegistryDefect = 'none';
19
+ url = '';
20
+ /** The v2 discovery document, so the scenario files themselves can run against the double. */
21
+ discovery: Record<string, unknown> = { connections: { status: 'experimental', since: '2.45', witness: 'witnessable-gated', packsSupported: true, providerRead: true } };
22
+ private server: Server | undefined;
23
+
24
+ async start(): Promise<string> {
25
+ this.server = createServer((req, res) => this.handle(req, res));
26
+ await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
27
+ this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
28
+ return this.url;
29
+ }
30
+ async stop(): Promise<void> { const s = this.server; if (s) { s.closeAllConnections(); await new Promise<void>((r) => s.close(() => r())); } }
31
+
32
+ private send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }
33
+
34
+ private registry(): Record<string, unknown> {
35
+ const d = this.defect;
36
+ const providers: Array<Record<string, unknown>> = [{ id: 'github', source: 'builtin' }];
37
+ if (d !== 'not-installed') {
38
+ providers.push({ id: FIXTURE_ID, source: 'pack', packName: d === 'first-refused' ? RIVAL_PACK : FIRST_PACK, ...(d === 'leaks-endpoint' ? { tokenUrl: 'https://acme.example/token' } : {}) });
39
+ if (d === 'duplicate-id') providers.push({ id: FIXTURE_ID, source: 'pack', packName: RIVAL_PACK });
40
+ }
41
+ const refusals: Array<Record<string, unknown>> = [];
42
+ if (d !== 'not-installed' && d !== 'no-refusal') {
43
+ refusals.push(d === 'first-refused'
44
+ ? { packName: FIRST_PACK, providerId: FIXTURE_ID, code: 'connection_provider_conflict', heldBy: RIVAL_PACK }
45
+ : { packName: RIVAL_PACK, providerId: FIXTURE_ID, code: 'connection_provider_conflict', heldBy: d === 'wrong-holder' ? 'builtin' : FIRST_PACK });
46
+ }
47
+ return { providers, refusals };
48
+ }
49
+
50
+ private handle(req: IncomingMessage, res: ServerResponse): void {
51
+ const u = new URL(req.url ?? '/', 'http://x');
52
+ if (req.method === 'GET' && u.pathname === '/.well-known/openwop') return this.send(res, 200, this.discovery);
53
+ if (this.defect === 'not-served' && u.pathname.startsWith('/connection-providers')) return this.send(res, 404, { error: 'not_found', message: 'no route' });
54
+ if (req.method === 'GET' && u.pathname === '/connection-providers') return this.send(res, 200, this.registry());
55
+ const m = /^\/connection-providers\/([^/]+)$/.exec(u.pathname);
56
+ if (req.method === 'GET' && m) {
57
+ const id = decodeURIComponent(m[1]!);
58
+ const pack = u.searchParams.get('pack');
59
+ const reg = this.registry();
60
+ const defs = (reg['providers'] as Array<Record<string, unknown>>).filter((p) => p['id'] === id);
61
+ let hit: Record<string, unknown> | undefined;
62
+ if (pack === null) hit = defs.length === 1 ? defs[0] : undefined;
63
+ else if (pack === RIVAL_PACK && this.defect === 'rival-resolves') hit = defs[0];
64
+ else if (pack === FIRST_PACK && this.defect === 'qualified-wrong-pack') hit = { id, source: 'builtin' };
65
+ else hit = defs.find((p) => p['packName'] === pack);
66
+ return hit ? this.send(res, 200, hit) : this.send(res, 404, { error: 'connection_provider_unresolved', message: `no definition for ${id}` });
67
+ }
68
+ return this.send(res, 404, { error: 'not_found', message: `no route ${req.method} ${u.pathname}` });
69
+ }
70
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The connection-provider registry witness (RFC 0233): the normative
3
+ * observation path for `connection-packs.md` §Provider identity and §The
4
+ * qualified form at major 2, read through `GET /connection-providers` and
5
+ * `GET /connection-providers/{providerId}`.
6
+ *
7
+ * Installing a pack is no protocol operation, so the suite never causes the
8
+ * conflict here. An operator installs the §D fixture pair
9
+ * (`connection-pack-acme-widgets`, then `connection-pack-acme-widgets-rival`)
10
+ * through the host's own install path, and the registry describes the outcome.
11
+ *
12
+ * Each leg OBSERVES and returns findings; it asserts nothing. The scenario maps
13
+ * findings to requirement ids, and `provider-registry-witness.test.ts` proves
14
+ * each leg against a scratch double with one defect turned on.
15
+ */
16
+
17
+ import { driver } from './driver.js';
18
+ import { readErrorCode } from './error-envelope.js';
19
+
20
+ export const FIRST_PACK = 'core.openwop.connections.acme-widgets';
21
+ export const RIVAL_PACK = 'core.openwop.connections.acme-widgets-rival';
22
+ export const FIXTURE_ID = 'acme-widgets';
23
+
24
+ export interface RegistryFinding { readonly ok: boolean; readonly doc: string; readonly message: string }
25
+ export type RegistryOutcome =
26
+ | { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
27
+ | { readonly kind: 'observed'; readonly findings: readonly RegistryFinding[] };
28
+
29
+ const DOC = 'connection-packs.md §Observation';
30
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
31
+ const f = (ok: boolean, doc: string, message: string): RegistryFinding => ({ ok, doc, message });
32
+
33
+ /** `connections.providerRead` as advertised in a v2 discovery document. */
34
+ export function providerReadAdvertised(connections: Record<string, unknown> | null): boolean {
35
+ return connections !== null && connections['providerRead'] === true;
36
+ }
37
+
38
+ interface Read { readonly status: number; readonly json: unknown }
39
+ async function list(): Promise<Read> {
40
+ const r = await driver.get('/connection-providers');
41
+ return { status: r.status, json: r.json };
42
+ }
43
+ async function resolve(providerId: string, pack?: string): Promise<Read> {
44
+ const q = pack === undefined ? '' : `?pack=${encodeURIComponent(pack)}`;
45
+ const r = await driver.get(`/connection-providers/${encodeURIComponent(providerId)}${q}`);
46
+ return { status: r.status, json: r.json };
47
+ }
48
+
49
+ const providersOf = (json: unknown): Array<Record<string, unknown>> => {
50
+ const p = isRecord(json) ? json['providers'] : undefined;
51
+ return Array.isArray(p) ? p.filter(isRecord) : [];
52
+ };
53
+ const refusalsOf = (json: unknown): Array<Record<string, unknown>> => {
54
+ const r = isRecord(json) ? json['refusals'] : undefined;
55
+ return Array.isArray(r) ? r.filter(isRecord) : [];
56
+ };
57
+
58
+ /** Is the §D fixture installed? The registry says so itself (RFC 0233 §D.2). */
59
+ export function fixtureInstalled(json: unknown): boolean {
60
+ return providersOf(json).some((p) => p['id'] === FIXTURE_ID && p['packName'] === FIRST_PACK);
61
+ }
62
+
63
+ /** Leg 3: a schema-valid, content-free registry with each bare id once. Runs on any host with the facet. */
64
+ export async function uniqueLeg(validate: (doc: unknown) => { ok: boolean; errors: string }): Promise<RegistryOutcome> {
65
+ const r = await list();
66
+ if (r.status !== 200) return { kind: 'observed', findings: [f(false, DOC, `a host advertising connections.providerRead MUST serve GET /connection-providers (got ${r.status})`)] };
67
+ const v = validate(r.json);
68
+ const ids = providersOf(r.json).map((p) => String(p['id']));
69
+ const dupes = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
70
+ return { kind: 'observed', findings: [
71
+ f(v.ok, DOC, `the registry MUST validate against connection-provider-registry.schema.json (closed: no endpoint, scope catalog or credential)${v.ok ? '' : `: ${v.errors}`}`),
72
+ f(dupes.length === 0, 'connection-packs.md §Provider identity', `a bare provider id MUST appear at most once (duplicated: ${dupes.join(', ') || 'none'})`),
73
+ ] };
74
+ }
75
+
76
+ const NOT_INSTALLED = 'the §D fixture pack connection-pack-acme-widgets is not installed on this host (RFC 0233 §D: operator opt-in), so no conflict has been caused to read';
77
+
78
+ /** Leg 1: the later registration of a bare id was refused, and the id is held once. */
79
+ export async function failClosedLeg(): Promise<RegistryOutcome> {
80
+ const r = await list();
81
+ if (r.status !== 200) return { kind: 'observed', findings: [f(false, DOC, `a host advertising connections.providerRead MUST serve GET /connection-providers (got ${r.status})`)] };
82
+ if (!fixtureInstalled(r.json)) return { kind: 'skip', disposition: 'inapplicable', reason: NOT_INSTALLED };
83
+ const holders = providersOf(r.json).filter((p) => p['id'] === FIXTURE_ID);
84
+ const refused = refusalsOf(r.json).filter((x) => x['providerId'] === FIXTURE_ID && x['code'] === 'connection_provider_conflict');
85
+ return { kind: 'observed', findings: [
86
+ f(holders.length === 1, 'connection-packs.md §Provider identity', `${FIXTURE_ID} MUST be defined exactly once (got ${holders.length})`),
87
+ f(refused.length >= 1, 'connection-packs.md §Provider identity', `a later registration of ${FIXTURE_ID} MUST be refused with connection_provider_conflict and appear in refusals (RFC 0233 §D.1: the rival is attempted after the first pack)`),
88
+ f(refused.every((x) => x['heldBy'] === FIRST_PACK), 'connection-packs.md §Observation', `a conflict refusal MUST name the holder, ${FIRST_PACK} (got ${refused.map((x) => String(x['heldBy'])).join(', ') || 'none'})`),
89
+ f(!refused.some((x) => x['packName'] === FIRST_PACK), 'connection-packs.md §Provider identity', 'the first registration MUST NOT be the one refused'),
90
+ ] };
91
+ }
92
+
93
+ /** Leg 2: the qualified form resolves only to the named pack; bare resolves to the one definition. */
94
+ export async function qualifiedLeg(): Promise<RegistryOutcome> {
95
+ const r = await list();
96
+ if (r.status !== 200) return { kind: 'observed', findings: [f(false, DOC, `a host advertising connections.providerRead MUST serve GET /connection-providers (got ${r.status})`)] };
97
+ if (!fixtureInstalled(r.json)) return { kind: 'skip', disposition: 'inapplicable', reason: NOT_INSTALLED };
98
+ const named = await resolve(FIXTURE_ID, FIRST_PACK);
99
+ const rival = await resolve(FIXTURE_ID, RIVAL_PACK);
100
+ const bare = await resolve(FIXTURE_ID);
101
+ const row = (x: Read): Record<string, unknown> => (isRecord(x.json) ? x.json : {});
102
+ return { kind: 'observed', findings: [
103
+ f(named.status === 200 && row(named)['source'] === 'pack' && row(named)['packName'] === FIRST_PACK, 'connection-packs.md §The qualified form', `${FIXTURE_ID}?pack=${FIRST_PACK} MUST resolve to that pack's definition (got ${named.status} ${JSON.stringify(named.json ?? null).slice(0, 160)})`),
104
+ f(rival.status === 404 && readErrorCode(rival.json) === 'connection_provider_unresolved', 'connection-packs.md §The qualified form', `a qualified reference to a pack that does not define the id MUST answer 404 connection_provider_unresolved (got ${rival.status} ${String(readErrorCode(rival.json))})`),
105
+ f(bare.status === 200 && row(bare)['packName'] === FIRST_PACK, 'connection-packs.md §The qualified form', `the bare id MUST resolve to its one definition, ${FIRST_PACK} (got ${bare.status} ${JSON.stringify(bare.json ?? null).slice(0, 160)})`),
106
+ ] };
107
+ }
@@ -78,9 +78,9 @@ async function register(profile: MajorProfile, mode: 'none' | 'required'): Promi
78
78
  return { subscriptionId, ingestUrl, signingSecret };
79
79
  }
80
80
 
81
- async function ingest(sub: Sub, body: string, opts: { webhookId?: string; signature?: string } = {}): Promise<{ status: number; json: Record<string, unknown> | undefined }> {
81
+ async function ingest(sub: Sub, body: string, opts: { webhookId?: string; signature?: string; timestamp?: string } = {}): Promise<{ status: number; json: Record<string, unknown> | undefined }> {
82
82
  const id = opts.webhookId ?? freshWebhookId();
83
- const ts = String(Math.floor(Date.now() / 1000));
83
+ const ts = opts.timestamp ?? String(Math.floor(Date.now() / 1000));
84
84
  const sig = opts.signature ?? standardWebhooksSignature(sub.signingSecret, id, ts, body);
85
85
  const res = await driver.post(sub.ingestUrl, body, {
86
86
  authenticated: false,
@@ -133,6 +133,69 @@ export async function refusedLeg(profile: MajorProfile, a: TriggerAdverts): Prom
133
133
  ] };
134
134
  }
135
135
 
136
+ /** How far off the skew probes are: twice the 300 s bound, so ordinary clock drift cannot pass or fail them. */
137
+ export const SKEW_PROBE_SECONDS = 600;
138
+
139
+ /**
140
+ * Leg 2b (RFC 0230 §C): under `required`, a `webhook-timestamp` more than 300 s
141
+ * from the host's clock fails the check, in either direction, even when the
142
+ * signature over that timestamp is correct. The post starts no run and leaves
143
+ * the subscription `active`.
144
+ */
145
+ export async function staleTimestampLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
146
+ const gate = signedGate(a); if (gate) return gate;
147
+ const sub = await register(profile, 'required'); if (typeof sub === 'string') return bindingFinding(sub);
148
+ const body = JSON.stringify({ conformance: 'stale-timestamp' });
149
+ const now = Math.floor(Date.now() / 1000);
150
+ const findings: TriggerFinding[] = [];
151
+ for (const [label, ts] of [['stale', now - SKEW_PROBE_SECONDS], ['future', now + SKEW_PROBE_SECONDS]] as const) {
152
+ // Correctly signed over the skewed timestamp: only the skew can refuse it.
153
+ const res = await ingest(sub, body, { timestamp: String(ts) });
154
+ findings.push(
155
+ f(res.status === 401, DOC_INGEST, `a ${label} webhook-timestamp (${SKEW_PROBE_SECONDS} s off) under required verification MUST answer 401 (got ${res.status})`),
156
+ f(readErrorCode(res.json) === 'signature_invalid', DOC_INGEST, `the ${label}-timestamp refusal MUST carry signature_invalid (got ${String(readErrorCode(res.json))})`),
157
+ f(res.json?.['runId'] === undefined, DOC_INGEST, `a ${label}-timestamp post MUST NOT start a run`),
158
+ );
159
+ }
160
+ const read = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(sub.subscriptionId)}`);
161
+ const state = (read.json as { subscription?: { state?: unknown } } | undefined)?.subscription?.state;
162
+ const ok = await ingest(sub, body);
163
+ findings.push(
164
+ f(state === 'active', DOC_INGEST, `a skewed post MUST NOT change the subscription's state (read answered ${read.status}, state ${String(state)})`),
165
+ f(ok.status === 202, DOC_INGEST, `after the skewed posts the subscription MUST still deliver a current, correctly signed one (got ${ok.status})`),
166
+ );
167
+ return { kind: 'observed', findings };
168
+ }
169
+
170
+ /** True when `v` holds a `signingSecret` key at any depth. */
171
+ function hasSecretKey(v: unknown): boolean {
172
+ if (Array.isArray(v)) return v.some(hasSecretKey);
173
+ if (!isRecord(v)) return false;
174
+ return Object.entries(v).some(([k, x]) => k === 'signingSecret' || hasSecretKey(x));
175
+ }
176
+
177
+ /**
178
+ * Leg 2c (RFC 0230 §B, SR-1): the `whsec_` secret is returned once, in the
179
+ * registration's binding, and never on a re-read of the subscription. A host
180
+ * that serves no re-read cannot return it there, so the leg is `inapplicable`.
181
+ */
182
+ export async function signingSecretOnceLeg(profile: MajorProfile, a: TriggerAdverts): Promise<LegOutcome> {
183
+ const gate = signedGate(a); if (gate) return gate;
184
+ const sub = await register(profile, 'none'); if (typeof sub === 'string') return bindingFinding(sub);
185
+ const read = await driver.get(`${profile.triggerSubscriptionsPath}/${profile.idSegment(sub.subscriptionId)}`);
186
+ if (read.status === 404 || read.status === 405) {
187
+ return { kind: 'skip', disposition: 'inapplicable', reason: `the host serves no re-read of a trigger subscription at this major (GET answered ${read.status}), so no re-read can return the secret` };
188
+ }
189
+ const text = read.text;
190
+ const keyBytes = sub.signingSecret.replace(/^whsec_/, '');
191
+ return { kind: 'observed', findings: [
192
+ f(read.status === 200, DOC_INGEST, `the re-read of a subscription just registered MUST answer 200 when the host serves it (got ${read.status})`),
193
+ f(!hasSecretKey(read.json), DOC_INGEST, 'a re-read MUST NOT carry signingSecret'),
194
+ f(!text.includes(sub.signingSecret) && (keyBytes.length < 8 || !text.includes(keyBytes)), DOC_INGEST, 'a re-read MUST NOT carry the secret\'s value'),
195
+ f(!text.includes('whsec_'), DOC_INGEST, 'a re-read MUST NOT carry any whsec_ secret'),
196
+ ] };
197
+ }
198
+
136
199
  const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
137
200
 
138
201
  /** Leg 3: the delivered run's `run.started` carries the delivery as `causationId`. */
@@ -19,7 +19,7 @@ export const OTHER_TENANT_KEY = 'tenant-b-key';
19
19
  export type TriggerDefect =
20
20
  | 'none' | 'dedup-new-run' | 'refused-starts-run' | 'refused-changes-state' | 'no-causation'
21
21
  | 'record-leaks-canary' | 'refused-not-recorded' | 'refused-records-state-change' | 'wrong-retention'
22
- | 'limit-ignored' | 'cursor-not-bound' | 'tenant-leak' | 'tenant-404';
22
+ | 'limit-ignored' | 'cursor-not-bound' | 'tenant-leak' | 'tenant-404' | 'skew-accepted' | 'reread-leaks-secret';
23
23
 
24
24
  interface DoubleSub { id: string; n: number; mode: string; secret: string; state: string; seen: Map<string, string>; dead: Array<Record<string, unknown>> }
25
25
 
@@ -74,7 +74,9 @@ export class TriggerDouble {
74
74
  if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no ingest' });
75
75
  const body = await this.raw(req);
76
76
  const id = String(req.headers['webhook-id'] ?? ''); const ts = String(req.headers['webhook-timestamp'] ?? '');
77
- const good = req.headers['webhook-signature'] === standardWebhooksSignature(sub.secret, id, ts, body);
77
+ // RFC 0230 §C: a timestamp more than 300 s from the clock fails the check, however well signed.
78
+ const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300 || this.defect === 'skew-accepted';
79
+ const good = fresh && req.headers['webhook-signature'] === standardWebhooksSignature(sub.secret, id, ts, body);
78
80
  if (!good && sub.mode === 'required') {
79
81
  if (this.defect !== 'refused-not-recorded') {
80
82
  const at = Date.now();
@@ -112,7 +114,7 @@ export class TriggerDouble {
112
114
  if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect === 'tenant-404') return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
113
115
  if (auth === `Bearer ${OTHER_TENANT_KEY}` && this.defect !== 'tenant-leak') return this.send(res, 403, { error: 'id_tenant_mismatch', message: 'tenant segment is not the caller\'s' });
114
116
  if (!sub) return this.send(res, 404, { error: 'not_found', message: 'no such subscription' });
115
- if (!m[2]) return this.send(res, 200, { subscription: { subscriptionId: sub.id, source: 'webhook', state: sub.state } });
117
+ if (!m[2]) return this.send(res, 200, { subscription: { subscriptionId: sub.id, source: 'webhook', state: sub.state, ...(this.defect === 'reread-leaks-secret' ? { signingSecret: sub.secret } : {}) } });
116
118
  const limit = Math.min(Number(u.searchParams.get('limit') ?? '100'), 100);
117
119
  const cursor = u.searchParams.get('cursor');
118
120
  let offset = 0;
@@ -6,11 +6,18 @@
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
- * Five legs, each its own requirement (RFC 0230 split the one combined `it`;
10
- * RFC 0232 split the fourth):
9
+ * Seven legs, each its own requirement (RFC 0230 split the one combined `it`;
10
+ * RFC 0232 split the fourth; 2.45.12 added 2b and 2c):
11
11
  *
12
12
  * 1. DEDUP (§C-1) — a repeated delivery is effectively-once.
13
13
  * 2. DEAD-LETTER (§C-2 + RFC 0053) — an exhausted/refused delivery starts no run.
14
+ * 2b. STALE TIMESTAMP (RFC 0230 §C) — under `required`, a `webhook-timestamp`
15
+ * more than 300 s off, either way, answers `401 signature_invalid`, starts
16
+ * no run and leaves the subscription `active`.
17
+ * 2c. SECRET ONCE (RFC 0230 §B, SR-1) — a re-read of the subscription carries
18
+ * no `signingSecret` and no `whsec_` value.
19
+ * Legs 2b and 2c have only the normative-surface path: they are
20
+ * `inapplicable` on a host that does not advertise `inboundSigning`.
14
21
  * 3. CAUSATION (§C / RFC 0040) — a delivered run carries the delivery as
15
22
  * `run.started.causationId`.
16
23
  * 4a. RUN-LESS ATTEMPT (SR-1) — a dead-lettered `trigger.delivery.attempted`
@@ -71,10 +78,23 @@ import { deadLetterFacet, freshCanary, judge as judgeDeadLetters, readDeadLetter
71
78
 
72
79
  const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
73
80
  const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
81
+ const R_STALE_TIMESTAMP = 'openwop.requirement.0230.stale-timestamp-refused';
82
+ const R_SECRET_ONCE = 'openwop.requirement.0230.signing-secret-once';
74
83
  const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
75
84
  const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
76
85
  const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
77
86
 
87
+ /** How far off the skew probes are: twice the 300 s bound, so ordinary clock drift cannot pass or fail them. */
88
+ const SKEW_PROBE_SECONDS = 600;
89
+ const NO_SIGNING = 'the host does not advertise triggerBridge.ingestion.inboundSigning ["standard-webhooks-1"]: this rule binds only the RFC 0230 signed ingest, and no seam path witnesses it';
90
+
91
+ /** True when `v` holds a `signingSecret` key at any depth. */
92
+ function hasSecretKey(v: unknown): boolean {
93
+ if (Array.isArray(v)) return v.some(hasSecretKey);
94
+ if (v === null || typeof v !== 'object') return false;
95
+ return Object.entries(v as Record<string, unknown>).some(([k, x]) => k === 'signingSecret' || hasSecretKey(x));
96
+ }
97
+
78
98
  /** The bad signature every refused-post probe sends. */
79
99
  const BAD_SIGNATURE = 'v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
80
100
 
@@ -215,6 +235,45 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
215
235
  notePaths(paths.seam, true);
216
236
  });
217
237
 
238
+ it('refuses a webhook-timestamp more than 300 s off without starting a run', async () => {
239
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
240
+ if (!(await inboundSigningAdvertised())) return softSkip('inapplicable', NO_SIGNING);
241
+ const sub = await registerSignedWebhook('required');
242
+ if ('reason' in sub) throw new Error(req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
243
+ const body = JSON.stringify({ conformance: 'stale-timestamp' });
244
+ const now = Math.floor(Date.now() / 1000);
245
+ for (const [label, ts] of [['stale', now - SKEW_PROBE_SECONDS], ['future', now + SKEW_PROBE_SECONDS]] as const) {
246
+ // Correctly signed over the skewed timestamp: only the skew can refuse it.
247
+ const res = await signedIngest(sub, body, { timestamp: String(ts) });
248
+ expect(res.status, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `a ${label} webhook-timestamp (${SKEW_PROBE_SECONDS} s off) under required verification MUST answer 401 (got ${res.status})`)).toBe(401);
249
+ expect((res.json as { error?: unknown } | undefined)?.error, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `the ${label}-timestamp refusal MUST carry signature_invalid`)).toBe('signature_invalid');
250
+ expect(res.json?.runId, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `a ${label}-timestamp post MUST NOT start a run`)).toBeUndefined();
251
+ }
252
+ const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(sub.subscriptionId)}`);
253
+ const state = (read.json as { subscription?: { state?: unknown } } | undefined)?.subscription?.state;
254
+ expect(state, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6 (RFC 0230 §C.1)', `a skewed post MUST NOT change the subscription's state (read answered ${read.status}, state ${String(state)})`)).toBe('active');
255
+ const ok = await signedIngest(sub, body);
256
+ expect(ok.status, req(R_STALE_TIMESTAMP, 'trigger-bridge.md §F.6', `after the skewed posts the subscription MUST still deliver a current, correctly signed one (got ${ok.status})`)).toBe(202);
257
+ notePaths(false, true);
258
+ });
259
+
260
+ it('returns the signing secret once: a re-read carries no whsec_ value', async () => {
261
+ if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
262
+ if (!(await inboundSigningAdvertised())) return softSkip('inapplicable', NO_SIGNING);
263
+ const sub = await registerSignedWebhook('none');
264
+ if ('reason' in sub) throw new Error(req(R_SECRET_ONCE, 'trigger-bridge.md §F.6', `an inboundSigning host MUST return the §B binding on webhook registration: ${sub.reason}`));
265
+ const read = await driver.get(`/v1/trigger-subscriptions/${encodeURIComponent(sub.subscriptionId)}`);
266
+ if (read.status === 404 || read.status === 405) {
267
+ return softSkip('inapplicable', `the host serves no re-read of a trigger subscription (GET answered ${read.status}), so no re-read can return the secret`);
268
+ }
269
+ const keyBytes = sub.signingSecret.replace(/^whsec_/, '');
270
+ expect(read.status, req(R_SECRET_ONCE, 'trigger-bridge.md §F.6', `the re-read of a subscription just registered MUST answer 200 when the host serves it (got ${read.status})`)).toBe(200);
271
+ expect(hasSecretKey(read.json), req(R_SECRET_ONCE, 'trigger-bridge.md §F.6 (SR-1)', 'a re-read MUST NOT carry signingSecret')).toBe(false);
272
+ expect(read.text.includes(sub.signingSecret) || (keyBytes.length >= 8 && read.text.includes(keyBytes)), req(R_SECRET_ONCE, 'trigger-bridge.md §F.6 (SR-1)', 'a re-read MUST NOT carry the secret\'s value')).toBe(false);
273
+ expect(read.text.includes('whsec_'), req(R_SECRET_ONCE, 'trigger-bridge.md §F.6 (SR-1)', 'a re-read MUST NOT carry any whsec_ secret')).toBe(false);
274
+ notePaths(false, true);
275
+ });
276
+
218
277
  it('links delivery→run causation on run.started', async () => {
219
278
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
220
279
  const paths = await witnessPaths();
@@ -135,6 +135,15 @@ describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
135
135
  if (!new Set(versions.map((v) => v.split('.')[0])).has('2')) {
136
136
  return softSkip('inapplicable', `the host advertises [${versions.join(', ') || 'no protocolVersions'}] — it does not claim major 2, so there is no path space to hold it to`);
137
137
  }
138
+ // versioning.md §1.2's obligation is an OVERLAP rule: reach under major 2
139
+ // what the host already serves under /v1. A host whose protocolVersions[]
140
+ // carries no 1.x member has retired /v1 (§5 "Retirement is atomic"), so
141
+ // nothing is served there and there is nothing to pair. Until 2.45.12 a
142
+ // retired host failed this leg: every /v1 path answers 410 there, and the
143
+ // leg read 410 as "served under /v1" (openwop-app's v1-cut rehearsal).
144
+ if (!new Set(versions.map((v) => v.split('.')[0])).has('1')) {
145
+ return softSkip('inapplicable', `the host advertises [${versions.join(', ')}] with no 1.x member — /v1 is retired (versioning.md §5), so there is no /v1 surface to pair`);
146
+ }
138
147
 
139
148
  const paths = parameterlessGets();
140
149
  if (paths.length === 0) return softSkip('blocked', 'spec/v2/path-manifest.json is unreadable from this layout');
@@ -146,8 +155,9 @@ describe('v2-advertised-path-space-served (RFC 0172 §A.1)', () => {
146
155
  const v1 = await get(`/v1${path}`, false);
147
156
  if (v1.status === null) return softSkip('blocked', `the host became unreachable while probing /v1${path}`);
148
157
  // The host does not serve this surface in EITHER major. Legitimate, and a
149
- // different question from the one asked here.
150
- if (v1.status === 404) continue;
158
+ // different question from the one asked here. 410 Gone says the same of
159
+ // /v1 (RFC 9110 §15.5.11: no longer available), so it is not a pair either.
160
+ if (v1.status === 404 || v1.status === 410) continue;
151
161
  pairable += 1;
152
162
 
153
163
  const v2 = await get(path, true);
@@ -9,7 +9,19 @@
9
9
  * precedence is gone. A connector MAY name a provider by the qualified form
10
10
  * `<packName>#<id>`, which resolves only to the named pack's definition.
11
11
  *
12
- * Seam-gated: driven through the RFC 0095 install/resolve seams
12
+ * TWO WITNESS PATHS (RFC 0233), and a host is measured on every path it offers.
13
+ * The NORMATIVE-SURFACE path reads `GET /connection-providers` and
14
+ * `GET /connection-providers/{providerId}` when the host advertises
15
+ * `connections.providerRead`; the conflict was caused operator-side by
16
+ * installing the §D fixture pair, and the registry describes the outcome
17
+ * (`lib/provider-registry-witness.ts`). Without the fixture installed, its
18
+ * legs are `inapplicable`; the uniqueness leg runs on any host with the facet.
19
+ * With neither `providerRead` nor the seams the legs are `inapplicable`
20
+ * (RFC 0233 §Decisions 2) — a host advertising `packsSupported` SHOULD
21
+ * advertise `providerRead`. Until 2.45.12 that case recorded `blocked`, a
22
+ * seam-only MUST that `conformance.md` §Witness class forbids.
23
+ *
24
+ * The SEAM path is driven through the RFC 0095 install/resolve seams
13
25
  * (`POST /v1/host/sample/connection-packs/{install,resolve}`,
14
26
  * `spec/v1/host-sample-test-seams.md` §10; the driver rewrites the v1 seam path
15
27
  * to `/conformance/seams/sample/…` under target major 2), gated on
@@ -37,6 +49,7 @@
37
49
  * rather than assumed.
38
50
  *
39
51
  * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §D.1
52
+ * @see RFCS/0233-connection-provider-registry-read.md
40
53
  * @see spec/v2/core/connection-packs.md
41
54
  * @see spec/v1/host-sample-test-seams.md §10
42
55
  */
@@ -50,7 +63,9 @@ import { behaviorGate } from '../lib/behavior-gate.js';
50
63
  import { req } from '../lib/requirement-ids.js';
51
64
  import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
52
65
  import { targetMajor } from '../lib/seams.js';
53
- import { v2Discovery, familyAdvertised } from '../lib/v2.js';
66
+ import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
67
+ import { noteObservation } from '../lib/row-observation.js';
68
+ import { failClosedLeg, providerReadAdvertised, qualifiedLeg, uniqueLeg, type RegistryOutcome } from '../lib/provider-registry-witness.js';
54
69
 
55
70
  const SECTION = 'connection-packs.md §"Provider identity" (RFC 0177 §D.1)';
56
71
  const FIXTURE = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-acme-widgets.json');
@@ -63,7 +78,8 @@ interface ResolveResult { resolved?: boolean; source?: 'pack' | 'builtin'; versi
63
78
 
64
79
  function fixture(): Manifest {
65
80
  const m = JSON.parse(readFileSync(FIXTURE, 'utf8')) as Manifest;
66
- // The v1 fixture pins `>=1.0.0`; a v2 host reads that as `<2.0.0` (RFC 0177 §A.1).
81
+ // Both fixture files carry `>=2.0.0 <3.0.0` on disk since 2.45.12 (an operator installs them
82
+ // as-is for RFC 0233 §D). The rewrite is kept so an older checked-out fixture still installs.
67
83
  return { ...m, engines: { openwop: '>=2.0.0 <3.0.0' } };
68
84
  }
69
85
  function conflicting(base: Manifest): Manifest {
@@ -73,7 +89,7 @@ function codes(r: InstallResult | undefined): string[] {
73
89
  return (r?.errors ?? []).map((e) => e.code ?? '').filter((c) => c.length > 0);
74
90
  }
75
91
 
76
- async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
92
+ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | { connections: Record<string, unknown> | null }> {
77
93
  if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
78
94
  let doc: Record<string, unknown> | null;
79
95
  try {
@@ -85,7 +101,22 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
85
101
  const connections = await familyAdvertised('connections');
86
102
  if (!behaviorGate('connections.packsSupported', connections?.['packsSupported'] === true)) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise connections.packsSupported (RFC 0095 §C)' };
87
103
  if (!existsSync(FIXTURE)) return { kind: 'blocked', reason: 'fixture connection-packs/connection-pack-acme-widgets.json is absent from this layout' };
88
- return null;
104
+ return { connections };
105
+ }
106
+
107
+ const NO_PATH = 'connections.packsSupported is advertised, but the host serves neither connections.providerRead (RFC 0233) nor the RFC 0095 seams, so no party can observe a provider conflict here (RFC 0233 §Decisions 2: a host advertising packsSupported SHOULD advertise providerRead)';
108
+
109
+ type SeamLeg = 'absent' | 'ran' | { kind: SoftSkipKind; reason: string };
110
+
111
+ /** Map a normative-surface outcome to assertions. `observed` when it asserted; else the skip to fall back on. */
112
+ function recordNormative(label: (doc: string, message: string) => string, out: RegistryOutcome): { kind: SoftSkipKind; reason: string } | 'observed' {
113
+ if (out.kind === 'skip') return { kind: out.disposition, reason: out.reason };
114
+ for (const x of out.findings) expect(x.ok, label(x.doc, x.message)).toBe(true);
115
+ return 'observed';
116
+ }
117
+
118
+ function noteWitnessPaths(normative: boolean, seam: boolean): void {
119
+ noteObservation([normative ? 'normative-surface path (RFC 0233 provider registry read)' : '', seam ? 'seam path (RFC 0095)' : ''].filter((x) => x !== '').join(' + '));
89
120
  }
90
121
 
91
122
  /** Install the fixture; null when the seam is unwired. */
@@ -97,10 +128,39 @@ async function installFixture(): Promise<{ res: InstallResult | undefined; statu
97
128
 
98
129
  describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
99
130
  it('two definitions of one bare provider id fail closed: the later registration is refused with connection_provider_conflict', async () => {
100
- const skip = await preflight();
101
- if (skip) return softSkip(skip.kind, skip.reason);
131
+ const pre = await preflight();
132
+ if ('kind' in pre) return softSkip(pre.kind, pre.reason);
133
+ const normative = providerReadAdvertised(pre.connections) ? recordNormative((doc, m) => req('openwop.requirement.0177.provider-conflict.fail-closed', doc, m), await failClosedLeg()) : null;
134
+ const seam = await seamFailClosed();
135
+ if (typeof seam === 'object') return softSkip(seam.kind, seam.reason);
136
+ if (normative !== 'observed' && seam === 'absent') return softSkip(normative?.kind ?? 'inapplicable', normative?.reason ?? NO_PATH);
137
+ noteWitnessPaths(normative === 'observed', seam === 'ran');
138
+ });
139
+
140
+ it('the qualified form <packName>#acme-widgets resolves to the named pack\'s definition', async () => {
141
+ const pre = await preflight();
142
+ if ('kind' in pre) return softSkip(pre.kind, pre.reason);
143
+ const normative = providerReadAdvertised(pre.connections) ? recordNormative((doc, m) => req('openwop.requirement.0177.provider-conflict.qualified-form', doc, m), await qualifiedLeg()) : null;
144
+ const seam = await seamQualified();
145
+ if (typeof seam === 'object') return softSkip(seam.kind, seam.reason);
146
+ if (normative !== 'observed' && seam === 'absent') return softSkip(normative?.kind ?? 'inapplicable', normative?.reason ?? NO_PATH);
147
+ noteWitnessPaths(normative === 'observed', seam === 'ran');
148
+ });
149
+
150
+ it('the provider registry lists each bare id once and carries no endpoint or credential (RFC 0233 §B)', async () => {
151
+ const pre = await preflight();
152
+ if ('kind' in pre) return softSkip(pre.kind, pre.reason);
153
+ if (!providerReadAdvertised(pre.connections)) return softSkip('inapplicable', 'the host does not advertise connections.providerRead (RFC 0233): no registry read to check');
154
+ const out = recordNormative((doc, m) => req('openwop.requirement.0233.provider-registry.unique', doc, m), await uniqueLeg(v2Validator('connection-provider-registry')));
155
+ if (out !== 'observed') return softSkip(out.kind, out.reason);
156
+ noteWitnessPaths(true, false);
157
+ });
158
+ });
159
+
160
+ /** The seam path for fail-closed. `absent` when the install seam is unwired. */
161
+ async function seamFailClosed(): Promise<SeamLeg> {
102
162
  const first = await installFixture();
103
- if (!first) return softSkip('blocked', 'RFC 0095 install seam not mounted (404/403) — connections.packsSupported advertised but /conformance/seams/sample/connection-packs/install is absent');
163
+ if (!first) return 'absent';
104
164
  // The branch is kept for a host that somehow ships a built-in of this id:
105
165
  // the fixture is then the LATER registration and the rule fires on the
106
166
  // first install. `acme-widgets` is fictional so the normal path is the
@@ -115,19 +175,19 @@ describe('v2-provider-conflict (RFC 0177 §D.1)', () => {
115
175
  })();
116
176
  expect(later?.installed, req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, 'the later registration of a bare provider id MUST NOT install (no version precedence)')).toBe(false);
117
177
  expect(codes(later), req('openwop.requirement.0177.provider-conflict.fail-closed', SECTION, 'the later registration MUST be refused with connection_provider_conflict')).toContain('connection_provider_conflict');
118
- });
178
+ return 'ran';
179
+ }
119
180
 
120
- it('the qualified form <packName>#acme-widgets resolves to the named pack\'s definition', async () => {
121
- const skip = await preflight();
122
- if (skip) return softSkip(skip.kind, skip.reason);
181
+ /** The seam path for the qualified form. `absent` when the install seam is unwired. */
182
+ async function seamQualified(): Promise<SeamLeg> {
123
183
  const first = await installFixture();
124
- if (!first) return softSkip('blocked', 'RFC 0095 install seam not mounted (404/403) — connections.packsSupported advertised but /conformance/seams/sample/connection-packs/install is absent');
125
- if (first.res?.installed !== true) return softSkip('blocked', `the fixture did not install (${codes(first.res).join(',') || first.status}) — a host with a built-in acme-widgets cannot exercise the qualified form through the pack`);
184
+ if (!first) return 'absent';
185
+ if (first.res?.installed !== true) return { kind: 'blocked', reason: `the fixture did not install (${codes(first.res).join(',') || first.status}) — a host with a built-in acme-widgets cannot exercise the qualified form through the pack` };
126
186
  const packName = fixture().name;
127
187
  const hit = await driver.post(RESOLVE, { provider: `${packName}#acme-widgets` });
128
- if (hit.status === 404) return softSkip('blocked', 'RFC 0095 resolve seam not mounted (404)');
188
+ if (hit.status === 404) return { kind: 'blocked', reason: 'RFC 0095 resolve seam not mounted (404)' };
129
189
  const resolved = hit.json as ResolveResult | undefined;
130
190
  expect(resolved?.resolved, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', `${packName}#acme-widgets MUST resolve (got ${JSON.stringify(resolved)})`)).toBe(true);
131
191
  expect(resolved?.source, req('openwop.requirement.0177.provider-conflict.qualified-form', 'connection-packs.md §"The qualified form" (RFC 0177 §D.1)', 'a qualified reference resolves only to the named pack\'s definition (source: pack)')).toBe('pack');
132
- });
133
- });
192
+ return 'ran';
193
+ }
@@ -12,6 +12,13 @@
12
12
  * 1. dedup a repeated `webhook-id` is effectively-once.
13
13
  * 2. dead-letter a refused post starts no run and leaves the subscription
14
14
  * `active`; a correctly signed post still delivers.
15
+ * 2b. stale timestamp under `required`, a `webhook-timestamp` more than
16
+ * 300 s off, either way, answers `401 signature_invalid`,
17
+ * starts no run and leaves the subscription `active`
18
+ * (RFC 0230 §C).
19
+ * 2c. secret once a re-read of the subscription carries no `signingSecret`
20
+ * and no `whsec_` value (RFC 0230 §B); `inapplicable` where
21
+ * the host serves no re-read.
15
22
  * 3. causation the delivered run's `run.started` carries the delivery.
16
23
  * 4a. run-less attempt a dead-lettered attempt is content-free, read through
17
24
  * `GET /trigger-subscriptions/{subscriptionId}/dead-letters`.
@@ -34,11 +41,13 @@ import { softSkip } from '../lib/soft-skip.js';
34
41
  import { req } from '../lib/requirement-ids.js';
35
42
  import { noteObservation } from '../lib/row-observation.js';
36
43
  import { majorProfile } from '../lib/major-profile.js';
37
- import { adverts, causationLeg, dedupLeg, refusedLeg, runlessAttemptLeg, runlessStateChangeLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
44
+ import { adverts, causationLeg, dedupLeg, refusedLeg, runlessAttemptLeg, runlessStateChangeLeg, signingSecretOnceLeg, staleTimestampLeg, type TriggerAdverts } from '../lib/trigger-delivery-witness.js';
38
45
 
39
46
  const PROFILE = majorProfile(2);
40
47
  const R_DEDUP = 'openwop.requirement.0083.trigger-delivery.dedup';
41
48
  const R_DEAD_LETTER = 'openwop.requirement.0083.trigger-delivery.dead-letter';
49
+ const R_STALE_TIMESTAMP = 'openwop.requirement.0230.stale-timestamp-refused';
50
+ const R_SECRET_ONCE = 'openwop.requirement.0230.signing-secret-once';
42
51
  const R_CAUSATION = 'openwop.requirement.0083.trigger-delivery.causation';
43
52
  const R_RUNLESS_ATTEMPT = 'openwop.requirement.0083.trigger-delivery.runless-attempt-content-free';
44
53
  const R_RUNLESS_STATE = 'openwop.requirement.0083.trigger-delivery.runless-state-change-content-free';
@@ -76,6 +85,26 @@ describe('v2 trigger-bridge delivery (webhooks.md §Inbound triggers)', () => {
76
85
  noteObservation(`normative-surface path (RFC 0230)${out.note ? `; ${out.note}` : ''}`);
77
86
  });
78
87
 
88
+ it('refuses a webhook-timestamp more than 300 s off without starting a run', async () => {
89
+ const a = await discovered();
90
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
91
+ if (typeof a === 'string') return softSkip('blocked', a);
92
+ const out = await staleTimestampLeg(PROFILE, a);
93
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
94
+ for (const x of out.findings) expect(x.ok, req(R_STALE_TIMESTAMP, x.doc, x.message)).toBe(true);
95
+ noteObservation('normative-surface path (RFC 0230)');
96
+ });
97
+
98
+ it('returns the signing secret once: a re-read carries no whsec_ value', async () => {
99
+ const a = await discovered();
100
+ if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');
101
+ if (typeof a === 'string') return softSkip('blocked', a);
102
+ const out = await signingSecretOnceLeg(PROFILE, a);
103
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
104
+ for (const x of out.findings) expect(x.ok, req(R_SECRET_ONCE, x.doc, x.message)).toBe(true);
105
+ noteObservation('normative-surface path (RFC 0230)');
106
+ });
107
+
79
108
  it('links delivery to run: run.started carries the delivery as causationId', async () => {
80
109
  const a = await discovered();
81
110
  if (a === 'GATED') return softSkip('inapplicable', 'the host does not advertise triggerBridge at major 2: no obligation');