@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.
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +58 -0
- package/api/openapi.yaml +4 -1
- package/dist/cli.js +107 -1
- package/dist/lib/profiles.js +70 -4
- package/package.json +2 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +2 -0
- package/schemas/capabilities.schema.json +2054 -572
- package/schemas/certification-bundle-v2.schema.json +108 -0
- package/schemas/run-event-payloads.schema.json +3411 -857
- package/schemas/run-event.schema.json +31 -9
- package/schemas/workflow-definition.schema.json +492 -130
- package/schemas/workload-identity.schema.json +73 -0
- package/src/cli.ts +119 -1
- package/src/lib/a2a-fake-peer.ts +20 -0
- package/src/lib/behavior-gate.ts +42 -7
- package/src/lib/llm-cache-key-recipe.ts +51 -0
- package/src/lib/mcp-fake-server.ts +20 -0
- package/src/lib/profiles.ts +95 -4
- package/src/lib/requirement-ledger.ts +138 -0
- package/src/lib/requirement-registry.ts +62 -0
- package/src/scenarios/a2a-version-negotiation.test.ts +159 -0
- package/src/scenarios/capability-example-root-layout.test.ts +113 -0
- package/src/scenarios/certification-bundle-v2.test.ts +157 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +115 -0
- package/src/scenarios/compensation-behavior.test.ts +164 -0
- package/src/scenarios/compensation-profile.test.ts +175 -0
- package/src/scenarios/contract-provenance.test.ts +209 -0
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +198 -0
- package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +219 -0
- package/src/scenarios/effect-identity-composition.test.ts +129 -0
- package/src/scenarios/effect-identity-cross-scope.test.ts +82 -0
- package/src/scenarios/mcp-version-negotiation.test.ts +159 -0
- package/src/scenarios/multi-region-effect-vocabulary.test.ts +175 -0
- package/src/scenarios/multi-region-idempotency.test.ts +17 -7
- package/src/scenarios/openapi-resolved-paths.test.ts +127 -0
- package/src/scenarios/protocol-version-grammar.test.ts +119 -0
- package/src/scenarios/requirement-ledger.test.ts +162 -0
- package/src/scenarios/rfc-0147-self-audit.test.ts +104 -0
- package/src/scenarios/rfc-lifecycle-coherence.test.ts +137 -0
- package/src/scenarios/semantic-digest-v2.test.ts +128 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +140 -0
- package/src/scenarios/spec-corpus-validity.test.ts +22 -8
- package/src/scenarios/strict-behavior-gate.test.ts +120 -0
- package/src/scenarios/versioned-composition-profiles.test.ts +183 -0
- package/src/scenarios/workload-identity-behavior.test.ts +188 -0
- package/src/scenarios/workload-identity-profile.test.ts +175 -0
- 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
|
+
});
|