@openwop/openwop-conformance 2.0.0-rc.0 → 2.0.0-rc.2

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 (72) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +209 -14
  3. package/dist/lib/certification-bundle-v3.js +142 -0
  4. package/dist/lib/requirement-ids.js +23 -0
  5. package/dist/lib/requirement-ledger.js +1 -0
  6. package/dist/lib/scenario-disposition.js +5 -1
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +2744 -4
  10. package/scenario-majors.json +155 -2
  11. package/schemas/CORPUS-STAMP.json +17 -17
  12. package/src/cli.ts +190 -19
  13. package/src/lib/certification-bundle-v3.ts +175 -0
  14. package/src/lib/driver.ts +9 -1
  15. package/src/lib/era2-seed.ts +197 -0
  16. package/src/lib/requirement-ids.ts +21 -0
  17. package/src/lib/requirement-ledger.ts +7 -1
  18. package/src/lib/scenario-disposition.ts +5 -1
  19. package/src/lib/seams.ts +45 -0
  20. package/src/lib/v2.ts +62 -0
  21. package/src/scenarios/v2-approver-enforced.test.ts +97 -0
  22. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
  23. package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
  24. package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
  25. package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
  26. package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
  27. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
  28. package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
  29. package/src/scenarios/v2-configurable-closed.test.ts +67 -0
  30. package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
  31. package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
  32. package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
  33. package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
  34. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
  35. package/src/scenarios/v2-era-key.test.ts +86 -0
  36. package/src/scenarios/v2-error-registry.test.ts +89 -0
  37. package/src/scenarios/v2-event-type-closed.test.ts +97 -0
  38. package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
  39. package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
  40. package/src/scenarios/v2-header-scheme.test.ts +67 -0
  41. package/src/scenarios/v2-id-grammar.test.ts +77 -0
  42. package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
  43. package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
  44. package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
  45. package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
  46. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
  47. package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
  48. package/src/scenarios/v2-min-client-version.test.ts +48 -0
  49. package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
  50. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
  51. package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
  52. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
  53. package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
  54. package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
  55. package/src/scenarios/v2-pack-isolation.test.ts +147 -0
  56. package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
  57. package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
  58. package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
  59. package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
  60. package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
  61. package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
  62. package/src/scenarios/v2-provider-conflict.test.ts +115 -0
  63. package/src/scenarios/v2-refresh-sla.test.ts +78 -0
  64. package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
  65. package/src/scenarios/v2-revocation-honored.test.ts +66 -0
  66. package/src/scenarios/v2-subject-link-record.test.ts +102 -0
  67. package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
  68. package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
  69. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
  70. package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
  71. package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
  72. package/src/setup.ts +40 -4
