@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
|
@@ -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.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.34.1",
|
|
5
|
+
"corpusTag": "v2.34.0",
|
|
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": "d26d906b044e53fbb6ba742be90c853b9dcf14af4005bb6376e354d618fbdd5a",
|
|
13
|
+
"api/v2/asyncapi.yaml": "eda3679b6902a3ab931e2dab55a04f6b6bb2b9b664748de38d8a104e53e93c76",
|
|
14
|
+
"api/v2/openapi.yaml": "313fc72043a3730fbb98fdf47f4539773564bf9c5bed54f1f180efb50cf57410",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
|
|
118
118
|
"schemas/v2/capabilities.schema.json": "e62d44f23c21c8f64b273fcdb083636e801c0de976cbd1f93333f99928dd1025",
|
|
119
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
119
|
+
"schemas/v2/certification-bundle.schema.json": "46a5a07670f2457bf1b7af8f6df1f4f76edb3ff7e64d1b6ed85738270c95668e",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
122
122
|
"schemas/v2/chat-card-pack-manifest.schema.json": "f7cf09b30d3e1d2251620d84ea9f65541438d015ab44aec10d1dcfe68d8bd496",
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
202
202
|
"spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
|
|
203
203
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
204
|
-
"spec/v1/core-standard-manifest.json": "
|
|
204
|
+
"spec/v1/core-standard-manifest.json": "0c1aaddf25eefbbe473d543067838a4a8eed8d3e5c00b55ce0ca98eabbc1ce66",
|
|
205
205
|
"spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
|
|
206
206
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
207
207
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
|
|
216
216
|
"spec/v2/README.md": "8dbfc17b10f373dba95bdd2e8fec0580f35b380a834d304be3e24e7498170185",
|
|
217
217
|
"spec/v2/core/capabilities.md": "4c4e5577caf4ae20c84fe59d580de4c6599e76783471d3b59f9730fc3807de71",
|
|
218
|
-
"spec/v2/core/conformance.md": "
|
|
218
|
+
"spec/v2/core/conformance.md": "e5cb403f6ea6633eebc8ee4fa7bde5bf023bf4aee7c959b849e7105d37ab1f8b",
|
|
219
219
|
"spec/v2/core/connection-packs.md": "466bdb9dde79d85615ad8281dacfb7bf923ca22d7fb3ed219749e59574c753ed",
|
|
220
220
|
"spec/v2/core/conversation.md": "e425887c4ba199b8cdc5c99e792689f7bd7d46de492f28670de2e402fa6d9506",
|
|
221
221
|
"spec/v2/core/errors.md": "0dff4a0ae102a86f6a17a9a3b16a203292588a0f422ed19d1c0d9213995aa01d",
|
|
@@ -229,7 +229,7 @@
|
|
|
229
229
|
"spec/v2/core/interrupt.md": "3a926d84b942d4ee7daccc4443c93dc29f6a69f2936ba813182f2c1336db2cbb",
|
|
230
230
|
"spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
|
|
231
231
|
"spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
|
|
232
|
-
"spec/v2/core/persistence.md": "
|
|
232
|
+
"spec/v2/core/persistence.md": "9785e536362ec2000dab7fee9847543240d40a907745efc70cf2c8b4c1b8344e",
|
|
233
233
|
"spec/v2/core/replay.md": "b980a66e2543107240e3dc4f58131b9f4ff52478737e79ade6d5777b26ad1119",
|
|
234
234
|
"spec/v2/core/runs.md": "136319842bacbbb300178980e4b83444917d5b193cc5fdd92e39a4902ba8d3c0",
|
|
235
235
|
"spec/v2/core/security-defaults.md": "2a54ceaa4cef02c26ee0fbcf44f822ceeea79537c7d5a51ed300f21aa4fea576",
|
|
@@ -279,8 +279,8 @@
|
|
|
279
279
|
"spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
|
|
280
280
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
281
281
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
282
|
-
"spec/v2/release.json": "
|
|
282
|
+
"spec/v2/release.json": "eaf652e3a01f02a1d9c4eee7d7ea2c2a0396fe5694b5dce4cb01ca2af2891cc5",
|
|
283
283
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
284
284
|
},
|
|
285
|
-
"corpusCommit": "
|
|
285
|
+
"corpusCommit": "ffc0f6bf6bfe378c1e69f94386373687b15cfdb6"
|
|
286
286
|
}
|
package/src/cli.ts
CHANGED
|
@@ -42,6 +42,7 @@ import Ajv2020 from 'ajv/dist/2020.js';
|
|
|
42
42
|
import addFormats from 'ajv-formats';
|
|
43
43
|
import { SCHEMAS_DIR } from './lib/paths.js';
|
|
44
44
|
import { readLedgerFile } from './lib/requirement-ledger.js';
|
|
45
|
+
import { deriveRung, emittedByNewerSuite, type RowEvidence } from './lib/durability-evidence.js';
|
|
45
46
|
import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
|
|
46
47
|
import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
|
|
47
48
|
import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Requirement } from './lib/certification-bundle-v3.js';
|
|
@@ -658,7 +659,13 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
658
659
|
process.stderr.write('openwop-conformance --certify: a v3 bundle needs --host-build <kind>:<id> (or OPENWOP_HOST_BUILD), --signing-key <pem> (or OPENWOP_BUNDLE_SIGNING_KEY) and --signing-key-id (or OPENWOP_BUNDLE_SIGNING_KEY_ID) — an unsigned bundle does not exist in v3 (RFC 0168 §E.2).\n');
|
|
659
660
|
process.exit(2);
|
|
660
661
|
}
|
|
661
|
-
const
|
|
662
|
+
const evidenceById = new Map<string, RowEvidence>();
|
|
663
|
+
for (const e of ledgerEntries) if (e.evidence !== undefined && e.disposition === 'executed-pass') evidenceById.set(e.requirementId, e.evidence);
|
|
664
|
+
const rows3: BundleV3Requirement[] = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition as BundleV3Requirement['result'], ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }),
|
|
665
|
+
// RFC 0158 §E: structured evidence rides on the ROW, so the witness digest —
|
|
666
|
+
// and through it the signature — covers it. Lifted from the raw ledger by
|
|
667
|
+
// requirement id, and only onto a row that is itself `executed-pass`.
|
|
668
|
+
...(r.disposition === 'executed-pass' && evidenceById.has(r.requirementId) ? { evidence: evidenceById.get(r.requirementId) as RowEvidence } : {}) }));
|
|
662
669
|
const totals3 = derived.totals;
|
|
663
670
|
const doc3 = document as Record<string, unknown>;
|
|
664
671
|
const protocolVersions = Array.isArray(doc3['protocolVersions']) ? (doc3['protocolVersions'] as string[]) : [String(doc3['protocolVersion'] ?? '')];
|
|
@@ -701,6 +708,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
701
708
|
const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
|
|
702
709
|
const lock = existsSync(lockPath) ? (JSON.parse(readFileSync(lockPath, 'utf8')) as { version: string; stampSha256: string }) : undefined;
|
|
703
710
|
const nonPass = rows3.filter((r) => r.result !== 'executed-pass');
|
|
711
|
+
const rung3 = deriveRung(rows3);
|
|
712
|
+
if (rows3.some((r) => r.id.startsWith('openwop.requirement.0158.') && r.result === 'executed-pass' && r.id !== 'openwop.requirement.0158.poison-exhaustion')) {
|
|
713
|
+
process.stderr.write(`openwop-conformance --certify: RFC 0158 rung — ${rung3.rung ?? 'NONE'} (${rung3.why})\n`);
|
|
714
|
+
}
|
|
704
715
|
const unsigned: Omit<BundleV3, 'signature'> = {
|
|
705
716
|
bundleVersion: '3',
|
|
706
717
|
generatedAt: new Date().toISOString(),
|
|
@@ -716,6 +727,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
716
727
|
witnessSha256: witnessDigest(rows3),
|
|
717
728
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
718
729
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
730
|
+
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
731
|
+
// re-derives the same answer from the same signed rows, so an emitter that
|
|
732
|
+
// claimed more would be writing a bundle its own `--verify` rejects.
|
|
733
|
+
...(rung3.rung === null ? {} : { durability: { rung: rung3.rung } }),
|
|
719
734
|
};
|
|
720
735
|
const signature = signBundleV3(unsigned, signingKeyPem, keyId);
|
|
721
736
|
const v3: BundleV3 = { ...unsigned, signature };
|
|
@@ -942,6 +957,8 @@ async function main(): Promise<never> {
|
|
|
942
957
|
`suite: ${bundle.suite?.version ?? '?'} (this CLI is ${suiteVersion()})`,
|
|
943
958
|
`totals: executedPass=${t.executedPass ?? '?'} executedFail=${t.executedFail ?? '?'} blocked=${t.blocked ?? '?'} inapplicable=${t.inapplicable ?? '?'} skipped=${t.skipped ?? '?'}`,
|
|
944
959
|
`certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
|
|
960
|
+
// RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
|
|
961
|
+
`rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
|
|
945
962
|
'',
|
|
946
963
|
'What this command does NOT do:',
|
|
947
964
|
' · It does not re-run anything. A host that measured itself wrongly, and signed',
|
|
@@ -952,6 +969,11 @@ async function main(): Promise<never> {
|
|
|
952
969
|
' an older bundle measured less, and whose fact that is belongs to its emitter.',
|
|
953
970
|
'',
|
|
954
971
|
];
|
|
972
|
+
if (emittedByNewerSuite(bundle.suite?.version, suiteVersion())) {
|
|
973
|
+
out.push(`NOTE \u2014 this bundle was emitted by suite ${String(bundle.suite?.version)}, NEWER than this verifier (${suiteVersion()}).`,
|
|
974
|
+
' A newer suite may digest row members this verifier does not know. If a `witness-digest`',
|
|
975
|
+
' rejection follows, upgrade the verifier before reading it as tampering.', '');
|
|
976
|
+
}
|
|
955
977
|
if (verdict.rejections.length > 0) {
|
|
956
978
|
out.push(`REJECTED \u2014 ${verdict.rejections.length} problem(s):`);
|
|
957
979
|
for (const r of verdict.rejections) out.push(` [${r.kind}]${r.profile ? ` (${r.profile})` : ''} ${r.detail}`);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
|
|
21
21
|
import { profileDerivable, type DiscoveryPayload } from './profiles.js';
|
|
22
|
+
import { checkRungClaim, type DurabilityRung, type RowEvidence } from './durability-evidence.js';
|
|
22
23
|
import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
|
|
23
24
|
|
|
24
25
|
export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
|
|
@@ -29,6 +30,8 @@ export interface BundleV3Requirement {
|
|
|
29
30
|
readonly result: BundleV3Result;
|
|
30
31
|
readonly assertions?: number;
|
|
31
32
|
readonly detail?: string;
|
|
33
|
+
/** RFC 0158 §E — structured evidence. Inside the witness digest, hence inside the signature. */
|
|
34
|
+
readonly evidence?: RowEvidence;
|
|
32
35
|
}
|
|
33
36
|
export interface BundleV3Profile {
|
|
34
37
|
readonly id: string;
|
|
@@ -67,6 +70,8 @@ export interface BundleV3 {
|
|
|
67
70
|
witnessSha256: string;
|
|
68
71
|
assertionCount: number;
|
|
69
72
|
detail?: { nonPass: { id: string; result: string; reason: string }[] };
|
|
73
|
+
/** RFC 0158 §D — a CLAIM, outside the signature and never trusted: the verifier re-derives it from the signed rows. */
|
|
74
|
+
durability?: { rung: DurabilityRung };
|
|
70
75
|
signature: BundleV3Signature;
|
|
71
76
|
verifierSignature?: { alg: 'ed25519'; keyId: string; sig: string };
|
|
72
77
|
}
|
|
@@ -84,7 +89,9 @@ export function canonicalJSON(value: unknown): string {
|
|
|
84
89
|
|
|
85
90
|
/** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
|
|
86
91
|
export function witnessDigest(rows: readonly BundleV3Requirement[]): string {
|
|
87
|
-
const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail })
|
|
92
|
+
const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
|
|
93
|
+
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
94
|
+
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
88
95
|
return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
|
|
89
96
|
}
|
|
90
97
|
|
|
@@ -181,6 +188,11 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
181
188
|
const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
|
|
182
189
|
// Ownership comes from the profile registry, not from the profile's name — see profilesRelaxedBy.
|
|
183
190
|
const relaxedProfiles = profilesRelaxedBy((bundle.host?.relaxations ?? []).map((r) => r.obligation), (bundle.claimedProfiles ?? []).map((p) => p.id));
|
|
191
|
+
// RFC 0158 §D: a rung is CLAIMED at the top level, outside the signature, and
|
|
192
|
+
// is therefore only ever as good as its re-derivation from the signed rows.
|
|
193
|
+
const rung = checkRungClaim(bundle.durability?.rung, rows);
|
|
194
|
+
if (!rung.ok) rejections.push({ kind: 'rung-not-derivable', detail: rung.detail });
|
|
195
|
+
|
|
184
196
|
// An OBSERVED relaxation nobody declared: the bundle's own results show the
|
|
185
197
|
// host accepting a destination its egress guard MUST refuse. Same denial as a
|
|
186
198
|
// declared one, so declaring nothing is not a way around the rule.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0158 §E — the rung and the recovery bound, IN THE BUNDLE.
|
|
3
|
+
*
|
|
4
|
+
* §E.10 mints no discovery capability: "a qualification ladder is evidence about
|
|
5
|
+
* behaviour that already exists, so the useful place for a rung and a recovery
|
|
6
|
+
* bound is the host's conformance evidence bundle — where a claim without
|
|
7
|
+
* evidence is already a defect." Until 2.34.0 that sentence had nothing behind
|
|
8
|
+
* it. `certification-bundle.schema.json` had no seat for a rung, a bound or its
|
|
9
|
+
* terms; a bundle showed five pass/fail rows, and a reader could neither tell
|
|
10
|
+
* which rung was claimed nor recompute the bound. The terms lived only on a
|
|
11
|
+
* non-normative seam route. Found by READING acceptance criterion 1 at the
|
|
12
|
+
* moment of flipping the RFC, with every row already green.
|
|
13
|
+
*
|
|
14
|
+
* ── Why the evidence rides on ROWS ───────────────────────────────────────────
|
|
15
|
+
* The attestation covers exactly `{ witnessSha256, host.build, suite.version,
|
|
16
|
+
* discovery.sha256 }` (`conformance.md` §Bundle v3), and `witnessSha256` digests
|
|
17
|
+
* the requirement rows. A top-level block would therefore be UNSIGNED — a bound
|
|
18
|
+
* or a rung editable after signing on a bundle that still verifies. Evidence
|
|
19
|
+
* carried on a row is inside the witness digest, and so inside the signature.
|
|
20
|
+
* It enters the digest ONLY WHEN PRESENT, so every bundle cut before 2.34.0
|
|
21
|
+
* digests byte-identically and still verifies (pinned against the three
|
|
22
|
+
* committed bundles in `durability-evidence.test.ts`).
|
|
23
|
+
*
|
|
24
|
+
* ── Why the rung claim is NOT trusted ────────────────────────────────────────
|
|
25
|
+
* `durability.rung` sits at the top level, outside the signature, and that is
|
|
26
|
+
* sound for the same reason `claimedProfiles[].certified` is: the verifier
|
|
27
|
+
* RE-DERIVES it from signed rows and rejects a claim it cannot derive. §D: "a
|
|
28
|
+
* host MAY claim a rung only with the evidence named for it."
|
|
29
|
+
*
|
|
30
|
+
* ── Why a kill row names its recovery CLASS ──────────────────────────────────
|
|
31
|
+
* Unresolved Question 1 resolved PER WORK CLASS, not a scalar. A host with an
|
|
32
|
+
* outbox lane (65 s) and a dispatch lease (750 s) has two bounds, so each kill
|
|
33
|
+
* row records `{ class, boundMs, observedMs }`: the class MUST name a declared
|
|
34
|
+
* entry, `boundMs` MUST equal that entry's `bound`, and `observedMs` MUST NOT
|
|
35
|
+
* exceed it.
|
|
36
|
+
*
|
|
37
|
+
* WHAT THAT CATCHES, and what it cannot. It refuses an undeclared class, a bound
|
|
38
|
+
* label that disagrees with its class, and a resumption outside the bound. It
|
|
39
|
+
* does NOT catch a host that names the WRONG DECLARED class while resuming
|
|
40
|
+
* inside that class's (longer) bound — which a tier-1 host actually shipped: the
|
|
41
|
+
* seam killed before the execution claim was held, the 65 s lane rescued the run
|
|
42
|
+
* in 11.6 s, and the exercise was labelled leased / 750 s, every row green. No
|
|
43
|
+
* arithmetic separates that from a fast leased recovery. What the evidence
|
|
44
|
+
* changes is that it becomes VISIBLE — 11.6 s recorded against a class whose own
|
|
45
|
+
* terms say 720 s of lease — where before nothing was recorded at all. Killing
|
|
46
|
+
* only once the claim is held stays the HOST's obligation (§E).
|
|
47
|
+
*
|
|
48
|
+
* `class` is an OPAQUE host-chosen string, never an enum: "leased" / "unleased"
|
|
49
|
+
* name one host's mechanisms and boot re-entry is another's. An enum would
|
|
50
|
+
* select for an architecture, which this RFC refuses to do everywhere else.
|
|
51
|
+
* There is NO scalar "overall bound": that is UQ1's lie by aggregation with a
|
|
52
|
+
* friendlier name, and a reader would use it.
|
|
53
|
+
*
|
|
54
|
+
* What this block proves: ARITHMETIC (Σ terms = bound) and, through the kill
|
|
55
|
+
* rows' `observedMs`, that the mechanism RAN at least once inside the bound.
|
|
56
|
+
* `bound-is-derived` alone remains a paper check and MUST NOT be cited for
|
|
57
|
+
* liveness.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** Host-chosen identifiers enter a PUBLISHED bundle: short, plain, no free text. */
|
|
61
|
+
export const EVIDENCE_NAME_PATTERN = /^[a-z][A-Za-z0-9._-]{0,63}$/;
|
|
62
|
+
export const MAX_RECOVERY_CLASSES = 16;
|
|
63
|
+
export const MAX_TERMS_PER_CLASS = 16;
|
|
64
|
+
|
|
65
|
+
export interface RecoveryTerm { readonly name: string; readonly ms: number }
|
|
66
|
+
export interface RecoveryBound { readonly class: string; readonly bound: number; readonly terms: readonly RecoveryTerm[] }
|
|
67
|
+
export interface RecoveryObservation { readonly class: string; readonly boundMs: number; readonly observedMs: number }
|
|
68
|
+
/** Closed. One optional member per KIND of structured evidence a row may carry. */
|
|
69
|
+
export interface RowEvidence { readonly recovery?: RecoveryObservation; readonly recoveryBounds?: readonly RecoveryBound[] }
|
|
70
|
+
|
|
71
|
+
export type DurabilityRung = 'durable-single-instance' | 'durable-multi-instance' | 'multi-region-qualified';
|
|
72
|
+
export const RUNGS: readonly DurabilityRung[] = ['durable-single-instance', 'durable-multi-instance', 'multi-region-qualified'];
|
|
73
|
+
|
|
74
|
+
const R = 'openwop.requirement.0158.';
|
|
75
|
+
/** The rows §Acceptance names for the lowest rung. */
|
|
76
|
+
export const SINGLE_INSTANCE_ROWS: readonly string[] = ['kill-after-accept', 'kill-during-execution', 'duplicate-delivery', 'poison-exhaustion', 'bound-is-derived'].map((r) => R + r);
|
|
77
|
+
const KILL_ROWS: readonly string[] = [R + 'kill-after-accept', R + 'kill-during-execution'];
|
|
78
|
+
const BOUND_ROW = R + 'bound-is-derived';
|
|
79
|
+
|
|
80
|
+
const isCount = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0;
|
|
81
|
+
const isName = (s: unknown): s is string => typeof s === 'string' && EVIDENCE_NAME_PATTERN.test(s);
|
|
82
|
+
|
|
83
|
+
/** Normalise what a host's seam returned into bundle evidence, or say exactly why it cannot be. */
|
|
84
|
+
export function parseRecoveryBounds(raw: unknown): { ok: true; bounds: RecoveryBound[] } | { ok: false; why: string } {
|
|
85
|
+
if (!Array.isArray(raw) || raw.length === 0) return { ok: false, why: 'no recovery classes were declared' };
|
|
86
|
+
if (raw.length > MAX_RECOVERY_CLASSES) return { ok: false, why: `more than ${MAX_RECOVERY_CLASSES} recovery classes` };
|
|
87
|
+
const bounds: RecoveryBound[] = [];
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
for (const entry of raw as Array<Record<string, unknown>>) {
|
|
90
|
+
const cls = entry?.['class'];
|
|
91
|
+
if (!isName(cls)) return { ok: false, why: `a recovery class name does not match ${String(EVIDENCE_NAME_PATTERN)}` };
|
|
92
|
+
if (seen.has(cls)) return { ok: false, why: `recovery class ${cls} is declared twice` };
|
|
93
|
+
seen.add(cls);
|
|
94
|
+
const terms = entry['terms'];
|
|
95
|
+
if (!Array.isArray(terms) || terms.length === 0 || terms.length > MAX_TERMS_PER_CLASS) return { ok: false, why: `class ${cls}: terms[] MUST carry 1..${MAX_TERMS_PER_CLASS} entries — a total alone cannot be recomputed` };
|
|
96
|
+
const clean: RecoveryTerm[] = [];
|
|
97
|
+
for (const t of terms as Array<Record<string, unknown>>) {
|
|
98
|
+
if (!isName(t?.['name']) || !isCount(t['ms'])) return { ok: false, why: `class ${cls}: every term is { name, ms } with ms a non-negative integer` };
|
|
99
|
+
clean.push({ name: t['name'] as string, ms: t['ms'] as number });
|
|
100
|
+
}
|
|
101
|
+
if (!isCount(entry['bound'])) return { ok: false, why: `class ${cls}: bound is a non-negative integer of milliseconds` };
|
|
102
|
+
const sum = clean.reduce((a, t) => a + t.ms, 0);
|
|
103
|
+
if (sum !== entry['bound']) return { ok: false, why: `class ${cls}: terms sum to ${sum} ms but the declared bound is ${String(entry['bound'])} ms — a host that states a bound its terms do not produce fails §B.5` };
|
|
104
|
+
bounds.push({ class: cls, bound: entry['bound'] as number, terms: clean });
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, bounds };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Row { readonly id: string; readonly result: string; readonly evidence?: RowEvidence }
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The highest rung these SIGNED rows support, and — when it is lower than a
|
|
113
|
+
* reader might hope — the first reason why. Only `durable-single-instance` is
|
|
114
|
+
* derivable today: `peer-resume` is bundle-witnessed by a per-boot incarnation
|
|
115
|
+
* token this revision does not yet carry (§E), so a higher claim is REFUSED
|
|
116
|
+
* rather than waved through.
|
|
117
|
+
*/
|
|
118
|
+
export function deriveRung(rows: readonly Row[]): { rung: DurabilityRung | null; why: string } {
|
|
119
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
120
|
+
for (const id of SINGLE_INSTANCE_ROWS) {
|
|
121
|
+
const row = byId.get(id);
|
|
122
|
+
if (row === undefined) return { rung: null, why: `${id} is absent from the bundle` };
|
|
123
|
+
if (row.result !== 'executed-pass') return { rung: null, why: `${id} is ${row.result}, not executed-pass` };
|
|
124
|
+
}
|
|
125
|
+
const declared = parseRecoveryBounds(byId.get(BOUND_ROW)?.evidence?.recoveryBounds);
|
|
126
|
+
if (!declared.ok) return { rung: null, why: `${BOUND_ROW} carries no usable evidence.recoveryBounds — ${declared.why}` };
|
|
127
|
+
const bounds = new Map(declared.bounds.map((b) => [b.class, b.bound]));
|
|
128
|
+
for (const id of KILL_ROWS) {
|
|
129
|
+
const obs = byId.get(id)?.evidence?.recovery;
|
|
130
|
+
if (obs === undefined) return { rung: null, why: `${id} passed but recorded no evidence.recovery — the class it exercised and the interval it observed are unknown` };
|
|
131
|
+
if (!isName(obs.class) || !isCount(obs.boundMs) || !isCount(obs.observedMs)) return { rung: null, why: `${id}: evidence.recovery is malformed` };
|
|
132
|
+
const bound = bounds.get(obs.class);
|
|
133
|
+
if (bound === undefined) return { rung: null, why: `${id} exercised recovery class "${obs.class}", which ${BOUND_ROW} does not declare (declared: ${[...bounds.keys()].join(', ')})` };
|
|
134
|
+
if (obs.boundMs !== bound) return { rung: null, why: `${id} was judged against ${obs.boundMs} ms but class "${obs.class}" declares ${bound} ms — the exercise was labelled with a bound that does not govern it` };
|
|
135
|
+
if (obs.observedMs > bound) return { rung: null, why: `${id} resumed after ${obs.observedMs} ms, outside the ${bound} ms bound of class "${obs.class}"` };
|
|
136
|
+
}
|
|
137
|
+
return { rung: 'durable-single-instance', why: 'all five rows executed-pass; each kill row names a declared class and resumed inside its bound' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Is the rung a bundle CLAIMS supported by its own signed rows? */
|
|
141
|
+
export function checkRungClaim(claimed: unknown, rows: readonly Row[]): { ok: true } | { ok: false; detail: string } {
|
|
142
|
+
if (claimed === undefined) return { ok: true };
|
|
143
|
+
if (typeof claimed !== 'string' || !(RUNGS as readonly string[]).includes(claimed)) return { ok: false, detail: `durability.rung ${JSON.stringify(claimed)} is not one of ${RUNGS.join(' | ')}` };
|
|
144
|
+
const derived = deriveRung(rows);
|
|
145
|
+
if (derived.rung === null) return { ok: false, detail: `durability.rung claims ${claimed}, but the signed rows do not support any rung: ${derived.why}` };
|
|
146
|
+
if (claimed !== derived.rung) return { ok: false, detail: `durability.rung claims ${claimed}, but the signed rows support only ${derived.rung} — a higher rung is bundle-witnessed by evidence this revision does not yet carry (RFC 0158 §E, peer-resume), so it is refused rather than assumed` };
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Was this bundle cut by a NEWER suite than the verifier reading it?
|
|
152
|
+
*
|
|
153
|
+
* Row evidence enters the witness digest, so a verifier older than 2.34.0 that
|
|
154
|
+
* meets a bundle carrying it recomputes a different digest and reports
|
|
155
|
+
* `witness-digest` — it FAILS CLOSED, which is right, but the message reads as
|
|
156
|
+
* tampering when the truth is "upgrade the verifier". Every later digested
|
|
157
|
+
* member will repeat that. The notice is advice; it never changes a verdict.
|
|
158
|
+
*/
|
|
159
|
+
export function emittedByNewerSuite(bundleSuite: unknown, verifierSuite: string): boolean {
|
|
160
|
+
const parse = (v: unknown): number[] | null => {
|
|
161
|
+
const m = typeof v === 'string' ? /^(\d+)\.(\d+)\.(\d+)/.exec(v) : null;
|
|
162
|
+
return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
163
|
+
};
|
|
164
|
+
const b = parse(bundleSuite); const mine = parse(verifierSuite);
|
|
165
|
+
if (b === null || mine === null) return false;
|
|
166
|
+
for (let i = 0; i < 3; i++) { if ((b[i] as number) !== (mine[i] as number)) return (b[i] as number) > (mine[i] as number); }
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── the scenario → setup.ts → ledger channel ─────────────────────────────────
|
|
171
|
+
let pending: RowEvidence | null = null;
|
|
172
|
+
/** Called by a scenario inside an `it`; setup.ts attaches it to that test's ledger row. Later notes merge. */
|
|
173
|
+
export function noteEvidence(evidence: RowEvidence): void { pending = { ...(pending ?? {}), ...evidence }; }
|
|
174
|
+
/** setup.ts reads and clears it after each test. */
|
|
175
|
+
export function takeNotedEvidence(): RowEvidence | null { const e = pending; pending = null; return e; }
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* That is the whole mechanism. Everything else here is bookkeeping.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import type { RowEvidence } from './durability-evidence.js';
|
|
19
20
|
import { appendFileSync, readFileSync, existsSync } from 'node:fs';
|
|
20
21
|
|
|
21
22
|
/** RFC 0148 §A. Exactly one of these per requirement, per run. */
|
|
@@ -66,6 +67,10 @@ export interface LedgerEntry {
|
|
|
66
67
|
* the row to a file and drops it — which is how every explicit requirement id
|
|
67
68
|
* went missing from bundle v3 while the per-`it` ids came through. */
|
|
68
69
|
readonly scenarioFile?: string;
|
|
70
|
+
/** RFC 0158 §E — structured evidence the scenario noted for this row
|
|
71
|
+
* (`durability-evidence.ts`). Recorded for `executed-pass` only: a row that
|
|
72
|
+
* failed, blocked or skipped witnessed nothing to describe. */
|
|
73
|
+
readonly evidence?: RowEvidence;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
const ledger = new Map<string, LedgerEntry>();
|
|
@@ -87,7 +92,7 @@ export function recordRequirement(
|
|
|
87
92
|
requirementId: string,
|
|
88
93
|
disposition: Disposition,
|
|
89
94
|
detail?: string,
|
|
90
|
-
extras?: { assertionCount?: number; scenarioFile?: string },
|
|
95
|
+
extras?: { assertionCount?: number; scenarioFile?: string; evidence?: RowEvidence },
|
|
91
96
|
): void {
|
|
92
97
|
const prior = ledger.get(requirementId);
|
|
93
98
|
if (prior !== undefined && prior.disposition !== disposition) {
|
|
@@ -108,6 +113,7 @@ export function recordRequirement(
|
|
|
108
113
|
...(detail === undefined ? {} : { detail }),
|
|
109
114
|
...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
|
|
110
115
|
...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
|
|
116
|
+
...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
|
|
111
117
|
};
|
|
112
118
|
ledger.set(requirementId, entry);
|
|
113
119
|
journal.push(entry);
|
|
@@ -102,11 +102,14 @@ export function resolveItRecord(
|
|
|
102
102
|
state: FileTestState,
|
|
103
103
|
assertionCalls: number,
|
|
104
104
|
gate: { disposition: 'inapplicable' | 'skipped'; detail?: string } | undefined,
|
|
105
|
-
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
|
|
105
|
+
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string; conclusive?: true } | null,
|
|
106
106
|
firstError?: string,
|
|
107
107
|
): { disposition: Disposition; detail?: string } {
|
|
108
108
|
if (state === 'fail') return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
109
109
|
if (state === 'pass' && assertionCalls > 0) {
|
|
110
|
+
// `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
|
|
111
|
+
// assertions are not the requirement, and the requirement went unobserved.
|
|
112
|
+
if (noted !== null && noted.kind === 'blocked' && noted.conclusive === true) return { disposition: 'blocked', detail: noted.reason };
|
|
110
113
|
// A leg that asserted AND THEN soft-skipped is only a partial witness, and
|
|
111
114
|
// the file-level record has always said so (`resolveFileRecord` below).
|
|
112
115
|
// This `it`-level record dropped the note — and the `it`-level rows are the
|
package/src/lib/soft-skip.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type SoftSkipKind = 'inapplicable' | 'skipped' | 'blocked';
|
|
|
43
43
|
/** Detail marker the runner writes for a zero-assertion file that noted nothing. */
|
|
44
44
|
export const UNCLASSIFIED_RETURN_DETAIL = 'every test returned early with zero assertions and no recorded reason — unclassified return; RFC 0148 §A resolves it to blocked (add softSkip(kind, reason) at the early return)';
|
|
45
45
|
|
|
46
|
-
interface Note { readonly kind: SoftSkipKind; readonly reason: string; readonly seq: number }
|
|
46
|
+
interface Note { readonly kind: SoftSkipKind; readonly reason: string; readonly seq: number; readonly conclusive?: true }
|
|
47
47
|
|
|
48
48
|
const notes = new Map<string, Note[]>();
|
|
49
49
|
let seq = 0;
|
|
@@ -83,15 +83,41 @@ export function seamAbsent(reason: string): undefined {
|
|
|
83
83
|
return softSkip('blocked', reason);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* `blocked` that STANDS even though the test already asserted something.
|
|
88
|
+
*
|
|
89
|
+
* A plain `softSkip` after an assertion records `executed-pass` with a
|
|
90
|
+
* `partial-witness:` detail (`resolveItRecord`): the acceptance predicate
|
|
91
|
+
* refuses such a row, but certification counts it as a pass. That is right for
|
|
92
|
+
* a leg that finished its requirement and skipped an optional extra. It is
|
|
93
|
+
* wrong for a leg whose REQUIREMENT went unobserved after setup assertions it
|
|
94
|
+
* could not avoid — a helper like `register()` asserts `201` before the leg has
|
|
95
|
+
* observed anything. Use this only there: the row records `blocked`, which
|
|
96
|
+
* denies certification (RFC 0168 §E.1) without convicting the host.
|
|
97
|
+
*
|
|
98
|
+
* Opt-in and per-call on purpose. Honouring every note-after-assertion as
|
|
99
|
+
* `blocked` would downgrade legs that legitimately completed; that is a suite-
|
|
100
|
+
* wide semantic change, not a patch. First use: `v2-webhook-durable-delivery`'s
|
|
101
|
+
* dead-letter leg when the retry window closes before exhaustion (2.34.1).
|
|
102
|
+
*/
|
|
103
|
+
export function blockedDespiteAssertions(reason: string): undefined {
|
|
104
|
+
const file = currentFile();
|
|
105
|
+
if (file === null) return undefined;
|
|
106
|
+
const arr = notes.get(file) ?? [];
|
|
107
|
+
arr.push({ kind: 'blocked', reason, seq: ++seq, conclusive: true });
|
|
108
|
+
notes.set(file, arr);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
const RANK: Record<SoftSkipKind, number> = { blocked: 0, skipped: 1, inapplicable: 2 };
|
|
87
113
|
|
|
88
|
-
function fold(arr: readonly Note[]): { kind: SoftSkipKind; reason: string } | null {
|
|
114
|
+
function fold(arr: readonly Note[]): { kind: SoftSkipKind; reason: string; conclusive?: true } | null {
|
|
89
115
|
if (arr.length === 0) return null;
|
|
90
116
|
const uniq: Note[] = [];
|
|
91
117
|
for (const n of arr) if (!uniq.some((u) => u.kind === n.kind && u.reason === n.reason)) uniq.push(n);
|
|
92
118
|
const kind = [...uniq].sort((a, b) => RANK[a.kind] - RANK[b.kind])[0]!.kind;
|
|
93
119
|
const reason = uniq.map((n) => (uniq.length > 1 ? `[${n.kind}] ${n.reason}` : n.reason)).join('; ');
|
|
94
|
-
return { kind, reason };
|
|
120
|
+
return uniq.some((n) => n.conclusive === true) ? { kind, reason, conclusive: true } : { kind, reason };
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
/**
|
|
@@ -113,7 +139,7 @@ export function softSkipMark(): number {
|
|
|
113
139
|
* The noted disposition for a file counting only notes written AFTER `mark`
|
|
114
140
|
* — the notes of the test that is ending. Same fold as the file rule.
|
|
115
141
|
*/
|
|
116
|
-
export function softSkipDispositionSince(file: string, mark: number): { kind: SoftSkipKind; reason: string } | null {
|
|
142
|
+
export function softSkipDispositionSince(file: string, mark: number): { kind: SoftSkipKind; reason: string; conclusive?: true } | null {
|
|
117
143
|
return fold((notes.get(file) ?? []).filter((n) => n.seq > mark));
|
|
118
144
|
}
|
|
119
145
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a webhook scenario waits for a host's retry schedule to play out.
|
|
3
|
+
*
|
|
4
|
+
* The advertised `webhooks.retryPolicy` facet is closed over exactly
|
|
5
|
+
* `{ maxAttempts, backoff }`: a host has NO way to put its intervals on the
|
|
6
|
+
* wire, so the suite cannot derive how long exhaustion takes and has to choose
|
|
7
|
+
* a window. Every window it has chosen so far has convicted a durable host:
|
|
8
|
+
*
|
|
9
|
+
* 2.0.1 a hard 20 s failed a host whose first backoff was 30 s.
|
|
10
|
+
* 2.34.1 a hard 90 s cap fails a host retrying at 15 / 30 / 60 / 120 s with
|
|
11
|
+
* `maxAttempts: 5` — attempts at t0, +15, +45, +105, +225 s. The
|
|
12
|
+
* dead-letter leg read the sink ~120 s before that host exhausts, and
|
|
13
|
+
* recorded `executed-fail` ("MUST be routed to the sink, not dropped")
|
|
14
|
+
* about a host that delivers, retries five times and dead-letters
|
|
15
|
+
* correctly. To pass it would have had to cut its PRODUCTION retry
|
|
16
|
+
* window from ~225 s to ~75 s for every real subscriber. Reported by a
|
|
17
|
+
* tier-2 host before it advertised the facet, from its own constants.
|
|
18
|
+
*
|
|
19
|
+
* An instrument must not choose a host's durability. So the cap is
|
|
20
|
+
* OPERATOR-RAISABLE — the shape `OPENWOP_DURABILITY_OBSERVATION_CEILING_MS`
|
|
21
|
+
* already has for RFC 0158's kill rows — and NEVER LOWERABLE: a value below the
|
|
22
|
+
* default, or not a number, is ignored, so no operator can shrink the window to
|
|
23
|
+
* hide a slow retry. A raised window is still bounded, so a host that never
|
|
24
|
+
* retries still fails; it only fails later.
|
|
25
|
+
*/
|
|
26
|
+
export const RETRY_WAIT_FLOOR_MS = 20_000;
|
|
27
|
+
export const DEFAULT_RETRY_WAIT_CAP_MS = 90_000;
|
|
28
|
+
/** A typo guard, not a policy: an hour per wait is longer than any retry schedule worth certifying in one sitting. */
|
|
29
|
+
export const MAX_RETRY_WAIT_CAP_MS = 3_600_000;
|
|
30
|
+
export const RETRY_WAIT_ENV = 'OPENWOP_WEBHOOK_RETRY_WAIT_MS';
|
|
31
|
+
|
|
32
|
+
export function retryWaitCapMs(env: Record<string, string | undefined> = process.env): number {
|
|
33
|
+
const raw = Number(env[RETRY_WAIT_ENV]);
|
|
34
|
+
if (!Number.isFinite(raw) || raw < DEFAULT_RETRY_WAIT_CAP_MS) return DEFAULT_RETRY_WAIT_CAP_MS;
|
|
35
|
+
return Math.min(Math.floor(raw), MAX_RETRY_WAIT_CAP_MS);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The floor stays 20 s so a host that advertises nothing is measured exactly as
|
|
40
|
+
* before; an advertised `exponential` / `fixed` backoff widens it to the cap.
|
|
41
|
+
*/
|
|
42
|
+
export function retryWaitFor(policy: { backoff?: string } | null, capMs: number): number {
|
|
43
|
+
if (policy === null) return RETRY_WAIT_FLOOR_MS;
|
|
44
|
+
const backoff = String(policy.backoff ?? '');
|
|
45
|
+
return backoff === 'exponential' || backoff === 'fixed' ? capMs : RETRY_WAIT_FLOOR_MS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a row says when its window closed before the host's schedule did. Computed, so the numbers a host reads are the ones the run used. */
|
|
49
|
+
export function windowClosedNote(seen: number, maxAttempts: number, waitedMs: number, capMs: number): string {
|
|
50
|
+
const raised = capMs > DEFAULT_RETRY_WAIT_CAP_MS;
|
|
51
|
+
return `${seen} of the advertised ${maxAttempts} attempts arrived inside the ${waitedMs}ms this scenario waits, and the delivery is not in the sink yet. `
|
|
52
|
+
+ 'webhooks.retryPolicy carries only { maxAttempts, backoff } — no interval — so the suite cannot tell a slow conformant schedule from a host that stopped retrying, and it does not convict on a deadline it chose. '
|
|
53
|
+
+ (raised
|
|
54
|
+
? `${RETRY_WAIT_ENV} is already raised to ${capMs}ms; set it above the SUM of this host's backoff intervals (at most ${MAX_RETRY_WAIT_CAP_MS}ms).`
|
|
55
|
+
: `Set ${RETRY_WAIT_ENV} above the SUM of this host's backoff intervals (default ${DEFAULT_RETRY_WAIT_CAP_MS}ms; it can be raised, never lowered) and re-run.`);
|
|
56
|
+
}
|