@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 +1 -1
- package/coverage.md +1 -1
- package/dist/lib/scenario-disposition.js +14 -1
- package/dist/lib/spec-coherence.js +103 -0
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/src/global-setup.ts +169 -0
- package/src/lib/fork-availability.test.ts +69 -0
- package/src/lib/fork-availability.ts +61 -0
- package/src/lib/global-setup.test.ts +76 -0
- package/src/lib/scenario-disposition.ts +16 -0
- package/src/lib/spec-coherence-registry.test.ts +100 -0
- package/src/lib/spec-coherence.ts +106 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +66 -10
- package/src/scenarios/agent-channel-dispatch.test.ts +3 -3
- package/src/scenarios/replay-fanout-suppression.test.ts +39 -6
- package/src/scenarios/replay-fork-arbitrary.test.ts +9 -3
- package/src/scenarios/replay-fork.test.ts +10 -2
- package/src/scenarios/replay-side-effect-suppression.test.ts +3 -2
- package/src/scenarios/replayDeterminism.test.ts +8 -6
- package/src/scenarios/webhook-signed-delivery.test.ts +53 -7
- package/src/setup.ts +1 -1
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps `SPEC_COHERENCE_SCENARIOS` honest by re-deriving it from source.
|
|
3
|
+
*
|
|
4
|
+
* A hand-maintained list of filenames is a claim that decays silently: a new
|
|
5
|
+
* spec-coherence scenario lands and reports `blocked` in every host's bundle
|
|
6
|
+
* forever, or one grows a `driver` call and starts telling hosts a requirement
|
|
7
|
+
* about their own behaviour does not apply to them. Neither shows up as a
|
|
8
|
+
* failure anywhere — which is the whole reason the original defect survived.
|
|
9
|
+
*
|
|
10
|
+
* The membership rule is mechanical, so the check can be too:
|
|
11
|
+
* gates on `V1_DIR === null` AND never calls `driver.get/post/delete`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
18
|
+
import { resolveFileRecord } from './scenario-disposition.js';
|
|
19
|
+
|
|
20
|
+
const SCENARIOS = new URL('../scenarios/', import.meta.url).pathname;
|
|
21
|
+
|
|
22
|
+
function derive(): { pure: string[]; hostTouching: string[] } {
|
|
23
|
+
const pure: string[] = [];
|
|
24
|
+
const hostTouching: string[] = [];
|
|
25
|
+
for (const f of readdirSync(SCENARIOS)) {
|
|
26
|
+
if (!f.endsWith('.test.ts')) continue;
|
|
27
|
+
const src = readFileSync(join(SCENARIOS, f), 'utf8');
|
|
28
|
+
if (!/V1_DIR\s*===?\s*null/.test(src)) continue;
|
|
29
|
+
(/\bdriver\.(get|post|delete)\b/.test(src) ? hostTouching : pure).push(f);
|
|
30
|
+
}
|
|
31
|
+
return { pure: pure.sort(), hostTouching: hostTouching.sort() };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('SPEC_COHERENCE_SCENARIOS is derivable, not asserted', () => {
|
|
35
|
+
it('matches every scenario that reads spec/v1 and never drives a host', () => {
|
|
36
|
+
const { pure } = derive();
|
|
37
|
+
const listed = [...SPEC_COHERENCE_SCENARIOS].sort();
|
|
38
|
+
// Named diffs rather than a bare inequality: a failure here should say
|
|
39
|
+
// which file to add or drop, not that two sets differ.
|
|
40
|
+
expect(pure.filter((f) => !SPEC_COHERENCE_SCENARIOS.has(f)), 'reads spec/v1, drives no host, NOT in the registry — it will report `blocked` in every host bundle').toEqual([]);
|
|
41
|
+
expect(listed.filter((f) => !pure.includes(f)), 'in the registry but no longer qualifies — it now drives a host, or stopped reading spec/v1').toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('excludes the host-touching ones, which are honestly `blocked`', () => {
|
|
45
|
+
// These assert ADVERTISED behaviour that a missing dependency prevented
|
|
46
|
+
// exercising — RFC 0148 §A's definition of `blocked`, verbatim. Calling
|
|
47
|
+
// them `inapplicable` would tell a host a requirement about its own
|
|
48
|
+
// behaviour does not apply to it.
|
|
49
|
+
const { hostTouching } = derive();
|
|
50
|
+
expect(hostTouching.length, 'expected some V1_DIR-gated scenarios to also drive the host').toBeGreaterThan(0);
|
|
51
|
+
for (const f of hostTouching) {
|
|
52
|
+
expect(SPEC_COHERENCE_SCENARIOS.has(f), `${f} drives a host and must NOT be classified inapplicable`).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('the registry is non-empty — an empty set would silently disable the fix', () => {
|
|
57
|
+
expect(SPEC_COHERENCE_SCENARIOS.size).toBeGreaterThan(20);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('resolveFileRecord classifies a corpus scenario as inapplicable, not blocked', () => {
|
|
62
|
+
// A published-layout run: describe.skipIf fires at COLLECTION, so vitest
|
|
63
|
+
// reports the file's tests as skipped, nothing notes a reason, and before
|
|
64
|
+
// this change resolveFileRecord returned `blocked` with the unclassified
|
|
65
|
+
// marker — the row a host operator could not tell from a real gap.
|
|
66
|
+
const CORPUS = 'protocol-version-grammar.test.ts';
|
|
67
|
+
|
|
68
|
+
it('a corpus scenario that never ran is inapplicable, with a reason aimed at the host operator', () => {
|
|
69
|
+
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, CORPUS);
|
|
70
|
+
expect(r.disposition).toBe('inapplicable');
|
|
71
|
+
expect(r.detail).toBe(SPEC_COHERENCE_DETAIL);
|
|
72
|
+
expect(r.detail).toContain('asserts nothing about a host');
|
|
73
|
+
expect(r.detail).toContain('OPENWOP_CONFORMANCE_ROOT');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('WITHOUT the registry it would still be blocked — the branch is what changes it', () => {
|
|
77
|
+
// Same inputs, filename withheld: the pre-change behaviour. This is the
|
|
78
|
+
// negative control; if it ever returns `inapplicable`, the branch is not
|
|
79
|
+
// what is doing the work and the test above proves nothing.
|
|
80
|
+
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null);
|
|
81
|
+
expect(r.disposition).toBe('blocked');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a non-corpus scenario is untouched', () => {
|
|
85
|
+
const r = resolveFileRecord(['skip', 'skip'], undefined, 0, null, 'webhook-signed-delivery.test.ts');
|
|
86
|
+
expect(r.disposition).toBe('blocked');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('a corpus scenario that FAILED is never laundered into inapplicable', () => {
|
|
90
|
+
// The guard that matters: if the corpus IS present and an assertion fails,
|
|
91
|
+
// that is a real spec-coherence defect and must stay executed-fail.
|
|
92
|
+
const r = resolveFileRecord(['pass', 'fail'], undefined, 12, null, CORPUS);
|
|
93
|
+
expect(r.disposition).toBe('executed-fail');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('a corpus scenario that RAN and passed stays executed-pass', () => {
|
|
97
|
+
const r = resolveFileRecord(['pass'], undefined, 40, null, CORPUS);
|
|
98
|
+
expect(r.disposition).toBe('executed-pass');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
|
|
70
|
+
/** Scenarios whose subject is the corpus. Kept honest by `spec-coherence-registry.test.ts`. */
|
|
71
|
+
export const SPEC_COHERENCE_SCENARIOS: ReadonlySet<string> = new Set([
|
|
72
|
+
'artifact-schema-compile-bounded.test.ts',
|
|
73
|
+
'artifact-type-legacy-ids.test.ts',
|
|
74
|
+
'capability-example-root-layout.test.ts',
|
|
75
|
+
'certification-floor-enforcement.test.ts',
|
|
76
|
+
'chain-subchain-unsupported-refused.test.ts',
|
|
77
|
+
'compensation-profile.test.ts',
|
|
78
|
+
'core-manifest-and-extension-registry.test.ts',
|
|
79
|
+
'discovery-canonical-family-no-shadow.test.ts',
|
|
80
|
+
'edge-condition-truthy-falsy.test.ts',
|
|
81
|
+
'effect-identity-composition.test.ts',
|
|
82
|
+
'effect-identity-cross-scope.test.ts',
|
|
83
|
+
'error-envelope-canonical-shape.test.ts',
|
|
84
|
+
'form-content-packs.test.ts',
|
|
85
|
+
'multi-region-effect-vocabulary.test.ts',
|
|
86
|
+
'normative-example-extraction.test.ts',
|
|
87
|
+
'openapi-asyncapi-sdk-parity.test.ts',
|
|
88
|
+
'pack-manifest-extensions.test.ts',
|
|
89
|
+
'protocol-version-grammar.test.ts',
|
|
90
|
+
'registry-declarative-kinds.test.ts',
|
|
91
|
+
'rfc-0147-self-audit.test.ts',
|
|
92
|
+
'rfc-lifecycle-coherence.test.ts',
|
|
93
|
+
'semantic-digest-v2.test.ts',
|
|
94
|
+
'spec-corpus-validity.test.ts',
|
|
95
|
+
'spec-section-citations.test.ts',
|
|
96
|
+
'tool-result-trust-monotone.test.ts',
|
|
97
|
+
'versioned-composition-profiles.test.ts',
|
|
98
|
+
'workflow-chain-internal-flag.test.ts',
|
|
99
|
+
'workload-identity-profile.test.ts',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/** The reason recorded on such a row, written for the host operator reading it. */
|
|
103
|
+
export const SPEC_COHERENCE_DETAIL =
|
|
104
|
+
'inapplicable to any host: this scenario reads spec/v1/ to check the SPEC corpus is internally coherent and asserts nothing about a host. '
|
|
105
|
+
+ 'The published tarball does not bundle spec/v1/ (see lib/paths.ts), so it does not run here. '
|
|
106
|
+
+ 'Set OPENWOP_CONFORMANCE_ROOT to a spec checkout to run it; it needs no host.';
|
|
@@ -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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 §
|
|
420
|
-
'a2a-push-egress-ssrf — a
|
|
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
|
|
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
|
|
216
|
+
if (forkDeclined(fork2.status, 'channel-dispatch replay fork 2')) {
|
|
217
217
|
ctx.skip();
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
@@ -72,7 +72,8 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|
|
72
72
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
73
73
|
import { softSkip } from '../lib/soft-skip.js';
|
|
74
74
|
import { driver } from '../lib/driver.js';
|
|
75
|
-
import {
|
|
75
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
76
|
+
import { discoveryFamilies, readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
76
77
|
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
77
78
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
78
79
|
import { discoverOwnedTenant } from '../lib/webhook-receiver.js';
|
|
@@ -213,18 +214,50 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
213
214
|
),
|
|
214
215
|
).toBeGreaterThan(0);
|
|
215
216
|
|
|
217
|
+
// ── CAPABILITY GATE (added 2026-08-25) ──────────────────────────────────
|
|
218
|
+
// This is the ONLY scenario in the replay family that never checked whether
|
|
219
|
+
// the host advertises replay before forking — every sibling reads
|
|
220
|
+
// `replay.supported` first. That omission is why it was the one scenario
|
|
221
|
+
// reaching the fork seam on a host that does not implement it, and why it
|
|
222
|
+
// hard-failed `expected 404 to be 201` on every CI run of `main` while the
|
|
223
|
+
// siblings quietly returned at their capability check.
|
|
224
|
+
//
|
|
225
|
+
// `inapplicable`, not `blocked`: a host that does not advertise replay is
|
|
226
|
+
// outside this MUST NOT's scope entirely, and `blocked` would claim the
|
|
227
|
+
// requirement applies but could not be witnessed — a stronger claim than
|
|
228
|
+
// the evidence supports. Recorded explicitly because LEG 1 above asserted,
|
|
229
|
+
// so the file-level disposition would otherwise be `executed-pass`.
|
|
230
|
+
const replayCap = await readCapabilityFamily<{ supported?: boolean; modes?: unknown }>('replay');
|
|
231
|
+
if (replayCap?.supported !== true) {
|
|
232
|
+
recordRequirement(
|
|
233
|
+
REQUIREMENT_ID,
|
|
234
|
+
'inapplicable',
|
|
235
|
+
'host does not advertise `replay.supported: true`, so a replay fork cannot occur and this MUST NOT '
|
|
236
|
+
+ 'has nothing to constrain on this host',
|
|
237
|
+
);
|
|
238
|
+
ctx.skip();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
216
242
|
// ── LEG 2 — THE MUST NOT. A replay fork re-emits; it must not deliver. ───
|
|
217
243
|
const replay = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
218
244
|
mode: 'replay',
|
|
219
245
|
});
|
|
220
|
-
if (replay.status
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
246
|
+
if (forkDeclined(replay.status, 'fanout-suppression replay fork')) {
|
|
247
|
+
// Leg 1 already asserted, so the FILE is `executed-pass` — but the MUST
|
|
248
|
+
// NOT was never exercised, and that is what this row must say. The
|
|
249
|
+
// explicit record wins over the file-level one (setup.ts).
|
|
250
|
+
//
|
|
251
|
+
// 404 and 403 were NOT handled here until 2026-08-25, only 501. The
|
|
252
|
+
// postgres reference host 404s this route, so this was the one scenario
|
|
253
|
+
// in the replay family that actually reached the seam — and it hard-
|
|
254
|
+
// failed `expected 404 to be 201` on every CI run of `main`, absorbed by
|
|
255
|
+
// the 85% pass-rate floor. The suite required a host to implement the
|
|
256
|
+
// route in order to say it had not implemented the route.
|
|
224
257
|
recordRequirement(
|
|
225
258
|
REQUIREMENT_ID,
|
|
226
259
|
'blocked',
|
|
227
|
-
|
|
260
|
+
`replay fork returned ${replay.status} — the re-emission this requirement is stated over never happened, `
|
|
228
261
|
+ 'so the absence of deliveries below would prove nothing',
|
|
229
262
|
);
|
|
230
263
|
ctx.skip();
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { describe, it, expect } from 'vitest';
|
|
32
32
|
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
33
35
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
34
36
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
35
37
|
|
|
@@ -122,6 +124,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
122
124
|
it('mid-fromSeq replay fork produces a new run that reaches `completed`', async (ctx) => {
|
|
123
125
|
const replay = await fetchReplayCapability();
|
|
124
126
|
if (replay?.supported !== true) {
|
|
127
|
+
softSkip('inapplicable', "host does not advertise `replay.supported: true` — the replay contract does not apply to it");
|
|
125
128
|
ctx.skip();
|
|
126
129
|
return;
|
|
127
130
|
}
|
|
@@ -129,6 +132,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
129
132
|
? replay.modes.filter((m): m is string => typeof m === 'string')
|
|
130
133
|
: [];
|
|
131
134
|
if (!modes.includes('replay')) {
|
|
135
|
+
softSkip('inapplicable', "host does not advertise the `replay` fork mode — this leg's rule has no path to apply");
|
|
132
136
|
ctx.skip();
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
@@ -140,6 +144,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
140
144
|
// Fixture's wire shape doesn't expose node.completed(b) with a
|
|
141
145
|
// numeric sequence — skip rather than fail. Conformant hosts
|
|
142
146
|
// with the standard event shape will hit the assertions below.
|
|
147
|
+
softSkip('blocked', "the advertised fixture's wire shape exposes no numeric sequence for node.completed(b), so there is no mid-run point to fork from");
|
|
143
148
|
ctx.skip();
|
|
144
149
|
return;
|
|
145
150
|
}
|
|
@@ -149,7 +154,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
149
154
|
{ fromSeq, mode: 'replay' },
|
|
150
155
|
);
|
|
151
156
|
|
|
152
|
-
if (fork.status
|
|
157
|
+
if (forkDeclined(fork.status, 'arbitrary-event fork')) {
|
|
153
158
|
ctx.skip();
|
|
154
159
|
return;
|
|
155
160
|
}
|
|
@@ -226,7 +231,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
226
231
|
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
227
232
|
{ fromSeq, mode: 'replay' },
|
|
228
233
|
);
|
|
229
|
-
if (fork1.status
|
|
234
|
+
if (forkDeclined(fork1.status, 'arbitrary-event fork 1')) {
|
|
230
235
|
ctx.skip();
|
|
231
236
|
return;
|
|
232
237
|
}
|
|
@@ -238,7 +243,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
238
243
|
`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
|
|
239
244
|
{ fromSeq, mode: 'replay' },
|
|
240
245
|
);
|
|
241
|
-
if (fork2.status
|
|
246
|
+
if (forkDeclined(fork2.status, 'arbitrary-event fork 2')) {
|
|
242
247
|
ctx.skip();
|
|
243
248
|
return;
|
|
244
249
|
}
|
|
@@ -294,6 +299,7 @@ describe.skipIf(SKIP_NO_MULTI)(
|
|
|
294
299
|
it('mid-fromSeq branch fork with empty overlay produces a new run that reaches `completed`', async (ctx) => {
|
|
295
300
|
const replay = await fetchReplayCapability();
|
|
296
301
|
if (replay?.supported !== true) {
|
|
302
|
+
softSkip('inapplicable', "host does not advertise `replay.supported: true` — the replay contract does not apply to it");
|
|
297
303
|
ctx.skip();
|
|
298
304
|
return;
|
|
299
305
|
}
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import { describe, it, expect } from 'vitest';
|
|
27
27
|
import { driver } from '../lib/driver.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
28
30
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
29
31
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
30
32
|
|
|
@@ -60,6 +62,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in replay mode', () =
|
|
|
60
62
|
// Visible skip — earlier this was a silent `return` that
|
|
61
63
|
// collapsed to a vacuous pass and made it impossible to tell
|
|
62
64
|
// unexercised tests apart from honest passes.
|
|
65
|
+
softSkip('inapplicable', "host does not advertise the `replay` fork mode — this leg's rule has no path to apply");
|
|
63
66
|
ctx.skip();
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
@@ -70,7 +73,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in replay mode', () =
|
|
|
70
73
|
{ fromSeq: 0, mode: 'replay' },
|
|
71
74
|
);
|
|
72
75
|
|
|
73
|
-
if (fork.status
|
|
76
|
+
if (forkDeclined(fork.status, 'replay fork')) return;
|
|
74
77
|
expect(fork.status, driver.describe(
|
|
75
78
|
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
76
79
|
'fork MUST return 201 on accepted replay',
|
|
@@ -101,6 +104,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in branch mode with e
|
|
|
101
104
|
it('produces a new run that reaches terminal `completed`', async (ctx) => {
|
|
102
105
|
const modes = await fetchReplayModes();
|
|
103
106
|
if (!modes.includes('branch')) {
|
|
107
|
+
softSkip('inapplicable', "host does not advertise the `branch` fork mode — this leg's rule has no path to apply");
|
|
104
108
|
ctx.skip();
|
|
105
109
|
return;
|
|
106
110
|
}
|
|
@@ -111,7 +115,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: fork from fromSeq=0 in branch mode with e
|
|
|
111
115
|
{ fromSeq: 0, mode: 'branch', runOptionsOverlay: {} },
|
|
112
116
|
);
|
|
113
117
|
|
|
114
|
-
if (fork.status
|
|
118
|
+
if (forkDeclined(fork.status, 'branch fork')) return;
|
|
115
119
|
expect(fork.status, driver.describe(
|
|
116
120
|
'rest-endpoints.md POST /v1/runs/{runId}:fork',
|
|
117
121
|
'branch fork MUST return 201',
|
|
@@ -137,6 +141,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
137
141
|
it('rejects negative fromSeq with 400', async (ctx) => {
|
|
138
142
|
const modes = await fetchReplayModes();
|
|
139
143
|
if (modes.length === 0) {
|
|
144
|
+
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
140
145
|
ctx.skip();
|
|
141
146
|
return;
|
|
142
147
|
}
|
|
@@ -155,6 +160,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
155
160
|
it('rejects fromSeq beyond source event log length with 422', async (ctx) => {
|
|
156
161
|
const modes = await fetchReplayModes();
|
|
157
162
|
if (modes.length === 0) {
|
|
163
|
+
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
158
164
|
ctx.skip();
|
|
159
165
|
return;
|
|
160
166
|
}
|
|
@@ -179,6 +185,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
179
185
|
// → 400) only applies on hosts that advertise the `replay` mode.
|
|
180
186
|
// A `branch`-only host has no path to even attempt the request.
|
|
181
187
|
// Visible skip rather than silent vacuous pass.
|
|
188
|
+
softSkip('inapplicable', "host does not advertise the `replay` fork mode — the runOptionsOverlay rejection rule only applies to hosts that do");
|
|
182
189
|
ctx.skip();
|
|
183
190
|
return;
|
|
184
191
|
}
|
|
@@ -200,6 +207,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay: validation errors', () => {
|
|
|
200
207
|
it('rejects fork on a non-existent run with 404', async (ctx) => {
|
|
201
208
|
const modes = await fetchReplayModes();
|
|
202
209
|
if (modes.length === 0) {
|
|
210
|
+
softSkip('inapplicable', "host advertises no usable fork mode for this leg");
|
|
203
211
|
ctx.skip();
|
|
204
212
|
return;
|
|
205
213
|
}
|
|
@@ -48,6 +48,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
48
48
|
import { recordRequirement } from '../lib/requirement-ledger.js';
|
|
49
49
|
import { requirementIdForScenario } from '../lib/requirement-registry.js';
|
|
50
50
|
import { driver } from '../lib/driver.js';
|
|
51
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
51
52
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
52
53
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
53
54
|
import { readFileSync } from 'node:fs';
|
|
@@ -173,8 +174,8 @@ describe.skipIf(SKIP_NO_FIXTURE)('replay-side-effect-suppression: a replay does
|
|
|
173
174
|
const fork = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, {
|
|
174
175
|
mode: 'replay',
|
|
175
176
|
});
|
|
176
|
-
if (fork.status
|
|
177
|
-
ctx.skip();
|
|
177
|
+
if (forkDeclined(fork.status, 'side-effect-suppression replay fork')) {
|
|
178
|
+
ctx.skip();
|
|
178
179
|
return;
|
|
179
180
|
}
|
|
180
181
|
expect(fork.status, 'fork should be accepted').toBe(201);
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import { describe, it, expect } from 'vitest';
|
|
20
20
|
import { driver } from '../lib/driver.js';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { forkDeclined } from '../lib/fork-availability.js';
|
|
21
23
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
22
24
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
23
25
|
|
|
@@ -69,7 +71,7 @@ function structuralShape(events: readonly RawEvent[]): Array<{ type: unknown; no
|
|
|
69
71
|
describe('replay-determinism: openwop-replay-fork profile gate', () => {
|
|
70
72
|
it('host advertising replay.supported MUST also advertise replay.modes', async () => {
|
|
71
73
|
const replay = await fetchReplayCapability();
|
|
72
|
-
if (replay === null || replay.supported !== true) return
|
|
74
|
+
if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
|
|
73
75
|
|
|
74
76
|
expect(Array.isArray(replay.modes), driver.describe(
|
|
75
77
|
'spec/v1/replay.md',
|
|
@@ -91,8 +93,8 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
91
93
|
'two replay forks of the same point produce structurally-identical event lists',
|
|
92
94
|
async () => {
|
|
93
95
|
const replay = await fetchReplayCapability();
|
|
94
|
-
if (replay === null || replay.supported !== true) return
|
|
95
|
-
if (!Array.isArray(replay.modes) || !replay.modes.includes('replay')) return
|
|
96
|
+
if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
|
|
97
|
+
if (!Array.isArray(replay.modes) || !replay.modes.includes('replay')) return softSkip('inapplicable', 'host advertises replay but not the `replay` mode — this leg is out of scope for it');
|
|
96
98
|
|
|
97
99
|
// Phase 1: complete an original run.
|
|
98
100
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
@@ -105,7 +107,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
105
107
|
mode: 'replay',
|
|
106
108
|
fromSeq: 0,
|
|
107
109
|
});
|
|
108
|
-
if (fork1.status
|
|
110
|
+
if (forkDeclined(fork1.status, 'determinism fork 1')) return;
|
|
109
111
|
expect(fork1.status, driver.describe(
|
|
110
112
|
'spec/v1/replay.md',
|
|
111
113
|
'POST /v1/runs/{runId}:fork with mode=replay MUST return 201',
|
|
@@ -118,7 +120,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
118
120
|
mode: 'replay',
|
|
119
121
|
fromSeq: 0,
|
|
120
122
|
});
|
|
121
|
-
if (fork2.status
|
|
123
|
+
if (forkDeclined(fork2.status, 'determinism fork 2')) return;
|
|
122
124
|
expect(fork2.status).toBe(201);
|
|
123
125
|
const fork2Id = (fork2.json as { runId: string }).runId;
|
|
124
126
|
await pollUntilTerminal(fork2Id, { timeoutMs: 10_000 });
|
|
@@ -152,7 +154,7 @@ describe.skipIf(SKIP_NO_NOOP)('replay-determinism: same fromSeq + same workflow
|
|
|
152
154
|
describe.skipIf(SKIP_NO_NOOP)('replay-determinism: branch-mode is permitted to diverge', () => {
|
|
153
155
|
it('branch mode does NOT need to produce identical event sequences (negative-control)', async () => {
|
|
154
156
|
const replay = await fetchReplayCapability();
|
|
155
|
-
if (replay === null || replay.supported !== true) return;
|
|
157
|
+
if (replay === null || replay.supported !== true) return softSkip('inapplicable', 'host does not advertise `replay.supported: true` — the replay contract does not apply to it');
|
|
156
158
|
if (!Array.isArray(replay.modes) || !replay.modes.includes('branch')) return;
|
|
157
159
|
|
|
158
160
|
// Self-test on the spec interpretation: branch and replay are
|