@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 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;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.0.5",
4
- "stampSha256": "0046fddf0cc553f5da0fa9ee108a4c45af6b35fe3c1456e62436127c591dc405"
3
+ "version": "2.0.7",
4
+ "stampSha256": "1d8f7916c3c32af8dfffdd419b773358b812ff5954829b87bff576eb3f0dc669"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.0.5",
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.5"
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": 95,
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": 120,
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": 146,
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",
5
- "corpusTag": "v2.0.5",
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": "99eae8d4204c4c23c4af05ed5e4982171ca6cf4fe9be03ebcedd9d977c5006dc",
13
- "api/v2/asyncapi.yaml": "42040149c2a2056b1f69533fe4fb96e4f0299c8a06e952a88b8b7fd6d21748d2",
14
- "api/v2/openapi.yaml": "4dcb20f14d28b4a7bfbbd2f6c1740723937169b7d1581670fe82c968e5d587a8",
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": "6e6059b6bcb2196ebbdf4015a28d4a077f93e7bec710326c492b49935988fd15",
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": "91418b882190e116ab9dac14527f52b8582b0462f41e96beeceb07e3fcc5f99a",
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": "e18195e078d7b0b799055005567e8d86a2ea7518c87809cfaf43dfe9f5d5c8ee",
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": "9e191dffae9f95e622b3fc7087b8c17a2f42177bb4db3c7ee5fdf650443e036d",
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": "b5017d420b712ef558a93e0a6576df225583294157bb5c8cb198ee490c118780",
271
+ "spec/v2/release.json": "2937de1d170c218e4d336088035c2a4fe6b7e951b6c6bd8bd375f0580ae07a53",
272
272
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
273
273
  },
274
- "corpusCommit": "3d3eca2a5b8e856c1f6cf448ee9e19fbb4fe5dd1"
274
+ "corpusCommit": "8f8818dd663240bdd1ea074a25fd9354d0fde2c3"
275
275
  }
@@ -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 { V1_DIR } from './paths.js';
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
- const candidate = V1_DIR ? join(V1_DIR, '..', 'v2', 'event-codemap.json') : null;
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
- const candidate = V1_DIR ? join(V1_DIR, '..', 'v2', 'declaration.json') : null;
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 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.
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
- 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
- }
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 = preconditions();
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 = preconditions();
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
- const pre = preconditions();
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');