@openwop/openwop-conformance 1.136.11 → 1.139.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.
Files changed (36) hide show
  1. package/README.md +268 -3
  2. package/coverage.md +2 -2
  3. package/dist/cli.js +8 -1
  4. package/dist/lib/requirement-ledger.js +8 -0
  5. package/dist/lib/scenario-disposition.js +30 -0
  6. package/package.json +1 -1
  7. package/schemas/CORPUS-STAMP.json +2 -2
  8. package/schemas/capabilities.schema.json +1 -1
  9. package/schemas/certification-bundle-v2.schema.json +11 -1
  10. package/src/cli.ts +8 -1
  11. package/src/global-setup.ts +169 -0
  12. package/src/lib/fork-availability.test.ts +69 -0
  13. package/src/lib/fork-availability.ts +61 -0
  14. package/src/lib/global-setup.test.ts +76 -0
  15. package/src/lib/polling.test.ts +80 -0
  16. package/src/lib/polling.ts +39 -2
  17. package/src/lib/requirement-ledger.test.ts +75 -0
  18. package/src/lib/requirement-ledger.ts +9 -0
  19. package/src/lib/risk-disposition.test.ts +91 -0
  20. package/src/lib/scenario-disposition.ts +30 -0
  21. package/src/lib/webhook-receiver.test.ts +76 -0
  22. package/src/lib/webhook-receiver.ts +24 -3
  23. package/src/scenarios/a2a-task-roundtrip.test.ts +66 -10
  24. package/src/scenarios/agent-channel-dispatch.test.ts +3 -3
  25. package/src/scenarios/conformance-execution-witness.test.ts +29 -0
  26. package/src/scenarios/cross-host-traceparent-propagation.test.ts +10 -6
  27. package/src/scenarios/durability-poison-exhaustion.test.ts +154 -0
  28. package/src/scenarios/replay-fanout-suppression.test.ts +328 -0
  29. package/src/scenarios/replay-fork-arbitrary.test.ts +9 -3
  30. package/src/scenarios/replay-fork.test.ts +10 -2
  31. package/src/scenarios/replay-side-effect-suppression.test.ts +3 -2
  32. package/src/scenarios/replayDeterminism.test.ts +8 -6
  33. package/src/scenarios/webhook-receiver-adversarial.test.ts +9 -3
  34. package/src/scenarios/webhook-signed-delivery.test.ts +76 -13
  35. package/src/setup.ts +24 -5
  36. package/vitest.config.ts +7 -0
@@ -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
+ });
@@ -17,6 +17,13 @@
17
17
 
18
18
  import { createHmac, timingSafeEqual } from 'node:crypto';
19
19
 
20
+ /**
21
+ * The `X-openwop-Signature` value prefix, per `webhooks.md` §"Delivery headers"
22
+ * (`sha256={hex}`). Distinct from the ALGORITHM header's `v1`, which names the
23
+ * signing scheme, not the encoding — see `verifyWebhookDelivery`.
24
+ */
25
+ export const SIGNATURE_PREFIX = 'sha256=';
26
+
20
27
  export const DEFAULT_FRESHNESS_WINDOW_SECONDS = 300;
21
28
 
22
29
  export type WebhookRejectionReason =
@@ -75,10 +82,24 @@ export function verifyWebhookDelivery(
75
82
  }
76
83
 
77
84
  // 2. Signature header parse.
