@openwop/openwop-conformance 2.40.0 → 2.40.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 +14 -0
- package/README.md +1 -1
- package/dist/cli.js +18 -2
- package/dist/lib/certification-bundle-v3.js +12 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirement-aliases.json +3 -1
- package/requirements.json +265 -65
- package/schemas/CORPUS-STAMP.json +15 -13
- package/src/cli.ts +20 -2
- package/src/lib/certification-bundle-v3.ts +15 -5
- package/src/lib/harness-issuer.ts +101 -0
- package/src/lib/v2-retired-members.ts +119 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +15 -0
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +14 -0
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +62 -1
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +22 -19
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +24 -23
|
@@ -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.40.
|
|
5
|
-
"corpusTag": "v2.40.
|
|
4
|
+
"version": "2.40.2",
|
|
5
|
+
"corpusTag": "v2.40.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": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
|
|
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": "c92bde1fb2ac949edfb50519651ac3755cfc88b8a2a8785eab0e15bcb6a8dbb9",
|
|
13
|
+
"api/v2/asyncapi.yaml": "87d5b9de8e3fa920a0ddecce7f51018f8f573b5ef14f954973a0776d3b9d9a0f",
|
|
14
|
+
"api/v2/openapi.yaml": "532467b699c179c355349c8bb483a801156a68b003d02aa9d7cf576f867c3097",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
"schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
|
|
118
118
|
"schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
|
|
119
119
|
"schemas/v2/capabilities.schema.json": "740944d7f0a00c91308449f3a3f52fa7b91b16b8fd36a0171b917afe9f4b0f09",
|
|
120
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
120
|
+
"schemas/v2/certification-bundle.schema.json": "82ce31a1ed6c7e21b0ba88ff62a7b8b768fc3b176b1d06fce023203830b94725",
|
|
121
121
|
"schemas/v2/channel-presence-payload.schema.json": "4eeb224910e6f7eee3ab5e7c3899559c25c83a26d0022e0ad195c4672611977a",
|
|
122
122
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
123
123
|
"schemas/v2/chat-card-pack-manifest.schema.json": "92a57db2fd4b2463f14ef155fef8e3f18afc78960b646459dcf32efc79ff6bf5",
|
|
@@ -204,13 +204,13 @@
|
|
|
204
204
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
205
205
|
"spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
|
|
206
206
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
207
|
-
"spec/v1/core-standard-manifest.json": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "a16e3a91b9121d7b6d58fd9f65a348f92a07636417406f56bc3173571e9d55ea",
|
|
208
208
|
"spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
|
|
209
209
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "3decfec271c30f796ba76586ca06ab31e6ea3b8bcae174c3af0b8bb1c19709b5",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -218,7 +218,7 @@
|
|
|
218
218
|
"spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
|
|
219
219
|
"spec/v2/README.md": "ccfed7d978ce7293a6d8f3c0f6045835dfb7e488f2b67a68f6b04d67c4b0cfc9",
|
|
220
220
|
"spec/v2/core/capabilities.md": "e22381fed3a93376ad478fa7f07571fdad9653aefd7c05f189a2ef45a49f6c63",
|
|
221
|
-
"spec/v2/core/conformance.md": "
|
|
221
|
+
"spec/v2/core/conformance.md": "e4d3b34ef5b7c6a34675ed59a086143e8bfd0e00d0d04ce0f2a2ec6245275084",
|
|
222
222
|
"spec/v2/core/connection-packs.md": "7257339a4fc125011bf89961588c949cb058a0c0f7d9808ad08a5288deb3f8fc",
|
|
223
223
|
"spec/v2/core/conversation.md": "c9a937e410fdb4e092cec2f05a7cab8b5232745090cfb20041c03c70ccbb5cdc",
|
|
224
224
|
"spec/v2/core/errors.md": "619cd74381c41acf33d012eb1afd834e148ac292663a1a6fa6744dbaff9661d9",
|
|
@@ -231,7 +231,7 @@
|
|
|
231
231
|
"spec/v2/core/interop.md": "9a6b37e07c0235856199ace6b3a84f592fa5903fcf6c442f8a18f4d6b6aa5525",
|
|
232
232
|
"spec/v2/core/interrupt.md": "1dc94f7c791dd5a547b1abafde6a8a76acd3d2993d288057f302f57911dc83bf",
|
|
233
233
|
"spec/v2/core/node-pack-runtimes.md": "c435ad506f618831b6c0a9fd9150d83a5dc6caa1e3d755b949af2ff8949f1140",
|
|
234
|
-
"spec/v2/core/oauth.md": "
|
|
234
|
+
"spec/v2/core/oauth.md": "38ec0e70d46f328430138559095f261ae4fcd3d32c2d994a2d3e630a062c0e6c",
|
|
235
235
|
"spec/v2/core/overview.md": "8f9d4a68b6185664e40d226cb82d9a9270a6c6630e3db59bddff300be781947d",
|
|
236
236
|
"spec/v2/core/packs.md": "91cd577b4a636c1587ef78742e583a203244b8508b289b01a18a8ef2ef0a9b3e",
|
|
237
237
|
"spec/v2/core/persistence.md": "d32736bd74e51df7c552565b4105a8ec801ece6d2292c8676421368ee9bffec1",
|
|
@@ -284,6 +284,8 @@
|
|
|
284
284
|
"spec/v2/facets/supportedEnvelopes.schema.json": "644f22cc2236e30a611b5b7f1ba0458ff1925d68b94c514d9672583224e126bd",
|
|
285
285
|
"spec/v2/facets/webhooks.schema.json": "2fac635ee5fbc10a14a1a46cf512a11bac660bfbabe0aea79f6c7b657ecb9e4e",
|
|
286
286
|
"spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
|
|
287
|
+
"spec/v2/harness-trust-anchors.json": "6007b92eae16a070fbc3777424efb0abfe865fda89d096963109b96282b91205",
|
|
288
|
+
"spec/v2/harness-trust-anchors.schema.json": "372b5d92337c893d3449eff3f6bfd009ac09fdac285296d8bdbe0a08fff9e34c",
|
|
287
289
|
"spec/v2/id-field-bindings.json": "9f6fd280250447e26a6704811c352bb8f2ff6c4bd242befe3da0ba38962370e9",
|
|
288
290
|
"spec/v2/interop-map.json": "02255dafe7eb55f61ca7f9ae7c21f079d1848a6756991343088975ac9d184b8d",
|
|
289
291
|
"spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
|
|
@@ -292,9 +294,9 @@
|
|
|
292
294
|
"spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
|
|
293
295
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
294
296
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
295
|
-
"spec/v2/release.json": "
|
|
297
|
+
"spec/v2/release.json": "f04bc2406dfc3af40a495d8750349409f053bb56f009359ebc62a8877251c90f",
|
|
296
298
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
297
|
-
"spec/v2/surface-baseline.json": "
|
|
299
|
+
"spec/v2/surface-baseline.json": "26f2a03c650413ac9c0d0b5facee47ee0eaebeb01d377c808fe6e737bb08687c"
|
|
298
300
|
},
|
|
299
|
-
"corpusCommit": "
|
|
301
|
+
"corpusCommit": "70c93c0fcd5c595893c1cac7cf1020d5c5835ec2"
|
|
300
302
|
}
|
package/src/cli.ts
CHANGED
|
@@ -83,6 +83,8 @@ interface ParsedArgs {
|
|
|
83
83
|
readonly verifyPath: string | undefined;
|
|
84
84
|
/** `--host-key <pem>` — the key `--verify` checks the host signature under. Absent ⇒ the verdict is INCOMPLETE, not clean. */
|
|
85
85
|
readonly verifyHostKeyPath: string | undefined;
|
|
86
|
+
/** RFC 0216 §B — `--as-colocated-companion`. */
|
|
87
|
+
readonly colocatedCompanion: boolean;
|
|
86
88
|
readonly verifierKeyId: string | undefined;
|
|
87
89
|
/**
|
|
88
90
|
* S43 (2026-08-18) — cap on concurrently running scenario FILES, forwarded to
|
|
@@ -112,6 +114,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
112
114
|
let signingKeyPath: string | undefined, signingKeyId: string | undefined, verifierKeyPath: string | undefined, verifierKeyId: string | undefined;
|
|
113
115
|
let targetMajor: 1 | 2 | undefined;
|
|
114
116
|
let maxWorkers: number | undefined = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
|
|
117
|
+
let colocatedCompanion = false;
|
|
115
118
|
|
|
116
119
|
for (let i = 0; i < argv.length; i++) {
|
|
117
120
|
const arg = argv[i] ?? '';
|
|
@@ -202,6 +205,12 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
202
205
|
case '--host-key':
|
|
203
206
|
verifyHostKeyPath = nextValue();
|
|
204
207
|
break;
|
|
208
|
+
// RFC 0216 §B — marks the bundle as cut from a colocated companion (the
|
|
209
|
+
// served host's image beside the suite, trusting a suite-held anchor).
|
|
210
|
+
// The marker is inside witnessSha256, so a signed companion cannot shed it.
|
|
211
|
+
case '--as-colocated-companion':
|
|
212
|
+
colocatedCompanion = true;
|
|
213
|
+
break;
|
|
205
214
|
case '--require-behavior':
|
|
206
215
|
process.env['OPENWOP_REQUIRE_BEHAVIOR'] = 'true';
|
|
207
216
|
break;
|
|
@@ -240,6 +249,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
240
249
|
maxWorkers,
|
|
241
250
|
verifyPath,
|
|
242
251
|
verifyHostKeyPath,
|
|
252
|
+
colocatedCompanion,
|
|
243
253
|
};
|
|
244
254
|
}
|
|
245
255
|
|
|
@@ -288,6 +298,10 @@ Certification (RFC 0089):
|
|
|
288
298
|
§C) records per-requirement DISPOSITIONS instead of pass/fail/skip
|
|
289
299
|
file lists, so "we could not check" stops being indistinguishable
|
|
290
300
|
from "checked and it holds". See the note it prints.
|
|
301
|
+
--as-colocated-companion v3 (RFC 0216): mark the bundle as cut from a colocated companion — the
|
|
302
|
+
served host's image run beside the suite so it can trust a suite-held
|
|
303
|
+
trust anchor. Signed (inside witnessSha256). A companion is acceptance
|
|
304
|
+
evidence only for spec/v2/harness-trust-anchors.json rows.
|
|
291
305
|
--max-workers <n> Cap concurrently running scenario files (vitest --maxWorkers).
|
|
292
306
|
Default: one worker per CPU. Use a small number against a
|
|
293
307
|
rate-limited production origin so 429s don't read as failures.
|
|
@@ -709,7 +723,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
709
723
|
bundleVersion: '3',
|
|
710
724
|
generatedAt: new Date().toISOString(),
|
|
711
725
|
suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
|
|
712
|
-
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
|
|
726
|
+
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}), ...(args.colocatedCompanion ? { deployment: 'colocated-companion' as const } : {}) },
|
|
713
727
|
// `document` is what makes `claimedProfiles[].certified` checkable by
|
|
714
728
|
// someone other than this process (RFC 0148 §B(1)); v2 carried it and v3
|
|
715
729
|
// dropped it. `sha256` is a digest of `canonicalJSON(document)`, so the
|
|
@@ -717,7 +731,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
717
731
|
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
718
732
|
claimedProfiles: claimed3,
|
|
719
733
|
results: { totals: totals3, requirements: rows3 },
|
|
720
|
-
witnessSha256: witnessDigest(rows3, relaxations),
|
|
734
|
+
witnessSha256: witnessDigest(rows3, relaxations, args.colocatedCompanion ? 'colocated-companion' : undefined),
|
|
721
735
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
722
736
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
723
737
|
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
@@ -908,6 +922,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
908
922
|
|
|
909
923
|
async function main(): Promise<never> {
|
|
910
924
|
const args = parseArgs(process.argv.slice(2));
|
|
925
|
+
if (args.colocatedCompanion && (!args.certify || args.bundleVersion !== '3')) {
|
|
926
|
+
process.stderr.write('openwop-conformance: --as-colocated-companion marks a v3 certification bundle; it needs --certify with bundle version 3 (RFC 0216 §B)\n');
|
|
927
|
+
process.exit(2);
|
|
928
|
+
}
|
|
911
929
|
// Refused before anything runs: a pinned-port certification that is not
|
|
912
930
|
// single-worker loses the host's traffic to a worker nobody reads (lib/pinned-ports.ts).
|
|
913
931
|
const pinnedConflict = pinnedPortWorkerConflict(process.env, args.maxWorkers, args.certify !== undefined);
|
|
@@ -41,12 +41,14 @@ export interface BundleV3Profile {
|
|
|
41
41
|
readonly certified: boolean;
|
|
42
42
|
}
|
|
43
43
|
export interface BundleV3Relaxation { readonly obligation: string; readonly durability: 'session' | 'deployment' | 'persisted'; readonly reason: string }
|
|
44
|
+
/** RFC 0216 §B — absent means the bundle measures the served host. */
|
|
45
|
+
export type BundleV3Deployment = 'colocated-companion';
|
|
44
46
|
export interface BundleV3Signature { readonly alg: 'ed25519'; readonly keyId: string; readonly sig: string; readonly over: readonly string[]; readonly verifierKeyId?: string }
|
|
45
47
|
export interface BundleV3 {
|
|
46
48
|
bundleVersion: '3';
|
|
47
49
|
generatedAt: string;
|
|
48
50
|
suite: { name: '@openwop/openwop-conformance'; version: string; targetMajor: 1 | 2; specArtifactsVersion: string; stampSha256?: string };
|
|
49
|
-
host: { name: string; version: string; vendor?: string; build: { kind: 'image-digest' | 'commit' | 'artifact-sha256'; id: string }; signingKeyId?: string; relaxations?: BundleV3Relaxation[] };
|
|
51
|
+
host: { name: string; version: string; vendor?: string; build: { kind: 'image-digest' | 'commit' | 'artifact-sha256'; id: string }; signingKeyId?: string; relaxations?: BundleV3Relaxation[]; deployment?: BundleV3Deployment };
|
|
50
52
|
/**
|
|
51
53
|
* `document` is the captured `/.well-known/openwop` payload, OPTIONAL.
|
|
52
54
|
*
|
|
@@ -106,12 +108,20 @@ export { canonicalJSON } from './jcs.js';
|
|
|
106
108
|
* from the same bundle anywhere else (Czech sorts `ch` after `h`). Every
|
|
107
109
|
* committed bundle's ids are `[a-z0-9.-]`, where the two orders coincide, so no
|
|
108
110
|
* stored digest changes (v2-bundle-witness-preimage.test.ts pins that).
|
|
111
|
+
*
|
|
112
|
+
* RFC 0216 §B.7 — a colocated companion's `host.deployment` joins the preimage
|
|
113
|
+
* the same way, ONLY WHEN PRESENT (`{ rows, relaxations?, deployment }`), so a
|
|
114
|
+
* signed companion cannot shed the marker and pass as a served-host bundle, and
|
|
115
|
+
* every bundle without it digests exactly as before.
|
|
109
116
|
*/
|
|
110
|
-
export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[]): string {
|
|
117
|
+
export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[], deployment?: BundleV3Deployment): string {
|
|
111
118
|
const canonicalRows = [...rows].sort((a, b) => codeUnitCompare(a.id, 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 }),
|
|
112
119
|
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
113
120
|
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
114
|
-
const
|
|
121
|
+
const relaxed = relaxations !== undefined && relaxations.length > 0;
|
|
122
|
+
const preimage = relaxed || deployment !== undefined
|
|
123
|
+
? { rows: canonicalRows, ...(relaxed ? { relaxations } : {}), ...(deployment !== undefined ? { deployment } : {}) }
|
|
124
|
+
: canonicalRows;
|
|
115
125
|
return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
|
|
116
126
|
}
|
|
117
127
|
|
|
@@ -219,8 +229,8 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
219
229
|
const count = (d: BundleV3Result): number => rows.filter((r) => r.result === d).length;
|
|
220
230
|
const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
|
|
221
231
|
for (const k of Object.keys(expected) as (keyof typeof expected)[]) if (bundle.results?.totals?.[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
222
|
-
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations));
|
|
223
|
-
if (digest !== undefined && bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows
|
|
232
|
+
const digest = refusedAs(rejections, 'witness-digest', 'the rows or declared relaxations', () => witnessDigest(rows, bundle.host?.relaxations, bundle.host?.deployment));
|
|
233
|
+
if (digest !== undefined && bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows, declared relaxations and deployment marker (${digest.slice(0, 12)})` });
|
|
224
234
|
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
225
235
|
if (bundle.assertionCount !== assertions) rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
226
236
|
const nonPass = rows.filter((r) => r.result !== 'executed-pass');
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite's harness OIDC issuer, as an INSTRUMENT a host claims.
|
|
3
|
+
*
|
|
4
|
+
* Two major-2 scenarios need a token the host would accept, and the suite can
|
|
5
|
+
* mint one only with a key it holds: `v2-oidc-id-token-audience` (RFC 0200 §D)
|
|
6
|
+
* and `v2-lane-exp-only-bound` (RFC 0210 §B). The suite holds the key of exactly
|
|
7
|
+
* one issuer, the one it stands up at `OPENWOP_TEST_OIDC_ISSUER_URL`.
|
|
8
|
+
*
|
|
9
|
+
* ── Why the gate reads the host's lane, not only the environment ────────────
|
|
10
|
+
* Until 2.40.2 both files recorded `blocked` whenever the harness was not the
|
|
11
|
+
* host's trust root. A PRODUCTION host MUST NOT trust a test issuer — a deployed
|
|
12
|
+
* service that accepts tokens signed by a key living on a test runner has an
|
|
13
|
+
* authentication bypass — so every honest production bundle advertising an
|
|
14
|
+
* `oidc` or `exp-only` lane carried `blocked` rows, and RFC 0168 §E.1 makes one
|
|
15
|
+
* `blocked` row deny certification bundle-wide. Measured on openwop-app
|
|
16
|
+
* (ADR 0745 corpus defect 1); MyndHyve is in the same position.
|
|
17
|
+
*
|
|
18
|
+
* RFC 0168 §C.1 already rules on this shape for the other suite instrument a
|
|
19
|
+
* host has to be configured for, the seams profile: "a scenario that finds the
|
|
20
|
+
* exact advert absent records `inapplicable` — never `blocked`, which is
|
|
21
|
+
* reserved for an advertised profile whose seam does not answer". The harness
|
|
22
|
+
* issuer is claimed the same way, by the host's own advertisement: a lane's
|
|
23
|
+
* `issuers[]` (`spec/v2/facets/auth.schema.json`) names the trust roots that
|
|
24
|
+
* lane accepts. So:
|
|
25
|
+
*
|
|
26
|
+
* - the lane's `issuers[]` does not name the harness URL (or no harness URL is
|
|
27
|
+
* configured) → `inapplicable`, and the reason names the issuers the host
|
|
28
|
+
* DOES trust — a fact about the host, not about the suite;
|
|
29
|
+
* - the lane names it → the host has claimed the instrument; a harness that
|
|
30
|
+
* cannot be served, or a control token the host then refuses, is `blocked`
|
|
31
|
+
* or a failure exactly as before.
|
|
32
|
+
*
|
|
33
|
+
* The cost, stated rather than hidden: a production bundle no longer witnesses
|
|
34
|
+
* these rows at all, just as it witnesses no seam-gated row. The witness comes
|
|
35
|
+
* from a cut whose lane advertises the harness issuer — the reference host, or
|
|
36
|
+
* a colocated boot of the same release image.
|
|
37
|
+
*
|
|
38
|
+
* ── Reachability ─────────────────────────────────────────────────────────────
|
|
39
|
+
* The PORT is `issuerListenPort()` (2.39.4: `OPENWOP_TEST_OIDC_ISSUER_PORT`,
|
|
40
|
+
* else the URL's port). The BIND is `receiverBinding()`, as for the webhook
|
|
41
|
+
* receiver and the OAuth doubles: loopback, or `0.0.0.0` when
|
|
42
|
+
* `OPENWOP_CONFORMANCE_HARNESS_HOST` says the host is in a container or on
|
|
43
|
+
* another box. Until 2.40.2 the issuer always bound `127.0.0.1`, so a host in a
|
|
44
|
+
* Linux container reaching it through `host-gateway` found nothing listening on
|
|
45
|
+
* the interface it arrived at (openwop-app ADR 0745 defect 2). The URL is what
|
|
46
|
+
* the host was configured with and is what `iss` and discovery carry; only the
|
|
47
|
+
* listening socket moves.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { createServer, type Server } from 'node:http';
|
|
51
|
+
import { receiverBinding } from './webhook-receiver.js';
|
|
52
|
+
import { issuerListenPort, type SyntheticOIDCIssuer } from './oidc-issuer.js';
|
|
53
|
+
|
|
54
|
+
const norm = (u: string): string => u.trim().replace(/\/+$/, '');
|
|
55
|
+
|
|
56
|
+
export type HarnessClaim =
|
|
57
|
+
| { readonly ok: true; readonly url: string }
|
|
58
|
+
| { readonly ok: false; readonly kind: 'inapplicable'; readonly reason: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Has the host claimed the harness issuer on `lane`? Pure: decided from the
|
|
62
|
+
* advertised lane record and the configured harness URL.
|
|
63
|
+
*/
|
|
64
|
+
export function harnessClaimed(lane: Readonly<Record<string, unknown>>, harnessUrl: string | undefined): HarnessClaim {
|
|
65
|
+
const name = String(lane['lane']);
|
|
66
|
+
const issuers = Array.isArray(lane['issuers']) ? (lane['issuers'] as unknown[]).filter((x): x is string => typeof x === 'string') : [];
|
|
67
|
+
const listed = issuers.length > 0 ? issuers.join(', ') : 'none';
|
|
68
|
+
const url = harnessUrl?.trim();
|
|
69
|
+
if (!url) {
|
|
70
|
+
return { ok: false, kind: 'inapplicable', reason: `lane ${name} trusts ${listed}, and no harness issuer is configured (OPENWOP_TEST_OIDC_ISSUER_URL unset): the suite holds a signing key for none of the host's trust roots, so no token it mints could be accepted. A host claims the harness by listing it in the lane's issuers[] (RFC 0168 §C.1's reading for suite instruments)` };
|
|
71
|
+
}
|
|
72
|
+
if (!issuers.some((i) => norm(i) === norm(url))) {
|
|
73
|
+
return { ok: false, kind: 'inapplicable', reason: `lane ${name} trusts ${listed}, which does not include the harness issuer ${url}: this host has not claimed the suite's issuer as a trust root (a production host must not), so the suite holds no key the host accepts. RFC 0168 §C.1's reading for suite instruments — witnessed on a cut whose lane lists the harness` };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, url: norm(url) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Serve `issuer`'s discovery + JWKS for the host to fetch. Resolves to the listening server. */
|
|
79
|
+
export async function serveHarnessIssuer(issuer: SyntheticOIDCIssuer, url: string): Promise<Server> {
|
|
80
|
+
const port = issuerListenPort(url);
|
|
81
|
+
const { bind } = receiverBinding();
|
|
82
|
+
const srv = createServer((r, res) => {
|
|
83
|
+
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(issuer.jwksJson); return; }
|
|
84
|
+
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(issuer.discoveryJson); return; }
|
|
85
|
+
res.writeHead(404); res.end();
|
|
86
|
+
});
|
|
87
|
+
// Two scenario files serve the one issuer URL, and vitest runs files in
|
|
88
|
+
// parallel: wait for the holder to close rather than fail (each instance has
|
|
89
|
+
// its own kid, so a host that cached the other file's JWKS re-fetches).
|
|
90
|
+
const deadline = Date.now() + 25_000;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const e = await new Promise<NodeJS.ErrnoException | null>((ok) => {
|
|
93
|
+
const onErr = (err: NodeJS.ErrnoException): void => ok(err);
|
|
94
|
+
srv.once('error', onErr);
|
|
95
|
+
srv.listen(port, bind, () => { srv.off('error', onErr); ok(null); });
|
|
96
|
+
});
|
|
97
|
+
if (e === null) return srv;
|
|
98
|
+
if (e.code !== 'EADDRINUSE' || Date.now() > deadline) throw e;
|
|
99
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0197 §A.3 — which persisted-class v2 members are retired at the release
|
|
3
|
+
* the suite ships, and whether an emitted event carries one.
|
|
4
|
+
*
|
|
5
|
+
* A persisted-class retirement keeps the member in the READER schema,
|
|
6
|
+
* annotated `x-openwop-retired-in: "2.N"`, and forbids only its EMISSION after
|
|
7
|
+
* `2.N`. The deprecations register says WHICH rows are persisted-class and
|
|
8
|
+
* when they fall due; the annotation in the v2 schema says WHAT the member is
|
|
9
|
+
* (a property name, or a `const` / single-member `enum` value). This module
|
|
10
|
+
* joins the two so `v2-capability-maturity-bounded`'s `retired-not-emitted`
|
|
11
|
+
* leg can scan real events for it.
|
|
12
|
+
*
|
|
13
|
+
* Pure (the caller passes the parsed register and a schema loader) so the
|
|
14
|
+
* self-test drives the due / not-due / unlocatable branches with fixtures —
|
|
15
|
+
* the register is empty today, so the host leg cannot reach them yet.
|
|
16
|
+
*
|
|
17
|
+
* @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §A.3
|
|
18
|
+
* @see scripts/check-removal-dates.mjs (the corpus gate that requires the annotation on a due persisted row)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type Json = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
export interface PersistedRetirement {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly removeIn: string;
|
|
26
|
+
readonly due: boolean;
|
|
27
|
+
/** `schemas/v2/…` source files the row cites, relative to the contract root. */
|
|
28
|
+
readonly schemaFiles: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RetiredMember {
|
|
32
|
+
readonly rowId: string;
|
|
33
|
+
readonly removeIn: string;
|
|
34
|
+
readonly schemaFile: string;
|
|
35
|
+
/** `property`: an object key; `value`: a string value. */
|
|
36
|
+
readonly kind: 'property' | 'value';
|
|
37
|
+
readonly name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RetiredEmission {
|
|
41
|
+
readonly rowId: string;
|
|
42
|
+
readonly member: string;
|
|
43
|
+
readonly eventType: string;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const minorOf = (v: string): number | null => {
|
|
48
|
+
const m = /^2\.(\d+)(?:\.\d+)?$/.exec(v.trim());
|
|
49
|
+
return m ? Number(m[1]) : null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const triggersOf = (e: Json): string[] => {
|
|
53
|
+
const t = e['removalTrigger'];
|
|
54
|
+
return Array.isArray(t) ? t.filter((x): x is string => typeof x === 'string') : typeof t === 'string' ? [t] : [];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Every `v2-minor` row whose `retirement.persistence` is `persisted`, and whether `release` has reached its `removeIn`. */
|
|
58
|
+
export function persistedRetirements(deprecations: unknown, release: string): PersistedRetirement[] {
|
|
59
|
+
const entries = ((deprecations as { entries?: unknown } | null)?.entries ?? []) as Json[];
|
|
60
|
+
const rel = minorOf(release);
|
|
61
|
+
const out: PersistedRetirement[] = [];
|
|
62
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
63
|
+
if (!triggersOf(e).includes('v2-minor')) continue;
|
|
64
|
+
const r = e['retirement'] as Json | undefined;
|
|
65
|
+
if ((r?.['persistence'] ?? 'advertised') !== 'persisted') continue;
|
|
66
|
+
const removeIn = String(e['removeIn'] ?? '');
|
|
67
|
+
const at = minorOf(removeIn);
|
|
68
|
+
const sources = (Array.isArray(e['sources']) ? e['sources'] : []) as Json[];
|
|
69
|
+
out.push({
|
|
70
|
+
id: String(e['id'] ?? '(no id)'),
|
|
71
|
+
removeIn,
|
|
72
|
+
due: rel !== null && at !== null && rel >= at,
|
|
73
|
+
schemaFiles: sources.map((s) => String(s['file'] ?? '')).filter((f) => /^schemas\/v2\//.test(f)),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The members a due row's schema annotates `x-openwop-retired-in: <removeIn>`. */
|
|
80
|
+
export function retiredMembers(row: PersistedRetirement, loadSchema: (file: string) => unknown): RetiredMember[] {
|
|
81
|
+
const out: RetiredMember[] = [];
|
|
82
|
+
for (const file of row.schemaFiles) {
|
|
83
|
+
const walk = (node: unknown, parentKey: string | null, grandKey: string | null): void => {
|
|
84
|
+
if (Array.isArray(node)) { for (const n of node) walk(n, null, parentKey); return; }
|
|
85
|
+
if (node === null || typeof node !== 'object') return;
|
|
86
|
+
const o = node as Json;
|
|
87
|
+
if (o['x-openwop-retired-in'] === row.removeIn) {
|
|
88
|
+
if (typeof o['const'] === 'string') out.push({ rowId: row.id, removeIn: row.removeIn, schemaFile: file, kind: 'value', name: o['const'] });
|
|
89
|
+
else if (Array.isArray(o['enum']) && o['enum'].length === 1 && typeof o['enum'][0] === 'string') out.push({ rowId: row.id, removeIn: row.removeIn, schemaFile: file, kind: 'value', name: o['enum'][0] });
|
|
90
|
+
else if (grandKey === 'properties' && parentKey !== null) out.push({ rowId: row.id, removeIn: row.removeIn, schemaFile: file, kind: 'property', name: parentKey });
|
|
91
|
+
}
|
|
92
|
+
for (const [k, v] of Object.entries(o)) walk(v, k, parentKey);
|
|
93
|
+
};
|
|
94
|
+
walk(loadSchema(file), null, null);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every place an event carries a retired member: a key named for a `property` member, or a string equal to a `value` member. */
|
|
100
|
+
export function findRetiredEmissions(events: readonly unknown[], members: readonly RetiredMember[]): RetiredEmission[] {
|
|
101
|
+
const hits: RetiredEmission[] = [];
|
|
102
|
+
for (const ev of events) {
|
|
103
|
+
const eventType = String((ev as Json | null)?.['type'] ?? '(untyped)');
|
|
104
|
+
const walk = (node: unknown, path: string): void => {
|
|
105
|
+
if (typeof node === 'string') {
|
|
106
|
+
for (const m of members) if (m.kind === 'value' && node === m.name) hits.push({ rowId: m.rowId, member: m.name, eventType, path });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(node)) { node.forEach((n, i) => walk(n, `${path}[${i}]`)); return; }
|
|
110
|
+
if (node === null || typeof node !== 'object') return;
|
|
111
|
+
for (const [k, v] of Object.entries(node as Json)) {
|
|
112
|
+
for (const m of members) if (m.kind === 'property' && k === m.name) hits.push({ rowId: m.rowId, member: m.name, eventType, path: `${path}.${k}` });
|
|
113
|
+
walk(v, `${path}.${k}`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
walk(ev, '$');
|
|
117
|
+
}
|
|
118
|
+
return hits;
|
|
119
|
+
}
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* Every refusal leg carries an admitted control in the same run, so a host that
|
|
20
20
|
* refuses everything fails as surely as one that refuses nothing.
|
|
21
21
|
*
|
|
22
|
+
* The last describe accounts for §C.9's `render-needs-root`, whose
|
|
23
|
+
* Falsifiability verdict is `unwitnessable`: the guarantee is render-side, and
|
|
24
|
+
* a server-oriented suite cannot observe a renderer. It records `inapplicable`
|
|
25
|
+
* on EVERY host, before any request and before the seam gate, and names where
|
|
26
|
+
* the real witness lives: a reference-app client probe, `tier: reference-impl`,
|
|
27
|
+
* following the `a2ui-surface-no-code-exec` precedent. It never asserts and
|
|
28
|
+
* cannot become a pass.
|
|
29
|
+
*
|
|
22
30
|
* @see RFCS/0209-v2-a2ui-surfaces-are-a2ui-v0-9.md
|
|
23
31
|
* @see spec/v2/ext/a2uiSurface/README.md
|
|
24
32
|
* @see spec/v2/core/events.md §"The envelope-kind catalog"
|
|
@@ -295,3 +303,10 @@ describe('RFC 0209 §C.12 — trust is sticky across the fold (seam-gated)', ()
|
|
|
295
303
|
}
|
|
296
304
|
});
|
|
297
305
|
});
|
|
306
|
+
|
|
307
|
+
describe('RFC 0209 §C.9 — no render before root (declared unwitnessable here; reference-impl witness)', () => {
|
|
308
|
+
it('records why render-needs-root has no server-side witness, and where its witness lives', () => {
|
|
309
|
+
req('openwop.requirement.0209.render-needs-root', 'RFC 0209 §C.9', 'a renderer does not render a surface before its root component exists');
|
|
310
|
+
return softSkip('inapplicable', 'unwitnessable by this suite (RFC 0209 §C.9 Falsifiability verdict): a render-side guarantee, and a server-oriented suite cannot observe a renderer. The fold guard the server CAN observe is openwop.requirement.0209.fold-guarded, above. The render half is witnessed as a reference-app client probe (tier: reference-impl, the a2ui-surface-no-code-exec precedent): openwop/openwop-app frontend/react/src/chat/a2ui/__tests__/a2ui-v09-render-needs-root.test.tsx (openwop-app#4121, ADR 0749). Removing the `if (!state.renderable)` guard in frontend/react/src/chat/a2ui/v09/A2uiV09Surface.tsx turns 2 of its 4 cases red. This row records that the suite reached the requirement, and it is not a pass.');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* The server-free legs (the schemas against A2A v1.0.1) are corpus rows in
|
|
22
22
|
* `src/coherence/a2a-parts-schemas.test.ts`.
|
|
23
23
|
*
|
|
24
|
+
* The last describe accounts for §C.11's `export-alias-equivalence`, whose
|
|
25
|
+
* Falsifiability verdict is `unwitnessable`. It records `inapplicable` with that
|
|
26
|
+
* verdict's reason on EVERY host, before any request, and never asserts. The
|
|
27
|
+
* row exists so a bundle says the suite reached the requirement and why it
|
|
28
|
+
* cannot observe it (`check-accepted-predicate` rule 4, second branch). It is
|
|
29
|
+
* not a witness and cannot become one.
|
|
30
|
+
*
|
|
24
31
|
* @see RFCS/0205-run-artifacts-and-turns-speak-a2a-parts.md
|
|
25
32
|
* @see spec/v2/core/runs.md §"Annotations, artifacts, eval summary"
|
|
26
33
|
*/
|
|
@@ -120,3 +127,10 @@ describe('RFC 0205 §A.3 — a url Part does not resolve beyond the caller\'s au
|
|
|
120
127
|
}
|
|
121
128
|
});
|
|
122
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('RFC 0205 §C.11 — an export alias and its media type name one format (declared unwitnessable)', () => {
|
|
132
|
+
it('records why export-alias-equivalence has no wire witness', () => {
|
|
133
|
+
req('openwop.requirement.0205.export-alias-equivalence', 'RFC 0205 §C.11', 'an export alias and its media type name one format');
|
|
134
|
+
return softSkip('inapplicable', 'unwitnessable on the wire (RFC 0205 §C.11 Falsifiability verdict): the equivalence binds a consumer\'s format-matching logic and the pack author\'s host.artifactTypes.export call, and neither is on the wire. The advertised spellings are readable, but a mismatch between them is legal, so no host response can falsify the row. No wire witness exists; this row records that the suite reached the requirement, and it is not a pass.');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -27,21 +27,44 @@
|
|
|
27
27
|
* document and 38 families fail. That run is the sabotage proof, recorded in
|
|
28
28
|
* the RFC 0197 gates PR.
|
|
29
29
|
*
|
|
30
|
+
* The second describe is RFC 0197 §A.3's emission leg,
|
|
31
|
+
* `openwop.requirement.0197.retired-not-emitted`. Its Falsifiability verdict
|
|
32
|
+
* is "unwitnessable until the first persisted-class retirement row exists", so
|
|
33
|
+
* the leg is CONDITIONAL, never vacuous. It reads the deprecations register
|
|
34
|
+
* the suite ships, and while that has no due `v2-minor` row with
|
|
35
|
+
* `retirement.persistence: "persisted"`, it records `inapplicable` with the
|
|
36
|
+
* RFC's own reason. It must record a reason: rule 4 of
|
|
37
|
+
* `check-accepted-predicate` accepts a declared non-executable row only when a
|
|
38
|
+
* bundle row gives one. Once such a row is due, the leg drives a run and fails
|
|
39
|
+
* on any event that carries a member the v2 schema annotates
|
|
40
|
+
* `x-openwop-retired-in`. It never records `executed-pass` without scanning
|
|
41
|
+
* events. The branches the host leg cannot reach today are self-tested in
|
|
42
|
+
* `lib/v2-retired-members.test.ts`.
|
|
43
|
+
*
|
|
30
44
|
* @see spec/v2/core/capabilities.md §8
|
|
31
45
|
* @see spec/v2/declaration.json
|
|
32
|
-
* @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §C
|
|
46
|
+
* @see RFCS/0197-v2-surfaces-retired-never-reshaped.md §A.3, §C
|
|
33
47
|
*/
|
|
34
48
|
|
|
35
49
|
import { describe, it, expect } from 'vitest';
|
|
36
50
|
import { existsSync, readFileSync } from 'node:fs';
|
|
37
51
|
import { join } from 'node:path';
|
|
38
52
|
import { v2Discovery } from '../lib/v2.js';
|
|
53
|
+
import { driver } from '../lib/driver.js';
|
|
54
|
+
import { persistedRetirements, retiredMembers, findRetiredEmissions } from '../lib/v2-retired-members.js';
|
|
39
55
|
import { SCHEMAS_DIR, SPEC_V2_DIR } from '../lib/paths.js';
|
|
40
56
|
import { softSkip } from '../lib/soft-skip.js';
|
|
41
57
|
import { req } from '../lib/requirement-ids.js';
|
|
42
58
|
|
|
43
59
|
const DOC = 'spec/v2/core/capabilities.md §8';
|
|
44
60
|
const ID = 'openwop.requirement.0197.maturity-not-overstated';
|
|
61
|
+
const RETIRED_ID = 'openwop.requirement.0197.retired-not-emitted';
|
|
62
|
+
const RETIRED_DOC = 'RFC 0197 §A.3';
|
|
63
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
64
|
+
|
|
65
|
+
function readJson(path: string): unknown {
|
|
66
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
67
|
+
}
|
|
45
68
|
|
|
46
69
|
interface DeclarationFamily { key: string; maturity?: { technical?: string } }
|
|
47
70
|
|
|
@@ -106,3 +129,41 @@ describe('v2 capability-maturity-bounded (RFC 0197 §C.7)', () => {
|
|
|
106
129
|
expect(records.length, req(ID, DOC, `the scenario asserted over ${records.length} family record(s)${undeclared.length > 0 ? `; ${undeclared.length} carry no declaration row and are left to v2-capabilities-root-closed: ${undeclared.join(', ')}` : ''}`)).toBeGreaterThan(0);
|
|
107
130
|
});
|
|
108
131
|
});
|
|
132
|
+
|
|
133
|
+
describe('v2 retired-not-emitted (RFC 0197 §A.3) — conditional on a due persisted-class retirement', () => {
|
|
134
|
+
it('no run emits a persisted member the release has retired', async () => {
|
|
135
|
+
if (SPEC_V2_DIR === null) return softSkip('blocked', 'spec/v2/ is not present in this layout — the retirement register cannot be read, and a register that cannot be read is not an absence');
|
|
136
|
+
const deprecations = readJson(join(SPEC_V2_DIR, '..', 'v1', 'deprecations.json'));
|
|
137
|
+
const migrations = readJson(join(SPEC_V2_DIR, 'migrations.json')) as { rows?: unknown[] } | null;
|
|
138
|
+
const release = (readJson(join(SPEC_V2_DIR, 'release.json')) as { version?: unknown } | null)?.version;
|
|
139
|
+
if (deprecations === null || typeof release !== 'string') return softSkip('blocked', 'spec/v1/deprecations.json or spec/v2/release.json is missing from the shipped corpus — the retirement register cannot be read');
|
|
140
|
+
const rows = persistedRetirements(deprecations, release);
|
|
141
|
+
const due = rows.filter((r) => r.due);
|
|
142
|
+
const census = `corpus ${release}: ${rows.length} persisted-class v2-minor retirement row(s), ${(migrations?.rows ?? []).length} v2→v2 migration row(s)`;
|
|
143
|
+
if (due.length === 0) {
|
|
144
|
+
return softSkip('inapplicable', `unwitnessable until the first persisted-class retirement row is due (RFC 0197 §A.3 Falsifiability): ${census}${rows.length > 0 ? `, none due yet (${rows.map((r) => `${r.id} removeIn ${r.removeIn}`).join(', ')})` : ''} — no retired member exists for a post-2.N event to carry. Before one does, a scenario would be vacuous, and a row that cannot fail is not a witness. This leg asserts the moment such a row falls due.`);
|
|
145
|
+
}
|
|
146
|
+
const contractRoot = join(SPEC_V2_DIR, '..', '..');
|
|
147
|
+
const members = due.flatMap((r) => retiredMembers(r, (file) => readJson(join(contractRoot, file))));
|
|
148
|
+
const unlocated = due.filter((r) => !members.some((m) => m.rowId === r.id));
|
|
149
|
+
if (unlocated.length > 0) return softSkip('blocked', `due persisted-class retirement row(s) whose member carries no x-openwop-retired-in annotation the suite can read: ${unlocated.map((r) => `${r.id} (${r.schemaFiles.join(', ') || 'no schemas/v2 source'})`).join('; ')}. Without the member there is nothing to scan for, and scanning for nothing is not a pass (check-removal-dates owns the missing annotation)`);
|
|
150
|
+
const create = await driver.post('/runs', { workflowId: 'conformance-noop' });
|
|
151
|
+
const runId = String((create.json as { runId?: unknown } | null)?.runId ?? '');
|
|
152
|
+
if (create.status !== 201 || runId === '') return softSkip('blocked', `POST /runs {workflowId: conformance-noop} answered ${create.status} — no post-${due[0]!.removeIn} run to read events from`);
|
|
153
|
+
const deadline = Date.now() + 20_000;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
156
|
+
if (res.status === 200 && TERMINAL.has(String((res.json as { status?: unknown } | null)?.status ?? ''))) break;
|
|
157
|
+
if (Date.now() > deadline) return softSkip('blocked', `conformance-noop run ${runId} did not reach a terminal status within 20 s`);
|
|
158
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
159
|
+
}
|
|
160
|
+
const poll = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
161
|
+
const events = (poll.json as { events?: unknown } | null)?.events;
|
|
162
|
+
if (poll.status !== 200 || !Array.isArray(events) || events.length === 0) return softSkip('blocked', `the event poll for run ${runId} answered ${poll.status} with no events — there is nothing emitted to scan`);
|
|
163
|
+
const hits = findRetiredEmissions(events, members);
|
|
164
|
+
expect(
|
|
165
|
+
hits.map((h) => `${h.member} (${h.rowId}, retired in ${members.find((m) => m.rowId === h.rowId)?.removeIn}) in ${h.eventType} at ${h.path}`),
|
|
166
|
+
req(RETIRED_ID, RETIRED_DOC, `a host MUST NOT emit a persisted member after the minor that retired it; scanned ${events.length} event(s) for ${members.length} retired member(s): ${members.map((m) => `${m.kind} ${m.name}`).join(', ')}`),
|
|
167
|
+
).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|