@openwop/openwop-conformance 1.136.10 → 1.138.1

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/coverage.md CHANGED
@@ -200,7 +200,7 @@ Every OpenAPI operation should have:
200
200
  4. A cited spec section in each assertion message.
201
201
 
202
202
  | Operation ID | Positive coverage | Negative / auth / validation coverage | Gap |
203
- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
203
+ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
204
204
  | `getCapabilities` | `discovery.test.ts`, `runtime-capabilities.test.ts`, `profileDerivation.test.ts`, `mcp-discoverability.test.ts` | `discovery.test.ts` covers optional `Capabilities-Etag`; `spec-corpus-validity.test.ts` validates schema shape | Add scoped discovery scenario when a host advertises it. |
205
205
  | `getOpenApiSpec` | `discovery.test.ts` | `spec-corpus-validity.test.ts` validates OpenAPI refs | Add unavailable/transient error scenario only if host can simulate it. |
206
206
  | `getWorkflow` | `route-coverage.test.ts`; fixture-dependent lifecycle tests indirectly require seeded workflow IDs | `route-coverage.test.ts` covers unknown workflow `404`/`403` envelope | Good. |
package/dist/cli.js CHANGED
@@ -17,7 +17,14 @@
17
17
  * Environment variables override flags (per the conformance harness's
18
18
  * existing convention):
19
19
  * OPENWOP_BASE_URL, OPENWOP_API_KEY, OPENWOP_IMPLEMENTATION_NAME,
20
- * OPENWOP_IMPLEMENTATION_VERSION, OPENWOP_LIFECYCLE_TIMEOUT_MS
20
+ * OPENWOP_IMPLEMENTATION_VERSION, OPENWOP_LIFECYCLE_TIMEOUT_MS,
21
+ * OPENWOP_POLL_TIMEOUT_SCALE, OPENWOP_MAX_WORKERS
22
+ *
23
+ * OPENWOP_LIFECYCLE_TIMEOUT_MS sets the DEFAULT poll bound only — it does
24
+ * not reach the many scenarios that pass an explicit `timeoutMs`.
25
+ * OPENWOP_POLL_TIMEOUT_SCALE (default 1) multiplies EVERY bound and is the
26
+ * knob to reach for when a run's failures are the endpoint (cold start,
27
+ * contention) rather than the host. See src/lib/polling.ts.
21
28
  *
22
29
  * Exit codes:
23
30
  * 0 all scenarios pass
@@ -123,6 +123,14 @@ export function readLedgerFile(path) {
123
123
  * returned early, threw and swallowed, or was never written leaves no entry, and
124
124
  * the honest reading of no entry is "this was not exercised".
125
125
  */
126
+ /**
127
+ * Has this requirement already been recorded in THIS run? Distinguishes "the
128
+ * scenario classified itself" from "nothing has been recorded yet" — which
129
+ * `dispositionOf` cannot, since it folds the absent case to `blocked`.
130
+ */
131
+ export function hasRequirement(requirementId) {
132
+ return ledger.has(requirementId);
133
+ }
126
134
  export function dispositionOf(requirementId) {
127
135
  return ledger.get(requirementId)?.disposition ?? 'blocked';
128
136
  }
@@ -48,6 +48,14 @@ export function requirementIdForFile(basename) {
48
48
  ? requirementIdForScenario(basename)
49
49
  : `openwop.scenario.${basename.replace(/\.test\.ts$/, '')}`;
50
50
  }
51
+ /**
52
+ * Marks an `executed-pass` row whose file ALSO recorded a soft-skip note — the
53
+ * file asserted something, then stopped short (gap G8). Greppable on purpose: a
54
+ * bundle reader filters `disposition === 'executed-pass' && detail?.startsWith(
55
+ * PARTIAL_WITNESS_PREFIX)` to find rows where the requirement may not have been
56
+ * the thing that passed.
57
+ */
58
+ export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
51
59
  /** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
52
60
  * the ONE disposition the file records. */
53
61
  export function fileDisposition(states, gateReason, assertionCount) {
@@ -115,6 +123,28 @@ export function resolveFileRecord(states, gateReason, assertionCount, noted) {
115
123
  disposition = noted.kind;
116
124
  detail = noted.reason;
117
125
  }
126
+ else if (noted !== null && disposition === 'executed-pass') {
127
+ // PARTIAL WITNESS (2026-08-19, gap G8). The file asserted something and then
128
+ // soft-skipped: `return softSkip(...)` yields a PASS state, not a skip, so
129
+ // neither branch above fires and the note used to be discarded outright. The
130
+ // row then read `executed-pass` for a requirement the run may never have
131
+ // reached — e.g. a file asserting a `201` setup precondition before
132
+ // returning `inapplicable`.
133
+ //
134
+ // Same defect as the note-after-`ctx.skip()` case the comment above records;
135
+ // note-after-ASSERTION was the half that stayed. Both hid because nothing
136
+ // goes red.
137
+ //
138
+ // The disposition is deliberately NOT changed. Honouring the note here would
139
+ // downgrade a file that legitimately completed its requirement AND
140
+ // soft-skipped an optional extra leg — trading a false positive for a false
141
+ // negative, on a per-FILE note that cannot say which leg it came from. The
142
+ // durable fix is per-`it` recording; this makes the affected rows
143
+ // self-identifying first, so that change follows measurement instead of
144
+ // preceding it. `detail` is permitted on `executed-pass` (RFC 0148 §A only
145
+ // REQUIRES it for other dispositions), so this is additive on the wire.
146
+ detail = `${PARTIAL_WITNESS_PREFIX}${noted.kind}: ${noted.reason}`;
147
+ }
118
148
  return detail === undefined ? { disposition } : { disposition, detail };
119
149
  }
120
150
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.136.10",
3
+ "version": "1.138.1",
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.136.10",
4
- "corpusCommit": "0d8fd3fde8cca5785c23d4b7e64281f1876ab342"
3
+ "suiteVersion": "1.138.1",
4
+ "corpusCommit": "3555363038ce8ea7e40dc2f2a41c39066228e69c"
5
5
  }
