@openwop/openwop-conformance 2.0.4 → 2.0.5
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 +54 -0
- package/dist/cli.js +5 -1
- package/dist/lib/certification-bundle-v3.js +29 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirement-aliases.json +167 -2
- package/requirements.json +606 -766
- package/scenario-majors.json +3 -2
- package/schemas/CORPUS-STAMP.json +49 -11
- package/src/cli.ts +5 -1
- package/src/lib/certification-bundle-v3.ts +54 -1
- package/src/lib/era2-seed.ts +27 -0
- package/src/lib/memoryAttribution.ts +30 -3
- package/src/scenarios/memory-attribution-replay-stable.test.ts +30 -6
- package/src/scenarios/v2-run-fork-prefix.test.ts +85 -1
- package/src/scenarios/v2-unmapped-type-refused.test.ts +82 -5
package/scenario-majors.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"counts": {
|
|
4
4
|
"files": 516,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 73
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -773,7 +773,8 @@
|
|
|
773
773
|
1
|
|
774
774
|
],
|
|
775
775
|
"memory-attribution-replay-stable.test.ts": [
|
|
776
|
-
1
|
|
776
|
+
1,
|
|
777
|
+
2
|
|
777
778
|
],
|
|
778
779
|
"memory-attribution-shape.test.ts": [
|
|
779
780
|
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.0.
|
|
5
|
-
"corpusTag": "v2.0.
|
|
4
|
+
"version": "2.0.5",
|
|
5
|
+
"corpusTag": "v2.0.5",
|
|
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": "99eae8d4204c4c23c4af05ed5e4982171ca6cf4fe9be03ebcedd9d977c5006dc",
|
|
13
|
+
"api/v2/asyncapi.yaml": "42040149c2a2056b1f69533fe4fb96e4f0299c8a06e952a88b8b7fd6d21748d2",
|
|
14
|
+
"api/v2/openapi.yaml": "4dcb20f14d28b4a7bfbbd2f6c1740723937169b7d1581670fe82c968e5d587a8",
|
|
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": "cb54c4d89ba5dfb73f49aa8959a980be65c2dc2a87a12f71ba36d3e836fe85da",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
|
|
118
118
|
"schemas/v2/capabilities.schema.json": "792669b2cc7b1fd44ce9a4fd3e79696ad1277734e29299a4504b39eda15913ed",
|
|
119
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
119
|
+
"schemas/v2/certification-bundle.schema.json": "d2cbc5cadddff13630a528dc9673162f582c035c6a6663ca02c1ac53e8fec40c",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "9475d46f358d21c19126e78204987a5633979917a2293653354ef5c5e044d54e",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
122
122
|
"schemas/v2/chat-card-pack-manifest.schema.json": "8df0d9d273ae22efc6876d5586833f1d4c2e551edc3ac6cf6e8f0719329f03dd",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "6e6059b6bcb2196ebbdf4015a28d4a077f93e7bec710326c492b49935988fd15",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -211,12 +211,50 @@
|
|
|
211
211
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
212
212
|
"spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
|
|
213
213
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
214
|
-
"spec/v2/
|
|
215
|
-
"spec/v2/
|
|
214
|
+
"spec/v2/README.md": "f56bd8d58be4ac04543c9f7b2fe92b294738d6121a4f21cbd1ce751e72b94c83",
|
|
215
|
+
"spec/v2/core/capabilities.md": "0f222434bbe58c881362a626ab9db6bce0ae2e54cb858e3d71b662dd1fc13150",
|
|
216
|
+
"spec/v2/core/conformance.md": "91418b882190e116ab9dac14527f52b8582b0462f41e96beeceb07e3fcc5f99a",
|
|
217
|
+
"spec/v2/core/connection-packs.md": "34e519dbe5b091c9758729787d398b35b1dc41f1e01ebd028e7e6b838ce2f6ee",
|
|
218
|
+
"spec/v2/core/errors.md": "671895de1b7c987a1dfd53fe0a814159efadae0ffd4c8c06bf1749d345270580",
|
|
219
|
+
"spec/v2/core/events.md": "7b9207079cede7ec335126b98bcb95d655ae3b7322b18febd1b4df51750b0a33",
|
|
220
|
+
"spec/v2/core/form-content-packs.md": "976f6e9b05a015f3bfe0748bc4f8bb11bbbd71460d12769f900bcde006b7cbb1",
|
|
221
|
+
"spec/v2/core/headers.md": "b7fc4f77904653ad47360b20302e3f406973821fb6d74ac86d6e81b674f52c4a",
|
|
222
|
+
"spec/v2/core/idempotency.md": "0ab68c1e41e81005b560421a3d22f985dda2f7537b2369bff67e40b79c8a581f",
|
|
223
|
+
"spec/v2/core/identity.md": "e3fa8d2d87c1dec2673e4bf4aec5f2257c684eaf64161c1a1016a634a87509c2",
|
|
224
|
+
"spec/v2/core/interop.md": "f701fddf15343d878faea31401e25e6ffb3672c2c8a87f30adc13b1e3c5c300c",
|
|
225
|
+
"spec/v2/core/interrupt.md": "4bd345bccdb8e2762ca95833cc67086158640c27ffa5ef84915a4ab3de348440",
|
|
226
|
+
"spec/v2/core/overview.md": "8ece6667535d24ed4013a046cb11e1a649f70e39625fc55e09ceb2c02505ed9c",
|
|
227
|
+
"spec/v2/core/packs.md": "90f32947816b4ec2b045b063ad2914126c73353883514a270ad200205dc8e51b",
|
|
228
|
+
"spec/v2/core/persistence.md": "e18195e078d7b0b799055005567e8d86a2ea7518c87809cfaf43dfe9f5d5c8ee",
|
|
229
|
+
"spec/v2/core/replay.md": "19b2fcbda4693895d39b99593f498b76376d77472a813bba6c22fca02e106332",
|
|
230
|
+
"spec/v2/core/runs.md": "f5f24df6da76b1d6e2cf655af3bbe3340195c36ec8d9ce0ca6e357385b1a8771",
|
|
231
|
+
"spec/v2/core/security-defaults.md": "53a1bc549aaa356a4fcb56cdb1c4f5880d3fb65d5d49dd570a413bb29aff82e5",
|
|
232
|
+
"spec/v2/core/versioning.md": "2eb4e8e5a1208a026a3d497d1e0fb76a4f7cb97e514d6f41f45dd66b89405abb",
|
|
233
|
+
"spec/v2/core/webhooks.md": "1d2719335f8fb46cd31dbb836b44ea7d78ed54f98ea181927502814603c5b935",
|
|
234
|
+
"spec/v2/core/workflow-chain-packs.md": "99ad4aeeeeb05521720dc2ae18ae63e6d604361780ed1b76778ca6d44da70eed",
|
|
235
|
+
"spec/v2/declaration.json": "9e191dffae9f95e622b3fc7087b8c17a2f42177bb4db3c7ee5fdf650443e036d",
|
|
236
|
+
"spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
|
|
216
237
|
"spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
|
|
217
238
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
218
239
|
"spec/v2/event-codemap.schema.json": "ce2d0ca085d7668d7ef745d2560b385ec6881d24a6d4b47c49931986b2ba642d",
|
|
240
|
+
"spec/v2/ext/a2uiSurface/README.md": "176cb0542e1c236ffcd6d682536ba0081242684444e2dec790abd480a44a571e",
|
|
241
|
+
"spec/v2/ext/brand/README.md": "1a80102a9cd766dcb6098c999e552af88f5b83c88951acde18d17cc0b29aa09d",
|
|
242
|
+
"spec/v2/ext/canvas/README.md": "a2d5f28523753e8e85cc9042db39faf9207a2eebce1a8553be22e341f7ea58d9",
|
|
243
|
+
"spec/v2/ext/chat/README.md": "39fe2961502d2937fe487cf52acfffeeb4a396731f0b2bf737b109eac2752bca",
|
|
244
|
+
"spec/v2/ext/coordination/README.md": "a193aa9a59a2d9ac8d2e9775e5be40c29b6f7476ffef35231dbab49377af9207",
|
|
245
|
+
"spec/v2/ext/dataIntegration/README.md": "8361c626ed9f77dc48a3bcaa0c9f769cb6706632941014700f7683efe0918bd1",
|
|
246
|
+
"spec/v2/ext/entities/README.md": "c3bf6c59ddf2255c22a522e689f6e7a11646fbcd309b17699b6cf69a4e2e0f91",
|
|
247
|
+
"spec/v2/ext/grpc-transport/README.md": "7fa692715744d6573443f994c521cbff0bf0be2e81ffdbb01e42f58f32a3ffaf",
|
|
248
|
+
"spec/v2/ext/kanban/README.md": "6792926f7f0cb8a4dcb0acdedb86a1988574fb0b5de164b37159dfe3ba35f908",
|
|
249
|
+
"spec/v2/ext/knowledge/README.md": "d2615320c7de4aec279f276381376a8881e962dd4e86b1435462d742dd00a52f",
|
|
250
|
+
"spec/v2/ext/launchStudio/README.md": "bfe417e774689773f3bbced9fdfa0c18809577aa6040a1312d914c821beed026",
|
|
251
|
+
"spec/v2/ext/messaging/README.md": "8a4702935483ce7900c0ee14793f9dfbeeb09d5e43f8943c946f8f94de2efa6f",
|
|
252
|
+
"spec/v2/ext/portability/README.md": "d9caf3dd723212676a0dd7f847284ce8aa3f0f5ebd4b273580c34a260ece96d7",
|
|
253
|
+
"spec/v2/ext/provider-idempotency/README.md": "c7620aadaf4e7ded997487c917e1583c8bc9ba20aa5353476d66f229207dbd1a",
|
|
219
254
|
"spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
|
|
255
|
+
"spec/v2/ext/restTransport/README.md": "b39f4d1ddbabd72dd945a39e90218d8413e46baea1269798b96dffb4e429cb4f",
|
|
256
|
+
"spec/v2/ext/sandbox-runtime-notes/README.md": "36c5517082b1cce0f1ccec5153e4bea10144bba88503b3b023f920444f3c15e6",
|
|
257
|
+
"spec/v2/ext/webResearch/README.md": "941dfe8323fd536791167f9fc7d119dd4e165bb7937e3382a19f15cda3d9f19a",
|
|
220
258
|
"spec/v2/facets/a2a.schema.json": "6b0bceddec6d18d5115aa7d04720a0cc1ad7a9a0a78f53c208b34becaba5d16e",
|
|
221
259
|
"spec/v2/facets/aiProviders.schema.json": "0c76e48e5d487b5be3b3ab1f6512a4e206c840ee5087d1336c7d7e2853b58d03",
|
|
222
260
|
"spec/v2/facets/auth.schema.json": "73440c944ad12c41aab0618aeb799a90a0b59e97fc685527f248afb7d015381a",
|
|
@@ -230,8 +268,8 @@
|
|
|
230
268
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
231
269
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
232
270
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
233
|
-
"spec/v2/release.json": "
|
|
271
|
+
"spec/v2/release.json": "b5017d420b712ef558a93e0a6576df225583294157bb5c8cb198ee490c118780",
|
|
234
272
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
235
273
|
},
|
|
236
|
-
"corpusCommit": "
|
|
274
|
+
"corpusCommit": "3d3eca2a5b8e856c1f6cf448ee9e19fbb4fe5dd1"
|
|
237
275
|
}
|
package/src/cli.ts
CHANGED
|
@@ -677,7 +677,11 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
677
677
|
generatedAt: new Date().toISOString(),
|
|
678
678
|
suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
|
|
679
679
|
host: { name: host.name, version: host.version, ...(host.vendor ? { vendor: host.vendor } : {}), build, signingKeyId: keyId, ...(relaxations && relaxations.length ? { relaxations } : {}) },
|
|
680
|
-
|
|
680
|
+
// `document` is what makes `claimedProfiles[].certified` checkable by
|
|
681
|
+
// someone other than this process (RFC 0148 §B(1)); v2 carried it and v3
|
|
682
|
+
// dropped it. `sha256` is a digest of `canonicalJSON(document)`, so the
|
|
683
|
+
// two are consistent by construction and the verifier re-derives it.
|
|
684
|
+
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
681
685
|
claimedProfiles: claimed3,
|
|
682
686
|
results: { totals: totals3, requirements: rows3 },
|
|
683
687
|
witnessSha256: witnessDigest(rows3),
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* in cli.ts) and pure: it never reads the environment.
|
|
19
19
|
*/
|
|
20
20
|
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
|
|
21
|
+
import { profileDerivable, type DiscoveryPayload } from './profiles.js';
|
|
21
22
|
|
|
22
23
|
export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
|
|
23
24
|
|
|
@@ -41,7 +42,25 @@ export interface BundleV3 {
|
|
|
41
42
|
generatedAt: string;
|
|
42
43
|
suite: { name: '@openwop/openwop-conformance'; version: string; targetMajor: 1 | 2; specArtifactsVersion: string; stampSha256?: string };
|
|
43
44
|
host: { name: string; version: string; vendor?: string; build: { kind: 'image-digest' | 'commit' | 'artifact-sha256'; id: string }; signingKeyId?: string; relaxations?: BundleV3Relaxation[] };
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* `document` is the captured `/.well-known/openwop` payload, OPTIONAL.
|
|
47
|
+
*
|
|
48
|
+
* v2 bundles carried it (`discovery: { url, sha256, document }`) and
|
|
49
|
+
* `verifyBundleV2` read it to decide whether each claimed profile was
|
|
50
|
+
* DERIVABLE from what the host actually advertised. v3 shipped
|
|
51
|
+
* `{ url, sha256, protocolVersions, preferredVersion }` and `verifyBundleV3`
|
|
52
|
+
* dropped the check to match — so a v3 bundle's `certified: true` became a
|
|
53
|
+
* claim only its emitter could evaluate. `sha256` does not help a reader: it
|
|
54
|
+
* is a digest of `canonicalJSON(document)`, and a digest of a document you do
|
|
55
|
+
* not have proves nothing about its contents. Re-fetching is not a
|
|
56
|
+
* substitute, because the host redeploys and the bundle is supposed to
|
|
57
|
+
* attest to the build it names.
|
|
58
|
+
*
|
|
59
|
+
* Optional, not required: every bundle cut before this revision lacks it and
|
|
60
|
+
* stays verifiable. Present-and-inconsistent is a rejection; absent is a
|
|
61
|
+
* stated gap (`V3Verdict.derivabilityChecked === false`).
|
|
62
|
+
*/
|
|
63
|
+
discovery: { url: string; sha256: string; protocolVersions: string[]; preferredVersion: string; document?: Record<string, unknown> };
|
|
45
64
|
claimedProfiles: BundleV3Profile[];
|
|
46
65
|
results: { totals: Record<'executedPass' | 'executedFail' | 'skipped' | 'inapplicable' | 'blocked', number>; requirements: BundleV3Requirement[] };
|
|
47
66
|
witnessSha256: string;
|
|
@@ -104,6 +123,13 @@ export interface V3Verdict {
|
|
|
104
123
|
readonly signatureVerified: boolean;
|
|
105
124
|
readonly verifierSignatureVerified: boolean;
|
|
106
125
|
readonly certifiedProfiles: string[];
|
|
126
|
+
/**
|
|
127
|
+
* Whether every `certified` profile was re-derived from the bundle's own
|
|
128
|
+
* `discovery.document`. `false` means the bundle did not carry one, so the
|
|
129
|
+
* certified list is the emitter's word — not wrong, but not independently
|
|
130
|
+
* checked. Read it before quoting a verdict.
|
|
131
|
+
*/
|
|
132
|
+
readonly derivabilityChecked: boolean;
|
|
107
133
|
}
|
|
108
134
|
|
|
109
135
|
export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3Verdict {
|
|
@@ -158,6 +184,32 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
158
184
|
else if (p.certified) certifiedProfiles.push(p.id);
|
|
159
185
|
}
|
|
160
186
|
if (expected.blocked > 0 && certifiedProfiles.length > 0) rejections.push({ kind: 'blocked-certified', detail: `${expected.blocked} blocked row(s): a bundle with blocked > 0 does not certify (RFC 0168 §E.1)` });
|
|
187
|
+
|
|
188
|
+
// Derivability, restored. RFC 0148 §B(1): a profile certifies only if it is
|
|
189
|
+
// DERIVABLE from what the host advertised. `verifyBundleV2` checked exactly
|
|
190
|
+
// that against `discovery.document`; v3 stopped carrying the document and the
|
|
191
|
+
// check went with it, leaving `certified: true` a claim only its emitter
|
|
192
|
+
// could evaluate. When the document is present the check runs again — and it
|
|
193
|
+
// runs on the SAME bytes the signature covers, because `discovery.sha256` is
|
|
194
|
+
// a digest of `canonicalJSON(document)` and is re-derived here first. A
|
|
195
|
+
// document that does not hash to the signed digest is a substituted
|
|
196
|
+
// document, and rejecting it is the whole point of checking.
|
|
197
|
+
const document = bundle.discovery?.document;
|
|
198
|
+
let derivabilityChecked = false;
|
|
199
|
+
if (document !== undefined) {
|
|
200
|
+
const digest = createHash('sha256').update(canonicalJSON(document)).digest('hex');
|
|
201
|
+
if (digest !== bundle.discovery?.sha256) {
|
|
202
|
+
rejections.push({ kind: 'discovery-digest', detail: `discovery.document hashes to ${digest.slice(0, 12)} but discovery.sha256 is ${String(bundle.discovery?.sha256).slice(0, 12)} — the captured document is not the one the signature attests to` });
|
|
203
|
+
} else {
|
|
204
|
+
derivabilityChecked = true;
|
|
205
|
+
for (const p of bundle.claimedProfiles ?? []) {
|
|
206
|
+
if (!p.certified) continue;
|
|
207
|
+
if (!profileDerivable(document as DiscoveryPayload, p.id)) {
|
|
208
|
+
rejections.push({ kind: 'profile-not-derivable', profile: p.id, detail: `${p.id} is marked certified, but the captured discovery document does not derive it (RFC 0148 §B(1)) — evidence cannot certify a profile the host does not advertise` });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
161
213
|
// RFC 0168 §E.2: the verifier REFUSES, it does not warn — but a refusal is
|
|
162
214
|
// scoped to what it names. A rejection carrying a `profile` removes that
|
|
163
215
|
// profile only (a relaxation on one obligation does not poison an unrelated
|
|
@@ -171,5 +223,6 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
171
223
|
signatureVerified,
|
|
172
224
|
verifierSignatureVerified,
|
|
173
225
|
certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
|
|
226
|
+
derivabilityChecked,
|
|
174
227
|
};
|
|
175
228
|
}
|
package/src/lib/era2-seed.ts
CHANGED
|
@@ -182,6 +182,33 @@ export function codemapV1toV2(): Map<string, string> {
|
|
|
182
182
|
return m;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The registered vendor orgs, from `spec/v2/declaration.json` `extensions`
|
|
187
|
+
* (events.md §Rules; RFC 0171 §A.1). A vendor event type's first segment MUST
|
|
188
|
+
* be a key here, which is what separates "read under its own name unchanged"
|
|
189
|
+
* from "fail the read with `event_type_unmapped`" (persistence.md §The codemap
|
|
190
|
+
* is data / §The era key).
|
|
191
|
+
*
|
|
192
|
+
* `undefined` means the declaration is not on disk in this layout — a scenario
|
|
193
|
+
* that needs the registry MUST record that as a soft-skip rather than guess.
|
|
194
|
+
* There is no fallback list on purpose: a hard-coded org would make the suite
|
|
195
|
+
* the registry, and the whole defect this closes was a rule citing a registry
|
|
196
|
+
* that did not exist.
|
|
197
|
+
*/
|
|
198
|
+
let orgs: ReadonlySet<string> | null | undefined;
|
|
199
|
+
export function registeredOrgs(): ReadonlySet<string> | undefined {
|
|
200
|
+
if (orgs !== undefined) return orgs ?? undefined;
|
|
201
|
+
const candidate = V1_DIR ? join(V1_DIR, '..', 'v2', 'declaration.json') : null;
|
|
202
|
+
orgs = null;
|
|
203
|
+
if (candidate && existsSync(candidate)) {
|
|
204
|
+
try {
|
|
205
|
+
const doc = JSON.parse(readFileSync(candidate, 'utf8')) as { extensions?: Record<string, unknown> };
|
|
206
|
+
if (doc.extensions && typeof doc.extensions === 'object') orgs = new Set(Object.keys(doc.extensions));
|
|
207
|
+
} catch { /* left null: unreadable is indistinguishable from absent, and both are soft-skips */ }
|
|
208
|
+
}
|
|
209
|
+
return orgs ?? undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
/** A minimal era-2 log in v1 vocabulary: two renamed rows between run.started and run.completed. */
|
|
186
213
|
export function v1FixtureLog(workflowId = 'conformance-noop'): SeedEvent[] {
|
|
187
214
|
const t0 = Date.parse('2026-01-15T10:00:00.000Z');
|
|
@@ -5,9 +5,36 @@
|
|
|
5
5
|
import { driver } from './driver.js';
|
|
6
6
|
import { discoveryFamilies } from './discovery-capabilities.js';
|
|
7
7
|
import { isFixtureAdvertised } from './fixtures.js';
|
|
8
|
+
import { targetMajor } from './seams.js';
|
|
9
|
+
import { gateFamily } from './v2.js';
|
|
8
10
|
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The runs collection under the major in play. `/v1/runs` is a v1 address, not
|
|
13
|
+
* a seam, so `driver`'s seam rewrite does not touch it and a v1-shaped helper
|
|
14
|
+
* simply 404s on a v2 host. Every path in this file goes through here so a
|
|
15
|
+
* scenario can be made dual-major without editing three call sites and missing
|
|
16
|
+
* a fourth.
|
|
17
|
+
*/
|
|
18
|
+
export const runsPath = (): string => (targetMajor() === 2 ? '/runs' : '/v1/runs');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `memory.attribution`, read the way the major in play advertises it, with the
|
|
22
|
+
* gate RECORDED at major 2.
|
|
23
|
+
*
|
|
24
|
+
* At major 1 this is the v1 capability read it always was. At major 2 it goes
|
|
25
|
+
* through `gateFamily('memory')`, which registers `openwop.family.memory` in
|
|
26
|
+
* the behaviour ledger — so a bundle distinguishes "the host does not
|
|
27
|
+
* advertise `memory`" from "the suite's reader did not understand the shape".
|
|
28
|
+
* An unrecorded early return looks identical to both, and to a scenario that
|
|
29
|
+
* was never selected; that is the whole reason a v1 behavioural file cannot
|
|
30
|
+
* just be pointed at a v2 host and trusted.
|
|
31
|
+
*/
|
|
10
32
|
export async function readMemoryAttributionCap(): Promise<Record<string, unknown> | null> {
|
|
33
|
+
if (targetMajor() === 2) {
|
|
34
|
+
const family = await gateFamily('memory');
|
|
35
|
+
const attr = family?.['attribution'];
|
|
36
|
+
return attr && typeof attr === 'object' && !Array.isArray(attr) ? (attr as Record<string, unknown>) : null;
|
|
37
|
+
}
|
|
11
38
|
const res = await driver.get('/.well-known/openwop');
|
|
12
39
|
const caps = discoveryFamilies(res.json);
|
|
13
40
|
const mem = caps && typeof caps === 'object' ? (caps as Record<string, unknown>)['memory'] : undefined;
|
|
@@ -33,7 +60,7 @@ export async function seedRun(_label: string): Promise<string | null> {
|
|
|
33
60
|
// certifying. No caller ever needed a SECOND tenant: every leg reads back its own run.
|
|
34
61
|
// The tenant is the credential's own tenant (the host defaults it from the API key —
|
|
35
62
|
// rest-endpoints.md), which is what "a run the bearer provably owns" means.
|
|
36
|
-
const r = await driver.post(
|
|
63
|
+
const r = await driver.post(runsPath(), { workflowId: SEED_FIXTURE, inputs: {} });
|
|
37
64
|
if (r.status !== 200 && r.status !== 201) return null;
|
|
38
65
|
return (r.json as { runId?: string } | undefined)?.runId ?? null;
|
|
39
66
|
}
|
|
@@ -46,7 +73,7 @@ interface RunEventLike {
|
|
|
46
73
|
|
|
47
74
|
/** Fetches a run's events and returns only the `memory.written` ones. */
|
|
48
75
|
export async function memoryWrittenEvents(runId: string): Promise<RunEventLike[]> {
|
|
49
|
-
const res = await driver.get(
|
|
76
|
+
const res = await driver.get(`${runsPath()}/${encodeURIComponent(runId)}/events`);
|
|
50
77
|
const events = (res.json as { events?: RunEventLike[] } | undefined)?.events ?? [];
|
|
51
78
|
return events.filter((e) => e.type === 'memory.written');
|
|
52
79
|
}
|
|
@@ -29,18 +29,42 @@
|
|
|
29
29
|
* should have: nothing normative changed, the instrument just could not see
|
|
30
30
|
* it before.
|
|
31
31
|
*
|
|
32
|
-
* Gated on `
|
|
33
|
-
* when
|
|
34
|
-
*
|
|
32
|
+
* Gated on `memory.attribution.emitsWriteEvents`; soft-skips when unadvertised,
|
|
33
|
+
* when the seeded run wrote no memory, or when the host doesn't support `:fork`
|
|
34
|
+
* in `replay` mode.
|
|
35
|
+
*
|
|
36
|
+
* MAJORS [1, 2] (suite 2.0.5), and why this file is the first one moved.
|
|
37
|
+
* The rule it checks — a replay MUST re-emit recorded-fact events and MUST NOT
|
|
38
|
+
* regenerate their ids — is `replay.md` §Determinism caveat 5 in BOTH majors,
|
|
39
|
+
* word for word. It was nevertheless `majors: [1]`, so no v2 host had ever been
|
|
40
|
+
* measured on it, for two reasons that have nothing to do with the rule:
|
|
41
|
+
*
|
|
42
|
+
* 1. Its gate read the v1 capability shape directly and returned early with
|
|
43
|
+
* NOTHING RECORDED. An unrecorded early return is indistinguishable, in a
|
|
44
|
+
* bundle, from a host that does not advertise `memory`, from a suite
|
|
45
|
+
* reader that does not understand the v2 shape, and from a file that was
|
|
46
|
+
* never selected. It now goes through `gateFamily('memory')` at major 2,
|
|
47
|
+
* which registers `openwop.family.memory` — the gate becomes evidence.
|
|
48
|
+
* 2. Its three paths were hard-coded `/v1/…`. That is a v1 address and not a
|
|
49
|
+
* seam, so the driver's seam rewrite never touched it; on a v2 host the
|
|
50
|
+
* helper would simply 404 and the file would report a host defect that was
|
|
51
|
+
* really a suite defect. `runsPath()` resolves the major.
|
|
52
|
+
*
|
|
53
|
+
* Both are properties of the INSTRUMENT, not of the obligation. That is the
|
|
54
|
+
* shape to look for in the remaining v1 behavioural files: a rule that holds at
|
|
55
|
+
* major 2, held back by a v1-shaped gate and a v1-shaped path. Neither is a
|
|
56
|
+
* reason for a host to go unmeasured, and neither announces itself — the file
|
|
57
|
+
* was green at major 1 the whole time.
|
|
35
58
|
*
|
|
36
59
|
* @see RFCS/0057-memory-write-attribution-event.md §D
|
|
60
|
+
* @see spec/v2/core/replay.md §Determinism caveats
|
|
37
61
|
*/
|
|
38
62
|
|
|
39
63
|
import { describe, it, expect } from 'vitest';
|
|
40
64
|
import { softSkip } from '../lib/soft-skip.js';
|
|
41
65
|
import { driver } from '../lib/driver.js';
|
|
42
66
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
43
|
-
import { readMemoryAttributionCap, emitsWriteEvents, seedRun, memoryWrittenEvents } from '../lib/memoryAttribution.js';
|
|
67
|
+
import { readMemoryAttributionCap, emitsWriteEvents, seedRun, memoryWrittenEvents, runsPath } from '../lib/memoryAttribution.js';
|
|
44
68
|
import { req } from '../lib/requirement-ids.js';
|
|
45
69
|
|
|
46
70
|
function memoryIdOf(payload: Record<string, unknown> | undefined): string | null {
|
|
@@ -51,7 +75,7 @@ function memoryIdOf(payload: Record<string, unknown> | undefined): string | null
|
|
|
51
75
|
describe('memory-attribution-replay-stable (RFC 0057 §D)', () => {
|
|
52
76
|
it('a replay-mode fork introduces no memory.written with a new memoryId', async () => {
|
|
53
77
|
const cap = await readMemoryAttributionCap();
|
|
54
|
-
if (!emitsWriteEvents(cap)) return softSkip('inapplicable', '
|
|
78
|
+
if (!emitsWriteEvents(cap)) return softSkip('inapplicable', 'memory.attribution.emitsWriteEvents not advertised — at major 2 the gate is recorded under openwop.family.memory, so an unadvertised family and an unreadable one are distinguishable in the bundle');
|
|
55
79
|
const runId = await seedRun('mem-attr-replay');
|
|
56
80
|
if (!runId) return softSkip('blocked', 'precondition not met — `!runId` returned early (seam, prior step, or fixture unavailable)');
|
|
57
81
|
try {
|
|
@@ -63,7 +87,7 @@ describe('memory-attribution-replay-stable (RFC 0057 §D)', () => {
|
|
|
63
87
|
if (original.length === 0) return softSkip('blocked', 'run wrote no memory — nothing to test');
|
|
64
88
|
const recordedIds = new Set(original.map((e) => memoryIdOf(e.payload)).filter((x): x is string => x !== null));
|
|
65
89
|
|
|
66
|
-
const fork = await driver.post(
|
|
90
|
+
const fork = await driver.post(`${runsPath()}/${encodeURIComponent(runId)}:fork`, { fromSeq: 0, mode: 'replay' });
|
|
67
91
|
if (fork.status !== 200 && fork.status !== 201) return softSkip('inapplicable', 'replay fork unsupported — soft-skip (fork.status !== 200 && fork.status !== 201)');
|
|
68
92
|
const forkId = (fork.json as { runId?: string } | undefined)?.runId;
|
|
69
93
|
if (!forkId) return softSkip('blocked', 'precondition not met — `!forkId` returned early (seam, prior step, or fixture unavailable)');
|
|
@@ -52,13 +52,57 @@
|
|
|
52
52
|
* file's worth: the boundary it checks is real and a tier-1 host shipped the
|
|
53
53
|
* bug it catches.
|
|
54
54
|
*
|
|
55
|
+
* The payload-equivalence leg below does NOT move that verdict. It strengthens
|
|
56
|
+
* the same single `it` behind the same fixture gate, so the file still cannot
|
|
57
|
+
* witness for a predicate holder that advertises no optional fixture. Promotion
|
|
58
|
+
* stays blocked on a prior condition anyway: the strengthened leg has not been
|
|
59
|
+
* MEASURED on the reference host, and a floor is not a place to find out.
|
|
60
|
+
*
|
|
55
61
|
* What this file deliberately does NOT assert: the CONTENT of the re-executed
|
|
56
62
|
* tail, determinism across two forks, timing, and effect re-fire suppression.
|
|
57
63
|
* Those are `replay.md` §Replay determinism and the seams floor's, witnessed in
|
|
58
64
|
* `replay-fork-arbitrary` (major 1) and `v2-effect-seam-no-refire` (seams).
|
|
59
65
|
*
|
|
66
|
+
* PAYLOAD EQUIVALENCE, and the honest size of what it witnesses.
|
|
67
|
+
* `replay.md` §Byte-equivalence of the prefix opens: "The events at indices
|
|
68
|
+
* `[0, fromSeq)` MUST be byte-equivalent between source and replay, modulo
|
|
69
|
+
* per-region clock fields (RFC 0036 §E) and ULID time-component entropy when
|
|
70
|
+
* ULIDs are minted fresh." Until this revision the only witness of that MUST
|
|
71
|
+
* compared `${sequence}:${type}` — sequence-and-TYPE equality. A host could
|
|
72
|
+
* inherit a prefix whose event PAYLOADS differed from the source, in every
|
|
73
|
+
* field, and pass. "Byte-equivalent" was checked by comparing two of an event's
|
|
74
|
+
* ten fields, and the gap was invisible because the leg was green.
|
|
75
|
+
*
|
|
76
|
+
* The exclusion set is NOT invented here. `runs.md` §Diff and ancestry already
|
|
77
|
+
* states, normatively, what two logs are compared on: "`eventId`, `runId`,
|
|
78
|
+
* `timestamp` and other run-scoped fields MUST be excluded from comparison."
|
|
79
|
+
* That is the spec's own definition of the same event on two runs, and a fork
|
|
80
|
+
* IS a new run with its own `runId` and its own `eventId`s. So:
|
|
81
|
+
*
|
|
82
|
+
* compared `type`, `nodeId`, `payload` (deep)
|
|
83
|
+
* excluded `eventId` run-scoped (runs.md §Diff) + ULID entropy (§36)
|
|
84
|
+
* `runId` the fork is a different run, by construction
|
|
85
|
+
* `timestamp` a per-region clock field (RFC 0036 §E, named in §36)
|
|
86
|
+
* `causationId` names an `eventId` of this run, so run-scoped
|
|
87
|
+
*
|
|
88
|
+
* NAMED RESIDUE, not silently dropped. `schemaVersion` and `engineVersion` are
|
|
89
|
+
* not asserted: §36's carve-out list does not excuse them, but neither does any
|
|
90
|
+
* clause say whether a host re-stamps them when copying fixed history into a
|
|
91
|
+
* replay running against CURRENT code, and a scenario should not settle a
|
|
92
|
+
* question the prose left open. `replay.md` line 37 — "`variables`, `channels`,
|
|
93
|
+
* and `status` of the run snapshot at each index in that range MUST be
|
|
94
|
+
* byte-equivalent" — has no witness at any major and gets none here: there is
|
|
95
|
+
* no wire surface that returns a run snapshot AS OF an index, only at head, so
|
|
96
|
+
* an unaided scenario cannot reach it. Both are gaps in the evidence, recorded
|
|
97
|
+
* as gaps. This leg witnesses a NAMED SUBSET of §36 and says which.
|
|
98
|
+
*
|
|
99
|
+
* The leg runs LAST, after the boundary assertion, so a host that fails this
|
|
100
|
+
* stricter new check still reports the boundary verdict the file was cut for.
|
|
101
|
+
*
|
|
60
102
|
* @see spec/v2/core/replay.md §Endpoint
|
|
103
|
+
* @see spec/v2/core/replay.md §Byte-equivalence of the prefix
|
|
61
104
|
* @see spec/v2/core/runs.md §Fork
|
|
105
|
+
* @see spec/v2/core/runs.md §Diff and ancestry
|
|
62
106
|
*/
|
|
63
107
|
|
|
64
108
|
import { describe, it, expect } from 'vitest';
|
|
@@ -71,10 +115,32 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
71
115
|
|
|
72
116
|
const ID = 'openwop.requirement.0170.fork-prefix-boundary';
|
|
73
117
|
const DOC = 'spec/v2/core/replay.md §Endpoint';
|
|
118
|
+
const BYTES = 'spec/v2/core/replay.md §Byte-equivalence of the prefix';
|
|
74
119
|
const MULTI = 'conformance-multi-node';
|
|
75
120
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
76
121
|
|
|
77
|
-
interface Ev { readonly sequence?: unknown; readonly type?: unknown }
|
|
122
|
+
interface Ev { readonly sequence?: unknown; readonly type?: unknown; readonly nodeId?: unknown; readonly payload?: unknown }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Stable JSON for deep comparison: object keys sorted at every depth, so two
|
|
126
|
+
* payloads differing only in serialization order compare equal. Array order is
|
|
127
|
+
* preserved — it is content.
|
|
128
|
+
*/
|
|
129
|
+
function canonical(v: unknown): string {
|
|
130
|
+
const norm = (x: unknown): unknown => {
|
|
131
|
+
if (Array.isArray(x)) return x.map(norm);
|
|
132
|
+
if (x !== null && typeof x === 'object') {
|
|
133
|
+
const o: Record<string, unknown> = {};
|
|
134
|
+
for (const k of Object.keys(x as Record<string, unknown>).sort()) o[k] = norm((x as Record<string, unknown>)[k]);
|
|
135
|
+
return o;
|
|
136
|
+
}
|
|
137
|
+
return x;
|
|
138
|
+
};
|
|
139
|
+
return JSON.stringify(norm(v)) ?? 'undefined';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The compared projection of an event: everything §36 binds that is not run-scoped. */
|
|
143
|
+
const projection = (e: Ev): string => canonical({ type: e.type ?? null, nodeId: e.nodeId ?? null, payload: e.payload ?? null });
|
|
78
144
|
|
|
79
145
|
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
80
146
|
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
@@ -156,5 +222,23 @@ describe('v2 run-fork-prefix (replay.md §Endpoint — the boundary is exclusive
|
|
|
156
222
|
String(at.type),
|
|
157
223
|
req(ID, DOC, `the event at sequence ${fromSeq} is re-executed, not fixed history: the fork MUST NOT carry the source's ${String(point.type)} at that sequence — seeing it means the prefix was copied inclusively ([0, ${fromSeq}] instead of [0, ${fromSeq}))`),
|
|
158
224
|
).not.toBe(String(point.type));
|
|
225
|
+
|
|
226
|
+
// Byte-equivalence of the prefix, on the named subset (see the docblock).
|
|
227
|
+
// Last, so a host that fails this stricter check still reports the boundary
|
|
228
|
+
// verdict above. The exclusion set is runs.md §Diff and ancestry's, not
|
|
229
|
+
// this file's: eventId, runId, timestamp and causationId are run-scoped,
|
|
230
|
+
// and a fork is a different run.
|
|
231
|
+
const src = source.filter((e) => (e.sequence as number) < fromSeq);
|
|
232
|
+
const differing = inherited
|
|
233
|
+
.map((e, i) => ({ seq: e.sequence as number, fork: projection(e), from: src[i] === undefined ? '<missing>' : projection(src[i]) }))
|
|
234
|
+
.filter((r) => r.fork !== r.from);
|
|
235
|
+
expect(
|
|
236
|
+
differing.map((r) => r.seq),
|
|
237
|
+
req(
|
|
238
|
+
ID,
|
|
239
|
+
BYTES,
|
|
240
|
+
`every event in [0, ${fromSeq}) MUST be byte-equivalent to the source's, modulo per-region clock fields and freshly minted ULIDs — compared on {type, nodeId, payload}, excluding eventId/runId/timestamp/causationId as run-scoped (runs.md §Diff and ancestry). ${differing.length} row(s) differ${differing.length ? `: ${differing.slice(0, 3).map((r) => `seq ${r.seq} fork ${r.fork.slice(0, 160)} vs source ${r.from.slice(0, 160)}`).join(' | ')}` : ''}. Inherited history is copied, not recomputed: a differing payload means the fork re-derived a row the spec calls fixed`,
|
|
241
|
+
),
|
|
242
|
+
).toEqual([]);
|
|
159
243
|
}, 60_000);
|
|
160
244
|
});
|
|
@@ -11,9 +11,21 @@
|
|
|
11
11
|
*
|
|
12
12
|
* An era-2 log carrying one `foo.bar` row is seeded through the event-log seed
|
|
13
13
|
* seam (lib/era2-seed.ts); the read is driven through poll and, where `replay`
|
|
14
|
-
* is advertised, through a fork.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* is advertised, through a fork.
|
|
15
|
+
*
|
|
16
|
+
* THE CONTROL, and why it was missing. Until this revision only the REFUSAL
|
|
17
|
+
* half was driven, and a host that refuses EVERY type the codemap does not name
|
|
18
|
+
* — including a registered vendor type it is required to pass through — went
|
|
19
|
+
* green on the whole file while violating `persistence.md` §The codemap is data.
|
|
20
|
+
* A one-sided rule is not witnessed by its own negative case. The positive half
|
|
21
|
+
* needs an org registered in the `extensions` object of `spec/v2/declaration.json`
|
|
22
|
+
* that no host owns; the registry did not exist, so the suite could not drive
|
|
23
|
+
* it and this docblock recorded the gap instead of closing it. `extensions` now
|
|
24
|
+
* exists and holds `example`, reserved by the protocol and never assignable to
|
|
25
|
+
* a vendor (RFC 2606 precedent), so `example.thing-happened` is a type that is
|
|
26
|
+
* registered, unmapped, and owned by nobody — exactly the control this file
|
|
27
|
+
* lacked. The two legs are each other's non-vacuity check: refusing both is a
|
|
28
|
+
* defect, accepting both is a defect, and only the pair can tell them apart.
|
|
17
29
|
*
|
|
18
30
|
* @see spec/v2/core/persistence.md §The reader rule
|
|
19
31
|
* @see spec/v2/core/events.md §Reading an era-2 log
|
|
@@ -24,11 +36,35 @@ import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
|
24
36
|
import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
|
|
25
37
|
import { softSkip } from '../lib/soft-skip.js';
|
|
26
38
|
import { req } from '../lib/requirement-ids.js';
|
|
27
|
-
import { era2Gate, eventsOf, forkRun, pollEvents, seedEra2Log, type SeedEvent } from '../lib/era2-seed.js';
|
|
39
|
+
import { codemapV1toV2, era2Gate, eventsOf, forkRun, pollEvents, registeredOrgs, seedEra2Log, type SeedEvent } from '../lib/era2-seed.js';
|
|
28
40
|
|
|
29
41
|
const DOC = 'spec/v2/core/persistence.md §The reader rule';
|
|
42
|
+
const MAP = 'spec/v2/core/persistence.md §The codemap is data';
|
|
30
43
|
const CODE = 'event_type_unmapped';
|
|
44
|
+
const ID_VENDOR = 'openwop.requirement.0176.vendor-type-passthrough';
|
|
31
45
|
const UNMAPPED = 'foo.bar';
|
|
46
|
+
/** Registered (`extensions` in the declaration), unmapped, owned by no host. */
|
|
47
|
+
const VENDOR = 'example.thing-happened';
|
|
48
|
+
const orgOf = (type: string): string => type.split('.')[0] ?? '';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The two types this file drives are only meaningful under preconditions the
|
|
52
|
+
* corpus can move: `foo` must stay unregistered, `example` must stay
|
|
53
|
+
* registered, and neither type may acquire a codemap row. Until this revision
|
|
54
|
+
* those were a note inside a seeded payload — prose in a place no runner reads,
|
|
55
|
+
* asserting a fact nothing checked. If `foo` is ever registered, the refusal
|
|
56
|
+
* leg would quietly begin testing the opposite rule and still pass. Checked
|
|
57
|
+
* here so the file fails loudly instead.
|
|
58
|
+
*/
|
|
59
|
+
function preconditions(): { ok: true } | { ok: false; kind: 'blocked' | 'inapplicable'; reason: string } {
|
|
60
|
+
const registered = registeredOrgs();
|
|
61
|
+
if (registered === undefined) return { ok: false, kind: 'inapplicable', reason: 'spec/v2/declaration.json is not resolvable in this layout — the vendor-org registry decides which half of the reader rule each type exercises, and guessing it would make the suite the registry' };
|
|
62
|
+
if (registered.has(orgOf(UNMAPPED))) return { ok: false, kind: 'blocked', reason: `the refusal leg drives ${UNMAPPED}, whose org '${orgOf(UNMAPPED)}' is NOW REGISTERED in spec/v2/declaration.json extensions — it is a vendor type that must pass through, not an unmapped one that must be refused; pick an unregistered org for this leg` };
|
|
63
|
+
if (!registered.has(orgOf(VENDOR))) return { ok: false, kind: 'blocked', reason: `the control leg needs org '${orgOf(VENDOR)}' registered in spec/v2/declaration.json extensions (registered: ${[...registered].join(', ') || 'none'}) — without a registered org the positive half of the vendor rule cannot be driven at all` };
|
|
64
|
+
const map = codemapV1toV2();
|
|
65
|
+
for (const t of [UNMAPPED, VENDOR]) if (map.has(t)) return { ok: false, kind: 'blocked', reason: `${t} now has a codemap row (→ ${String(map.get(t))}) — both legs require a type the codemap does not name` };
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
32
68
|
|
|
33
69
|
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
34
70
|
try { return await v2Discovery(); } catch { return null; }
|
|
@@ -39,7 +75,18 @@ function unmappedLog(): SeedEvent[] {
|
|
|
39
75
|
const ts = (i: number) => new Date(t0 + i * 1000).toISOString();
|
|
40
76
|
return [
|
|
41
77
|
{ type: 'run.started', sequence: 0, payload: { workflowId: 'conformance-noop' }, timestamp: ts(0) },
|
|
42
|
-
{ type: UNMAPPED, sequence: 1, payload: {
|
|
78
|
+
{ type: UNMAPPED, sequence: 1, payload: {}, timestamp: ts(1) },
|
|
79
|
+
{ type: 'run.completed', sequence: 2, payload: { durationMs: 2000 }, timestamp: ts(2) },
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The same shape carrying a REGISTERED vendor type instead of an unmapped one. */
|
|
84
|
+
function vendorLog(): SeedEvent[] {
|
|
85
|
+
const t0 = Date.parse('2026-01-15T12:00:00.000Z');
|
|
86
|
+
const ts = (i: number) => new Date(t0 + i * 1000).toISOString();
|
|
87
|
+
return [
|
|
88
|
+
{ type: 'run.started', sequence: 0, payload: { workflowId: 'conformance-noop' }, timestamp: ts(0) },
|
|
89
|
+
{ type: VENDOR, sequence: 1, payload: { marker: 'era2-vendor-passthrough' }, timestamp: ts(1) },
|
|
43
90
|
{ type: 'run.completed', sequence: 2, payload: { durationMs: 2000 }, timestamp: ts(2) },
|
|
44
91
|
];
|
|
45
92
|
}
|
|
@@ -50,6 +97,8 @@ describe('RFC 0176 §A.3 — unmapped-type-refused (seam-gated)', () => {
|
|
|
50
97
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
51
98
|
const gate = era2Gate(doc);
|
|
52
99
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
100
|
+
const pre = preconditions();
|
|
101
|
+
if (!pre.ok) return softSkip(pre.kind, pre.reason);
|
|
53
102
|
const seeded = await seedEra2Log(unmappedLog(), 'completed');
|
|
54
103
|
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
55
104
|
const res = await pollEvents(seeded.runId);
|
|
@@ -68,12 +117,40 @@ describe('RFC 0176 §A.3 — unmapped-type-refused (seam-gated)', () => {
|
|
|
68
117
|
).toBe(false);
|
|
69
118
|
});
|
|
70
119
|
|
|
120
|
+
it('a REGISTERED vendor type the codemap does not name is read under its own name, unchanged', async () => {
|
|
121
|
+
const doc = await discovery();
|
|
122
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
123
|
+
const gate = era2Gate(doc);
|
|
124
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
125
|
+
const pre = preconditions();
|
|
126
|
+
if (!pre.ok) return softSkip(pre.kind, pre.reason);
|
|
127
|
+
const seeded = await seedEra2Log(vendorLog(), 'completed');
|
|
128
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
129
|
+
const res = await pollEvents(seeded.runId);
|
|
130
|
+
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll unreachable (fetch failed)');
|
|
131
|
+
// The whole point of the control: this is the SAME log shape as the refusal
|
|
132
|
+
// leg with one segment changed, so a 500 here says the host is refusing on
|
|
133
|
+
// "not in the codemap" and not on "not a registered org" — the tolerant
|
|
134
|
+
// reader's mirror image, and just as wrong.
|
|
135
|
+
expect(
|
|
136
|
+
res.status,
|
|
137
|
+
req(ID_VENDOR, MAP, `a vendor-prefixed type whose org IS registered MUST be read under its own name unchanged, not refused — got ${res.status}${res.status === 500 ? ` ${readErrorCode(res.json) ?? ''}: the host refuses every type the codemap does not name, which fails the registered half of the rule` : ''}`.trim()),
|
|
138
|
+
).toBe(200);
|
|
139
|
+
const types = eventsOf(res.json).map((e) => String(e.type));
|
|
140
|
+
expect(
|
|
141
|
+
types.includes(VENDOR),
|
|
142
|
+
req(ID_VENDOR, MAP, `the ${VENDOR} row MUST appear under its own name — read back [${types.join(', ')}]; a translated or dropped row is a private mapping, and the codemap is the only authority`),
|
|
143
|
+
).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
71
146
|
it('a fork of the same log is refused for the same reason — the rule binds every reader', async () => {
|
|
72
147
|
const doc = await discovery();
|
|
73
148
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
74
149
|
const gate = era2Gate(doc);
|
|
75
150
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
76
151
|
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — the fork reader has no surface');
|
|
152
|
+
const pre = preconditions();
|
|
153
|
+
if (!pre.ok) return softSkip(pre.kind, pre.reason);
|
|
77
154
|
const log = unmappedLog();
|
|
78
155
|
const seeded = await seedEra2Log(log, 'completed');
|
|
79
156
|
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|