@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,138 @@
1
+ /**
2
+ * RFC 0148 §A — the requirement execution ledger.
3
+ *
4
+ * The program exists because a green bundle could overstate behavior that never
5
+ * ran. Every instance found so far shares one shape: **something absent was
6
+ * treated as something proven.** `verifyBundleProfile()` derived `floorProven`
7
+ * from `[].every(...)` over an undefined floor. A gated subtest 404'd and
8
+ * soft-skipped. A scenario early-returned and the file still counted as passed.
9
+ *
10
+ * So the ledger inverts the default. A requirement has **no** disposition until
11
+ * a scenario explicitly records one, and a requirement with no recorded
12
+ * disposition resolves to `blocked` — never to `executed-pass`. Silence is
13
+ * evidence of nothing, and the data structure now says so rather than relying on
14
+ * each author to remember it.
15
+ *
16
+ * That is the whole mechanism. Everything else here is bookkeeping.
17
+ */
18
+
19
+ /** RFC 0148 §A. Exactly one of these per requirement, per run. */
20
+ export type Disposition =
21
+ /** The assertion executed against the target and passed. */
22
+ | 'executed-pass'
23
+ /** The assertion executed and failed. */
24
+ | 'executed-fail'
25
+ /** The operator explicitly excluded an optional, unadvertised profile. */
26
+ | 'skipped'
27
+ /** The requirement does not apply to the captured discovery/profile set. */
28
+ | 'inapplicable'
29
+ /** Advertised behavior could not be exercised — seam, fixture, credential, or dependency missing. */
30
+ | 'blocked';
31
+
32
+ export const DISPOSITIONS: readonly Disposition[] = [
33
+ 'executed-pass',
34
+ 'executed-fail',
35
+ 'skipped',
36
+ 'inapplicable',
37
+ 'blocked',
38
+ ] as const;
39
+
40
+ /**
41
+ * Dispositions that permit a profile to certify. `blocked` is deliberately NOT
42
+ * here: RFC 0148 §A says a blocked requirement in a claimed profile invalidates
43
+ * that profile's certification, because "we could not check" and "we checked and
44
+ * it holds" are the two states this program exists to stop conflating.
45
+ */
46
+ export const CERTIFIABLE: readonly Disposition[] = [
47
+ 'executed-pass',
48
+ 'skipped',
49
+ 'inapplicable',
50
+ ] as const;
51
+
52
+ export interface LedgerEntry {
53
+ readonly requirementId: string;
54
+ readonly disposition: Disposition;
55
+ /** Why — required for every disposition except `executed-pass`. */
56
+ readonly detail?: string;
57
+ }
58
+
59
+ const ledger = new Map<string, LedgerEntry>();
60
+
61
+ /**
62
+ * Record a requirement's outcome. Recording the same id twice with different
63
+ * dispositions throws: RFC 0148 §A says **exactly one** disposition per
64
+ * requirement, and a silent last-write-wins would let a later soft-skip
65
+ * overwrite an earlier real failure — the failure mode in reverse.
66
+ */
67
+ export function recordRequirement(
68
+ requirementId: string,
69
+ disposition: Disposition,
70
+ detail?: string,
71
+ ): void {
72
+ const prior = ledger.get(requirementId);
73
+ if (prior !== undefined && prior.disposition !== disposition) {
74
+ throw new Error(
75
+ `RFC 0148 §A: ${requirementId} already recorded as '${prior.disposition}', now '${disposition}'. ` +
76
+ 'Exactly one disposition per requirement per run.',
77
+ );
78
+ }
79
+ if (disposition !== 'executed-pass' && (detail === undefined || detail.trim() === '')) {
80
+ throw new Error(
81
+ `RFC 0148 §A: ${requirementId} recorded as '${disposition}' without a reason. ` +
82
+ 'Anything other than executed-pass MUST say why, or the ledger records an outcome nobody can act on.',
83
+ );
84
+ }
85
+ ledger.set(requirementId, detail === undefined ? { requirementId, disposition } : { requirementId, disposition, detail });
86
+ }
87
+
88
+ /**
89
+ * The disposition for a requirement. **Absent resolves to `blocked`, never to a
90
+ * pass.** This is the inversion the whole section turns on: a scenario that
91
+ * returned early, threw and swallowed, or was never written leaves no entry, and
92
+ * the honest reading of no entry is "this was not exercised".
93
+ */
94
+ export function dispositionOf(requirementId: string): Disposition {
95
+ return ledger.get(requirementId)?.disposition ?? 'blocked';
96
+ }
97
+
98
+ export function entryOf(requirementId: string): LedgerEntry {
99
+ return (
100
+ ledger.get(requirementId) ?? {
101
+ requirementId,
102
+ disposition: 'blocked',
103
+ detail: 'no disposition recorded — the requirement was not exercised',
104
+ }
105
+ );
106
+ }
107
+
108
+ export function snapshot(): readonly LedgerEntry[] {
109
+ return [...ledger.values()].sort((a, b) => a.requirementId.localeCompare(b.requirementId));
110
+ }
111
+
112
+ /** Test-support only. Production runs record once and read once. */
113
+ export function resetLedger(): void {
114
+ ledger.clear();
115
+ }
116
+
117
+ export interface ProfileVerdict {
118
+ readonly profile: string;
119
+ readonly certifiable: boolean;
120
+ readonly blocking: readonly LedgerEntry[];
121
+ }
122
+
123
+ /**
124
+ * Whether a profile's requirements permit certification. A profile with **no**
125
+ * requirements is NOT certifiable by this function — an empty requirement set
126
+ * is the `[].every(...)` shape that started all of this, and callers must
127
+ * distinguish "no floor by design" (`discoveryOnly`) from "no floor written yet"
128
+ * before reaching here.
129
+ */
130
+ export function verifyProfileRequirements(
131
+ profile: string,
132
+ requirementIds: readonly string[],
133
+ ): ProfileVerdict {
134
+ const blocking = requirementIds
135
+ .map(entryOf)
136
+ .filter((e) => !CERTIFIABLE.includes(e.disposition));
137
+ return { profile, certifiable: requirementIds.length > 0 && blocking.length === 0, blocking };
138
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * RFC 0148 §A — stable requirement IDs for the certification floor.
3
+ *
4
+ * §A requires "every normative conformance assertion included in a certifiable
5
+ * profile" to carry a stable `requirementId`. This registry is the first tranche:
6
+ * the floor scenarios that `PROFILE_FLOOR_SCENARIOS` already makes certification
7
+ * depend on. It is deliberately NOT a sweep over all 421 scenario files.
8
+ *
9
+ * The reason is the measurement in `docs/RFC-LIFECYCLE-COHERENCE.md`, applied to
10
+ * a different surface: a gate that fires hundreds of times on its first run gets
11
+ * disabled rather than fixed. Tagging every assertion in one pass would produce a
12
+ * registry nobody could review, and an unreviewed requirement ID is worth less
13
+ * than no requirement ID — it looks like coverage.
14
+ *
15
+ * So the scope is exactly what certification consumes today. `requirementsFor()`
16
+ * returns the IDs a profile's claim rests on, and anything outside this registry
17
+ * is honestly outside §A's coverage rather than silently assumed covered.
18
+ *
19
+ * Adding a requirement is deliberately cheap; adding it *without* a scenario
20
+ * recording a disposition for it is deliberately loud, because the ledger
21
+ * resolves an unrecorded requirement to `blocked`.
22
+ */
23
+
24
+ import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
25
+
26
+ /** `runs-lifecycle.test.ts` → `openwop.floor.runs-lifecycle`. */
27
+ export function requirementIdForScenario(scenarioFile: string): string {
28
+ return `openwop.floor.${scenarioFile.replace(/\.test\.ts$/, '')}`;
29
+ }
30
+
31
+ /** Prefix groups become one requirement: `interrupt-` → `openwop.floor.any.interrupt-`. */
32
+ export function requirementIdForPrefix(prefix: string): string {
33
+ return `openwop.floor.any.${prefix}`;
34
+ }
35
+
36
+ /**
37
+ * The requirement IDs a profile's certification rests on.
38
+ *
39
+ * Returns `null` — not an empty array — when the corpus has no floor for the
40
+ * profile. An empty array would flow into `verifyProfileRequirements()` and read
41
+ * as "nothing blocking", which is the `[].every(...)` shape this program exists
42
+ * to close. `null` forces the caller to decide between `discoveryOnly` (an empty
43
+ * floor by design) and unspecified (no floor written yet).
44
+ */
45
+ export function requirementsFor(profile: string): readonly string[] | null {
46
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
47
+ if (floor === undefined) return null;
48
+ if (floor.discoveryOnly === true) return [];
49
+ return [
50
+ ...floor.required.map(requirementIdForScenario),
51
+ ...(floor.requiredAnyPrefix ?? []).map(requirementIdForPrefix),
52
+ ];
53
+ }
54
+
55
+ /** Every registered requirement ID across every profile with a runtime floor. */
56
+ export function allRequirements(): readonly string[] {
57
+ const ids = new Set<string>();
58
+ for (const profile of Object.keys(PROFILE_FLOOR_SCENARIOS)) {
59
+ for (const id of requirementsFor(profile) ?? []) ids.add(id);
60
+ }
61
+ return [...ids].sort();
62
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * RFC 0152 §B — A2A version negotiation, witnessed against the fake peer.
3
+ *
4
+ * This file exists because I twice described RFC 0152 as needing a live
5
+ * upstream A2A peer, and that was only half true. **Interop needs a real peer.
6
+ * Negotiation does not** — negotiation is the *host's* behavior, and the host is
7
+ * right here. What was actually missing was that `A2AFakePeer` recorded method,
8
+ * path, rpcMethod, and body, but **not headers** — and §B lives entirely in the
9
+ * headers. The peer could observe that a call happened but not which version was
10
+ * negotiated, which is the only part §B is about.
11
+ *
12
+ * So the blocker was apparatus, again, and the fix was to record what the fake
13
+ * peer already received.
14
+ *
15
+ * `Real upstream A2A 1.0 peer passes in CI` remains a separate and unmet
16
+ * acceptance item — it tests interoperation, which no fake can stand in for.
17
+ * This file does not claim it, and RFC 0152's acceptance criteria still say so.
18
+ *
19
+ * The load-bearing requirement is negative: **"a host MUST NOT silently
20
+ * downgrade an authenticated request."** A silent downgrade is the dangerous
21
+ * outcome precisely because it *succeeds* — the caller believes it negotiated
22
+ * 1.0, the peer answered 0.3, and nothing in the response says otherwise. A
23
+ * failure would at least be visible.
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { driver } from '../lib/driver.js';
28
+ import { behaviorGate } from '../lib/behavior-gate.js';
29
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
30
+ import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
31
+
32
+ const PROFILE = 'a2a.versionNegotiation';
33
+
34
+ interface A2ACaps {
35
+ readonly supported?: boolean;
36
+ readonly protocolVersions?: readonly string[];
37
+ readonly preferredVersion?: string;
38
+ }
39
+
40
+ async function a2a(): Promise<A2ACaps | undefined> {
41
+ const disco = await driver.get('/.well-known/openwop');
42
+ return capabilityFamily<A2ACaps>(disco.json, 'a2a');
43
+ }
44
+
45
+ /** Advertised versions are a §A shape claim; §B is what the host DOES with them. */
46
+ async function negotiationAdvertised(): Promise<boolean> {
47
+ const caps = await a2a();
48
+ return caps?.supported === true && (caps.protocolVersions?.length ?? 0) > 0;
49
+ }
50
+
51
+ describe('RFC 0152 §B — A2A version negotiation', () => {
52
+ it('the advertised preferred version is one the host actually claims', async () => {
53
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
54
+ const caps = await a2a();
55
+ expect(
56
+ caps?.protocolVersions ?? [],
57
+ driver.describe(
58
+ 'RFCS/0152-a2a-1-0-versioned-composition.md §A',
59
+ '`preferredVersion` MUST be present in `protocolVersions`. Preferring a version you do ' +
60
+ 'not list is a claim no peer can act on.',
61
+ ),
62
+ ).toContain(caps?.preferredVersion);
63
+ });
64
+
65
+ it('outbound calls carry an explicit A2A-Version header', async () => {
66
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
67
+ const peer = getA2AFakePeer();
68
+ if (peer === null) return; // no fake peer wired in this run
69
+ peer.reset();
70
+ const drive = await driver.post('/v1/host/sample/a2a/invoke', { peerUrl: peer.endpoint() });
71
+ if (drive.status === 404 || drive.status === 403) {
72
+ // Seam absent. RFC 0148 §A: unobservable resolves to `blocked`, not a pass.
73
+ expect(
74
+ drive.status,
75
+ driver.describe(
76
+ 'RFCS/0152 §B',
77
+ 'a host advertising A2A version negotiation MUST expose an invoke seam so the negotiated ' +
78
+ 'version is observable. Without it the requirement cannot be witnessed and resolves to ' +
79
+ '`blocked` per RFC 0148 §A.',
80
+ ),
81
+ ).not.toBe(404);
82
+ return;
83
+ }
84
+ const calls = peer.invocations().filter((i: { method: string }) => i.method !== 'GET');
85
+ expect(calls.length, 'the host MUST have called the peer').toBeGreaterThan(0);
86
+ for (const c of calls) {
87
+ expect(
88
+ c.headers['a2a-version'],
89
+ driver.describe(
90
+ 'RFCS/0152 §B',
91
+ 'the sender MUST send `A2A-Version`. An absent header leaves the receiver guessing, and ' +
92
+ 'a guess that happens to be right is not a negotiation.',
93
+ ),
94
+ ).toBeTruthy();
95
+ }
96
+ });
97
+
98
+ it('an authenticated request is not silently downgraded', async () => {
99
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
100
+ const peer = getA2AFakePeer();
101
+ if (peer === null) return;
102
+ const caps = await a2a();
103
+ const preferred = caps?.preferredVersion;
104
+ peer.reset();
105
+ const drive = await driver.post('/v1/host/sample/a2a/invoke', {
106
+ peerUrl: peer.endpoint(),
107
+ authenticated: true,
108
+ peerOffersOnly: '0.3',
109
+ });
110
+ if (drive.status === 404 || drive.status === 403) return; // covered above
111
+
112
+ // THE load-bearing leg. A silent downgrade is dangerous precisely because it
113
+ // SUCCEEDS: the caller believes it negotiated `preferred`, the peer answered
114
+ // 0.3, and nothing in the response says otherwise. Either the host refuses
115
+ // (fails closed, visibly) or it negotiates down EXPLICITLY — what it must
116
+ // not do is return success while having quietly used the lower version.
117
+ if (drive.status < 400) {
118
+ const negotiated = (drive.json as { negotiatedVersion?: string }).negotiatedVersion;
119
+ expect(
120
+ negotiated,
121
+ driver.describe(
122
+ 'RFCS/0152 §B',
123
+ 'a host that proceeds after a downgrade MUST report the version it actually negotiated. ' +
124
+ 'Reporting the preferred version while having used a lower one is the silent downgrade ' +
125
+ '§B forbids — and it is worse than a failure because it succeeds.',
126
+ ),
127
+ ).not.toBe(preferred);
128
+ const calls = peer.invocations().filter((i: { method: string }) => i.method !== 'GET');
129
+ for (const c of calls) {
130
+ expect(
131
+ c.headers['a2a-version'],
132
+ driver.describe('RFCS/0152 §B', 'the wire header MUST match the reported negotiated version'),
133
+ ).toBe(negotiated);
134
+ }
135
+ }
136
+ });
137
+
138
+ it('an unsupported version fails through the canonical interop error envelope', async () => {
139
+ if (!behaviorGate(PROFILE, await negotiationAdvertised())) return;
140
+ const peer = getA2AFakePeer();
141
+ if (peer === null) return;
142
+ const drive = await driver.post('/v1/host/sample/a2a/invoke', {
143
+ peerUrl: peer.endpoint(),
144
+ requestVersion: '99.0',
145
+ });
146
+ if (drive.status === 404 || drive.status === 403) return;
147
+ expect(drive.status >= 400, driver.describe('RFCS/0152 §B', 'an unsupported version MUST fail')).toBe(true);
148
+ const err = (drive.json as { error?: { code?: string; retriable?: boolean } }).error;
149
+ expect(
150
+ err?.code,
151
+ driver.describe(
152
+ 'RFCS/0152 §B',
153
+ 'the upstream version error MUST be projected through the canonical OpenWOP interop error ' +
154
+ 'envelope when the failure crosses an OpenWOP boundary — a raw upstream body leaves the ' +
155
+ 'caller parsing a foreign protocol to learn its own request was rejected',
156
+ ),
157
+ ).toBeTruthy();
158
+ });
159
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * RFC 0149 §B — normative discovery examples place capability families at the
3
+ * document root.
4
+ *
5
+ * RFC 0073 (`Accepted`) made the root layout "the normative MUST since Phase 1"
6
+ * and, at Phase 4, made the suite enforce it: a wrapper-only host "grades as
7
+ * non-conformant". The `capabilities` wrapper survives only as a deprecated
8
+ * shape that runtime discovery tolerates through the v1.x window, retiring at
9
+ * v2.0 when `capabilities.schema.json` tightens.
10
+ *
11
+ * Tolerating a shape at runtime and teaching it in a normative example are
12
+ * different things. Eight `spec/v1` examples — several under headings like
13
+ * "Capability advertisement (normative)", introduced by prose saying hosts
14
+ * "advertise it under `/.well-known/openwop`" — showed the deprecated wrapper.
15
+ * An implementer copying one produced a document RFC 0073 grades as
16
+ * non-conformant, and no gate noticed, because a fenced example is prose to
17
+ * every validator in the corpus.
18
+ *
19
+ * This gate is authoring-time only. It says nothing about what a server may
20
+ * emit: RFC 0149 §B is explicit that the runtime schema MUST NOT reject an
21
+ * otherwise legal unknown server-emitted property, and nothing here reads a
22
+ * host.
23
+ *
24
+ * `spec/v1/` and `RFCS/` ship in the repository, NOT in the published tarball,
25
+ * so this self-skips under the published layout. That asymmetry has produced
26
+ * three defects in this corpus — the `CORPUS-STAMP` gate, the link-checker's
27
+ * filesystem walk, and RFC 0146 leg A4, which ENOENT'd for every adopter
28
+ * running from the package.
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
33
+ import { join, resolve as pathResolve } from 'node:path';
34
+ import { V1_DIR } from '../lib/paths.js';
35
+
36
+ /** RFC 0073 established the root layout; examples in earlier RFCs are historical record. */
37
+ const ROOT_LAYOUT_RFC = 73;
38
+
39
+ const RFCS_DIR = V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'RFCS');
40
+
41
+ interface FencedExample {
42
+ readonly file: string;
43
+ readonly line: number;
44
+ readonly rootWrapper: boolean;
45
+ }
46
+
47
+ /** Fenced ```json / ```jsonc blocks, flagged when the root object's first key is `capabilities`. */
48
+ function fencedExamples(dir: string): FencedExample[] {
49
+ const found: FencedExample[] = [];
50
+ for (const name of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
51
+ const lines = readFileSync(join(dir, name), 'utf8').split('\n');
52
+ for (let i = 0; i < lines.length; i++) {
53
+ if (!/^```(json|jsonc)\s*$/.test(lines[i]!.trim())) continue;
54
+ const body: string[] = [];
55
+ let j = i + 1;
56
+ while (j < lines.length && lines[j]!.trim() !== '```') {
57
+ body.push(lines[j]!);
58
+ j++;
59
+ }
60
+ const rootWrapper =
61
+ body.length > 1 && /^\s*\{\s*$/.test(body[0]!) && /^\s*"capabilities"\s*:\s*\{\s*$/.test(body[1]!);
62
+ found.push({ file: name, line: i + 2, rootWrapper });
63
+ i = j;
64
+ }
65
+ }
66
+ return found;
67
+ }
68
+
69
+ describe.skipIf(V1_DIR === null)('RFC 0149 §B — discovery examples use the document-root layout', () => {
70
+ const v1Dir = V1_DIR as string;
71
+
72
+ it('the scan finds fenced examples at all', () => {
73
+ // Guard: an extractor that silently matched nothing would make the
74
+ // assertions below vacuously true, which is the failure mode RFC 0148
75
+ // exists to close. This gate must not become an instance of it.
76
+ const examples = fencedExamples(v1Dir);
77
+ expect(examples.length, 'spec/v1 MUST contain fenced json/jsonc examples to check').toBeGreaterThan(20);
78
+ });
79
+
80
+ it('no spec/v1 example wraps capability families in a top-level `capabilities` object', () => {
81
+ const offenders = fencedExamples(v1Dir)
82
+ .filter((e) => e.rootWrapper)
83
+ .map((e) => `spec/v1/${e.file}:${e.line}`);
84
+ expect(
85
+ offenders,
86
+ 'RFC 0073: capability families are a property of the DOCUMENT ROOT; there is no `capabilities` wrapper. ' +
87
+ 'A normative example showing the deprecated shape teaches a document RFC 0073 grades as non-conformant.\n ' +
88
+ offenders.join('\n '),
89
+ ).toEqual([]);
90
+ });
91
+
92
+ it('every RFC still showing the wrapper predates RFC 0073, which established root layout', () => {
93
+ // RFCs are a dated record of what was proposed, so their examples are NOT
94
+ // rewritten to match a later layout — that would make the record lie. The
95
+ // exemption is asserted rather than assumed: a NEW post-0073 RFC that
96
+ // introduces a wrapper fails here, so the historical carve-out cannot widen
97
+ // into a licence.
98
+ if (RFCS_DIR === null || !existsSync(RFCS_DIR)) return;
99
+ const late = fencedExamples(RFCS_DIR)
100
+ .filter((e) => e.rootWrapper)
101
+ .filter((e) => {
102
+ const n = Number.parseInt(e.file.slice(0, 4), 10);
103
+ return Number.isFinite(n) && n >= ROOT_LAYOUT_RFC;
104
+ })
105
+ .map((e) => `RFCS/${e.file}:${e.line}`);
106
+ expect(
107
+ late,
108
+ `an RFC numbered >= ${ROOT_LAYOUT_RFC} uses the deprecated \`capabilities\` wrapper. ` +
109
+ 'Pre-0073 RFCs keep theirs as historical record; a later one has no such excuse.\n ' +
110
+ late.join('\n '),
111
+ ).toEqual([]);
112
+ });
113
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * RFC 0148 §C — certification bundle v2.
3
+ *
4
+ * v1 recorded `{passed, failed, skipped}` as scenario-file lists. Those three
5
+ * words cannot express the distinction this entire program turns on:
6
+ *
7
+ * - a file counted as **passed** whether its assertions ran or its runner
8
+ * returned early — which is how a gated subtest that 404'd left a green file;
9
+ * - **skipped** flattened three different claims into one word: "the operator
10
+ * excluded this", "the requirement does not apply here", and "we could not
11
+ * check". The first two are certifiable. The third invalidates the claim,
12
+ * and v1 had no way to say it.
13
+ *
14
+ * v2 replaces the lists with per-requirement dispositions from §A and adds the
15
+ * counts §A.4 requires. Two properties are load-bearing and are what these legs
16
+ * defend:
17
+ *
18
+ * - **`blocked` is a required total.** `blocked: 0` asserted is a different
19
+ * claim from `blocked` unstated, and an omitted total is indistinguishable
20
+ * from zero.
21
+ * - **`assertionCount` makes a vacuous pass visible.** `executed-pass` with
22
+ * `assertionCount: 0` is exactly the shape RFC 0148 exists to close, and a
23
+ * reader can now see it in the artifact rather than having to re-run.
24
+ *
25
+ * Server-free.
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import Ajv2020 from 'ajv/dist/2020.js';
32
+ import { SCHEMAS_DIR } from '../lib/paths.js';
33
+
34
+ const HEX = 'a'.repeat(64);
35
+
36
+ /**
37
+ * Resolve the schema through `SCHEMAS_DIR`, not through `V1_DIR/../..`.
38
+ *
39
+ * The earlier form derived the repo root from the PROSE directory, which is
40
+ * `null` in the published tarball because prose is not bundled — and then cast
41
+ * the null away with `as string`. The cast satisfied the compiler and the file
42
+ * still passed in a repo checkout, so nothing anywhere reported a problem;
43
+ * installed from npm it threw at import and took the whole suite file down.
44
+ *
45
+ * Schemas ARE vendored into the package, so `SCHEMAS_DIR` is non-null in both
46
+ * layouts. Reading through it does not merely stop the crash — it makes these
47
+ * legs RUN for consumers, where before they could only have skipped.
48
+ */
49
+ function validator() {
50
+ const schema = JSON.parse(
51
+ readFileSync(join(SCHEMAS_DIR, 'certification-bundle-v2.schema.json'), 'utf8'),
52
+ ) as object;
53
+ return new Ajv2020({ strict: false, allErrors: true }).compile(schema);
54
+ }
55
+
56
+ const bundle = (over: Record<string, unknown> = {}) => ({
57
+ bundleVersion: '2',
58
+ suite: { package: '@openwop/openwop-conformance', version: '1.92.0' },
59
+ host: { name: 'example-host', version: '1.0.0' },
60
+ discovery: { sha256: HEX, document: { protocolVersion: '1.0' } },
61
+ claimedProfiles: ['openwop-core-standard'],
62
+ results: {
63
+ totals: { executedPass: 41, executedFail: 0, skipped: 2, inapplicable: 7, blocked: 0 },
64
+ requirements: [
65
+ { requirementId: 'openwop.floor.runs-lifecycle', scenarioId: 'runs-lifecycle', disposition: 'executed-pass', assertionCount: 3, witnessSha256: HEX },
66
+ ],
67
+ },
68
+ scenarioManifestSha256: HEX,
69
+ targetConfigurationSha256: HEX,
70
+ ...over,
71
+ });
72
+
73
+ describe('RFC 0148 §C — certification bundle v2', () => {
74
+ const validate = validator();
75
+
76
+ it('a well-formed v2 bundle validates', () => {
77
+ expect(validate(bundle()), JSON.stringify(validate.errors)).toBe(true);
78
+ });
79
+
80
+ it('all five totals are required, including blocked', () => {
81
+ // `blocked: 0` asserted is a different claim from `blocked` unstated. An
82
+ // omitted total is indistinguishable from zero, and the one total that
83
+ // invalidates a certification is the one most worth omitting.
84
+ for (const missing of ['executedPass', 'executedFail', 'skipped', 'inapplicable', 'blocked']) {
85
+ const totals: Record<string, number> = { executedPass: 1, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 };
86
+ delete totals[missing];
87
+ expect(
88
+ validate(bundle({ results: { totals, requirements: bundle().results.requirements } })),
89
+ `RFC 0148 §C: \`${missing}\` MUST be present — an omitted total reads as zero without ` +
90
+ 'anyone having asserted it',
91
+ ).toBe(false);
92
+ }
93
+ });
94
+
95
+ it('the requirement list cannot be empty', () => {
96
+ expect(
97
+ validate(bundle({ results: { totals: bundle().results.totals, requirements: [] } })),
98
+ 'RFC 0148 §C: a bundle with no requirement rows records no execution. An empty evidence set ' +
99
+ 'reading as proof is the defect this section exists to close.',
100
+ ).toBe(false);
101
+ });
102
+
103
+ it('a disposition outside the §A vocabulary is rejected', () => {
104
+ expect(
105
+ validate(bundle({
106
+ results: {
107
+ totals: bundle().results.totals,
108
+ requirements: [{ requirementId: 'x', scenarioId: 'y', disposition: 'probably-fine' }],
109
+ },
110
+ })),
111
+ ).toBe(false);
112
+ });
113
+
114
+ it('a vacuous pass is representable and therefore visible', () => {
115
+ // Deliberately VALID. The schema does not forbid `executed-pass` with zero
116
+ // assertions — forbidding it would only move the lie one field over, since
117
+ // a generator could write `assertionCount: 1`. What v2 buys is that the
118
+ // number is IN the artifact, so a reader or a downstream verifier can see a
119
+ // pass that executed nothing without re-running the suite.
120
+ const b = bundle({
121
+ results: {
122
+ totals: bundle().results.totals,
123
+ requirements: [{ requirementId: 'x', scenarioId: 'y', disposition: 'executed-pass', assertionCount: 0 }],
124
+ },
125
+ });
126
+ expect(validate(b), JSON.stringify(validate.errors)).toBe(true);
127
+ const rows = (b.results as { requirements: { disposition: string; assertionCount?: number }[] }).requirements;
128
+ expect(
129
+ rows.some((r) => r.disposition === 'executed-pass' && r.assertionCount === 0),
130
+ 'the vacuous shape MUST remain inspectable in the artifact — v1 could not express it at all',
131
+ ).toBe(true);
132
+ });
133
+
134
+ it('claimed profiles use canonical IDs, not deprecated aliases', () => {
135
+ // RFC 0155 §E. A badge substantiated by a name that no longer means what it
136
+ // did is the `openwop-core` ambiguity in bundle form.
137
+ expect(validate(bundle({ claimedProfiles: ['openwop-core-standard'] }))).toBe(true);
138
+ expect(validate(bundle({ claimedProfiles: ['Legacy Core'] }))).toBe(false);
139
+ expect(validate(bundle({ claimedProfiles: [] }))).toBe(false);
140
+ });
141
+
142
+ it('provenance digests are required and hex-shaped', () => {
143
+ // Without `scenarioManifestSha256` a bundle cannot be distinguished from one
144
+ // produced against a different, smaller suite; without
145
+ // `targetConfigurationSha256`, from one against a differently-configured host.
146
+ for (const field of ['scenarioManifestSha256', 'targetConfigurationSha256']) {
147
+ const b = bundle() as Record<string, unknown>;
148
+ delete b[field];
149
+ expect(validate(b), `RFC 0147 §A.4: ${field} MUST be present`).toBe(false);
150
+ }
151
+ expect(validate(bundle({ scenarioManifestSha256: 'not-a-digest' }))).toBe(false);
152
+ });
153
+
154
+ it('bundleVersion is pinned to 2', () => {
155
+ expect(validate(bundle({ bundleVersion: '1' })), 'a v1 bundle MUST NOT validate as v2').toBe(false);
156
+ });
157
+ });