@openwop/openwop-conformance 1.72.2 → 1.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +58 -0
  4. package/api/openapi.yaml +4 -1
  5. package/dist/cli.js +107 -1
  6. package/dist/lib/profiles.js +70 -4
  7. package/package.json +2 -1
  8. package/schemas/CORPUS-STAMP.json +2 -2
  9. package/schemas/README.md +2 -0
  10. package/schemas/capabilities.schema.json +2054 -572
  11. package/schemas/certification-bundle-v2.schema.json +108 -0
  12. package/schemas/run-event-payloads.schema.json +3411 -857
  13. package/schemas/run-event.schema.json +31 -9
  14. package/schemas/workflow-definition.schema.json +492 -130
  15. package/schemas/workload-identity.schema.json +73 -0
  16. package/src/cli.ts +119 -1
  17. package/src/lib/a2a-fake-peer.ts +20 -0
  18. package/src/lib/behavior-gate.ts +42 -7
  19. package/src/lib/llm-cache-key-recipe.ts +51 -0
  20. package/src/lib/mcp-fake-server.ts +20 -0
  21. package/src/lib/profiles.ts +95 -4
  22. package/src/lib/requirement-ledger.ts +138 -0
  23. package/src/lib/requirement-registry.ts +62 -0
  24. package/src/scenarios/a2a-version-negotiation.test.ts +159 -0
  25. package/src/scenarios/capability-example-root-layout.test.ts +113 -0
  26. package/src/scenarios/certification-bundle-v2.test.ts +157 -0
  27. package/src/scenarios/certification-floor-enforcement.test.ts +115 -0
  28. package/src/scenarios/compensation-behavior.test.ts +164 -0
  29. package/src/scenarios/compensation-profile.test.ts +175 -0
  30. package/src/scenarios/contract-provenance.test.ts +209 -0
  31. package/src/scenarios/core-manifest-and-extension-registry.test.ts +198 -0
  32. package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +219 -0
  33. package/src/scenarios/effect-identity-composition.test.ts +129 -0
  34. package/src/scenarios/effect-identity-cross-scope.test.ts +82 -0
  35. package/src/scenarios/mcp-version-negotiation.test.ts +159 -0
  36. package/src/scenarios/multi-region-effect-vocabulary.test.ts +175 -0
  37. package/src/scenarios/multi-region-idempotency.test.ts +17 -7
  38. package/src/scenarios/openapi-resolved-paths.test.ts +127 -0
  39. package/src/scenarios/protocol-version-grammar.test.ts +119 -0
  40. package/src/scenarios/requirement-ledger.test.ts +162 -0
  41. package/src/scenarios/rfc-0147-self-audit.test.ts +104 -0
  42. package/src/scenarios/rfc-lifecycle-coherence.test.ts +137 -0
  43. package/src/scenarios/semantic-digest-v2.test.ts +128 -0
  44. package/src/scenarios/semantic-digest-vectors.test.ts +140 -0
  45. package/src/scenarios/spec-corpus-validity.test.ts +22 -8
  46. package/src/scenarios/strict-behavior-gate.test.ts +120 -0
  47. package/src/scenarios/versioned-composition-profiles.test.ts +183 -0
  48. package/src/scenarios/workload-identity-behavior.test.ts +188 -0
  49. package/src/scenarios/workload-identity-profile.test.ts +175 -0
  50. package/vectors/semantic-request-digest-v2.json +236 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * RFC 0153 §A/§B — MCP revision negotiation, witnessed against the fake server.
