@openwop/openwop-conformance 2.0.5 → 2.0.7
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 +98 -0
- package/dist/lib/paths.js +4 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +3 -3
- package/schemas/CORPUS-STAMP.json +11 -11
- package/src/lib/era2-seed.ts +70 -3
- package/src/lib/paths.ts +19 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +19 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.7] — 2026-09-06 — three claims of coverage that were not coverage
|
|
4
|
+
|
|
5
|
+
No host behaviour changes. One gate changes disposition, and the two prose
|
|
6
|
+
corrections remove claims the corpus was making about its own evidence.
|
|
7
|
+
|
|
8
|
+
**`vendorControlGate` answers `blocked`, not `inapplicable`, when
|
|
9
|
+
`spec/v2/declaration.json` cannot be resolved.** 2.0.6 — the release that fixed
|
|
10
|
+
this scenario — got the disposition wrong on the branch it had just repaired.
|
|
11
|
+
`inapplicable` asserts *the requirement does not bind this host*, which is a
|
|
12
|
+
statement about the host made on the strength of a fact about the suite, and a
|
|
13
|
+
false one: the rule binds exactly as before and the suite merely failed to read
|
|
14
|
+
its own corpus. It is also the quiet answer. `inapplicable` certifies;
|
|
15
|
+
`blocked` is bundle-wide fatal (RFC 0168 §E.1). The disposition that was wrong
|
|
16
|
+
was the one that made no sound.
|
|
17
|
+
|
|
18
|
+
As of 2.0.6 that branch is unreachable — the registry resolves in every layout
|
|
19
|
+
and the publish workflow asserts it. That is the argument *for* making it
|
|
20
|
+
fatal. An unreachable branch answering `inapplicable` is a trapdoor back to the
|
|
21
|
+
D1 resolution defect, which was invisible precisely because it degraded a live
|
|
22
|
+
witness into a quiet skip.
|
|
23
|
+
|
|
24
|
+
**The general rule is now written down** (`spec/v2/core/conformance.md`
|
|
25
|
+
§"Whose fact is the reason?"): a soft-skip reason MUST name a fact about the
|
|
26
|
+
host under test, and where the predicate is instead a fact about the suite —
|
|
27
|
+
its layout, its corpus, a fixture it cannot resolve — the row MUST record
|
|
28
|
+
`blocked`. Gate ordering follows (host facts before suite facts) but is not the
|
|
29
|
+
guarantee; ordering only decides which *true* reason is reported. The guarantee
|
|
30
|
+
is that a suite-side gate can never be silent, because it is never
|
|
31
|
+
`inapplicable`.
|
|
32
|
+
|
|
33
|
+
Credit where it is due: this came from a host operator who predicted a third
|
|
34
|
+
failure mode I had not considered — a row already `inapplicable` for a true host
|
|
35
|
+
reason, re-gated onto a suite-side precondition, stays `inapplicable`. `skip →
|
|
36
|
+
skip`, no count moves, no gate reddens, and the row silently stops describing
|
|
37
|
+
the host it names. Measured against the tree, their case does not bite this
|
|
38
|
+
scenario (the seams gate returns first, at `:96`, above the precondition at
|
|
39
|
+
`:97`), but the rule they proposed was right and the ordering only held by
|
|
40
|
+
construction — nothing written down stopped the next scenario from getting it
|
|
41
|
+
wrong.
|
|
42
|
+
|
|
43
|
+
`era2-unmapped-gates.test.ts` gains a row asserting that **no** reachable
|
|
44
|
+
verdict in either gate is `inapplicable`, so a future suite-side gate that
|
|
45
|
+
soft-skips quietly reddens a test instead of a bundle. Sabotage-verified:
|
|
46
|
+
restoring 2.0.6's disposition reddens exactly those two rows and no others.
|
|
47
|
+
|
|
48
|
+
### Corpus prose
|
|
49
|
+
|
|
50
|
+
- **`persistence.md` §"The seat"** claimed `v2-v1-events-translated` reading
|
|
51
|
+
through poll, SSE and a fork meant "a wrapper-only adapter is caught". It does
|
|
52
|
+
not. Three wrappers pass those three legs exactly as one correctly seated
|
|
53
|
+
adapter does, and the rule binds *every* reader, including ones the suite has
|
|
54
|
+
no name for. The seat is a **claims-check** discharged by ADR disclosure and
|
|
55
|
+
audit; the scenario catches a reader that was *missed*, not an adapter that
|
|
56
|
+
was *misplaced*. The clause also cited a scenario by the wrong name and
|
|
57
|
+
pointed at `conformance.md`, which said nothing about any of it. Both MUSTs
|
|
58
|
+
are unchanged — only the false coverage claim is gone.
|
|
59
|
+
- **RFC 0180** supplies the vendor-org registration procedure the registry
|
|
60
|
+
never had, and `persistence.md` §"The codemap is data" now points at it.
|
|
61
|
+
|
|
62
|
+
## [2.0.6] — 2026-09-06 — the release that made a rule uncheckable
|
|
63
|
+
|
|
64
|
+
**If you pinned 2.0.5, `v2-unmapped-type-refused` did not run against your
|
|
65
|
+
host.** It soft-skipped `inapplicable` and the lane went green. Pin 2.0.6 and
|
|
66
|
+
re-measure before trusting any result that scenario gave you.
|
|
67
|
+
|
|
68
|
+
2.0.5 shipped four fixes for rules whose instruments could not answer, and one
|
|
69
|
+
of those fixes did the same thing to a fifth. Reported by a host operator who
|
|
70
|
+
kept a local witness for a defect they knew was unfixed and re-measured before
|
|
71
|
+
accepting a green they wanted. Two defects, one symptom:
|
|
72
|
+
|
|
73
|
+
- **The corpus resolver was anchored on a repo-only directory.**
|
|
74
|
+
`registeredOrgs()` and `codemapV1toV2()` located `spec/v2/` via
|
|
75
|
+
`V1_DIR/../v2` — a **v1**-anchored path to a **v2** file. No published package
|
|
76
|
+
ships `spec/v1/`, so `V1_DIR` is `null` in every install and both lookups
|
|
77
|
+
returned nothing. The data was never missing; it is in the exact-pinned
|
|
78
|
+
`@openwop/spec-artifacts` peer. Both now resolve through `SPEC_V2_DIR`
|
|
79
|
+
(`lib/paths`), anchored on the contract root, which holds in a repo checkout
|
|
80
|
+
and an install alike. The codemap failed more quietly — 7 fallback rows
|
|
81
|
+
instead of 118, so era-2 readers asserted against names it never had.
|
|
82
|
+
|
|
83
|
+
- **A precondition written for one leg gated the other.**
|
|
84
|
+
`v2-unmapped-type-refused` drives two opposite halves of the reader rule.
|
|
85
|
+
Both were gated on a single check that demanded a resolvable registry, which
|
|
86
|
+
only the *control* leg needs. With the registry unreachable, the *refusal*
|
|
87
|
+
leg skipped on exactly the hosts it exists to catch: one answering `200` and
|
|
88
|
+
one answering `500` were both green. Gates are now separate and pure
|
|
89
|
+
(`unmappedRefusalGate` / `vendorControlGate`), with the fail-closed reading
|
|
90
|
+
explicit — an unreadable registry registers nothing, so the refusal is still
|
|
91
|
+
required and the leg stays drivable.
|
|
92
|
+
|
|
93
|
+
Added `src/lib/era2-unmapped-gates.test.ts` (the gates' truth table, including
|
|
94
|
+
the rows a live-host scenario cannot check about itself; sabotage-verified) and
|
|
95
|
+
a post-install corpus-resolution assertion in `verify-installable` — a clean
|
|
96
|
+
`npm install` was never evidence the suite works.
|
|
97
|
+
|
|
98
|
+
**A ratchet's STALE signal means the scenario stopped failing. It is not
|
|
99
|
+
evidence the defect is fixed.**
|
|
100
|
+
|
|
3
101
|
## [2.0.5] — 2026-09-06 — four rules with no way to be checked
|
|
4
102
|
|
|
5
103
|
Four instruments that could not, even in principle, return the answer they
|
package/dist/lib/paths.js
CHANGED
|
@@ -103,6 +103,8 @@ function resolveFromRoot(root, layout, contractRoot = root) {
|
|
|
103
103
|
: null;
|
|
104
104
|
const v1Probe = join(root, 'spec', 'v1');
|
|
105
105
|
const v1Dir = existsSync(v1Probe) ? v1Probe : null;
|
|
106
|
+
const specV2Probe = join(contractRoot, 'spec', 'v2');
|
|
107
|
+
const specV2Dir = existsSync(specV2Probe) ? specV2Probe : null;
|
|
106
108
|
const readmeProbe = join(root, 'README.md');
|
|
107
109
|
const readmePath = existsSync(readmeProbe) ? readmeProbe : null;
|
|
108
110
|
const typescriptRunHelpersProbe = join(root, 'sdk', 'typescript', 'src', 'run-helpers.ts');
|
|
@@ -121,6 +123,7 @@ function resolveFromRoot(root, layout, contractRoot = root) {
|
|
|
121
123
|
fixturesDocPath,
|
|
122
124
|
coverageDocPath,
|
|
123
125
|
v1Dir,
|
|
126
|
+
specV2Dir,
|
|
124
127
|
readmePath,
|
|
125
128
|
typescriptRunHelpersPath,
|
|
126
129
|
pythonTypesPath,
|
|
@@ -171,6 +174,7 @@ export const CONFORMANCE_README_PATH = _layout.conformanceReadmePath;
|
|
|
171
174
|
export const FIXTURES_DOC_PATH = _layout.fixturesDocPath;
|
|
172
175
|
export const COVERAGE_DOC_PATH = _layout.coverageDocPath;
|
|
173
176
|
export const V1_DIR = _layout.v1Dir;
|
|
177
|
+
export const SPEC_V2_DIR = _layout.specV2Dir;
|
|
174
178
|
export const README_PATH = _layout.readmePath;
|
|
175
179
|
export const TYPESCRIPT_RUN_HELPERS_PATH = _layout.typescriptRunHelpersPath;
|
|
176
180
|
export const PYTHON_TYPES_PATH = _layout.pythonTypesPath;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.7"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -28687,7 +28687,7 @@
|
|
|
28687
28687
|
{
|
|
28688
28688
|
"id": "openwop.it.v2-unmapped-type-refused.poll-over-a-log-with-an-unmapped-unprefixed-type-fails-with-500-event-type-unmap",
|
|
28689
28689
|
"file": "v2-unmapped-type-refused.test.ts",
|
|
28690
|
-
"line":
|
|
28690
|
+
"line": 92,
|
|
28691
28691
|
"title": "poll over a log with an unmapped, unprefixed type fails with 500 event_type_unmapped",
|
|
28692
28692
|
"explicitId": "openwop.requirement.0176.unmapped-type-refused",
|
|
28693
28693
|
"citations": [
|
|
@@ -28711,7 +28711,7 @@
|
|
|
28711
28711
|
{
|
|
28712
28712
|
"id": "openwop.it.v2-unmapped-type-refused.a-registered-vendor-type-the-codemap-does-not-name-is-read-under-its-own-name-un",
|
|
28713
28713
|
"file": "v2-unmapped-type-refused.test.ts",
|
|
28714
|
-
"line":
|
|
28714
|
+
"line": 117,
|
|
28715
28715
|
"title": "a REGISTERED vendor type the codemap does not name is read under its own name, unchanged",
|
|
28716
28716
|
"explicitId": "openwop.requirement.0176.vendor-type-passthrough",
|
|
28717
28717
|
"citations": [
|
|
@@ -28730,7 +28730,7 @@
|
|
|
28730
28730
|
{
|
|
28731
28731
|
"id": "openwop.it.v2-unmapped-type-refused.a-fork-of-the-same-log-is-refused-for-the-same-reason-the-rule-binds-every-reade",
|
|
28732
28732
|
"file": "v2-unmapped-type-refused.test.ts",
|
|
28733
|
-
"line":
|
|
28733
|
+
"line": 143,
|
|
28734
28734
|
"title": "a fork of the same log is refused for the same reason — the rule binds every reader",
|
|
28735
28735
|
"explicitId": "openwop.requirement.0176.unmapped-type-refused.fork",
|
|
28736
28736
|
"citations": [
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.0.
|
|
5
|
-
"corpusTag": "v2.0.
|
|
4
|
+
"version": "2.0.7",
|
|
5
|
+
"corpusTag": "v2.0.7",
|
|
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": "0d18b9414567efb5662f0a8bc06725d0e038aab14f327db7a1b8ebe3b9f4cbb9",
|
|
13
|
+
"api/v2/asyncapi.yaml": "25196e36b92b0dab97904b1f3461826d2066e579963c26c7e6168d2e5688bdfe",
|
|
14
|
+
"api/v2/openapi.yaml": "e48e5669918ddda197a17508eca24ea42e834a62b309091c87d063cb52b1b2cb",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "0484560c0bb72e5da9fa393b0723961e1cb7837eaf90c035ff37b7e7fdad6476",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
214
214
|
"spec/v2/README.md": "f56bd8d58be4ac04543c9f7b2fe92b294738d6121a4f21cbd1ce751e72b94c83",
|
|
215
215
|
"spec/v2/core/capabilities.md": "0f222434bbe58c881362a626ab9db6bce0ae2e54cb858e3d71b662dd1fc13150",
|
|
216
|
-
"spec/v2/core/conformance.md": "
|
|
216
|
+
"spec/v2/core/conformance.md": "6dda58339117819b77710cd53d25f34cc9e602a35a7958d1afc4e3d4b6b7de23",
|
|
217
217
|
"spec/v2/core/connection-packs.md": "34e519dbe5b091c9758729787d398b35b1dc41f1e01ebd028e7e6b838ce2f6ee",
|
|
218
218
|
"spec/v2/core/errors.md": "671895de1b7c987a1dfd53fe0a814159efadae0ffd4c8c06bf1749d345270580",
|
|
219
219
|
"spec/v2/core/events.md": "7b9207079cede7ec335126b98bcb95d655ae3b7322b18febd1b4df51750b0a33",
|
|
@@ -225,14 +225,14 @@
|
|
|
225
225
|
"spec/v2/core/interrupt.md": "4bd345bccdb8e2762ca95833cc67086158640c27ffa5ef84915a4ab3de348440",
|
|
226
226
|
"spec/v2/core/overview.md": "8ece6667535d24ed4013a046cb11e1a649f70e39625fc55e09ceb2c02505ed9c",
|
|
227
227
|
"spec/v2/core/packs.md": "90f32947816b4ec2b045b063ad2914126c73353883514a270ad200205dc8e51b",
|
|
228
|
-
"spec/v2/core/persistence.md": "
|
|
228
|
+
"spec/v2/core/persistence.md": "e9830ae228bcbc33f03d81a5f2b51e031466feb84387971dd3459bf5e1687701",
|
|
229
229
|
"spec/v2/core/replay.md": "19b2fcbda4693895d39b99593f498b76376d77472a813bba6c22fca02e106332",
|
|
230
230
|
"spec/v2/core/runs.md": "f5f24df6da76b1d6e2cf655af3bbe3340195c36ec8d9ce0ca6e357385b1a8771",
|
|
231
231
|
"spec/v2/core/security-defaults.md": "53a1bc549aaa356a4fcb56cdb1c4f5880d3fb65d5d49dd570a413bb29aff82e5",
|
|
232
232
|
"spec/v2/core/versioning.md": "2eb4e8e5a1208a026a3d497d1e0fb76a4f7cb97e514d6f41f45dd66b89405abb",
|
|
233
233
|
"spec/v2/core/webhooks.md": "1d2719335f8fb46cd31dbb836b44ea7d78ed54f98ea181927502814603c5b935",
|
|
234
234
|
"spec/v2/core/workflow-chain-packs.md": "99ad4aeeeeb05521720dc2ae18ae63e6d604361780ed1b76778ca6d44da70eed",
|
|
235
|
-
"spec/v2/declaration.json": "
|
|
235
|
+
"spec/v2/declaration.json": "bd8a1dcee899a4478ac96b52da6ee07e1cb5fce4465939ac32e19e52f4d5c6b2",
|
|
236
236
|
"spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
|
|
237
237
|
"spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
|
|
238
238
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
@@ -268,8 +268,8 @@
|
|
|
268
268
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
269
269
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
270
270
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
271
|
-
"spec/v2/release.json": "
|
|
271
|
+
"spec/v2/release.json": "2937de1d170c218e4d336088035c2a4fe6b7e951b6c6bd8bd375f0580ae07a53",
|
|
272
272
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
273
273
|
},
|
|
274
|
-
"corpusCommit": "
|
|
274
|
+
"corpusCommit": "8f8818dd663240bdd1ea074a25fd9354d0fde2c3"
|
|
275
275
|
}
|
package/src/lib/era2-seed.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { driver, type OpenWOPResponse } from './driver.js';
|
|
|
35
35
|
import { loadEnv } from './env.js';
|
|
36
36
|
import { seamPath, seamsProfileAdvertised } from './seams.js';
|
|
37
37
|
import { readErrorCode } from './error-envelope.js';
|
|
38
|
-
import {
|
|
38
|
+
import { SPEC_V2_DIR } from './paths.js';
|
|
39
39
|
|
|
40
40
|
export const SEED_PATH_V1 = '/v1/host/sample/event-log/seed';
|
|
41
41
|
export const SEED_PATH = seamPath(SEED_PATH_V1);
|
|
@@ -171,7 +171,12 @@ let codemap: Map<string, string> | undefined;
|
|
|
171
171
|
export function codemapV1toV2(): Map<string, string> {
|
|
172
172
|
if (codemap) return codemap;
|
|
173
173
|
const m = new Map<string, string>(FALLBACK);
|
|
174
|
-
|
|
174
|
+
// Same wrong anchor as `registeredOrgs` carried, found while fixing that one:
|
|
175
|
+
// v1-anchored, so null on every published layout. It fails less loudly here
|
|
176
|
+
// because FALLBACK stands — but 7 rows are not 117, and a scenario asserting
|
|
177
|
+
// `got.type === map.get(s.type)` for a type outside the fallback was checking
|
|
178
|
+
// against a name the truncated map never had.
|
|
179
|
+
const candidate = SPEC_V2_DIR ? join(SPEC_V2_DIR, 'event-codemap.json') : null;
|
|
175
180
|
if (candidate && existsSync(candidate)) {
|
|
176
181
|
try {
|
|
177
182
|
const doc = JSON.parse(readFileSync(candidate, 'utf8')) as { rows?: Array<{ v1?: unknown; v2?: unknown }> };
|
|
@@ -198,7 +203,12 @@ export function codemapV1toV2(): Map<string, string> {
|
|
|
198
203
|
let orgs: ReadonlySet<string> | null | undefined;
|
|
199
204
|
export function registeredOrgs(): ReadonlySet<string> | undefined {
|
|
200
205
|
if (orgs !== undefined) return orgs ?? undefined;
|
|
201
|
-
|
|
206
|
+
// SPEC_V2_DIR, not `V1_DIR/../v2`. The v1-anchored form shipped in 2.0.5 and
|
|
207
|
+
// resolved to null for every consumer of the published package — `spec/v1/`
|
|
208
|
+
// exists only in a repo checkout, so a v2 lookup routed through a v1 probe
|
|
209
|
+
// lost the registry the moment anyone installed the suite. The declaration is
|
|
210
|
+
// reachable in both layouts; only the anchor was wrong.
|
|
211
|
+
const candidate = SPEC_V2_DIR ? join(SPEC_V2_DIR, 'declaration.json') : null;
|
|
202
212
|
orgs = null;
|
|
203
213
|
if (candidate && existsSync(candidate)) {
|
|
204
214
|
try {
|
|
@@ -209,6 +219,63 @@ export function registeredOrgs(): ReadonlySet<string> | undefined {
|
|
|
209
219
|
return orgs ?? undefined;
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
export type Gate = { ok: true } | { ok: false; kind: 'blocked' | 'inapplicable'; reason: string };
|
|
223
|
+
|
|
224
|
+
const orgOf = (type: string): string => type.split('.')[0] ?? '';
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Preconditions for the REFUSAL half of the reader rule (`v2-unmapped-type-
|
|
228
|
+
* refused`): a type the codemap does not name, whose org is not registered,
|
|
229
|
+
* MUST fail the read.
|
|
230
|
+
*
|
|
231
|
+
* `registered === undefined` (no resolvable registry) is NOT a blocker here.
|
|
232
|
+
* An unreadable registry registers nothing, so the driven org is unregistered
|
|
233
|
+
* either way and the refusal is still the required outcome — the fail-CLOSED
|
|
234
|
+
* reading. Only a registry that resolves AND names the org invalidates the leg.
|
|
235
|
+
*
|
|
236
|
+
* Suite 2.0.5 got this wrong by sharing one precondition with the control leg,
|
|
237
|
+
* which does need the registry. On published layouts the registry did not
|
|
238
|
+
* resolve, so the refusal leg soft-skipped `inapplicable` on exactly the hosts
|
|
239
|
+
* it exists to catch: one still answering `200` to an unmapped type and one
|
|
240
|
+
* correctly answering `500` were both green. A precondition belongs to the leg
|
|
241
|
+
* that needs it, never to the file.
|
|
242
|
+
*/
|
|
243
|
+
export function unmappedRefusalGate(registered: ReadonlySet<string> | undefined, map: ReadonlyMap<string, string>, type: string): Gate {
|
|
244
|
+
if (registered?.has(orgOf(type)) === true) {
|
|
245
|
+
return { ok: false, kind: 'blocked', reason: `the refusal leg drives ${type}, whose org '${orgOf(type)}' 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` };
|
|
246
|
+
}
|
|
247
|
+
if (map.has(type)) return { ok: false, kind: 'blocked', reason: `${type} now has a codemap row (→ ${String(map.get(type))}) — the refusal leg requires a type the codemap does not name` };
|
|
248
|
+
return { ok: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Preconditions for the CONTROL half: a registered vendor org the codemap does
|
|
253
|
+
* not name MUST be read under its own name unchanged.
|
|
254
|
+
*
|
|
255
|
+
* This leg genuinely needs the registry to resolve — it asserts that an org IS
|
|
256
|
+
* registered, and an absent registry cannot establish that.
|
|
257
|
+
*
|
|
258
|
+
* AN UNRESOLVABLE REGISTRY IS `blocked`, NOT `inapplicable` (conformance.md
|
|
259
|
+
* §Whose fact is the reason?). Suite 2.0.6 said `inapplicable`, which asserts
|
|
260
|
+
* the requirement does not bind this host — a statement about the HOST made on
|
|
261
|
+
* the strength of a fact about the SUITE, and a false one: the rule binds
|
|
262
|
+
* exactly as before and the suite merely failed to read its own corpus.
|
|
263
|
+
* `inapplicable` also certifies, while `blocked` is bundle-wide fatal
|
|
264
|
+
* (RFC 0168 §E.1), so the wrong disposition is the silent one.
|
|
265
|
+
*
|
|
266
|
+
* As of 2.0.6 this branch is unreachable — the registry resolves in every
|
|
267
|
+
* layout and the publish workflow asserts it. That is the argument FOR making
|
|
268
|
+
* it fatal, not against: an unreachable branch answering `inapplicable` is a
|
|
269
|
+
* trapdoor back to the D1 resolution defect, which was invisible precisely
|
|
270
|
+
* because it degraded a live witness into a quiet skip.
|
|
271
|
+
*/
|
|
272
|
+
export function vendorControlGate(registered: ReadonlySet<string> | undefined, map: ReadonlyMap<string, string>, type: string): Gate {
|
|
273
|
+
if (registered === undefined) return { ok: false, kind: 'blocked', reason: 'spec/v2/declaration.json is not resolvable in this layout — the control leg asserts that a REGISTERED org passes through, and guessing which orgs are registered would make the suite the registry. This is a fact about the SUITE, so it is blocked and not inapplicable (conformance.md §Whose fact is the reason?)' };
|
|
274
|
+
if (!registered.has(orgOf(type))) return { ok: false, kind: 'blocked', reason: `the control leg needs org '${orgOf(type)}' 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` };
|
|
275
|
+
if (map.has(type)) return { ok: false, kind: 'blocked', reason: `${type} now has a codemap row (→ ${String(map.get(type))}) — the control leg requires a type the codemap does not name` };
|
|
276
|
+
return { ok: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
212
279
|
/** A minimal era-2 log in v1 vocabulary: two renamed rows between run.started and run.completed. */
|
|
213
280
|
export function v1FixtureLog(workflowId = 'conformance-noop'): SeedEvent[] {
|
|
214
281
|
const t0 = Date.parse('2026-01-15T10:00:00.000Z');
|
package/src/lib/paths.ts
CHANGED
|
@@ -66,6 +66,21 @@ interface ResolvedLayout {
|
|
|
66
66
|
readonly coverageDocPath: string | null;
|
|
67
67
|
/** Directory containing v1 prose docs (`*.md`), if present in this layout. */
|
|
68
68
|
readonly v1Dir: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Directory containing the v2 corpus data (`declaration.json`, the codemap,
|
|
71
|
+
* `spec/v2/core/*.md`), if present in this layout.
|
|
72
|
+
*
|
|
73
|
+
* Anchored on the CONTRACT root, not the layout root, because that is the
|
|
74
|
+
* only anchor that holds in both shapes: in a repo checkout the contract
|
|
75
|
+
* root is the repo and `spec/v2/` sits inside it; in a published install it
|
|
76
|
+
* is the `@openwop/spec-artifacts` peer, which ships `spec/` while the
|
|
77
|
+
* conformance package ships none. A resolver anchored on `v1Dir` instead
|
|
78
|
+
* (the shape before 2.0.6) returned null for every consumer of the published
|
|
79
|
+
* package, because `spec/v1/` is a repo-only directory — so a v2 lookup was
|
|
80
|
+
* routed through a v1 probe and every published layout silently lost the
|
|
81
|
+
* data. See `era2-seed.registeredOrgs`.
|
|
82
|
+
*/
|
|
83
|
+
readonly specV2Dir: string | null;
|
|
69
84
|
/** Path to repository README.md, if present in this layout. */
|
|
70
85
|
readonly readmePath: string | null;
|
|
71
86
|
/** Path to the TypeScript SDK run-helper source, if present in this layout. */
|
|
@@ -137,6 +152,8 @@ function resolveFromRoot(root: string, layout: ResolvedLayout['layout'], contrac
|
|
|
137
152
|
: null;
|
|
138
153
|
const v1Probe = join(root, 'spec', 'v1');
|
|
139
154
|
const v1Dir = existsSync(v1Probe) ? v1Probe : null;
|
|
155
|
+
const specV2Probe = join(contractRoot, 'spec', 'v2');
|
|
156
|
+
const specV2Dir = existsSync(specV2Probe) ? specV2Probe : null;
|
|
140
157
|
const readmeProbe = join(root, 'README.md');
|
|
141
158
|
const readmePath = existsSync(readmeProbe) ? readmeProbe : null;
|
|
142
159
|
const typescriptRunHelpersProbe = join(root, 'sdk', 'typescript', 'src', 'run-helpers.ts');
|
|
@@ -155,6 +172,7 @@ function resolveFromRoot(root: string, layout: ResolvedLayout['layout'], contrac
|
|
|
155
172
|
fixturesDocPath,
|
|
156
173
|
coverageDocPath,
|
|
157
174
|
v1Dir,
|
|
175
|
+
specV2Dir,
|
|
158
176
|
readmePath,
|
|
159
177
|
typescriptRunHelpersPath,
|
|
160
178
|
pythonTypesPath,
|
|
@@ -208,6 +226,7 @@ export const CONFORMANCE_README_PATH: string | null = _layout.conformanceReadmeP
|
|
|
208
226
|
export const FIXTURES_DOC_PATH: string | null = _layout.fixturesDocPath;
|
|
209
227
|
export const COVERAGE_DOC_PATH: string | null = _layout.coverageDocPath;
|
|
210
228
|
export const V1_DIR: string | null = _layout.v1Dir;
|
|
229
|
+
export const SPEC_V2_DIR: string | null = _layout.specV2Dir;
|
|
211
230
|
export const README_PATH: string | null = _layout.readmePath;
|
|
212
231
|
export const TYPESCRIPT_RUN_HELPERS_PATH: string | null = _layout.typescriptRunHelpersPath;
|
|
213
232
|
export const PYTHON_TYPES_PATH: string | null = _layout.pythonTypesPath;
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* lacked. The two legs are each other's non-vacuity check: refusing both is a
|
|
28
28
|
* defect, accepting both is a defect, and only the pair can tell them apart.
|
|
29
29
|
*
|
|
30
|
+
* BECAUSE THEY CHECK OPPOSITE HALVES, THEY GATE SEPARATELY (`refusalGate` /
|
|
31
|
+
* `controlGate`). Suite 2.0.5 gated both on the control leg's precondition and
|
|
32
|
+
* so disabled the refusal witness wherever the registry did not resolve — on
|
|
33
|
+
* exactly the hosts it exists to catch. A precondition belongs to the leg that
|
|
34
|
+
* needs it, never to the file.
|
|
35
|
+
*
|
|
30
36
|
* @see spec/v2/core/persistence.md §The reader rule
|
|
31
37
|
* @see spec/v2/core/events.md §Reading an era-2 log
|
|
32
38
|
*/
|
|
@@ -36,7 +42,7 @@ import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
|
36
42
|
import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
|
|
37
43
|
import { softSkip } from '../lib/soft-skip.js';
|
|
38
44
|
import { req } from '../lib/requirement-ids.js';
|
|
39
|
-
import { codemapV1toV2, era2Gate, eventsOf, forkRun, pollEvents, registeredOrgs, seedEra2Log, type SeedEvent } from '../lib/era2-seed.js';
|
|
45
|
+
import { codemapV1toV2, era2Gate, eventsOf, forkRun, pollEvents, registeredOrgs, seedEra2Log, unmappedRefusalGate, vendorControlGate, type Gate, type SeedEvent } from '../lib/era2-seed.js';
|
|
40
46
|
|
|
41
47
|
const DOC = 'spec/v2/core/persistence.md §The reader rule';
|
|
42
48
|
const MAP = 'spec/v2/core/persistence.md §The codemap is data';
|
|
@@ -45,26 +51,17 @@ const ID_VENDOR = 'openwop.requirement.0176.vendor-type-passthrough';
|
|
|
45
51
|
const UNMAPPED = 'foo.bar';
|
|
46
52
|
/** Registered (`extensions` in the declaration), unmapped, owned by no host. */
|
|
47
53
|
const VENDOR = 'example.thing-happened';
|
|
48
|
-
const orgOf = (type: string): string => type.split('.')[0] ?? '';
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
|
-
* The
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* here so the file fails loudly instead.
|
|
56
|
+
* The gate logic is PURE and lives in lib/era2-seed (`unmappedRefusalGate` /
|
|
57
|
+
* `vendorControlGate`) so its truth table can be measured directly — see
|
|
58
|
+
* `src/lib/era2-unmapped-gates.test.ts`. The rows that matter cannot be
|
|
59
|
+
* exercised from here: they are the layouts where the registry does NOT
|
|
60
|
+
* resolve, which is where the 2.0.5 defect lived and where a scenario running
|
|
61
|
+
* against a live host can never put itself.
|
|
58
62
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
}
|
|
63
|
+
const refusalGate = (): Gate => unmappedRefusalGate(registeredOrgs(), codemapV1toV2(), UNMAPPED);
|
|
64
|
+
const controlGate = (): Gate => vendorControlGate(registeredOrgs(), codemapV1toV2(), VENDOR);
|
|
68
65
|
|
|
69
66
|
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
70
67
|
try { return await v2Discovery(); } catch { return null; }
|
|
@@ -97,7 +94,7 @@ describe('RFC 0176 §A.3 — unmapped-type-refused (seam-gated)', () => {
|
|
|
97
94
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
98
95
|
const gate = era2Gate(doc);
|
|
99
96
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
100
|
-
const pre =
|
|
97
|
+
const pre = refusalGate();
|
|
101
98
|
if (!pre.ok) return softSkip(pre.kind, pre.reason);
|
|
102
99
|
const seeded = await seedEra2Log(unmappedLog(), 'completed');
|
|
103
100
|
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
@@ -122,7 +119,7 @@ describe('RFC 0176 §A.3 — unmapped-type-refused (seam-gated)', () => {
|
|
|
122
119
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
123
120
|
const gate = era2Gate(doc);
|
|
124
121
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
125
|
-
const pre =
|
|
122
|
+
const pre = controlGate();
|
|
126
123
|
if (!pre.ok) return softSkip(pre.kind, pre.reason);
|
|
127
124
|
const seeded = await seedEra2Log(vendorLog(), 'completed');
|
|
128
125
|
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
@@ -149,7 +146,8 @@ describe('RFC 0176 §A.3 — unmapped-type-refused (seam-gated)', () => {
|
|
|
149
146
|
const gate = era2Gate(doc);
|
|
150
147
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
151
148
|
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — the fork reader has no surface');
|
|
152
|
-
|
|
149
|
+
// The fork leg drives the UNMAPPED type, so it takes the refusal gate.
|
|
150
|
+
const pre = refusalGate();
|
|
153
151
|
if (!pre.ok) return softSkip(pre.kind, pre.reason);
|
|
154
152
|
const log = unmappedLog();
|
|
155
153
|
const seeded = await seedEra2Log(log, 'completed');
|