@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,73 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/workload-identity.schema.json",
4
+ "title": "OpenWOP workload identity and delegated actor chain (RFC 0154)",
5
+ "description": "RFC 0154 §A/§B. An authenticated workload identity, and the delegation context a request may carry.\n\nThe governing rule is negative and it is why every object here is closed: raw certificates, tokens, proofs, and credentials MUST NOT enter these objects. `subject` is an OPAQUE identifier and `proofRef` is a REFERENCE. A verified identity is a fact about a completed verification, not a container for the material that proved it — and these objects are projected into events, spans, and audit records, which is exactly where credential material must never reach (`SECURITY/threat-model-secret-leakage.md`).\n\nThe second rule is that identity is not authorization (RFC 0147 R12). Knowing which workload called says nothing about what it may do; tenant, audience, and policy are re-evaluated at every boundary.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["scheme", "subject"],
9
+ "properties": {
10
+ "scheme": {
11
+ "enum": ["spiffe", "mtls-san", "cloud-subject", "oauth-client"],
12
+ "description": "How the identity was presented and verified. Closed: an unrecognized scheme is a verification path nobody implemented."
13
+ },
14
+ "subject": {
15
+ "type": "string",
16
+ "minLength": 1,
17
+ "description": "Opaque verified identifier, e.g. a SPIFFE ID. MUST NOT be a credential, token, or certificate."
18
+ },
19
+ "issuer": { "type": "string", "minLength": 1 },
20
+ "audience": {
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "description": "RFC 0154 §A — the audience the presented identity was minted for. A host MUST reject an identity whose audience is not its own: accepting one is how a credential valid elsewhere becomes a credential valid here."
24
+ },
25
+ "keyBinding": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "description": "Proof-of-possession binding. Carries the METHOD and a thumbprint reference, never the key.",
29
+ "required": ["method"],
30
+ "properties": {
31
+ "method": { "enum": ["mtls", "dpop"] },
32
+ "thumbprintRef": {
33
+ "type": "string",
34
+ "pattern": "^sha256:[0-9a-f]{64}$",
35
+ "description": "Digest reference to the bound key. Pattern-constrained so a raw key cannot be pasted here and still validate."
36
+ }
37
+ }
38
+ },
39
+ "delegation": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "description": "RFC 0154 §B — the verified delegation chain, if any.",
43
+ "required": ["chain", "audience"],
44
+ "properties": {
45
+ "chain": {
46
+ "type": "array",
47
+ "minItems": 1,
48
+ "description": "Ordered delegation hops. Non-empty: a delegation context with no chain claims a delegation nobody can inspect.",
49
+ "items": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": ["subject"],
53
+ "properties": {
54
+ "subject": { "type": "string", "minLength": 1 },
55
+ "issuer": { "type": "string", "minLength": 1 }
56
+ }
57
+ }
58
+ },
59
+ "audience": { "type": "string", "minLength": 1 },
60
+ "expiresAt": {
61
+ "type": "string",
62
+ "format": "date-time",
63
+ "description": "RFC 0154 §B — a delegation without an expiry is a standing grant, which is not what delegation means."
64
+ },
65
+ "proofRef": {
66
+ "type": "string",
67
+ "pattern": "^sha256:[0-9a-f]{64}$",
68
+ "description": "Digest REFERENCE to the verified proof. Never the proof itself."
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
package/src/cli.ts CHANGED
@@ -50,6 +50,7 @@ interface ParsedArgs {
50
50
  readonly implVersion: string | undefined;
51
51
  /** RFC 0089 — emit a conformance certification bundle to this path. */
52
52
  readonly certify: string | undefined;
53
+ readonly bundleVersion: '1' | '2';
53
54
  }
54
55
 
55
56
  function parseArgs(argv: readonly string[]): ParsedArgs {
@@ -61,6 +62,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
61
62
  let impl: string | undefined;
62
63
  let implVersion: string | undefined;
63
64
  let certify: string | undefined;
65
+ let bundleVersion: '1' | '2' = '1';
64
66
 
65
67
  for (let i = 0; i < argv.length; i++) {
66
68
  const arg = argv[i] ?? '';
@@ -103,6 +105,15 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
103
105
  case '--implementation-version':
104
106
  implVersion = nextValue();
105
107
  break;
108
+ case '--bundle-version': {
109
+ const v = nextValue();
110
+ if (v !== '1' && v !== '2') {
111
+ process.stderr.write(`--bundle-version must be 1 or 2 (got '${v}')\n`);
112
+ process.exit(2);
113
+ }
114
+ bundleVersion = v;
115
+ break;
116
+ }
106
117
  case '--certify':
107
118
  certify = nextValue();
108
119
  break;
@@ -113,7 +124,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
113
124
  }
114
125
  }
115
126
 
116
- return { baseUrl, apiKey, offline, filter, help, impl, implVersion, certify };
127
+ return { baseUrl, apiKey, offline, filter, help, impl, implVersion, certify, bundleVersion };
117
128
  }
