@openwop/openwop-conformance 2.0.5 → 2.0.6

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,44 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.0.6] — 2026-09-06 — the release that made a rule uncheckable
4
+
5
+ **If you pinned 2.0.5, `v2-unmapped-type-refused` did not run against your
6
+ host.** It soft-skipped `inapplicable` and the lane went green. Pin 2.0.6 and
7
+ re-measure before trusting any result that scenario gave you.
8
+
9
+ 2.0.5 shipped four fixes for rules whose instruments could not answer, and one
10
+ of those fixes did the same thing to a fifth. Reported by a host operator who
11
+ kept a local witness for a defect they knew was unfixed and re-measured before
12
+ accepting a green they wanted. Two defects, one symptom:
13
+
14
+ - **The corpus resolver was anchored on a repo-only directory.**
15
+ `registeredOrgs()` and `codemapV1toV2()` located `spec/v2/` via
16
+ `V1_DIR/../v2` — a **v1**-anchored path to a **v2** file. No published package
17
+ ships `spec/v1/`, so `V1_DIR` is `null` in every install and both lookups
18
+ returned nothing. The data was never missing; it is in the exact-pinned
19
+ `@openwop/spec-artifacts` peer. Both now resolve through `SPEC_V2_DIR`
20
+ (`lib/paths`), anchored on the contract root, which holds in a repo checkout
21
+ and an install alike. The codemap failed more quietly — 7 fallback rows
22
+ instead of 118, so era-2 readers asserted against names it never had.
23
+
24
+ - **A precondition written for one leg gated the other.**
25
+ `v2-unmapped-type-refused` drives two opposite halves of the reader rule.
26
+ Both were gated on a single check that demanded a resolvable registry, which
27
+ only the *control* leg needs. With the registry unreachable, the *refusal*
28
+ leg skipped on exactly the hosts it exists to catch: one answering `200` and
29
+ one answering `500` were both green. Gates are now separate and pure
30
+ (`unmappedRefusalGate` / `vendorControlGate`), with the fail-closed reading
31
+ explicit — an unreadable registry registers nothing, so the refusal is still
32
+ required and the leg stays drivable.
33
+
34
+ Added `src/lib/era2-unmapped-gates.test.ts` (the gates' truth table, including
35
+ the rows a live-host scenario cannot check about itself; sabotage-verified) and
36
+ a post-install corpus-resolution assertion in `verify-installable` — a clean
37
+ `npm install` was never evidence the suite works.
38
+
39
+ **A ratchet's STALE signal means the scenario stopped failing. It is not
40
+ evidence the defect is fixed.**
41
+
3
42
  ## [2.0.5] — 2026-09-06 — four rules with no way to be checked
4
43
 
5
44
  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.6",
4
+ "stampSha256": "4f7707b8acb297687e2cc9017f2fa34191033dd0b5db6c1cc64c2c04477ecee6"
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.6",
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.6"
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.6",
5
+ "corpusTag": "v2.0.6",
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": "594aad07baceb9c8e00dc9fe91dd8f6f99bc6624c64eaf5be91dbb5f22a35c48",
13
+ "api/v2/asyncapi.yaml": "5839f6cb16ed8997a335491a6700ddc2f4166c994ca2838863d4029c1ef6d58e",
14
+ "api/v2/openapi.yaml": "f1288076f774dbb46b885519b94719df84b31b4535fe2a5bccb9881d2e09b0ea",
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": "0018230eb426fb0af82545b9f63920cc9504a9eed2a03c44ef61cf73f429e82b",
203
203
  "spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
204
204
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
205
205
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -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": "7735a176082e42a30d7884654d79ac7affd7fc50f4b7346085f7d841769c6819",
272
272
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
273
273
  },
274
- "corpusCommit": "3d3eca2a5b8e856c1f6cf448ee9e19fbb4fe5dd1"
274
+ "corpusCommit": "0ebdbc9ad741d49c44731e2bd255499a7da6c4a7"
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,50 @@ 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. `inapplicable` here
257
+ * is honest rather than over-gating.
258
+ */
259
+ export function vendorControlGate(registered: ReadonlySet<string> | undefined, map: ReadonlyMap<string, string>, type: string): Gate {
260
+ if (registered === undefined) return { ok: false, kind: 'inapplicable', 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' };
261
+ 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` };
262
+ 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` };
263
+ return { ok: true };
264
+ }
265
+
212
266
  /** A minimal era-2 log in v1 vocabulary: two renamed rows between run.started and run.completed. */
213
267
  export function v1FixtureLog(workflowId = 'conformance-noop'): SeedEvent[] {
214
268
  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');