@openwop/openwop-conformance 1.138.1 → 1.140.0

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/README.md CHANGED
@@ -78,7 +78,7 @@ Run `npm run test` for normal CI cadence; `npm run test:strict` when claiming fu
78
78
  | `OPENWOP_OTEL_COLLECTOR_GRPC=true` | Boots the parallel OTLP/gRPC collector alongside the HTTP one (h2c HTTP/2 on a separate port). Shares the same `spans()` + `metrics()` store; spans captured over either transport surface in `getCollector().spans()`. Requires `OPENWOP_OTEL_COLLECTOR=true`. Same `--no-file-parallelism` requirement applies. |
79
79
  | `OPENWOP_OTEL_COLLECTOR_GRPC_PORT=4317` | Bind the OTLP/gRPC collector on a specific port (default `4317`, OTLP/gRPC convention). The host MUST be configured with `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>` AND `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`. |
80
80
  | `OPENWOP_OTEL_COLLECTOR_PORT=14318` | Bind the OTel collector on a specific port (default `4318`). The host MUST be configured with `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:<port>`. |
81
- | `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` | Hosts implementing the webhook SSRF guard (rejecting loopback / RFC1918 / link-local destinations) MUST advertise this flag for the loopback test receiver in `webhook-signed-delivery.test.ts` to be accepted. The SQLite reference host honors this env var; the scenario soft-skips when the host rejects the URL. |
81
+ | `OPENWOP_WEBHOOK_ALLOW_PRIVATE=true` | Relaxes the webhook egress guard for the loopback test receiver used by `webhook-signed-delivery.test.ts`, `webhook-negative.test.ts`, and `replay-fanout-suppression.test.ts`. **The receiver is `http://127.0.0.1:{port}/`, which `webhooks.md` forbids three separate times, and the opt-in MUST relax all three to be witnessable:** (1) the **scheme** check — §"SSRF protection" bullet 1 rejects non-`https://`, and this gate fires FIRST on a host that validates scheme before address; (2) the **registration-time** address check §"SSRF protection"; and (3) **delivery-time re-resolution** — §"Delivery-time egress validation (RFC 0093)". Gates 2 and 3 are independent MUSTs at different layers, so an opt-in reaching only one layer **cannot** produce a witness: delivery-only leaves registration returning `400 webhook_url_rejected`, registration-only leaves the dispatcher refusing to connect. A host whose opt-in reaches one layer is **not** non-conformant — it cannot witness these scenarios, which is a property of the test posture and not of its webhook signing. The scheme gate went unstated here until 2026-08-25; a tier-2 host validating scheme-then-address was blocked by a gate the documented contract never named. Relaxing gates 2 and 3 is **test-only posture** — see `SECURITY/threat-model-secret-leakage.md` §4.9 for why a registration-time relaxation is the more dangerous of the two. When the host rejects, the scenario records `blocked` (RFC 0148 §A), not a pass. |
82
82
  | `OPENWOP_MCP_FAKE_SERVER=true` | Boots the synthetic MCP peer for `mcp-tool-roundtrip.test.ts`. |
83
83
  | `OPENWOP_MCP_REAL_SERVER_URL=<base-url>` | Points the MCP wire-shape probe at a real MCP server. The probe POSTs JSON-RPC and reads a single-JSON response — matches MCP's `streamable-http` transport in single-response mode. **Does NOT support** stdio transport (which is what most `modelcontextprotocol/servers` references default to) or SSE-streamed responses; an operator collecting interop evidence today runs a custom `StreamableHTTPServerTransport`-style server that returns a single JSON body per request. Adding SSE-frame parsing is tracked in `docs/PROTOCOL-GAP-CLOSURE-PLAN.md` Track 6. Assertions relax to shape-only. When both this and `OPENWOP_MCP_FAKE_SERVER` are set, the real URL wins. Phase 3 T3.4 interop-evidence path. |
84
84
  | `OPENWOP_A2A_FAKE_PEER=true` | Boots the synthetic A2A peer for `a2a-task-roundtrip.test.ts`. |
package/coverage.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenWOP Conformance Coverage Map
2
2
 
