@openwop/openwop-conformance 2.33.2 → 2.34.1
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 +25 -0
- package/README.md +3 -1
- package/coverage.md +1 -1
- package/dist/cli.js +23 -1
- package/dist/lib/certification-bundle-v3.js +9 -1
- package/dist/lib/durability-evidence.js +182 -0
- package/dist/lib/requirement-ledger.js +1 -0
- package/dist/lib/scenario-disposition.js +4 -0
- package/dist/lib/soft-skip.js +27 -1
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +21 -9
- package/schemas/CORPUS-STAMP.json +11 -11
- package/src/cli.ts +23 -1
- package/src/lib/certification-bundle-v3.ts +13 -1
- package/src/lib/durability-evidence.ts +175 -0
- package/src/lib/requirement-ledger.ts +7 -1
- package/src/lib/scenario-disposition.ts +4 -1
- package/src/lib/soft-skip.ts +30 -4
- package/src/lib/webhook-retry-window.ts +56 -0
- package/src/scenarios/v2-durability-recovery.test.ts +61 -13
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +71 -25
- package/src/setup.ts +3 -1
|
@@ -74,6 +74,7 @@ import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
|
74
74
|
import { req } from '../lib/requirement-ids.js';
|
|
75
75
|
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
76
76
|
import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
|
|
77
|
+
import { noteEvidence, parseRecoveryBounds, EVIDENCE_NAME_PATTERN } from '../lib/durability-evidence.js';
|
|
77
78
|
|
|
78
79
|
const FIXTURE = 'conformance-noop';
|
|
79
80
|
const FAILURE_FIXTURE = 'conformance-failure';
|
|
@@ -186,6 +187,32 @@ const KILL_ROW_TIMEOUT_MS = OBSERVATION_CEILING_MS + 120_000;
|
|
|
186
187
|
/** Used only when the host serves no bound to read; named in the row's detail. */
|
|
187
188
|
const UNDECLARED_BOUND_FALLBACK_MS = 60_000;
|
|
188
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The declared recovery bounds, PER CLASS, read off the seam and normalised.
|
|
192
|
+
*
|
|
193
|
+
* Three response shapes are accepted, because the seam is non-normative and two
|
|
194
|
+
* hosts built it before this was written down:
|
|
195
|
+
* { classes: [{ class, bound, terms[] }] } — the preferred shape
|
|
196
|
+
* { classes: { <name>: { bound, terms[] } } } — a map keyed by class
|
|
197
|
+
* { bound, terms[], class? } — one class; named `class`
|
|
198
|
+
* or, absent that, `default`
|
|
199
|
+
* A top-level scalar `bound` beside `classes` is IGNORED: Unresolved Question 1
|
|
200
|
+
* resolved per class, and a convenience total is the aggregation it rejects.
|
|
201
|
+
* Only `{ name, ms }` survives from a term — free text a host attaches (the
|
|
202
|
+
* reference host's `enforcedBy`) never reaches a published bundle.
|
|
203
|
+
*/
|
|
204
|
+
function readClasses(json: unknown): unknown[] | null {
|
|
205
|
+
const body = (json as { classes?: unknown; class?: unknown; bound?: unknown; terms?: unknown } | null) ?? {};
|
|
206
|
+
const strip = (cls: unknown, e: { bound?: unknown; terms?: unknown }): unknown => ({
|
|
207
|
+
class: cls, bound: e.bound,
|
|
208
|
+
terms: Array.isArray(e.terms) ? (e.terms as Array<{ name?: unknown; ms?: unknown }>).map((t) => ({ name: t?.name, ms: t?.ms })) : e.terms,
|
|
209
|
+
});
|
|
210
|
+
if (Array.isArray(body.classes)) return (body.classes as Array<{ class?: unknown; bound?: unknown; terms?: unknown }>).map((e) => strip(e?.class, e ?? {}));
|
|
211
|
+
if (body.classes !== null && typeof body.classes === 'object') return Object.entries(body.classes as Record<string, { bound?: unknown; terms?: unknown }>).map(([k, e]) => strip(k, e ?? {}));
|
|
212
|
+
if (body.bound !== undefined || body.terms !== undefined) return [strip(typeof body.class === 'string' ? body.class : 'default', body)];
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
189
216
|
/**
|
|
190
217
|
* How long to keep observing: the host's OWN declared recovery bound.
|
|
191
218
|
*
|
|
@@ -199,19 +226,21 @@ const UNDECLARED_BOUND_FALLBACK_MS = 60_000;
|
|
|
199
226
|
* bound is used as a CEILING FOR WAITING only; it is never asserted as a
|
|
200
227
|
* scalar here (`bound-is-derived` owns the arithmetic).
|
|
201
228
|
*/
|
|
202
|
-
async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared: boolean }> {
|
|
229
|
+
async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared: boolean; recoveryClass: string | null }> {
|
|
230
|
+
const named = (fired as { recoveryClass?: unknown } | null)?.recoveryClass;
|
|
231
|
+
const recoveryClass = typeof named === 'string' && EVIDENCE_NAME_PATTERN.test(named) ? named : null;
|
|
203
232
|
// A host whose bound is PER CLASS (Unresolved Question 1: unleased work waits
|
|
204
233
|
// out an outbox lease, leased work a dispatch lease — 65 s against 750 s on
|
|
205
234
|
// one measured host) names the figure that governs THIS work on the seam's
|
|
206
235
|
// own response. The bare read below returns one class and would report the
|
|
207
236
|
// interval against a bound that does not govern the staged run.
|
|
208
237
|
const governing = (fired as { recoveryBoundMs?: unknown } | null)?.recoveryBoundMs;
|
|
209
|
-
if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true };
|
|
238
|
+
if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true, recoveryClass };
|
|
210
239
|
const r = await driver.get('/host/durability/bound');
|
|
211
240
|
const bound = (r.json as { bound?: unknown } | null)?.bound;
|
|
212
241
|
return r.status === 200 && typeof bound === 'number' && Number.isFinite(bound) && bound > 0
|
|
213
|
-
? { ms: bound, declared: true }
|
|
214
|
-
: { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
|
|
242
|
+
? { ms: bound, declared: true, recoveryClass }
|
|
243
|
+
: { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false, recoveryClass };
|
|
215
244
|
}
|
|
216
245
|
|
|
217
246
|
/**
|
|
@@ -294,6 +323,15 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
294
323
|
w.resumedAfterMs !== null,
|
|
295
324
|
req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume within the declared recovery bound — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.nodeStarted} node.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; dispatch first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
|
|
296
325
|
).toBe(true);
|
|
326
|
+
// RFC 0158 §E, 2.34.0: the interval this row MEASURED is evidence, and until
|
|
327
|
+
// now it existed only inside a failure message — a passing row recorded
|
|
328
|
+
// nothing, so a bundle could not show what was observed or against which
|
|
329
|
+
// class. Recorded only when the seam NAMED the class it exercised: without
|
|
330
|
+
// that the bundle cannot bind this exercise to a declared bound, the rung is
|
|
331
|
+
// not derivable, and saying so is more honest than guessing a class.
|
|
332
|
+
if (bound.declared && bound.recoveryClass !== null && w.resumedAfterMs !== null) {
|
|
333
|
+
noteEvidence({ recovery: { class: bound.recoveryClass, boundMs: Math.round(bound.ms), observedMs: backIn + w.resumedAfterMs } });
|
|
334
|
+
}
|
|
297
335
|
}, KILL_ROW_TIMEOUT_MS);
|
|
298
336
|
|
|
299
337
|
it('work executing at a real process death is never reported complete, and resumes', async () => {
|
|
@@ -341,6 +379,15 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
341
379
|
w.resumedAfterMs !== null,
|
|
342
380
|
req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST resume within the declared recovery bound; §B.4 measures kill → RESUMPTION, never kill → terminal — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; resumption first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
|
|
343
381
|
).toBe(true);
|
|
382
|
+
// RFC 0158 §E, 2.34.0: the interval this row MEASURED is evidence, and until
|
|
383
|
+
// now it existed only inside a failure message — a passing row recorded
|
|
384
|
+
// nothing, so a bundle could not show what was observed or against which
|
|
385
|
+
// class. Recorded only when the seam NAMED the class it exercised: without
|
|
386
|
+
// that the bundle cannot bind this exercise to a declared bound, the rung is
|
|
387
|
+
// not derivable, and saying so is more honest than guessing a class.
|
|
388
|
+
if (bound.declared && bound.recoveryClass !== null && w.resumedAfterMs !== null) {
|
|
389
|
+
noteEvidence({ recovery: { class: bound.recoveryClass, boundMs: Math.round(bound.ms), observedMs: backIn + w.resumedAfterMs } });
|
|
390
|
+
}
|
|
344
391
|
}, KILL_ROW_TIMEOUT_MS);
|
|
345
392
|
|
|
346
393
|
it('the same accepted work delivered twice fires each effect exactly once', async () => {
|
|
@@ -427,24 +474,25 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
427
474
|
if (terms.status === 404 || terms.status === 405) {
|
|
428
475
|
return softSkip('blocked', 'the host exposes the durability seam but serves no recovery-bound terms — §E puts the per-class arithmetic in the RFC 0148 evidence bundle where a reader can recompute it, and there is nothing here to recompute');
|
|
429
476
|
}
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (bound === null || parts === null || parts.length === 0) {
|
|
434
|
-
return softSkip('blocked', 'the recovery-bound response names no { bound, terms[] } — §E asks for the per-class arithmetic, not a single total (Unresolved Question 1), and a total alone cannot be recomputed');
|
|
477
|
+
const classes = readClasses(terms.json);
|
|
478
|
+
if (classes === null) {
|
|
479
|
+
return softSkip('blocked', 'the recovery-bound response names no classes and no { bound, terms[] } — §E asks for the per-class arithmetic, not a single total (Unresolved Question 1), and a total alone cannot be recomputed');
|
|
435
480
|
}
|
|
436
|
-
const summed = parts.reduce<number>((acc, t) => acc + (typeof (t as { ms?: unknown }).ms === 'number' ? (t as { ms: number }).ms : Number.NaN), 0);
|
|
437
481
|
// THIS ROW IS A PAPER CHECK BY CONSTRUCTION AND THE RFC SAYS SO. It checks
|
|
438
|
-
// that
|
|
482
|
+
// that each declared number follows from the stated mechanism. It cannot
|
|
439
483
|
// check that the mechanism RUNS: a host whose sweeper wedges has a
|
|
440
484
|
// derivation that stays perfectly correct while the bound is not produced
|
|
441
485
|
// at all — a run sat unclaimed for 16 minutes against a derived bound of
|
|
442
486
|
// 12.5 with every isolated check of the mechanism passing. Only the kill
|
|
443
487
|
// rows above witness liveness, and this row MUST NOT be cited for it.
|
|
488
|
+
const parsed = parseRecoveryBounds(classes);
|
|
444
489
|
expect(
|
|
445
|
-
|
|
446
|
-
req('openwop.requirement.0158.bound-is-derived', 'RFC 0158 §B.5', `
|
|
490
|
+
parsed.ok,
|
|
491
|
+
req('openwop.requirement.0158.bound-is-derived', 'RFC 0158 §B.5', `every declared recovery bound MUST follow from the per-class terms that produce it, each term { name, ms } — ${parsed.ok ? '' : parsed.why}. A host that states a bound it cannot produce fails; this checks the ARITHMETIC only, never that the mechanism runs`),
|
|
447
492
|
).toBe(true);
|
|
493
|
+
// The derivation goes INTO THE BUNDLE, per class, where a reader can
|
|
494
|
+
// recompute it (§E) — until 2.34.0 it lived only on this seam route.
|
|
495
|
+
if (parsed.ok) noteEvidence({ recoveryBounds: parsed.bounds });
|
|
448
496
|
}, 120_000);
|
|
449
497
|
|
|
450
498
|
it('deterministically failing work reaches a terminal state and stops being retried', async () => {
|
|
@@ -34,8 +34,9 @@ import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
|
34
34
|
import { projectBoundId } from '../lib/bound-id.js';
|
|
35
35
|
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
36
36
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
37
|
+
import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
|
|
38
38
|
import { req } from '../lib/requirement-ids.js';
|
|
39
|
+
import { retryWaitCapMs, retryWaitFor, windowClosedNote } from '../lib/webhook-retry-window.js';
|
|
39
40
|
|
|
40
41
|
const FIXTURE = 'conformance-noop';
|
|
41
42
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
@@ -167,14 +168,14 @@ function advertisedRetryPolicy(doc: Record<string, unknown>): { maxAttempts?: nu
|
|
|
167
168
|
* which covers a 30 s first attempt with room for the second. The cap is
|
|
168
169
|
* deliberate: unbounded waiting would let a host that never retries hold the
|
|
169
170
|
* suite open instead of failing.
|
|
171
|
+
*
|
|
172
|
+
* 2.34.1: and 90 s was the same defect again, one schedule further out — see
|
|
173
|
+
* `lib/webhook-retry-window.ts`. The cap is now operator-RAISABLE through
|
|
174
|
+
* `OPENWOP_WEBHOOK_RETRY_WAIT_MS` and never lowerable; still bounded.
|
|
170
175
|
*/
|
|
171
|
-
const
|
|
172
|
-
const RETRY_WAIT_CAP_MS = 90_000;
|
|
176
|
+
const RETRY_WAIT_CAP_MS = retryWaitCapMs();
|
|
173
177
|
function retryWaitMs(doc: Record<string, unknown>): number {
|
|
174
|
-
|
|
175
|
-
if (policy === null) return RETRY_WAIT_FLOOR_MS;
|
|
176
|
-
const backoff = String(policy.backoff ?? '');
|
|
177
|
-
return backoff === 'exponential' || backoff === 'fixed' ? RETRY_WAIT_CAP_MS : RETRY_WAIT_FLOOR_MS;
|
|
178
|
+
return retryWaitFor(advertisedRetryPolicy(doc), RETRY_WAIT_CAP_MS);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
/**
|
|
@@ -330,8 +331,26 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
330
331
|
const sub = await register(receiver.url);
|
|
331
332
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
332
333
|
|
|
334
|
+
// The row id is the FIRST thing this leg names: `register()` asserts under
|
|
335
|
+
// the base `0173.webhook-durable-delivery` id, and a leg that returned before
|
|
336
|
+
// any `.dead-letter` req() ran recorded its outcome under THAT id, leaving
|
|
337
|
+
// `.dead-letter` with no row in the bundle at all (measured, 2.34.1 draft).
|
|
338
|
+
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', 'an exhausted delivery MUST be routed to the sink, not dropped');
|
|
333
339
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
334
|
-
|
|
340
|
+
// 2.34.1 — NO OBLIGATION IS ASSERTED UNTIL THE WINDOW QUESTION IS ANSWERED. A leg
|
|
341
|
+
// that asserts and THEN soft-skips records `executed-pass` with a
|
|
342
|
+
// `partial-witness:` detail (`resolveItRecord`), which certifies. The first
|
|
343
|
+
// draft of this fix asserted `create.status` and the retry, then
|
|
344
|
+
// soft-skipped `blocked` on a closed window, and so turned a false FAIL into
|
|
345
|
+
// a pass that never looked at the sink — caught by measuring it: five rows
|
|
346
|
+
// `executed-pass`, 106 s, on a host whose first delivery exhausts at 225 s.
|
|
347
|
+
// So everything is OBSERVED first, the one inconclusive case returns with
|
|
348
|
+
// zero assertions (a real `blocked`, which denies certification), and only
|
|
349
|
+
// then are the obligations asserted, in their original order. A failure the
|
|
350
|
+
// observations already show still fails: the create check fires at once.
|
|
351
|
+
if (create.status !== 201) {
|
|
352
|
+
expect(create.status, req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
353
|
+
}
|
|
335
354
|
const runId = (create.json as { runId: string }).runId;
|
|
336
355
|
await waitTerminal(runId, 10_000);
|
|
337
356
|
// Filter by the delivery's own SUBSCRIPTION, not just its run. A host that
|
|
@@ -344,19 +363,11 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
344
363
|
const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
|
|
345
364
|
await waitFor(() => ours().length > 1, retryWaitMs(doc));
|
|
346
365
|
const attempts = ours();
|
|
347
|
-
expect(
|
|
348
|
-
attempts.length,
|
|
349
|
-
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', 'a delivery that keeps failing MUST be retried before it can be exhausted (one attempt is a drop)'),
|
|
350
|
-
).toBeGreaterThan(1);
|
|
351
366
|
const policy = advertisedRetryPolicy(doc);
|
|
352
|
-
if (policy?.maxAttempts !== undefined) {
|
|
367
|
+
if (policy?.maxAttempts !== undefined && attempts.length > 1) {
|
|
353
368
|
// Give the policy time to exhaust, then the host MUST stop.
|
|
354
369
|
await waitFor(() => ours().length >= policy.maxAttempts!, retryWaitMs(doc));
|
|
355
370
|
await new Promise((r) => setTimeout(r, 1_000));
|
|
356
|
-
expect(
|
|
357
|
-
ours().length,
|
|
358
|
-
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', `retries MUST stop at the advertised retryPolicy.maxAttempts (${policy.maxAttempts}) — exhaustion routes to the dead-letter sink, not to an unbounded loop`),
|
|
359
|
-
).toBeLessThanOrEqual(policy.maxAttempts);
|
|
360
371
|
}
|
|
361
372
|
// The sink itself. Until RFC 0188 this was an UNCONDITIONAL soft-skip on
|
|
362
373
|
// every host — `webhooks.md` §Durability said the sink was "inspectable for
|
|
@@ -364,14 +375,41 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
364
375
|
// every bundle recorded "exhaustion was observed, routing to the sink was
|
|
365
376
|
// not". The read exists now; read it before deleting the subscription.
|
|
366
377
|
const fam = await gateFamily('webhooks');
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
378
|
+
let inSink: boolean | null = null;
|
|
379
|
+
if (fam?.['deadLetter']) {
|
|
380
|
+
const sink = await driver.get(`/webhooks/${projectBoundId(sub.webhookId)}/dead-letters`);
|
|
381
|
+
inSink = sink.status === 200 && ((sink.json as { deliveries?: unknown[] } | null)?.deliveries ?? []).some((r) => (r as Record<string, unknown>)['runId'] === runId);
|
|
370
382
|
}
|
|
371
|
-
const sink = await driver.get(`/webhooks/${projectBoundId(sub.webhookId)}/dead-letters`);
|
|
372
383
|
await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
384
|
+
// "Routed to the sink, not dropped" is a claim about an EXHAUSTED delivery,
|
|
385
|
+
// and until 2.34.1 it was asserted whether or not exhaustion had been
|
|
386
|
+
// observed: a host retrying at 15 / 30 / 60 / 120 s had made 4 of its 5
|
|
387
|
+
// attempts when the 90 s window closed, and this row said it had dropped a
|
|
388
|
+
// delivery still in flight. Retried, fewer attempts than advertised, and
|
|
389
|
+
// nothing in the sink is a window that closed early OR a host that stopped
|
|
390
|
+
// retrying — indistinguishable without an interval on the wire — so it is
|
|
391
|
+
// `blocked`, never a conviction. Reached maxAttempts with nothing in the
|
|
392
|
+
// sink still FAILS below; so does a delivery that was never retried.
|
|
393
|
+
const seen = ours().length;
|
|
394
|
+
if (inSink === false && policy?.maxAttempts !== undefined && attempts.length > 1 && seen < policy.maxAttempts) {
|
|
395
|
+
return blockedDespiteAssertions(windowClosedNote(seen, policy.maxAttempts, retryWaitMs(doc), RETRY_WAIT_CAP_MS));
|
|
396
|
+
}
|
|
397
|
+
expect(create.status, req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
398
|
+
expect(
|
|
399
|
+
attempts.length,
|
|
400
|
+
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', 'a delivery that keeps failing MUST be retried before it can be exhausted (one attempt is a drop)'),
|
|
401
|
+
).toBeGreaterThan(1);
|
|
402
|
+
if (policy?.maxAttempts !== undefined) {
|
|
403
|
+
expect(
|
|
404
|
+
ours().length,
|
|
405
|
+
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', `retries MUST stop at the advertised retryPolicy.maxAttempts (${policy.maxAttempts}) — exhaustion routes to the dead-letter sink, not to an unbounded loop`),
|
|
406
|
+
).toBeLessThanOrEqual(policy.maxAttempts);
|
|
407
|
+
}
|
|
408
|
+
if (inSink === null) {
|
|
409
|
+
return softSkip('inapplicable', 'host does not advertise the webhooks.deadLetter facet — RFC 0188 §A.5 makes the read a 404 rather than an obligation, so the sink half of §Durability is unwitnessable here (the retry half above passed)');
|
|
410
|
+
}
|
|
373
411
|
expect(
|
|
374
|
-
|
|
412
|
+
inSink,
|
|
375
413
|
req('openwop.requirement.0173.webhook-durable-delivery.dead-letter', 'webhooks.md §Durability', 'an exhausted delivery MUST be routed to the sink, not dropped — the half no bundle could witness before RFC 0188 served a read'),
|
|
376
414
|
).toBe(true);
|
|
377
415
|
}, DEAD_LETTER_TEST_TIMEOUT_MS);
|
|
@@ -424,10 +462,14 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
424
462
|
active = receiver.server;
|
|
425
463
|
const sub = await register(receiver.url);
|
|
426
464
|
if (sub === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
465
|
+
// Same two traps as the leg above (2.34.1): `register()` asserts under the base
|
|
466
|
+
// id, so name this row first; and every `blocked` after it must STAND rather
|
|
467
|
+
// than fold into a partial-witness pass that certifies.
|
|
468
|
+
req('openwop.requirement.0188.dead-letter-content-free', 'RFC 0188 §B.1', 'a dead-letter record MUST NOT carry the delivered body, headers or subscription secret');
|
|
427
469
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
428
470
|
if (create.status !== 201) {
|
|
429
471
|
await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
430
|
-
return
|
|
472
|
+
return blockedDespiteAssertions(`POST /runs answered ${create.status} — no delivery to exhaust`);
|
|
431
473
|
}
|
|
432
474
|
const runId = (create.json as { runId: string }).runId;
|
|
433
475
|
await waitTerminal(runId, 10_000);
|
|
@@ -441,12 +483,16 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
|
|
|
441
483
|
|
|
442
484
|
const sink = await driver.get(`/webhooks/${projectBoundId(sub.webhookId)}/dead-letters`);
|
|
443
485
|
await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
|
|
444
|
-
if (sink.status !== 200) return
|
|
486
|
+
if (sink.status !== 200) return blockedDespiteAssertions(`the dead-letter read answered ${sink.status}`);
|
|
445
487
|
const rows = ((sink.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
|
|
446
488
|
// An empty sink is NOT a pass. Recording one as a pass is exactly the
|
|
447
489
|
// vacuous witness this leg used to produce; say so instead.
|
|
448
490
|
if (rows.length === 0) {
|
|
449
|
-
|
|
491
|
+
const seen = ours().length;
|
|
492
|
+
const why = policy?.maxAttempts !== undefined && seen < policy.maxAttempts
|
|
493
|
+
? windowClosedNote(seen, policy.maxAttempts, retryWaitMs(doc), RETRY_WAIT_CAP_MS)
|
|
494
|
+
: `the delivery made ${seen} attempt(s) and is not in the sink`;
|
|
495
|
+
return blockedDespiteAssertions(`§B.1 is a claim about a real record and there is none here to read — ${why}`);
|
|
450
496
|
}
|
|
451
497
|
expect(
|
|
452
498
|
rows.every((r) => r['body'] === undefined && r['headers'] === undefined && r['secret'] === undefined),
|
package/src/setup.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-cohe
|
|
|
43
43
|
import type { DiscoveryPayload } from './lib/profiles.js';
|
|
44
44
|
import { targetMajor } from './lib/seams.js';
|
|
45
45
|
import { softSkip } from './lib/soft-skip.js';
|
|
46
|
+
import { takeNotedEvidence } from './lib/durability-evidence.js';
|
|
46
47
|
|
|
47
48
|
// 20 s, not 5: a Cloud Run cold start routinely exceeds 5 s, and a discovery
|
|
48
49
|
// fetch that aborted at init used to turn every fixture-gated scenario into a
|
|
@@ -457,7 +458,8 @@ afterEach(({ task }) => {
|
|
|
457
458
|
detail = rec.detail;
|
|
458
459
|
}
|
|
459
460
|
try {
|
|
460
|
-
|
|
461
|
+
const evidence = takeNotedEvidence();
|
|
462
|
+
recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file, ...(evidence === null ? {} : { evidence }) });
|
|
461
463
|
} catch {
|
|
462
464
|
/* never fail a test for bookkeeping */
|
|
463
465
|
}
|