@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,115 @@
1
+ /**
2
+ * RFC 0148 §C / gap G6 — an undefined floor set is UNPROVABLE, never proven.
3
+ *
4
+ * `conformance-certification.md` §B(2) requires every floor scenario of a
5
+ * claimed profile to appear in `results.passed`, and `profiles.md` §"Claiming
6
+ * vs passing" says a host claims a profile "by satisfying its predicate AND
7
+ * passing the conformance scenarios labelled with the profile tag."
8
+ *
9
+ * `PROFILE_FLOOR_SCENARIOS` transcribed that prose for `openwop-core-standard`
10
+ * alone. For every other profile the floor was `undefined`, and the verifier
11
+ * computed `floorProven` from `missingFloor.length === 0 && prefixOk` — both
12
+ * vacuously true over an absent floor. So a claim with nothing behind it
13
+ * verified as PROVEN. That is a third vacuity mode alongside the two RFC 0148
14
+ * §"Motivation" names: those let an unexecuted assertion count as a pass, this
15
+ * lets an entire profile claim verify against nothing.
16
+ *
17
+ * These legs are server-free and always-on. A floor rule that only ran when a
18
+ * host advertised something would be gated on the very claim it exists to
19
+ * check.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { verifyBundle, verifyBundleProfile, PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
24
+
25
+ /** Derives `openwop-core` and `openwop-stream-sse` (transports omitted ⇒ all). */
26
+ const streamingDiscovery = {
27
+ protocolVersion: '1.0',
28
+ supportedEnvelopes: ['final', 'clarification.request'],
29
+ schemaVersions: { 'workflow-definition': '1.0' },
30
+ limits: { clarificationRounds: 3, schemaRounds: 2, envelopesPerTurn: 8 },
31
+ };
32
+
33
+ const bundleClaiming = (profiles: readonly string[], passed: readonly string[], failed: readonly string[] = []) => ({
34
+ discovery: { document: streamingDiscovery },
35
+ claimedProfiles: profiles,
36
+ results: { passed, failed },
37
+ });
38
+
39
+ describe('RFC 0148 §C — floor enforcement is not vacuous', () => {
40
+ it('a profile with NO floor definition is unprovable, not proven', () => {
41
+ // `openwop-interrupts` is deliberately untranscribed: its prose section does
42
+ // not yet name a settled floor set. Claiming it must therefore fail — the
43
+ // corpus cannot substantiate it, which is a different thing from the host
44
+ // having failed something.
45
+ const v = verifyBundleProfile(bundleClaiming(['openwop-interrupts'], []), 'openwop-interrupts');
46
+ expect(v.floorUnspecified, 'no floor set is defined for openwop-interrupts').toBe(true);
47
+ expect(v.floorProven, 'RFC 0148 §C: an undefined floor MUST NOT satisfy the floor condition').toBe(false);
48
+ expect(v.valid).toBe(false);
49
+ expect(v.missingFloor, 'nothing is "missing" — the floor was never evaluable').toEqual([]);
50
+ });
51
+
52
+ it('a discovery-only profile with an EXPLICIT empty floor is proven by its predicate alone', () => {
53
+ // The distinction the fix turns on: `openwop-fixtures` is discovery-payload
54
+ // -only by `profiles.md`, so an empty floor is a decision on record. Without
55
+ // `discoveryOnly`, "legitimately empty" and "not yet written" are the same
56
+ // value, which is what produced the defect.
57
+ const v = verifyBundleProfile(bundleClaiming(['openwop-fixtures'], []), 'openwop-fixtures');
58
+ expect(PROFILE_FLOOR_SCENARIOS['openwop-fixtures']?.discoveryOnly).toBe(true);
59
+ expect(v.floorUnspecified).toBe(false);
60
+ expect(v.floorProven).toBe(true);
61
+ });
62
+
63
+ it('a claimed streaming profile whose floor scenarios FAILED is rejected', () => {
64
+ // The shape of the real defect: the one published v1 bundle claimed
65
+ // `openwop-stream-sse` while all three `stream-modes*` scenarios sat in its
66
+ // own `results.failed`, and the verifier accepted it.
67
+ const v = verifyBundleProfile(
68
+ bundleClaiming(
69
+ ['openwop-stream-sse'],
70
+ [],
71
+ ['stream-modes.test.ts', 'stream-modes-buffer.test.ts', 'stream-modes-mixed.test.ts'],
72
+ ),
73
+ 'openwop-stream-sse',
74
+ );
75
+ expect(v.derivable, 'the discovery document does derive the profile').toBe(true);
76
+ expect(v.floorProven, 'profiles.md §openwop-stream-sse: predicate AND those scenarios pass').toBe(false);
77
+ expect(v.missingFloor).toContain('stream-modes.test.ts');
78
+ expect(v.valid).toBe(false);
79
+ });
80
+
81
+ it('a claimed streaming profile whose floor scenarios all PASSED is accepted', () => {
82
+ const v = verifyBundleProfile(
83
+ bundleClaiming(['openwop-stream-sse'], [
84
+ 'stream-modes.test.ts',
85
+ 'stream-modes-buffer.test.ts',
86
+ 'stream-modes-mixed.test.ts',
87
+ ]),
88
+ 'openwop-stream-sse',
89
+ );
90
+ expect(v.floorProven).toBe(true);
91
+ expect(v.valid).toBe(true);
92
+ });
93
+
94
+ it('one unprovable claim invalidates the whole bundle', () => {
95
+ // A bundle is valid iff EVERY claim is. Mixing a provable claim with an
96
+ // unprovable one must not average out to valid.
97
+ const r = verifyBundle(bundleClaiming(['openwop-core', 'openwop-interrupts'], []));
98
+ expect(r.verdicts.find((v) => v.profile === 'openwop-core')?.valid).toBe(true);
99
+ expect(r.verdicts.find((v) => v.profile === 'openwop-interrupts')?.valid).toBe(false);
100
+ expect(r.valid).toBe(false);
101
+ });
102
+
103
+ it('every transcribed floor names scenario files that exist in this suite', () => {
104
+ // Guards the transcription itself: a floor citing a renamed or deleted
105
+ // scenario can never be satisfied, which would fail honest hosts for a
106
+ // reason unrelated to their behavior.
107
+ const all = Object.entries(PROFILE_FLOOR_SCENARIOS).flatMap(([profile, floor]) =>
108
+ floor.required.map((scenario) => ({ profile, scenario })),
109
+ );
110
+ expect(all.length, 'the floor map MUST NOT be empty — an empty map re-opens the vacuity').toBeGreaterThan(0);
111
+ for (const { profile, scenario } of all) {
112
+ expect(scenario, `${profile} floor cites a non-scenario filename`).toMatch(/\.test\.ts$/);
113
+ }
114
+ });
115
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * RFC 0151 §C–§G — the compensation BEHAVIORAL witness.
3
+ *
4
+ * This file exists because RFC 0147 §A.5 requires "at least one host MUST
5
+ * execute every normative behavioral path in strict mode", and until now RFC
6
+ * 0151 had no behavioral scenario at all — only a server-free schema check.
7
+ * A willing host had nothing to run.
8
+ *
9
+ * That is a gap in the *suite*, not in any host, and it is worth naming plainly:
10
+ * for several turns the blocker was described as "no host implements this", when
11
+ * part of it was that the evidence-collecting apparatus did not exist either.
12
+ *
13
+ * Every leg here is capability-gated on `compensation.supported` via
14
+ * `behaviorGate`, so it soft-skips against a host that does not advertise and
15
+ * HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`. The gate records an RFC 0148
16
+ * §A ledger disposition either way, so an unrun requirement resolves to
17
+ * `blocked` rather than to silence.
18
+ *
19
+ * What each leg pins, and why it is the observable projection of a §C rule that
20
+ * would otherwise be unverifiable:
21
+ *
22
+ * - **Plan before first effect.** §C: "the host MUST persist a compensation
23
+ * plan before executing its first inverse action." Black-box, that is
24
+ * `compensation.requested` strictly preceding `compensation.started`. A host
25
+ * that starts unwinding before persisting cannot resume after a crash — and
26
+ * the crash is exactly when it matters.
27
+ * - **Reverse-completion order.** §C orders by *descending durable
28
+ * forward-completion sequence*. Compensating in forward order can release a
29
+ * resource another inverse action still depends on.
30
+ * - **Replay does not re-fire.** §F. A replay that re-executes inverse effects
31
+ * turns a recovery into a second outage.
32
+ * - **No provider bodies or credentials in events.** §D and §G. These events
33
+ * land in the durable log, which is the least revocable place a credential
34
+ * can go.
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { driver } from '../lib/driver.js';
39
+ import { behaviorGate } from '../lib/behavior-gate.js';
40
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
41
+
42
+ const PROFILE = 'openwop-compensation';
43
+
44
+ interface CompensationCaps {
45
+ readonly supported?: boolean;
46
+ readonly orderingModels?: readonly string[];
47
+ readonly manualIntervention?: boolean;
48
+ }
49
+
50
+ /** §D's closed event set. Content-free by construction. */
51
+ const COMPENSATION_EVENTS = [
52
+ 'compensation.requested',
53
+ 'compensation.started',
54
+ 'compensation.completed',
55
+ 'compensation.failed',
56
+ 'compensation.paused',
57
+ 'compensation.manual_intervention_required',
58
+ ] as const;
59
+
60
+ async function advertised(): Promise<boolean> {
61
+ const disco = await driver.get('/.well-known/openwop');
62
+ const caps = capabilityFamily<CompensationCaps>(disco.json, 'compensation');
63
+ return caps?.supported === true;
64
+ }
65
+
66
+ describe('RFC 0151 §C — compensation lifecycle (capability-gated behavior)', () => {
67
+ it('a host advertising compensation implements reverse-completion', async () => {
68
+ if (!behaviorGate(PROFILE, await advertised())) return;
69
+ const disco = await driver.get('/.well-known/openwop');
70
+ const caps = capabilityFamily<CompensationCaps>(disco.json, 'compensation');
71
+ expect(
72
+ caps?.orderingModels ?? [],
73
+ driver.describe(
74
+ 'RFCS/0151-compensation-and-partial-failure-profile.md §A',
75
+ 'an advertising host MUST implement `reverse-completion`; `dependency-graph` is optional',
76
+ ),
77
+ ).toContain('reverse-completion');
78
+ });
79
+
80
+ it('the plan is persisted before the first inverse action', async () => {
81
+ if (!behaviorGate(PROFILE, await advertised())) return;
82
+ // §C's crash-safety rule, in its only black-box form: `requested` (plan
83
+ // persisted) strictly precedes `started` (first inverse action). A host that
84
+ // unwinds before persisting cannot resume — and the crash is precisely when
85
+ // resumption is the thing that matters.
86
+ const seam = await driver.post('/v1/host/sample/test/compensation/unwind', {});
87
+ if (seam.status === 404) {
88
+ expect(
89
+ seam.status,
90
+ driver.describe(
91
+ 'RFCS/0151 §C',
92
+ 'a host advertising `compensation` MUST expose the unwind sample seam so plan-before-effect ' +
93
+ 'ordering can be witnessed; without it the requirement is unobservable and the profile ' +
94
+ 'cannot be certified (RFC 0148 §A: unobservable resolves to `blocked`, not to a pass)',
95
+ ),
96
+ ).not.toBe(404);
97
+ return;
98
+ }
99
+ const events = (seam.json as { events?: { type: string }[] }).events ?? [];
100
+ const requestedAt = events.findIndex((e) => e.type === 'compensation.requested');
101
+ const startedAt = events.findIndex((e) => e.type === 'compensation.started');
102
+ expect(requestedAt, driver.describe('RFCS/0151 §D', '`compensation.requested` MUST be emitted')).toBeGreaterThanOrEqual(0);
103
+ expect(startedAt, driver.describe('RFCS/0151 §D', '`compensation.started` MUST be emitted')).toBeGreaterThanOrEqual(0);
104
+ expect(
105
+ requestedAt,
106
+ driver.describe(
107
+ 'RFCS/0151 §C',
108
+ 'the plan MUST be persisted BEFORE the first inverse action executes',
109
+ ),
110
+ ).toBeLessThan(startedAt);
111
+ });
112
+
113
+ it('inverse actions run in descending forward-completion order', async () => {
114
+ if (!behaviorGate(PROFILE, await advertised())) return;
115
+ const seam = await driver.post('/v1/host/sample/test/compensation/unwind', { nodes: 3 });
116
+ if (seam.status === 404) return; // covered by the seam assertion above
117
+ const order = (seam.json as { compensatedOrder?: number[] }).compensatedOrder ?? [];
118
+ const descending = [...order].sort((a, b) => b - a);
119
+ expect(
120
+ order,
121
+ driver.describe(
122
+ 'RFCS/0151 §C',
123
+ '`reverse-completion` orders compensations by DESCENDING durable forward-completion ' +
124
+ 'sequence. Compensating forward can release a resource a later inverse action still needs.',
125
+ ),
126
+ ).toEqual(descending);
127
+ });
128
+
129
+ it('replay does not re-fire inverse effects', async () => {
130
+ if (!behaviorGate(PROFILE, await advertised())) return;
131
+ const seam = await driver.post('/v1/host/sample/test/compensation/replay', {});
132
+ if (seam.status === 404) return;
133
+ expect(
134
+ (seam.json as { refiredEffects?: number }).refiredEffects ?? 0,
135
+ driver.describe(
136
+ 'RFCS/0151 §F',
137
+ 'replay defaults MUST use recorded compensation outcomes and MUST NOT re-fire inverse ' +
138
+ 'effects — a replay that re-executes them turns a recovery into a second outage',
139
+ ),
140
+ ).toBe(0);
141
+ });
142
+
143
+ it('compensation events carry no provider bodies or credentials', async () => {
144
+ if (!behaviorGate(PROFILE, await advertised())) return;
145
+ const seam = await driver.post('/v1/host/sample/test/compensation/unwind', {});
146
+ if (seam.status === 404) return;
147
+ const events = (seam.json as { events?: Record<string, unknown>[] }).events ?? [];
148
+ for (const e of events) {
149
+ if (!COMPENSATION_EVENTS.includes(e['type'] as (typeof COMPENSATION_EVENTS)[number])) continue;
150
+ const serialized = JSON.stringify(e);
151
+ for (const forbidden of ['-----BEGIN', 'Bearer ', 'sk-', 'authorization', 'providerResponse']) {
152
+ expect(
153
+ serialized.toLowerCase().includes(forbidden.toLowerCase()),
154
+ driver.describe(
155
+ 'RFCS/0151 §D + §G',
156
+ 'compensation event payloads carry opaque IDs and closed reason codes — never provider ' +
157
+ 'bodies or credentials. These land in the DURABLE log, which is the least revocable ' +
158
+ `place a credential can reach. Found: ${forbidden}`,
159
+ ),
160
+ ).toBe(false);
161
+ }
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RFC 0151 — the compensation profile's shape contract.
3
+ *
4
+ * **What this proves, stated first because RFC 0147 §A.5 turns on it:** the
5
+ * schemas admit exactly the shapes §A and §B describe and reject the ones they
6
+ * forbid. That is *shape-only* evidence. It is **not** evidence that any host
7
+ * orders an unwind correctly, persists a plan before the first inverse action,
8
+ * or resumes one after a crash — and RFC 0151 cannot reach a defensible
9
+ * `Accepted` on this alone. §A.5 requires a host executing every normative
10
+ * behavioral path in strict mode, and none does.
11
+ *
12
+ * Saying that here rather than in a register keeps it next to the thing it
13
+ * qualifies. A conformance file that verifies structure while its RFC claims
14
+ * behavior is how "green suite" and "working protocol" come apart.
15
+ *
16
+ * The design constraints worth holding onto, each of which the schema encodes:
17
+ *
18
+ * - **Compensation is a second effect, not an undo.** It can fail, can be
19
+ * partially applied, and can itself be harmful (RFC 0147 R9) — hence
20
+ * `requiresApproval` and the security-high tier.
21
+ * - **Inputs come from recorded facts.** §B forbids prompt/model regeneration
22
+ * from constructing a compensation input during replay, because an inverse
23
+ * built from a re-inferred value is not the inverse of what was done.
24
+ * - **`nodeTypeId` resolves at registration**, so an unwind cannot fail on a
25
+ * typo first discovered during a failure — the worst possible moment.
26
+ *
27
+ * Server-free.
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { readFileSync, readdirSync } from 'node:fs';
32
+ import { join, resolve as pathResolve } from 'node:path';
33
+ import Ajv2020 from 'ajv/dist/2020.js';
34
+ import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
35
+
36
+ /**
37
+ * Schemas ship inside the package; RFC prose does not. The old form derived
38
+ * BOTH from `V1_DIR` — null in the published tarball — and cast the null away,
39
+ * so this file threw at import for every consumer installing from npm.
40
+ */
41
+ const RFCS_DIR = V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'RFCS');
42
+
43
+ function schema(name: string): Record<string, unknown> {
44
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
45
+ }
46
+
47
+ /**
48
+ * Validate a candidate node against `WorkflowNode` alone.
49
+ *
50
+ * Every schema in the directory is registered first: `WorkflowNode` `$ref`s
51
+ * siblings by filename, so compiling it in isolation resolves nothing and Ajv
52
+ * throws rather than silently accepting — which is the good failure, but only
53
+ * if the harness registers what the schema actually depends on.
54
+ */
55
+ function nodeValidator() {
56
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
57
+ for (const file of readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json'))) {
58
+ const s = schema(file);
59
+ ajv.addSchema(s, file);
60
+ }
61
+ const wf = schema('workflow-definition.schema.json') as { $defs: Record<string, unknown> };
62
+ return ajv.compile({ ...(wf.$defs['WorkflowNode'] as object), $defs: wf.$defs });
63
+ }
64
+
65
+ describe('RFC 0151 §A — compensation capability shape', () => {
66
+ const caps = schema('capabilities.schema.json') as {
67
+ properties: Record<string, { properties?: Record<string, unknown>; required?: string[] }>;
68
+ };
69
+
70
+ it('the capability family exists and is closed', () => {
71
+ const c = caps.properties['compensation'];
72
+ expect(c, 'RFC 0151 §A: `compensation` MUST be a declared capability family').toBeDefined();
73
+ expect(c.required).toEqual(['supported']);
74
+ expect(Object.keys(c.properties ?? {}).sort()).toEqual(
75
+ ['manualIntervention', 'orderingModels', 'profileVersion', 'supported'].sort(),
76
+ );
77
+ });
78
+
79
+ it('orderingModels is a closed enum of the two named models', () => {
80
+ const models = (caps.properties['compensation'].properties?.['orderingModels'] as {
81
+ items: { enum: string[] };
82
+ }).items.enum;
83
+ expect(
84
+ [...models].sort(),
85
+ 'RFC 0151 §A: an advertising host MUST implement `reverse-completion` and MAY add ' +
86
+ '`dependency-graph`. A third model would be an unadvertised ordering guarantee.',
87
+ ).toEqual(['dependency-graph', 'reverse-completion']);
88
+ });
89
+
90
+ it('profileVersion participates in identity, so it is constrained', () => {
91
+ // §C derives the inverse-action id partly from `profileVersion`. An
92
+ // unconstrained string there would let two hosts mint colliding identities
93
+ // under different ordering rules.
94
+ const pv = caps.properties['compensation'].properties?.['profileVersion'] as { pattern?: string };
95
+ expect(pv.pattern, 'RFC 0151 §C: profileVersion is part of the inverse-action id').toBe('^[1-9][0-9]*$');
96
+ });
97
+ });
98
+
99
+ describe('RFC 0151 §B — node compensation declaration', () => {
100
+ const validate = nodeValidator();
101
+ // A minimally VALID node — `WorkflowNode` requires `name`, `position`,
102
+ // `config`, and `inputs` independently of this RFC. Building the fixture from
103
+ // the real required set keeps the legs below testing `compensation` rather
104
+ // than accidentally testing whether the base node is well-formed.
105
+ const base = {
106
+ id: 'reserve-inventory',
107
+ typeId: 'vendor.shop.reserve',
108
+ name: 'Reserve inventory',
109
+ position: { x: 0, y: 0 },
110
+ config: {},
111
+ inputs: {},
112
+ };
113
+
114
+ it('a well-formed declaration validates', () => {
115
+ const ok = validate({
116
+ ...base,
117
+ compensation: {
118
+ nodeTypeId: 'vendor.shop.release',
119
+ inputMapping: { reservationId: '${nodes.reserve-inventory.output.id}' },
120
+ retry: { maxAttempts: 5, backoffMs: 1000 },
121
+ requiresApproval: false,
122
+ },
123
+ });
124
+ expect(ok, JSON.stringify(validate.errors)).toBe(true);
125
+ });
126
+
127
+ it('a node without compensation stays valid', () => {
128
+ // The profile is optional. Absent MUST NOT become a validation error, or
129
+ // every existing workflow in the corpus breaks.
130
+ expect(validate({ ...base }), JSON.stringify(validate.errors)).toBe(true);
131
+ });
132
+
133
+ it('the declaration is closed — an unknown key is rejected', () => {
134
+ expect(
135
+ validate({ ...base, compensation: { nodeTypeId: 'x', onFailure: 'ignore' } }),
136
+ 'RFC 0151 §B: `compensation` is closed. An unrecognized key here is a silent behavioral ' +
137
+ 'assumption on the unwind path, which is the least observable place to have one.',
138
+ ).toBe(false);
139
+ });
140
+
141
+ it('nodeTypeId is required and non-empty', () => {
142
+ expect(validate({ ...base, compensation: {} }), 'nodeTypeId is required').toBe(false);
143
+ expect(
144
+ validate({ ...base, compensation: { nodeTypeId: '' } }),
145
+ 'RFC 0151 §B: `nodeTypeId` MUST resolve at registration — an empty id resolves to nothing, ' +
146
+ 'and the failure would surface only during an unwind.',
147
+ ).toBe(false);
148
+ });
149
+
150
+ it('retry bounds are integers within sane floors', () => {
151
+ expect(validate({ ...base, compensation: { nodeTypeId: 'x', retry: { maxAttempts: 0 } } })).toBe(false);
152
+ expect(validate({ ...base, compensation: { nodeTypeId: 'x', retry: { backoffMs: -1 } } })).toBe(false);
153
+ expect(validate({ ...base, compensation: { nodeTypeId: 'x', retry: { maxAttempts: 1, backoffMs: 0 } } })).toBe(true);
154
+ });
155
+ });
156
+
157
+ describe.skipIf(RFCS_DIR === null)('RFC 0151 — what this file does NOT establish', () => {
158
+ it('records that behavioral conformance is absent, per RFC 0147 §A.5', () => {
159
+ // Not decoration. RFC 0147 §A.5 forbids `Accepted` on shape-only evidence
160
+ // for a behavioral requirement, and this scenario is shape-only by
161
+ // construction: it compiles schemas and never contacts a host. The RFC's
162
+ // acceptance criteria and `docs/RFC-0147-SELF-AUDIT.md` both record 0151 as
163
+ // violating §A.5, and this leg exists so that reading the conformance suite
164
+ // alone cannot leave a different impression.
165
+ const rfc = readFileSync(
166
+ join(RFCS_DIR as string, '0151-compensation-and-partial-failure-profile.md'),
167
+ 'utf8',
168
+ );
169
+ expect(
170
+ /Reverse-unwind, retry, crash, partial\/manual, approval, replay, and isolation scenarios pass\.\s*\(/.test(rfc),
171
+ 'RFC 0151\'s behavioral acceptance item MUST remain unticked and annotated: no host executes ' +
172
+ 'an unwind, so ordering, persistence-before-first-action, and crash resumption are unproven.',
173
+ ).toBe(true);
174
+ });
175
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `contractProvenance` — which corpus revision a host implements against (RFC 0146).
3
+ *
4
+ * THE FAILURE THIS EXISTS FOR, and it is not hypothetical. A reference host's hand-copied
5
+ * `capabilities.schema.json` carried 81 properties where the corpus had 88, so it validated
6
+ * its own discovery document against a contract that predated the very declaration it was
7
+ * checking. Green, and wrong. Nothing on the wire distinguished it from a current host.
8
+ *
9
+ * WHY VALIDATION CANNOT SEE THIS — the point that decides the whole design. v1.x changes are
10
+ * additive (`COMPATIBILITY.md` §2.1), so a document written against an older contract still
11
+ * validates against the newer schema. Validation is precisely the instrument that is blind
12
+ * here, which is why the stale host was green. Only a claim on the wire can surface it.
13
+ *
14
+ * ADVISORY BY CONSTRUCTION. A host on an older corpus revision is CONFORMANT — additive means
15
+ * older is legal. So requirement 3 forbids rejecting on a mismatch, and these legs assert the
16
+ * SHAPE of the claim, never that a host is current. A leg that failed a host for being behind
17
+ * would convert an optional disclosure into a de-facto upgrade mandate inside a version line
18
+ * where being behind is permitted.
19
+ *
20
+ * NOT BUILT (RFC 0146 G2): a leg asserting the advertised revision matches what the host
21
+ * ACTUALLY validates with (requirement 2). Nothing observable from outside distinguishes a
22
+ * host implementing corpus X from one merely claiming X — the same self-report limit as
23
+ * RFC 0145 requirement 3. Asserting it would be theatre.
24
+ *
25
+ * THE CONSUMER HALF (RFC 0146 G3). A provenance nobody reads is a field, not a mechanism, so
26
+ * this suite reads it: leg C compares a host's advertised revision against the suite's OWN
27
+ * `schemas/CORPUS-STAMP.json` and REPORTS the drift. It is the second half of G3 — the first
28
+ * being a host that advertises.
29
+ *
30
+ * IT REPORTS AND NEVER FAILS, and that is requirement 3, not timidity. v1.x revisions are
31
+ * additive, so a host on an older corpus is CONFORMANT; a leg that reddened it would convert
32
+ * an advisory disclosure into an upgrade mandate inside a version line where being behind is
33
+ * legal. The same call the RFC 0144 G1 arm-witness leg makes for dotted-at-root.
34
+ *
35
+ * THE STAMP ONLY EXISTS IN THE PUBLISHED LAYOUT. It is written at prepack into the vendored
36
+ * `schemas/`, never into the repo tree — so a repo-layout run has nothing to compare against
37
+ * and the leg is INAPPLICABLE there. That asymmetry already bit once: when the stamp landed,
38
+ * "written only into the tarball" and "visible to the gate" turned out to be different claims,
39
+ * and only forcing the published layout revealed it. Verified the same way here.
40
+ *
41
+ * @see schemas/capabilities.schema.json §contractProvenance
42
+ * @see RFCS/0146-contract-provenance-advertisement.md
43
+ */
44
+
45
+ import { describe, it, expect } from 'vitest';
46
+ import { readFileSync } from 'node:fs';
47
+ import { join } from 'node:path';
48
+ import Ajv2020 from 'ajv/dist/2020.js';
49
+ import addFormats from 'ajv-formats';
50
+ import { driver } from '../lib/driver.js';
51
+ import { SCHEMAS_DIR } from '../lib/paths.js';
52
+
53
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
54
+
55
+ function provenanceSubschema(): Record<string, unknown> {
56
+ const caps = JSON.parse(
57
+ readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
58
+ ) as { properties: Record<string, Record<string, unknown>>; required?: string[] };
59
+ return caps.properties.contractProvenance;
60
+ }
61
+
62
+ function compiled(): ReturnType<Ajv2020['compile']> {
63
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
64
+ addFormats(ajv);
65
+ return ajv.compile(provenanceSubschema());
66
+ }
67
+
68
+ describe('contract-provenance (RFC 0146, always-on)', () => {
69
+ it('A1 — declared at the document ROOT, and OPTIONAL', () => {
70
+ const caps = JSON.parse(
71
+ readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
72
+ ) as { properties: Record<string, unknown>; required?: string[] };
73
+
74
+ expect(
75
+ caps.properties['contractProvenance'],
76
+ why('capabilities.schema.json', 'declared as a root property — families live at the document root per capabilities.md §"Document-root layout", never under the deprecated wrapper'),
77
+ ).toBeDefined();
78
+ expect(
79
+ (caps.required ?? []).includes('contractProvenance'),
80
+ why('RFC 0146 req 1', 'OPTIONAL — absent means UNSPECIFIED provenance, not "current" and not "stale"; a host must not be non-conformant for staying silent'),
81
+ ).toBe(false);
82
+ });
83
+
84
+ it('A2 — a full 40-hex commit validates; a short SHA or a vendor build string does NOT', () => {
85
+ const validate = compiled();
86
+
87
+ expect(
88
+ validate({ suiteVersion: '1.72.0', corpusCommit: '93d4692eb1e28244b860da6ddcb6521b57a712b3' }),
89
+ why('RFC 0146 req 4', `a real stamp validates: ${JSON.stringify(validate.errors)}`),
90
+ ).toBe(true);
91
+ // This is what keeps the field from decaying into a free-text version box. A short SHA is
92
+ // ambiguous across a growing history; a vendor build id belongs in `implementation`, which
93
+ // already exists for exactly that.
94
+ expect(
95
+ validate({ corpusCommit: '93d4692' }),
96
+ why('RFC 0146 req 4', 'a SHORT sha is REJECTED — abbreviated commits are ambiguous and this field is an identity, not a hint'),
97
+ ).toBe(false);
98
+ expect(
99
+ validate({ corpusCommit: 'build-4711' }),
100
+ why('RFC 0146 req 4', 'a vendor build identifier is REJECTED — `implementation` is the field for that'),
101
+ ).toBe(false);
102
+ });
103
+
104
+ it('A3 — both members optional, and unknown members rejected', () => {
105
+ const validate = compiled();
106
+
107
+ expect(validate({}), why('RFC 0146 req 1', 'an empty object validates — a host may know neither')).toBe(true);
108
+ expect(
109
+ validate({ suiteVersion: '1.72.0' }),
110
+ why('RFC 0146 req 1', 'a host that knows only its suite version advertises only that'),
111
+ ).toBe(true);
112
+ expect(
113
+ validate({ suiteVersion: '1.72.0', schemaDigest: 'abc' }),
114
+ why('RFC 0146 §Proposal', 'the object is closed — a digest is a DIFFERENT artifact answering a different question (tamper vs identity) and is deliberately not part of this shape'),
115
+ ).toBe(false);
116
+ });
117
+
118
+ // `RFCS/` is NOT shipped in the published tarball, so this leg is repo-layout only. Reading
119
+ // it unconditionally made the scenario ENOENT for every adopter running from the package —
120
+ // passing locally and reddening for a reason that has nothing to do with the host under
121
+ // test, which is worse than a no-op. Third instance of this asymmetry in this corpus; the
122
+ // first two were the CORPUS-STAMP gate and the link-checker's filesystem walk.
123
+ const rfcText = ((): string | null => {
124
+ try {
125
+ return readFileSync(
126
+ join(SCHEMAS_DIR, '..', 'RFCS', '0146-contract-provenance-advertisement.md'),
127
+ 'utf8',
128
+ );
129
+ } catch {
130
+ return null;
131
+ }
132
+ })();
133
+
134
+ it.skipIf(rfcText === null)('A4 — the RFC states the advisory rule, which is what stops this becoming an upgrade mandate', () => {
135
+ const rfc = rfcText ?? '';
136
+ expect(
137
+ /MUST NOT reject a request, refuse interop, or fail a run solely because/.test(rfc),
138
+ why('RFC 0146 req 3', 'a consumer MUST NOT reject on a mismatch — v1.x revisions are additive, so a host on an older revision is CONFORMANT and the field detects drift rather than creating an error'),
139
+ ).toBe(true);
140
+ });
141
+ });
142
+
143
+ /** The suite's OWN provenance, from the vendored stamp — present only in the published layout. */
144
+ function suiteStamp(): { suiteVersion?: string; corpusCommit?: string } | null {
145
+ try {
146
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, 'CORPUS-STAMP.json'), 'utf8')) as {
147
+ suiteVersion?: string;
148
+ corpusCommit?: string;
149
+ };
150
+ } catch {
151
+ // Repo layout: no stamp is written into the tree, so there is nothing to compare against.
152
+ return null;
153
+ }
154
+ }
155
+
156
+ describe('contract-provenance: the suite READS the advert — the consumer half (RFC 0146 G3)', () => {
157
+ it('compares the advertised revision against the suite\'s own, and reports drift', async () => {
158
+ const mine = suiteStamp();
159
+ if (mine === null) return; // repo layout — inapplicable, see the docblock
160
+
161
+ const res = await driver.get('/.well-known/openwop');
162
+ if (res.status !== 200 || res.json === null || res.json === undefined) return;
163
+ const adv = (res.json as Record<string, unknown>)['contractProvenance'] as
164
+ | { suiteVersion?: string; corpusCommit?: string }
165
+ | undefined;
166
+ if (adv === undefined) return; // silent host — absent means UNSPECIFIED (req 1), not stale
167
+
168
+ const same = adv.corpusCommit !== undefined && adv.corpusCommit === mine.corpusCommit;
169
+ console.log(
170
+ ` [contract-provenance] host: suite=${adv.suiteVersion ?? '?'} commit=${(adv.corpusCommit ?? '?').slice(0, 12)} | ` +
171
+ `this suite: suite=${mine.suiteVersion ?? '?'} commit=${(mine.corpusCommit ?? '?').slice(0, 12)} | ` +
172
+ `${same ? 'SAME corpus revision' : 'DIFFERENT corpus revision — the host implements a revision this suite was not cut from'}`,
173
+ );
174
+
175
+ // NO ASSERTION ON EQUALITY, deliberately. Requirement 3: a consumer MUST NOT reject or
176
+ // fail solely because a host's provenance differs from its own — additive means older is
177
+ // CONFORMANT. What IS asserted is that a host making the claim makes a well-formed one,
178
+ // because an unparseable provenance is useless to every consumer, not just this one.
179
+ expect(
180
+ typeof adv.corpusCommit === 'string' || typeof adv.suiteVersion === 'string',
181
+ driver.describe(
182
+ 'RFC 0146 req 1 + req 4',
183
+ 'an advertised contractProvenance carries at least one of suiteVersion / corpusCommit — an object conveying neither is indistinguishable from silence while looking like an answer',
184
+ ),
185
+ ).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe('contract-provenance: a host advertising it makes a well-formed claim (RFC 0146)', () => {
190
+ it('the advertised provenance validates against the declared shape', async () => {
191
+ const res = await driver.get('/.well-known/openwop');
192
+ if (res.status !== 200 || res.json === null || res.json === undefined) return;
193
+
194
+ const doc = res.json as Record<string, unknown>;
195
+ const adv = doc['contractProvenance'];
196
+ // INAPPLICABLE, not gated. The field is OPTIONAL (req 1) and strict mode must not coerce a
197
+ // host into advertising — the same call RFC 0142 makes for `store` and RFC 0145 for
198
+ // `registrationSource`. Silence is an honest answer here.
199
+ if (adv === undefined) return;
200
+
201
+ expect(
202
+ compiled()(adv),
203
+ driver.describe(
204
+ 'capabilities.schema.json §contractProvenance',
205
+ `an advertised contractProvenance MUST match the declared shape — a full 40-hex corpusCommit and a published suiteVersion, nothing else: ${JSON.stringify(compiled().errors)}`,
206
+ ),
207
+ ).toBe(true);
208
+ });
209
+ });