@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,198 @@
1
+ /**
2
+ * RFC 0155 §B + §C — the stable core manifest and the extension registry.
3
+ *
4
+ * §B's value is not the inventory. It is the sentence after it: *"prose and code
5
+ * profile definitions MUST be generated from or checked against this manifest."*
6
+ * Three places describe the core-standard floor independently — the profile
7
+ * prose, `PROFILE_FLOOR_SCENARIOS`, and the requirement registry — and before
8
+ * this they could only be assumed to agree.
9
+ *
10
+ * They did not. `PROFILE_FLOOR_SCENARIOS` was an incomplete transcription of
11
+ * `profiles.md`, and every profile it omitted verified as floor-proven against
12
+ * nothing (RFC 0148 §C). A manifest with a parity gate is the mechanism that
13
+ * would have caught that, which is why the manifest is DERIVED and never
14
+ * hand-listed: a hand-listed manifest drifts the moment the corpus moves and
15
+ * then asserts the drift with a digest attached.
16
+ *
17
+ * §C's bar for `stable` is deliberately hard — normative prose, schemas,
18
+ * non-vacuous conformance, SDK support, and at least one Tier-3 implementation.
19
+ * The consequence, stated plainly rather than worked around: **nothing in this
20
+ * corpus can currently be `stable`**, because no Tier-3 host exists. That is a
21
+ * fact about adoption, not about the work.
22
+ *
23
+ * Server-free; reads the corpus.
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { V1_DIR } from '../lib/paths.js';
30
+ import { PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
31
+ import { requirementsFor } from '../lib/requirement-registry.js';
32
+
33
+ const MATURITIES = ['experimental', 'draft', 'stable', 'deprecated'] as const;
34
+ type Maturity = (typeof MATURITIES)[number];
35
+
36
+ interface Extension {
37
+ readonly id: string;
38
+ readonly maturity: Maturity;
39
+ readonly owningRfc: string;
40
+ readonly capabilityPath: string;
41
+ readonly dependsOn: readonly string[];
42
+ readonly securityTier: string;
43
+ readonly minimumSuiteVersion: string | null;
44
+ readonly evidenceTier: string | null;
45
+ readonly note?: string;
46
+ }
47
+
48
+ /** The capability schema, for `capabilityPath` resolution. */
49
+ function caps(): Record<string, unknown> {
50
+ return JSON.parse(
51
+ readFileSync(join(V1_DIR as string, '..', '..', 'schemas', 'capabilities.schema.json'), 'utf8'),
52
+ ) as Record<string, unknown>;
53
+ }
54
+
55
+ function readJson<T>(name: string): T {
56
+ return JSON.parse(readFileSync(join(V1_DIR as string, name), 'utf8')) as T;
57
+ }
58
+
59
+ describe.skipIf(V1_DIR === null)('RFC 0155 §B — core-standard manifest parity', () => {
60
+ const manifest = V1_DIR === null ? null : readJson<{
61
+ profile: string;
62
+ digest: string;
63
+ floor: { requiredScenarios: string[]; requiredAnyPrefix: string[] };
64
+ requirementIds: string[];
65
+ openapiOperations: string[];
66
+ schemas: { file: string; $id: string | null }[];
67
+ }>('core-standard-manifest.json');
68
+
69
+ it('the manifest exists and is non-trivial', () => {
70
+ // Guard: an empty manifest would make every parity leg below vacuously true,
71
+ // which is the exact shape RFC 0148 §C found in the floor verifier.
72
+ expect(manifest, 'RFC 0155 §B: the manifest MUST be published').not.toBeNull();
73
+ const m = manifest as NonNullable<typeof manifest>;
74
+ expect(m.profile).toBe('openwop-core-standard');
75
+ expect(m.digest, 'the manifest MUST carry a digest').toMatch(/^[0-9a-f]{64}$/);
76
+ expect(m.floor.requiredScenarios.length).toBeGreaterThan(5);
77
+ expect(m.openapiOperations.length).toBeGreaterThan(20);
78
+ expect(m.schemas.length).toBeGreaterThan(20);
79
+ });
80
+
81
+ it('the manifest floor matches the floor the suite actually enforces', () => {
82
+ // The parity §B asks for. If these drift, the manifest is asserting a floor
83
+ // nobody runs — worse than no manifest, because it looks authoritative.
84
+ const m = manifest as NonNullable<typeof manifest>;
85
+ const live = PROFILE_FLOOR_SCENARIOS['openwop-core-standard'];
86
+ expect(live, 'the suite MUST define a core-standard floor').toBeDefined();
87
+ expect([...m.floor.requiredScenarios].sort()).toEqual([...(live?.required ?? [])].sort());
88
+ expect([...m.floor.requiredAnyPrefix].sort()).toEqual([...(live?.requiredAnyPrefix ?? [])].sort());
89
+ });
90
+
91
+ it('the manifest requirement IDs match the requirement registry', () => {
92
+ const m = manifest as NonNullable<typeof manifest>;
93
+ const fromRegistry = requirementsFor('openwop-core-standard');
94
+ expect(fromRegistry, 'core-standard MUST have registered requirements').not.toBeNull();
95
+ expect([...m.requirementIds].sort()).toEqual([...(fromRegistry as readonly string[])].sort());
96
+ });
97
+
98
+ it('every schema the manifest lists declares an $id', () => {
99
+ const m = manifest as NonNullable<typeof manifest>;
100
+ const missing = m.schemas.filter((s) => s.$id === null).map((s) => s.file);
101
+ expect(missing, 'CONTRIBUTING.md: every schema carries an `$id` under openwop.dev/spec/v1/').toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe.skipIf(V1_DIR === null)('RFC 0155 §C — extension registry', () => {
106
+ const registry = V1_DIR === null ? null : readJson<{ extensions: Extension[] }>('extensions.json');
107
+
108
+ it('the registry exists and every record is closed', () => {
109
+ expect(registry, 'RFC 0155 §C: `spec/v1/extensions.json` MUST exist').not.toBeNull();
110
+ const exts = (registry as NonNullable<typeof registry>).extensions;
111
+ expect(exts.length, 'the registry MUST cover the program extensions').toBeGreaterThan(3);
112
+ for (const e of exts) {
113
+ for (const k of ['id', 'maturity', 'owningRfc', 'capabilityPath', 'dependsOn', 'securityTier']) {
114
+ expect(e[k as keyof Extension], `${e.id} MUST declare ${k}`).toBeDefined();
115
+ }
116
+ expect(MATURITIES, `${e.id}: maturity is a closed enum`).toContain(e.maturity);
117
+ }
118
+ expect(new Set(exts.map((e) => e.id)).size, 'extension ids MUST be unique').toBe(exts.length);
119
+ });
120
+
121
+ it('no extension is `stable` without a Tier-3 implementation', () => {
122
+ // §C: stable requires normative prose, schemas, non-vacuous conformance, SDK
123
+ // support where applicable, and at least one Tier-3 implementation. The last
124
+ // one is the binding constraint here, and it is external to this repo — no
125
+ // Tier-3 host exists, so NOTHING can currently be stable. Recording that
126
+ // ceiling is the honest move; promoting anything past it would be the
127
+ // overclaim RFC 0147 §A bans.
128
+ const overclaimed = (registry as NonNullable<typeof registry>).extensions
129
+ .filter((e) => e.maturity === 'stable' && (e.evidenceTier === null || e.evidenceTier === undefined))
130
+ .map((e) => e.id);
131
+ expect(
132
+ overclaimed,
133
+ 'RFC 0155 §C: `stable` requires at least one Tier-3 implementation, recorded in `evidenceTier`. ' +
134
+ 'An extension marked stable with no evidence tier is a claim the corpus cannot substantiate.',
135
+ ).toEqual([]);
136
+ });
137
+
138
+ it('every dependency resolves to a known profile or listed extension', () => {
139
+ // A dependency on something that does not exist is a closure hole: the
140
+ // record looks complete and the graph does not.
141
+ const exts = (registry as NonNullable<typeof registry>).extensions;
142
+ const known = new Set<string>([...Object.keys(PROFILE_FLOOR_SCENARIOS), ...exts.map((e) => e.id)]);
143
+ const dangling = exts.flatMap((e) =>
144
+ e.dependsOn.filter((d) => !known.has(d)).map((d) => `${e.id} -> ${d}`),
145
+ );
146
+ expect(dangling, 'RFC 0155 §C: `dependsOn` MUST resolve').toEqual([]);
147
+ });
148
+
149
+ it('every capabilityPath resolves against the capability schema', () => {
150
+ // Fifth axis of the named-list check, and it found four of six broken.
151
+ // Three were typos introduced when this registry was written —
152
+ // `a2a.protocolVersion` for `protocolVersions`, the same for MCP, and
153
+ // `workloadIdentity.supported` omitting its `auth.` parent. The fourth,
154
+ // `idempotency.supported`, pointed at a field the corpus USES in its own
155
+ // examples but had never DECLARED; it validated only because that family
156
+ // carries `additionalProperties: true`, so a typo like `suported` was
157
+ // accepted silently.
158
+ //
159
+ // An extension whose capabilityPath does not resolve is unreachable: a
160
+ // consumer following the registry to find the flag finds nothing, and the
161
+ // registry looks complete while pointing at empty space.
162
+ const schema = caps() as { properties: Record<string, unknown> };
163
+ const unresolved: string[] = [];
164
+ for (const e of (registry as NonNullable<typeof registry>).extensions) {
165
+ let node = schema.properties as Record<string, { properties?: Record<string, unknown> }> | undefined;
166
+ let ok = true;
167
+ for (const part of e.capabilityPath.split('.')) {
168
+ if (node === undefined || !(part in node)) {
169
+ ok = false;
170
+ break;
171
+ }
172
+ node = node[part]?.properties as typeof node;
173
+ }
174
+ if (!ok) unresolved.push(`${e.id} -> ${e.capabilityPath}`);
175
+ }
176
+ expect(
177
+ unresolved,
178
+ 'RFC 0155 §C: `capabilityPath` MUST resolve to a declared property in ' +
179
+ '`capabilities.schema.json`. An unresolvable path makes the extension unreachable — a ' +
180
+ 'consumer following the registry to find the flag finds nothing, while the registry ' +
181
+ 'still reads as complete.\n ' + unresolved.join('\n '),
182
+ ).toEqual([]);
183
+ });
184
+
185
+ it('every record names the RFC that owns it', () => {
186
+ // Vendor extensions may not use an `openwop-*` id without an accepted RFC
187
+ // (§F). The owning RFC is what makes that checkable.
188
+ for (const e of (registry as NonNullable<typeof registry>).extensions) {
189
+ expect(e.owningRfc, `${e.id} MUST name an owning RFC`).toMatch(/^\d{4}$/);
190
+ if (e.id.startsWith('openwop-')) {
191
+ expect(
192
+ e.owningRfc.length,
193
+ `${e.id}: an \`openwop-*\` id requires an accepted RFC (§F)`,
194
+ ).toBeGreaterThan(0);
195
+ }
196
+ }
197
+ });
198
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * RFC 0149 §E — a vendor extension MUST NOT shadow a canonical capability family.
3
+ *
4
+ * RFC 0073 put canonical families at the document root, and `host-extensions.md`
5
+ * §"Canonical prefixes" puts vendor surface under `x-host-<vendor>-*`,
6
+ * `vendor.<org>.*`, or `private.<host>.*`. Those two rules together are what make
7
+ * discovery negotiable: a consumer reads the root for what the protocol defines
8
+ * and treats a namespaced key as opaque.
9
+ *
10
+ * The gap is what happens when a canonical family name appears *inside* the
11
+ * namespaced region — `vendor.acme.auth`, or an `x-host-acme-*` object carrying
12
+ * its own `interrupts`. Nothing in the corpus forbade it, and a consumer that
13
+ * merges vendor surface over the root before negotiating reads a vendor's
14
+ * `auth` block as *the* auth contract. `host-extensions.md` already says clients
15
+ * MUST treat extension surface as opaque, but "opaque" is a rule about the
16
+ * consumer; it does not stop a host from publishing the collision, and the
17
+ * consumer that gets it wrong is the one that most needed the guardrail.
18
+ *
19
+ * §E's second clause is separate and unconditional: no discovery example may
20
+ * carry credentials or tenant data. Discovery is the one document a host serves
21
+ * credential-free to anonymous callers (RFC 0100 requires `agentCardUrl` to GET-
22
+ * resolve without credentials), so a secret pasted into an example is a secret
23
+ * in the most-copied, least-guarded artifact in the corpus.
24
+ *
25
+ * Both legs are structural and server-free — they read the corpus, not a host.
26
+ * `spec/v1/` and `RFCS/` are repository-only, so this self-skips under the
27
+ * published tarball layout.
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 { V1_DIR } from '../lib/paths.js';
34
+
35
+ const SCHEMA_PATH =
36
+ V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'schemas', 'capabilities.schema.json');
37
+
38
+ /** The canonical families, read from the schema rather than hand-listed. */
39
+ function canonicalFamilies(): Set<string> {
40
+ if (SCHEMA_PATH === null) return new Set();
41
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as {
42
+ properties?: Record<string, unknown>;
43
+ };
44
+ return new Set(Object.keys(schema.properties ?? {}));
45
+ }
46
+
47
+ /** `host-extensions.md` §"Canonical prefixes". */
48
+ function isVendorKey(key: string): boolean {
49
+ return /^x-host-/.test(key) || /^(vendor|private)\./.test(key);
50
+ }
51
+
52
+ interface Finding {
53
+ readonly file: string;
54
+ readonly line: number;
55
+ readonly detail: string;
56
+ }
57
+
58
+ /** Every fenced json/jsonc example under `dir`, paired with its source line. */
59
+ function fencedObjects(dir: string): { file: string; line: number; value: unknown }[] {
60
+ const out: { file: string; line: number; value: unknown }[] = [];
61
+ for (const name of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
62
+ const lines = readFileSync(join(dir, name), 'utf8').split('\n');
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (!/^```(json|jsonc)\s*$/.test(lines[i]!.trim())) continue;
65
+ const body: string[] = [];
66
+ let j = i + 1;
67
+ while (j < lines.length && lines[j]!.trim() !== '```') body.push(lines[j++]!);
68
+ try {
69
+ out.push({ file: name, line: i + 2, value: JSON.parse(body.join('\n')) });
70
+ } catch {
71
+ // Unparseable blocks are RFC 0150 §D's problem, not this gate's.
72
+ }
73
+ i = j;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Canonical family names appearing anywhere beneath a vendor-namespaced key. */
80
+ function shadowed(value: unknown, families: Set<string>, insideVendor: boolean): string[] {
81
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return [];
82
+ const hits: string[] = [];
83
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
84
+ const nowInside = insideVendor || isVendorKey(key);
85
+ if (insideVendor && families.has(key)) hits.push(key);
86
+ hits.push(...shadowed(child, families, nowInside));
87
+ }
88
+ return hits;
89
+ }
90
+
91
+ /**
92
+ * Credential detection runs on THREE axes with different failure modes, because
93
+ * no one of them is complete and the ways they are incomplete do not overlap.
94
+ *
95
+ * A peer put the problem precisely: *a negative existence claim cannot be
96
+ * established by grepping the vocabulary you would have chosen.* Axis 1 alone —
97
+ * a hand-picked list of issuer prefixes — reports clean on every credential
98
+ * format its author did not think of, and reports it in exactly the confident
99
+ * tone of a real check. That is the vacuous-witness pattern wearing a different
100
+ * hat: the gate is honest about what it observed and silent about what it
101
+ * cannot see.
102
+ *
103
+ * None of the three closes the claim. Together they fail differently, which is
104
+ * the most that can be said for them, and it is said here rather than implied by
105
+ * a green run.
106
+ */
107
+
108
+ /** Axis 1 — known issuer prefixes. Blind to any format not listed. */
109
+ const SECRET_PREFIX = [
110
+ /\bsk-[A-Za-z0-9]{16,}/,
111
+ /\bghp_[A-Za-z0-9]{20,}/,
112
+ /\bAKIA[0-9A-Z]{16}\b/,
113
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/,
114
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
115
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
116
+ ];
117
+
118
+ /** Axis 2 — the property NAME claims to hold a credential. Blind to odd names. */
119
+ const CREDENTIAL_KEY = /secret|password|token|api_?key|credential|private_?key|bearer/i;
120
+
121
+ /** An example is allowed to say `sk-…` or `<your-key>`; that is what examples are for. */
122
+ function isPlaceholder(v: string): boolean {
123
+ if (v.length < 16) return true;
124
+ if (/^https?:\/\//.test(v)) return true;
125
+ return /\.\.\.|…|<|>|\bexample\b|\bplaceholder\b|\bredacted\b|\byour-|\bchangeme\b|x{4,}/i.test(v);
126
+ }
127
+
128
+ /**
129
+ * Axis 3 — dense random-looking material regardless of issuer. Requires no
130
+ * separators, mixed case, digits, and high Shannon entropy, which is what
131
+ * distinguishes a credential body from a long dotted identifier or an SRI hash.
132
+ * Blind to low-entropy secrets and to anything with word structure.
133
+ */
134
+ function isDenseToken(v: string): boolean {
135
+ if (!/^[A-Za-z0-9]{24,}$/.test(v)) return false;
136
+ if (!(/[a-z]/.test(v) && /[A-Z]/.test(v) && /[0-9]/.test(v))) return false;
137
+ const counts = new Map<string, number>();
138
+ for (const ch of v) counts.set(ch, (counts.get(ch) ?? 0) + 1);
139
+ let entropy = 0;
140
+ for (const c of counts.values()) {
141
+ const p = c / v.length;
142
+ entropy -= p * Math.log2(p);
143
+ }
144
+ return entropy >= 4.2;
145
+ }
146
+
147
+ function secrets(value: unknown): string[] {
148
+ const hits: string[] = [];
149
+ const walk = (v: unknown, key: string | null): void => {
150
+ if (typeof v === 'string') {
151
+ for (const re of SECRET_PREFIX) if (re.test(v)) hits.push(`${v.slice(0, 24)} [issuer-prefix]`);
152
+ if (key !== null && CREDENTIAL_KEY.test(key) && !isPlaceholder(v)) {
153
+ hits.push(`${key}=${v.slice(0, 24)} [credential-named]`);
154
+ }
155
+ if (isDenseToken(v)) hits.push(`${v.slice(0, 24)} [dense-token]`);
156
+ return;
157
+ }
158
+ if (Array.isArray(v)) return v.forEach((c) => walk(c, key));
159
+ if (v !== null && typeof v === 'object') {
160
+ for (const [k, c] of Object.entries(v)) walk(c, k);
161
+ }
162
+ };
163
+ walk(value, null);
164
+ return [...new Set(hits)];
165
+ }
166
+
167
+ describe.skipIf(V1_DIR === null)('RFC 0149 §E — canonical families are not shadowed', () => {
168
+ const v1Dir = V1_DIR as string;
169
+ const rfcsDir = V1_DIR === null ? '' : pathResolve(v1Dir, '..', '..', 'RFCS');
170
+
171
+ it('the canonical family list and the example corpus are both non-empty', () => {
172
+ // Guard: an empty family set or an empty scan makes both legs vacuous —
173
+ // the failure RFC 0148 exists to close.
174
+ expect(canonicalFamilies().size, 'capabilities.schema.json MUST declare families').toBeGreaterThan(50);
175
+ expect(fencedObjects(v1Dir).length, 'spec/v1 MUST contain parseable json examples').toBeGreaterThan(20);
176
+ });
177
+
178
+ it('no vendor-namespaced object re-declares a canonical family', () => {
179
+ const families = canonicalFamilies();
180
+ const findings: Finding[] = [];
181
+ for (const dir of [v1Dir, rfcsDir]) {
182
+ for (const { file, line, value } of fencedObjects(dir)) {
183
+ const hits = shadowed(value, families, false);
184
+ if (hits.length > 0) {
185
+ const rel = dir === v1Dir ? 'spec/v1' : 'RFCS';
186
+ findings.push({ file, line, detail: `${rel}/${file}:${line} → ${[...new Set(hits)].join(', ')}` });
187
+ }
188
+ }
189
+ }
190
+ expect(
191
+ findings.map((f) => f.detail),
192
+ 'RFC 0149 §E: a canonical family name inside `x-host-*` / `vendor.*` / `private.*` shadows ' +
193
+ 'the family a consumer negotiates on. `host-extensions.md` tells clients to treat ' +
194
+ 'extension surface as opaque, but that binds the consumer — it does not stop a host from ' +
195
+ 'publishing the collision, and the consumer that merges vendor over root before ' +
196
+ 'negotiating is exactly the one the rule was meant to protect.\n ' +
197
+ findings.map((f) => f.detail).join('\n '),
198
+ ).toEqual([]);
199
+ });
200
+
201
+ it('no discovery example carries credential material', () => {
202
+ const findings: string[] = [];
203
+ for (const dir of [v1Dir, rfcsDir]) {
204
+ for (const { file, line, value } of fencedObjects(dir)) {
205
+ const hits = secrets(value);
206
+ if (hits.length > 0) {
207
+ const rel = dir === v1Dir ? 'spec/v1' : 'RFCS';
208
+ findings.push(`${rel}/${file}:${line} → ${hits.join(', ')}…`);
209
+ }
210
+ }
211
+ }
212
+ expect(
213
+ findings,
214
+ 'RFC 0149 §E: discovery is served credential-free to anonymous callers, so a real-shaped ' +
215
+ 'secret in an example sits in the most-copied, least-guarded artifact in the corpus. ' +
216
+ 'Use an obvious placeholder.\n ' + findings.join('\n '),
217
+ ).toEqual([]);
218
+ });
219
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * RFC 0150 §B — the Layer-2 logical effect identity is stable across retries.
3
+ *
4
+ * `spec/v1/idempotency.md` contradicted itself. §"Idempotency key composition"
5
+ * put `attempt` — documented one line later as the "zero-based retry attempt
6
+ * counter" — inside the hash, while §"Composition: how the layers compose"
7
+ * promised that when "the engine retries the OpenAI call internally (transient
8
+ * 503), Layer 2's `invocationId` is identical, so the second call either
9
+ * short-circuits (cache hit) or hits OpenAI's own idempotency cache".
10
+ *
11
+ * Both cannot hold. A retry counter in the key means every retry hashes to a
12
+ * NEW key, so the invocation log never hits, the injected `Idempotency-Key`
13
+ * differs, and the provider's own dedup is defeated too. The composition
14
+ * guaranteed a duplicate side effect on precisely the path Layer 2 exists to
15
+ * protect — a duplicate charge, a duplicate send, a duplicate completion.
16
+ *
17
+ * The defect was invisible to every gate in the corpus because both halves are
18
+ * prose. Nothing parsed the formula, and nothing cross-read it against the
19
+ * paragraph asserting the opposite.
20
+ *
21
+ * This gate reads the normative composition block and holds it to §B: domain
22
+ * separation, tenant binding, a per-logical-invocation ordinal that is stable
23
+ * across retries, and NO retry counter. `attempt` remains legitimate telemetry;
24
+ * §B's requirement is that it MUST NOT participate in the identity.
25
+ *
26
+ * Server-free and always-on: it reads the corpus, never a host. `spec/v1/`
27
+ * ships in the repository and NOT in the published tarball, so it self-skips
28
+ * under the published layout — the asymmetry that has already produced three
29
+ * defects here (the `CORPUS-STAMP` gate, the link-checker's filesystem walk,
30
+ * and RFC 0146 leg A4).
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { readFileSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import { V1_DIR } from '../lib/paths.js';
37
+
38
+ const HEADING = '### Idempotency key composition';
39
+
40
+ /**
41
+ * The first fenced block under the composition heading. Returns null when the
42
+ * heading or its fence is absent, so the guard leg can fail loudly rather than
43
+ * letting every assertion below pass over an empty string.
44
+ */
45
+ function compositionBlock(doc: string): string | null {
46
+ const lines = doc.split('\n');
47
+ const start = lines.findIndex((l) => l.trim() === HEADING);
48
+ if (start === -1) return null;
49
+ const open = lines.findIndex((l, i) => i > start && /^```/.test(l.trim()));
50
+ if (open === -1) return null;
51
+ const body: string[] = [];
52
+ for (let i = open + 1; i < lines.length && lines[i]!.trim() !== '```'; i++) body.push(lines[i]!);
53
+ return body.length === 0 ? null : body.join('\n');
54
+ }
55
+
56
+ describe.skipIf(V1_DIR === null)('RFC 0150 §B — Layer-2 effect identity is retry-stable', () => {
57
+ const doc = V1_DIR === null ? '' : readFileSync(join(V1_DIR as string, 'idempotency.md'), 'utf8');
58
+
59
+ it('the normative composition block is found at all', () => {
60
+ // Guard: an extractor that matched nothing would make every leg below
61
+ // vacuously true. That is the exact failure RFC 0148 exists to close, and
62
+ // this gate must not become an instance of it.
63
+ expect(
64
+ compositionBlock(doc),
65
+ `spec/v1/idempotency.md MUST carry a fenced key composition under "${HEADING}"`,
66
+ ).not.toBeNull();
67
+ });
68
+
69
+ it('the retry counter does not participate in the identity', () => {
70
+ const block = compositionBlock(doc) ?? '';
71
+ expect(
72
+ /\battempt\b/.test(block),
73
+ 'RFC 0150 §B: `attempt` is separate telemetry and MUST NOT participate in the logical ID. ' +
74
+ 'A retry counter inside the hash gives every retry a different key, so the invocation ' +
75
+ 'log never hits and the injected Idempotency-Key differs — the duplicate side effect ' +
76
+ 'Layer 2 exists to prevent.\n' +
77
+ block,
78
+ ).toBe(false);
79
+ });
80
+
81
+ it('the identity is domain-separated and version-tagged', () => {
82
+ const block = compositionBlock(doc) ?? '';
83
+ expect(
84
+ block,
85
+ 'RFC 0150 §B: the preimage MUST open with the `openwop:activity:v2` domain tag so a v1 ' +
86
+ 'and a v2 identity for the same effect cannot collide.',
87
+ ).toContain('openwop:activity:v2');
88
+ });
89
+
90
+ it('the identity binds the tenant', () => {
91
+ const block = compositionBlock(doc) ?? '';
92
+ expect(
93
+ block,
94
+ 'RFC 0150 §B: `tenantId` is part of the preimage. Without it two tenants that collide on ' +
95
+ '(runId, nodeId, providerKey) share an invocation-log entry, and one tenant reads the ' +
96
+ "other's cached provider response.",
97
+ ).toContain('tenantId');
98
+ });
99
+
100
+ it('the ordinal is documented as stable across retries', () => {
101
+ const block = compositionBlock(doc) ?? '';
102
+ expect(block, 'RFC 0150 §B: the preimage carries `logicalInvocationOrdinal`').toContain(
103
+ 'logicalInvocationOrdinal',
104
+ );
105
+ // The ordinal only does its job if the prose pins it. An ordinal that a host
106
+ // is free to re-derive per attempt reintroduces the defect under a new name.
107
+ // Emphasis and code spans are stripped first so the assertion reads the
108
+ // requirement, not the markdown that happens to decorate it.
109
+ const plain = doc.replace(/[`*_]/g, '').replace(/\s+/g, ' ');
110
+ expect(
111
+ /logicalInvocationOrdinal MUST NOT change/.test(plain),
112
+ 'RFC 0150 §B: `logicalInvocationOrdinal` MUST NOT change across transport/provider retries, ' +
113
+ 'and the spec MUST say so — otherwise a host may re-derive it per attempt and the ' +
114
+ 'retry-instability returns under a different field name.',
115
+ ).toBe(true);
116
+ });
117
+
118
+ it('the composition agrees with the claim that a retried call reuses the identity', () => {
119
+ // The two halves of the contradiction. This leg anchors the ones above to a
120
+ // real promise in the document rather than to the RFC alone: if the claim
121
+ // is ever deleted instead of the formula being fixed, this fails and says so.
122
+ expect(
123
+ /is identical/.test(doc),
124
+ 'spec/v1/idempotency.md §"Composition: how the layers compose" MUST keep the guarantee ' +
125
+ 'that an internally retried call reuses the same Layer-2 identity. It is the promise the ' +
126
+ 'composition above has to honor.',
127
+ ).toBe(true);
128
+ });
129
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * RFC 0150 §B — Layer-2 identity is run-scoped, and the spec has to say what
3
+ * that costs.
4
+ *
5
+ * `runId` is in the §B preimage. An effect issued outside any run has no
6
+ * `runId`, so the two identities can never collide, and Layer 2 cannot
7
+ * deduplicate an in-run effect against the same logical effect issued through
8
+ * an operator route, an admin action, or a scheduled job.
9
+ *
10
+ * §"Why this exists" already says implementations "MUST support layer 2 for any
11
+ * node executor that performs an external side effect", and §"Layer 2" opens
12
+ * "Inside a workflow run…". A host reading those literally uses the §B form for
13
+ * the node path — correctly. If that same effect is *also* reachable outside a
14
+ * run, the two paths issue two effects for one logical operation, which is
15
+ * precisely the duplicate-effect class §B exists to kill, on the highest-stakes
16
+ * path it touches.
17
+ *
18
+ * Reported by a tier-1 host from a shipped node pack, not a thought experiment:
19
+ * `feature.commerce.nodes.refund-order` calls the same `refundOrder` that an
20
+ * HTTP route, a connect-admin route, and a seeder call. They key it on business
21
+ * identity rather than the §B form *deliberately*, because run-scoped identity
22
+ * is the wrong scope for that effect — and §B says as much about itself in the
23
+ * fork note, one face of the same limitation.
24
+ *
25
+ * The corpus was silent on this. Silence here reads as "the ordinal form is
26
+ * sufficient", which for a cross-entry-point effect is false.
27
+ *
28
+ * Server-free; reads the corpus, never a host.
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { readFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { V1_DIR } from '../lib/paths.js';
35
+
36
+ describe.skipIf(V1_DIR === null)('RFC 0150 §B — cross-scope effect identity', () => {
37
+ const doc = V1_DIR === null ? '' : readFileSync(join(V1_DIR as string, 'idempotency.md'), 'utf8');
38
+ const plain = doc.replace(/[`*_]/g, '').replace(/\s+/g, ' ');
39
+
40
+ it('the Layer-2 section is found at all', () => {
41
+ // Guard: an empty read makes every leg below vacuously true.
42
+ expect(doc.length, 'idempotency.md MUST be readable').toBeGreaterThan(1000);
43
+ expect(plain, 'the Layer-2 section MUST exist').toContain('Layer 2: Activity-level idempotency');
44
+ });
45
+
46
+ it('the spec states that Layer-2 identity is run-scoped', () => {
47
+ expect(
48
+ /run-scoped/.test(plain),
49
+ 'RFC 0150 §B: `runId` is in the preimage, so the identity is scoped to a run. Saying so ' +
50
+ 'explicitly is what makes the next requirement follow rather than look arbitrary.',
51
+ ).toBe(true);
52
+ });
53
+
54
+ it('the spec requires a business identity when the effect escapes the run', () => {
55
+ expect(
56
+ /reachable outside any run/.test(plain),
57
+ 'RFC 0150 §B: the spec MUST name the case — a node side effect that is ALSO reachable ' +
58
+ 'outside any run (operator route, admin action, scheduled job).',
59
+ ).toBe(true);
60
+ expect(
61
+ /MUST additionally key/.test(plain),
62
+ 'RFC 0150 §B: for such an effect the host MUST additionally key on an identity derived from ' +
63
+ 'the business operation. Layer-2 identity alone cannot dedupe across the boundary, because ' +
64
+ 'the out-of-run path has no `runId` to put in the preimage — so a host following the ' +
65
+ 'ordinal form literally reintroduces the duplicate effect §B exists to prevent.',
66
+ ).toBe(true);
67
+ });
68
+
69
+ it('the run-scope cost is tied to the fork limitation it shares a cause with', () => {
70
+ // Both are the same fact seen from two sides: `runId` in the preimage. The
71
+ // spec already documented the fork face; documenting only that one taught
72
+ // half a limitation.
73
+ // Asserts the linkage, not a magic phrase: the cross-scope section must
74
+ // name the fork limitation as the same fact seen from another side.
75
+ expect(
76
+ /fork note[\s\S]{0,120}?(one face|other face|same)/.test(plain),
77
+ 'RFC 0150 §B: the fork note and the cross-scope note are one limitation seen twice — ' +
78
+ '`runId` in the preimage. The cross-scope section MUST reference the fork note, or a ' +
79
+ 'reader concludes the fork case is a special exception rather than an instance.',
80
+ ).toBe(true);
81
+ });
82
+ });