@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2

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 (72) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +209 -14
  3. package/dist/lib/certification-bundle-v3.js +142 -0
  4. package/dist/lib/requirement-ids.js +23 -0
  5. package/dist/lib/requirement-ledger.js +1 -0
  6. package/dist/lib/scenario-disposition.js +5 -1
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +2744 -4
  10. package/scenario-majors.json +155 -2
  11. package/schemas/CORPUS-STAMP.json +17 -17
  12. package/src/cli.ts +190 -19
  13. package/src/lib/certification-bundle-v3.ts +175 -0
  14. package/src/lib/driver.ts +9 -1
  15. package/src/lib/era2-seed.ts +197 -0
  16. package/src/lib/requirement-ids.ts +21 -0
  17. package/src/lib/requirement-ledger.ts +7 -1
  18. package/src/lib/scenario-disposition.ts +5 -1
  19. package/src/lib/seams.ts +45 -0
  20. package/src/lib/v2.ts +62 -0
  21. package/src/scenarios/v2-approver-enforced.test.ts +97 -0
  22. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
  23. package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
  24. package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
  25. package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
  26. package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
  27. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
  28. package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
  29. package/src/scenarios/v2-configurable-closed.test.ts +67 -0
  30. package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
  31. package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
  32. package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
  33. package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
  34. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
  35. package/src/scenarios/v2-era-key.test.ts +86 -0
  36. package/src/scenarios/v2-error-registry.test.ts +89 -0
  37. package/src/scenarios/v2-event-type-closed.test.ts +97 -0
  38. package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
  39. package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
  40. package/src/scenarios/v2-header-scheme.test.ts +67 -0
  41. package/src/scenarios/v2-id-grammar.test.ts +77 -0
  42. package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
  43. package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
  44. package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
  45. package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
  46. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
  47. package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
  48. package/src/scenarios/v2-min-client-version.test.ts +48 -0
  49. package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
  50. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
  51. package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
  52. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
  53. package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
  54. package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
  55. package/src/scenarios/v2-pack-isolation.test.ts +147 -0
  56. package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
  57. package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
  58. package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
  59. package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
  60. package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
  61. package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
  62. package/src/scenarios/v2-provider-conflict.test.ts +115 -0
  63. package/src/scenarios/v2-refresh-sla.test.ts +78 -0
  64. package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
  65. package/src/scenarios/v2-revocation-honored.test.ts +66 -0
  66. package/src/scenarios/v2-subject-link-record.test.ts +102 -0
  67. package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
  68. package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
  69. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
  70. package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
  71. package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
  72. package/src/setup.ts +40 -4