118
129
 
119
130
  const HELP_TEXT = `openwop-conformance — run the openwop conformance suite against a server
@@ -134,6 +145,10 @@ Implementation labels (cosmetic — surface in failure messages):
134
145
  --impl-version <version> Implementation version (env: OPENWOP_IMPLEMENTATION_VERSION)
135
146
 
136
147
  Certification (RFC 0089):
148
+ --bundle-version <1|2> Certification bundle format. Default 1. Version 2 (RFC 0148
149
+ §C) records per-requirement DISPOSITIONS instead of pass/fail/skip
150
+ file lists, so "we could not check" stops being indistinguishable
151
+ from "checked and it holds". See the note it prints.
137
152
  --certify <out.json> Generate a machine-readable conformance certification
138
153
  bundle: fetch /.well-known/openwop (captured verbatim +
139
154
  SHA-256), derive claimedProfiles from it, run the suite
@@ -348,6 +363,109 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
348
363
  },
349
364
  };
350
365
 
366
+ // (d2) RFC 0148 §C — bundle v2.
367
+ //
368
+ // v1's `{passed, failed, skipped}` file lists cannot express the distinction
369
+ // this program exists for. A file counted as `passed` whether its assertions
370
+ // ran or its runner returned early, and `skipped` flattened three different
371
+ // claims — operator-excluded, not-applicable, and could-not-check — of which
372
+ // only the first two are certifiable.
373
+ //
374
+ // THE HONEST LIMIT, stated because it changes what a v2 bundle from this
375
+ // runner means: the runner reads vitest's per-FILE outcome. It does not read
376
+ // the RFC 0148 §A requirement ledger, because scenarios do not yet record
377
+ // into it. So a skipped file cannot be split into `skipped` /`inapplicable` /
378
+ // `blocked` here, and §A's rule is that an unclassifiable requirement resolves
379
+ // to `blocked` — never to a pass. That is what this emits.
380
+ //
381
+ // The consequence is deliberate and correct: a v2 bundle produced today shows
382
+ // a large `blocked` count and therefore does NOT certify. That is not a defect
383
+ // in the emitter. It is the true state of the evidence, which v1 was unable to
384
+ // represent and therefore reported as a clean skip.
385
+ if (args.bundleVersion === '2') {
386
+ const scenarioIds = [...passed, ...failed, ...skipped].sort();
387
+ const manifestSha = createHash('sha256').update(scenarioIds.join('\n'), 'utf8').digest('hex');
388
+ // Configuration identity per RFC 0147 §A.4: what the run was pointed at, and
389
+ // the discovery it saw. Two runs against differently-configured hosts are
390
+ // different evidence, and this is what says so.
391
+ const configSha = createHash('sha256')
392
+ .update(`${args.baseUrl}\n${sha256}\n${process.env['OPENWOP_REQUIRE_BEHAVIOR'] ?? ''}`, 'utf8')
393
+ .digest('hex');
394
+
395
+ const requirements = [
396
+ ...passed.map((f) => ({
397
+ requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
398
+ scenarioId: f,
399
+ disposition: 'executed-pass' as const,
400
+ })),
401
+ ...failed.map((f) => ({
402
+ requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
403
+ scenarioId: f,
404
+ disposition: 'executed-fail' as const,
405
+ detail: 'the scenario executed and failed',
406
+ })),
407
+ ...skipped.map((f) => ({
408
+ requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
409
+ scenarioId: f,
410
+ disposition: 'blocked' as const,
411
+ detail:
412
+ 'runner cannot classify a skipped file: the RFC 0148 §A requirement ledger is not ' +
413
+ 'populated during a run, so skipped/inapplicable/blocked are indistinguishable here. ' +
414
+ '§A resolves an unclassifiable requirement to `blocked`, never to a pass.',
415
+ })),
416
+ ];
417
+
418
+ const v2 = {
419
+ bundleVersion: '2' as const,
420
+ generatedAt: new Date().toISOString(),
421
+ generator: { name: '@openwop/openwop-conformance --certify', version },
422
+ suite: { package: '@openwop/openwop-conformance' as const, version },
423
+ host,
424
+ discovery: { url: discoveryUrl, sha256, document },
425
+ claimedProfiles,
426
+ results: {
427
+ totals: {
428
+ executedPass: passed.length,
429
+ executedFail: failed.length,
430
+ skipped: 0,
431
+ inapplicable: 0,
432
+ blocked: skipped.length,
433
+ },
434
+ requirements,
435
+ },
436
+ scenarioManifestSha256: manifestSha,
437
+ targetConfigurationSha256: configSha,
438
+ };
439
+
440
+ const v2Schema = JSON.parse(
441
+ readFileSync(join(SCHEMAS_DIR, 'certification-bundle-v2.schema.json'), 'utf8'),
442
+ ) as Record<string, unknown>;
443
+ const v2Ajv = new Ajv2020({ allErrors: true, strict: false });
444
+ addFormats(v2Ajv);
445
+ const v2Validate = v2Ajv.compile(v2Schema);
446
+ if (!v2Validate(v2)) {
447
+ process.stderr.write(
448
+ 'openwop-conformance --certify: assembled v2 bundle FAILED schema validation:\n' +
449
+ `${JSON.stringify(v2Validate.errors, null, 2)}\n`,
450
+ );
451
+ process.exit(2);
452
+ }
453
+ writeFileSync(outPath, `${JSON.stringify(v2, null, 2)}\n`);
454
+ process.stdout.write(
455
+ `openwop-conformance --certify: wrote bundle v2 to ${outPath}\n` +
456
+ ` host: ${host.name}@${host.version}\n` +
457
+ ` executed-pass ${passed.length} / executed-fail ${failed.length} / blocked ${skipped.length}\n` +
458
+ (skipped.length > 0
459
+ ? ` NOTE: ${skipped.length} requirement(s) are 'blocked', not 'skipped'. The runner cannot\n` +
460
+ ` classify a skipped file until scenarios record into the RFC 0148 §A ledger, and §A\n` +
461
+ ` resolves an unclassifiable requirement to 'blocked' rather than to a pass. A bundle\n` +
462
+ ` with blocked > 0 does NOT certify — that is the true state of the evidence, not a\n` +
463
+ ` defect in this emitter.\n`
464
+ : ''),
465
+ );
466
+ process.exit(failed.length > 0 ? 1 : 0);
467
+ }
468
+
351
469
  // (e) Validate against the bundle schema BEFORE writing.
352
470
  const schema = JSON.parse(
353
471
  readFileSync(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json'), 'utf8'),
@@ -70,6 +70,20 @@ export interface A2APeerInvocation {
70
70
  readonly rpcMethod: string | null;
71
71
  readonly body: unknown;
72
72
  readonly timestamp: number;
73
+ /**
74
+ * Request headers as received, lowercased.
75
+ *
76
+ * Added for RFC 0152 §B, whose central requirement — the sender MUST send
77
+ * `A2A-Version` and a host MUST NOT silently downgrade an authenticated
78
+ * request — lives entirely in the headers. Without capturing them the peer
79
+ * could observe that a call happened but not which version was negotiated,
80
+ * which is the only part §B is about.
81
+ *
82
+ * The absence was itself the blocker: RFC 0152 was being described as needing
83
+ * a live upstream peer when part of what it needed was for this fake one to
84
+ * record what it already received.
85
+ */
86
+ readonly headers: Readonly<Record<string, string>>;
73
87
  }
74
88
 
75
89
  export class A2AFakePeer {
@@ -186,12 +200,18 @@ export class A2AFakePeer {
186
200
  ? String((body as { method: unknown }).method)
187
201
  : null;
188
202
 
203
+ const headers: Record<string, string> = {};
204
+ for (const [k, v] of Object.entries(req.headers)) {
205
+ if (typeof v === 'string') headers[k.toLowerCase()] = v;
206
+ else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(', ');
207
+ }
189
208
  this._invocations.push({
190
209
  method: req.method ?? 'GET',
191
210
  path: url,
192
211
  rpcMethod,
193
212
  body,
194
213
  timestamp: Date.now(),
214
+ headers,
195
215
  });
196
216
 
197
217
  // GET /.well-known/agent-card.json — A2A v0.3 well-known path
@@ -51,6 +51,12 @@
51
51
 
52
52
  import { expect } from 'vitest';
53
53
  import { loadEnv } from './env.js';
54
+ import { recordRequirement } from './requirement-ledger.js';
55
+
56
+ /** RFC 0148 §A requirement ID for a capability-gated profile decision. */
57
+ export function profileRequirementId(profileName: string): string {
58
+ return `openwop.profile.${profileName}`;
59
+ }
54
60
 
55
61
  /**
56
62
  * Returns true if the scenario should proceed with assertions (advertised),
@@ -69,18 +75,38 @@ export function behaviorGate(profileName: string, advertised: boolean): boolean
69
75
  const optedOut = env.optedOutProfiles.has(profileName);
70
76
 
71
77
  if (advertised && optedOut) {
72
- // eslint-disable-next-line no-console
73
- console.warn(
74
- `[${profileName}] both ADVERTISED by the host AND listed in OPENWOP_OPTED_OUT_PROFILES ` +
75
- `opt-out is ignored. Remove from the env var to clear this warning.`,
78
+ // RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
79
+ // profile." This was a `console.warn` that then proceeded as advertised —
80
+ // and a MUST NOT enforced by a warning is not enforced. Nothing consumes the
81
+ // warning, nothing fails on it, and the certification bundle produced from
82
+ // that run records a pass.
83
+ //
84
+ // The two claims are opposite in kind: advertising says the host implements
85
+ // the profile, opting out says the operator declares it does not. A run
86
+ // where both hold has no defensible reading, and resolving it in favour of
87
+ // advertisement extracted MORE certification claim from a MORE
88
+ // contradictory input.
89
+ throw new Error(
90
+ `RFC 0148 §B: [${profileName}] is BOTH advertised by the host AND listed in ` +
91
+ `OPENWOP_OPTED_OUT_PROFILES. A host MUST NOT both advertise and opt out of the same ` +
92
+ `profile — the two are contradictory claims and neither can be trusted while both ` +
93
+ `stand. Remove it from OPENWOP_OPTED_OUT_PROFILES, or stop advertising it.`,
76
94
  );
77
95
  }
78
96
 
79
97
  if (advertised) return true;
80
98
 
81
99
  if (optedOut) {
82
- // Honest opt-out: the operator declared the host does not implement
83
- // this profile. Skip in BOTH default and strict mode.
100
+ // Honest opt-out: the operator declared the host does not implement this
101
+ // profile. Skip in BOTH default and strict mode. Recorded as `skipped`
102
+ // rather than left silent — RFC 0148 §A resolves an unrecorded requirement
103
+ // to `blocked`, so without this an opted-out profile and a never-run profile
104
+ // would be the same observable.
105
+ recordRequirement(
106
+ profileRequirementId(profileName),
107
+ 'skipped',
108
+ 'operator declared an honest opt-out via OPENWOP_OPTED_OUT_PROFILES',
109
+ );
84
110
  // eslint-disable-next-line no-console
85
111
  console.warn(
86
112
  `[${profileName}] honest opt-out (OPENWOP_OPTED_OUT_PROFILES); skipping`,
@@ -98,7 +124,16 @@ export function behaviorGate(profileName: string, advertised: boolean): boolean
98
124
  // expect.toBe(true) throws; we won't reach here.
99
125
  }
100
126
 
101
- // Default-mode soft-skip.
127
+ // Default-mode soft-skip. Recorded as `inapplicable`: the host does not
128
+ // advertise the profile and the operator made no declaration, so the
129
+ // requirement does not apply to this discovery set. That is a different claim
130
+ // from `blocked` ("we could not check"), and it is certifiable where blocked
131
+ // is not — RFC 0148 §A.
132
+ recordRequirement(
133
+ profileRequirementId(profileName),
134
+ 'inapplicable',
135
+ 'profile not advertised in the captured discovery set',
136
+ );
102
137
  // eslint-disable-next-line no-console
103
138
  console.warn(
104
139
  `[${profileName}] profile not advertised; skipping (set OPENWOP_REQUIRE_BEHAVIOR=true to fail)`,
@@ -57,6 +57,57 @@ export function expectedCacheKey(input: Record<string, unknown>): string {
57
57
  return createHash('sha256').update(canonicalize(projectRecipe(input)), 'utf8').digest('hex');
58
58
  }
59
59
 
60
+ /** RFC 0150 §C recipe stamp. Present in the preimage so a v1 digest and a v2
61
+ * digest for the same request cannot be mistaken for each other. */
62
+ export const SEMANTIC_REQUEST_RECIPE_V2 = 'openwop-semantic-request-v2';
63
+
64
+ /**
65
+ * RFC 0150 §C — project to the **v2** semantic request.
66
+ *
67
+ * The difference from v1 is not additive tidying. v1 EXCLUDED `maxOutputTokens`,
68
+ * `stop`, and `seed`, and every one of them changes the completion — so two
69
+ * requests that produce different text hashed to the same key. That is a wrong
70
+ * hit rather than a miss, which is why v2 is a safety-fix and not a refinement.
71
+ *
72
+ * Transport-only fields are still excluded. The test is whether a field can
73
+ * change what the model returns, not whether it appears in the HTTP request.
74
+ */
75
+ export function projectSemanticRequestV2(raw: Record<string, unknown>): Record<string, unknown> {
76
+ const request: Record<string, unknown> = { messages: raw.messages };
77
+ if (Array.isArray(raw.tools) && raw.tools.length > 0) {
78
+ request.tools = [...(raw.tools as Array<{ name: string }>)].sort((a, b) => a.name.localeCompare(b.name));
79
+ }
80
+ for (const k of ['temperature', 'topP', 'topK', 'maxOutputTokens', 'seed'] as const) {
81
+ if (typeof raw[k] === 'number') request[k] = raw[k];
82
+ }
83
+ if (Array.isArray(raw.stop)) request.stop = raw.stop;
84
+ if (raw.responseFormat !== undefined && typeof raw.responseFormat === 'object') {
85
+ request.responseFormat = raw.responseFormat;
86
+ }
87
+ if (raw.safetySettings !== undefined && typeof raw.safetySettings === 'object') {
88
+ request.safetySettings = raw.safetySettings;
89
+ }
90
+ const out: Record<string, unknown> = {
91
+ recipe: SEMANTIC_REQUEST_RECIPE_V2,
92
+ provider: raw.provider,
93
+ model: raw.model,
94
+ request,
95
+ };
96
+ // Carried, never dropped: a dropped option that alters output is
97
+ // indistinguishable from one that was never set.
98
+ if (raw.providerOptions !== undefined && typeof raw.providerOptions === 'object') {
99
+ out.providerOptions = raw.providerOptions;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** RFC 0150 §C — SHA-256 over the JCS-canonical v2 object, lowercase hex. */
105
+ export function semanticRequestDigestV2(input: Record<string, unknown>): string {
106
+ return createHash('sha256')
107
+ .update(canonicalize(projectSemanticRequestV2(input)), 'utf8')
108
+ .digest('hex');
109
+ }
110
+
60
111
  /** Drive the host's `POST /v1/host/sample/test/llm-cache-key` test seam.
61
112
  * Returns the host's emitted cacheKey when the seam responds 200; status
62
113
  * alone when the seam returns 404 (host doesn't expose the seam → caller
@@ -30,6 +30,20 @@ export interface McpInvocation {
30
30
  readonly method: string;
31
31
  readonly params: unknown;
32
32
  readonly timestamp: number;
33
+ /**
34
+ * Request headers as received, lowercased.
35
+ *
36
+ * Added for RFC 0153 §A/§B alongside the identical addition to
37
+ * `A2AFakePeer`. MCP's revision is negotiated in `MCP-Protocol-Version`, so a
38
+ * recorder that captures only the JSON-RPC method and params can see that a
39
+ * call happened and not which revision it was made under — which is the whole
40
+ * of what §B governs.
41
+ *
42
+ * Both fake peers had the same gap, which is worth noting: the omission was
43
+ * not an oversight in one file but a shared assumption that the interesting
44
+ * part of a call is its body.
45
+ */
46
+ readonly headers: Readonly<Record<string, string>>;
33
47
  }
34
48
 
35
49
  export class McpFakeServer {
@@ -93,10 +107,16 @@ export class McpFakeServer {
93
107
  }
94
108
 
95
109
  if (typeof rpc.method === 'string') {
110
+ const headers: Record<string, string> = {};
111
+ for (const [k, v] of Object.entries(req.headers)) {
112
+ if (typeof v === 'string') headers[k.toLowerCase()] = v;
113
+ else if (Array.isArray(v)) headers[k.toLowerCase()] = v.join(', ');
114
+ }
96
115
  this._invocations.push({
97
116
  method: rpc.method,
98
117
  params: rpc.params ?? null,
99
118
  timestamp: Date.now(),
119
+ headers,
100
120
  });
101
121
  }
102
122
 
@@ -83,6 +83,14 @@ function isNonNegativeInteger(value: unknown): value is number {
83
83
  return typeof value === 'number' && Number.isInteger(value) && value >= 0;
84
84
  }
85
85
 
86
+ /**
87
+ * RFC 0149 §C — `protocolVersion` is ASCII `<major>.<minor>`, no leading zero
88
+ * except zero itself. Kept identical to the `pattern` in
89
+ * `schemas/capabilities.schema.json`; `protocol-version-grammar.test.ts` asserts
90
+ * the two agree, so this cannot drift into being the looser of the pair again.
91
+ */
92
+ const PROTOCOL_VERSION_GRAMMAR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
93
+
86
94
  /**
87
95
  * `openwop-core` predicate. Every other profile implies `openwop-core`. A host
88
96
  * that fails this predicate is not openwop-compatible.
@@ -91,7 +99,13 @@ function isNonNegativeInteger(value: unknown): value is number {
91
99
  */
92
100
  export function isCore(c: DiscoveryPayload): boolean {
93
101
  if (typeof c.protocolVersion !== 'string') return false;
94
- if (!c.protocolVersion.startsWith('1.')) return false;
102
+ // RFC 0149 §C. `startsWith('1.')` admitted `1.0.0` and `1.banana` while
103
+ // rejecting `1`, so the predicate deciding whether a host is openwop-compatible
104
+ // at all was looser than the schema every host validates against. The major is
105
+ // the hard boundary; a higher minor stays core under v1 additive rules.
106
+ const version = PROTOCOL_VERSION_GRAMMAR.exec(c.protocolVersion);
107
+ if (version === null) return false;
108
+ if (version[1] !== '1') return false;
95
109
  if (!Array.isArray(c.supportedEnvelopes)) return false;
96
110
  if (!c.supportedEnvelopes.every((entry) => typeof entry === 'string')) return false;
97
111
  if (typeof c.schemaVersions !== 'object' || c.schemaVersions === null) return false;
@@ -452,6 +466,14 @@ export interface ProfileFloor {
452
466
  readonly required: readonly string[];
453
467
  /** Prefix groups where ≥1 matching passed scenario satisfies the group. */
454
468
  readonly requiredAnyPrefix?: readonly string[];
469
+ /**
470
+ * The profile is discovery-payload-only: its predicate IS the whole claim and
471
+ * it has no runtime floor. This flag exists so an EMPTY floor is a decision on
472
+ * record rather than an absence — an absent key means "not yet transcribed",
473
+ * which `verifyBundleProfile()` treats as unprovable (RFC 0148 §C). Conflating
474
+ * the two is what let five undefined-floor claims verify as proven.
475
+ */
476
+ readonly discoveryOnly?: true;
455
477
  }
456
478
 
457
479
  export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
@@ -469,6 +491,49 @@ export const PROFILE_FLOOR_SCENARIOS: Readonly<Record<string, ProfileFloor>> = {
469
491
  ],
470
492
  requiredAnyPrefix: ['interrupt-'],
471
493
  },
494
+
495
+ // ── Floors TRANSCRIBED from `profiles.md`, not invented here ────────────────
496
+ // `profiles.md` §"Claiming vs passing" already states the rule normatively:
497
+ // "A host CLAIMS a profile by satisfying its predicate AND passing the
498
+ // conformance scenarios labelled with the profile tag." Each per-profile
499
+ // section then names its scenarios. This map was an incomplete transcription
500
+ // of that prose, and every profile it omitted verified as floor-proven
501
+ // against nothing (RFC 0148 §C, gap G6).
502
+
503
+ // `profiles.md` §`openwop-core` is a pure discovery-payload predicate — it
504
+ // names no runtime scenario, so the predicate IS the whole claim.
505
+ 'openwop-core': { required: [], discoveryOnly: true },
506
+
507
+ // `profiles.md` §`openwop-fixtures`: "The profile is discovery-payload-only —
508
+ // it confirms the host claims SOME fixture, not that any specific fixture is
509
+ // wired."
510
+ 'openwop-fixtures': { required: [], discoveryOnly: true },
511
+
512
+ // `profiles.md` §`openwop-stream-sse`: "A host passes `openwop-stream-sse`
513
+ // when discovery passes the predicate AND those scenarios pass."
514
+ 'openwop-stream-sse': {
515
+ required: ['stream-modes.test.ts', 'stream-modes-buffer.test.ts', 'stream-modes-mixed.test.ts'],
516
+ },
517
+
518
+ // `profiles.md` §`openwop-stream-poll`: "Conformance scenarios in
519
+ // `stream-modes.test.ts` exercise polling."
520
+ 'openwop-stream-poll': { required: ['stream-modes.test.ts'] },
521
+
522
+ // `profiles.md` §`openwop-node-packs`: "a host passes `openwop-node-packs`
523
+ // when it passes those scenarios."
524
+ 'openwop-node-packs': { required: ['pack-registry.test.ts', 'pack-registry-publish.test.ts'] },
525
+
526
+ // ── Deliberately NOT transcribed ───────────────────────────────────────────
527
+ // `openwop-replay-fork` cannot be expressed by this model. `profiles.md`
528
+ // §`openwop-replay-fork` says "Hosts MAY support either or both modes; the
529
+ // conformance scenarios pass on whichever mode the host advertises" — a floor
530
+ // conditional on the advertised mode, which a flat `required[]` cannot state.
531
+ // Forcing it would either over-require (failing an honest single-mode host) or
532
+ // under-require (the vacuity this fix removes). It stays unspecified, and is
533
+ // therefore unprovable, until the model can express a discovery-conditional
534
+ // floor. Same for `openwop-interrupts`, `openwop-secrets`,
535
+ // `openwop-provider-policy`, `openwop-memory`, and `openwop-trigger-bridge`,
536
+ // whose prose sections do not yet name a settled floor set.
472
537
  };
473
538
 
474
539
  /** Is `profile` derivable from a discovery document? Maps a profile name to its predicate (RFC 0089 §B(1)). */
@@ -496,6 +561,14 @@ export interface BundleProfileVerdict {
496
561
  readonly floorProven: boolean;
497
562
  readonly valid: boolean;
498
563
  readonly missingFloor: readonly string[];
564
+ /**
565
+ * True when no floor set is defined for this profile, so §B(2) cannot be
566
+ * evaluated at all. Distinct from `floorProven: false` with a populated
567
+ * `missingFloor`, which means the floor WAS evaluated and the bundle failed
568
+ * it. Both are invalid; only this one is a gap in the corpus rather than a
569
+ * defect in the host.
570
+ */
571
+ readonly floorUnspecified: boolean;
499
572
  }
500
573
 
501
574
  const scenarioBasename = (id: string): string => id.split('/').pop() ?? id;
@@ -507,11 +580,29 @@ const scenarioBasename = (id: string): string => id.split('/').pop() ?? id;
507
580
  export function verifyBundleProfile(bundle: CertificationBundleLike, profile: string): BundleProfileVerdict {
508
581
  const derivable = profileDerivable(bundle.discovery.document, profile);
509
582
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
583
+
584
+ // An UNDEFINED floor set makes §B(2) unevaluable, so the claim is unprovable —
585
+ // never proven. Reading the old code, `missingFloor` fell to `[]` and
586
+ // `prefixOk` to `[].every(...)` === true, so `floorProven` came out TRUE for
587
+ // every profile this map omitted: the verifier reported a claim as floor-proven
588
+ // having checked nothing. An empty floor that is a real decision says so with
589
+ // `discoveryOnly` (RFC 0148 §C, gap G6).
590
+ if (floor === undefined) {
591
+ return { profile, derivable, floorProven: false, valid: false, missingFloor: [], floorUnspecified: true };
592
+ }
593
+
510
594
  const passed = new Set(bundle.results.passed.map(scenarioBasename));
511
- const missingFloor = floor ? floor.required.filter((r) => !passed.has(scenarioBasename(r))) : [];
512
- const prefixOk = (floor?.requiredAnyPrefix ?? []).every((p) => [...passed].some((s) => s.startsWith(p)));
595
+ const missingFloor = floor.required.filter((r) => !passed.has(scenarioBasename(r)));
596
+ const prefixOk = (floor.requiredAnyPrefix ?? []).every((p) => [...passed].some((s) => s.startsWith(p)));
513
597
  const floorProven = missingFloor.length === 0 && prefixOk;
514
- return { profile, derivable, floorProven, valid: derivable && floorProven, missingFloor };
598
+ return {
599
+ profile,
600
+ derivable,
601
+ floorProven,
602
+ valid: derivable && floorProven,
603
+ missingFloor,
604
+ floorUnspecified: false,
605
+ };
515
606
  }
516
607
 
517
608
  /** Verify every profile in `bundle.claimedProfiles`; the bundle is valid iff all claims are valid. */