@@ -3495,7 +3495,7 @@
3495
3495
  },
3496
3496
  "idempotency": {
3497
3497
  "type": "object",
3498
- "description": "RFC 0036 \u2014 Multi-region idempotency contract. Optional v1 advertisement. The existing `crossRegion: 'single-region'|'best-effort'|'strict'` categorical claim lives under `capabilities.idempotency.crossRegion` per spec/v1/idempotency.md \u00a7'Multi-region idempotency (annex)'. The `multiRegion` sub-block here gives a granular advertisement that hosts SHOULD pair with the categorical `crossRegion` claim.",
3498
+ "description": "RFC 0036 \u2014 Multi-region idempotency contract. Optional v1 advertisement. The existing `crossRegion: 'single-region'|'reconciled-records'|'fenced-effects'` categorical claim lives under `capabilities.idempotency.crossRegion` (this description named the pre-RFC-0150-\u00a7D vocabulary `'best-effort'|'strict'` until 2026-08-18 \u2014 both values were RETIRED by \u00a7D and the sibling `crossRegion` enum has been correct throughout, so only this prose was wrong; a reader of it would have believed two non-values were advertisable) per spec/v1/idempotency.md \u00a7'Multi-region idempotency (annex)'. The `multiRegion` sub-block here gives a granular advertisement that hosts SHOULD pair with the categorical `crossRegion` claim.",
3499
3499
  "additionalProperties": true,
3500
3500
  "properties": {
3501
3501
  "supported": {
@@ -24,7 +24,17 @@
24
24
  "properties": {
25
25
  "name": { "type": "string", "minLength": 1 },
26
26
  "version": { "type": "string", "minLength": 1 },
27
- "vendor": { "type": "string" }
27
+ "vendor": { "type": "string" },
28
+ "commit": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "description": "OPTIONAL, self-reported source revision the host was built from. NOT sufficient to attribute the bundle: a commit does not determine the artifact unless the build is deterministic — a tier-2 host measured 283/22 vs 303/2 from two builds of one commit whose container image ran `npm install` rather than `npm ci` (conformance-certification.md §\"What a bundle attributes to\")."
32
+ },
33
+ "build": {
34
+ "type": "string",
35
+ "minLength": 1,
36
+ "description": "OPTIONAL, self-reported identifier of what ACTUALLY EXECUTED — this, not `commit`, is what a verifier needs to re-run the same thing. Which identifier is reachable depends on when the bundle is written: a container image digest is strongest but does not exist yet for a bundle baked INTO the image at certify time; a builder-assigned build id is available during the build; a digest of the resolved dependency manifest (lockfile or resolved tree) is available at certify time and pins the closure that actually varied in the measurement behind this field. State which kind it is rather than reaching for the strongest-sounding one. See conformance-certification.md §\"A bundle attributes to a BUILD\"."
37
+ }
28
38
  }
29
39
  },
30
40
  "discovery": {
package/src/cli.ts CHANGED
@@ -17,7 +17,14 @@
17
17
  * Environment variables override flags (per the conformance harness's
18
18
  * existing convention):
19
19
  * OPENWOP_BASE_URL, OPENWOP_API_KEY, OPENWOP_IMPLEMENTATION_NAME,
20
- * OPENWOP_IMPLEMENTATION_VERSION, OPENWOP_LIFECYCLE_TIMEOUT_MS
20
+ * OPENWOP_IMPLEMENTATION_VERSION, OPENWOP_LIFECYCLE_TIMEOUT_MS,
21
+ * OPENWOP_POLL_TIMEOUT_SCALE, OPENWOP_MAX_WORKERS
22
+ *
23
+ * OPENWOP_LIFECYCLE_TIMEOUT_MS sets the DEFAULT poll bound only — it does
24
+ * not reach the many scenarios that pass an explicit `timeoutMs`.
25
+ * OPENWOP_POLL_TIMEOUT_SCALE (default 1) multiplies EVERY bound and is the
26
+ * knob to reach for when a run's failures are the endpoint (cold start,
27
+ * contention) rather than the host. See src/lib/polling.ts.
21
28
  *
22
29
  * Exit codes:
23
30
  * 0 all scenarios pass
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Unit tests for `polling.ts` — the poll-bound scaling knob.
3
+ *
4
+ * These exist because the defect being fixed was a knob that did not reach
5
+ * what its documentation said it reached: `OPENWOP_LIFECYCLE_TIMEOUT_MS` was
6
+ * documented as the way to bound long polls, but it supplies only the DEFAULT,
7
+ * so every call site passing an explicit `timeoutMs` ignored it. Shipping the
8
+ * replacement untested would repeat the defect one layer up, so the scale is
9
+ * asserted here rather than assumed.
10
+ *
11
+ * @see polling.ts
12
+ */
13
+
14
+ import { describe, it, expect, afterEach } from 'vitest';
15
+ import { scaledTimeoutMs } from './polling.js';
16
+
17
+ const KEY = 'OPENWOP_POLL_TIMEOUT_SCALE';
18
+
19
+ function withScale(value: string | undefined, fn: () => void): void {
20
+ if (value === undefined) delete process.env[KEY];
21
+ else process.env[KEY] = value;
22
+ fn();
23
+ }
24
+
25
+ afterEach(() => {
26
+ delete process.env[KEY];
27
+ });
28
+
29
+ describe('polling: scaledTimeoutMs', () => {
30
+ it('is an exact no-op when the knob is unset — no existing measurement moves', () => {
31
+ withScale(undefined, () => {
32
+ for (const ms of [100, 1_000, 5_000, 10_000, 15_000, 30_000, 60_000]) {
33
+ expect(scaledTimeoutMs(ms)).toBe(ms);
34
+ }
35
+ });
36
+ });
37
+
38
+ it('is an exact no-op at scale 1, including the string form', () => {
39
+ withScale('1', () => expect(scaledTimeoutMs(10_000)).toBe(10_000));
40
+ withScale('1.0', () => expect(scaledTimeoutMs(10_000)).toBe(10_000));
41
+ });
42
+
43
+ it('scales explicit bounds, which the documented env var could never reach', () => {
44
+ withScale('3', () => {
45
+ expect(scaledTimeoutMs(10_000)).toBe(30_000);
46
+ expect(scaledTimeoutMs(15_000)).toBe(45_000);
47
+ });
48
+ });
49
+
50
+ it('preserves the ORDERING of deliberately-short bounds — the reason this scales rather than floors', () => {
51
+ withScale('4', () => {
52
+ const short = scaledTimeoutMs(100);
53
+ const long = scaledTimeoutMs(10_000);
54
+ expect(short).toBe(400);
55
+ expect(long).toBe(40_000);
56
+ // A floor would have collapsed these two to the same value, silently
57
+ // rewriting every negative assertion that depends on a short bound.
58
+ expect(short).toBeLessThan(long);
59
+ });
60
+ });
61
+
62
+ it('rounds up, so a fractional scale never shortens a bound', () => {
63
+ withScale('1.5', () => expect(scaledTimeoutMs(1_001)).toBe(1_502));
64
+ withScale('1.0001', () => expect(scaledTimeoutMs(100)).toBeGreaterThanOrEqual(100));
65
+ });
66
+
67
+ it('falls back to 1 for a mis-set knob rather than producing an instant failure', () => {
68
+ // A zero, negative, or unparseable scale would turn every poll into an
69
+ // immediate timeout — which reads as a catastrophic host defect, the exact
70
+ // misattribution this knob exists to prevent.
71
+ for (const bad of ['0', '-2', 'abc', 'NaN', 'Infinity', '', ' ']) {
72
+ withScale(bad, () => expect(scaledTimeoutMs(10_000)).toBe(10_000));
73
+ }
74
+ });
75
+
76
+ it('reads the environment at CALL time, so a harness may set it after import', () => {
77
+ withScale('2', () => expect(scaledTimeoutMs(1_000)).toBe(2_000));
78
+ withScale('5', () => expect(scaledTimeoutMs(1_000)).toBe(5_000));
79
+ });
80
+ });
@@ -6,7 +6,25 @@
6
6
  * Polling is the lowest-common-denominator wire; SSE-specific scenarios
7
7
  * live in stream-modes.test.ts.
8
8
  *
9
- * Bound long polls with OPENWOP_LIFECYCLE_TIMEOUT_MS env var (default 10s).
9
+ * Bound long polls with OPENWOP_LIFECYCLE_TIMEOUT_MS env var (default 10s)
10
+ * but note what that knob can and cannot reach. It supplies the DEFAULT only,
11
+ * so it has no effect on the ~110 call sites that pass an explicit `timeoutMs`
12
+ * (59 of them passing the same `10_000` the default already was). An operator
13
+ * measuring a host on a cold or contended endpoint would set the documented
14
+ * variable, observe no change in those scenarios, and record a failure that
15
+ * measured the environment rather than the host.
16
+ *
17
+ * `OPENWOP_POLL_TIMEOUT_SCALE` (default `1`) closes that: it multiplies EVERY
18
+ * poll bound, explicit or default. Scaling rather than flooring is deliberate —
19
+ * a floor would flatten the deliberately-short bounds (`100`, `1000`) that some
20
+ * negative assertions depend on, while a scale preserves every call site's
21
+ * intent relative to the others. At the default it is a no-op, so no existing
22
+ * measurement moves.
23
+ *
24
+ * Neither knob is a way to make a hanging host pass: the assertion is that a
25
+ * terminal state is REACHED, and a host that never reaches one fails at any
26
+ * bound. What they buy is the ability to say whether a timeout measured the
27
+ * host or the harness.
10
28
  */
11
29
 
12
30
  import { driver } from './driver.js';
@@ -33,6 +51,25 @@ export interface RunSnapshot {
33
51
  const POLL_INTERVAL_MS = 250;
34
52
  const DEFAULT_TIMEOUT_MS = Number(process.env.OPENWOP_LIFECYCLE_TIMEOUT_MS ?? 10_000);
35
53
 
54
+ /**
55
+ * Multiplier applied to every poll bound (see the module docstring). Invalid,
56
+ * non-positive, or non-finite values fall back to `1` rather than silently
57
+ * producing a zero or negative deadline — a mis-set knob must not turn every
58
+ * poll into an instant failure that looks like a host defect.
59
+ */
60
+ function pollTimeoutScale(): number {
61
+ const raw = process.env.OPENWOP_POLL_TIMEOUT_SCALE;
62
+ if (raw === undefined || raw === '') return 1;
63
+ const n = Number(raw);
64
+ return Number.isFinite(n) && n > 0 ? n : 1;
65
+ }
66
+
67
+ /** Apply the scale to a bound, rounding up so a scale of 1 is exactly a no-op. */
68
+ export function scaledTimeoutMs(timeoutMs: number): number {
69
+ const scale = pollTimeoutScale();
70
+ return scale === 1 ? timeoutMs : Math.ceil(timeoutMs * scale);
71
+ }
72
+
36
73
  const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
37
74
 
38
75
  export async function getRun(runId: string): Promise<RunSnapshot> {
@@ -48,7 +85,7 @@ export async function pollUntil(
48
85
  predicate: (snap: RunSnapshot) => boolean,
49
86
  opts: { timeoutMs?: number; label?: string } = {},
50
87
  ): Promise<RunSnapshot> {
51
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
88
+ const timeoutMs = scaledTimeoutMs(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
52
89
  const deadline = Date.now() + timeoutMs;
53
90
  let last: RunSnapshot | null = null;
54
91
  while (Date.now() < deadline) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Unit tests for `requirement-ledger.ts` — recording precedence.
3
+ *
4
+ * These pin the property `setup.ts` relies on when it decides whether to write
5
+ * its automatic file-level record: a scenario that classified ITSELF must win
6
+ * outright. The comment on that line claimed as much for years while the code
7
+ * only delivered it on DISAGREEMENT — a same-disposition re-record reached
8
+ * `ledger.set` and replaced the scenario's own `detail` and `assertionCount`
9
+ * with the file-level ones. Harmless while details were rarely set on a pass;
10
+ * visible the moment `resolveFileRecord` began attaching a `partial-witness:`
11
+ * marker, which would have stamped "may not have witnessed this" over a
12
+ * scenario's own explicit finding.
13
+ *
14
+ * @see requirement-ledger.ts, setup.ts
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach } from 'vitest';
18
+ import {
19
+ recordRequirement,
20
+ hasRequirement,
21
+ entryOf,
22
+ dispositionOf,
23
+ resetLedger,
24
+ } from './requirement-ledger.js';
25
+
26
+ const ID = 'openwop.scenario.ledger-precedence-fixture';
27
+
28
+ beforeEach(() => {
29
+ resetLedger();
30
+ });
31
+
32
+ describe('requirement-ledger: recording precedence', () => {
33
+ it('hasRequirement distinguishes "not recorded" from "recorded", which dispositionOf cannot', () => {
34
+ // `dispositionOf` folds the absent case to `blocked`, so it reads the same
35
+ // for a requirement nobody touched and one deliberately recorded blocked.
36
+ expect(hasRequirement(ID)).toBe(false);
37
+ expect(dispositionOf(ID)).toBe('blocked');
38
+
39
+ recordRequirement(ID, 'blocked', 'seam absent');
40
+ expect(hasRequirement(ID)).toBe(true);
41
+ expect(dispositionOf(ID)).toBe('blocked');
42
+ });
43
+
44
+ it('a same-disposition re-record OVERWRITES detail and assertionCount — the reason setup.ts must guard', () => {
45
+ recordRequirement(ID, 'executed-pass', 'witnessed the MUST NOT on the wire', { assertionCount: 9 });
46
+ expect(entryOf(ID).detail).toBe('witnessed the MUST NOT on the wire');
47
+
48
+ // No throw: `recordRequirement` only rejects a CONFLICTING disposition.
49
+ recordRequirement(ID, 'executed-pass', 'partial-witness: inapplicable: branch leg skipped', { assertionCount: 2 });
50
+ expect(entryOf(ID).detail).toBe('partial-witness: inapplicable: branch leg skipped');
51
+ expect(entryOf(ID).assertionCount).toBe(2);
52
+ });
53
+
54
+ it('a CONFLICTING disposition throws, which is how the explicit record already won on disagreement', () => {
55
+ recordRequirement(ID, 'executed-pass', undefined, { assertionCount: 4 });
56
+ expect(() => recordRequirement(ID, 'blocked', 'file-level fold said blocked')).toThrow(/already recorded/);
57
+ // The first recording survives the rejected second one.
58
+ expect(dispositionOf(ID)).toBe('executed-pass');
59
+ });
60
+
61
+ it('guarding on hasRequirement preserves the explicit record in BOTH directions', () => {
62
+ // This is precisely what setup.ts now does before its automatic write.
63
+ recordRequirement(ID, 'executed-pass', 'witnessed the MUST NOT on the wire', { assertionCount: 9 });
64
+ if (!hasRequirement(ID)) {
65
+ recordRequirement(ID, 'executed-pass', 'partial-witness: inapplicable: branch leg skipped', { assertionCount: 2 });
66
+ }
67
+ expect(entryOf(ID).detail).toBe('witnessed the MUST NOT on the wire');
68
+ expect(entryOf(ID).assertionCount).toBe(9);
69
+ });
70
+
71
+ it('still refuses a non-pass disposition with no reason — an unactionable row', () => {
72
+ expect(() => recordRequirement(ID, 'blocked', ' ')).toThrow(/without a reason/);
73
+ expect(hasRequirement(ID)).toBe(false);
74
+ });
75
+ });
@@ -158,6 +158,15 @@ export function readLedgerFile(path: string): readonly LedgerEntry[] {
158
158
  * returned early, threw and swallowed, or was never written leaves no entry, and
159
159
  * the honest reading of no entry is "this was not exercised".
160
160
  */
161
+ /**
162
+ * Has this requirement already been recorded in THIS run? Distinguishes "the
163
+ * scenario classified itself" from "nothing has been recorded yet" — which
164
+ * `dispositionOf` cannot, since it folds the absent case to `blocked`.
165
+ */
166
+ export function hasRequirement(requirementId: string): boolean {
167
+ return ledger.has(requirementId);
168
+ }
169
+
161
170
  export function dispositionOf(requirementId: string): Disposition {
162
171
  return ledger.get(requirementId)?.disposition ?? 'blocked';
163
172
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Unit tests for the closed/transferred predicate in
3
+ * `scripts/generate-assurance-status.mjs`.
4
+ *
5
+ * This predicate is not cosmetic. Project-wide gates have been keyed to the
6
+ * open-Critical count it produces, so a false closure silently loosens a
7
+ * constraint — and a false open silently keeps one in force.
8
+ *
9
+ * It previously matched the bare substring `closed` anywhere in a row's status
10
+ * cell, which produced two failures in opposite directions:
11
+ *
12
+ * · RFC 0151 R1 ("Compensation executes twice", Critical) reads
13
+ * "Open — ... unwitnessed" and was counted CLOSED from 2026-08-16 onward,
14
+ * because the cell mentions "(G1 closed 2026-08-16)" — a DIFFERENT item's
15
+ * closure. A substring of an adjacent concept.
16
+ * · A row stating that a risk "cannot be closed by repository work" was
17
+ * counted closed by saying so.
18
+ *
19
+ * The predicate is duplicated here rather than imported because the generator is
20
+ * a standalone ESM script with no exports; the duplication is pinned by
21
+ * `matches the generator's source` below, which fails if the two drift.
22
+ *
23
+ * @see scripts/generate-assurance-status.mjs
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { readFileSync, existsSync } from 'node:fs';
28
+ import { join, dirname } from 'node:path';
29
+ import { V1_DIR } from './paths.js';
30
+
31
+ const NEGATED = /\b(cannot|can ?not|could not|will not|never|not)\s+be\s+(closed|resolved)\b|\bnot closed\b/i;
32
+ const EXPLICIT = /\*\*(CLOSED|Closed)\b|~~|Realised and remediated/i;
33
+ const TRANSFERRED = /\*\*(?:OPEN\s+—\s+)?TRANSFERRED\b/i;
34
+
35
+ function disposition(status: string): 'closed' | 'transferred' | 'open' {
36
+ const closed = EXPLICIT.test(status) && !NEGATED.test(status);
37
+ if (closed) return 'closed';
38
+ return TRANSFERRED.test(status) ? 'transferred' : 'open';
39
+ }
40
+
41
+ describe('assurance risk disposition', () => {
42
+ it('does NOT read a nested reference to another item as this row being closed', () => {
43
+ // The exact RFC 0151 R1 shape. "Open", "unwitnessed", and a parenthetical
44
+ // about gap G1 closing — a different thing entirely.
45
+ const status =
46
+ 'Open — **Sweep 2026-08-16:** **Mitigated in prose** — inverse-action identity tuple stated; ' +
47
+ '`compensation.md` §C now states the persistence shape (G1 closed 2026-08-16); unwitnessed for retry-stability';
48
+ expect(disposition(status)).toBe('open');
49
+ });
50
+
51
+ it('does NOT read a row that says a risk cannot be closed as closed', () => {
52
+ expect(disposition('**OPEN — TRANSFERRED.** This risk cannot be closed by repository work.')).not.toBe('closed');
53
+ });
54
+
55
+ it('reads an explicit marker as closed', () => {
56
+ expect(disposition('**CLOSED.** The recurrence mechanism is in the tree and executing.')).toBe('closed');
57
+ expect(disposition('~~superseded~~ — folded into RFC 0150 §D')).toBe('closed');
58
+ expect(disposition('**Realised and remediated in scope:** bundle 1 invalidated')).toBe('closed');
59
+ });
60
+
61
+ it('distinguishes transferred from open, because §A.1 turns on the difference', () => {
62
+ expect(disposition('**OPEN — TRANSFERRED to a named tracked surface.** Tracked in KNOWN-LIMITS.')).toBe('transferred');
63
+ expect(disposition('Open — unwitnessed. No host advertises the capability.')).toBe('open');
64
+ });
65
+
66
+ it('is not fooled by the word appearing in ordinary prose', () => {
67
+ expect(disposition('Open — the comment window closed without review.')).toBe('open');
68
+ expect(disposition('Open — closes when a host implements fencing.')).toBe('open');
69
+ });
70
+
71
+ it('matches the generator source — fails if the two drift apart', () => {
72
+ // `V1_DIR` is null in the PUBLISHED package layout, which ships no `spec/`
73
+ // and no `scripts/`. Resolving the path at module scope — or casting the
74
+ // null away — is what made six scenarios throw at import for every npm
75
+ // consumer while staying green in a repo checkout. Resolve inside the test
76
+ // and skip when the repo is not there.
77
+ if (V1_DIR === null) return;
78
+ const script = join(dirname(V1_DIR), '..', 'scripts', 'generate-assurance-status.mjs');
79
+ if (!existsSync(script)) return;
80
+ const src = readFileSync(script, 'utf8');
81
+ // Guard the shape, not the byte-for-byte text: the generator must still gate
82
+ // on an explicit marker AND a negation check, never on a bare substring.
83
+ expect(src, 'generator must keep the negation guard').toMatch(/const negated = /);
84
+ expect(src, 'generator must require an explicit closed marker').toMatch(/const explicitlyClosed =/);
85
+ expect(src, 'generator must still separate transferred rows').toMatch(/const transferred = /);
86
+ expect(
87
+ /const closed = \/\(\^\|\\s\)\(closed\|resolved/.test(src),
88
+ 'the bare-substring test must not come back',
89
+ ).toBe(false);
90
+ });
91
+ });
@@ -49,6 +49,15 @@ export function requirementIdForFile(basename: string): string {
49
49
  : `openwop.scenario.${basename.replace(/\.test\.ts$/, '')}`;
50
50
  }
51
51
 
52
+ /**
53
+ * Marks an `executed-pass` row whose file ALSO recorded a soft-skip note — the
54
+ * file asserted something, then stopped short (gap G8). Greppable on purpose: a
55
+ * bundle reader filters `disposition === 'executed-pass' && detail?.startsWith(
56
+ * PARTIAL_WITNESS_PREFIX)` to find rows where the requirement may not have been
57
+ * the thing that passed.
58
+ */
59
+ export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
60
+
52
61
  export type FileTestState = 'pass' | 'fail' | 'skip';
53
62
 
54
63
  /** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
@@ -125,6 +134,27 @@ export function resolveFileRecord(
125
134
  // dead code — which is how seven files carried notes the ledger never saw.
126
135
  disposition = noted.kind;
127
136
  detail = noted.reason;
137
+ } else if (noted !== null && disposition === 'executed-pass') {
138
+ // PARTIAL WITNESS (2026-08-19, gap G8). The file asserted something and then
139
+ // soft-skipped: `return softSkip(...)` yields a PASS state, not a skip, so
140
+ // neither branch above fires and the note used to be discarded outright. The
141
+ // row then read `executed-pass` for a requirement the run may never have
142
+ // reached — e.g. a file asserting a `201` setup precondition before
143
+ // returning `inapplicable`.
144
+ //
145
+ // Same defect as the note-after-`ctx.skip()` case the comment above records;
146
+ // note-after-ASSERTION was the half that stayed. Both hid because nothing
147
+ // goes red.
148
+ //
149
+ // The disposition is deliberately NOT changed. Honouring the note here would
150
+ // downgrade a file that legitimately completed its requirement AND
151
+ // soft-skipped an optional extra leg — trading a false positive for a false
152
+ // negative, on a per-FILE note that cannot say which leg it came from. The
153
+ // durable fix is per-`it` recording; this makes the affected rows
154
+ // self-identifying first, so that change follows measurement instead of
155
+ // preceding it. `detail` is permitted on `executed-pass` (RFC 0148 §A only
156
+ // REQUIRES it for other dispositions), so this is additive on the wire.
157
+ detail = `${PARTIAL_WITNESS_PREFIX}${noted.kind}: ${noted.reason}`;
128
158
  }
129
159
  return detail === undefined ? { disposition } : { disposition, detail };
130
160
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Unit tests for `webhook-receiver.ts` — the signature-header contract.
3
+ *
4
+ * This verifier is the reference a subscriber implementer copies. It required
5
+ * `v1=` and therefore rejected, as malformed, the exact header `webhooks.md`
6
+ * §"Delivery headers" mandates (`X-openwop-Signature: sha256={hex}`). The
7
+ * divergence survived because `webhook-receiver-adversarial.test.ts` signs with
8
+ * `signPayload` and verifies with `verifyWebhookDelivery` — a closed loop that is
9
+ * self-consistent and wrong, and so green against every host. These cases pin the
10
+ * header against the SPEC rather than against the suite's own output.
11
+ *
12
+ * @see webhook-receiver.ts, spec/v1/webhooks.md §"Delivery headers"
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import { createHmac } from 'node:crypto';
17
+ import {
18
+ SIGNATURE_PREFIX,
19
+ createReceiverState,
20
+ verifyWebhookDelivery,
21
+ signPayload,
22
+ } from './webhook-receiver.js';
23
+
24
+ const SECRET = 'shhh-not-a-real-secret';
25
+ const BODY = JSON.stringify({ event: { type: 'run.completed' } });
26
+
27
+ /** Build the header exactly as `webhooks.md` documents it, not as we emit it. */
28
+ function specShapedHeader(ts: number): string {
29
+ const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
30
+ return `sha256=${hex}`;
31
+ }
32
+
33
+ describe('webhook-receiver: the X-openwop-Signature prefix follows the spec', () => {
34
+ it('the constant is the spec value, not the algorithm-header value', () => {
35
+ // `v1` names the SIGNING SCHEME (X-openwop-Signature-Algorithm). It is not
36
+ // the encoding prefix. One value, two fields — the conflation this fixes.
37
+ expect(SIGNATURE_PREFIX).toBe('sha256=');
38
+ });
39
+
40
+ it('ACCEPTS a header built to the spec by hand, with no help from signPayload', () => {
41
+ const ts = Math.floor(Date.now() / 1000);
42
+ const result = verifyWebhookDelivery(
43
+ SECRET,
44
+ specShapedHeader(ts),
45
+ 'v1',
46
+ String(ts),
47
+ BODY,
48
+ createReceiverState(),
49
+ );
50
+ expect(result.accepted).toBe(true);
51
+ });
52
+
53
+ it('REJECTS the pre-2026-08-19 `v1=` prefix as malformed — the shape the spec never defined', () => {
54
+ const ts = Math.floor(Date.now() / 1000);
55
+ const hex = createHmac('sha256', SECRET).update(`${ts}.${BODY}`).digest('hex');
56
+ const result = verifyWebhookDelivery(
57
+ SECRET,
58
+ `v1=${hex}`,
59
+ 'v1',
60
+ String(ts),
61
+ BODY,
62
+ createReceiverState(),
63
+ );
64
+ expect(result.accepted).toBe(false);
65
+ if (!result.accepted) expect(result.reason).toBe('malformed_signature_header');
66
+ });
67
+
68
+ it('signPayload emits what the verifier accepts AND what the spec documents', () => {
69
+ const ts = Math.floor(Date.now() / 1000);
70
+ const { signatureHeader, algorithmHeader } = signPayload(SECRET, ts, BODY);
71
+ // Both halves matter: agreeing with the verifier alone is the closed loop
72
+ // that hid the bug, so this also compares against the hand-built header.
73
+ expect(signatureHeader).toBe(specShapedHeader(ts));
74
+ expect(algorithmHeader).toBe('v1');
75
+ });
76
+ });