@openwop/openwop-conformance 2.0.0 → 2.0.2
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,26 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.2] — 2026-09-06 — 2.0.1's own fix could not run: the derived wait exceeded the harness timeout that governs it
|
|
4
|
+
|
|
5
|
+
A regression in 2.0.1, measured by the host that reported the defect 2.0.1 fixed. On a 2.0.1 re-cut one row moved, the wrong way: `0173.webhook-durable-delivery.dead-letter` went `executed-pass` → `executed-fail`.
|
|
6
|
+
|
|
7
|
+
2.0.1 widened the retry wait to a 90 s cap and left both `it()` blocks on the harness default (`vitest.config.ts` `testTimeout: 30_000`, no per-test override). **A wait longer than the timeout that governs it can never elapse** — the test dies at 30 s with "Test timed out in 30000ms". The dead-letter leg is worse than one wait: `waitTerminal` plus **two** sequential `retryWaitMs` waits, up to 191 s inside a 30 s budget. And it took a passing row with it — `dead-letter` passed at rc.67 by observing `attempts.length > 1` inside the old 20 s window, and 2.0.1 moved that leg onto `retryWaitMs` too.
|
|
8
|
+
|
|
9
|
+
Both `it()` blocks now take a timeout **derived from the wait constant** — `RETRY_WAIT_CAP_MS + WAIT_SLACK_MS`, and twice the cap for the two-wait leg — rather than a second literal, so a later change to the wait carries its own budget.
|
|
10
|
+
|
|
11
|
+
The shape is the defect 2.0.1 fixed, displaced one layer: 2.0.1 stopped the scenario blaming a host for a deadline *the scenario* chose, then let *the harness* choose a shorter one silently, on exactly the durable hosts the widening was written to help.
|
|
12
|
+
|
|
13
|
+
## [2.0.1] — 2026-09-05 — the webhook durability scenario read the wrong carrier and imposed its own deadline
|
|
14
|
+
|
|
15
|
+
Two defects in `v2-webhook-durable-delivery`, both reported by a host running against 2.0.0.
|
|
16
|
+
|
|
17
|
+
**It read the v1 carrier.** `advertisedRetryPolicy()` read `triggerBridge.retryPolicy` and its docstring claimed that was "the only v2 carrier". `spec/v2/facets/webhooks.schema.json` says the opposite in as many words — *"retryPolicy is the v2 carrier of the delivery obligation (was triggerBridge.retryPolicy at v1)"* — so a host correctly advertising the **v2** carrier had its policy read as `null`, and a host on the v1 field was measured against a **different subsystem's** budget. The reporting host advertises 8 on its trigger bridge and enforces 5 on webhook delivery, and could not be honest about both under one borrowed field. Now reads `webhooks.retryPolicy` first and falls back to `triggerBridge.retryPolicy` for the v1 overlap the schema preserves.
|
|
18
|
+
|
|
19
|
+
**It imposed a 20-second deadline and blamed the host for missing it.** A host whose first backoff is slower than 20 s was recorded `executed-fail` on a core-standard floor row *for being durable*: the retry lands at t+30 s, the window closed at t+20 s, and the assertion said "a 500 MUST be retried" about a host that retried. The wait now derives from the advertised policy — 20 s floor unchanged when nothing is advertised, widening to a 90 s cap for `fixed`/`exponential`. The cap is deliberate: unbounded waiting would let a host that never retries hold the suite open instead of failing.
|
|
20
|
+
|
|
21
|
+
This is rc.67's poll-cursor defect one file over, and **deterministic rather than flaky** — the instrument's own window, attributed to the host. A scenario must not blame a host for a deadline the scenario chose.
|
|
22
|
+
|
|
23
|
+
|
|
3
24
|
## [2.0.0] — 2026-09-05 — openwop v2
|
|
4
25
|
|
|
5
26
|
The v2 major. `@openwop/openwop-conformance@2.0.0` and `@openwop/spec-artifacts@2.0.0` are one release under two names — conformance pins the contract package to an exact version, so install both.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"typescript": "^5.6.0",
|
|
55
|
-
"@types/node": "^22.0.
|
|
55
|
+
"@types/node": "^22.0.1",
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.2"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -29006,7 +29006,7 @@
|
|
|
29006
29006
|
{
|
|
29007
29007
|
"id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
|
|
29008
29008
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
29009
|
-
"line":
|
|
29009
|
+
"line": 221,
|
|
29010
29010
|
"title": "a failed attempt is retried and the event is delivered at least once",
|
|
29011
29011
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery",
|
|
29012
29012
|
"citations": [
|
|
@@ -29041,7 +29041,7 @@
|
|
|
29041
29041
|
{
|
|
29042
29042
|
"id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
|
|
29043
29043
|
"file": "v2-webhook-durable-delivery.test.ts",
|
|
29044
|
-
"line":
|
|
29044
|
+
"line": 275,
|
|
29045
29045
|
"title": "an exhausted delivery is dead-lettered, never dropped",
|
|
29046
29046
|
"explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
|
|
29047
29047
|
"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.0.
|
|
5
|
-
"corpusTag": "v2.0.
|
|
4
|
+
"version": "2.0.2",
|
|
5
|
+
"corpusTag": "v2.0.2",
|
|
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": "95eccbef6e1f7f51417efe2f307d8950bb520d3b86806bf9d1a316e96aa6b9b5",
|
|
13
|
+
"api/v2/asyncapi.yaml": "70d1a50b3acdf51763239463efe0336fc80b4eb2f9b4ab50d05860ead0490cf0",
|
|
14
|
+
"api/v2/openapi.yaml": "e55547a384bb43f8fe21ae93a27dfc7a16df6494f5ae50aca60028968ce039d4",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "1afaf3936e9d13d5fb71a83fe63a81f8bbb18efb7417c60e523cebf63a5060e7",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -230,8 +230,8 @@
|
|
|
230
230
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
231
231
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
232
232
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
233
|
-
"spec/v2/release.json": "
|
|
233
|
+
"spec/v2/release.json": "fea7f9b757b226e263b7626d1bfebfb680e7ea853cca86b2426c82435e8fdbd4",
|
|
234
234
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
235
235
|
},
|
|
236
|
-
"corpusCommit": "
|
|
236
|
+
"corpusCommit": "7cf764b47ef3b79ddcf5c6e5dfea9f37f9b60a66"
|
|
237
237
|
}
|
|
@@ -117,13 +117,89 @@ async function waitFor(pred: () => boolean, timeoutMs: number): Promise<boolean>
|
|
|
117
117
|
return pred();
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* The retry policy the host advertises for WEBHOOK delivery.
|
|
122
|
+
*
|
|
123
|
+
* 2.0.1: this read the WRONG FIELD. Its docstring claimed
|
|
124
|
+
* `triggerBridge.retryPolicy` was "the only v2 carrier", but
|
|
125
|
+
* `spec/v2/facets/webhooks.schema.json` says the opposite in as many words:
|
|
126
|
+
* "retryPolicy is the v2 carrier of the delivery obligation (was
|
|
127
|
+
* triggerBridge.retryPolicy at v1)", and the field's own description adds
|
|
128
|
+
* "The webhooks family carries it at v2; `triggerBridge.retryPolicy` is the
|
|
129
|
+
* v1 carrier and stays through the overlap."
|
|
130
|
+
*
|
|
131
|
+
* So a host that correctly advertises the v2 carrier had its policy read as
|
|
132
|
+
* `null`, and a host still on the v1 carrier was measured against a policy
|
|
133
|
+
* belonging to a DIFFERENT SUBSYSTEM — the trigger-bridge state machine,
|
|
134
|
+
* whose delivery budget need not equal the webhook one. A tier-1 host
|
|
135
|
+
* reported exactly that: 8 on the trigger bridge, 5 on webhook delivery,
|
|
136
|
+
* and no way to be honest about both under a single borrowed field.
|
|
137
|
+
*
|
|
138
|
+
* `webhooks.retryPolicy` first, `triggerBridge.retryPolicy` second for the
|
|
139
|
+
* v1 overlap the schema explicitly preserves.
|
|
140
|
+
*/
|
|
121
141
|
function advertisedRetryPolicy(doc: Record<string, unknown>): { maxAttempts?: number; backoff?: string } | null {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
const read = (holder: unknown): { maxAttempts?: number; backoff?: string } | null => {
|
|
143
|
+
const rp = holder && typeof holder === 'object' ? (holder as { retryPolicy?: unknown }).retryPolicy : undefined;
|
|
144
|
+
return rp && typeof rp === 'object' ? (rp as { maxAttempts?: number; backoff?: string }) : null;
|
|
145
|
+
};
|
|
146
|
+
return read(doc['webhooks']) ?? read(doc['triggerBridge']);
|
|
125
147
|
}
|
|
126
148
|
|
|
149
|
+
/**
|
|
150
|
+
* How long to wait for a retry, derived from what the host ADVERTISED.
|
|
151
|
+
*
|
|
152
|
+
* 2.0.1: this was a hard 20 s, and a host whose first backoff is deliberately
|
|
153
|
+
* slower than that was recorded `executed-fail` on a core-standard floor row
|
|
154
|
+
* for being durable. Measured on a tier-1 host: Cloud Tasks `minBackoff: 30s`,
|
|
155
|
+
* the retry lands at t+30 s, the window closed at t+20 s, and the assertion
|
|
156
|
+
* said "a 500 MUST be retried" about a host that retried. 30 s is not an
|
|
157
|
+
* unusual first backoff.
|
|
158
|
+
*
|
|
159
|
+
* That is rc.67's poll-cursor defect one file over and DETERMINISTIC rather
|
|
160
|
+
* than flaky: the instrument's own window, attributed to the host. A scenario
|
|
161
|
+
* must not blame a host for a deadline the scenario chose.
|
|
162
|
+
*
|
|
163
|
+
* The floor stays 20 s so a host that advertises nothing is measured exactly
|
|
164
|
+
* as before; an advertised `exponential`/`fixed` backoff widens it to 90 s,
|
|
165
|
+
* which covers a 30 s first attempt with room for the second. The cap is
|
|
166
|
+
* deliberate: unbounded waiting would let a host that never retries hold the
|
|
167
|
+
* suite open instead of failing.
|
|
168
|
+
*/
|
|
169
|
+
const RETRY_WAIT_FLOOR_MS = 20_000;
|
|
170
|
+
const RETRY_WAIT_CAP_MS = 90_000;
|
|
171
|
+
function retryWaitMs(doc: Record<string, unknown>): number {
|
|
172
|
+
const policy = advertisedRetryPolicy(doc);
|
|
173
|
+
if (policy === null) return RETRY_WAIT_FLOOR_MS;
|
|
174
|
+
const backoff = String(policy.backoff ?? '');
|
|
175
|
+
return backoff === 'exponential' || backoff === 'fixed' ? RETRY_WAIT_CAP_MS : RETRY_WAIT_FLOOR_MS;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The per-test budget, DERIVED from the wait above (suite 2.0.2).
|
|
180
|
+
*
|
|
181
|
+
* 2.0.1 raised the derived wait to 90 s and left the `it()` blocks on the
|
|
182
|
+
* harness default (`vitest.config.ts` `testTimeout: 30_000`). A wait longer
|
|
183
|
+
* than the timeout that governs it can never elapse: on exactly the durable
|
|
184
|
+
* hosts the widening was written to help, the test died at 30 s with "Test
|
|
185
|
+
* timed out in 30000ms" — and took `dead-letter` with it, which had passed at
|
|
186
|
+
* the old 20 s window. Measured by a host on 2.0.1 (`00337-dgw`): one row moved
|
|
187
|
+
* `executed-pass -> executed-fail` and it was this one.
|
|
188
|
+
*
|
|
189
|
+
* The shape is the defect 2.0.1 itself fixed, one layer out: 2.0.1 stopped the
|
|
190
|
+
* scenario blaming a host for a deadline the SCENARIO chose, and then let the
|
|
191
|
+
* HARNESS choose a shorter one silently. So the budget is computed from
|
|
192
|
+
* `RETRY_WAIT_CAP_MS` rather than written as a second literal — a later change
|
|
193
|
+
* to the wait carries its own timeout, the way the advert is sourced from the
|
|
194
|
+
* constant the delivery loop reads. `WAIT_SLACK_MS` covers `waitTerminal`,
|
|
195
|
+
* registration and the HTTP round trips around the waits.
|
|
196
|
+
*/
|
|
197
|
+
const WAIT_SLACK_MS = 30_000;
|
|
198
|
+
/** One `retryWaitMs` wait (the retry leg). */
|
|
199
|
+
const RETRY_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS + WAIT_SLACK_MS;
|
|
200
|
+
/** Two sequential `retryWaitMs` waits (the dead-letter leg: observe a retry, then exhaust). */
|
|
201
|
+
const DEAD_LETTER_TEST_TIMEOUT_MS = RETRY_WAIT_CAP_MS * 2 + WAIT_SLACK_MS;
|
|
202
|
+
|
|
127
203
|
/** Register the suite receiver; null (with a note) when the host's SSRF guard refuses a loopback URL. */
|
|
128
204
|
async function register(url: string): Promise<{ webhookId: string } | null> {
|
|
129
205
|
const registration = resolveRegistrationUrl(url);
|
|
@@ -159,7 +235,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
159
235
|
await waitTerminal(runId, 10_000);
|
|
160
236
|
|
|
161
237
|
const ours = () => receiver.attempts.filter((a) => a.runId === runId);
|
|
162
|
-
const retried = await waitFor(() => ours().some((a) => a.status === 204),
|
|
238
|
+
const retried = await waitFor(() => ours().some((a) => a.status === 204), retryWaitMs(doc));
|
|
163
239
|
const attempts = ours();
|
|
164
240
|
expect(
|
|
165
241
|
attempts.length,
|
|
@@ -194,7 +270,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
194
270
|
|
|
195
271
|
const del = await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
196
272
|
expect(del.status, req('openwop.requirement.0173.webhook-durable-delivery', 'webhooks.md §Surfaces', 'DELETE /webhooks/{webhookId} MUST answer 204')).toBe(204);
|
|
197
|
-
});
|
|
273
|
+
}, RETRY_TEST_TIMEOUT_MS);
|
|
198
274
|
|
|
199
275
|
it('an exhausted delivery is dead-lettered, never dropped', async () => {
|
|
200
276
|
const doc = await discovery();
|
|
@@ -212,7 +288,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
212
288
|
const runId = (create.json as { runId: string }).runId;
|
|
213
289
|
await waitTerminal(runId, 10_000);
|
|
214
290
|
const ours = () => receiver.attempts.filter((a) => a.runId === runId);
|
|
215
|
-
await waitFor(() => ours().length > 1,
|
|
291
|
+
await waitFor(() => ours().length > 1, retryWaitMs(doc));
|
|
216
292
|
const attempts = ours();
|
|
217
293
|
expect(
|
|
218
294
|
attempts.length,
|
|
@@ -221,7 +297,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
221
297
|
const policy = advertisedRetryPolicy(doc);
|
|
222
298
|
if (policy?.maxAttempts !== undefined) {
|
|
223
299
|
// Give the policy time to exhaust, then the host MUST stop.
|
|
224
|
-
await waitFor(() => ours().length >= policy.maxAttempts!,
|
|
300
|
+
await waitFor(() => ours().length >= policy.maxAttempts!, retryWaitMs(doc));
|
|
225
301
|
await new Promise((r) => setTimeout(r, 1_000));
|
|
226
302
|
expect(
|
|
227
303
|
ours().length,
|
|
@@ -234,5 +310,5 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
234
310
|
// (no GET /webhooks/{webhookId}/dead-letters). Without a normative read the
|
|
235
311
|
// routing to the sink is not observable from the suite.
|
|
236
312
|
return softSkip('blocked', 'no normative dead-letter read surface for webhook deliveries in api/v2/openapi.yaml (a GET /webhooks/{webhookId}/dead-letters projection is needed) — exhaustion was observed, routing to the sink was not');
|
|
237
|
-
});
|
|
313
|
+
}, DEAD_LETTER_TEST_TIMEOUT_MS);
|
|
238
314
|
});
|