3
- > **Status: Living document. Updated 2026-06-29.** This map connects the current scenario files to the protocol surfaces they protect and records the remaining gaps from the protocol deep dive. Scenario names are source-of-truth file names under `conformance/src/scenarios/`.
3
+ > **Status: Living document. Updated 2026-08-25.** This map connects the current scenario files to the protocol surfaces they protect and records the remaining gaps from the protocol deep dive. Scenario names are source-of-truth file names under `conformance/src/scenarios/`.
4
4
 
5
5
  > **Shape grade vs behavior grade.** Some optional-profile scenarios validate **capability shape** (the host's discovery advertisement is well-formed) without yet exercising **behavior** (the host actually implements the profile end-to-end). The "Current grade" column reflects shape; see §"Capability-gated scenarios: shape vs behavior" below for the dual-grade view and the `OPENWOP_REQUIRE_BEHAVIOR=true` strict-mode runner flag.
6
6
 
@@ -28,6 +28,7 @@
28
28
  import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
29
29
  import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
30
30
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
31
+ import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
31
32
  import { CERTIFIABLE } from './requirement-ledger.js';
32
33
  /** All scenario basenames that appear in some profile's runtime floor. */
33
34
  export function floorScenarioFiles() {
@@ -100,7 +101,19 @@ export function fileDisposition(states, gateReason, assertionCount) {
100
101
  * - every test `ctx.skip()`ped ⇒ the file's noted reason if it wrote one
101
102
  * BEFORE skipping (`ctx.skip()` throws), else `blocked` + the marker.
102
103
  */
103
- export function resolveFileRecord(states, gateReason, assertionCount, noted) {
104
+ export function resolveFileRecord(states, gateReason, assertionCount, noted, specCoherenceFile) {
105
+ // A scenario whose subject is the CORPUS, skipped because the published
106
+ // tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
107
+ // ADVERTISED BEHAVIOUR, and there is none here — nothing about the host was
108
+ // ever going to be exercised. `inapplicable` is the honest label, and it is
109
+ // CERTIFIABLE, so these rows stop counting against a host that cannot affect
110
+ // them. See lib/spec-coherence.ts for why not a new disposition value.
111
+ if (specCoherenceFile !== undefined
112
+ && SPEC_COHERENCE_SCENARIOS.has(specCoherenceFile)
113
+ && !states.includes('fail')
114
+ && assertionCount === 0) {
115
+ return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
116
+ }
104
117
  let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
105
118
  if (disposition === 'executed-pass' && assertionCount === 0) {
106
119
  if (noted !== null) {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Scenarios that measure the SPEC, not the host (RFC 0148 §A).
3
+ *
4
+ * ## The defect
5
+ *
6
+ * 28 scenarios read `spec/v1/*.md` to check the corpus is internally coherent —
7
+ * that the `protocolVersion` grammar in the schema matches RFC 0149, that error
8
+ * envelopes are the shape `error-envelope.schema.json` declares, that every
9
+ * normative example extracts and validates. They assert nothing whatever about
10
+ * a host.
11
+ *
12
+ * `spec/v1/` is deliberately NOT bundled in the published tarball (`paths.ts`
13
+ * says so), so in a published-layout run `V1_DIR` is null and they
14
+ * `describe.skipIf` at COLLECTION time. No test body runs, so no `softSkip`
15
+ * note is recorded, and `resolveFileRecord` resolves an all-skipped file with no
16
+ * reason to **`blocked`** carrying "every test returned early … no recorded
17
+ * reason".
18
+ *
19
+ * That row then lands in a HOST's certification bundle. A host operator reads
20
+ * `blocked` and cannot tell it from a real gap in their implementation. A
21
+ * tier-2 host measured 13 such rows — **a third of their undiagnosed set** —
22
+ * and only discovered what they were by pointing `OPENWOP_CONFORMANCE_ROOT` at
23
+ * a spec checkout and watching 85 assertions pass in about a second, 59 of them
24
+ * against a dead `localhost:9`.
25
+ *
26
+ * ## Why `inapplicable`, and why not the other four
27
+ *
28
+ * RFC 0148 §A defines the two candidates precisely, and the definitions decide
29
+ * it:
30
+ *
31
+ * - `blocked` — "**advertised behavior** could not be exercised because a
32
+ * required seam, fixture, credential, or dependency was unavailable."
33
+ * There is no advertised behaviour here. Nothing about the host was ever
34
+ * going to be exercised, so nothing about the host failed to be.
35
+ * - `inapplicable` — "the requirement does not apply to the captured
36
+ * discovery/profile set." A requirement about the spec corpus does not
37
+ * apply to any host's discovery set. This is the honest label.
38
+ *
39
+ * `executed-pass` is wrong for the obvious reason: in a run where the corpus is
40
+ * absent, nothing executed, and claiming a pass for an unrun scenario is the
41
+ * defect this whole disposition system exists to prevent. A NEW disposition
42
+ * value was considered and rejected — `certification-bundle-v2.schema.json`
43
+ * enumerates the five, and `verifyBundleV2` is a published consumer contract,
44
+ * so a sixth is a wire break for every existing verifier. Correct use of an
45
+ * existing value costs nothing and breaks no one.
46
+ *
47
+ * `inapplicable` is in `CERTIFIABLE`, which is the point: these rows stop
48
+ * counting against a host that has no way to affect them.
49
+ *
50
+ * ## Why a list and not a predicate
51
+ *
52
+ * The property is static — "gates on `V1_DIR` and never touches `driver`" — and
53
+ * cannot be evaluated from `setup.ts` at runtime. So it is a list, and a list
54
+ * drifts. `spec-coherence-registry.test.ts` re-derives it from source on every
55
+ * run and fails when the two disagree, which is the only thing that makes a
56
+ * hand-maintained set trustworthy.
57
+ *
58
+ * ## What is deliberately NOT here
59
+ *
60
+ * Seven scenarios gate on `V1_DIR` **and** drive the host
61
+ * (`replay-side-effect-suppression`, `data-residency-admission`,
62
+ * `profile-discovery-core-alias`, `workflow-variable-format`,
63
+ * `workflow-chain-deferred-parameters`, `artifact-type-store-emission`,
64
+ * `artifact-type-registration-source`). Those assert advertised host behaviour
65
+ * that could not be exercised because a dependency was unavailable — which is
66
+ * `blocked`, exactly as §A defines it. Classifying them `inapplicable` would
67
+ * tell a host "this does not apply to you" about a requirement that does.
68
+ */
69
+ /** Scenarios whose subject is the corpus. Kept honest by `spec-coherence-registry.test.ts`. */
70
+ export const SPEC_COHERENCE_SCENARIOS = new Set([
71
+ 'artifact-schema-compile-bounded.test.ts',
72
+ 'artifact-type-legacy-ids.test.ts',
73
+ 'capability-example-root-layout.test.ts',
74
+ 'certification-floor-enforcement.test.ts',
75
+ 'chain-subchain-unsupported-refused.test.ts',
76
+ 'compensation-profile.test.ts',
77
+ 'core-manifest-and-extension-registry.test.ts',
78
+ 'discovery-canonical-family-no-shadow.test.ts',
79
+ 'edge-condition-truthy-falsy.test.ts',
80
+ 'effect-identity-composition.test.ts',
81
+ 'effect-identity-cross-scope.test.ts',
82
+ 'error-envelope-canonical-shape.test.ts',
83
+ 'form-content-packs.test.ts',
84
+ 'multi-region-effect-vocabulary.test.ts',
85
+ 'normative-example-extraction.test.ts',
86
+ 'openapi-asyncapi-sdk-parity.test.ts',
87
+ 'pack-manifest-extensions.test.ts',
88
+ 'protocol-version-grammar.test.ts',
89
+ 'registry-declarative-kinds.test.ts',
90
+ 'rfc-0147-self-audit.test.ts',
91
+ 'rfc-lifecycle-coherence.test.ts',
92
+ 'semantic-digest-v2.test.ts',
93
+ 'spec-corpus-validity.test.ts',
94
+ 'spec-section-citations.test.ts',
95
+ 'tool-result-trust-monotone.test.ts',
96
+ 'versioned-composition-profiles.test.ts',
97
+ 'workflow-chain-internal-flag.test.ts',
98
+ 'workload-identity-profile.test.ts',
99
+ ]);
100
+ /** The reason recorded on such a row, written for the host operator reading it. */
101
+ export const SPEC_COHERENCE_DETAIL = 'inapplicable to any host: this scenario reads spec/v1/ to check the SPEC corpus is internally coherent and asserts nothing about a host. '
102
+ + 'The published tarball does not bundle spec/v1/ (see lib/paths.ts), so it does not run here. '
103
+ + 'Set OPENWOP_CONFORMANCE_ROOT to a spec checkout to run it; it needs no host.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.138.1",
3
+ "version": "1.140.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
3
- "suiteVersion": "1.138.1",
4
- "corpusCommit": "3555363038ce8ea7e40dc2f2a41c39066228e69c"
3
+ "suiteVersion": "1.140.0",
4
+ "corpusCommit": "8fe8053bfa7436a9c9a2dbbebd304095ecd29b16"
5
5
  }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Run-end disposition summary (RFC 0148 §A, 2026-08-25).
3
+ *
4
+ * ## The gap this closes
5
+ *
6
+ * Every scenario file already records ONE honest disposition when it
7
+ * finishes (`setup.ts`'s `afterAll` → `resolveFileRecord`): a file that
8
+ * returned early with zero assertions is `blocked` / `skipped` /
9
+ * `inapplicable` with a reason, never a pass. That machinery is correct.
10
+ *
11
+ * It was **published on one invocation out of two**. The worker appends
12
+ * each recording to `OPENWOP_LEDGER_PATH`, and the ONLY writer of that
13
+ * variable was `cli.ts` inside `runCertify`. Under a plain `vitest run`
14
+ * — what `README.md` documents for host operators, and what host
15
+ * implementers actually run — the worker computed `blocked`, held it in
16
+ * memory, and discarded it at process exit. The single surviving artifact
17
+ * was vitest's `1 passed`.
18
+ *
19
+ * So the suite computed an honest disposition on every run and published
20
+ * it on one. Two host implementers independently read the console line as
21
+ * coverage and reported the suite as claiming a pass it had internally
22
+ * classified `blocked` — correctly, given the only artifact they had.
23
+ * RFC 0158 §"Operator preconditions are declared, not hidden" requires a
24
+ * blocked row be surfaced "never silently skipped or reported as a pass";
25
+ * the bundle honored that and the console contradicted it.
26
+ *
27
+ * ## What this does
28
+ *
29
+ * When `OPENWOP_LEDGER_PATH` is unset, point it at a temp file so the
30
+ * workers record there anyway, then read it at run end and print a
31
+ * summary. `--certify` sets the variable itself, so this defers to it
32
+ * completely and never interferes with bundle generation.
33
+ *
34
+ * The summary is deliberately NOT a pass/fail signal — it does not change
35
+ * the exit code. `vitest`'s exit code answers "did any assertion fail";
36
+ * this answers "what did the run actually witness", and those are
37
+ * different questions. Conflating them is what produced the gap.
38
+ *
39
+ * Set `OPENWOP_DISPOSITION_SUMMARY=false` to silence it.
40
+ */
41
+
42
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import { tmpdir } from 'node:os';
45
+
46
+ /** Dispositions that are NOT a witnessed execution — the rows worth printing. */
47
+ const NON_EXECUTING = new Set(['blocked', 'skipped', 'inapplicable']);
48
+
49
+ /** Cap on printed rows; the overflow is always announced, never silent. */
50
+ const MAX_ROWS = 40;
51
+
52
+ // Import the REAL entry type rather than restating its shape. A hand-written
53
+ // structural guess here read `entry.id` — a key the ledger has never written
54
+ // (it is `requirementId`) — and the `(unnamed)` fallback made the mismatch
55
+ // look like missing data instead of a wrong reader. A type-only import erases
56
+ // at runtime, so this costs nothing and makes the compiler the oracle: rename
57
+ // a ledger field and this file stops building instead of silently degrading.
58
+ import type { LedgerEntry } from './lib/requirement-ledger.js';
59
+
60
+ /** What a JSONL line parses to before validation — every field may be absent. */
61
+ type LedgerLine = Partial<Record<keyof LedgerEntry, unknown>>;
62
+
63
+ let ownedDir: string | null = null;
64
+ let ledgerPath: string | null = null;
65
+
66
+ export function setup(): void {
67
+ if (process.env['OPENWOP_DISPOSITION_SUMMARY'] === 'false') return;
68
+ // `--certify` already routes the ledger to its own report dir and reads it
69
+ // there. Never take it over — this exists only for the path that had no
70
+ // reader at all.
71
+ if ((process.env['OPENWOP_LEDGER_PATH'] ?? '') !== '') return;
72
+ ownedDir = mkdtempSync(join(tmpdir(), 'owp-dispositions-'));
73
+ ledgerPath = join(ownedDir, 'requirement-ledger.jsonl');
74
+ process.env['OPENWOP_LEDGER_PATH'] = ledgerPath;
75
+ }
76
+
77
+ /**
78
+ * Fold raw JSONL into the printable summary. PURE — no I/O, no env — so
79
+ * `global-setup.test.ts` can pin it without a vitest subprocess or a host.
80
+ * Returns `null` when there is nothing to say.
81
+ */
82
+ export function summarise(raw: string): string | null {
83
+ const counts = new Map<string, number>();
84
+ const rows: Array<{ id: string; disposition: string; detail: string }> = [];
85
+ for (const line of raw.split('\n')) {
86
+ if (line.trim() === '') continue;
87
+ let entry: LedgerLine;
88
+ try {
89
+ entry = JSON.parse(line) as LedgerLine;
90
+ } catch {
91
+ continue;
92
+ }
93
+ const disposition = typeof entry.disposition === 'string' ? entry.disposition : 'unknown';
94
+ const id = typeof entry.requirementId === 'string' ? entry.requirementId : '(unnamed)';
95
+ const detail = typeof entry.detail === 'string' ? entry.detail : '';
96
+ counts.set(disposition, (counts.get(disposition) ?? 0) + 1);
97
+ if (NON_EXECUTING.has(disposition)) {
98
+ rows.push({ id, disposition, detail });
99
+ } else if (disposition === 'executed-pass' && entry.assertionCount === 0) {
100
+ // The other shape RFC 0148 §A rejects: a pass that asserted nothing.
101
+ rows.push({ id, disposition: 'executed-pass (0 assertions)', detail });
102
+ }
103
+ }
104
+ if (counts.size === 0) return null;
105
+ const total = [...counts.values()].reduce((a, b) => a + b, 0);
106
+ const order = ['executed-pass', 'executed-fail', 'blocked', 'skipped', 'inapplicable'];
107
+ const summary = [...order, ...[...counts.keys()].filter((k) => !order.includes(k))]
108
+ .filter((k) => counts.has(k))
109
+ .map((k) => `${k} ${counts.get(k)}`)
110
+ .join(' \u00b7 ');
111
+ const out: string[] = [
112
+ '',
113
+ `[openwop-conformance] RFC 0148 \u00a7A dispositions \u2014 ${total} requirement(s) recorded`,
114
+ ` ${summary}`,
115
+ ];
116
+ if (rows.length > 0) {
117
+ out.push(
118
+ ` ${rows.length} requirement(s) did NOT witness. vitest reports these as passes because`,
119
+ ' no assertion failed; that is a test outcome, not a conformance disposition.',
120
+ );
121
+ for (const r of rows.slice(0, MAX_ROWS)) {
122
+ const detail = r.detail.length > 120 ? `${r.detail.slice(0, 117)}...` : r.detail;
123
+ out.push(` ${r.disposition.padEnd(28)} ${r.id}${detail === '' ? '' : `\n ${detail}`}`);
124
+ }
125
+ if (rows.length > MAX_ROWS) {
126
+ // Never truncate silently: a capped list that does not say it was capped
127
+ // reads as a complete one, which is the same defect this whole summary
128
+ // exists to remove.
129
+ out.push(` ... and ${rows.length - MAX_ROWS} more not listed (run with --certify for the full bundle)`);
130
+ }
131
+ }
132
+ out.push('');
133
+ return out.join('\n');
134
+ }
135
+
136
+ export function teardown(): void {
137
+ if (ledgerPath === null || ownedDir === null) return;
138
+ // We set the variable; unset it so a subsequent in-process run re-derives.
139
+ delete process.env['OPENWOP_LEDGER_PATH'];
140
+ let raw = '';
141
+ try {
142
+ raw = readFileSync(ledgerPath, 'utf8');
143
+ } catch {
144
+ // No file means no worker ever recorded \u2014 nothing to summarise, and a
145
+ // missing summary must never be mistaken for "nothing was blocked", so
146
+ // say so rather than printing an empty clean bill.
147
+ process.stderr.write(
148
+ '\n[openwop-conformance] no disposition ledger was written \u2014 the run recorded nothing, ' +
149
+ 'which is NOT the same as every scenario having witnessed its requirement.\n',
150
+ );
151
+ cleanup();
152
+ return;
153
+ }
154
+ const text = summarise(raw);
155
+ if (text !== null) process.stderr.write(text);
156
+ cleanup();
157
+ }
158
+
159
+ function cleanup(): void {
160
+ if (ownedDir !== null) {
161
+ try {
162
+ rmSync(ownedDir, { recursive: true, force: true });
163
+ } catch {
164
+ /* best effort */
165
+ }
166
+ }
167
+ ownedDir = null;
168
+ ledgerPath = null;
169
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pins `forkDeclined` (RFC 0148 §A).
3
+ *
4
+ * The risk in a helper like this is not that it misses a status — that shows up
5
+ * as a red test somebody investigates. It is that it swallows one: every status
6
+ * it accepts turns an assertion into a skip, silently, and a suite that skips
7
+ * is indistinguishable from a suite that passes unless something records why.
8
+ * So the load-bearing cases here are the NEGATIVE ones.
9
+ */
10
+
11
+ import { describe, expect, it, beforeEach } from 'vitest';
12
+ import { forkDeclined } from './fork-availability.js';
13
+ import { resetSoftSkips, softSkipDisposition } from './soft-skip.js';
14
+
15
+ describe('forkDeclined', () => {
16
+ beforeEach(() => resetSoftSkips());
17
+
18
+ it('treats 404 and 403 the same as 501 — route absent is not weaker than route-declines', () => {
19
+ // The asymmetry this fixes: every replay scenario handled 501 and none
20
+ // handled 404. `501` means "I know this route and decline"; `404` means
21
+ // "there is no such route" — strictly less implemented. The suite treated
22
+ // the weaker signal as a skip and the stronger one as a defect, so a host
23
+ // had to implement the route in order to say it had not implemented it.
24
+ for (const status of [404, 403, 501]) {
25
+ expect(forkDeclined(status, 'leg'), `status ${status} means the fork did not happen`).toBe(true);
26
+ }
27
+ });
28
+
29
+ it('does NOT swallow any other status — a 500 is still a defect, not a skip', () => {
30
+ // If this ever goes green for one of these, a real failure has become a
31
+ // silent skip and no assertion downstream will ever run again.
32
+ for (const status of [200, 201, 202, 400, 409, 422, 500, 502, 503]) {
33
+ expect(forkDeclined(status, 'leg'), `status ${status} must reach the assertion`).toBe(false);
34
+ }
35
+ });
36
+
37
+ it('records WHY, so the ledger row is not an unclassified return', () => {
38
+ // Two of the eleven original call sites were a bare `return`. A bare return
39
+ // in a file whose OTHER tests assert is invisible: the file records
40
+ // `executed-pass` and the leg that never ran leaves no trace.
41
+ forkDeclined(404, 'fanout-suppression replay fork');
42
+ const noted = softSkipDisposition('fork-availability.test.ts');
43
+ expect(noted?.kind).toBe('blocked');
44
+ expect(noted?.reason).toContain('404');
45
+ expect(noted?.reason).toContain('fanout-suppression replay fork');
46
+ expect(noted?.reason).toContain('not mounted');
47
+ });
48
+
49
+ it('records nothing when the status is not a decline', () => {
50
+ forkDeclined(201, 'leg');
51
+ expect(softSkipDisposition('fork-availability.test.ts')).toBeNull();
52
+ });
53
+
54
+ it('names the leg, so one file with several forks stays attributable', () => {
55
+ forkDeclined(501, 'branch fork');
56
+ expect(softSkipDisposition('fork-availability.test.ts')?.reason).toContain('branch fork');
57
+ });
58
+
59
+ it('distinguishes the three declines in its reason text', () => {
60
+ forkDeclined(404, 'a');
61
+ const a = softSkipDisposition('fork-availability.test.ts')?.reason ?? '';
62
+ resetSoftSkips();
63
+ forkDeclined(501, 'a');
64
+ const b = softSkipDisposition('fork-availability.test.ts')?.reason ?? '';
65
+ // `blocked` alone does not tell a bundle reader whether the host lacks the
66
+ // route or declines the range; the reason must.
67
+ expect(a).not.toBe(b);
68
+ });
69
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * "The fork seam did not run" — one rule, one place (RFC 0148 §A).
3
+ *
4
+ * `POST /v1/runs/{runId}:fork` can decline in more than one way, and eleven
5
+ * call sites across six scenarios each decided for themselves which ways
6
+ * counted:
7
+ *
8
+ * - **501** — every site handled this. "Advertised but not implemented for
9
+ * this range", per the suite convention their comments cite.
10
+ * - **404 / 403** — *no* site in the replay family handled it. A host that
11
+ * never mounts the route hard-failed `expect(status).toBe(201)`.
12
+ *
13
+ * That asymmetry is backwards. `501` means "I know this route and decline";
14
+ * `404` means "there is no such route" — strictly less implemented, and the
15
+ * suite treated the weaker signal as a skip and the stronger one as a defect.
16
+ * The postgres reference host 404s, so `replay-fanout-suppression` was red in
17
+ * CI on every run while the other four replay scenarios skipped past the same
18
+ * seam for unrelated fixture reasons. **The suite required a host to implement
19
+ * the route in order to say it had not implemented the route.**
20
+ *
21
+ * Recording matters as much as the predicate. Two of those eleven sites were a
22
+ * bare `return` with no note: `replay-fork.test.ts` reported "6 tests | 6
23
+ * skipped" against a host whose fork surface is entirely unmeasured, and the
24
+ * ledger had nothing to say about why. §A resolves an unclassified return to
25
+ * `blocked`, but only when the file records nothing at all — a bare return in a
26
+ * file whose *other* tests assert is invisible.
27
+ *
28
+ * Disposition is `blocked`, not `inapplicable`: the obligation applies to this
29
+ * host, it simply could not be witnessed. `inapplicable` would claim the host
30
+ * is outside the requirement's scope, which nothing here establishes — these
31
+ * scenarios carry no capability gate, so the suite does not actually know
32
+ * whether the host advertises fork. Saying `blocked` is the claim the evidence
33
+ * supports.
34
+ */
35
+
36
+ import { softSkip } from './soft-skip.js';
37
+
38
+ /** Statuses that mean "the fork did not happen", with what each one tells us. */
39
+ const DECLINED: ReadonlyMap<number, string> = new Map([
40
+ [404, 'the route is not mounted on this host'],
41
+ [403, 'the caller is not permitted to fork (route present, access refused)'],
42
+ [501, 'the host knows the route and declines this range as not implemented'],
43
+ ]);
44
+
45
+ /**
46
+ * True when `status` means the fork seam did not run — and records WHY for the
47
+ * RFC 0148 §A ledger as a side effect, so a caller can `if (forkDeclined(...))
48
+ * return;` without leaving an unclassified return behind.
49
+ *
50
+ * `where` names the leg, so a bundle reader can tell which of several forks in
51
+ * one file declined.
52
+ */
53
+ export function forkDeclined(status: number, where: string): boolean {
54
+ const why = DECLINED.get(status);
55
+ if (why === undefined) return false;
56
+ softSkip(
57
+ 'blocked',
58
+ `${where}: POST /v1/runs/{runId}:fork returned ${status} — ${why}, so the behaviour this requirement is stated over never occurred and its absence below would prove nothing`,
59
+ );
60
+ return true;
61
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pins the run-end disposition summary (`src/global-setup.ts`, RFC 0148 §A).
3
+ *
4
+ * The defect this guards is not "the summary is ugly" — it is that the suite
5
+ * computed an honest disposition on every run and PUBLISHED it on one. Two
6
+ * host implementers read vitest's `1 passed` as coverage for a requirement the
7
+ * suite had internally classified `blocked`, because the classification had no
8
+ * reader outside `--certify`.
9
+ *
10
+ * Server-free and pure: `summarise` takes the raw JSONL and returns the text.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import { summarise } from '../global-setup.js';
15
+
16
+ const line = (o: Record<string, unknown>): string => JSON.stringify(o);
17
+
18
+ describe('global-setup: RFC 0148 §A disposition summary', () => {
19
+ it('reads the ledger key the ledger actually writes (`requirementId`, not `id`)', () => {
20
+ // Regression: the first draft of this reader keyed on `entry.id`, a field
21
+ // `recordRequirement` has never written, and fell back to `(unnamed)`.
22
+ // The fallback made a WRONG READER look like MISSING DATA — the same
23
+ // failure shape the summary exists to expose, reproduced in the exposer.
24
+ const out = summarise(
25
+ line({ requirementId: 'openwop.scenario.webhook-signed-delivery', disposition: 'blocked', detail: 'guard rejected loopback' }),
26
+ );
27
+ expect(out).toContain('openwop.scenario.webhook-signed-delivery');
28
+ expect(out).not.toContain('(unnamed)');
29
+ });
30
+
31
+ it('lists blocked / skipped / inapplicable as NOT witnessed, with the reason', () => {
32
+ const out = summarise(
33
+ [
34
+ line({ requirementId: 'a', disposition: 'blocked', detail: 'seam absent' }),
35
+ line({ requirementId: 'b', disposition: 'skipped', detail: 'operator opted out' }),
36
+ line({ requirementId: 'c', disposition: 'inapplicable', detail: 'profile not advertised' }),
37
+ ].join('\n'),
38
+ );
39
+ expect(out).toContain('did NOT witness');
40
+ for (const s of ['seam absent', 'operator opted out', 'profile not advertised']) {
41
+ expect(out).toContain(s);
42
+ }
43
+ });
44
+
45
+ it('flags an executed-pass that asserted nothing — the other RFC 0148 §A vacuity shape', () => {
46
+ const out = summarise(line({ requirementId: 'vacuous', disposition: 'executed-pass', assertionCount: 0 }));
47
+ expect(out).toContain('executed-pass (0 assertions)');
48
+ expect(out).toContain('vacuous');
49
+ });
50
+
51
+ it('does NOT flag a real executed-pass', () => {
52
+ const out = summarise(line({ requirementId: 'real', disposition: 'executed-pass', assertionCount: 12 }));
53
+ expect(out).toContain('executed-pass 1');
54
+ expect(out).not.toContain('did NOT witness');
55
+ });
56
+
57
+ it('announces truncation rather than capping silently', () => {
58
+ // A capped list that does not say it was capped reads as a complete one.
59
+ const many = Array.from({ length: 45 }, (_, i) =>
60
+ line({ requirementId: `r${i}`, disposition: 'blocked', detail: 'why' }),
61
+ ).join('\n');
62
+ const out = summarise(many);
63
+ expect(out).toContain('5 more not listed');
64
+ });
65
+
66
+ it('returns null on an empty ledger so teardown prints nothing', () => {
67
+ expect(summarise('')).toBeNull();
68
+ expect(summarise('\n\n')).toBeNull();
69
+ });
70
+
71
+ it('skips unparseable lines without discarding the rest of the run', () => {
72
+ const out = summarise(['{ not json', line({ requirementId: 'ok', disposition: 'blocked', detail: 'r' })].join('\n'));
73
+ expect(out).toContain('ok');
74
+ expect(out).toContain('blocked 1');
75
+ });
76
+ });
@@ -29,6 +29,7 @@
29
29
  import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
30
30
  import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
31
31
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
32
+ import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
32
33
  import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
33
34
 
34
35
  /** All scenario basenames that appear in some profile's runtime floor. */
@@ -112,7 +113,22 @@ export function resolveFileRecord(
112
113
  gateReason: 'inapplicable' | 'skipped' | undefined,
113
114
  assertionCount: number,
114
115
  noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
116
+ specCoherenceFile?: string,
115
117
  ): { disposition: Disposition; detail?: string } {
118
+ // A scenario whose subject is the CORPUS, skipped because the published
119
+ // tarball does not bundle spec/v1/. RFC 0148 §A: `blocked` is defined over
120
+ // ADVERTISED BEHAVIOUR, and there is none here — nothing about the host was
121
+ // ever going to be exercised. `inapplicable` is the honest label, and it is
122
+ // CERTIFIABLE, so these rows stop counting against a host that cannot affect
123
+ // them. See lib/spec-coherence.ts for why not a new disposition value.
124
+ if (
125
+ specCoherenceFile !== undefined
126
+ && SPEC_COHERENCE_SCENARIOS.has(specCoherenceFile)
127
+ && !states.includes('fail')
128
+ && assertionCount === 0
129
+ ) {
130
+ return { disposition: 'inapplicable', detail: SPEC_COHERENCE_DETAIL };
131
+ }
116
132
  let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
117
133
  if (disposition === 'executed-pass' && assertionCount === 0) {
118
134
  if (noted !== null) {