78
- if (!signatureHeader.startsWith('v1=')) {
85
+ //
86
+ // `sha256=`, NOT `v1=` (corrected 2026-08-19). `webhooks.md` §"Delivery
87
+ // headers" specifies `X-openwop-Signature: sha256={hex}` and its verification
88
+ // recipe says "Strip the `sha256=` prefix". This verifier required `v1=` and
89
+ // rejected the spec's own header as malformed — so the reference verifier a
90
+ // subscriber implementer would copy refused every conforming delivery.
91
+ //
92
+ // The confusion is visible one comment above: `v1` is the value of the
93
+ // ALGORITHM header (`X-openwop-Signature-Algorithm: v1`), a different field.
94
+ // One value, two fields, conflated. It survived because
95
+ // `webhook-receiver-adversarial.test.ts` signs with `signPayload` and verifies
96
+ // with this function — a closed loop that is self-consistent and wrong, and
97
+ // therefore green on every host. Reported by a tier-2 host that could not
98
+ // adjudicate which of the suite's three signature shapes was canonical.
99
+ if (!signatureHeader.startsWith(SIGNATURE_PREFIX)) {
79
100
  return { accepted: false, reason: 'malformed_signature_header' };
80
101
  }
81
- const providedHex = signatureHeader.slice(3);
102
+ const providedHex = signatureHeader.slice(SIGNATURE_PREFIX.length);
82
103
  if (!/^[0-9a-f]+$/i.test(providedHex)) {
83
104
  return { accepted: false, reason: 'malformed_signature_header' };
84
105
  }
@@ -130,7 +151,7 @@ export function signPayload(
130
151
  const bodyStr = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
131
152
  const hex = createHmac('sha256', secret).update(`${timestamp}.${bodyStr}`, 'utf8').digest('hex');
132
153
  return {
133
- signatureHeader: `v1=${hex}`,
154
+ signatureHeader: `${SIGNATURE_PREFIX}${hex}`,
134
155
  timestampHeader: String(timestamp),
135
156
  algorithmHeader: 'v1',
136
157
  };
@@ -47,6 +47,7 @@ import { pollUntilTerminal, pollUntilStatus } from '../lib/polling.js';
47
47
  import { SCHEMAS_DIR } from '../lib/paths.js';
48
48
  import { behaviorGate } from '../lib/behavior-gate.js';
49
49
  import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
50
+ import { seamAbsent } from '../lib/soft-skip.js';
50
51
 
51
52
  /**
52
53
  * Callback-shaped: the host issues A2A JSON-RPC calls to the suite's fake peer.
@@ -403,21 +404,76 @@ describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: durable tasks/get after disconne
403
404
  });
404
405
  });
405
406
 
406
- describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: push-config SSRF (gated on a2a.pushNotifications; RFC 0100)', () => {
407
- it('registering a pushConfig.url at a private address is refused (a2a-push-egress-ssrf)', async () => {
407
+ /**
408
+ * Push-config SSRF, TWO-SIDED (RFC 0100 §4, a2a-integration.md §D.6).
409
+ *
410
+ * The RFC 0093 webhook-egress guard has two arms — `webhooks.md`
411
+ * §"SSRF protection" rejects non-`https://` protocols AND private/loopback/
412
+ * link-local/ULA/metadata addresses — and a host may implement either one
413
+ * alone.
414
+ *
415
+ * Until 2026-08-25 this file probed with a single `http://10.0.0.5/push`,
416
+ * which **violates both arms at once**. Either arm alone refuses it, so a
417
+ * `>= 400` witnessed *that something refused* and never *which guard ran*.
418
+ * A host with only the address arm passed; so did a host with only the
419
+ * scheme arm; so would a host that refused every push URL for an unrelated
420
+ * reason. The assertion was real and the conclusion drawn from it was not.
421
+ *
422
+ * The probes below isolate one arm each: `https` at a private address can
423
+ * only be refused by the address arm, and `http` at a public host can only
424
+ * be refused by the scheme arm. Two legs is the minimum that distinguishes
425
+ * them — the same reason a negative control is not optional.
426
+ *
427
+ * The scheme leg is new, and the obligation it checks was previously stated
428
+ * only by reference (every prior wording abbreviated the guard to its
429
+ * address arm). `COMPATIBILITY.md` §3 records the Class 3 classification: a
430
+ * host accepting a plaintext push target was never conforming. An
431
+ * implementer reddened by this leg is reading a clarification, not a new
432
+ * requirement — the failure message says so.
433
+ */
434
+ describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: push-config SSRF, two-sided (gated on a2a.pushNotifications; RFC 0100)', () => {
435
+ async function registerPush(url: string): Promise<{ status: number } | null> {
408
436
  const a2a = await readCapabilityFamily<{ pushNotifications?: boolean }>('a2a');
409
- if (!behaviorGate('a2a.pushNotifications', a2a?.pushNotifications === true)) return;
437
+ if (!behaviorGate('a2a.pushNotifications', a2a?.pushNotifications === true)) return null;
438
+ const res = await driver.post('/v1/host/sample/a2a/tasks/push-config', { taskId: 'run_x', url });
439
+ if (res.status === 404 || res.status === 403) {
440
+ // Previously a bare `return`, invisible to the RFC 0148 §A ledger because
441
+ // this file's other tests assert — so the file recorded `executed-pass`
442
+ // while this leg had witnessed nothing. Say why instead.
443
+ return seamAbsent(
444
+ 'a2a-push-egress-ssrf — the `/v1/host/sample/a2a/tasks/push-config` seam is not mounted (404/403), so neither guard arm is observable',
445
+ ) ?? null;
446
+ }
447
+ return { status: res.status };
448
+ }
449
+
450
+ it('ADDRESS arm: an https pushConfig.url at a private address is refused', async () => {
451
+ // `https` on purpose: this probe is refusable ONLY by the address arm, so a
452
+ // host that implements the scheme arm alone cannot pass it by accident.
453
+ const res = await registerPush('https://10.0.0.5/push');
454
+ if (res === null) return;
455
+ expect(
456
+ res.status >= 400,
457
+ driver.describe(
458
+ 'a2a-integration.md §D.6 (address arm)',
459
+ 'a2a-push-egress-ssrf — a pushConfig.url at a private/loopback address MUST be refused before any push, even over https',
460
+ ),
461
+ ).toBe(true);
462
+ });
410
463
 
411
- const res = await driver.post('/v1/host/sample/a2a/tasks/push-config', {
412
- taskId: 'run_x',
413
- url: 'http://10.0.0.5/push',
414
- });
415
- if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
464
+ it('SCHEME arm: an http pushConfig.url at a public host is refused', async () => {
465
+ // Public hostname on purpose: refusable ONLY by the scheme arm.
466
+ const res = await registerPush('http://push.example.com/push');
467
+ if (res === null) return;
416
468
  expect(
417
469
  res.status >= 400,
418
470
  driver.describe(
419
- 'a2a-integration.md §"Async / durable Tasks"',
420
- 'a2a-push-egress-ssrf — a caller-supplied pushConfig.url at a private/loopback address MUST be refused before any push',
471
+ 'a2a-integration.md §D.6 (scheme arm)',
472
+ 'a2a-push-egress-ssrf — a plaintext `http://` pushConfig.url MUST be refused before any push. '
473
+ + 'The RFC 0093 webhook-egress guard is the `webhooks.md` §"SSRF protection" list IN FULL, whose first entry is '
474
+ + '"Non-`https://` protocols". Every prior wording of this requirement abbreviated the guard to its address arm; '
475
+ + 'COMPATIBILITY.md §3 records this as a Class 3 clarification, so a host failing here was never conforming rather '
476
+ + 'than newly non-conforming.',
421
477
  ),
422
478
  ).toBe(true);
423
479
  });
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { describe, it, expect } from 'vitest';
40
40
  import { driver } from '../lib/driver.js';
41
+ import { forkDeclined } from '../lib/fork-availability.js';
41
42
  import { pollUntilTerminal } from '../lib/polling.js';
42
43
  import { behaviorGate } from '../lib/behavior-gate.js';
43
44
  import { isFixtureAdvertised } from '../lib/fixtures.js';
@@ -149,8 +150,7 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
149
150
  `/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
150
151
  { fromSeq: 0, mode: 'replay' },
151
152
  );
152
- if (fork1.status === 501) {
153
- // replay advertised but not implemented for this run — skip-equivalent.
153
+ if (forkDeclined(fork1.status, 'channel-dispatch replay fork 1')) {
154
154
  ctx.skip();
155
155
  return;
156
156
  }
@@ -213,7 +213,7 @@ describe.skipIf(HTTP_SKIP)('agent-channel-dispatch (RFC 0082 §B): production ru
213
213
  `/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
214
214
  { fromSeq: 0, mode: 'replay' },
215
215
  );
216
- if (fork2.status === 501) {
216
+ if (forkDeclined(fork2.status, 'channel-dispatch replay fork 2')) {
217
217
  ctx.skip();
218
218
  return;
219
219
  }
@@ -51,6 +51,35 @@ describe('RFC 0148 §A — conformance-execution-witness: the runner record', ()
51
51
  expect(resolveFileRecord(['pass'], undefined, 0, { kind: 'skipped', reason: 'operator opt-out' })).toEqual({ disposition: 'skipped', detail: 'operator opt-out' });
52
52
  });
53
53
 
54
+ it('gap G8 — a file that asserted then soft-skipped keeps the note as a partial-witness detail, and its disposition does NOT move', () => {
55
+ // `return softSkip(...)` produces a PASS state, not a skip, so neither the
56
+ // zero-assertion branch nor the all-skipped branch fires. The note used to be
57
+ // discarded outright and the row read a bare `executed-pass` — for a
58
+ // requirement the run may never have reached, because the assertions came
59
+ // from a positive control or a setup precondition. Measured on this exact
60
+ // function by a tier-1 host writing a scenario whose first leg is a control.
61
+ const one = resolveFileRecord(['pass'], undefined, 1, { kind: 'blocked', reason: 'fork returned 501' });
62
+ expect(one.disposition).toBe('executed-pass');
63
+ expect(one.detail).toBe('partial-witness: blocked: fork returned 501');
64
+
65
+ const many = resolveFileRecord(['pass', 'pass'], undefined, 4, { kind: 'inapplicable', reason: 'fork unsupported' });
66
+ expect(many.disposition).toBe('executed-pass');
67
+ expect(many.detail).toBe('partial-witness: inapplicable: fork unsupported');
68
+
69
+ // The disposition deliberately does not move: honouring a per-FILE note here
70
+ // would downgrade a file that legitimately finished its requirement and also
71
+ // soft-skipped an optional extra leg. The marker makes the rows findable so
72
+ // the per-`it` fix can follow measurement instead of preceding it.
73
+ expect(many.disposition).not.toBe('inapplicable');
74
+
75
+ // A clean pass is untouched — no note, no marker, no detail at all.
76
+ expect(resolveFileRecord(['pass', 'pass'], undefined, 7, null)).toEqual({ disposition: 'executed-pass' });
77
+
78
+ // A FAILING file keeps its failure; the note must not dress a red as a pass.
79
+ const red = resolveFileRecord(['pass', 'fail'], undefined, 3, { kind: 'blocked', reason: 'seam 404' });
80
+ expect(red.disposition).toBe('executed-fail');
81
+ });
82
+
54
83
  it('every test ctx.skip()ped takes the noted reason when one was written before the skip, else stays the blocked marker', () => {
55
84
  // `ctx.skip()` throws — a `softSkip(...)` AFTER it is dead code. Seven files
56
85
  // carried exactly that dead note and reported as unclassified for a suite minor.
@@ -51,17 +51,21 @@ describe('cross-host-traceparent-propagation: behavioral (RFC 0040 §B)', () =>
51
51
  // Until the peer harness lands, the assertion is surfaced as `it.skip` so
52
52
  // test reporters track the gap rather than reporting a vacuous PASS.
53
53
  // Marked out of stable profile via RFC 0042 §B (experimental tier):
54
- // RFC 0040 remains Active. Hosts that wire Phase 3 cross-host causation
55
- // before RFC 0040 graduates SHOULD advertise
54
+ // RFC 0040 is Accepted, but the cross-host behavioral scenario stays
55
+ // experimental until a non-steward host produces the behavioral
56
+ // traceparent evidence — RFC status (Accepted) and conformance-profile
57
+ // tier (experimental) are separate axes per RFC 0042 §B. Hosts that wire
58
+ // Phase 3 cross-host causation SHOULD advertise
56
59
  // `multiAgent.executionModel.tier: 'experimental'` per RFC 0042 §A
57
- // until cross-host evidence drives the promotion. Path-to-runnable
60
+ // until that behavioral evidence lands. Path-to-runnable
58
61
  // requires the MCP peer harness (OPENWOP_MCP_REAL_SERVER_URL) +
59
62
  // inbound-header recorder; flips to a real `it()` on first non-steward
60
63
  // Phase 3 host advertising matching capabilities.
61
64
  it.skip('Phase 3 host MUST inject parent run\'s traceparent into outbound MCP requests — out of stable profile via RFC 0042');
62
65
 
63
- // Same routing — out of stable profile via RFC 0042 §B until RFC 0040
64
- // graduates to Accepted; behavioral A2A test seam contract still to be
65
- // designed alongside the corresponding peer harness.
66
+ // Same routing — out of stable profile via RFC 0042 §B until behavioral
67
+ // A2A cross-host evidence lands (RFC 0040 itself is already Accepted); the
68
+ // A2A test seam contract is still to be designed alongside the
69
+ // corresponding peer harness.
66
70
  it.skip('Phase 3 host MUST inject parent run\'s traceparent into outbound A2A messages — out of stable profile via RFC 0042');
67
71
  });