3
+ *
4
+ * The companion to `a2a-version-negotiation.test.ts`, and it exists for the same
5
+ * reason: `McpFakeServer` recorded the JSON-RPC method, params, and timestamp
6
+ * but **not headers** — while MCP's revision is negotiated in
7
+ * `MCP-Protocol-Version`. A recorder that captures only the body can see that a
8
+ * call happened and not which revision it was made under, which is the whole of
9
+ * what §B governs.
10
+ *
11
+ * **Both fake peers had the identical gap**, which is the part worth keeping:
12
+ * it was not an oversight in one file but a shared assumption that the
13
+ * interesting part of a call is its body. Version negotiation is the case where
14
+ * that assumption is exactly wrong.
15
+ *
16
+ * MCP revisions are **dates**, and that shapes the assertions. A host that sends
17
+ * `2026-7-28` or `latest` has sent something no peer can match against a pinned
18
+ * revision, and the failure surfaces at the peer rather than at the handshake
19
+ * meant to prevent it. So the header is checked for date form, not merely for
20
+ * presence.
21
+ *
22
+ * `Pinned real MCP current peer passes in CI` stays separate and unmet — that
23
+ * item tests interoperation, which no fake stands in for, and RFC 0153's
24
+ * acceptance criteria still say so.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { driver } from '../lib/driver.js';
29
+ import { behaviorGate } from '../lib/behavior-gate.js';
30
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
31
+ import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
32
+
33
+ const PROFILE = 'mcp.versionNegotiation';
34
+ const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/;
35
+
36
+ interface McpCaps {
37
+ readonly supported?: boolean;
38
+ readonly protocolVersions?: readonly string[];
39
+ readonly preferredVersion?: string;
40
+ readonly features?: readonly string[];
41
+ }
42
+
43
+ async function mcp(): Promise<McpCaps | undefined> {
44
+ const disco = await driver.get('/.well-known/openwop');
45
+ return capabilityFamily<McpCaps>(disco.json, 'mcp');
46
+ }
47
+
48
+ async function negotiationAdvertised(): Promise<boolean> {
49
+ const caps = await mcp();
50
+ return caps?.supported === true && (caps.protocolVersions?.length ?? 0) > 0;
51
+ }
52
+
53
+ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
54
+ it('advertised revisions use MCP date form and include the preferred one', async () => {
55
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
56
+ const caps = await mcp();
57
+ for (const v of caps?.protocolVersions ?? []) {
58
+ expect(
59
+ DATE_FORM.test(v),
60
+ driver.describe(
61
+ 'RFCS/0153-mcp-2026-07-28-versioned-composition.md §A',
62
+ `'${v}' is not MCP's date form. MCP revisions ARE dates, and 'latest' or an unpadded ` +
63
+ 'month lets two hosts disagree about which revision they share while both look valid.',
64
+ ),
65
+ ).toBe(true);
66
+ }
67
+ expect(
68
+ caps?.protocolVersions ?? [],
69
+ driver.describe('RFCS/0153 §A', '`preferredVersion` MUST be one the host actually lists'),
70
+ ).toContain(caps?.preferredVersion);
71
+ });
72
+
73
+ it('outbound calls carry MCP-Protocol-Version in date form', async () => {
74
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
75
+ const server = getMcpFakeServer();
76
+ if (server === null) return;
77
+ server.reset();
78
+ const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint() });
79
+ if (drive.status === 404 || drive.status === 403) {
80
+ expect(
81
+ drive.status,
82
+ driver.describe(
83
+ 'RFCS/0153 §B',
84
+ 'a host advertising MCP revisions MUST expose an invoke seam so the negotiated revision ' +
85
+ 'is observable. Without it the requirement resolves to `blocked` per RFC 0148 §A — not ' +
86
+ 'to a pass.',
87
+ ),
88
+ ).not.toBe(404);
89
+ return;
90
+ }
91
+ const calls = server.invocations();
92
+ expect(calls.length, 'the host MUST have called the server').toBeGreaterThan(0);
93
+ for (const c of calls) {
94
+ const v = c.headers['mcp-protocol-version'];
95
+ expect(
96
+ v,
97
+ driver.describe('RFCS/0153 §A', 'every MCP call MUST declare its revision'),
98
+ ).toBeTruthy();
99
+ expect(
100
+ DATE_FORM.test(v ?? ''),
101
+ driver.describe(
102
+ 'RFCS/0153 §A',
103
+ `the revision on the wire MUST be MCP's date form; got '${v}'. A malformed revision is ` +
104
+ 'unmatchable against a pinned peer, and the failure then surfaces at the peer rather ' +
105
+ 'than at the handshake meant to prevent it.',
106
+ ),
107
+ ).toBe(true);
108
+ }
109
+ });
110
+
111
+ it('the negotiated revision is one the host advertises', async () => {
112
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
113
+ const server = getMcpFakeServer();
114
+ if (server === null) return;
115
+ const caps = await mcp();
116
+ server.reset();
117
+ const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint() });
118
+ if (drive.status === 404 || drive.status === 403) return;
119
+ // Discovery is a promise about behavior. A host that negotiates a revision
120
+ // it never advertised has made its own discovery document unreliable, which
121
+ // is worse than advertising nothing — a consumer that read it made a
122
+ // decision on a fact that was not true.
123
+ for (const c of server.invocations()) {
124
+ expect(
125
+ caps?.protocolVersions ?? [],
126
+ driver.describe(
127
+ 'RFCS/0153 §A/§B',
128
+ 'the revision sent on the wire MUST appear in `protocolVersions`. Negotiating an ' +
129
+ 'unadvertised revision makes the discovery document unreliable for every consumer ' +
130
+ 'that read it.',
131
+ ),
132
+ ).toContain(c.headers['mcp-protocol-version']);
133
+ }
134
+ });
135
+
136
+ it('an unsupported revision fails through the canonical interop envelope', async () => {
137
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
138
+ const server = getMcpFakeServer();
139
+ if (server === null) return;
140
+ const drive = await driver.post('/v1/host/sample/mcp/invoke', {
141
+ serverUrl: server.endpoint(),
142
+ requestVersion: '1999-01-01',
143
+ });
144
+ if (drive.status === 404 || drive.status === 403) return;
145
+ expect(
146
+ drive.status >= 400,
147
+ driver.describe('RFCS/0153 §B', 'an unsupported revision MUST fail rather than silently proceed'),
148
+ ).toBe(true);
149
+ expect(
150
+ (drive.json as { error?: { code?: string } }).error?.code,
151
+ driver.describe(
152
+ 'RFCS/0153 §B',
153
+ 'the upstream error MUST be projected through the canonical OpenWOP interop envelope — a ' +
154
+ 'raw JSON-RPC error body leaves the caller parsing a foreign protocol to learn its own ' +
155
+ 'request was rejected',
156
+ ),
157
+ ).toBeTruthy();
158
+ });
159
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RFC 0150 §D — record reconciliation and effect authorization are separate
3
+ * claims, and the capability vocabulary must not conflate them.
4
+ *
5
+ * Two defects, both in the corpus rather than in any host.
6
+ *
7
+ * 1. `crossRegion` read as a safety ladder while meaning something else at the
8
+ * top. `schemas/capabilities.schema.json` documented `strict` as
9
+ * "cross-region read-visibility is bounded by
10
+ * `multiRegion.replicationLagBoundMs`" — a LATENCY claim. A host can
11
+ * replicate synchronously at 0 ms and still issue duplicate external effects
12
+ * from two regions, because knowing what the other region wrote is not the
13
+ * same as being authorized to act. `strict` therefore sat at the top of an
14
+ * enum implementers read as an effect-safety claim while promising nothing
15
+ * about effects. Its latency content already had its own field, so it is
16
+ * removed rather than renamed: `fenced-effects` is a genuinely stronger
17
+ * property, and promoting existing `strict` advertisements into it by rename
18
+ * would assert evidence no host has produced.
19
+ *
20
+ * 2. `partitionRecoveryStrategy` offering rules the annex forbids.
21
+ * `spec/v1/idempotency.md` §"Guarantees under partition" MUSTs lex-min(runId)
22
+ * convergence, "deterministic without coordination", and the `multiRegion`
23
+ * block MUSTs that "re-running the same conflict input MUST produce the same
24
+ * survivor". Both `last-writer-wins` and `first-writer-wins` are time-ordered:
25
+ * under a partition there is no shared clock, so both regions believe they
26
+ * wrote last (or first), and neither can produce a reproducible survivor.
27
+ * They also select a different survivor than the lex-min rule the same
28
+ * document requires. The schema was advertising strategies that violate two
29
+ * MUSTs already in force. RFC 0150 §D names only `last-writer-wins`; leaving
30
+ * `first-writer-wins` would keep the identical defect under a different label.
31
+ *
32
+ * This gate reads the schema and the prose. It observes no host, and says
33
+ * nothing about whether any engine fences correctly — that is gap G10.
34
+ *
35
+ * `spec/v1/` ships in the repository and NOT in the published tarball, so the
36
+ * prose legs self-skip under the published layout; the schema ships in both.
37
+ */
38
+
39
+ import { describe, it, expect } from 'vitest';
40
+ import { readFileSync } from 'node:fs';
41
+ import { join, resolve as pathResolve } from 'node:path';
42
+ import { V1_DIR } from '../lib/paths.js';
43
+
44
+ const SCHEMA_PATH =
45
+ V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'schemas', 'capabilities.schema.json');
46
+
47
+ interface EnumLike {
48
+ readonly enum?: readonly string[];
49
+ readonly anyOf?: readonly EnumLike[];
50
+ readonly properties?: Record<string, EnumLike>;
51
+ }
52
+
53
+ function idempotencyCaps(): EnumLike | null {
54
+ if (SCHEMA_PATH === null) return null;
55
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as {
56
+ properties?: Record<string, EnumLike>;
57
+ };
58
+ return schema.properties?.['idempotency'] ?? null;
59
+ }
60
+
61
+ /** Every literal in an `enum`, including those nested under `anyOf`. */
62
+ function literals(node: EnumLike | undefined): string[] {
63
+ if (node === undefined) return [];
64
+ return [...(node.enum ?? []), ...(node.anyOf ?? []).flatMap(literals)];
65
+ }
66
+
67
+ describe.skipIf(V1_DIR === null)('RFC 0150 §D — multi-region effect vocabulary', () => {
68
+ const idem = idempotencyCaps();
69
+ const doc = V1_DIR === null ? '' : readFileSync(join(V1_DIR as string, 'idempotency.md'), 'utf8');
70
+
71
+ it('the idempotency capability family is found at all', () => {
72
+ // Guard: a lookup that silently returned null would make every leg below
73
+ // vacuously true — the failure RFC 0148 exists to close.
74
+ expect(idem, 'capabilities.schema.json MUST declare an `idempotency` family').not.toBeNull();
75
+ expect(
76
+ literals(idem?.properties?.['crossRegion']).length,
77
+ 'the `crossRegion` enum MUST be non-empty',
78
+ ).toBeGreaterThan(0);
79
+ });
80
+
81
+ it('crossRegion offers the three RFC 0150 §D postures', () => {
82
+ const values = literals(idem?.properties?.['crossRegion']);
83
+ expect(
84
+ [...values].sort(),
85
+ 'RFC 0150 §D: the canonical postures are `single-region` (no cross-region guarantee), ' +
86
+ '`reconciled-records` (records converge, effects may remain at-least-once), and ' +
87
+ '`fenced-effects` (records converge AND every effect is fenced or provider-idempotent).',
88
+ ).toEqual(['fenced-effects', 'reconciled-records', 'single-region']);
89
+ });
90
+
91
+ it('crossRegion does not carry `strict`, which was a latency claim in a safety slot', () => {
92
+ const values = literals(idem?.properties?.['crossRegion']);
93
+ expect(
94
+ values.includes('strict'),
95
+ 'RFC 0150 §D: `strict` promised bounded read-visibility, not effect authorization — a host ' +
96
+ 'replicating at 0 ms can still issue duplicate effects from two regions. Its latency ' +
97
+ 'content belongs to `multiRegion.replicationLagBoundMs`, which already carries it.',
98
+ ).toBe(false);
99
+ });
100
+
101
+ it('partitionRecoveryStrategy offers no time-ordered rule', () => {
102
+ const values = literals(idem?.properties?.['multiRegion']?.properties?.['partitionRecoveryStrategy']);
103
+ const timeOrdered = values.filter((v) => v === 'last-writer-wins' || v === 'first-writer-wins');
104
+ expect(
105
+ timeOrdered,
106
+ 'RFC 0150 §D: under a partition there is no shared clock, so a time-ordered rule cannot ' +
107
+ 'satisfy the annex MUST that "re-running the same conflict input MUST produce the same ' +
108
+ 'survivor", and it selects a different survivor than the lex-min(runId) rule the same ' +
109
+ 'document requires. Removing only `last-writer-wins` leaves the identical defect under ' +
110
+ `a different label. Found: ${timeOrdered.join(', ')}`,
111
+ ).toEqual([]);
112
+ });
113
+
114
+ it('partitionRecoveryStrategy names the rule the annex actually requires', () => {
115
+ const values = literals(idem?.properties?.['multiRegion']?.properties?.['partitionRecoveryStrategy']);
116
+ expect(
117
+ values,
118
+ 'the annex MUSTs lex-min(runId) convergence, so that rule MUST be nameable in the ' +
119
+ 'advertisement rather than reachable only through a vendor `x-host-*` extension.',
120
+ ).toContain('lexicographic-min-run-id');
121
+ });
122
+
123
+ it('the spec states that reconciliation does not authorize effects', () => {
124
+ const plain = doc.replace(/[`*_]/g, '').replace(/\s+/g, ' ');
125
+ expect(
126
+ /MUST NOT authorize effects/.test(plain),
127
+ 'RFC 0150 §D: run-record reconciliation and permission to issue effects are separate. ' +
128
+ 'Lexicographic run-ID reconciliation MAY select a surviving record but MUST NOT ' +
129
+ 'authorize an external effect — the sentence the whole section rests on.',
130
+ ).toBe(true);
131
+ });
132
+
133
+ it('a host that can neither fence nor rely on provider dedup must say so', () => {
134
+ const plain = doc.replace(/[`*_]/g, '').replace(/\s+/g, ' ');
135
+ expect(
136
+ /at-least-once-risk/.test(plain),
137
+ 'RFC 0150 §D: absent a fencing token or a provider guaranteeing duplicate suppression, a ' +
138
+ 'host MUST NOT claim strict multi-region effect safety and MUST classify the effect as ' +
139
+ '`at-least-once-risk`. An unclassifiable risk is the one operators cannot plan around.',
140
+ ).toBe(true);
141
+ });
142
+
143
+ it('the capability advertisement example parses as JSON', () => {
144
+ // RFC 0149 §B found this and deliberately left it: the annex's example is
145
+ // fenced as ```json while containing `"single-region" | "best-effort" |
146
+ // "strict"`, which no parser accepts. An example that cannot parse is
147
+ // exactly what RFC 0150 §D's extraction requirement exists to catch.
148
+ const lines = doc.split('\n');
149
+ const blocks: { line: number; body: string }[] = [];
150
+ for (let i = 0; i < lines.length; i++) {
151
+ if (lines[i]!.trim() !== '```json') continue;
152
+ const body: string[] = [];
153
+ let j = i + 1;
154
+ while (j < lines.length && lines[j]!.trim() !== '```') body.push(lines[j++]!);
155
+ blocks.push({ line: i + 2, body: body.join('\n') });
156
+ i = j;
157
+ }
158
+ expect(blocks.length, 'idempotency.md MUST contain fenced json examples to check').toBeGreaterThan(0);
159
+ const unparseable = blocks
160
+ .filter((b) => {
161
+ try {
162
+ JSON.parse(b.body);
163
+ return false;
164
+ } catch {
165
+ return true;
166
+ }
167
+ })
168
+ .map((b) => `spec/v1/idempotency.md:${b.line}`);
169
+ expect(
170
+ unparseable,
171
+ 'a block fenced as ```json MUST parse as JSON. Union-type notation belongs in prose or a ' +
172
+ '```text fence.\n ' + unparseable.join('\n '),
173
+ ).toEqual([]);
174
+ });
175
+ });
@@ -3,8 +3,18 @@
3
3
  *
