@openwop/openwop-conformance 2.45.15 → 2.45.17

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 (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -2
  3. package/dist/spec-artifacts.lock.json +2 -2
  4. package/package.json +2 -2
  5. package/requirements.json +1335 -14
  6. package/scenario-majors.json +104 -2
  7. package/schemas/CORPUS-STAMP.json +13 -13
  8. package/src/lib/artifact-schema-url-witness.ts +83 -0
  9. package/src/lib/dispatch-witness.ts +212 -0
  10. package/src/lib/family-advert-witness.ts +204 -5
  11. package/src/lib/fixture-run-observer.ts +97 -0
  12. package/src/lib/prompt-library-witness.ts +11 -2
  13. package/src/lib/provider-usage-witness.ts +63 -0
  14. package/src/lib/run-graph-double.ts +144 -0
  15. package/src/lib/subworkflow-witness.ts +192 -0
  16. package/src/scenarios/v2-agent-runtime-advertisement.test.ts +51 -0
  17. package/src/scenarios/v2-ai-envelope-advertisement.test.ts +43 -0
  18. package/src/scenarios/v2-artifact-created-static.test.ts +92 -0
  19. package/src/scenarios/v2-artifact-type-schema-url.test.ts +44 -0
  20. package/src/scenarios/v2-artifact-types-advertisement.test.ts +64 -0
  21. package/src/scenarios/v2-authorization-advertisement.test.ts +66 -0
  22. package/src/scenarios/v2-channel-presence-advertisement.test.ts +41 -0
  23. package/src/scenarios/v2-conversation-turn-model-provenance-advertisement.test.ts +41 -0
  24. package/src/scenarios/v2-conversation-turn-model-provenance.test.ts +110 -0
  25. package/src/scenarios/v2-credentials-advertisement.test.ts +66 -0
  26. package/src/scenarios/v2-data-residency-admission.test.ts +98 -0
  27. package/src/scenarios/v2-data-residency-advertisement.test.ts +52 -0
  28. package/src/scenarios/v2-dead-letter-advertisement.test.ts +54 -0
  29. package/src/scenarios/v2-discovery-etag.test.ts +86 -0
  30. package/src/scenarios/v2-dispatch-input-mapping.test.ts +70 -0
  31. package/src/scenarios/v2-dispatch-loop.test.ts +80 -0
  32. package/src/scenarios/v2-envelope-contracts-advertisement.test.ts +40 -0
  33. package/src/scenarios/v2-export-bundle-static.test.ts +108 -0
  34. package/src/scenarios/v2-multi-party-conversation-advertisement.test.ts +52 -0
  35. package/src/scenarios/v2-node-pack-runtimes-advertisement.test.ts +52 -0
  36. package/src/scenarios/v2-nondeterminism-policy-advertisement.test.ts +41 -0
  37. package/src/scenarios/v2-portability-advertisement.test.ts +63 -0
  38. package/src/scenarios/v2-prompt-library-advertisement.test.ts +40 -0
  39. package/src/scenarios/v2-prompt-list-and-fetch.test.ts +4 -2
  40. package/src/scenarios/v2-provider-usage-advertisement.test.ts +61 -0
  41. package/src/scenarios/v2-provider-usage-emission.test.ts +50 -0
  42. package/src/scenarios/v2-provider-usage-payload-static.test.ts +92 -0
  43. package/src/scenarios/v2-purpose-propagation-advertisement.test.ts +52 -0
  44. package/src/scenarios/v2-run-snapshot-etag.test.ts +35 -22
  45. package/src/scenarios/v2-scheduling-advertisement.test.ts +55 -0
  46. package/src/scenarios/v2-self-hosted-runner-advertisement.test.ts +52 -0
  47. package/src/scenarios/v2-self-hosted-runner-frames-static.test.ts +121 -0
  48. package/src/scenarios/v2-sub-workflow-advertisement.test.ts +53 -0
  49. package/src/scenarios/v2-subworkflow-input-mapping.test.ts +74 -0
  50. package/src/scenarios/v2-subworkflow-linkage.test.ts +76 -0
  51. package/src/scenarios/v2-ui-plugins-advertisement.test.ts +85 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * v2 — the `purposePropagation` advertisement (`spec/v2/core/security-defaults.md` §Onward hops): the purpose-label propagation claim. The v1 twin is
3
+ * `purpose-propagation`;
4
+ * the legs live in `lib/family-advert-witness.ts` (witness wave 3).
5
+ *
6
+ * record the record validates against its capabilities-schema seat;
7
+ * propagates-onward-boolean purposePropagation.propagatesOnward is a boolean;
8
+ *
9
+ * Not here: propagation itself needs a carrier the suite can receive with a label seat (TODO §8).
10
+ *
11
+ * Dispositions: discovery unreadable ⇒ `blocked`; `purposePropagation` absent, or the
12
+ * facet a leg reads absent ⇒ `inapplicable`.
13
+ *
14
+ * @see spec/v2/core/security-defaults.md §Onward hops
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
19
+ import { softSkip } from '../lib/soft-skip.js';
20
+ import { req } from '../lib/requirement-ids.js';
21
+ import { majorProfile } from '../lib/major-profile.js';
22
+ import { purposePropagatesOnwardLeg, recordSchemaLeg } from '../lib/family-advert-witness.js';
23
+
24
+ const PROFILE = majorProfile(2);
25
+ const DOC = 'security-defaults.md §Onward hops';
26
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
27
+ const ID_RECORD = 'openwop.requirement.purpose-propagation.advert-record-schema';
28
+ const ID_PROPAGATES_ONWARD_BOOLEAN = 'openwop.requirement.purpose-propagation.propagates-onward-boolean';
29
+
30
+ async function discovery(): Promise<Record<string, unknown> | null> {
31
+ try { return await v2Discovery(); } catch { return null; }
32
+ }
33
+
34
+ describe('v2 purposePropagation advertisement (security-defaults.md §Onward hops)', () => {
35
+ it('the purposePropagation record validates against its capabilities-schema seat', async () => {
36
+ const doc = await discovery();
37
+ if (!doc) return softSkip('blocked', UNREADABLE);
38
+ if (!(await familyAdvertised('purposePropagation'))) return softSkip('inapplicable', 'the host does not advertise purposePropagation');
39
+ const out = recordSchemaLeg(PROFILE, doc, 'purposePropagation', DOC);
40
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
41
+ for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
42
+ });
43
+
44
+ it('purposePropagation.propagatesOnward is a boolean', async () => {
45
+ const doc = await discovery();
46
+ if (!doc) return softSkip('blocked', UNREADABLE);
47
+ if (!(await familyAdvertised('purposePropagation'))) return softSkip('inapplicable', 'the host does not advertise purposePropagation');
48
+ const out = purposePropagatesOnwardLeg(PROFILE, doc);
49
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
50
+ for (const x of out.findings) expect(x.ok, req(ID_PROPAGATES_ONWARD_BOOLEAN, x.doc, x.message)).toBe(true);
51
+ });
52
+ });
@@ -7,6 +7,10 @@
7
7
  * a host that sends no ETag records `inapplicable`. Every response carries
8
8
  * `OpenWOP-Version` (versioning.md §1.4), the 304 included.
9
9
  *
10
+ * The revalidation runs once per client spelling (`OpenWOP-Version: 2.0`,
11
+ * `2`, and `2` with a wildcard Accept): a host MUST treat them alike
12
+ * (versioning.md §1), and one public origin served 304 to the first only.
13
+ *
10
14
  * Control: a non-matching `If-None-Match` MUST receive 200 with the body — a
11
15
  * host that answers 304 to any conditional request fails here. `headers.md`
12
16
  * scopes `If-None-Match` to the discovery document; runs.md applies it to the
@@ -27,6 +31,13 @@ import { req } from '../lib/requirement-ids.js';
27
31
  const ID = 'openwop.requirement.0170.run-snapshot-etag';
28
32
  const DOC = 'spec/v2/core/runs.md §Snapshot';
29
33
  const NOOP = 'conformance-noop';
34
+ /** The client spellings a host MUST treat alike (versioning.md §1): openwop-app's public origin answered 304 to `2.0` and 200 to bare `2`. */
35
+ const SPELLINGS: ReadonlyArray<Readonly<Record<string, string>>> = [
36
+ { 'OpenWOP-Version': '2.0' },
37
+ { 'OpenWOP-Version': '2' },
38
+ { 'OpenWOP-Version': '2', Accept: '*/*' },
39
+ ];
40
+ const label = (h: Readonly<Record<string, string>>): string => Object.entries(h).map(([k, v]) => `${k}: ${v}`).join(', ');
30
41
 
31
42
  async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
32
43
  async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
@@ -51,29 +62,31 @@ describe('v2 run-snapshot-etag (runs.md §Snapshot)', () => {
51
62
  // happened to finish inside the sleep. It also let a host with a CONSTANT tag
52
63
  // pass for the wrong reason, which the control below still catches.
53
64
  await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(30_000) });
54
- let etag: string | null = null;
55
- let hit: OpenWOPResponse | null = null;
56
- // A terminal run should be still, but "should" is not the claim under test:
57
- // if the tag moves again, take the NEW tag and retry. The violation is a 200
58
- // whose ETag EQUALS the If-None-Match that was sent — never a 200 per se.
59
- for (let attempt = 0; attempt < 4; attempt++) {
60
- const first = await http(() => driver.get(path));
61
- if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
62
- etag = first.headers.get('etag');
63
- if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
64
- const sent = etag;
65
- hit = await http(() => driver.get(path, { headers: { 'If-None-Match': sent } }));
66
- if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
67
- if (hit.status !== 200 || hit.headers.get('etag') === sent) break; // 304, or the real violation: decided below
68
- await new Promise((r) => setTimeout(r, 500)); // 200 with a DIFFERENT tag: the representation moved; the tag sent was honestly stale
69
- }
70
- if (hit === null || etag === null) return softSkip('blocked', 'no conditional GET was made');
71
- if (hit.status === 200 && hit.headers.get('etag') !== etag) {
72
- return softSkip('blocked', `the snapshot of a TERMINAL run kept changing across four reads (last tag sent ${etag}, answered 200 with ${String(hit.headers.get('etag'))}) — the 304 rule cannot be witnessed against a representation that never holds still`);
65
+ for (const spelling of SPELLINGS) {
66
+ let etag: string | null = null;
67
+ let hit: OpenWOPResponse | null = null;
68
+ // A terminal run should be still, but "should" is not the claim under test:
69
+ // if the tag moves again, take the NEW tag and retry. The violation is a 200
70
+ // whose ETag EQUALS the If-None-Match that was sent — never a 200 per se.
71
+ for (let attempt = 0; attempt < 4; attempt++) {
72
+ const first = await http(() => driver.get(path, { headers: { ...spelling } }));
73
+ if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
74
+ etag = first.headers.get('etag');
75
+ if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
76
+ const sent = etag;
77
+ hit = await http(() => driver.get(path, { headers: { ...spelling, 'If-None-Match': sent } }));
78
+ if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
79
+ if (hit.status !== 200 || hit.headers.get('etag') === sent) break; // 304, or the real violation: decided below
80
+ await new Promise((r) => setTimeout(r, 500)); // 200 with a DIFFERENT tag: the representation moved; the tag sent was honestly stale
81
+ }
82
+ if (hit === null || etag === null) return softSkip('blocked', 'no conditional GET was made');
83
+ if (hit.status === 200 && hit.headers.get('etag') !== etag) {
84
+ return softSkip('blocked', `the snapshot of a TERMINAL run kept changing across four reads (last tag sent ${etag}, answered 200 with ${String(hit.headers.get('etag'))}) — the 304 rule cannot be witnessed against a representation that never holds still`);
85
+ }
86
+ expect(hit.status, req(ID, DOC, `under ${label(spelling)}, a request whose If-None-Match matches the CURRENT ETag MUST receive 304 — got ${hit.status} while the response still carried the same tag ${etag}`)).toBe(304);
87
+ expect(hit.text.length, req(ID, DOC, `under ${label(spelling)}, the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
88
+ expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', `under ${label(spelling)}, every response carries OpenWOP-Version, the 304 included`)).not.toBeNull();
73
89
  }
74
- expect(hit.status, req(ID, DOC, `a request whose If-None-Match matches the CURRENT ETag MUST receive 304 — got ${hit.status} while the response still carried the same tag ${etag}`)).toBe(304);
75
- expect(hit.text.length, req(ID, DOC, `the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
76
- expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', 'every response carries OpenWOP-Version, the 304 included')).not.toBeNull();
77
90
  const miss = await http(() => driver.get(path, { headers: { 'If-None-Match': '"openwop-conformance-no-such-tag"' } }));
78
91
  if (miss === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
79
92
  expect(miss.status, req(ID, DOC, `a non-matching If-None-Match MUST receive 200 with the body — got ${miss.status} (a host answering 304 to any conditional request is not honouring the tag)`)).toBe(200);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * v2 — the `scheduling` advertisement (`spec/v2/core/host-services.md`
3
+ * §scheduling; RFC 0052). The v1 twin is the advertisement legs of
4
+ * `scheduling-capability-shape`; the legs live in
5
+ * `lib/family-advert-witness.ts`.
6
+ *
7
+ * record the record validates against its capabilities-schema seat;
8
+ * forms each advertised form (`cron`, `delayed`, `calendar`) is one boolean.
9
+ *
10
+ * Not ported: the v1 leg that `maxFutureHorizon` is an ISO-8601 duration. The v2
11
+ * schema seat is a plain string and no v2 prose states the format as binding
12
+ * the host, so it is not a failing leg here.
13
+ *
14
+ * Dispositions: discovery unreadable ⇒ `blocked`; `scheduling` absent, or the
15
+ * facet a leg reads absent ⇒ `inapplicable`.
16
+ *
17
+ * @see spec/v2/core/host-services.md §scheduling
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
22
+ import { softSkip } from '../lib/soft-skip.js';
23
+ import { req } from '../lib/requirement-ids.js';
24
+ import { majorProfile } from '../lib/major-profile.js';
25
+ import { recordSchemaLeg, schedulingFormsLeg } from '../lib/family-advert-witness.js';
26
+
27
+ const PROFILE = majorProfile(2);
28
+ const DOC = 'host-services.md §scheduling';
29
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
30
+ const ID_RECORD = 'openwop.requirement.scheduling.advert-record-schema';
31
+ const ID_FORMS_BOOLEAN = 'openwop.requirement.scheduling.forms-boolean';
32
+
33
+ async function discovery(): Promise<Record<string, unknown> | null> {
34
+ try { return await v2Discovery(); } catch { return null; }
35
+ }
36
+
37
+ describe('v2 scheduling advertisement (host-services.md §scheduling)', () => {
38
+ it('the scheduling record validates against its capabilities-schema seat', async () => {
39
+ const doc = await discovery();
40
+ if (!doc) return softSkip('blocked', UNREADABLE);
41
+ if (!(await familyAdvertised('scheduling'))) return softSkip('inapplicable', 'the host does not advertise scheduling');
42
+ const out = recordSchemaLeg(PROFILE, doc, 'scheduling', DOC);
43
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
44
+ for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
45
+ });
46
+
47
+ it('scheduling.cron, delayed and calendar are booleans', async () => {
48
+ const doc = await discovery();
49
+ if (!doc) return softSkip('blocked', UNREADABLE);
50
+ if (!(await familyAdvertised('scheduling'))) return softSkip('inapplicable', 'the host does not advertise scheduling');
51
+ const out = schedulingFormsLeg(PROFILE, doc);
52
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
53
+ for (const x of out.findings) expect(x.ok, req(ID_FORMS_BOOLEAN, x.doc, x.message)).toBe(true);
54
+ });
55
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * v2 — the `selfHostedRunner` advertisement (`spec/v2/core/execution.md`
3
+ * §selfHostedRunner; RFC 0122). The v1 twin is the advertisement leg of
4
+ * `self-hosted-runner`; the legs live in `lib/family-advert-witness.ts`.
5
+ *
6
+ * record the record validates against its seat in
7
+ * `schemas/v2/capabilities.schema.json` (no v1 `supported` seat);
8
+ * dispatchKinds `dispatchKinds` lists `model`, `tool` or both, each once.
9
+ *
10
+ * Not here: registration, subject isolation, at-most-once and liveness need a
11
+ * runner seam; the frame shapes are `v2-self-hosted-runner-frames-static`.
12
+ *
13
+ * Dispositions: discovery unreadable ⇒ `blocked`; `selfHostedRunner` absent, or the
14
+ * facet a leg reads absent ⇒ `inapplicable`.
15
+ *
16
+ * @see spec/v2/core/execution.md §selfHostedRunner
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { v2Discovery } from '../lib/v2.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+ import { majorProfile } from '../lib/major-profile.js';
24
+ import { recordSchemaLeg, selfHostedRunnerDispatchKindsLeg } from '../lib/family-advert-witness.js';
25
+
26
+ const PROFILE = majorProfile(2);
27
+ const DOC = 'execution.md §selfHostedRunner';
28
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
29
+ const ID_RECORD = 'openwop.requirement.self-hosted-runner.advert-record-schema';
30
+ const ID_DISPATCH_KINDS = 'openwop.requirement.self-hosted-runner.dispatch-kinds';
31
+
32
+ async function discovery(): Promise<Record<string, unknown> | null> {
33
+ try { return await v2Discovery(); } catch { return null; }
34
+ }
35
+
36
+ describe('v2 selfHostedRunner advertisement (execution.md §selfHostedRunner)', () => {
37
+ it('the selfHostedRunner record validates against its capabilities-schema seat', async () => {
38
+ const doc = await discovery();
39
+ if (!doc) return softSkip('blocked', UNREADABLE);
40
+ const out = recordSchemaLeg(PROFILE, doc, 'selfHostedRunner', DOC);
41
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
42
+ for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
43
+ });
44
+
45
+ it('selfHostedRunner.dispatchKinds lists model, tool or both', async () => {
46
+ const doc = await discovery();
47
+ if (!doc) return softSkip('blocked', UNREADABLE);
48
+ const out = selfHostedRunnerDispatchKindsLeg(PROFILE, doc);
49
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
50
+ for (const x of out.findings) expect(x.ok, req(ID_DISPATCH_KINDS, x.doc, x.message)).toBe(true);
51
+ });
52
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * v2 — the self-hosted-runner frame and registration shapes, statically
3
+ * (`spec/v2/core/execution.md` §selfHostedRunner; RFC 0122). The v1 twin is
4
+ * the schema half of `self-hosted-runner`; its live legs (subject isolation,
5
+ * `runner_unavailable`, at-most-once) need a runner seam and are not ported.
6
+ *
7
+ * compile the three schemas compile under Ajv 2020 with the v2 tree;
8
+ * dispatch a model and a tool dispatch frame validate; a frame with no
9
+ * `seq`, a non-integer or negative `seq`, an unknown `kind`, a
10
+ * model frame without `provider`/`model`, or a tool frame
11
+ * without `tool` is rejected;
12
+ * result a result frame validates; one without `output` is rejected;
13
+ * closed dispatch, result and registration (and its `capabilities`)
14
+ * are closed objects, so no frame or record can carry a
15
+ * credential field alongside the step ("A runner credential
16
+ * MUST NOT transit the host or appear in any frame");
17
+ * registration a registration validates; one without `subject` is rejected
18
+ * (subject-first routing keys on it).
19
+ *
20
+ * The shape rules these schemas state bind the host's dispatch frames and its
21
+ * registration record; the result frame is the runner's, so its legs witness
22
+ * the corpus shape the host's intake is written against, not the host.
23
+ *
24
+ * Host-free: every leg runs with no OPENWOP_BASE_URL.
25
+ *
26
+ * @see spec/v2/core/execution.md §selfHostedRunner
27
+ * @see schemas/v2/self-hosted-runner-dispatch-frame.schema.json
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { v2Validator } from '../lib/v2.js';
32
+ import { req } from '../lib/requirement-ids.js';
33
+
34
+ const DOC = 'execution.md §selfHostedRunner';
35
+ const ID_COMPILE = 'openwop.requirement.self-hosted-runner.frame-schemas-compile';
36
+ const ID_DISPATCH = 'openwop.requirement.self-hosted-runner.dispatch-frame-shape';
37
+ const ID_RESULT = 'openwop.requirement.self-hosted-runner.result-frame-shape';
38
+ const ID_CLOSED = 'openwop.requirement.self-hosted-runner.frames-closed';
39
+ const ID_REGISTRATION = 'openwop.requirement.self-hosted-runner.registration-shape';
40
+
41
+ const SCHEMAS = ['self-hosted-runner-dispatch-frame', 'self-hosted-runner-result-frame', 'self-hosted-runner-registration'] as const;
42
+
43
+ const MODEL_FRAME = { runId: 'acme/run_x-xxxxxxxxxx', stepId: 'step_3', seq: 12, kind: 'model', provider: 'anthropic', model: 'claude-x', inputs: { messages: [] } } as const;
44
+ const TOOL_FRAME = { runId: 'acme/run_y-xxxxxxxxxx', stepId: 'step_1', seq: 0, kind: 'tool', tool: 'local.shell.readFile', inputs: { path: '/tmp/x' } } as const;
45
+ const RESULT = { runId: 'acme/run_x-xxxxxxxxxx', stepId: 'step_3', seq: 12, output: { role: 'assistant', content: 'ok' } } as const;
46
+ const REGISTRATION = { runnerId: 'runner_laptop_01', subject: 'user_42', capabilities: { providers: ['anthropic'], models: ['claude-x'] } } as const;
47
+
48
+ function without(o: Readonly<Record<string, unknown>>, field: string): Record<string, unknown> {
49
+ const c: Record<string, unknown> = { ...o };
50
+ delete c[field];
51
+ return c;
52
+ }
53
+
54
+ describe('v2 self-hosted-runner frames: schemas compile', () => {
55
+ for (const name of SCHEMAS) {
56
+ it(`${name}.schema.json compiles under Ajv 2020`, () => {
57
+ expect(() => v2Validator(name), req(ID_COMPILE, DOC, `schemas/v2/${name}.schema.json MUST compile`)).not.toThrow();
58
+ });
59
+ }
60
+ });
61
+
62
+ describe('v2 self-hosted-runner frames: dispatch frame', () => {
63
+ const validate = v2Validator('self-hosted-runner-dispatch-frame');
64
+
65
+ it('accepts a model frame and a tool frame', () => {
66
+ for (const f of [MODEL_FRAME, TOOL_FRAME]) {
67
+ const r = validate(f);
68
+ expect(r.ok, req(ID_DISPATCH, DOC, `a well-formed ${f.kind} dispatch frame MUST validate (${r.errors})`)).toBe(true);
69
+ }
70
+ });
71
+
72
+ it.each<[string, Record<string, unknown>]>([
73
+ ['no seq cursor', without(MODEL_FRAME, 'seq')],
74
+ ['a fractional seq', { ...MODEL_FRAME, seq: 1.5 }],
75
+ ['a negative seq', { ...MODEL_FRAME, seq: -1 }],
76
+ ['an unknown kind', { ...MODEL_FRAME, kind: 'agent' }],
77
+ ['a model frame without provider', without(MODEL_FRAME, 'provider')],
78
+ ['a model frame without model', without(MODEL_FRAME, 'model')],
79
+ ['a tool frame without tool', without(TOOL_FRAME, 'tool')],
80
+ ['no stepId (the at-most-once key)', without(MODEL_FRAME, 'stepId')],
81
+ ])('rejects %s', (_what, frame) => {
82
+ expect(validate(frame).ok, req(ID_DISPATCH, DOC, `a dispatch frame with ${_what} MUST be rejected`)).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe('v2 self-hosted-runner frames: result frame', () => {
87
+ const validate = v2Validator('self-hosted-runner-result-frame');
88
+
89
+ it('accepts a result frame', () => {
90
+ const r = validate(RESULT);
91
+ expect(r.ok, req(ID_RESULT, DOC, `a well-formed result frame MUST validate (${r.errors})`)).toBe(true);
92
+ });
93
+
94
+ it('rejects a result frame without output', () => {
95
+ expect(validate(without(RESULT, 'output')).ok, req(ID_RESULT, DOC, 'a result frame without output MUST be rejected')).toBe(false);
96
+ });
97
+ });
98
+
99
+ describe('v2 self-hosted-runner frames: closed shapes carry no credential field', () => {
100
+ it.each<[string, string, Record<string, unknown>]>([
101
+ ['dispatch frame', 'self-hosted-runner-dispatch-frame', { ...MODEL_FRAME, apiKey: 'sk-canary' }],
102
+ ['result frame', 'self-hosted-runner-result-frame', { ...RESULT, credential: 'sk-canary' }],
103
+ ['registration', 'self-hosted-runner-registration', { ...REGISTRATION, bearer: 'sk-canary' }],
104
+ ['registration capabilities', 'self-hosted-runner-registration', { ...REGISTRATION, capabilities: { providers: ['anthropic'], apiKeys: { anthropic: 'sk-canary' } } }],
105
+ ])('a %s with an extra credential-named field is rejected', (what, schema, doc) => {
106
+ expect(v2Validator(schema)(doc).ok, req(ID_CLOSED, DOC, `the ${what} is closed: a runner credential MUST NOT appear in any frame`)).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('v2 self-hosted-runner frames: registration', () => {
111
+ const validate = v2Validator('self-hosted-runner-registration');
112
+
113
+ it('accepts a registration', () => {
114
+ const r = validate(REGISTRATION);
115
+ expect(r.ok, req(ID_REGISTRATION, DOC, `a well-formed registration MUST validate (${r.errors})`)).toBe(true);
116
+ });
117
+
118
+ it('rejects a registration without subject', () => {
119
+ expect(validate(without(REGISTRATION, 'subject')).ok, req(ID_REGISTRATION, DOC, 'a registration without its owning subject MUST be rejected')).toBe(false);
120
+ });
121
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * v2 — the `subWorkflow` advertisement (`spec/v2/core/execution.md`
3
+ * §subWorkflow; RFCs 0007, 0022). The v1 twin is the record shape `subWorkflow`
4
+ * carried at v1; the legs live in `lib/family-advert-witness.ts`.
5
+ *
6
+ * record the record validates against its capabilities-schema seat;
7
+ * inputMapping one boolean.
8
+ *
9
+ * Not here: the refusal of a non-empty `inputMapping` by a host not advertising
10
+ * it, and the child-run legs, need workflow registration and a fixture run.
11
+ *
12
+ * Dispositions: discovery unreadable ⇒ `blocked`; `subWorkflow` absent, or the
13
+ * facet a leg reads absent ⇒ `inapplicable`.
14
+ *
15
+ * @see spec/v2/core/execution.md §subWorkflow
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
20
+ import { softSkip } from '../lib/soft-skip.js';
21
+ import { req } from '../lib/requirement-ids.js';
22
+ import { majorProfile } from '../lib/major-profile.js';
23
+ import { recordSchemaLeg, subWorkflowInputMappingLeg } from '../lib/family-advert-witness.js';
24
+
25
+ const PROFILE = majorProfile(2);
26
+ const DOC = 'execution.md §subWorkflow';
27
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
28
+ const ID_RECORD = 'openwop.requirement.sub-workflow.advert-record-schema';
29
+ const ID_INPUT_MAPPING_BOOLEAN = 'openwop.requirement.sub-workflow.input-mapping-boolean';
30
+
31
+ async function discovery(): Promise<Record<string, unknown> | null> {
32
+ try { return await v2Discovery(); } catch { return null; }
33
+ }
34
+
35
+ describe('v2 subWorkflow advertisement (execution.md §subWorkflow)', () => {
36
+ it('the subWorkflow record validates against its capabilities-schema seat', async () => {
37
+ const doc = await discovery();
38
+ if (!doc) return softSkip('blocked', UNREADABLE);
39
+ if (!(await familyAdvertised('subWorkflow'))) return softSkip('inapplicable', 'the host does not advertise subWorkflow');
40
+ const out = recordSchemaLeg(PROFILE, doc, 'subWorkflow', DOC);
41
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
42
+ for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
43
+ });
44
+
45
+ it('subWorkflow.inputMapping is one boolean', async () => {
46
+ const doc = await discovery();
47
+ if (!doc) return softSkip('blocked', UNREADABLE);
48
+ if (!(await familyAdvertised('subWorkflow'))) return softSkip('inapplicable', 'the host does not advertise subWorkflow');
49
+ const out = subWorkflowInputMappingLeg(PROFILE, doc);
50
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
51
+ for (const x of out.findings) expect(x.ok, req(ID_INPUT_MAPPING_BOOLEAN, x.doc, x.message)).toBe(true);
52
+ });
53
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * v2 — `core.subWorkflow` `inputMapping` (`spec/v2/core/execution.md`
3
+ * §`subWorkflow`: "`inputMapping` (`childVar → parentVar`) seeds the child
4
+ * once, at creation, after and over its `variables[].defaultValue`, which MUST
5
+ * seed first. An unset parent variable MUST arrive undefined, never an error
6
+ * or `null`."). The v1 twin is `subworkflow-input-mapping`; the legs live in
7
+ * `lib/subworkflow-witness.ts`.
8
+ *
9
+ * seed `conformance-subworkflow-input-mapping`: the child's
10
+ * `receivedPrdId` is seeded at creation;
11
+ * override it is the parent's `currentPrdId` ("prd-1"), not the child's
12
+ * default ("baked-in");
13
+ * unset `conformance-subworkflow-input-mapping-no-default`: the parent's
14
+ * `currentPrdId` is unset — both runs complete, and the child's
15
+ * value is absent or its own default, never `null`.
16
+ * NOT ported: the mid-run mutation leg (it drives a variables seam) and the
17
+ * registration-refusal leg (it drives a capability-toggle seam).
18
+ *
19
+ * Dispositions: the parent fixture or `conformance-subworkflow-input-mapping-child`
20
+ * not in `fixtures[]` ⇒ `inapplicable`; discovery unreadable ⇒ `blocked`.
21
+ *
22
+ * Proven against a double in `lib/subworkflow-witness.test.ts`.
23
+ *
24
+ * @see spec/v2/core/execution.md §subWorkflow (inputMapping)
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { v2Discovery } from '../lib/v2.js';
29
+ import { softSkip } from '../lib/soft-skip.js';
30
+ import { req } from '../lib/requirement-ids.js';
31
+ import { majorProfile } from '../lib/major-profile.js';
32
+ import type { Outcome, Skip } from '../lib/fixture-run-observer.js';
33
+ import { IM_NO_DEFAULT_FIXTURE, IM_PARENT_FIXTURE, driveInputMapping, judgeOverride, judgeSeed, judgeUnset, type MappingObservation } from '../lib/subworkflow-witness.js';
34
+
35
+ const PROFILE = majorProfile(2);
36
+ const ID_SEED = 'openwop.requirement.sub-workflow.input-mapping-seeds-child';
37
+ const ID_OVERRIDE = 'openwop.requirement.sub-workflow.input-mapping-overrides-default';
38
+ const ID_UNSET = 'openwop.requirement.sub-workflow.input-mapping-unset-undefined';
39
+
40
+ const runs = new Map<string, Promise<Skip | MappingObservation>>();
41
+ async function observation(fixture: string): Promise<MappingObservation | (() => undefined)> {
42
+ if (!process.env['OPENWOP_BASE_URL']) return () => softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
43
+ let doc: Record<string, unknown> | null;
44
+ try { doc = await v2Discovery(); } catch { doc = null; }
45
+ if (!doc) return () => softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
46
+ if (!runs.has(fixture)) runs.set(fixture, driveInputMapping(PROFILE, fixture));
47
+ const o = await runs.get(fixture)!;
48
+ return 'kind' in o ? () => softSkip(o.disposition, o.reason) : o;
49
+ }
50
+ function assert(id: string, out: Outcome): undefined {
51
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
52
+ for (const x of out.findings) expect(x.ok, req(id, x.doc, x.message)).toBe(true);
53
+ return undefined;
54
+ }
55
+
56
+ describe('v2 subWorkflow: inputMapping (execution.md §subWorkflow)', () => {
57
+ it('inputMapping seeds the child variable bag at creation', async () => {
58
+ const o = await observation(IM_PARENT_FIXTURE);
59
+ if (typeof o === 'function') return o();
60
+ return assert(ID_SEED, judgeSeed(o));
61
+ }, 90_000);
62
+
63
+ it('the mapped parent value lands after and over the child defaultValue', async () => {
64
+ const o = await observation(IM_PARENT_FIXTURE);
65
+ if (typeof o === 'function') return o();
66
+ return assert(ID_OVERRIDE, judgeOverride(o));
67
+ }, 90_000);
68
+
69
+ it('an unset parent variable arrives undefined, never an error or null', async () => {
70
+ const o = await observation(IM_NO_DEFAULT_FIXTURE);
71
+ if (typeof o === 'function') return o();
72
+ return assert(ID_UNSET, judgeUnset(o));
73
+ }, 90_000);
74
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * v2 — `core.subWorkflow` output and parent link (`spec/v2/core/execution.md`
3
+ * §`subWorkflow`). The v1 twin is `subworkflow`; the legs live in
4
+ * `lib/subworkflow-witness.ts`.
5
+ *
6
+ * A run of `conformance-subworkflow-parent` (node `subwf-call` starts
7
+ * `conformance-subworkflow-child`, `outputMapping: { childOutcome: childResult }`)
8
+ * is created with `POST /runs` and its log read with `events/poll`:
9
+ * output the parent's `node.completed` for `subwf-call` carries
10
+ * `outputs.childRunId` and `outputs.childStatus`, and the
11
+ * status is the child's terminal status;
12
+ * outputMapping the parent's `childOutcome` is the child's `childResult`;
13
+ * ancestry where `getRunAncestry` is served, the child's `parent` is
14
+ * the parent run with `cause: "core.subWorkflow"`.
15
+ * RETIRED, not ported: v1's child `parentRunId` / `parentNodeId` linkage
16
+ * (`spec/v1/migrations.json` C4.18). At v2 `parentRunId` is fork lineage.
17
+ *
18
+ * Dispositions: either fixture not in `fixtures[]` ⇒ `inapplicable`;
19
+ * `multiAgent.executionModel.crossHostCausation.ancestryEndpointSupported` not
20
+ * advertised ⇒ the ancestry leg `inapplicable`; discovery unreadable ⇒ `blocked`.
21
+ *
22
+ * Proven against a double in `lib/subworkflow-witness.test.ts`.
23
+ *
24
+ * @see spec/v2/core/execution.md §subWorkflow (Output, outputMapping, Parent link)
25
+ * @see spec/v2/core/runs.md §Diff and ancestry
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { v2Discovery, v2RefValidator } from '../lib/v2.js';
30
+ import { softSkip } from '../lib/soft-skip.js';
31
+ import { req } from '../lib/requirement-ids.js';
32
+ import { majorProfile } from '../lib/major-profile.js';
33
+ import type { Outcome, Skip } from '../lib/fixture-run-observer.js';
34
+ import { driveLinkage, judgeAncestry, judgeOutput, judgeOutputMapping, type LinkageObservation } from '../lib/subworkflow-witness.js';
35
+
36
+ const PROFILE = majorProfile(2);
37
+ const ID_OUTPUT = 'openwop.requirement.sub-workflow.node-completed-child-outputs';
38
+ const ID_OUTPUT_MAPPING = 'openwop.requirement.sub-workflow.output-mapping-copied';
39
+ const ID_ANCESTRY = 'openwop.requirement.sub-workflow.ancestry-parent-link';
40
+ const validateAncestry = v2RefValidator('run-ancestry-response.schema.json');
41
+
42
+ let run: Promise<Skip | LinkageObservation> | undefined;
43
+ async function observation(): Promise<LinkageObservation | (() => undefined)> {
44
+ if (!process.env['OPENWOP_BASE_URL']) return () => softSkip('inapplicable', 'no target: OPENWOP_BASE_URL is unset');
45
+ let doc: Record<string, unknown> | null;
46
+ try { doc = await v2Discovery(); } catch { doc = null; }
47
+ if (!doc) return () => softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0');
48
+ run ??= driveLinkage(PROFILE, doc);
49
+ const o = await run;
50
+ return 'kind' in o ? () => softSkip(o.disposition, o.reason) : o;
51
+ }
52
+ function assert(id: string, out: Outcome): undefined {
53
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
54
+ for (const x of out.findings) expect(x.ok, req(id, x.doc, x.message)).toBe(true);
55
+ return undefined;
56
+ }
57
+
58
+ describe('v2 subWorkflow: output and parent link (execution.md §subWorkflow)', () => {
59
+ it('node.completed for the core.subWorkflow node carries outputs.childRunId and outputs.childStatus', async () => {
60
+ const o = await observation();
61
+ if (typeof o === 'function') return o();
62
+ return assert(ID_OUTPUT, judgeOutput(o));
63
+ }, 90_000);
64
+
65
+ it('outputMapping copies the child variable into the parent after the child completes', async () => {
66
+ const o = await observation();
67
+ if (typeof o === 'function') return o();
68
+ return assert(ID_OUTPUT_MAPPING, judgeOutputMapping(o));
69
+ }, 90_000);
70
+
71
+ it('getRunAncestry names the parent run with cause core.subWorkflow', async () => {
72
+ const o = await observation();
73
+ if (typeof o === 'function') return o();
74
+ return assert(ID_ANCESTRY, judgeAncestry(o, validateAncestry));
75
+ }, 90_000);
76
+ });