@@ -0,0 +1,130 @@
1
+ /**
2
+ * v2-manifest-ceiling-refused — RFC 0177 §A.1; `spec/v2/core/packs.md` §"The engine range".
3
+ *
4
+ * Suite 2.0.0, target major 2. A v2 host MUST read an `engines.openwop` range
5
+ * with no upper bound as bounded by `<2.0.0` and MUST refuse, at install and on
6
+ * every publication path, a version whose range does not admit its protocol
7
+ * major — with `pack_engine_unsupported` (`spec/v2/errors.json`, 400), never
8
+ * `pack_runtime_requirement_unmet`. Three tiny node packs are built in the test
9
+ * and PUT through the seams-profile publish seam
10
+ * (`/conformance/seams/packs-test/{name}/-/{version}.tgz`, RFC 0168 §C.2; the
11
+ * driver rewrites the v1 seam path under target major 2):
12
+ *
13
+ * 1. `>=1.0.0` (no ceiling) → refused, pack_engine_unsupported
14
+ * 2. `>=1.0.0 <2.0.0` (v1 ceiling) → refused, pack_engine_unsupported
15
+ * 3. `>=2.0.0 <3.0.0` (admits major 2) → installs (200/201)
16
+ *
17
+ * Gated on the `packs` family (RFC 0169 §A.2: presence is the claim) and on
18
+ * the seams profile (`conformance.seamsProfile: openwop-conformance-seams-v2`);
19
+ * a host without the profile records `blocked` — the write API is the only
20
+ * install path the suite can drive.
21
+ *
22
+ * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §A.1
23
+ * @see spec/v2/core/packs.md §"The engine range"
24
+ * @see api/seams-v2.yaml `putTestPackTarball`
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { gzipSync } from 'node:zlib';
29
+ import { driver } from '../lib/driver.js';
30
+ import { readErrorCode } from '../lib/error-envelope.js';
31
+ import { req } from '../lib/requirement-ids.js';
32
+ import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
33
+ import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
34
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
35
+
36
+ const SECTION = 'packs.md §"The engine range" (RFC 0177 §A.1)';
37
+
38
+ /** One ustar entry: 512-byte header + content padded to a 512 boundary. */
39
+ function tarEntry(name: string, data: Buffer): Buffer {
40
+ const h = Buffer.alloc(512, 0);
41
+ h.write(name, 0, 100, 'utf8');
42
+ h.write('0000644\0', 100, 8, 'utf8');
43
+ h.write('0000000\0', 108, 8, 'utf8');
44
+ h.write('0000000\0', 116, 8, 'utf8');
45
+ h.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf8');
46
+ h.write('00000000000\0', 136, 12, 'utf8');
47
+ h.write(' ', 148, 8, 'utf8');
48
+ h.write('0', 156, 1, 'utf8');
49
+ h.write('ustar\0', 257, 6, 'utf8');
50
+ h.write('00', 263, 2, 'utf8');
51
+ let sum = 0;
52
+ for (const b of h) sum += b;
53
+ h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8');
54
+ const pad = (512 - (data.length % 512)) % 512;
55
+ return Buffer.concat([h, data, Buffer.alloc(pad, 0)]);
56
+ }
57
+
58
+ /** A gzipped tarball with `pack.json` at the root (node-packs.md §"Tarball extraction"). */
59
+ function packTarball(files: Record<string, string>): Buffer {
60
+ return gzipSync(Buffer.concat([...Object.entries(files).map(([n, c]) => tarEntry(n, Buffer.from(c, 'utf8'))), Buffer.alloc(1024, 0)]));
61
+ }
62
+
63
+ const ENTRY = 'export default { execute: async (input) => input };\n';
64
+
65
+ function freshName(slug: string): string {
66
+ return `core.openwop.v2-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
67
+ }
68
+
69
+ function nodePack(name: string, engines: string): Record<string, unknown> {
70
+ return {
71
+ name,
72
+ version: '1.0.0',
73
+ kind: 'node',
74
+ engines: { openwop: engines },
75
+ runtime: { language: 'javascript', entry: 'index.mjs', format: 'esm' },
76
+ nodes: [{ typeId: `${name}.echo`, version: '1.0.0', category: 'data', role: 'pure' }],
77
+ };
78
+ }
79
+
80
+ async function publish(manifest: Record<string, unknown>) {
81
+ const name = manifest['name'] as string;
82
+ const version = manifest['version'] as string;
83
+ const body = packTarball({ 'pack.json': JSON.stringify(manifest, null, 2), 'index.mjs': ENTRY });
84
+ return driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, body, {
85
+ headers: { 'Content-Type': 'application/octet-stream' },
86
+ });
87
+ }
88
+
89
+ /** The reason this leg cannot run, or null when the seam is reachable. */
90
+ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
91
+ if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
92
+ let doc: Record<string, unknown> | null;
93
+ try {
94
+ doc = await v2Discovery();
95
+ } catch {
96
+ doc = null;
97
+ }
98
+ if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
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: 'blocked', 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
+ return null;
102
+ }
103
+
104
+ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
105
+ it('an engines.openwop range with no upper bound (>=1.0.0) reads <2.0.0 and is refused at install with pack_engine_unsupported', async () => {
106
+ const skip = await preflight();
107
+ if (skip) return softSkip(skip.kind, skip.reason);
108
+ const res = await publish(nodePack(freshName('unbounded'), '>=1.0.0'));
109
+ if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
110
+ expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'an unbounded range does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
111
+ expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'the refusal code MUST be pack_engine_unsupported (not pack_runtime_requirement_unmet)')).toBe('pack_engine_unsupported');
112
+ });
113
+
114
+ it('a v1 ceiling (>=1.0.0 <2.0.0) is refused at install with pack_engine_unsupported', async () => {
115
+ const skip = await preflight();
116
+ if (skip) return softSkip(skip.kind, skip.reason);
117
+ const res = await publish(nodePack(freshName('v1-ceiling'), '>=1.0.0 <2.0.0'));
118
+ if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
119
+ expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'a <2.0.0 ceiling does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
120
+ expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'the refusal code MUST be pack_engine_unsupported')).toBe('pack_engine_unsupported');
121
+ });
122
+
123
+ it('a range admitting the host major (>=2.0.0 <3.0.0) installs', async () => {
124
+ const skip = await preflight();
125
+ if (skip) return softSkip(skip.kind, skip.reason);
126
+ const res = await publish(nodePack(freshName('v2-installs'), '>=2.0.0 <3.0.0'));
127
+ if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
128
+ expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-ceiling-refused.v2-installs', SECTION, `a manifest whose range admits major 2 MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
129
+ });
130
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * v2-manifest-hatch-carried — RFC 0177 §C.2; `spec/v2/core/packs.md`
3
+ * §"The manifest schema family".
4
+ *
5
+ * Suite 2.0.0, target major 2. Every pack-authored document MUST admit the
6
+ * vendor hatch `^(openwop-|x-|vendor\.)` — including the two nested documents
7
+ * RFC 0138 deferred, `agent-manifest` (inside `node-pack-manifest.agents[]`)
8
+ * and `prompt-template` (inside `prompt-pack-manifest.prompts[]`). A consumer
9
+ * that does not recognise a hatch property MUST ignore it and MUST NOT reject
10
+ * the document.
11
+ *
12
+ * 1. a node pack whose `agents[0]` carries `x-vendor-note` installs.
13
+ * 2. a prompt pack whose `prompts[0]` (a PromptTemplate) carries
14
+ * `x-vendor-note` installs; a host that does not install `kind: prompt`
15
+ * packs at all (`pack_kind_invalid`) records `skipped` for that leg.
16
+ *
17
+ * Installs through the seams-profile publish seam
18
+ * (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on `packs` + the
19
+ * seams profile.
20
+ *
21
+ * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §C.2
22
+ * @see spec/v2/core/packs.md §"The manifest schema family"
23
+ * @see schemas/v2/agent-manifest.schema.json, schemas/v2/prompt-template.schema.json
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { gzipSync } from 'node:zlib';
28
+ import { driver } from '../lib/driver.js';
29
+ import { readErrorCode } from '../lib/error-envelope.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+ import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
32
+ import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
33
+ import { v2Discovery, gateFamily } from '../lib/v2.js';
34
+
35
+ const SECTION = 'packs.md §"The manifest schema family" (RFC 0177 §C.2)';
36
+
37
+ function tarEntry(name: string, data: Buffer): Buffer {
38
+ const h = Buffer.alloc(512, 0);
39
+ h.write(name, 0, 100, 'utf8');
40
+ h.write('0000644\0', 100, 8, 'utf8');
41
+ h.write('0000000\0', 108, 8, 'utf8');
42
+ h.write('0000000\0', 116, 8, 'utf8');
43
+ h.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf8');
44
+ h.write('00000000000\0', 136, 12, 'utf8');
45
+ h.write(' ', 148, 8, 'utf8');
46
+ h.write('0', 156, 1, 'utf8');
47
+ h.write('ustar\0', 257, 6, 'utf8');
48
+ h.write('00', 263, 2, 'utf8');
49
+ let sum = 0;
50
+ for (const b of h) sum += b;
51
+ h.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'utf8');
52
+ const pad = (512 - (data.length % 512)) % 512;
53
+ return Buffer.concat([h, data, Buffer.alloc(pad, 0)]);
54
+ }
55
+ function packTarball(files: Record<string, string>): Buffer {
56
+ return gzipSync(Buffer.concat([...Object.entries(files).map(([n, c]) => tarEntry(n, Buffer.from(c, 'utf8'))), Buffer.alloc(1024, 0)]));
57
+ }
58
+ const ENTRY = 'export default { execute: async (input) => input };\n';
59
+ function freshName(slug: string): string {
60
+ return `core.openwop.v2-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
61
+ }
62
+ async function publish(manifest: Record<string, unknown>, files: Record<string, string>) {
63
+ const name = manifest['name'] as string;
64
+ const version = manifest['version'] as string;
65
+ const body = packTarball({ 'pack.json': JSON.stringify(manifest, null, 2), ...files });
66
+ return driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, body, {
67
+ headers: { 'Content-Type': 'application/octet-stream' },
68
+ });
69
+ }
70
+ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
71
+ if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
72
+ let doc: Record<string, unknown> | null;
73
+ try {
74
+ doc = await v2Discovery();
75
+ } catch {
76
+ doc = null;
77
+ }
78
+ if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
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: 'blocked', 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
+ return null;
82
+ }
83
+
84
+ describe('v2-manifest-hatch-carried (RFC 0177 §C.2)', () => {
85
+ it('an x- field inside agents[0] of a node pack is ignored, not rejected — the pack installs', async () => {
86
+ const skip = await preflight();
87
+ if (skip) return softSkip(skip.kind, skip.reason);
88
+ const name = freshName('hatch-agent');
89
+ const manifest = {
90
+ name,
91
+ version: '1.0.0',
92
+ kind: 'node',
93
+ engines: { openwop: '>=2.0.0 <3.0.0' },
94
+ runtime: { language: 'javascript', entry: 'index.mjs', format: 'esm' },
95
+ nodes: [{ typeId: `${name}.echo`, version: '1.0.0', category: 'data', role: 'pure' }],
96
+ agents: [{ agentId: `${name}.helper`, persona: 'Hatch Helper', modelClass: 'general', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
97
+ };
98
+ const res = await publish(manifest, { 'index.mjs': ENTRY });
99
+ if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
100
+ expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-hatch-carried.agents-x-field', SECTION, `agent-manifest admits ^(openwop-|x-|vendor\\.) — a pack whose agents[0] carries x-vendor-note MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
101
+ });
102
+
103
+ it('an x- field inside a prompt template of a prompt pack is ignored, not rejected — the pack installs', async () => {
104
+ const skip = await preflight();
105
+ if (skip) return softSkip(skip.kind, skip.reason);
106
+ const name = freshName('hatch-prompt');
107
+ const manifest = {
108
+ name,
109
+ version: '1.0.0',
110
+ kind: 'prompt',
111
+ engines: { openwop: '>=2.0.0 <3.0.0' },
112
+ prompts: [{ templateId: `${name}.system`, version: '1.0.0', kind: 'system', text: 'You are a conformance fixture.', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
113
+ };
114
+ const res = await publish(manifest, {});
115
+ if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
116
+ const code = readErrorCode(res.json);
117
+ if (res.status >= 400 && code === 'pack_kind_invalid') return softSkip('skipped', 'host does not install kind: prompt packs (pack_kind_invalid) — the prompt-template hatch cannot be observed on this host');
118
+ expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-hatch-carried.prompt-template-x-field', SECTION, `prompt-template admits ^(openwop-|x-|vendor\\.) — a prompt pack whose prompts[0] carries x-vendor-note MUST install (got ${res.status} ${code ?? ''})`)).toBe(true);
119
+ });
120
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * v2 — `min-client-version` (suite 2.0.0; RFC 0172 §A.5, row C5.8;
3
+ * `spec/v2/core/versioning.md` §1.5 "Client precedence and minClientVersion").
4
+ *
5
+ * Witness class: witnessable — gated on a host that sets `minClientVersion`.
6
+ * `minClientVersion` is a MUST: a host MAY refuse a client below it with
7
+ * `426 client_version_unsupported`. A host that does not advertise it records
8
+ * `inapplicable`. A request announcing `OpenWOP-Client-Version: 0.0.1` (below
9
+ * any advertised floor) is sent; when the host refuses, the refusal MUST be the
10
+ * registered code at its registered status in the closed envelope; a host that
11
+ * exercises the MAY by serving the request records `inapplicable` with that
12
+ * reason. `OpenWOP-Client-Version` is the `OpenWOP-<Name>` header this
13
+ * scenario uses to announce the client; it is not yet declared in
14
+ * `api/v2/openapi.yaml` / `headers.md`.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
20
+ import { readErrorCode } from '../lib/error-envelope.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+
24
+ const DOC = 'spec/v2/core/versioning.md §1.5';
25
+ const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
26
+
27
+ async function discovery(): Promise<Record<string, unknown> | null> {
28
+ try { return await v2Discovery(); } catch { return null; }
29
+ }
30
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
31
+ try { return await fn(); } catch { return null; }
32
+ }
33
+
34
+ describe('v2 min-client-version (RFC 0172 §A.5 — gated on minClientVersion)', () => {
35
+ it('a client below minClientVersion is refused with 426 client_version_unsupported', async () => {
36
+ const doc = await discovery();
37
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
38
+ const floor = doc['minClientVersion'];
39
+ if (floor === undefined) return softSkip('inapplicable', 'the host does not advertise minClientVersion — the 426 refusal is gated on a host that sets it');
40
+ expect(typeof floor === 'string' && VERSION.test(floor), req('openwop.requirement.0172.min-client-version', DOC, 'minClientVersion MUST use the <major>.<minor> grammar (axis 15, as axis 1)')).toBe(true);
41
+ const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Client-Version': '0.0.1' } }));
42
+ if (res === null) return softSkip('blocked', 'GET /.well-known/openwop with OpenWOP-Client-Version unreachable (fetch failed)');
43
+ if (res.status !== 426) return softSkip('inapplicable', `the host advertises minClientVersion ${String(floor)} but served a client announcing 0.0.1 (${res.status}) — refusal is a MAY; nothing further is observable`);
44
+ expect(readErrorCode(res.json), req('openwop.requirement.0172.min-client-version', DOC, 'a 426 refusal MUST carry client_version_unsupported')).toBe('client_version_unsupported');
45
+ const r = v2Validator('error-envelope')(res.json);
46
+ expect(r.ok, req('openwop.requirement.0172.min-client-version', 'spec/v2/core/errors.md §The envelope', `the refusal MUST be the closed error envelope (${r.errors})`)).toBe(true);
47
+ });
48
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * RFC 0175 §D.2 — `minimum-version-refused` (suite 2.0.0, target major 2; gated on a2a/mcp + seams).
3
+ *
4
+ * A host advertises a floor (`a2a.minimumVersion` / `mcp.minimumRevision`); a
5
+ * negotiation that would land below it MUST fail closed with
6
+ * `interop_version_unsupported` (400, `spec/v2/errors.json`) whether or not
7
+ * policy permits an explicit downgrade above the floor, and the
8
+ * `negotiation.decided` event MUST say `outcome: refused` (`spec/v2/core/interop.md`
9
+ * §The floor, §The audit event; RFC 0175 §D.2/§D.3; invariant
10
+ * `interop-minimum-version-enforced`).
11
+ *
12
+ * How the below-floor peer is produced: the suite constructs its OWN single-era
13
+ * peer offering only a version below the advertised floor — `A2AFakePeer`
14
+ * with `['0.3']`, `McpFakeServer` with `['2025-06-18']` — and points the host at
15
+ * it through the §22/§23 invoke seams (`peerOffersOnly` for A2A). When the
16
+ * host's floor is at or below the lowest version the suite can offer there is
17
+ * nothing below the floor to offer, and the leg records `blocked` naming that.
18
+ *
19
+ * Callback-shaped (the host calls the suite's peer): unwitnessable when the
20
+ * host is in a separate network namespace — `../lib/host-callback.ts`.
21
+ *
22
+ * @see spec/v2/core/interop.md §Negotiation is a protocol
23
+ * @see SECURITY/threat-model-interop.md (interop-minimum-version-enforced)
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { driver } from '../lib/driver.js';
28
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
29
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
30
+ import { A2AFakePeer } from '../lib/a2a-fake-peer.js';
31
+ import { McpFakeServer } from '../lib/mcp-fake-server.js';
32
+ import { readErrorCode } from '../lib/error-envelope.js';
33
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
34
+ import { req } from '../lib/requirement-ids.js';
35
+
36
+ export const REQUIRES_HOST_CALLBACK = "the host's A2A/MCP client calls a suite-owned peer that offers only a version below the advertised floor";
37
+
38
+ const CODE = 'interop_version_unsupported';
39
+ const A2A_LOWEST = '0.3';
40
+ const MCP_LOWEST = '2025-06-18';
41
+
42
+ async function discovery(): Promise<Record<string, unknown> | null> {
43
+ try { return await v2Discovery(); } catch { return null; }
44
+ }
45
+
46
+ function a2aBelow(a: string, b: string): boolean {
47
+ const [am, an] = a.split('.').map(Number);
48
+ const [bm, bn] = b.split('.').map(Number);
49
+ return am! < bm! || (am === bm && an! < bn!);
50
+ }
51
+
52
+ interface EventRow { type?: unknown; payload?: Record<string, unknown> }
53
+ async function decidedEvents(runId: string): Promise<EventRow[]> {
54
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
55
+ const events = res.status === 200 ? ((res.json as { events?: EventRow[] } | null)?.events ?? []) : [];
56
+ return events.filter((e) => e.type === 'negotiation.decided');
57
+ }
58
+
59
+ async function assertRefused(id: string, protocol: 'a2a' | 'mcp', floor: string, res: { status: number; json: unknown }): Promise<void> {
60
+ expect(
61
+ res.status,
62
+ req(id, 'interop.md §The floor', `a negotiation that would land below ${protocol}'s floor (${floor}) MUST fail closed with 400 (RFC 0175 §D.2)`),
63
+ ).toBe(400);
64
+ expect(
65
+ readErrorCode(res.json),
66
+ req(id, 'errors.json interop_version_unsupported', `the refusal MUST carry ${CODE} in the canonical envelope`),
67
+ ).toBe(CODE);
68
+ const details = (res.json as { details?: Record<string, unknown> } | null)?.details ?? {};
69
+ if (details['protocol'] !== undefined) {
70
+ expect(details['protocol'], req(id, 'host-sample-test-seams.md §22/§23', `details.protocol MUST name ${protocol}`)).toBe(protocol);
71
+ }
72
+ const runId = (res.json as { runId?: unknown } | null)?.runId ?? details['runId'];
73
+ if (typeof runId !== 'string') {
74
+ softSkip('blocked', `the refusal names no runId — the negotiation.decided { outcome: refused } event on the host log is not addressable from the suite`);
75
+ return;
76
+ }
77
+ const decided = await decidedEvents(runId);
78
+ expect(decided.length, req(id, 'interop.md §The audit event', 'the refused outcome MUST also emit negotiation.decided (RFC 0175 §D.3 — every outcome, including the fail-closed one)')).toBeGreaterThan(0);
79
+ const last = decided[decided.length - 1]!.payload ?? {};
80
+ expect(last['outcome'], req(id, 'run-event-payloads.schema.json negotiationDecided.outcome', 'the event MUST say outcome: refused')).toBe('refused');
81
+ expect(last['reason'], req(id, 'run-event-payloads.schema.json negotiationDecided.reason', 'the reason MUST be below-floor')).toBe('below-floor');
82
+ if (last['floor'] !== undefined) expect(last['floor'], req(id, 'run-event-payloads.schema.json negotiationDecided.floor', 'the recorded floor MUST equal the advertised one')).toBe(floor);
83
+ }
84
+
85
+ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)', () => {
86
+ it('an A2A peer offering only a version below minimumVersion is refused', async () => {
87
+ const doc = await discovery();
88
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
89
+ const facet = await familyAdvertised('a2a');
90
+ if (!facet) return softSkip('inapplicable', 'a2a facet not advertised — no floor to enforce');
91
+ if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'the below-floor exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
92
+ const floor = String(facet['minimumVersion']);
93
+ if (!a2aBelow(A2A_LOWEST, floor)) {
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`);
95
+ }
96
+ const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
97
+ await peer.start();
98
+ try {
99
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.endpoint(), authenticated: true, peerOffersOnly: A2A_LOWEST });
100
+ if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
101
+ await assertRefused('openwop.requirement.0175.minimum-version-refused', 'a2a', floor, res);
102
+ // The wire leg: the host MUST NOT have spoken the below-floor version to the peer.
103
+ for (const c of peer.invocations().filter((i) => i.method !== 'GET')) {
104
+ expect(
105
+ a2aBelow(String(c.headers['a2a-version'] ?? ''), floor),
106
+ req('openwop.requirement.0175.minimum-version-refused', 'interop.md §The floor', `no call to the peer may carry an A2A-Version below the floor (got ${String(c.headers['a2a-version'])})`),
107
+ ).toBe(false);
108
+ }
109
+ } finally {
110
+ await peer.stop();
111
+ }
112
+ });
113
+
114
+ it('an MCP server offering only a revision below minimumRevision is refused', async () => {
115
+ const doc = await discovery();
116
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
117
+ const facet = await familyAdvertised('mcp');
118
+ if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no floor to enforce');
119
+ if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'the below-floor exchange is driven through the seams profile — conformance.seamsProfile is not openwop-conformance-seams-v2');
120
+ const floor = String(facet['minimumRevision']);
121
+ if (!(MCP_LOWEST < floor)) {
122
+ return softSkip('blocked', `the advertised mcp.minimumRevision (${floor}) is at or below the lowest revision the suite server can offer (${MCP_LOWEST}) — nothing below the floor can be offered`);
123
+ }
124
+ const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
125
+ await server.start();
126
+ try {
127
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.endpoint(), requestVersion: MCP_LOWEST });
128
+ if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
129
+ await assertRefused('openwop.requirement.0175.minimum-version-refused.mcp', 'mcp', floor, res);
130
+ for (const c of server.invocations()) {
131
+ const v = String(c.headers['mcp-protocol-version'] ?? '');
132
+ expect(
133
+ v !== '' && v < floor,
134
+ req('openwop.requirement.0175.minimum-version-refused.mcp', 'interop.md §The floor', `no call to the server may carry an MCP-Protocol-Version below the floor (got ${v})`),
135
+ ).toBe(false);
136
+ }
137
+ } finally {
138
+ await server.stop();
139
+ }
140
+ });
141
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * RFC 0175 §E.1 — `mrtr-rounds-ceiling` (suite 2.0.0, target major 2; gated on mcp + mrtr).
3
+ *
4
+ * `mcp.mrtr.maxRounds` (integer 1–16) is the advertised ceiling on multi-round
5
+ * tool-result rounds; the host MUST refuse an `input_required` round beyond it
6
+ * with `mcp_mrtr_rounds_exceeded` (422, `spec/v2/errors.json`); RFC 0153 G9
7
+ * closes (`spec/v2/core/interop.md` §The MCP round ceiling; RFC 0175 row C8.7).
8
+ *
9
+ * Legs:
10
+ * 1. unaided on the gate: the facet's `maxRounds` is an integer in 1..16;
11
+ * 2. behavioural: drive `maxRounds + 1` `input_required` rounds through the
12
+ * suite's fake MCP server via the §23 invoke seam. The suite's server
13
+ * (`McpFakeServer`) has ONE MRTR tool, `needs_input`, which completes on
14
+ * the first retry — it cannot re-issue `input_required` N times — so the
15
+ * behavioural leg records `blocked` naming the fixture it needs: a
16
+ * `needs_input_loop` tool that answers `input_required` for
17
+ * `arguments.rounds` retries before completing.
18
+ *
19
+ * @see spec/v2/core/interop.md §The MCP round ceiling
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
25
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
26
+ import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
27
+ import { readErrorCode } from '../lib/error-envelope.js';
28
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
29
+ import { req } from '../lib/requirement-ids.js';
30
+
31
+ export const REQUIRES_HOST_CALLBACK = "the host's MCP client drives MRTR rounds against the suite's fake server through /conformance/seams/sample/mcp/invoke";
32
+
33
+ const CODE = 'mcp_mrtr_rounds_exceeded';
34
+ const LOOP_TOOL = 'needs_input_loop';
35
+
36
+ async function discovery(): Promise<Record<string, unknown> | null> {
37
+ try { return await v2Discovery(); } catch { return null; }
38
+ }
39
+
40
+ function maxRoundsOf(facet: Record<string, unknown>): unknown {
41
+ const mrtr = facet['mrtr'];
42
+ return mrtr && typeof mrtr === 'object' ? (mrtr as { maxRounds?: unknown }).maxRounds : undefined;
43
+ }
44
+
45
+ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
46
+ it('mcp.mrtr.maxRounds is an integer ceiling in 1..16', async () => {
47
+ const doc = await discovery();
48
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
49
+ const facet = await familyAdvertised('mcp');
50
+ if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no MRTR ceiling');
51
+ const maxRounds = maxRoundsOf(facet);
52
+ if (maxRounds === undefined) return softSkip('inapplicable', 'mcp.mrtr not advertised — the host offers no multi-round tool results');
53
+ expect(
54
+ Number.isInteger(maxRounds) && (maxRounds as number) >= 1 && (maxRounds as number) <= 16,
55
+ req('openwop.requirement.0175.mrtr-rounds-ceiling', 'facets/mcp.schema.json mrtr.maxRounds', `maxRounds MUST be an integer in 1..16 (got ${JSON.stringify(maxRounds)}) — "the host MUST bound the number of rounds (host policy)" was a MUST with no number (RFC 0153 G9)`),
56
+ ).toBe(true);
57
+ });
58
+
59
+ it('round maxRounds + 1 is refused with mcp_mrtr_rounds_exceeded', async () => {
60
+ const doc = await discovery();
61
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
62
+ const facet = await familyAdvertised('mcp');
63
+ if (!facet) return softSkip('inapplicable', 'mcp facet not advertised — no MRTR ceiling');
64
+ const maxRounds = maxRoundsOf(facet);
65
+ if (!Number.isInteger(maxRounds)) return softSkip('inapplicable', 'mcp.mrtr.maxRounds not advertised — no ceiling to exceed');
66
+ if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'the rounds are driven through the seams profile (§23 invoke seam) — conformance.seamsProfile is not openwop-conformance-seams-v2');
67
+ const server = getMcpFakeServer();
68
+ if (server === null) return softSkip('blocked', 'the suite MCP fake server (OPENWOP_MCP_FAKE_SERVER=true) is not started in this run');
69
+ server.reset();
70
+ // The suite server's only MRTR tool, `needs_input`, completes on the first
71
+ // retry: it cannot re-issue input_required maxRounds+1 times. The fixture
72
+ // this leg needs is a `needs_input_loop` tool on McpFakeServer that answers
73
+ // input_required for `arguments.rounds` retries before completing.
74
+ const rounds = (maxRounds as number) + 1;
75
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, {
76
+ serverUrl: server.endpoint(),
77
+ tool: LOOP_TOOL,
78
+ arguments: { rounds },
79
+ clientCapabilities: { elicitation: {} },
80
+ elicitationAnswer: { name: 'Ada' },
81
+ });
82
+ if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp.mrtr but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
83
+ const served = server.invocations().filter((i) => i.method === 'tools/call');
84
+ const loopKnown = served.some((i) => (i.params as { name?: unknown } | null)?.name === LOOP_TOOL) && !res.text.includes('Unknown tool');
85
+ if (!loopKnown) {
86
+ return softSkip('blocked', `the suite MCP fake server cannot loop — its only MRTR tool (needs_input) completes on the first retry; a \`${LOOP_TOOL}\` fixture tool that re-issues input_required ${rounds} times is required to drive maxRounds + 1`);
87
+ }
88
+ expect(res.status, req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'interop.md §The MCP round ceiling', `round ${rounds} (maxRounds + 1) MUST be refused with 422`)).toBe(422);
89
+ expect(readErrorCode(res.json), req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'errors.json mcp_mrtr_rounds_exceeded', `the refusal MUST carry ${CODE}`)).toBe(CODE);
90
+ expect(
91
+ served.length,
92
+ req('openwop.requirement.0175.mrtr-rounds-ceiling.refused', 'interop.md §The MCP round ceiling', `the host MUST NOT issue a retry beyond the ceiling — the server saw ${served.length} tools/call for a ceiling of ${String(maxRounds)}`),
93
+ ).toBeLessThanOrEqual((maxRounds as number) + 1);
94
+ });
95
+ });