@openwop/openwop-conformance 2.0.0-rc.8 → 2.0.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/CHANGELOG.md +46 -0
- package/README.md +8 -3
- package/dist/cli.js +24 -34
- package/dist/lib/requirement-registry.js +69 -0
- package/dist/lib/scenario-disposition.js +84 -11
- package/dist/lib/seams.js +72 -0
- package/dist/lib/soft-skip.js +39 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirement-aliases.json +4 -1
- package/requirements.json +1087 -52
- package/scenario-majors.json +57 -3
- package/schemas/CORPUS-STAMP.json +41 -38
- package/src/cli.ts +23 -30
- package/src/global-setup.ts +11 -0
- package/src/lib/corpus-stamp.ts +24 -2
- package/src/lib/era2-seed.ts +12 -1
- package/src/lib/fixtures.ts +31 -0
- package/src/lib/requirement-registry.ts +68 -0
- package/src/lib/scenario-disposition.ts +88 -9
- package/src/lib/seams.ts +31 -1
- package/src/lib/soft-skip.ts +42 -7
- package/src/lib/sse.ts +8 -0
- package/src/scenarios/era-key-stamped-v1.test.ts +156 -0
- package/src/scenarios/pause-resume.test.ts +159 -75
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +25 -43
- package/src/scenarios/v2-advertised-path-space-served.test.ts +165 -0
- package/src/scenarios/v2-assurance-downgrade-audited.test.ts +1 -1
- package/src/scenarios/v2-chain-pin-exact.test.ts +1 -1
- package/src/scenarios/v2-coherence-not-in-bundle.test.ts +11 -2
- package/src/scenarios/v2-created-run-readable.test.ts +98 -0
- package/src/scenarios/v2-dual-stack-negotiation.test.ts +29 -1
- package/src/scenarios/v2-effect-identity-business-key.test.ts +1 -1
- package/src/scenarios/v2-effect-seam-manifest.test.ts +21 -55
- package/src/scenarios/v2-effect-seam-no-refire.test.ts +104 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +16 -2
- package/src/scenarios/v2-interrupt-token-scheme.test.ts +1 -1
- package/src/scenarios/v2-malformed-body-envelope.test.ts +79 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +1 -1
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +1 -1
- package/src/scenarios/v2-minimum-version-refused.test.ts +1 -1
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +1 -1
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +1 -1
- package/src/scenarios/v2-pack-isolation.test.ts +1 -1
- package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
- package/src/scenarios/v2-poll-cursor-v2.test.ts +18 -0
- package/src/scenarios/v2-revocation-honored.test.ts +1 -1
- package/src/scenarios/v2-run-annotation-not-event.test.ts +71 -0
- package/src/scenarios/v2-run-bulk-cancel.test.ts +91 -0
- package/src/scenarios/v2-run-cancel.test.ts +97 -0
- package/src/scenarios/v2-run-completed-outputs.test.ts +93 -0
- package/src/scenarios/v2-run-diff-identical.test.ts +75 -0
- package/src/scenarios/v2-run-fork-prefix.test.ts +160 -0
- package/src/scenarios/v2-run-fork-refusals.test.ts +70 -0
- package/src/scenarios/v2-run-options-limits.test.ts +69 -0
- package/src/scenarios/v2-run-pause-resume.test.ts +117 -0
- package/src/scenarios/v2-run-snapshot-etag.test.ts +57 -0
- package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
- package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
- package/src/scenarios/v2-stream-sse-projection.test.ts +79 -0
- package/src/scenarios/v2-subject-link-record.test.ts +1 -1
- package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +1 -1
- package/src/scenarios/version-negotiation.test.ts +25 -3
- package/src/setup.ts +104 -50
|
@@ -93,7 +93,7 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
93
93
|
}
|
|
94
94
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
95
95
|
if (!(await gateFamily('workflowChainPacks'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the workflowChainPacks family (RFC 0169 §A.2)' };
|
|
96
|
-
if (!seamsProfileAdvertised(doc)) return { kind: '
|
|
96
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only register path the suite can drive (RFC 0168 §C.1)' };
|
|
97
97
|
if (!v2Validator('workflow-chain-pack-manifest')(chainPack('core.openwop.pin-probe', 'core.ai.callPrompt@1.0.0')).ok) return { kind: 'blocked', reason: SCHEMA_GAP };
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
@@ -58,8 +58,17 @@ function bundleWith(rows: BundleV3Requirement[]): BundleV3 {
|
|
|
58
58
|
|
|
59
59
|
describe('v2-coherence-not-in-bundle (RFC 0168 §D.1)', () => {
|
|
60
60
|
it('every corpus-ledger id has a scenario in src/coherence and none in src/scenarios — the two id sets are disjoint by construction', () => {
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// rc.57: `inapplicable`, not `blocked`. The subject of this scenario is the
|
|
62
|
+
// CORPUS — it reads evidence/corpus-ledger.json and the two source
|
|
63
|
+
// directories and asserts nothing about a host. RFC 0148 §A defines
|
|
64
|
+
// `blocked` over ADVERTISED behaviour a missing dependency prevented
|
|
65
|
+
// exercising; there is none here, and `blocked` denied certification
|
|
66
|
+
// bundle-wide (RFC 0168 §E.1) to every host running the published tarball,
|
|
67
|
+
// which ships neither the ledger nor src/coherence. Same reasoning as
|
|
68
|
+
// lib/spec-coherence.ts for the v1 corpus scenarios; the reference host
|
|
69
|
+
// carried this row as one of its "16 blocked" since rc.16.
|
|
70
|
+
if (!existsSync(LEDGER)) return softSkip('inapplicable', 'inapplicable to any host: evidence/corpus-ledger.json is absent from this layout — the subject of this scenario is the corpus, which the published tarball does not ship; it runs in a spec checkout (scripts/check-spec-coherence.mjs) and needs no host');
|
|
71
|
+
if (SCENARIOS_DIR === null || COHERENCE_DIR === null || !existsSync(COHERENCE_DIR)) return softSkip('inapplicable', 'inapplicable to any host: src/scenarios or src/coherence is absent from this layout — the subject of this scenario is the corpus, which the published tarball does not ship; it runs in a spec checkout and needs no host');
|
|
63
72
|
const ledger = JSON.parse(readFileSync(LEDGER, 'utf8')) as { requirements: Record<string, unknown> };
|
|
64
73
|
const ids = Object.keys(ledger.requirements);
|
|
65
74
|
expect(ids.length, req('openwop.requirement.0168.coherence-not-in-bundle.disjoint-by-construction', SECTION, 'the corpus ledger MUST carry at least one requirement id')).toBeGreaterThan(0);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Create + `versioning.md` §1.2 — a run created at a
|
|
3
|
+
* base is readable at that base by the id the base returned, and the links in
|
|
4
|
+
* the create response stay on that base (suite 2.0.0, target major 2; unaided;
|
|
5
|
+
* creates one run).
|
|
6
|
+
*
|
|
7
|
+
* This is the assertion the suite deferred to a soft-skip. `v2-id-grammar`
|
|
8
|
+
* reads the run it just created and, on a non-200, records `blocked` — the
|
|
9
|
+
* honest word for "the host did not let me look". It is the wrong word when
|
|
10
|
+
* the host DID answer and the answer was `404 No route matches this request`,
|
|
11
|
+
* because that is not evidence going unread; it is a routing hole, and a
|
|
12
|
+
* `blocked` row over a routing hole is a vacuous pass one step removed.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-09-05 on a tier-1 host through its public origin: a run
|
|
15
|
+
* created at `https://app.openwop.dev` under major 2 could not be read,
|
|
16
|
+
* polled, cancelled or streamed at `https://app.openwop.dev` — the hosting
|
|
17
|
+
* layer decoded the tenant-bound id's `%2F` to `/` before forwarding, and the
|
|
18
|
+
* backend correctly had no route for a literal slash. One hop behind, the
|
|
19
|
+
* direct service URL answered every read with 200. Every bound id was
|
|
20
|
+
* unreachable through the front door, and every bound-id scenario recorded
|
|
21
|
+
* `blocked`. The same create response linked `eventsUrl` and `statusUrl` to
|
|
22
|
+
* `http://<the direct service host>/…` — a client that follows them leaves the
|
|
23
|
+
* origin it discovered on and downgrades to plain http.
|
|
24
|
+
*
|
|
25
|
+
* Two hard assertions, then: the read-back MUST be 200 at the same base, and
|
|
26
|
+
* the links MUST resolve under the request's origin without a scheme
|
|
27
|
+
* downgrade — a relative path satisfies both.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v2/core/runs.md §Create
|
|
30
|
+
* @see spec/v2/core/versioning.md §1.2
|
|
31
|
+
* @see spec/v2/core/identity.md §5
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
36
|
+
import { loadEnv } from '../lib/env.js';
|
|
37
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
38
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
39
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
40
|
+
import { req } from '../lib/requirement-ids.js';
|
|
41
|
+
|
|
42
|
+
const ID = 'openwop.requirement.0172.created-run-readable';
|
|
43
|
+
const DOC = 'spec/v2/core/runs.md §Create';
|
|
44
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
45
|
+
const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
46
|
+
|
|
47
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
48
|
+
try { return await fn(); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A link is on-base when it is relative, or absolute under the request origin with the same scheme. */
|
|
52
|
+
function linkOnBase(link: unknown, baseUrl: string): { ok: boolean; why: string } {
|
|
53
|
+
if (typeof link !== 'string' || link.length === 0) return { ok: false, why: 'not a non-empty string' };
|
|
54
|
+
if (link.startsWith('/')) return { ok: true, why: 'relative' };
|
|
55
|
+
let l: URL; let b: URL;
|
|
56
|
+
try { l = new URL(link); b = new URL(baseUrl); } catch { return { ok: false, why: 'not a URL' }; }
|
|
57
|
+
if (l.protocol === 'http:' && b.protocol === 'https:') return { ok: false, why: `scheme downgrade ${b.protocol} → ${l.protocol}` };
|
|
58
|
+
if (l.origin !== b.origin) return { ok: false, why: `origin ${l.origin} is not the request origin ${b.origin}` };
|
|
59
|
+
return { ok: true, why: 'same-origin absolute' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('v2 created-run-readable (runs.md §Create)', () => {
|
|
63
|
+
it('a run created at this base is readable and pollable at this base by the id it returned, and its links stay on this base', async () => {
|
|
64
|
+
try { if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable'); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
|
|
65
|
+
const { baseUrl } = loadEnv();
|
|
66
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
67
|
+
if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
68
|
+
if (created.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
|
|
69
|
+
const body = (created.json ?? {}) as { runId?: unknown; eventsUrl?: unknown; statusUrl?: unknown };
|
|
70
|
+
if (created.status !== 201 || typeof body.runId !== 'string') return softSkip('blocked', `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim());
|
|
71
|
+
const runId = body.runId;
|
|
72
|
+
expect(RUN_ID.test(runId), req(ID, 'spec/v2/core/identity.md §5', `the created runId MUST be tenant-bound (got ${runId})`)).toBe(true);
|
|
73
|
+
|
|
74
|
+
// HARD, not soft: the base that minted the id MUST resolve it. A 404 here is
|
|
75
|
+
// not evidence going unread; it is the front door not routing its own ids.
|
|
76
|
+
const read = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
77
|
+
expect(
|
|
78
|
+
read?.status ?? null,
|
|
79
|
+
req(ID, DOC, `GET /runs/{runId} at the base that created the run MUST answer 200 by the id it returned — got ${read?.status ?? 'no response'} ${readErrorCode(read?.json) ?? ''}. A 404 "No route matches" for a tenant-bound id is a hosting layer decoding %2F to / before the backend sees the path: every bound id is unreachable through this base`.trim()),
|
|
80
|
+
).toBe(200);
|
|
81
|
+
const poll = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
82
|
+
expect(
|
|
83
|
+
poll?.status ?? null,
|
|
84
|
+
req(ID, 'spec/v2/core/events.md §Poll', `GET /runs/{runId}/events/poll at the base that created the run MUST answer 200 — got ${poll?.status ?? 'no response'} ${readErrorCode(poll?.json) ?? ''}`.trim()),
|
|
85
|
+
).toBe(200);
|
|
86
|
+
|
|
87
|
+
// The links in the create response MUST keep the client on this base.
|
|
88
|
+
const ev = linkOnBase(body.eventsUrl, baseUrl);
|
|
89
|
+
expect(
|
|
90
|
+
ev.ok,
|
|
91
|
+
req(ID, DOC, `eventsUrl MUST resolve under the origin the create was made to, without a scheme downgrade — a relative path satisfies both (got ${JSON.stringify(body.eventsUrl)}: ${ev.why}). A link to a different host leaves the origin the client discovered on; http on an https origin is a downgrade`),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
if (body.statusUrl !== undefined) {
|
|
94
|
+
const st = linkOnBase(body.statusUrl, baseUrl);
|
|
95
|
+
expect(st.ok, req(ID, DOC, `statusUrl, when present, MUST resolve under the request origin without a scheme downgrade (got ${JSON.stringify(body.statusUrl)}: ${st.why})`)).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -76,7 +76,35 @@ describe('v2 dual-stack-negotiation (RFC 0172 §A.3–§A.4 — gated on two maj
|
|
|
76
76
|
const read = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`, { headers: { 'OpenWOP-Version': '2.0' } }));
|
|
77
77
|
if (read === null) return softSkip('blocked', 'GET /runs/{runId} unreachable (fetch failed)');
|
|
78
78
|
expect(read.status, req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', 'the overlap: a run created through /v1/runs MUST be readable through GET /runs/{runId} with OpenWOP-Version: 2.0')).toBe(200);
|
|
79
|
-
|
|
79
|
+
// The v2 read MUST name the same run BY ITS TENANT-BOUND PROJECTION.
|
|
80
|
+
//
|
|
81
|
+
// This assertion has been wrong twice, in opposite directions, and the pair
|
|
82
|
+
// is the point:
|
|
83
|
+
//
|
|
84
|
+
// until 2026-09-04 `.toBe(runId)` — byte-equality with the v1 id, cited to
|
|
85
|
+
// versioning.md §5, which said nothing about identifiers.
|
|
86
|
+
// TIGHTER than its prose: it failed a host that had
|
|
87
|
+
// implemented identity.md §5 faithfully.
|
|
88
|
+
// rc.28 accepted the bare v1 id OR its projection. LOOSER than
|
|
89
|
+
// its prose: `run-snapshot.schema.json` binds `runId` to
|
|
90
|
+
// `ids.schema.json#/$defs/runId`, whose pattern REQUIRES
|
|
91
|
+
// the `/`. rc.28 passed a response the v2 contract
|
|
92
|
+
// rejects — the same defect it was written to fix,
|
|
93
|
+
// committed in the act of fixing it.
|
|
94
|
+
//
|
|
95
|
+
// §5 now states the rule outright, and the reason it is a MUST is not
|
|
96
|
+
// stylistic: `identity.md` §5 requires a host to refuse a tenant-bound id
|
|
97
|
+
// whose tenant segment is not the caller's (`403 id_tenant_mismatch`). A
|
|
98
|
+
// bare id HAS no tenant segment, so that mandatory check cannot run on it.
|
|
99
|
+
// A legacy unprefixed form would be a class of ids — precisely the ones
|
|
100
|
+
// carried over from v1 — exempt from major 2's tenant isolation.
|
|
101
|
+
//
|
|
102
|
+
// So: the projection, and only the projection. A bare id here is a finding.
|
|
103
|
+
const readId = (read.json as { runId?: unknown } | undefined)?.runId;
|
|
104
|
+
expect(
|
|
105
|
+
readId,
|
|
106
|
+
req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', `a run minted under major 1 MUST be named by its tenant-bound projection <tenantId>/${'${the v1 id}'} when read under major 2 — the bare id is not merely unconventional, it carries no tenant segment for the mandatory 403 id_tenant_mismatch check to read (identity.md §5), and ids.schema.json#/$defs/runId has no legacy branch. Got ${JSON.stringify(readId)} for a run created as ${JSON.stringify(runId)}`),
|
|
107
|
+
).toMatch(new RegExp(`^[A-Za-z0-9._~-]{1,128}/${runId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`));
|
|
80
108
|
expect(major(read), req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §1.4', 'the v2 read MUST report the 2.x contract that produced it')).toBe('2');
|
|
81
109
|
});
|
|
82
110
|
|
|
@@ -97,7 +97,7 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
97
97
|
const doc = await discovery();
|
|
98
98
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
99
99
|
if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
|
|
100
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
100
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the retry leg is driven through the suite fixture provider (RFC 0173 §D.2 G4) under the seams profile — seams profile not advertised');
|
|
101
101
|
// The seam is catalogued (api/seams-v2.yaml `forceEffectTransportRetry`). An
|
|
102
102
|
// unreachable providerUrl forces the transport retry; the witness is the
|
|
103
103
|
// host's own Layer-2 ledger (RFC 0173 §C.2), where every attempt of one
|
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
* 1. the manifest validates; every row is guarded; at least one row exists
|
|
13
13
|
* for every `kind` the host declares (the manifest is the host's own
|
|
14
14
|
* enumeration — RFC 0140 G7 answered by making the host list it);
|
|
15
|
-
* 2. the facet constant equals the served address
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* 2. the facet constant equals the served address.
|
|
16
|
+
*
|
|
17
|
+
* Both legs are unaided (a `GET` and a schema), which is what lets this file sit
|
|
18
|
+
* on the `openwop-core-standard` floor as the `replay` family's witness. The
|
|
19
|
+
* seam-driven no-re-fire leg lived here until rc.45 and moved to
|
|
20
|
+
* `v2-effect-seam-no-refire` (seams-v2 floor): a file's disposition is
|
|
21
|
+
* worst-first, so one seam-gated leg made the whole manifest witness read
|
|
22
|
+
* `blocked` on any host without the seams surface.
|
|
20
23
|
*
|
|
21
24
|
* Completeness of the manifest (a seam outside it) is negative-existence and is
|
|
22
25
|
* not asserted here (RFC 0173 falsifiability table, §C.1 row).
|
|
@@ -26,16 +29,15 @@
|
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
31
|
import { describe, it, expect } from 'vitest';
|
|
29
|
-
import { driver
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
30
33
|
import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
|
|
31
|
-
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
32
34
|
import { softSkip } from '../lib/soft-skip.js';
|
|
33
35
|
import { req } from '../lib/requirement-ids.js';
|
|
34
36
|
|
|
35
37
|
const MANIFEST_PATH = '/host/effect-seams';
|
|
36
|
-
const KINDS = ['http', 'queue', 'storage', 'provider-sdk', 'webhook-fanout'] as const;
|
|
38
|
+
const KINDS = ['http', 'smtp', 'queue', 'storage', 'provider-sdk', 'webhook-fanout', 'other'] as const;
|
|
37
39
|
|
|
38
|
-
interface SeamRow { seam?: unknown; kind?: unknown; guarded?: unknown; guardedBy?: unknown; branchReFires?: unknown }
|
|
40
|
+
interface SeamRow { seam?: unknown; kind?: unknown; guarded?: unknown; guardedBy?: unknown; branchReFires?: unknown; note?: unknown }
|
|
39
41
|
interface Manifest { manifestVersion?: unknown; seams?: SeamRow[] }
|
|
40
42
|
|
|
41
43
|
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
@@ -82,6 +84,16 @@ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
|
|
|
82
84
|
(KINDS as readonly string[]).includes(String(row.kind)),
|
|
83
85
|
req('openwop.requirement.0173.effect-seam-manifest', 'effect-seam-manifest.schema.json seams[].kind', `kind MUST be one of ${KINDS.join(' | ')} — seam ${String(row.seam)} declares ${String(row.kind)}`),
|
|
84
86
|
).toBe(true);
|
|
87
|
+
// `other` is the escape for a mechanism the enum does not name, and the
|
|
88
|
+
// note is what keeps it from being a place to hide a seam: a row that
|
|
89
|
+
// cannot say WHICH mechanism it is has not been enumerated, it has been
|
|
90
|
+
// labelled. Suite 2.0.0-rc.61, replay.md §The effect-seam manifest.
|
|
91
|
+
if (String(row.kind) === 'other') {
|
|
92
|
+
expect(
|
|
93
|
+
typeof row.note === 'string' && row.note.trim().length > 0,
|
|
94
|
+
req('openwop.requirement.0173.effect-seam-manifest', 'replay.md §The effect-seam manifest', `a kind: other row MUST carry a note naming the mechanism (raw TCP, gRPC, a filesystem write, a device SDK) — seam ${String(row.seam)} declares other with no note`),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
}
|
|
85
97
|
}
|
|
86
98
|
// One row per kind the host declares: the set of kinds the manifest names is
|
|
87
99
|
// the host's declaration, and each named kind is backed by at least one row.
|
|
@@ -114,50 +126,4 @@ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
|
|
|
114
126
|
).toBe(MANIFEST_PATH);
|
|
115
127
|
});
|
|
116
128
|
|
|
117
|
-
it('driving one seam of each kind observes no re-fire on replay', async () => {
|
|
118
|
-
const doc = await discovery();
|
|
119
|
-
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
120
|
-
const replay = await gateFamily('replay');
|
|
121
|
-
if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
|
|
122
|
-
if (!seamsProfileAdvertised(doc)) {
|
|
123
|
-
return softSkip('blocked', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
|
|
124
|
-
}
|
|
125
|
-
// The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
|
|
126
|
-
// the host's own Layer-2 ledger, not an externally reachable receiver: fire a
|
|
127
|
-
// guarded row that states `branchReFires: false`, fork the run in `replay`
|
|
128
|
-
// mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
|
|
129
|
-
// fork issues no further attempt for that seam, so its effect ledger cannot
|
|
130
|
-
// grow past the parent's.
|
|
131
|
-
const manifest = await driver.get(MANIFEST_PATH);
|
|
132
|
-
const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
|
|
133
|
-
? ((manifest.json as Manifest).seams ?? []) : [];
|
|
134
|
-
const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
|
|
135
|
-
if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
|
|
136
|
-
const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
|
|
137
|
-
if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
|
|
138
|
-
return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
|
|
139
|
-
}
|
|
140
|
-
const firedBody = fired.json as { runId?: unknown } | null;
|
|
141
|
-
if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
|
|
142
|
-
return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
|
|
143
|
-
}
|
|
144
|
-
const parentId = firedBody.runId;
|
|
145
|
-
const parentEffects = await driver.get(`/runs/${parentId}/effects`);
|
|
146
|
-
if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
|
|
147
|
-
const countOf = (r: OpenWOPResponse): number => {
|
|
148
|
-
const b = r.json as { effects?: unknown } | null;
|
|
149
|
-
return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
|
|
150
|
-
};
|
|
151
|
-
const forked = await driver.post(`/runs/${parentId}:fork`, { mode: 'replay' });
|
|
152
|
-
const forkBody = forked.json as { runId?: unknown } | null;
|
|
153
|
-
if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
|
|
154
|
-
return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
|
|
155
|
-
}
|
|
156
|
-
const forkEffects = await driver.get(`/runs/${String(forkBody.runId)}/effects`);
|
|
157
|
-
if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
|
|
158
|
-
expect(
|
|
159
|
-
countOf(forkEffects),
|
|
160
|
-
req('openwop.requirement.0173.effect-seam-manifest.no-re-fire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
|
|
161
|
-
).toBeLessThanOrEqual(countOf(parentEffects));
|
|
162
|
-
});
|
|
163
129
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §C.2 — `effect-seam-no-refire` (suite 2.0.0, target major 2; gated
|
|
3
|
+
* on `replay`, driven through the seams profile).
|
|
4
|
+
*
|
|
5
|
+
* A guarded manifest row that states `branchReFires: false` MUST NOT be fired
|
|
6
|
+
* again by a replay fork: the fork's Layer-2 effect ledger cannot grow past the
|
|
7
|
+
* parent's for that seam. The witness is the host's own ledger, reached by
|
|
8
|
+
* firing a named manifest row inside a run through the catalogued seam
|
|
9
|
+
* (`api/seams-v2.yaml` `fireEffectSeam`), forking in `replay` mode, and reading
|
|
10
|
+
* `GET /runs/{runId}/effects` on both.
|
|
11
|
+
*
|
|
12
|
+
* This leg lived in `v2-effect-seam-manifest` until rc.45. It is the one leg of
|
|
13
|
+
* that file that needs the seam, and a file's disposition is worst-first — so
|
|
14
|
+
* on a host without the seams surface the whole manifest file recorded
|
|
15
|
+
* `blocked`, and the manifest witness (unaided, a `GET` and a schema) could not
|
|
16
|
+
* sit on the `openwop-core-standard` floor without dragging a seam gate onto
|
|
17
|
+
* it. Split, the manifest file is a core-standard floor witness for the
|
|
18
|
+
* `replay` family and this file is a seams-v2 floor witness; each records its
|
|
19
|
+
* own truth.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/replay.md §The effect-seam manifest
|
|
22
|
+
* @see spec/v2/core/security-defaults.md §Replay suppression
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
27
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
28
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
29
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
|
|
32
|
+
const MANIFEST_PATH = '/host/effect-seams';
|
|
33
|
+
|
|
34
|
+
interface SeamRow { seam?: unknown; kind?: unknown; guarded?: unknown; guardedBy?: unknown; branchReFires?: unknown }
|
|
35
|
+
interface Manifest { manifestVersion?: unknown; seams?: SeamRow[] }
|
|
36
|
+
|
|
37
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
38
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('RFC 0173 §C.2 — effect-seam-no-refire (gated on replay, seam-driven)', () => {
|
|
42
|
+
it('driving one seam of each kind observes no re-fire on replay', async () => {
|
|
43
|
+
const doc = await discovery();
|
|
44
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
45
|
+
const replay = await gateFamily('replay');
|
|
46
|
+
if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
|
|
47
|
+
if (!seamsProfileAdvertised(doc)) {
|
|
48
|
+
return softSkip('inapplicable', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
|
|
49
|
+
}
|
|
50
|
+
// The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
|
|
51
|
+
// the host's own Layer-2 ledger, not an externally reachable receiver: fire a
|
|
52
|
+
// guarded row that states `branchReFires: false`, fork the run in `replay`
|
|
53
|
+
// mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
|
|
54
|
+
// fork issues no further attempt for that seam, so its effect ledger cannot
|
|
55
|
+
// grow past the parent's.
|
|
56
|
+
const manifest = await driver.get(MANIFEST_PATH);
|
|
57
|
+
const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
|
|
58
|
+
? ((manifest.json as Manifest).seams ?? []) : [];
|
|
59
|
+
const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
|
|
60
|
+
if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
|
|
61
|
+
const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
|
|
62
|
+
if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
|
|
63
|
+
return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
|
|
64
|
+
}
|
|
65
|
+
const firedBody = fired.json as { runId?: unknown } | null;
|
|
66
|
+
if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
|
|
67
|
+
return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
|
|
68
|
+
}
|
|
69
|
+
const parentId = firedBody.runId;
|
|
70
|
+
const parentEffects = await driver.get(`/runs/${encodeURIComponent(parentId)}/effects`);
|
|
71
|
+
if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
|
|
72
|
+
const countOf = (r: OpenWOPResponse): number => {
|
|
73
|
+
const b = r.json as { effects?: unknown } | null;
|
|
74
|
+
return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
|
|
75
|
+
};
|
|
76
|
+
// The suppression witness is a COMPARISON, and a comparison against an
|
|
77
|
+
// empty source measures nothing. Until rc.64 this leg asserted only
|
|
78
|
+
// `fork <= parent`, so a host whose ledger recorded no attempt for the
|
|
79
|
+
// fired seam read 0 on both sides and PASSED — certifying suppression it
|
|
80
|
+
// had not observed. A tier-1 host found it the honest way: it was building
|
|
81
|
+
// an escapes-only ledger, worked out that both reads would be empty, and
|
|
82
|
+
// refused to ship the projection that would have gone green.
|
|
83
|
+
//
|
|
84
|
+
// `blocked`, not `inapplicable`: this host advertises the seams profile,
|
|
85
|
+
// mounts the seam, and the seam reported firing — the obligation is taken
|
|
86
|
+
// on and the declared witness (RFC 0173 §C.2) produced nothing, so the
|
|
87
|
+
// suite could not measure it. `inapplicable` is for an obligation the host
|
|
88
|
+
// never took on (lib/seams.ts).
|
|
89
|
+
if (countOf(parentEffects) === 0) {
|
|
90
|
+
return softSkip('blocked', `the fired run's effect ledger is empty at GET /runs/{runId}/effects, so a fork that also reads empty would compare 0 against 0 and witness nothing — suppression is observable only against a source attempt the host recorded (RFC 0173 §C.2). A host whose ledger records escapes only, or nothing for this seam, cannot witness this obligation.`);
|
|
91
|
+
}
|
|
92
|
+
const forked = await driver.post(`/runs/${encodeURIComponent(parentId)}:fork`, { mode: 'replay' });
|
|
93
|
+
const forkBody = forked.json as { runId?: unknown } | null;
|
|
94
|
+
if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
|
|
95
|
+
return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
|
|
96
|
+
}
|
|
97
|
+
const forkEffects = await driver.get(`/runs/${encodeURIComponent(String(forkBody.runId))}/effects`);
|
|
98
|
+
if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
|
|
99
|
+
expect(
|
|
100
|
+
countOf(forkEffects),
|
|
101
|
+
req('openwop.requirement.0173.effect-seam-no-refire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
|
|
102
|
+
).toBeLessThanOrEqual(countOf(parentEffects));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -60,7 +60,21 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
60
60
|
if (before === null || before.status !== 200) {
|
|
61
61
|
return softSkip('blocked', `GET /runs/${runId}/events/poll answered ${before?.status ?? 'no response'} on the seeded era-2 run — the log cannot be read back`);
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
// rc.58: `eventsOf` reads `.events` off a JSON body; `before` is the
|
|
64
|
+
// RESPONSE. Passing the response read `events` off an object that never
|
|
65
|
+
// has one, so this leg recorded "reads back empty (0 events)" on every host
|
|
66
|
+
// — including one whose poll returned both seeded rows — and the writer
|
|
67
|
+
// rule was unwitnessable. Every other caller passes `.json`.
|
|
68
|
+
const seedCount = eventsOf(before.json).length;
|
|
69
|
+
// The seam returning ok is a WRAPPER claim; the readable log is the artifact.
|
|
70
|
+
// A seam that reports success and seeds nothing leaves no era-2 log to append
|
|
71
|
+
// to, so there is nothing here to witness the writer rule with — that is
|
|
72
|
+
// `blocked`, not a writer-rule failure. Asserting against an empty log would
|
|
73
|
+
// charge this requirement for a seam defect, which is the misattribution the
|
|
74
|
+
// suite exists to avoid.
|
|
75
|
+
if (seedCount === 0) {
|
|
76
|
+
return softSkip('blocked', `seedEra2Log reported success but the log reads back empty (0 events) — the seam's return value is not evidence that a log exists, and without a seeded era-2 log the writer rule is unwitnessed here`);
|
|
77
|
+
}
|
|
64
78
|
|
|
65
79
|
// One canonical mutation so the HOST's own writer appends. Cancel is the
|
|
66
80
|
// universally available terminal transition; a host that refuses it on a
|
|
@@ -84,7 +98,7 @@ describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () =>
|
|
|
84
98
|
return softSkip('blocked', `the era-2 read failed with ${after.status} after the host's own append — the assertion above already recorded the refusal`);
|
|
85
99
|
}
|
|
86
100
|
|
|
87
|
-
const rows = eventsOf(after) as ReadEvent[];
|
|
101
|
+
const rows = eventsOf(after.json) as ReadEvent[];
|
|
88
102
|
expect(
|
|
89
103
|
rows.length,
|
|
90
104
|
req(ID, DOC, `the append MUST be visible in the log (seeded ${seedCount}, read ${rows.length} after cancel)`),
|
|
@@ -40,7 +40,7 @@ describe('v2 interrupt-token-scheme (RFC 0170 §E.1)', () => {
|
|
|
40
40
|
if (res === null) return softSkip('blocked', 'GET /interrupts/{token} unreachable (fetch failed)');
|
|
41
41
|
expect([401, 404].includes(res.status), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', DOC, `a token outside the ow2.<alg>.<kid>.<payload>.<mac> grammar MUST NOT resolve — 401 interrupt_token_invalid (or 404 not_found where the signed-token surface is not mounted); got ${res.status}`)).toBe(true);
|
|
42
42
|
const code = readErrorCode(res.json);
|
|
43
|
-
expect(res.status === 401 ? code === 'interrupt_token_invalid' : code === 'not_found', req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry
|
|
43
|
+
expect(res.status === 401 ? code === 'interrupt_token_invalid' : (code === 'not_found' || code === 'interrupt_not_found'), req('openwop.requirement.0170.interrupt-token-scheme.unprefixed-refused', 'spec/v2/core/interrupt.md §Tokens', `the refusal MUST carry a registered code (401 → interrupt_token_invalid; 404 → not_found OR the more precise interrupt_not_found, both registered in spec/v2/errors.json for this state — a scenario narrower than its own registry fails the host that answers more precisely); got ${String(code)}`)).toBe(true);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it('a well-formed token under a kid the host does not hold is 401 interrupt_token_invalid', async () => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/versioning.md` §1.4 + `errors.md` — a malformed JSON body on a
|
|
3
|
+
* major-2 POST is answered by the HOST: `400`, the error envelope, and the
|
|
4
|
+
* `OpenWOP-Version` header (suite 2.0.0, target major 2; unaided; creates
|
|
5
|
+
* nothing).
|
|
6
|
+
*
|
|
7
|
+
* §1.4: "A response on any path MUST carry `OpenWOP-Version`". The response
|
|
8
|
+
* most likely to escape that rule is the one a framework produces before the
|
|
9
|
+
* host's own middleware runs. Measured on a tier-2 host's public origin on
|
|
10
|
+
* 2026-09-05: `POST /runs` with body `{` answered `400 text/html` with no
|
|
11
|
+
* header under BOTH majors — Express's JSON parser was mounted before
|
|
12
|
+
* negotiation, and its error left the middleware chain for the default HTML
|
|
13
|
+
* handler. Every well-formed request on that host carried the header; only
|
|
14
|
+
* the malformed one did not, which is exactly the request a scenario never
|
|
15
|
+
* sends by accident. The host suggested this probe: "`POST /runs` with `{` is
|
|
16
|
+
* cheaper than any run."
|
|
17
|
+
*
|
|
18
|
+
* The same probe distinguishes a host from the hosting layer in front of it: a
|
|
19
|
+
* fallback that answers `200 text/html` to everything fails here too.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/versioning.md §1.4
|
|
22
|
+
* @see spec/v2/core/errors.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { loadEnv } from '../lib/env.js';
|
|
27
|
+
import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
28
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
29
|
+
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
|
|
31
|
+
const ID = 'openwop.requirement.0172.malformed-body-envelope';
|
|
32
|
+
const DOC = 'spec/v2/core/versioning.md §1.4';
|
|
33
|
+
|
|
34
|
+
interface Answer { readonly status: number; readonly version: string | null; readonly contentType: string; readonly body: unknown; readonly text: string }
|
|
35
|
+
|
|
36
|
+
async function postMalformed(path: string): Promise<Answer | null> {
|
|
37
|
+
const { baseUrl, apiKey } = loadEnv();
|
|
38
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json', 'OpenWOP-Version': '2.0' };
|
|
39
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, { method: 'POST', headers, body: '{' });
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
let body: unknown = null;
|
|
44
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
45
|
+
return { status: res.status, version: res.headers.get('openwop-version'), contentType: res.headers.get('content-type') ?? '', body, text };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('v2 malformed-body-envelope (versioning.md §1.4; errors.md)', () => {
|
|
52
|
+
for (const path of ['/runs', '/webhooks']) {
|
|
53
|
+
it(`POST ${path} with a malformed JSON body is answered by the host: 400, the error envelope, OpenWOP-Version`, async () => {
|
|
54
|
+
try { if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable'); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
|
|
55
|
+
const a = await postMalformed(path);
|
|
56
|
+
if (a === null) return softSkip('blocked', `POST ${path} unreachable (fetch failed)`);
|
|
57
|
+
if (a.status === 404 && a.version !== null) return softSkip('inapplicable', `${path} is not mounted on this host (404 with the header — a host answer, not a fallback)`);
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
a.version,
|
|
61
|
+
req(ID, DOC, `POST ${path} with body '{': the response MUST carry OpenWOP-Version — a response without it did not come from the host's negotiation layer (a framework parser or a hosting fallback answered instead). Got status ${a.status}, content-type ${JSON.stringify(a.contentType)}`),
|
|
62
|
+
).not.toBeNull();
|
|
63
|
+
expect(
|
|
64
|
+
a.status,
|
|
65
|
+
req(ID, 'spec/v2/core/errors.md', `a malformed JSON body MUST be refused with 400 validation_error, not accepted and not passed to a default handler (got ${a.status})`),
|
|
66
|
+
).toBe(400);
|
|
67
|
+
expect(
|
|
68
|
+
a.body !== null && typeof a.body === 'object' && !/text\/html/i.test(a.contentType),
|
|
69
|
+
req(ID, 'spec/v2/core/errors.md', `the 400 MUST be the JSON error envelope, not a framework's HTML error page (content-type ${JSON.stringify(a.contentType)}, body starts ${JSON.stringify(a.text.slice(0, 40))})`),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
const r = v2Validator('error-envelope')(a.body);
|
|
72
|
+
expect(r.ok, req(ID, 'spec/v2/core/errors.md', `the body MUST validate against error-envelope.schema.json (${r.errors})`)).toBe(true);
|
|
73
|
+
expect(
|
|
74
|
+
(a.body as { error?: unknown }).error,
|
|
75
|
+
req(ID, 'spec/v2/core/errors.md', 'the envelope\'s `error` MUST be the registered code for a malformed request body, validation_error'),
|
|
76
|
+
).toBe('validation_error');
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -97,7 +97,7 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
97
97
|
}
|
|
98
98
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
99
99
|
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
100
|
-
if (!seamsProfileAdvertised(doc)) return { kind: '
|
|
100
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
101
101
|
return null;
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -77,7 +77,7 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
77
77
|
}
|
|
78
78
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
79
79
|
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
80
|
-
if (!seamsProfileAdvertised(doc)) return { kind: '
|
|
80
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -88,7 +88,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
|
|
|
88
88
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
89
|
const facet = await familyAdvertised('a2a');
|
|
90
90
|
if (!facet) return softSkip('inapplicable', 'a2a facet not advertised — no floor to enforce');
|
|
91
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
91
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the below-floor exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
92
92
|
const floor = String(facet['minimumVersion']);
|
|
93
93
|
if (!a2aBelow(A2A_LOWEST, floor)) {
|
|
94
94
|
return softSkip('blocked', `the advertised a2a.minimumVersion (${floor}) is at or below the lowest version the suite peer can offer (${A2A_LOWEST}) — nothing below the floor can be offered; a peer speaking a lower A2A revision is needed`);
|
|
@@ -63,7 +63,7 @@ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
|
|
|
63
63
|
if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no MRTR ceiling');
|
|
64
64
|
const maxRounds = maxRoundsOf(facet);
|
|
65
65
|
if (!Number.isInteger(maxRounds)) return softSkip('inapplicable', 'mcp.mrtr.maxRounds not advertised — no ceiling to exceed');
|
|
66
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
66
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the rounds are driven through the seams profile (§23 invoke seam) — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
67
67
|
const server = getMcpFakeServer();
|
|
68
68
|
if (server === null) return softSkip('blocked', 'the suite MCP fake server (OPENWOP_MCP_FAKE_SERVER=true) is not started in this run');
|
|
69
69
|
server.reset();
|
|
@@ -88,7 +88,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
88
88
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
89
|
const facet = await familyAdvertised('a2a');
|
|
90
90
|
if (!facet) return softSkip('inapplicable', 'a2a facet not advertised — no negotiation to authenticate');
|
|
91
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
91
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'the unauthenticated exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
|
|
92
92
|
const preferred = String(facet['preferredVersion']);
|
|
93
93
|
if (preferred === A2A_LOWER) return softSkip('blocked', `a2a.preferredVersion is ${preferred}, the lowest version the suite peer can offer — no lower version can be offered`);
|
|
94
94
|
const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
|
|
@@ -88,7 +88,7 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
|
88
88
|
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
89
89
|
const facet = await familyAdvertised(protocol);
|
|
90
90
|
if (!facet) return softSkip('inapplicable', `${protocol} facet not advertised — no negotiation to audit`);
|
|
91
|
-
if (!seamsProfileAdvertised(doc)) return softSkip('
|
|
91
|
+
if (!seamsProfileAdvertised(doc)) return softSkip('inapplicable', `the exchange is driven through the seams profile (RFC 0175 §D.3) — conformance.seamsProfile is not openwop-conformance-seams-v2`);
|
|
92
92
|
const peer = protocol === 'a2a' ? getA2AFakePeer() : getMcpFakeServer();
|
|
93
93
|
if (peer === null) return softSkip('blocked', `the suite ${protocol === 'a2a' ? 'A2A fake peer (OPENWOP_A2A_FAKE_PEER=true)' : 'MCP fake server (OPENWOP_MCP_FAKE_SERVER=true)'} is not started in this run`);
|
|
94
94
|
peer.reset();
|
|
@@ -54,7 +54,7 @@ async function gated(): Promise<Record<string, unknown> | null> {
|
|
|
54
54
|
/** Drive one misbehaving typeId through the sandbox seam; null (reason recorded) when the seam is absent. */
|
|
55
55
|
async function invoke(typeId: string, extra: Record<string, unknown> = {}): Promise<InvokeResult | null> {
|
|
56
56
|
const doc = await discovery();
|
|
57
|
-
if (!doc || !seamsProfileAdvertised(doc)) { softSkip('
|
|
57
|
+
if (!doc || !seamsProfileAdvertised(doc)) { softSkip('inapplicable', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
|
|
58
58
|
const res = await driver.post(INVOKE, { typeId, ...extra });
|
|
59
59
|
if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises packs + sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
|
|
60
60
|
expect(res.status, req('openwop.requirement.0173.pack-isolation.seam', 'host-sample-test-seams.md §8', `${INVOKE} MUST answer 200 { result } | 200 { error } for ${typeId}`)).toBe(200);
|
|
@@ -96,7 +96,7 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
96
96
|
}
|
|
97
97
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
98
98
|
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
99
|
-
if (!seamsProfileAdvertised(doc)) return { kind: '
|
|
99
|
+
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
|