@openwop/openwop-conformance 2.3.1 → 2.3.3
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 +32 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +36 -14
- package/schemas/CORPUS-STAMP.json +13 -13
- package/src/scenarios/v2-bound-id-path-projection.test.ts +16 -2
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +135 -55
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.3] — 2026-09-17 — the retirement lane bites again
|
|
4
|
+
|
|
5
|
+
**Why a patch.** One leg corrected, nothing added or removed.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`v2-bound-id-path-projection.test.ts`** — the double-projection leg expects
|
|
10
|
+
`404` on a dual-stack host and `400 validation_error` on a single-major one
|
|
11
|
+
(`identity.md` §5 refuses the bare form once no 1.x member is advertised),
|
|
12
|
+
read from `protocolVersions`. Found by the reference host's retirement lane.
|
|
13
|
+
- **`v2-webhook-delivery-shape.test.ts`** — one Ajv per wire, cached across legs.
|
|
14
|
+
|
|
15
|
+
## [2.3.2] — 2026-09-17 — the webhook reader read the wrong field
|
|
16
|
+
|
|
17
|
+
**Why a patch.** No scenario is added or removed; one is corrected. The 2.3.1 file asserted `event.owner` on a `run.completed` delivery — the envelope is closed and names no `owner`, and that payload carries none — so leg 1 was unsatisfiable and leg 3 vacuous on every host. Found by the reference host's W4 cut.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`v2-webhook-delivery-shape.test.ts`** — subscribes to `run.started`, whose
|
|
22
|
+
payload is where the owner echo lives (`runStarted.owner`). Leg 1 validates the
|
|
23
|
+
delivery against `webhook-delivery.schema.json` (v2) and the payload against the
|
|
24
|
+
v2 `runStarted` definition; the owner carries `subject`, never `principal`.
|
|
25
|
+
Leg 2 registers at `POST /v1/webhooks` (`versioning.md` §1.4) and validates the
|
|
26
|
+
payload against the **v1** `runStarted` definition — the owner's keys are not
|
|
27
|
+
the discriminator (v1 admits `subject`, RFC 0165 §B); the integer-vs-string
|
|
28
|
+
`engineVersion` is. A host with no 1.x webhook surface records leg 2
|
|
29
|
+
`inapplicable`. Leg 3 reads the seeded era-2 run's `run.started`; a host that
|
|
30
|
+
does not fan out seeded history records it `inapplicable`. Two 2.3.1 gates were
|
|
31
|
+
vacuous and are fixed: leg 2 read `versions.supported` (no such field — it is
|
|
32
|
+
`protocolVersions`), leg 3 read `era2Gate`'s `null` (= seam advertised) as
|
|
33
|
+
absence. Same requirement id, same gate.
|
|
34
|
+
|
|
3
35
|
## [2.3.1] — 2026-09-17 — the webhook body is finally read
|
|
4
36
|
|
|
5
37
|
**Why a patch.** One scenario file is added, which `PROTOCOL-STATUS.md` calls a minor — but the previous cut (2.3.0) was a minor ninety minutes ago and this file witnesses a MUST that predates it; cut as a patch alongside the register and ext-rule edits it ships with. If that reads as bending the rule, the rule wins and this becomes 2.4.0 on review.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.3",
|
|
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.3.
|
|
59
|
+
"@openwop/spec-artifacts": "2.3.3"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -26042,6 +26042,10 @@
|
|
|
26042
26042
|
"requirement": null,
|
|
26043
26043
|
"interpolated": true
|
|
26044
26044
|
},
|
|
26045
|
+
{
|
|
26046
|
+
"section": "spec/v2/core/identity.md §5",
|
|
26047
|
+
"requirement": "a single-major host refuses the bare form with validation_error (identity.md §5), not not_found"
|
|
26048
|
+
},
|
|
26045
26049
|
{
|
|
26046
26050
|
"section": "spec/v2/core/identity.md §5",
|
|
26047
26051
|
"requirement": null,
|
|
@@ -29227,51 +29231,69 @@
|
|
|
29227
29231
|
]
|
|
29228
29232
|
},
|
|
29229
29233
|
{
|
|
29230
|
-
"id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-
|
|
29234
|
+
"id": "openwop.it.v2-webhook-delivery-shape.a-major-2-subscriber-receives-the-v2-rendering-the-delivery-validates-and-run-st",
|
|
29231
29235
|
"file": "v2-webhook-delivery-shape.test.ts",
|
|
29232
|
-
"line":
|
|
29233
|
-
"title": "a major-2 subscriber receives the v2
|
|
29236
|
+
"line": 175,
|
|
29237
|
+
"title": "a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal",
|
|
29234
29238
|
"explicitId": "openwop.requirement.0171.webhook-delivery-shape",
|
|
29235
29239
|
"citations": [
|
|
29236
29240
|
{
|
|
29237
|
-
"section": "spec/v2/core/webhooks.md §
|
|
29241
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29238
29242
|
"requirement": null,
|
|
29239
29243
|
"interpolated": true
|
|
29240
29244
|
},
|
|
29241
29245
|
{
|
|
29242
|
-
"section": "spec/v2/core/webhooks.md §
|
|
29243
|
-
"requirement":
|
|
29246
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29247
|
+
"requirement": null,
|
|
29248
|
+
"interpolated": true
|
|
29249
|
+
},
|
|
29250
|
+
{
|
|
29251
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29252
|
+
"requirement": "a major-2 run.started payload MUST carry the owner echo { tenant, subject } (identity.md §1; run-event-payloads runStarted.owner requires both)"
|
|
29253
|
+
},
|
|
29254
|
+
{
|
|
29255
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29256
|
+
"requirement": "a major-2 owner echo MUST NOT carry the v1 keys principal / principalKind — that is the fan-out forwarding the in-process dialect instead of projecting"
|
|
29257
|
+
},
|
|
29258
|
+
{
|
|
29259
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29260
|
+
"requirement": "a major-2 owner echo carries tenant and subject"
|
|
29244
29261
|
}
|
|
29245
29262
|
]
|
|
29246
29263
|
},
|
|
29247
29264
|
{
|
|
29248
|
-
"id": "openwop.it.v2-webhook-delivery-shape.a-major-1-subscriber-still-receives-
|
|
29265
|
+
"id": "openwop.it.v2-webhook-delivery-shape.a-major-1-subscriber-still-receives-the-v1-rendering-the-v1-wire-does-not-move-m",
|
|
29249
29266
|
"file": "v2-webhook-delivery-shape.test.ts",
|
|
29250
|
-
"line":
|
|
29251
|
-
"title": "a major-1 subscriber still receives
|
|
29267
|
+
"line": 194,
|
|
29268
|
+
"title": "a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap",
|
|
29252
29269
|
"explicitId": "openwop.requirement.0171.webhook-delivery-shape",
|
|
29253
29270
|
"citations": [
|
|
29254
29271
|
{
|
|
29255
29272
|
"section": "spec/v2/core/versioning.md §1.2",
|
|
29256
|
-
"requirement":
|
|
29273
|
+
"requirement": null,
|
|
29274
|
+
"interpolated": true
|
|
29275
|
+
},
|
|
29276
|
+
{
|
|
29277
|
+
"section": "spec/v2/core/versioning.md §1.2",
|
|
29278
|
+
"requirement": "a v1 owner echo, when present, names the tenant (v1 runStarted.owner requires it)"
|
|
29257
29279
|
}
|
|
29258
29280
|
]
|
|
29259
29281
|
},
|
|
29260
29282
|
{
|
|
29261
29283
|
"id": "openwop.it.v2-webhook-delivery-shape.a-seeded-era-2-run-is-delivered-projected-too-the-fan-out-branch-a-fresh-run-can",
|
|
29262
29284
|
"file": "v2-webhook-delivery-shape.test.ts",
|
|
29263
|
-
"line":
|
|
29285
|
+
"line": 217,
|
|
29264
29286
|
"title": "a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)",
|
|
29265
29287
|
"explicitId": "openwop.requirement.0171.webhook-delivery-shape",
|
|
29266
29288
|
"citations": [
|
|
29267
29289
|
{
|
|
29268
|
-
"section": "spec/v2/core/webhooks.md §
|
|
29290
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29269
29291
|
"requirement": null,
|
|
29270
29292
|
"interpolated": true
|
|
29271
29293
|
},
|
|
29272
29294
|
{
|
|
29273
|
-
"section": "spec/v2/core/webhooks.md §
|
|
29274
|
-
"requirement": "a projected era-2 delivery MUST NOT carry the v1 owner
|
|
29295
|
+
"section": "spec/v2/core/webhooks.md §Delivery",
|
|
29296
|
+
"requirement": "a projected era-2 delivery MUST NOT carry the v1 owner keys"
|
|
29275
29297
|
}
|
|
29276
29298
|
]
|
|
29277
29299
|
},
|
|
@@ -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.3.
|
|
5
|
-
"corpusTag": "v2.3.
|
|
4
|
+
"version": "2.3.3",
|
|
5
|
+
"corpusTag": "v2.3.3",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "ac5a7fdff795eeee34d61af878afd61657ade24745dc8e87b970a18148571ed5",
|
|
13
|
+
"api/v2/asyncapi.yaml": "1bb9761fc9e90b5dd5c05f208b01d597691b52792dd2a488d4382467390d55dd",
|
|
14
|
+
"api/v2/openapi.yaml": "883ac5a1e6d9056b1acdd79538341d05a005f3f58fb9751237fd6d9ba0167fc9",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"schemas/v2/envelopes/schema.request.schema.json": "be2c5f7aa881cdd252ccff47ac02193659e1393633a1541f4297383205d384a2",
|
|
142
142
|
"schemas/v2/envelopes/schema.response.schema.json": "d049a081267c06c28879c331b147f3ed821b14ccfd93005c67aff64db0a8c68e",
|
|
143
143
|
"schemas/v2/envelopes/ui.a2ui-surface.schema.json": "0a06fbbdc8ba797a6818c79336a98e211ac5ba5747945b4e4098072cd3974d79",
|
|
144
|
-
"schemas/v2/error-envelope.schema.json": "
|
|
144
|
+
"schemas/v2/error-envelope.schema.json": "9c8f747e5bea81943d70e0930bc7d26586a4c3a3d90be77f46359b19b7e56cbe",
|
|
145
145
|
"schemas/v2/eval-summary.schema.json": "9d31025752d0f67ad3b8a32e82a5314972c530ef9f780dd82e484ae8c5131198",
|
|
146
146
|
"schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
|
|
147
147
|
"schemas/v2/form-content-pack-manifest.schema.json": "307884e57946703b6f86ec283034bf56beb68bea4a000743ebbc9eebeed31b07",
|
|
@@ -200,7 +200,7 @@
|
|
|
200
200
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
201
201
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
202
202
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
203
|
-
"spec/v1/core-standard-manifest.json": "
|
|
203
|
+
"spec/v1/core-standard-manifest.json": "2c9150bdde2ae37e788f947110acb2503ff33299f5cf74bc522bcf32f10bd02e",
|
|
204
204
|
"spec/v1/deprecations.json": "508071308e762c281807b487e8f3840a1a69b978071894593e18f2e2d3e4b5c0",
|
|
205
205
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
206
206
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -216,8 +216,8 @@
|
|
|
216
216
|
"spec/v2/core/capabilities.md": "c5107a4964e487c20bbb5254cfe1f706e0d9ba7e34da9070bc43b50b385e7948",
|
|
217
217
|
"spec/v2/core/conformance.md": "622084e3c74c4549087f4d861b1bb98cec201a0c5aec23788d27f53a355fde3b",
|
|
218
218
|
"spec/v2/core/connection-packs.md": "1d759681e2962f5103228c52c45f24607ed4bb9ae73e30605f690c1d5a59e9ee",
|
|
219
|
-
"spec/v2/core/errors.md": "
|
|
220
|
-
"spec/v2/core/events.md": "
|
|
219
|
+
"spec/v2/core/errors.md": "ad85fc830139e587f20deabf88297b91b1fb6ce31e80d5442624a650246ab16e",
|
|
220
|
+
"spec/v2/core/events.md": "20112f459e7f1bcab0e93fbfc2e28719ecd1435228e87c5476b9eaa691558603",
|
|
221
221
|
"spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
|
|
222
222
|
"spec/v2/core/headers.md": "2dd67d71673539e16612f09f3f2513a384f6566052d7cd8a00885885689854c2",
|
|
223
223
|
"spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
|
|
@@ -231,11 +231,11 @@
|
|
|
231
231
|
"spec/v2/core/runs.md": "f7a95fc82fda5b7c0c613de6e5edd376bbd6d8ba4c665a500c94eacbe42b86f2",
|
|
232
232
|
"spec/v2/core/security-defaults.md": "1d38aa03b9d2507f1d4a985333cc57ae7892cecc7356f6342fbd1672e34f638a",
|
|
233
233
|
"spec/v2/core/versioning.md": "d731782eeabb953919c7c7057340b7f9c2916b6186790e6b04c7d8ea60767189",
|
|
234
|
-
"spec/v2/core/webhooks.md": "
|
|
234
|
+
"spec/v2/core/webhooks.md": "158aff629f66cf1ea483e188eba3ef9695d9868d9b525353189db6d7a3c099d6",
|
|
235
235
|
"spec/v2/core/workflow-chain-packs.md": "ef2b07d2d99334eaec0cc370314e2ad2f84ea58deb699e5d92e6e52d6056613f",
|
|
236
236
|
"spec/v2/declaration.json": "a09bac61c1b825e4a77b5abc0f86f1f8c20c381e0650eb08c2f6d0ef0aaec1dc",
|
|
237
237
|
"spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
|
|
238
|
-
"spec/v2/errors.json": "
|
|
238
|
+
"spec/v2/errors.json": "4ff032033f02d7e3fb4204581456408ba5e7642b438c1f349bbd1c264e63e419",
|
|
239
239
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
240
240
|
"spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
|
|
241
241
|
"spec/v2/ext/README.md": "c7dea12c48772047f18b111e73be1c236460e72d9bf98026351d148a2ac4f608",
|
|
@@ -271,8 +271,8 @@
|
|
|
271
271
|
"spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
|
|
272
272
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
273
273
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
274
|
-
"spec/v2/release.json": "
|
|
274
|
+
"spec/v2/release.json": "ffb0a24cba6ab87c4b99a76d51868e42eb07bd337157836cf5e59b9e13ad44e0",
|
|
275
275
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
276
276
|
},
|
|
277
|
-
"corpusCommit": "
|
|
277
|
+
"corpusCommit": "c2e93ea53a5c87dea90697dc166d540b6fc0f945"
|
|
278
278
|
}
|
|
@@ -102,12 +102,26 @@ describe('v2 bound-id path projection (identity.md §5)', () => {
|
|
|
102
102
|
// projections on a single read path, so this is a live shape, not a
|
|
103
103
|
// thought experiment. Asserting 404 (not merely "not 200") keeps the leg
|
|
104
104
|
// from passing on a 500.
|
|
105
|
+
// What "not resolve" looks like depends on the host's majors. Decoded once, a
|
|
106
|
+
// double projection is a BARE id (it still carries `~2F`, no slash). Through
|
|
107
|
+
// the overlap the bare form is admitted and resolved under the caller's tenant
|
|
108
|
+
// — no such run, 404. Once the host advertises no 1.x member, identity.md §5
|
|
109
|
+
// requires the bare form itself to be refused `400 validation_error` — and
|
|
110
|
+
// the reference host's retirement lane (2.3.2) failed a CONFORMANT retired
|
|
111
|
+
// host on an unconditional 404 here.
|
|
105
112
|
const twice = projectBoundId(projected);
|
|
106
113
|
const doubled = await http(() => driver.get(`/runs/${twice}`));
|
|
114
|
+
const disc = await v2Discovery();
|
|
115
|
+
const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
|
|
116
|
+
const singleMajor = !versions.some((v) => v.startsWith('1.'));
|
|
117
|
+
const want = singleMajor ? 400 : 404;
|
|
107
118
|
expect(
|
|
108
119
|
doubled?.status ?? null,
|
|
109
|
-
req(ID, DOC, `a DOUBLE-projected segment MUST NOT resolve — the codec is not idempotent, so projecting twice yields a different id and a host that does it strands its own links. Expected
|
|
110
|
-
).toBe(
|
|
120
|
+
req(ID, DOC, `a DOUBLE-projected segment MUST NOT resolve — the codec is not idempotent, so projecting twice yields a different id and a host that does it strands its own links. Expected ${want} (${singleMajor ? 'single-major host: the decoded bare form is refused 400 validation_error' : 'dual-stack host: the bare form resolves under the caller tenant and is not found'}) for ${twice}, got ${doubled?.status ?? 'no response'} ${readErrorCode(doubled?.json) ?? ''}`.trim()),
|
|
121
|
+
).toBe(want);
|
|
122
|
+
if (singleMajor) {
|
|
123
|
+
expect(readErrorCode(doubled?.json), req(ID, DOC, 'a single-major host refuses the bare form with validation_error (identity.md §5), not not_found')).toBe('validation_error');
|
|
124
|
+
}
|
|
111
125
|
|
|
112
126
|
// DECODER RULE. A `~` not introducing two hex digits is malformed input, so
|
|
113
127
|
// 400 — not 404, which would say "no such run" about a request that never
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `spec/v2/core/webhooks.md` §
|
|
3
|
-
*
|
|
4
|
-
* gated on the `webhooks` family; creates one run per leg).
|
|
2
|
+
* `spec/v2/core/webhooks.md` §Delivery — a webhook delivery's `event` is the
|
|
3
|
+
* verbatim run event AS THE SUBSCRIBER'S CONTRACT RENDERS IT (suite 2.3.3,
|
|
4
|
+
* target major 2; gated on the `webhooks` family; creates one run per leg).
|
|
5
5
|
*
|
|
6
6
|
* `webhooks.md:22`: "The delivery envelope is generated from the same payload
|
|
7
7
|
* definition as the event itself and the CloudEvents mapping — one source,
|
|
@@ -14,19 +14,33 @@
|
|
|
14
14
|
* `principalKind`) — with every webhook scenario green. The wire truth is per
|
|
15
15
|
* channel, and a channel nobody's scenario reads is a channel that can drift.
|
|
16
16
|
*
|
|
17
|
+
* WHERE THE OWNER LIVES (the 2.3.1 → 2.3.2 correction). The owner echo is a
|
|
18
|
+
* property of the `run.started` PAYLOAD — `run-event-payloads.schema.json`
|
|
19
|
+
* `runStarted.owner`, `{ tenant, workspace?, subject }` on the v2 wire and
|
|
20
|
+
* `{ tenant, workspace?, principal?, principalKind? }` on the v1 wire, both
|
|
21
|
+
* `additionalProperties: false` — not of the event envelope, which is closed
|
|
22
|
+
* and names no `owner` in either major. 2.3.1 subscribed to `run.completed`
|
|
23
|
+
* (whose payload is `{ outputs, durationMs }`) and read `event.owner`: a
|
|
24
|
+
* predicate no host could satisfy, and a third leg that passed vacuously.
|
|
25
|
+
* This file subscribes to `run.started` and reads `event.payload.owner`.
|
|
26
|
+
*
|
|
17
27
|
* Three legs, and the SECOND is the one that keeps the first honest. Leg 1: a
|
|
18
|
-
* major-2 subscription's delivery
|
|
19
|
-
* (v2)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* major-2 subscription's delivery validates against `webhook-delivery.schema.json`
|
|
29
|
+
* (v2), its `run.started` payload against the v2 `runStarted` definition, and
|
|
30
|
+
* the owner carries `subject` and never `principal`. Leg 2: a major-1
|
|
31
|
+
* subscription's `run.started` payload validates against the V1 `runStarted`
|
|
32
|
+
* definition — `versioning.md` §1.2 forbids moving the v1 wire mid-overlap, so
|
|
33
|
+
* a host that projects BOTH channels to v2 is also wrong, and a v2-only check
|
|
34
|
+
* would reward it. The owner's keys are NOT the discriminator: v1's owner admits
|
|
35
|
+
* `subject` (RFC 0165 §B) beside `principal`, so a v2 owner is a valid v1 owner;
|
|
36
|
+
* the v2 payload's integer `engineVersion` (string on v1) is what the v1 wire
|
|
37
|
+
* cannot carry, and the v1 definition catches it. Leg 3 is
|
|
38
|
+
* seam-gated: the tier-2 host's fan-out is ERA-aware, not major-aware — an
|
|
39
|
+
* era-3 row was projected on write and passes through; only era-2 rows take the
|
|
40
|
+
* read projection — so a single fresh (era-3) run witnesses one branch. A
|
|
41
|
+
* seeded era-2 run witnesses the other.
|
|
28
42
|
*
|
|
29
|
-
* @see spec/v2/core/webhooks.md §
|
|
43
|
+
* @see spec/v2/core/webhooks.md §Delivery
|
|
30
44
|
* @see spec/v2/core/versioning.md §1.2
|
|
31
45
|
*/
|
|
32
46
|
import { afterEach, describe, it, expect } from 'vitest';
|
|
@@ -45,10 +59,13 @@ import { era2Gate, seedEra2Log, v1FixtureLog } from '../lib/era2-seed.js';
|
|
|
45
59
|
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
46
60
|
|
|
47
61
|
const ID = 'openwop.requirement.0171.webhook-delivery-shape';
|
|
48
|
-
const DOC = 'spec/v2/core/webhooks.md §
|
|
62
|
+
const DOC = 'spec/v2/core/webhooks.md §Delivery';
|
|
49
63
|
const FIXTURE = 'conformance-noop';
|
|
64
|
+
/** The one event type whose payload carries the owner echo — the field that differs between the two wires. */
|
|
65
|
+
const EVENT_TYPE = 'run.started';
|
|
50
66
|
|
|
51
67
|
type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
|
|
68
|
+
type Validator = (doc: unknown) => { ok: boolean; errors: string };
|
|
52
69
|
|
|
53
70
|
async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
|
|
54
71
|
const deliveries: Delivery[] = [];
|
|
@@ -67,10 +84,20 @@ async function startReceiver(): Promise<{ server: Server; url: string; deliverie
|
|
|
67
84
|
return { server, url: `http://${advertise}:${port}/hook`, deliveries };
|
|
68
85
|
}
|
|
69
86
|
|
|
70
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Register a subscription; `major` selects the contract the host speaks to this
|
|
89
|
+
* subscriber. A 1.x registration goes to `/v1/webhooks` — v1 operations keep
|
|
90
|
+
* their `/v1/` path keys through the overlap (versioning.md §1.4) — and a host
|
|
91
|
+
* that serves no 1.x webhook surface answers 404 there, which is `inapplicable`
|
|
92
|
+
* for leg 2, not a failure: there is no v1 wire to keep still.
|
|
93
|
+
*/
|
|
71
94
|
async function register(url: string, major: 1 | 2): Promise<string | null> {
|
|
72
95
|
const registration = resolveRegistrationUrl(url);
|
|
73
|
-
const reg = await driver.post('/webhooks', { url: registration.url, events: [
|
|
96
|
+
const reg = await driver.post(major === 2 ? '/webhooks' : '/v1/webhooks', { url: registration.url, events: [EVENT_TYPE] }, { headers: { 'OpenWOP-Version': major === 2 ? '2.0' : '1.0' } });
|
|
97
|
+
if (major === 1 && reg.status === 404) {
|
|
98
|
+
softSkip('inapplicable', 'host serves no 1.x webhook surface (POST /v1/webhooks not_found) — no v1 wire to keep still');
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
74
101
|
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
75
102
|
softSkip('blocked', 'host SSRF guard rejected the loopback receiver (webhooks.md §Egress requires it); set OPENWOP_WEBHOOK_RECEIVER_URL to a public https receiver to witness');
|
|
76
103
|
return null;
|
|
@@ -92,69 +119,122 @@ async function waitFor<T>(fn: () => T | undefined, ms: number): Promise<T | unde
|
|
|
92
119
|
return fn();
|
|
93
120
|
}
|
|
94
121
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The delivery for `runId` carrying the subscribed type. A v1 subscriber may
|
|
124
|
+
* receive the bare opaque id (versioning.md §5), so the match is on the segment
|
|
125
|
+
* both spellings share.
|
|
126
|
+
*/
|
|
127
|
+
function deliveryFor(deliveries: Delivery[], runId: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
|
|
128
|
+
const opaque = runId.includes('/') ? runId.slice(runId.indexOf('/') + 1) : runId;
|
|
129
|
+
for (const d of deliveries) {
|
|
130
|
+
if (!d.body.includes(opaque)) continue;
|
|
131
|
+
let parsed: unknown;
|
|
132
|
+
try { parsed = JSON.parse(d.body); } catch { continue; }
|
|
133
|
+
if (parsed === null || typeof parsed !== 'object') continue;
|
|
134
|
+
const envelope = parsed as Record<string, unknown>;
|
|
135
|
+
const event = (envelope['event'] ?? envelope) as Record<string, unknown>;
|
|
136
|
+
if (event['type'] === EVENT_TYPE) return { event, envelope };
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ownerOf(event: Record<string, unknown>): Record<string, unknown> | null {
|
|
142
|
+
const p = event['payload'];
|
|
143
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) return null;
|
|
144
|
+
const owner = (p as Record<string, unknown>)['owner'];
|
|
145
|
+
return owner !== null && typeof owner === 'object' && !Array.isArray(owner) ? (owner as Record<string, unknown>) : null;
|
|
101
146
|
}
|
|
102
147
|
|
|
103
|
-
|
|
148
|
+
/** One Ajv per wire: the v2 tree under `schemas/v2/`, the v1 tree at the root — each addressed by its absolute `$id`. */
|
|
149
|
+
const AJV_BY_MAJOR = new Map<1 | 2, Ajv2020>();
|
|
150
|
+
function validators(major: 1 | 2): { ref: (id: string) => Validator } {
|
|
151
|
+
const cached = AJV_BY_MAJOR.get(major);
|
|
152
|
+
if (cached) return { ref: (id) => { const fn = cached.compile({ $ref: id }); return (doc) => ({ ok: fn(doc) === true, errors: cached.errorsText(fn.errors, { separator: '; ' }) }); } };
|
|
153
|
+
const a = new Ajv2020({ strict: false, allErrors: true }); addFormats(a); AJV_BY_MAJOR.set(major, a);
|
|
154
|
+
const dir = major === 2 ? join(SCHEMAS_DIR, 'v2') : SCHEMAS_DIR;
|
|
155
|
+
for (const f of readdirSync(dir)) {
|
|
156
|
+
const full = join(dir, f);
|
|
157
|
+
if (!f.endsWith('.schema.json') || statSync(full).isDirectory()) continue;
|
|
158
|
+
try { a.addSchema(JSON.parse(readFileSync(full, 'utf8')) as Record<string, unknown>); } catch { /* duplicate $id */ }
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ref: (id: string): Validator => {
|
|
162
|
+
const fn = a.compile({ $ref: id });
|
|
163
|
+
return (doc) => ({ ok: fn(doc) === true, errors: a.errorsText(fn.errors, { separator: '; ' }) });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const V2 = 'https://openwop.dev/spec/v2/';
|
|
169
|
+
const V1 = 'https://openwop.dev/spec/v1/';
|
|
170
|
+
|
|
171
|
+
describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
|
|
104
172
|
let active: Server | null = null;
|
|
105
173
|
afterEach(async () => { const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
|
|
106
174
|
|
|
107
|
-
it('a major-2 subscriber receives the v2
|
|
175
|
+
it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
|
|
108
176
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
109
177
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
110
178
|
const receiver = await startReceiver(); active = receiver.server;
|
|
111
179
|
if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
112
180
|
const runId = await driveRun();
|
|
113
|
-
const d = await waitFor(() => receiver.deliveries
|
|
114
|
-
if (!d) return softSkip('blocked',
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
181
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
182
|
+
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
|
|
183
|
+
const v2 = validators(2);
|
|
184
|
+
const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
|
|
185
|
+
expect(envelope.ok, req(ID, DOC, `a major-2 delivery MUST validate against webhook-delivery.schema.json (v2) — { runId, workspaceId?, event } with event the verbatim v2 run event. ${envelope.errors}`)).toBe(true);
|
|
186
|
+
const payload = v2.ref(`${V2}run-event-payloads.schema.json#/$defs/runStarted`)(d.event['payload']);
|
|
187
|
+
expect(payload.ok, req(ID, DOC, `a major-2 run.started payload MUST validate against the v2 runStarted definition — "one source, three renderings" (RFC 0171 §A.4). ${payload.errors}`)).toBe(true);
|
|
188
|
+
const owner = ownerOf(d.event);
|
|
189
|
+
expect(owner !== null, req(ID, DOC, 'a major-2 run.started payload MUST carry the owner echo { tenant, subject } (identity.md §1; run-event-payloads runStarted.owner requires both)')).toBe(true);
|
|
190
|
+
expect(Object.keys(owner ?? {}).filter((k) => k === 'principal' || k === 'principalKind'), req(ID, DOC, 'a major-2 owner echo MUST NOT carry the v1 keys principal / principalKind — that is the fan-out forwarding the in-process dialect instead of projecting')).toEqual([]);
|
|
191
|
+
expect(typeof owner?.['tenant'] === 'string' && owner?.['subject'] !== undefined, req(ID, DOC, 'a major-2 owner echo carries tenant and subject')).toBe(true);
|
|
121
192
|
});
|
|
122
193
|
|
|
123
|
-
it('a major-1 subscriber still receives
|
|
194
|
+
it('a major-1 subscriber still receives the v1 rendering — the v1 wire does not move mid-overlap', async () => {
|
|
124
195
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
125
196
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
126
197
|
const disc = await v2Discovery();
|
|
127
|
-
|
|
128
|
-
|
|
198
|
+
// RFC 0172 §A.1: `protocolVersions` lists every <major>.<minor> the host serves — the 2.3.1 file read a
|
|
199
|
+
// `versions.supported` that no discovery document has, so this leg was inapplicable on every host.
|
|
200
|
+
const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
|
|
201
|
+
if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
|
|
129
202
|
const receiver = await startReceiver(); active = receiver.server;
|
|
130
|
-
if ((await register(receiver.url, 1)) === null) return softSkip('blocked', 'registration refused (
|
|
203
|
+
if ((await register(receiver.url, 1)) === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
|
|
131
204
|
const runId = await driveRun();
|
|
132
|
-
const d = await waitFor(() => receiver.deliveries
|
|
133
|
-
if (!d) return softSkip('blocked',
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
205
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
206
|
+
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
|
|
207
|
+
// The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
|
|
208
|
+
// §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
|
|
209
|
+
// What the v1 wire cannot carry is the v2 payload's integer `engineVersion` (string on v1) — a fan-out
|
|
210
|
+
// that forwards the v2 rendering to a 1.x subscriber fails here, on the field that actually differs.
|
|
211
|
+
const payload = validators(1).ref(`${V1}run-event-payloads.schema.json#/$defs/runStarted`)(d.event['payload']);
|
|
212
|
+
expect(payload.ok, req(ID, 'spec/v2/core/versioning.md §1.2', `a major-1 run.started payload MUST validate against the V1 runStarted definition — projecting every channel to v2 is as wrong as projecting none; the projection is contract-scoped. ${payload.errors}`)).toBe(true);
|
|
213
|
+
const owner = ownerOf(d.event);
|
|
214
|
+
expect(owner === null || typeof owner['tenant'] === 'string', req(ID, 'spec/v2/core/versioning.md §1.2', 'a v1 owner echo, when present, names the tenant (v1 runStarted.owner requires it)')).toBe(true);
|
|
138
215
|
});
|
|
139
216
|
|
|
140
217
|
it('a seeded era-2 run is delivered projected too — the fan-out branch a fresh run cannot reach (seam-gated)', async () => {
|
|
141
218
|
const disc = await v2Discovery();
|
|
142
219
|
if (!disc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
143
220
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
144
|
-
|
|
145
|
-
|
|
221
|
+
// era2Gate answers null when the seam IS advertised and a disposition when it is not — the 2.3.1 file
|
|
222
|
+
// read null as absence, so this leg was inapplicable on exactly the hosts that could witness it.
|
|
223
|
+
const gate = era2Gate(disc);
|
|
224
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
146
225
|
const receiver = await startReceiver(); active = receiver.server;
|
|
147
226
|
if ((await register(receiver.url, 2)) === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
148
227
|
const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
|
|
149
|
-
if (!
|
|
150
|
-
const runId =
|
|
151
|
-
const d = await waitFor(() => receiver.deliveries
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
228
|
+
if (!log.ok) return softSkip(log.kind, log.reason);
|
|
229
|
+
const runId = log.runId;
|
|
230
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
231
|
+
// The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
|
|
232
|
+
// reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
|
|
233
|
+
// era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
|
|
234
|
+
if (!d) return softSkip('inapplicable', `no ${EVENT_TYPE} delivery for the seeded era-2 run inside 15s — this host does not fan out seeded history, so the era-2 fan-out branch is unobservable here (recorded under openwop.family.conformance)`);
|
|
235
|
+
const payload = validators(2).ref(`${V2}run-event-payloads.schema.json#/$defs/runStarted`)(d.event['payload']);
|
|
236
|
+
expect(payload.ok, req(ID, DOC, `an era-2 row delivered to a major-2 subscriber MUST be projected — the read projection applies at the fan-out as at poll/SSE (events.md §Era-2). ${payload.errors}`)).toBe(true);
|
|
237
|
+
const owner = ownerOf(d.event);
|
|
238
|
+
expect(owner === null || !('principal' in owner || 'principalKind' in owner), req(ID, DOC, 'a projected era-2 delivery MUST NOT carry the v1 owner keys')).toBe(true);
|
|
159
239
|
});
|
|
160
240
|
});
|