4
4
  * Verifies that hosts advertising the multi-region idempotency annex
5
5
  * surface a valid `capabilities.idempotency.crossRegion` value AND, when
6
- * claiming `'best-effort'` or `'strict'`, expose the operator-tier
7
- * metric names per `idempotency.md` §"Operator surface".
6
+ * claiming `'reconciled-records'` or `'fenced-effects'`, expose the
7
+ * operator-tier metric names per `idempotency.md` §"Operator surface".
8
+ *
9
+ * RFC 0150 §D revised the vocabulary. `best-effort` became
10
+ * `reconciled-records` — it always meant the RECORDS converge, and the old
11
+ * name invited hearing "a best effort at not duplicating effects". `strict`
12
+ * was removed rather than renamed: it promised only that read-visibility was
13
+ * bounded by `multiRegion.replicationLagBoundMs`, a LATENCY claim sitting at
14
+ * the top of a ladder implementers read as effect safety. `fenced-effects`
15
+ * takes that slot and means something different and stronger, so promoting
16
+ * old `strict` advertisements into it by rename would have asserted evidence
17
+ * no host produced.
8
18
  *
9
19
  * The annex's partition-replay convergence rule cannot be exercised
10
20
  * black-box (it requires multi-region host deployment under a real
@@ -22,7 +32,7 @@ import { describe, it, expect } from 'vitest';
22
32
  import { driver } from '../lib/driver.js';
23
33
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
24
34
 
25
- const ALLOWED = new Set(['single-region', 'best-effort', 'strict']);
35
+ const ALLOWED = new Set(['single-region', 'reconciled-records', 'fenced-effects']);
26
36
  const REQUIRED_METRICS_WHEN_MULTI_REGION = [
27
37
  'openwop.idempotency.cross_region_conflicts_total',
28
38
  ];
@@ -53,7 +63,7 @@ describe('multi-region-idempotency: capability shape', () => {
53
63
 
54
64
  expect(ALLOWED.has(idem.crossRegion), driver.describe(
55
65
  'idempotency.md §"Multi-region idempotency" §"Capability advertisement"',
56
- 'crossRegion MUST be one of {"single-region","best-effort","strict"}',
66
+ 'crossRegion MUST be one of {"single-region","reconciled-records","fenced-effects"}',
57
67
  )).toBe(true);
58
68
 
59
69
  if (idem.layer1RetentionSeconds !== undefined) {
@@ -70,7 +80,7 @@ describe('multi-region-idempotency: capability shape', () => {
70
80
  const observability = capabilityFamily<ObservabilityCaps>(disco.json, 'observability');
71
81
  const crossRegion = idem?.crossRegion;
72
82
 
73
- if (crossRegion !== 'best-effort' && crossRegion !== 'strict') {
83
+ if (crossRegion !== 'reconciled-records' && crossRegion !== 'fenced-effects') {
74
84
  // Single-region hosts have no conflicts to count — skip.
75
85
  return;
76
86
  }
@@ -130,13 +140,13 @@ describe('multi-region-idempotency: granular multiRegion advertisement shape (RF
130
140
  }
131
141
  if (mr.partitionRecoveryStrategy !== undefined) {
132
142
  const s = mr.partitionRecoveryStrategy as string;
133
- const isCategorical = s === 'last-writer-wins' || s === 'first-writer-wins';
143
+ const isCategorical = s === 'lexicographic-min-run-id';
134
144
  const isExtension = /^x-host-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*$/.test(s);
135
145
  expect(
136
146
  isCategorical || isExtension,
137
147
  driver.describe(
138
148
  'RFCS/0036-multi-region-and-cross-engine-guarantees.md §A',
139
- 'partitionRecoveryStrategy MUST be one of {last-writer-wins, first-writer-wins} OR match ^x-host-<host>-<key>$',
149
+ 'partitionRecoveryStrategy MUST be `lexicographic-min-run-id` OR match ^x-host-<host>-<key>$ (RFC 0150 §D removed the time-ordered rules: with no shared clock under a partition, each region believes it wrote last, so neither can produce the reproducible survivor the annex requires)',
140
150
  ),
141
151
  ).toBe(true);
142
152
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * RFC 0149 §A — canonical URL resolution.
3
+ *
4
+ * A generator resolves an operation's URL by joining a `servers[].url` with a
5
+ * path key. Both halves are individually valid here, and every existing
6
+ * validator checks them separately: `redocly lint` accepts the server, accepts
7
+ * the paths, and never composes the two. The defect only exists in the join.
8
+ *
9
+ * That is why this gate resolves the PAIR. `servers[].url` ending in `/v1`
10
+ * against path keys beginning with `/v1/` yields `/v1/v1/runs` — a route no
11
+ * host serves, emitted by every client generated from the canonical contract.
12
+ * The reference SDKs are the control: `OpenwopClient` issues `/v1/runs`
13
+ * against a bare base URL, so the SDKs and the OpenAPI document disagree about
14
+ * where the version segment lives, and the SDKs are the ones that work.
15
+ *
16
+ * Server-free and always-on: this is a property of the corpus, not of a host,
17
+ * so there is no capability to gate on and nothing to skip.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { API_DIR } from '../lib/paths.js';
24
+
25
+ const OPENAPI_PATH = join(API_DIR, 'openapi.yaml');
26
+
27
+ /**
28
+ * Top-level `servers[].url` values.
29
+ *
30
+ * Text-scanned rather than YAML-parsed to match the rest of the corpus gates
31
+ * (`spec-corpus-validity.test.ts` uses `readYamlHeader`), which keeps the
32
+ * conformance package free of a YAML dependency.
33
+ */
34
+ function serverUrls(raw: string): string[] {
35
+ const urls: string[] = [];
36
+ let inServers = false;
37
+ for (const line of raw.split('\n')) {
38
+ if (/^servers:/.test(line)) {
39
+ inServers = true;
40
+ continue;
41
+ }
42
+ // Any other unindented, non-comment, non-blank line ends the block.
43
+ if (inServers && /^[^\s#]/.test(line)) break;
44
+ if (!inServers) continue;
45
+ const m = /^\s*-\s*url:\s*(\S+)\s*$/.exec(line);
46
+ if (m?.[1] !== undefined) urls.push(m[1]);
47
+ }
48
+ return urls;
49
+ }
50
+
51
+ /** Top-level path keys under `paths:` (two-space indented, starting with `/`). */
52
+ function pathKeys(raw: string): string[] {
53
+ const keys: string[] = [];
54
+ let inPaths = false;
55
+ for (const line of raw.split('\n')) {
56
+ if (/^paths:/.test(line)) {
57
+ inPaths = true;
58
+ continue;
59
+ }
60
+ if (inPaths && /^[^\s#]/.test(line)) break;
61
+ if (!inPaths) continue;
62
+ const m = /^ {2}(\/\S*):\s*$/.exec(line);
63
+ if (m?.[1] !== undefined) keys.push(m[1]);
64
+ }
65
+ return keys;
66
+ }
67
+
68
+ /**
69
+ * The path portion of a resolved URL, with the `{host}` template left intact —
70
+ * `URL` cannot parse a templated authority, and substituting a placeholder
71
+ * host would test a string this corpus never emits.
72
+ */
73
+ function resolvedPath(serverUrl: string, pathKey: string): string {
74
+ const afterScheme = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
75
+ const slash = afterScheme.indexOf('/');
76
+ const basePath = slash === -1 ? '' : afterScheme.slice(slash);
77
+ return `${basePath.replace(/\/$/, '')}${pathKey}`;
78
+ }
79
+
80
+ describe('RFC 0149 §A — every server/path pair resolves to exactly one /v1 segment', () => {
81
+ const raw = readFileSync(OPENAPI_PATH, 'utf8');
82
+ const servers = serverUrls(raw);
83
+ const paths = pathKeys(raw);
84
+
85
+ it('the document declares at least one server and one path', () => {
86
+ // Guards the gate itself: an extraction that silently found nothing would
87
+ // make every assertion below vacuously true — the exact failure mode
88
+ // RFC 0148 exists to prevent.
89
+ expect(servers.length, `${OPENAPI_PATH} MUST declare servers[]`).toBeGreaterThan(0);
90
+ expect(paths.length, `${OPENAPI_PATH} MUST declare paths`).toBeGreaterThan(0);
91
+ });
92
+
93
+ it('no versioned operation resolves to a duplicated /v1 prefix', () => {
94
+ const offenders: string[] = [];
95
+ for (const server of servers) {
96
+ for (const pathKey of paths) {
97
+ if (!pathKey.startsWith('/v1/') && pathKey !== '/v1') continue;
98
+ const resolved = resolvedPath(server, pathKey);
99
+ const segments = resolved.split('/').filter((s) => s === 'v1');
100
+ if (segments.length !== 1) offenders.push(`${server} + ${pathKey} -> ${resolved}`);
101
+ }
102
+ }
103
+ expect(
104
+ offenders,
105
+ 'RFC 0149 §A: a versioned operation MUST resolve with exactly one `/v1` segment. ' +
106
+ 'Offending server/path pairs:\n ' +
107
+ offenders.join('\n ') +
108
+ '\nFix: drop `/v1` from `servers[].url` and keep it in the path keys.',
109
+ ).toEqual([]);
110
+ });
111
+
112
+ it('the unversioned discovery route stays unversioned when resolved', () => {
113
+ // `/.well-known/openwop` is unversioned by RFC 0149 §A. A server base path
114
+ // would silently version it, which is the same class of defect pointing
115
+ // the other way.
116
+ const wellKnown = paths.filter((p) => p.startsWith('/.well-known/'));
117
+ for (const server of servers) {
118
+ for (const pathKey of wellKnown) {
119
+ const resolved = resolvedPath(server, pathKey);
120
+ expect(
121
+ resolved,
122
+ `RFC 0149 §A: ${pathKey} MUST remain unversioned; resolved as ${resolved}`,
123
+ ).toBe(pathKey);
124
+ }
125
+ }
126
+ });
127
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * RFC 0149 §C — `protocolVersion` is `<major>.<minor>`, and the corpus enforces it.
3
+ *
4
+ * The field was specified three incompatible ways at once. `capabilities.schema.json`
5
+ * constrained it to `minLength: 1` — so `"v1.0"`, `"1.0.0"`, `"01.0"`, and `"banana"` all
6
+ * validated. `profiles.ts` derived core-ness from `startsWith('1.')`, which admits
7
+ * `"1.0.0"` and `"1.banana"` while rejecting a legitimate future `"2.0"` for the right
8
+ * reason and `"1"` for the wrong one. Prose described it as semver while every example
9
+ * showed two components.
10
+ *
11
+ * The consequence is a negotiation the wire cannot decide. Version comparison needs an
12
+ * integer major as the hard compatibility boundary and an integer minor as the additive
13
+ * contract level. Neither can be extracted from a string the schema never constrained, so
14
+ * two hosts could advertise `"1.0"` and `"1.0.0"` and no consumer could tell whether it
15
+ * was looking at a patch convention, a typo, or a different protocol.
16
+ *
17
+ * RFC 0149 §C: ASCII `<major>.<minor>`, no leading zero except zero itself. Patch belongs
18
+ * to suite and SDK versions, not the spec version. This also closes gap V2 in
19
+ * `version-negotiation.md` §"Open spec gaps" — "concrete `protocolVersion` semver
20
+ * semantics" — which had sat open with owner `future`.
21
+ *
22
+ * Server-free. The schema ships in both the repository and the published tarball; the
23
+ * prose leg self-skips under the published layout.
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { readFileSync } from 'node:fs';
28
+ import { join, resolve as pathResolve } from 'node:path';
29
+ import { V1_DIR } from '../lib/paths.js';
30
+ import { isCore } from '../lib/profiles.js';
31
+
32
+ /** RFC 0149 §C. */
33
+ const GRAMMAR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
34
+
35
+ const SCHEMA_PATH =
36
+ V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'schemas', 'capabilities.schema.json');
37
+
38
+ function protocolVersionSchema(): { pattern?: string } | null {
39
+ if (SCHEMA_PATH === null) return null;
40
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as {
41
+ properties?: Record<string, { pattern?: string }>;
42
+ };
43
+ return schema.properties?.['protocolVersion'] ?? null;
44
+ }
45
+
46
+ /** A discovery payload that is core-valid except for the version under test. */
47
+ function payload(protocolVersion: unknown) {
48
+ return {
49
+ protocolVersion,
50
+ supportedEnvelopes: ['plan'],
51
+ schemaVersions: {},
52
+ limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
53
+ };
54
+ }
55
+
56
+ const VALID = ['1.0', '1.12', '0.0', '2.0', '10.3'];
57
+ const INVALID = ['1', '1.0.0', 'v1.0', '01.0', '1.', '.0', '1.0-rc1', '', ' 1.0', '1.0 '];
58
+
59
+ describe.skipIf(V1_DIR === null)('RFC 0149 §C — protocolVersion grammar', () => {
60
+ it('the schema declares protocolVersion at all', () => {
61
+ // Guard: a lookup returning null would make the pattern leg vacuous.
62
+ expect(protocolVersionSchema(), 'capabilities.schema.json MUST declare `protocolVersion`').not.toBeNull();
63
+ });
64
+
65
+ it('the schema constrains protocolVersion by pattern, not merely by length', () => {
66
+ const node = protocolVersionSchema();
67
+ expect(
68
+ node?.pattern,
69
+ 'RFC 0149 §C: `minLength: 1` admits `"v1.0"`, `"1.0.0"`, and `"banana"`. Compatibility ' +
70
+ 'comparison needs an integer major and an integer minor, which cannot be extracted from ' +
71
+ 'an unconstrained string.',
72
+ ).toBe(GRAMMAR.source);
73
+ });
74
+
75
+ it.each(VALID)('accepts %s', (v) => {
76
+ expect(GRAMMAR.test(v), `RFC 0149 §C: ${v} is a legal major.minor`).toBe(true);
77
+ });
78
+
79
+ it.each(INVALID)('rejects %s', (v) => {
80
+ expect(
81
+ GRAMMAR.test(v),
82
+ `RFC 0149 §C: ${JSON.stringify(v)} is not major.minor — patch belongs to suite and SDK ` +
83
+ 'versions, and a leading zero is forbidden except for zero itself',
84
+ ).toBe(false);
85
+ });
86
+
87
+ it('core derivation applies the grammar rather than a prefix test', () => {
88
+ // `startsWith('1.')` admitted `1.0.0` and `1.banana` and rejected `1`. The
89
+ // predicate that decides whether a host is openwop-compatible at all must
90
+ // not be looser than the schema every host validates against.
91
+ expect(isCore(payload('1.0')), '`1.0` is core-valid').toBe(true);
92
+ for (const bad of ['1.0.0', '1.banana', '1', 'v1.0', '01.0']) {
93
+ expect(
94
+ isCore(payload(bad)),
95
+ `RFC 0149 §C: \`${bad}\` MUST NOT derive \`openwop-core\` — the predicate cannot be ` +
96
+ 'more permissive than the grammar',
97
+ ).toBe(false);
98
+ }
99
+ });
100
+
101
+ it('a different major is not core; a higher minor is', () => {
102
+ // §C: consumers MUST reject a different unsupported major and MUST tolerate a
103
+ // higher minor under v1 additive rules. The predicate is the v1 suite's, so a
104
+ // v2 host is correctly not-core here — that is a major boundary, not a defect.
105
+ expect(isCore(payload('2.0')), 'a different major is outside this suite').toBe(false);
106
+ expect(isCore(payload('1.99')), 'a higher minor stays core under v1 additive rules').toBe(true);
107
+ });
108
+
109
+ it('the spec states the grammar normatively', () => {
110
+ // Searched raw: the grammar contains `*` and `_`-adjacent metacharacters, so
111
+ // the usual markdown-emphasis strip would eat its own quantifiers.
112
+ const doc = readFileSync(join(V1_DIR as string, 'version-negotiation.md'), 'utf8');
113
+ expect(
114
+ doc.includes(GRAMMAR.source),
115
+ 'RFC 0149 §C: `version-negotiation.md` MUST carry the grammar, so the schema pattern has ' +
116
+ 'a normative source rather than being the only place the rule exists.',
117
+ ).toBe(true);
118
+ });
119
+ });