@@ -0,0 +1,98 @@
1
+ /**
2
+ * v2-coherence-not-in-bundle — RFC 0168 §D.1; `spec/v2/core/conformance.md`
3
+ * §"Two products, two ledgers".
4
+ *
5
+ * Suite 2.0.0. Corpus-coherence checks run in the spec repo's CI and MUST NOT
6
+ * appear in a host bundle; the bundle schema forbids their ids. Unaided and
7
+ * fixture-based — no host is consulted:
8
+ *
9
+ * 1. disjoint by construction: every id in `evidence/corpus-ledger.json`
10
+ * belongs to a scenario in `src/coherence/` and to none in
11
+ * `src/scenarios/` (a derived `openwop.it.<stem>.…` id resolves to its
12
+ * file; an explicit `req()` id is located by its literal in the sources),
13
+ * so a bundle produced from the scenarios directory cannot carry one.
14
+ * 2. the bundle schema rejects a v3 fixture whose `results.requirements[]`
15
+ * carries an id under `openwop.it.spec-corpus-validity.` and accepts the
16
+ * same fixture under an `openwop.requirement.` id.
17
+ *
18
+ * @see RFCS/0168-v2-evidence-and-conformance.md §D.1
19
+ * @see spec/v2/core/conformance.md §"Two products, two ledgers"
20
+ * @see schemas/v2/certification-bundle.schema.json
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
25
+ import { generateKeyPairSync } from 'node:crypto';
26
+ import { join } from 'node:path';
27
+ import { SCHEMAS_DIR, SCENARIOS_DIR } from '../lib/paths.js';
28
+ import { req } from '../lib/requirement-ids.js';
29
+ import { scenarioFileOfId } from '../lib/requirement-ids.js';
30
+ import { softSkip } from '../lib/soft-skip.js';
31
+ import { v2Validator } from '../lib/v2.js';
32
+ import { signBundleV3, witnessDigest, type BundleV3, type BundleV3Requirement } from '../lib/certification-bundle-v3.js';
33
+
34
+ const SECTION = 'conformance.md §"Two products, two ledgers" (RFC 0168 §D.1)';
35
+ const LEDGER = join(SCHEMAS_DIR, '..', 'evidence', 'corpus-ledger.json');
36
+ const COHERENCE_DIR = SCENARIOS_DIR === null ? null : join(SCENARIOS_DIR, '..', 'coherence');
37
+
38
+ function sources(dir: string): Map<string, string> {
39
+ return new Map(readdirSync(dir).filter((f) => f.endsWith('.test.ts')).map((f) => [f, readFileSync(join(dir, f), 'utf8')]));
40
+ }
41
+
42
+ function bundleWith(rows: BundleV3Requirement[]): BundleV3 {
43
+ const { privateKey } = generateKeyPairSync('ed25519');
44
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
45
+ const unsigned: Omit<BundleV3, 'signature'> = {
46
+ bundleVersion: '3',
47
+ generatedAt: new Date().toISOString(),
48
+ suite: { name: '@openwop/openwop-conformance', version: '2.0.0', targetMajor: 2, specArtifactsVersion: '2.0.0' },
49
+ host: { name: 'fixture-host', version: '0.0.0', build: { kind: 'commit', id: 'fixture' } },
50
+ discovery: { url: 'https://host.invalid/.well-known/openwop', sha256: 'a'.repeat(64), protocolVersions: ['2.0'], preferredVersion: '2.0' },
51
+ claimedProfiles: [{ id: 'openwop-core-v2', evidenceTier: 'self', witnessCount: rows.length, certified: true }],
52
+ results: { totals: { executedPass: rows.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: rows },
53
+ witnessSha256: witnessDigest(rows),
54
+ assertionCount: rows.reduce((n, r) => n + (r.assertions ?? 0), 0),
55
+ };
56
+ return { ...unsigned, signature: signBundleV3(unsigned, pem, 'fixture-host-key') };
57
+ }
58
+
59
+ describe('v2-coherence-not-in-bundle (RFC 0168 §D.1)', () => {
60
+ it('every corpus-ledger id has a scenario in src/coherence and none in src/scenarios — the two id sets are disjoint by construction', () => {
61
+ if (!existsSync(LEDGER)) return softSkip('blocked', 'evidence/corpus-ledger.json is absent from this layout — the corpus id set cannot be read');
62
+ if (SCENARIOS_DIR === null || COHERENCE_DIR === null || !existsSync(COHERENCE_DIR)) return softSkip('blocked', 'src/scenarios or src/coherence is absent from this layout');
63
+ const ledger = JSON.parse(readFileSync(LEDGER, 'utf8')) as { requirements: Record<string, unknown> };
64
+ const ids = Object.keys(ledger.requirements);
65
+ expect(ids.length, req('openwop.requirement.0168.coherence-not-in-bundle.disjoint-by-construction', SECTION, 'the corpus ledger MUST carry at least one requirement id')).toBeGreaterThan(0);
66
+ const coherence = sources(COHERENCE_DIR);
67
+ const scenarios = sources(SCENARIOS_DIR);
68
+ const coherenceText = [...coherence.values()].join('\n');
69
+ const scenariosText = [...scenarios.values()].join('\n');
70
+ for (const id of ids) {
71
+ // `openwop.profile.*` / `openwop.family.*` are capability-GATE rows minted
72
+ // at run time by gateFamily(), not by a `req()` literal and not derived
73
+ // from a file. The same gate id is recorded by a corpus run and a host run
74
+ // alike, so it is shared by construction and carries no corpus content —
75
+ // the disjointness rule does not reach it.
76
+ if (id.startsWith('openwop.profile.') || id.startsWith('openwop.family.')) continue;
77
+ const file = scenarioFileOfId(id);
78
+ if (file !== null) {
79
+ expect(coherence.has(file), req('openwop.requirement.0168.coherence-not-in-bundle.disjoint-by-construction', SECTION, `${id} MUST belong to a src/coherence scenario (${file})`)).toBe(true);
80
+ expect(scenarios.has(file), req('openwop.requirement.0168.coherence-not-in-bundle.disjoint-by-construction', SECTION, `${id} MUST NOT belong to a src/scenarios file (${file}) — it would enter a host bundle`)).toBe(false);
81
+ } else {
82
+ const literal = `'${id}'`;
83
+ expect(coherenceText.includes(literal), req('openwop.requirement.0168.coherence-not-in-bundle.disjoint-by-construction', SECTION, `${id} MUST be cited by req() in a src/coherence scenario`)).toBe(true);
84
+ expect(scenariosText.includes(literal), req('openwop.requirement.0168.coherence-not-in-bundle.disjoint-by-construction', SECTION, `${id} MUST NOT be cited by req() in a src/scenarios file — it would enter a host bundle`)).toBe(false);
85
+ }
86
+ }
87
+ });
88
+
89
+ it('the bundle schema rejects a v3 bundle carrying a corpus-coherence id and accepts one under openwop.requirement.', () => {
90
+ const validate = v2Validator('certification-bundle');
91
+ const corpusRow: BundleV3Requirement = { id: 'openwop.it.spec-corpus-validity.every-fixture-validates', scenario: 'spec-corpus-validity.test.ts', result: 'executed-pass', assertions: 1 };
92
+ const hostRow: BundleV3Requirement = { id: 'openwop.requirement.0168.coherence-not-in-bundle.fixture', scenario: 'v2-coherence-not-in-bundle.test.ts', result: 'executed-pass', assertions: 1 };
93
+ const control = validate(bundleWith([hostRow]));
94
+ expect(control.ok, req('openwop.requirement.0168.coherence-not-in-bundle.schema-forbids-coherence-ids', SECTION, `a v3 bundle whose rows are openwop.requirement. ids MUST validate (${control.errors})`)).toBe(true);
95
+ const tainted = validate(bundleWith([hostRow, corpusRow]));
96
+ expect(tainted.ok, req('openwop.requirement.0168.coherence-not-in-bundle.schema-forbids-coherence-ids', SECTION, 'a v3 bundle carrying an openwop.it.spec-corpus-validity. id MUST be rejected by the bundle schema')).toBe(false);
97
+ });
98
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * RFC 0173 §B — `compensation-read-projection` (suite 2.0.0, target major 2; gated on `compensation`).
3
+ *
4
+ * Compensation is a core obligation with a declared witness: a host that
5
+ * advertises `compensation` MUST serve `GET /runs/{runId}/compensation`
6
+ * (`api/v2/openapi.yaml` `getRunCompensation`,
7
+ * `schemas/v2/compensation-projection.schema.json`) — `{ runId, status, plan[],
8
+ * attempts[] }` — so the obligation is deployed-wire evidence rather than
9
+ * seam-only (RFC 0151 G9; RFC 0173 §B row C6.6; `spec/v2/core/security-defaults.md`
10
+ * §Compensation).
11
+ *
12
+ * A fresh run of the noop fixture has nothing to compensate: the projection
13
+ * validates with `status: "none"`, an empty plan and no attempts. An unknown
14
+ * run is `404 not_found` through the canonical envelope.
15
+ *
16
+ * @see spec/v2/core/security-defaults.md §Compensation
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+ import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
22
+ import { readErrorCode } from '../lib/error-envelope.js';
23
+ import { softSkip } from '../lib/soft-skip.js';
24
+ import { req } from '../lib/requirement-ids.js';
25
+
26
+ const FIXTURE = 'conformance-noop';
27
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
28
+
29
+ async function discovery(): Promise<Record<string, unknown> | null> {
30
+ try { return await v2Discovery(); } catch { return null; }
31
+ }
32
+
33
+ async function waitTerminal(runId: string, timeoutMs: number): Promise<void> {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
37
+ if (res.status === 200 && TERMINAL.has(String((res.json as { status?: unknown } | null)?.status))) return;
38
+ await new Promise((r) => setTimeout(r, 250));
39
+ }
40
+ }
41
+
42
+ describe('RFC 0173 §B — compensation-read-projection (gated on compensation)', () => {
43
+ it('GET /runs/{runId}/compensation on a fresh run validates with status none', async () => {
44
+ const doc = await discovery();
45
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
46
+ if (!(await gateFamily('compensation'))) return softSkip('inapplicable', 'compensation family not advertised — no obligation (gate recorded under openwop.family.compensation)');
47
+ const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
48
+ if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to project`);
49
+
50
+ const create = await driver.post('/runs', { workflowId: FIXTURE });
51
+ expect(create.status, req('openwop.requirement.0173.compensation-read-projection', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
52
+ const runId = (create.json as { runId: string }).runId;
53
+ await waitTerminal(runId, 10_000);
54
+
55
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}/compensation`);
56
+ expect(
57
+ res.status,
58
+ req('openwop.requirement.0173.compensation-read-projection', 'security-defaults.md §Compensation', 'a host advertising `compensation` MUST serve GET /runs/{runId}/compensation with 200 (RFC 0173 §B — the read projection is the obligation\'s witness)'),
59
+ ).toBe(200);
60
+ const check = v2Validator('compensation-projection')(res.json);
61
+ expect(
62
+ check.ok,
63
+ req('openwop.requirement.0173.compensation-read-projection', 'compensation-projection.schema.json', `the projection MUST validate: ${check.errors}`),
64
+ ).toBe(true);
65
+ const body = res.json as { runId?: unknown; status?: unknown; plan?: unknown[]; attempts?: unknown[] };
66
+ expect(body.runId, req('openwop.requirement.0173.compensation-read-projection', 'compensation-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
67
+ expect(
68
+ body.status,
69
+ req('openwop.requirement.0173.compensation-read-projection', 'compensation-projection.schema.json status', 'a run with nothing to compensate MUST project status "none"'),
70
+ ).toBe('none');
71
+ expect(body.attempts, req('openwop.requirement.0173.compensation-read-projection', 'compensation-projection.schema.json attempts', 'no attempt exists before an unwind')).toEqual([]);
72
+ });
73
+
74
+ it('an unknown run projects 404 not_found through the canonical envelope', async () => {
75
+ const doc = await discovery();
76
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
77
+ if (!(await gateFamily('compensation'))) return softSkip('inapplicable', 'compensation family not advertised — no obligation (gate recorded under openwop.family.compensation)');
78
+ const res = await driver.get('/runs/conformance-no-such-run-0173/compensation');
79
+ expect(res.status, req('openwop.requirement.0173.compensation-read-projection.not-found', 'openapi.yaml getRunCompensation 404', 'an unknown runId MUST answer 404')).toBe(404);
80
+ expect(
81
+ readErrorCode(res.json),
82
+ req('openwop.requirement.0173.compensation-read-projection.not-found', 'errors.md §Envelope', 'the 404 MUST carry the registered not_found code in the flat envelope'),
83
+ ).toBe('not_found');
84
+ });
85
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * v2 — `configurable-closed` (suite 2.0.0; RFC 0171 §D.1;
3
+ * `spec/v2/core/runs.md` §"RunOptions" / `configurable`;
4
+ * `schemas/v2/configurable.schema.json`).
5
+ *
6
+ * Witness class: witnessable — unaided. `configurable` is a closed, nested,
7
+ * versioned object. A dotted key (`ai.provider` as a string key inside a
8
+ * section) and an unknown root key MUST be rejected with
9
+ * `400 validation_error`. The schema leg is server-free (the closure is a
10
+ * property of the standalone artifact); the two wire legs post the bodies to
11
+ * `POST /runs` and record `blocked` when the host refuses the control body
12
+ * for a reason unrelated to `configurable`.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
17
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
18
+ import { readErrorCode } from '../lib/error-envelope.js';
19
+ import { softSkip } from '../lib/soft-skip.js';
20
+ import { req } from '../lib/requirement-ids.js';
21
+
22
+ const DOC = 'spec/v2/core/runs.md §configurable';
23
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
24
+ const DOTTED = { version: 1, ai: { 'ai.provider': 'x' } };
25
+ const UNKNOWN = { version: 1, unknownKey: 1 };
26
+ const CONTROL = { version: 1 };
27
+
28
+ async function discovery(): Promise<Record<string, unknown> | null> {
29
+ try { return await v2Discovery(); } catch { return null; }
30
+ }
31
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
32
+ try { return await fn(); } catch { return null; }
33
+ }
34
+
35
+ /** Post a body; when the CONTROL body is itself refused the wire leg is blocked. */
36
+ async function probe(configurable: Record<string, unknown>): Promise<OpenWOPResponse | { reason: string }> {
37
+ if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
38
+ const control = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID, configurable: CONTROL }));
39
+ if (control === null) return { reason: 'POST /runs unreachable (fetch failed)' };
40
+ if (control.status !== 201) return { reason: `POST /runs with the control body { version: 1 } answered ${control.status} ${readErrorCode(control.json) ?? ''} — the create itself is refused, so the closure of configurable cannot be observed (fixture not seeded?)`.trim() };
41
+ const res = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID, configurable }));
42
+ return res ?? { reason: 'POST /runs unreachable (fetch failed)' };
43
+ }
44
+
45
+ describe('v2 configurable-closed (RFC 0171 §D.1)', () => {
46
+ it('the standalone schema rejects a dotted key and an unknown root key and accepts the versioned control', () => {
47
+ const validate = v2Validator('configurable');
48
+ expect(validate(CONTROL).ok, req('openwop.requirement.0171.configurable-closed.schema', DOC, '{ version: 1 } is the smallest valid configurable (version REQUIRED, const 1)')).toBe(true);
49
+ expect(validate(DOTTED).ok, req('openwop.requirement.0171.configurable-closed.schema', DOC, 'a dotted key (`ai.provider` as a string key) MUST fail the closed nested schema')).toBe(false);
50
+ expect(validate(UNKNOWN).ok, req('openwop.requirement.0171.configurable-closed.schema', DOC, 'an unknown root key MUST fail the closed schema (additionalProperties: false in the standalone artifact)')).toBe(false);
51
+ expect(validate({ ai: { provider: 'x' } }).ok, req('openwop.requirement.0171.configurable-closed.schema', DOC, 'a configurable without `version` MUST fail')).toBe(false);
52
+ });
53
+
54
+ it('POST /runs rejects a dotted key with 400 validation_error', async () => {
55
+ const res = await probe(DOTTED);
56
+ if ('reason' in res) return softSkip('blocked', res.reason);
57
+ expect(res.status, req('openwop.requirement.0171.configurable-closed.dotted-key', DOC, 'a dotted key inside a section MUST be rejected with 400')).toBe(400);
58
+ expect(readErrorCode(res.json), req('openwop.requirement.0171.configurable-closed.dotted-key', DOC, 'the refusal code MUST be validation_error')).toBe('validation_error');
59
+ });
60
+
61
+ it('POST /runs rejects an unknown root key with 400 validation_error', async () => {
62
+ const res = await probe(UNKNOWN);
63
+ if ('reason' in res) return softSkip('blocked', res.reason);
64
+ expect(res.status, req('openwop.requirement.0171.configurable-closed.unknown-key', DOC, 'an unknown root key MUST be rejected with 400')).toBe(400);
65
+ expect(readErrorCode(res.json), req('openwop.requirement.0171.configurable-closed.unknown-key', DOC, 'the refusal code MUST be validation_error')).toBe('validation_error');
66
+ });
67
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * v2 — `dual-stack-negotiation` (suite 2.0.0; RFC 0172 §A.3–§A.4;
3
+ * `spec/v2/core/versioning.md` §1.3 "The request header", §1.4 "The response
4
+ * header", §5 "The overlap").
5
+ *
6
+ * Witness class: witnessable — gated on two majors. Unless `protocolVersions[]`
7
+ * carries both a `1.x` and a `2.x` member the file records `inapplicable`. On a
8
+ * dual-advertising host: a run created through `/v1/runs` with NO
9
+ * `OpenWOP-Version` header is readable through `GET /runs/{runId}` under
10
+ * `OpenWOP-Version: 2.0`; an unlisted major (`9.0`) is `406
11
+ * protocol_version_unsupported` with `details.protocolVersions[]`;
12
+ * `OpenWOP-Version: 2.0` on a `/v1/…` path is `400 protocol_version_mismatch`;
13
+ * every response's `OpenWOP-Version` equals the contract that produced it. The
14
+ * header-less v1 call bypasses the driver (which stamps `OpenWOP-Version: 2.0`
15
+ * under target major 2) with a raw `fetch` against the same base URL.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
20
+ import { loadEnv } from '../lib/env.js';
21
+ import { v2Discovery } from '../lib/v2.js';
22
+ import { readErrorCode } from '../lib/error-envelope.js';
23
+ import { softSkip } from '../lib/soft-skip.js';
24
+ import { req } from '../lib/requirement-ids.js';
25
+
26
+ const DOC = 'spec/v2/core/versioning.md §1.3';
27
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
28
+
29
+ async function discovery(): Promise<Record<string, unknown> | null> {
30
+ try { return await v2Discovery(); } catch { return null; }
31
+ }
32
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
33
+ try { return await fn(); } catch { return null; }
34
+ }
35
+
36
+ /** Raw request with exactly the headers given — no OpenWOP-Version unless named. */
37
+ async function raw(method: string, path: string, headers: Record<string, string>, body?: unknown): Promise<OpenWOPResponse | null> {
38
+ try {
39
+ const env = loadEnv();
40
+ const init: RequestInit = { method, headers: { Accept: 'application/json', Authorization: `Bearer ${env.apiKey}`, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...headers } };
41
+ if (body !== undefined) init.body = JSON.stringify(body);
42
+ const res = await fetch(`${env.baseUrl}${path}`, init);
43
+ const text = await res.text();
44
+ let json: unknown;
45
+ try { json = text.length > 0 ? JSON.parse(text) : undefined; } catch { json = undefined; }
46
+ return { status: res.status, headers: res.headers, text, json };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function major(res: OpenWOPResponse | null): string | null {
53
+ const v = res?.headers.get('openwop-version')?.trim();
54
+ return v === undefined ? null : (v.split('.')[0] ?? null);
55
+ }
56
+
57
+ async function gate(): Promise<{ versions: string[] } | { kind: 'blocked' | 'inapplicable'; reason: string }> {
58
+ const doc = await discovery();
59
+ if (!doc) return { kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
60
+ const versions = Array.isArray(doc['protocolVersions']) ? (doc['protocolVersions'] as unknown[]).filter((v): v is string => typeof v === 'string') : [];
61
+ const has1 = versions.some((v) => v.startsWith('1.'));
62
+ const has2 = versions.some((v) => v.startsWith('2.'));
63
+ if (!(has1 && has2)) return { kind: 'inapplicable', reason: `protocolVersions [${versions.join(', ')}] does not carry both a 1.x and a 2.x member — dual-stack negotiation is gated on two majors` };
64
+ return { versions };
65
+ }
66
+
67
+ describe('v2 dual-stack-negotiation (RFC 0172 §A.3–§A.4 — gated on two majors)', () => {
68
+ it('a run created through /v1/runs with no header is readable through /runs under OpenWOP-Version: 2.0', async () => {
69
+ const g = await gate();
70
+ if ('kind' in g) return softSkip(g.kind, g.reason);
71
+ const created = await raw('POST', '/v1/runs', {}, { workflowId: NOOP_WORKFLOW_ID });
72
+ if (created === null) return softSkip('blocked', 'POST /v1/runs unreachable (fetch failed)');
73
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
74
+ if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /v1/runs {workflowId: ${NOOP_WORKFLOW_ID}} with no header answered ${created.status} ${readErrorCode(created.json) ?? ''} — the v1 create was refused (fixture not seeded?)`);
75
+ expect(major(created), req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §1.4', 'a /v1/ response MUST report the 1.x contract that produced it')).toBe('1');
76
+ const read = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`, { headers: { 'OpenWOP-Version': '2.0' } }));
77
+ if (read === null) return softSkip('blocked', 'GET /runs/{runId} unreachable (fetch failed)');
78
+ expect(read.status, req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', 'the overlap: a run created through /v1/runs MUST be readable through GET /runs/{runId} with OpenWOP-Version: 2.0')).toBe(200);
79
+ expect((read.json as { runId?: unknown } | undefined)?.runId, req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §5', 'the v2 read MUST name the same run')).toBe(runId);
80
+ expect(major(read), req('openwop.requirement.0172.dual-stack-negotiation.cross-major-read', 'spec/v2/core/versioning.md §1.4', 'the v2 read MUST report the 2.x contract that produced it')).toBe('2');
81
+ });
82
+
83
+ it('an unlisted major is 406 protocol_version_unsupported with details.protocolVersions[]', async () => {
84
+ const g = await gate();
85
+ if ('kind' in g) return softSkip(g.kind, g.reason);
86
+ const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Version': '9.0' } }));
87
+ if (res === null) return softSkip('blocked', 'GET /.well-known/openwop unreachable (fetch failed)');
88
+ expect(res.status, req('openwop.requirement.0172.dual-stack-negotiation.unlisted-major', DOC, 'a header naming a major not in protocolVersions[] MUST be answered 406')).toBe(406);
89
+ expect(readErrorCode(res.json), req('openwop.requirement.0172.dual-stack-negotiation.unlisted-major', DOC, 'the refusal code MUST be protocol_version_unsupported')).toBe('protocol_version_unsupported');
90
+ const echoed = (res.json as { details?: { protocolVersions?: unknown } } | undefined)?.details?.protocolVersions;
91
+ expect(Array.isArray(echoed) ? [...echoed].sort() : echoed, req('openwop.requirement.0172.dual-stack-negotiation.unlisted-major', DOC, 'details.protocolVersions[] MUST echo the advertised list')).toEqual([...g.versions].sort());
92
+ });
93
+
94
+ it('OpenWOP-Version: 2.0 on a /v1/ path is 400 protocol_version_mismatch', async () => {
95
+ const g = await gate();
96
+ if ('kind' in g) return softSkip(g.kind, g.reason);
97
+ const res = await http(() => driver.get('/v1/openapi.json', { authenticated: false, headers: { 'OpenWOP-Version': '2.0' } }));
98
+ if (res === null) return softSkip('blocked', 'GET /v1/openapi.json unreachable (fetch failed)');
99
+ expect(res.status, req('openwop.requirement.0172.dual-stack-negotiation.v1-path-mismatch', DOC, 'a /v1/… path with OpenWOP-Version other than 1 MUST be answered 400')).toBe(400);
100
+ expect(readErrorCode(res.json), req('openwop.requirement.0172.dual-stack-negotiation.v1-path-mismatch', DOC, 'the refusal code MUST be protocol_version_mismatch')).toBe('protocol_version_mismatch');
101
+ });
102
+
103
+ it('every response reports the contract that produced it', async () => {
104
+ const g = await gate();
105
+ if ('kind' in g) return softSkip(g.kind, g.reason);
106
+ const v1 = await raw('GET', '/v1/openapi.json', {});
107
+ const v1Named = await raw('GET', '/v1/openapi.json', { 'OpenWOP-Version': '1.0' });
108
+ const v2 = await http(() => driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Version': '2.0' } }));
109
+ if (v1 === null || v1Named === null || v2 === null) return softSkip('blocked', 'a probe was unreachable (fetch failed)');
110
+ expect(major(v1), req('openwop.requirement.0172.dual-stack-negotiation.response-header', 'spec/v2/core/versioning.md §1.4', 'a header-less /v1/ response MUST carry OpenWOP-Version: 1.<minor> — the contract used, never another (silent downgrade)')).toBe('1');
111
+ expect(major(v1Named), req('openwop.requirement.0172.dual-stack-negotiation.response-header', 'spec/v2/core/versioning.md §1.4', 'a /v1/ response under OpenWOP-Version: 1.0 MUST carry OpenWOP-Version: 1.<minor>')).toBe('1');
112
+ expect(major(v2), req('openwop.requirement.0172.dual-stack-negotiation.response-header', 'spec/v2/core/versioning.md §1.4', 'a response under OpenWOP-Version: 2.0 MUST carry OpenWOP-Version: 2.<minor>')).toBe('2');
113
+ for (const res of [v1, v1Named, v2]) {
114
+ const v = res.headers.get('openwop-version')?.trim() ?? '';
115
+ expect(g.versions.includes(v), req('openwop.requirement.0172.dual-stack-negotiation.response-header', 'spec/v2/core/versioning.md §1.4', `the reported contract (${v}) MUST be a member of the advertised protocolVersions[]`)).toBe(true);
116
+ }
117
+ });
118
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * RFC 0173 §B — `effect-identity-business-key` (suite 2.0.0, target major 2; gated on `idempotency`).
3
+ *
4
+ * Layer-2 effect identity is a core obligation keyed on business identity: a
5
+ * host that advertises `idempotency` assigns a logical effect id once per
6
+ * effect, stable across transport retries, injects it as the provider's
7
+ * idempotency key, and serves `GET /runs/{runId}/effects`
8
+ * (`schemas/v2/effect-ledger-projection.schema.json`) — each row
9
+ * `{ effectId, nodeId, attempt, keying: business-identity | activity-recipe,
10
+ * state, at }`, content-free of provider payloads (RFC 0150 §B; RFC 0173 §B row
11
+ * C6.7; `spec/v2/core/security-defaults.md` §Layer-2 effect identity).
12
+ *
13
+ * Legs:
14
+ * 1. the ledger read validates on a run of the noop fixture and every row's
15
+ * `keying` is one of the two documented modes;
16
+ * 2. the "same provider key across two transport retries" leg needs the
17
+ * suite's fixture provider (RFC 0173 §D.2 G4 — a provider that rejects a
18
+ * changed key) driven through the seams profile; no such seam is
19
+ * catalogued, so that leg records `blocked` naming it.
20
+ *
21
+ * @see spec/v2/core/security-defaults.md §Layer-2 effect identity
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { driver } from '../lib/driver.js';
26
+ import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
27
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
28
+ import { softSkip } from '../lib/soft-skip.js';
29
+ import { req } from '../lib/requirement-ids.js';
30
+
31
+ const FIXTURE = 'conformance-noop';
32
+ const KEYING = ['business-identity', 'activity-recipe'];
33
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
34
+
35
+ async function discovery(): Promise<Record<string, unknown> | null> {
36
+ try { return await v2Discovery(); } catch { return null; }
37
+ }
38
+
39
+ async function waitTerminal(runId: string, timeoutMs: number): Promise<void> {
40
+ const deadline = Date.now() + timeoutMs;
41
+ while (Date.now() < deadline) {
42
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}`);
43
+ if (res.status === 200 && TERMINAL.has(String((res.json as { status?: unknown } | null)?.status))) return;
44
+ await new Promise((r) => setTimeout(r, 250));
45
+ }
46
+ }
47
+
48
+ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)', () => {
49
+ it('GET /runs/{runId}/effects validates and every row is keyed on business identity or the activity recipe', async () => {
50
+ const doc = await discovery();
51
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
52
+ if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
53
+ const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
54
+ if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to read`);
55
+
56
+ const create = await driver.post('/runs', { workflowId: FIXTURE });
57
+ expect(create.status, req('openwop.requirement.0173.effect-identity-business-key', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
58
+ const runId = (create.json as { runId: string }).runId;
59
+ await waitTerminal(runId, 10_000);
60
+
61
+ const res = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
62
+ expect(
63
+ res.status,
64
+ req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', 'a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)'),
65
+ ).toBe(200);
66
+ const check = v2Validator('effect-ledger-projection')(res.json);
67
+ expect(
68
+ check.ok,
69
+ req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json', `the ledger projection MUST validate: ${check.errors}`),
70
+ ).toBe(true);
71
+ const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> };
72
+ expect(body.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
73
+ const effects = body.effects ?? [];
74
+ const ids = new Set<string>();
75
+ for (const e of effects) {
76
+ expect(
77
+ KEYING,
78
+ req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', `keying MUST be business-identity or activity-recipe (the documented fallback) — effect ${String(e.effectId)} declares ${String(e.keying)}`),
79
+ ).toContain(e.keying);
80
+ // One logical effect id per effect: a duplicate row is a re-assignment.
81
+ expect(
82
+ ids.has(String(e.effectId)),
83
+ req('openwop.requirement.0173.effect-identity-business-key', 'RFC 0150 §B', `effectId ${String(e.effectId)} MUST be assigned once per effect (duplicate ledger row)`),
84
+ ).toBe(false);
85
+ ids.add(String(e.effectId));
86
+ if (typeof e.providerKey === 'string') {
87
+ expect(
88
+ /(secret|bearer |sk-[a-z0-9]{8,})/i.test(e.providerKey),
89
+ req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json providerKey', 'providerKey is a redaction-safe identity, never credential material'),
90
+ ).toBe(false);
91
+ }
92
+ }
93
+ if (effects.length === 0) softSkip('inapplicable', 'the noop fixture issued no external effect — the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
94
+ });
95
+
96
+ it('the same provider key is presented across two transport retries', async () => {
97
+ const doc = await discovery();
98
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
99
+ if (!(await gateFamily('idempotency'))) return softSkip('inapplicable', 'idempotency family not advertised — no Layer-2 obligation (gate recorded under openwop.family.idempotency)');
100
+ if (!seamsProfileAdvertised(doc)) return softSkip('blocked', 'the retry leg is driven through the suite fixture provider (RFC 0173 §D.2 G4) under the seams profile — seams profile not advertised');
101
+ // The seam is catalogued (api/seams-v2.yaml `forceEffectTransportRetry`). An
102
+ // unreachable providerUrl forces the transport retry; the witness is the
103
+ // host's own Layer-2 ledger (RFC 0173 §C.2), where every attempt of one
104
+ // effect MUST carry the same business-identity key.
105
+ const fired = await driver.post(`${SEAMS_PREFIX}/sample/test/idempotency/effect-retry`, { providerUrl: 'http://127.0.0.1:1/' });
106
+ if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
107
+ return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/test/idempotency/effect-retry (answered ${fired.status}) — the cross-retry keying leg cannot be driven`);
108
+ }
109
+ const body = fired.json as { runId?: unknown; effectId?: unknown } | null;
110
+ if (fired.status !== 201 || typeof body?.runId !== 'string' || typeof body?.effectId !== 'string') {
111
+ return softSkip('blocked', `${SEAMS_PREFIX}/sample/test/idempotency/effect-retry answered ${fired.status} without { runId, effectId } — the seam contract in api/seams-v2.yaml is 201 { runId, effectId }`);
112
+ }
113
+ const ledger = await driver.get(`/runs/${body.runId}/effects`);
114
+ if (ledger.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${ledger.status} — the ledger is the witness for cross-retry keying`);
115
+ const all = ((ledger.json as { effects?: unknown } | null)?.effects ?? []) as Array<Record<string, unknown>>;
116
+ const attempts = all.filter((e) => e['effectId'] === body.effectId);
117
+ if (attempts.length < 2) {
118
+ return softSkip('blocked', `the seam produced ${attempts.length} ledger row(s) for effect ${String(body.effectId)} — a cross-retry assertion needs at least two attempts`);
119
+ }
120
+ const keys = new Set(attempts.map((e) => String(e['providerKey'] ?? '')));
121
+ expect(
122
+ keys.size,
123
+ req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/replay.md §Effect identity', `every attempt of one effect MUST present the same provider key across a transport retry — ${attempts.length} attempt(s) presented ${keys.size} distinct key(s)`),
124
+ ).toBe(1);
125
+ for (const a of attempts) {
126
+ expect(
127
+ a['keying'],
128
+ req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/replay.md §Effect identity', `a Layer-2 host keys a retried effect on business identity, not the activity recipe (attempt ${String(a['attempt'])})`),
129
+ ).toBe('business-identity');
130
+ }
131
+ });
132
+ });