@openwop/openwop-conformance 1.1.1 → 1.3.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 (109) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +2 -2
  3. package/api/redocly.yaml +15 -0
  4. package/coverage.md +27 -14
  5. package/fixtures/conformance-agent-low-confidence.json +7 -4
  6. package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
  7. package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
  8. package/fixtures/conformance-agent-reasoning.json +23 -4
  9. package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
  10. package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
  11. package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
  12. package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
  13. package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
  14. package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
  15. package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
  16. package/fixtures/conformance-dispatch-input-mapping.json +49 -0
  17. package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
  18. package/fixtures/conformance-dispatch-output-mapping.json +49 -0
  19. package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
  20. package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
  21. package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
  22. package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
  23. package/fixtures.md +18 -2
  24. package/package.json +1 -1
  25. package/schemas/README.md +7 -0
  26. package/schemas/agent-ref.schema.json +1 -1
  27. package/schemas/ai-envelope.schema.json +106 -0
  28. package/schemas/capabilities.schema.json +264 -0
  29. package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
  30. package/schemas/dispatch-config.schema.json +26 -0
  31. package/schemas/envelopes/clarification.request.schema.json +43 -0
  32. package/schemas/envelopes/error.schema.json +26 -0
  33. package/schemas/envelopes/schema.request.schema.json +22 -0
  34. package/schemas/envelopes/schema.response.schema.json +22 -0
  35. package/schemas/node-pack-manifest.schema.json +5 -0
  36. package/schemas/pack-lockfile.schema.json +16 -0
  37. package/schemas/run-event-payloads.schema.json +35 -1
  38. package/schemas/run-event.schema.json +2 -0
  39. package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
  40. package/src/lib/driver.ts +15 -0
  41. package/src/lib/env.ts +51 -0
  42. package/src/lib/event-log-query.ts +62 -0
  43. package/src/lib/fixtures.ts +38 -1
  44. package/src/lib/host-toggle.ts +54 -0
  45. package/src/lib/multi-agent-capabilities.ts +10 -0
  46. package/src/lib/otel-scrape.ts +59 -0
  47. package/src/lib/webhook-receiver.ts +137 -0
  48. package/src/lib/workflow-chain-expansion.ts +213 -0
  49. package/src/scenarios/agentPackCatalog.test.ts +216 -0
  50. package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
  51. package/src/scenarios/agentReasoningEvents.test.ts +58 -7
  52. package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
  53. package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
  54. package/src/scenarios/ai-envelope-shape.test.ts +362 -0
  55. package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
  56. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
  57. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
  58. package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
  59. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
  60. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
  61. package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
  62. package/src/scenarios/append-ordering.test.ts +44 -0
  63. package/src/scenarios/artifact-auth.test.ts +58 -0
  64. package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
  65. package/src/scenarios/blob-presign-expiry.test.ts +99 -0
  66. package/src/scenarios/blob-roundtrip.test.ts +0 -0
  67. package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
  68. package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
  69. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
  70. package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
  71. package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
  72. package/src/scenarios/fixtures-gating.test.ts +139 -1
  73. package/src/scenarios/fs-path-traversal.test.ts +124 -0
  74. package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
  75. package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
  76. package/src/scenarios/kv-atomic-increment.test.ts +74 -0
  77. package/src/scenarios/kv-cas.test.ts +75 -0
  78. package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
  79. package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
  80. package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
  81. package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
  82. package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
  83. package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
  84. package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
  85. package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
  86. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
  87. package/src/scenarios/pack-registry-publish.test.ts +231 -51
  88. package/src/scenarios/pause-resume.test.ts +43 -0
  89. package/src/scenarios/provider-usage.test.ts +185 -0
  90. package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
  91. package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
  92. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
  93. package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
  94. package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
  95. package/src/scenarios/spec-corpus-validity.test.ts +17 -1
  96. package/src/scenarios/sql-injection-rejection.test.ts +84 -0
  97. package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
  98. package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
  99. package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
  100. package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
  101. package/src/scenarios/table-cursor-pagination.test.ts +85 -0
  102. package/src/scenarios/table-schema-enforcement.test.ts +84 -0
  103. package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
  104. package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
  105. package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
  106. package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
  107. package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
  108. package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
  109. package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * cache-ttl-expiry — RFC 0019 advertisement-shape verification + behavioral placeholders.
3
+ *
4
+ * Status: ACTIVE (advertisement-shape). RFC 0019 promoted to `Active`
5
+ * 2026-05-17. The matching `capabilities.cache` block has landed in
6
+ * `schemas/capabilities.schema.json`. This scenario asserts the advertisement
7
+ * shape against any host that boots the conformance suite, and keeps the
8
+ * deeper behavioral assertions as `it.todo()` until a reference host wires
9
+ * a test seam.
10
+ *
11
+ * Summary: Cache TTL honored with at most 1-second drift.
12
+ *
13
+ * @see RFCS/0019-*.md
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import { driver } from '../lib/driver.js';
18
+
19
+ interface DiscoveryDoc {
20
+ capabilities?: Record<string, unknown>;
21
+ }
22
+
23
+ async function readCap(): Promise<Record<string, unknown> | null> {
24
+ const res = await driver.get('/.well-known/openwop');
25
+ const body = res.json as DiscoveryDoc | undefined;
26
+ const top = body?.capabilities as Record<string, unknown> | undefined;
27
+ const final = (top && typeof top === 'object') ? (top as Record<string, unknown>)["cache"] : undefined;
28
+ return (final && typeof final === 'object' ? (final as Record<string, unknown>) : null);
29
+ }
30
+
31
+ describe('cache-ttl-expiry: advertisement shape (RFC 0019)', () => {
32
+ it('capabilities.cache is either absent or a well-formed object', async () => {
33
+ const cap = await readCap();
34
+ if (cap === null) return; // host doesn't advertise — skip
35
+ expect(
36
+ typeof cap.supported,
37
+ driver.describe(
38
+ 'capabilities.schema.json §cache',
39
+ 'capabilities.cache.supported MUST be a boolean when present',
40
+ ),
41
+ ).toBe('boolean');
42
+ });
43
+ });
44
+
45
+ async function call(op: string, args: Record<string, unknown>) {
46
+ return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'cache', op, args });
47
+ }
48
+
49
+ describe('cache-ttl-expiry: behavioral (RFC 0019 §B point 2 — 1s TTL drift)', () => {
50
+ it('put with ttlSeconds=2 → hit within window; miss after expiry', async () => {
51
+ const probe = await call('get', { key: '__cache-probe__' });
52
+ if (probe.status === 404) return;
53
+ const key = `c-ttl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
+ const putRes = await call('put', { key, value: 'evicts-soon', ttlSeconds: 2 });
55
+ expect(putRes.status).toBe(200);
56
+
57
+ const within = await call('get', { key });
58
+ const withinBody = within.json as { value?: unknown; found?: boolean };
59
+ expect(
60
+ withinBody.value,
61
+ driver.describe('RFC 0019 §B point 2', 'cache get within TTL MUST return the stored value'),
62
+ ).toBe('evicts-soon');
63
+
64
+ await new Promise((r) => setTimeout(r, 3000));
65
+
66
+ const after = await call('get', { key });
67
+ const afterBody = after.json as { value?: unknown; found?: boolean };
68
+ expect(
69
+ afterBody.found,
70
+ driver.describe('RFC 0019 §B point 2', 'cache get after TTL expiry MUST surface as found:false (≤1s drift)'),
71
+ ).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * RFC 0022 §A + §D — `core.dispatch` sequential cross-worker handoff (HVMAP-1c).
3
+ * Normative reference: RFCS/0022-dispatch-input-output-mapping.md §A + §D
4
+ *
5
+ * Verifies that under `fanOutPolicy: 'sequential'` (v1.x default), the
6
+ * output mapping of child N MUST be visible to child N+1's input mapping
7
+ * within the same dispatch loop — they share the parent variable bag.
8
+ * The scenario routes child-a's `output` variable through the parent's
9
+ * `sharedVar` slot via per-worker mappings, then child-b reads it back
10
+ * as `inputs.input`. Verified end-to-end against the Postgres reference
11
+ * host on 2026-05-18 alongside the supervisor-mock extension (RFC 0022
12
+ * §"Unresolved questions" #6) that lets fixtures drive multi-worker
13
+ * `OrchestratorDecision` sequences.
14
+ *
15
+ * Capability-gated: skips when host doesn't advertise
16
+ * `capabilities.agents.dispatchMapping: true`. Fixture-gated: requires
17
+ * `conformance-dispatch-cross-worker-handoff` + the two child fixtures.
18
+ *
19
+ * @see RFCS/0022-dispatch-input-output-mapping.md §A + §D
20
+ * @see schemas/dispatch-config.schema.json #/properties/perWorker*
21
+ * @see examples/hosts/postgres/src/server.ts (core.dispatch executor)
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { driver } from '../lib/driver.js';
26
+ import { pollUntilTerminal } from '../lib/polling.js';
27
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
28
+
29
+ const PARENT = 'conformance-dispatch-cross-worker-handoff';
30
+ const CHILD_A = 'conformance-dispatch-cross-worker-handoff-child-a';
31
+ const CHILD_B = 'conformance-dispatch-cross-worker-handoff-child-b';
32
+ const SKIP =
33
+ !isFixtureAdvertised(PARENT) ||
34
+ !isFixtureAdvertised(CHILD_A) ||
35
+ !isFixtureAdvertised(CHILD_B);
36
+
37
+ interface RunEvent {
38
+ readonly type: string;
39
+ readonly nodeId?: string;
40
+ readonly payload?: { childRunId?: string; childWorkflowId?: string } & Record<string, unknown>;
41
+ }
42
+
43
+ interface RunSnapshot {
44
+ readonly status: string;
45
+ readonly inputs?: Record<string, unknown>;
46
+ readonly variables?: Record<string, unknown>;
47
+ }
48
+
49
+ describe.skipIf(SKIP)('dispatch-cross-worker-handoff: sequential child→parent→child variable flow (RFC 0022 §A + §D)', () => {
50
+ it('HVMAP-1c: child-a writes via perWorkerOutputMappings; child-b reads via perWorkerInputMappings; shared parent bag is the handoff channel', async () => {
51
+ const create = await driver.post('/v1/runs', { workflowId: PARENT });
52
+ expect(create.status).toBe(201);
53
+ const parentRunId = (create.json as { runId: string }).runId;
54
+
55
+ const parentTerminal = (await pollUntilTerminal(parentRunId)) as RunSnapshot;
56
+ expect(parentTerminal.status, driver.describe(
57
+ 'RFCS/0022-dispatch-input-output-mapping.md §D',
58
+ 'parent run MUST reach terminal `completed` once both children finish sequentially',
59
+ )).toBe('completed');
60
+
61
+ // Parent's sharedVar MUST be 'hello' — set by child-a's outputMapping.
62
+ const parentVars = parentTerminal.variables ?? {};
63
+ expect(parentVars.sharedVar, driver.describe(
64
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
65
+ 'parent `sharedVar` MUST be child-a\'s `output` projection ("hello") via perWorkerOutputMappings',
66
+ )).toBe('hello');
67
+
68
+ // Locate child-b via the parent's `node.dispatched` events.
69
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
70
+ expect(eventsRes.status).toBe(200);
71
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
72
+ const dispatchedB = events.find(
73
+ (e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_B,
74
+ );
75
+ expect(dispatchedB, driver.describe(
76
+ 'RFCS/0022-dispatch-input-output-mapping.md §D',
77
+ 'parent event log MUST contain a `node.dispatched` event for child-b after child-a completes (sequential fan-out)',
78
+ )).toBeDefined();
79
+ const childBRunId = dispatchedB?.payload?.childRunId;
80
+ expect(typeof childBRunId).toBe('string');
81
+
82
+ // Child-b's inputs MUST reflect child-a's output (via the shared
83
+ // parent variable bag's sharedVar).
84
+ const childBSnapshotRes = await driver.get(`/v1/runs/${encodeURIComponent(childBRunId!)}`);
85
+ expect(childBSnapshotRes.status).toBe(200);
86
+ const childBSnapshot = childBSnapshotRes.json as RunSnapshot;
87
+ expect(childBSnapshot.status).toBe('completed');
88
+ const childBInputs = childBSnapshot.inputs ?? {};
89
+ expect(childBInputs.input, driver.describe(
90
+ 'RFCS/0022-dispatch-input-output-mapping.md §A + §D',
91
+ 'child-b `inputs.input` MUST be parent\'s `sharedVar` ("hello") — written by child-a, read by child-b via shared parent variable bag',
92
+ )).toBe('hello');
93
+ });
94
+
95
+ it('HVMAP-1c-override: per-worker mapping overrides default mapping per §A effectiveInputMapping precedence', async () => {
96
+ const PARENT_OVERRIDE = 'conformance-dispatch-per-worker-override';
97
+ if (!isFixtureAdvertised(PARENT_OVERRIDE)) return; // fixture not seeded — soft-skip
98
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_OVERRIDE });
99
+ expect(create.status).toBe(201);
100
+ const parentRunId = (create.json as { runId: string }).runId;
101
+ await pollUntilTerminal(parentRunId);
102
+
103
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
104
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
105
+ const dispatchedA = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_A);
106
+ const dispatchedB = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_B);
107
+ if (!dispatchedA || !dispatchedB) return;
108
+
109
+ const childARes = await driver.get(`/v1/runs/${encodeURIComponent(dispatchedA.payload!.childRunId!)}`);
110
+ const childBRes = await driver.get(`/v1/runs/${encodeURIComponent(dispatchedB.payload!.childRunId!)}`);
111
+ const childAInputs = (childARes.json as { inputs?: Record<string, unknown> }).inputs ?? {};
112
+ const childBInputs = (childBRes.json as { inputs?: Record<string, unknown> }).inputs ?? {};
113
+
114
+ expect(
115
+ childAInputs.input,
116
+ driver.describe(
117
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
118
+ 'child-a uses the DEFAULT inputMapping; input MUST come from parent.defaultX',
119
+ ),
120
+ ).toBe('default-x-value');
121
+ expect(
122
+ childBInputs.input,
123
+ driver.describe(
124
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
125
+ 'child-b uses the per-worker OVERRIDE; input MUST come from parent.sharedVar (NOT defaultX)',
126
+ ),
127
+ ).toBe('shared-value');
128
+ });
129
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * RFC 0022 §A — `core.dispatch` `inputMapping` projection (HVMAP-1a).
3
+ * Normative reference: RFCS/0022-dispatch-input-output-mapping.md §A
4
+ *
5
+ * Verifies that when a `core.dispatch` config carries `inputMapping`, the
6
+ * host builds child inputs by projecting parent variables before invoking
7
+ * each `nextWorkerIds[i]` child. Per §A: `childInputs[childKey] =
8
+ * parentVariables.get(parentKey)`. Verified end-to-end against the
9
+ * Postgres reference host on 2026-05-18 alongside the supervisor-mock
10
+ * extension that lets fixtures drive `OrchestratorDecision` sequences
11
+ * (RFC 0022 §"Unresolved questions" #6 — `mockDispatchPlan` config on
12
+ * `core.orchestrator.supervisor`).
13
+ *
14
+ * Capability-gated: skips when host doesn't advertise
15
+ * `capabilities.agents.dispatchMapping: true`. Fixture-gated: requires
16
+ * `conformance-dispatch-input-mapping` + the matching child fixture.
17
+ *
18
+ * @see RFCS/0022-dispatch-input-output-mapping.md §A
19
+ * @see schemas/dispatch-config.schema.json #/properties/inputMapping
20
+ * @see examples/hosts/postgres/src/server.ts (core.dispatch executor)
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+ import { pollUntilTerminal } from '../lib/polling.js';
26
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
27
+ import { setHostCapability, resetHostCapabilities, isToggleAvailable } from '../lib/host-toggle.js';
28
+
29
+ const PARENT = 'conformance-dispatch-input-mapping';
30
+ const CHILD = 'conformance-dispatch-input-mapping-child';
31
+ const SKIP = !isFixtureAdvertised(PARENT) || !isFixtureAdvertised(CHILD);
32
+
33
+ interface RunEvent {
34
+ readonly type: string;
35
+ readonly nodeId?: string;
36
+ readonly payload?: { childRunId?: string; childWorkflowId?: string } & Record<string, unknown>;
37
+ }
38
+
39
+ interface RunSnapshot {
40
+ readonly status: string;
41
+ readonly inputs?: Record<string, unknown>;
42
+ readonly variables?: Record<string, unknown>;
43
+ }
44
+
45
+ describe.skipIf(SKIP)('dispatch-input-mapping: parent → child variable projection (RFC 0022 §A)', () => {
46
+ it('HVMAP-1a: inputMapping projects parent variables into child inputs', async () => {
47
+ const create = await driver.post('/v1/runs', { workflowId: PARENT });
48
+ expect(create.status).toBe(201);
49
+ const parentRunId = (create.json as { runId: string }).runId;
50
+
51
+ const parentTerminal = (await pollUntilTerminal(parentRunId)) as RunSnapshot;
52
+ expect(parentTerminal.status, driver.describe(
53
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
54
+ 'parent run MUST reach terminal `completed` once the dispatch loop terminates',
55
+ )).toBe('completed');
56
+
57
+ // Locate the child run via the parent's `node.dispatched` event.
58
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
59
+ expect(eventsRes.status).toBe(200);
60
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
61
+ const dispatched = events.find(
62
+ (e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD,
63
+ );
64
+ expect(dispatched, driver.describe(
65
+ 'RFCS/0007-dispatch.md §D',
66
+ 'parent event log MUST contain a `node.dispatched` event naming the child workflow',
67
+ )).toBeDefined();
68
+ const childRunId = dispatched?.payload?.childRunId;
69
+ expect(typeof childRunId).toBe('string');
70
+
71
+ // The child's inputs_json (surfaced as `inputs` on GET /v1/runs)
72
+ // MUST contain the parent's `parentName` projected onto `childGreeting`
73
+ // per the dispatch config's inputMapping.
74
+ const childSnapshotRes = await driver.get(`/v1/runs/${encodeURIComponent(childRunId!)}`);
75
+ expect(childSnapshotRes.status).toBe(200);
76
+ const childSnapshot = childSnapshotRes.json as RunSnapshot;
77
+ expect(childSnapshot.status, driver.describe(
78
+ 'RFCS/0007-dispatch.md',
79
+ 'child run MUST reach terminal `completed`',
80
+ )).toBe('completed');
81
+ const childInputs = childSnapshot.inputs ?? {};
82
+ expect(childInputs.childGreeting, driver.describe(
83
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
84
+ 'child `inputs.childGreeting` MUST be parent\'s `parentName` projection ("Alice") per inputMapping',
85
+ )).toBe('Alice');
86
+ });
87
+
88
+ it('HVMAP-1a-null: parent variable unset → child input surfaces as `undefined` per §A', async () => {
89
+ const PARENT_NO_DEFAULT = 'conformance-dispatch-input-mapping-no-default';
90
+ if (!isFixtureAdvertised(PARENT_NO_DEFAULT) || !isFixtureAdvertised(CHILD)) return; // fixture not seeded — soft-skip
91
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_NO_DEFAULT });
92
+ expect(create.status).toBe(201);
93
+ const parentRunId = (create.json as { runId: string }).runId;
94
+ await pollUntilTerminal(parentRunId);
95
+
96
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
97
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
98
+ const dispatched = events.find(
99
+ (e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD,
100
+ );
101
+ if (!dispatched) return; // host doesn't emit node.dispatched — soft-skip
102
+ const childRunId = dispatched.payload?.childRunId;
103
+
104
+ const childRes = await driver.get(`/v1/runs/${encodeURIComponent(childRunId!)}`);
105
+ const child = childRes.json as RunSnapshot;
106
+ // Per RFC 0022 §A: an unset parent variable MUST surface as `undefined`.
107
+ // On the wire, `undefined` becomes either omitted from the JSON object
108
+ // OR explicit `null`; the spec REJECTS the latter. We accept either
109
+ // "key absent" or "key === undefined" but FAIL on `null`.
110
+ const inputs = child.inputs ?? {};
111
+ const v = inputs.childGreeting;
112
+ expect(
113
+ v === undefined || !('childGreeting' in inputs),
114
+ driver.describe(
115
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
116
+ 'unset parent variable projection MUST surface as undefined (NOT null, NOT a default placeholder)',
117
+ ),
118
+ ).toBe(true);
119
+ expect(v).not.toBe(null);
120
+ });
121
+
122
+ });
123
+
124
+ describe('dispatch-input-mapping: registration refusal (RFC 0022 §C HVMAP-1a-refusal)', () => {
125
+ it('host with agents.dispatchMapping toggled OFF MUST refuse non-empty inputMapping at registration', async () => {
126
+ if (!(await isToggleAvailable())) return; // seam not exposed — soft-skip
127
+ await setHostCapability('agents.dispatchMapping', false);
128
+ try {
129
+ const workflow = {
130
+ workflowId: `hvmap-1a-refusal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
131
+ nodes: [
132
+ {
133
+ nodeId: 'dispatch-1',
134
+ typeId: 'core.dispatch',
135
+ config: {
136
+ nextWorkerIds: ['child-a'],
137
+ inputMapping: { childInput: 'parentVar' }, // non-empty — refusal trigger
138
+ },
139
+ },
140
+ ],
141
+ };
142
+ const res = await driver.post('/v1/host/sample/workflows', workflow);
143
+ expect(
144
+ res.status,
145
+ driver.describe(
146
+ 'RFCS/0022-dispatch-input-output-mapping.md §C',
147
+ 'workflow with non-empty inputMapping MUST be refused when capabilities.agents.dispatchMapping is not advertised',
148
+ ),
149
+ ).toBe(400);
150
+ const body = res.json as { error?: string; details?: { requiredCapability?: string } };
151
+ expect(body.error).toBe('validation_error');
152
+ expect(
153
+ body.details?.requiredCapability,
154
+ driver.describe(
155
+ 'RFCS/0022-dispatch-input-output-mapping.md §C',
156
+ 'refusal MUST surface requiredCapability: "agents.dispatchMapping"',
157
+ ),
158
+ ).toBe('agents.dispatchMapping');
159
+ } finally {
160
+ await resetHostCapabilities();
161
+ }
162
+ });
163
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * RFC 0022 §A — `core.dispatch` `outputMapping` harvesting (HVMAP-1b).
3
+ * Normative reference: RFCS/0022-dispatch-input-output-mapping.md §A
4
+ *
5
+ * Verifies that when a `core.dispatch` config carries `outputMapping` and
6
+ * a child reaches terminal `completed`, the host projects child variables
7
+ * back into parent variables: `parentVariables.set(parentKey, childVariables[childKey])`.
8
+ * Failed / cancelled children MUST skip the mapping; the parent's variable
9
+ * stays at its pre-dispatch state for that child. Verified end-to-end
10
+ * against the Postgres reference host on 2026-05-18.
11
+ *
12
+ * Capability-gated: skips when host doesn't advertise
13
+ * `capabilities.agents.dispatchMapping: true`. Fixture-gated: requires
14
+ * `conformance-dispatch-output-mapping` + the matching child fixture.
15
+ *
16
+ * @see RFCS/0022-dispatch-input-output-mapping.md §A
17
+ * @see schemas/dispatch-config.schema.json #/properties/outputMapping
18
+ * @see examples/hosts/postgres/src/server.ts (core.dispatch executor)
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+ import { pollUntilTerminal } from '../lib/polling.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+
26
+ const PARENT = 'conformance-dispatch-output-mapping';
27
+ const CHILD = 'conformance-dispatch-output-mapping-child';
28
+ const SKIP = !isFixtureAdvertised(PARENT) || !isFixtureAdvertised(CHILD);
29
+
30
+ interface RunSnapshot {
31
+ readonly status: string;
32
+ readonly variables?: Record<string, unknown>;
33
+ }
34
+
35
+ describe.skipIf(SKIP)('dispatch-output-mapping: child → parent variable harvest (RFC 0022 §A)', () => {
36
+ it('HVMAP-1b: outputMapping harvests child variables into parent variables on terminal completed', async () => {
37
+ const create = await driver.post('/v1/runs', { workflowId: PARENT });
38
+ expect(create.status).toBe(201);
39
+ const parentRunId = (create.json as { runId: string }).runId;
40
+
41
+ const parentTerminal = (await pollUntilTerminal(parentRunId)) as RunSnapshot;
42
+ expect(parentTerminal.status, driver.describe(
43
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
44
+ 'parent run MUST reach terminal `completed` once the dispatch loop terminates',
45
+ )).toBe('completed');
46
+
47
+ // Parent's `parentResult` MUST equal child's `childOutcome` ("done")
48
+ // per the dispatch config's outputMapping = { parentResult: 'childOutcome' }.
49
+ // Child declares childOutcome.defaultValue='done' so the value is
50
+ // present in the child's variables_json at terminal time.
51
+ const parentVars = parentTerminal.variables ?? {};
52
+ expect(parentVars.parentResult, driver.describe(
53
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
54
+ 'parent `parentResult` MUST be child\'s `childOutcome` projection ("done") per outputMapping',
55
+ )).toBe('done');
56
+ });
57
+
58
+ });
59
+
60
+ interface RunEvent { readonly type: string; readonly nodeId?: string; readonly payload?: { childRunId?: string; childWorkflowId?: string } & Record<string, unknown>; }
61
+
62
+ /** Register a parent workflow that dispatches to a specific child fixture
63
+ * with outputMapping. Returns the registered parent's workflowId.
64
+ * The parent declares `parentResult` with a sentinel default so the
65
+ * test can verify it stayed at the sentinel (NOT overwritten by
66
+ * outputMapping) when the child terminates non-completed. */
67
+ async function registerParent(childFixtureId: string): Promise<string | null> {
68
+ const workflowId = `hvmap-1b-${childFixtureId.replace(/[^a-zA-Z0-9_.-]/g, '-')}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
69
+ const def = {
70
+ workflowId,
71
+ nodes: [
72
+ {
73
+ nodeId: 'supervisor',
74
+ typeId: 'core.orchestrator.supervisor',
75
+ config: {
76
+ mockDispatchPlan: [
77
+ { kind: 'next-worker', nextWorkerIds: [childFixtureId] },
78
+ { kind: 'terminate', reason: 'goal-reached' },
79
+ ],
80
+ },
81
+ },
82
+ {
83
+ nodeId: 'dispatch',
84
+ typeId: 'core.dispatch',
85
+ config: {
86
+ askUserRouting: 'auto',
87
+ workerDispatchModel: 'child-run',
88
+ fanOutPolicy: 'sequential',
89
+ outputMapping: { parentResult: 'childOutcome' },
90
+ },
91
+ },
92
+ ],
93
+ };
94
+ const res = await driver.post('/v1/host/sample/workflows', def);
95
+ if (res.status !== 201) return null;
96
+ return workflowId;
97
+ }
98
+
99
+ describe.skipIf(!isFixtureAdvertised('conformance-dispatch-deterministic-fail-child'))('dispatch-output-mapping: HVMAP-1b-failed (RFC 0022 §B)', () => {
100
+ it('child terminates `failed` → outputMapping MUST be skipped; parent.parentResult stays at sentinel', async () => {
101
+ const parentId = await registerParent('conformance-dispatch-deterministic-fail-child');
102
+ if (!parentId) return; // workflow-register seam not exposed — soft-skip
103
+ const create = await driver.post('/v1/runs', { workflowId: parentId });
104
+ expect(create.status).toBe(201);
105
+ const parentRunId = (create.json as { runId: string }).runId;
106
+ const terminal = (await pollUntilTerminal(parentRunId)) as RunSnapshot & { variables?: Record<string, unknown> };
107
+ // Parent reaches some terminal state (completed if it tolerates failed children + supervisor terminates; failed if not).
108
+ // Either way, parentResult MUST NOT be overwritten with the child's "this-should-not-be-harvested" sentinel.
109
+ const parentVars = terminal.variables ?? {};
110
+ expect(
111
+ parentVars.parentResult,
112
+ driver.describe(
113
+ 'RFCS/0022-dispatch-input-output-mapping.md §B',
114
+ 'outputMapping MUST be SKIPPED when child terminates failed; parent variable MUST NOT be overwritten',
115
+ ),
116
+ ).not.toBe('this-should-not-be-harvested');
117
+ });
118
+ });
119
+
120
+ describe.skipIf(!isFixtureAdvertised('conformance-dispatch-cancellable-child'))('dispatch-output-mapping: HVMAP-1b-cancelled (RFC 0022 §B)', () => {
121
+ it('child terminates `cancelled` → outputMapping MUST be skipped; parent.parentResult stays at sentinel', async () => {
122
+ const parentId = await registerParent('conformance-dispatch-cancellable-child');
123
+ if (!parentId) return; // soft-skip
124
+ const create = await driver.post('/v1/runs', { workflowId: parentId });
125
+ expect(create.status).toBe(201);
126
+ const parentRunId = (create.json as { runId: string }).runId;
127
+
128
+ // Poll for the node.dispatched event so we can cancel the child mid-flight.
129
+ const start = Date.now();
130
+ let childRunId: string | undefined;
131
+ while (Date.now() - start < 10_000) {
132
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
133
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
134
+ const dispatched = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === 'conformance-dispatch-cancellable-child');
135
+ if (dispatched?.payload?.childRunId) {
136
+ childRunId = dispatched.payload.childRunId;
137
+ break;
138
+ }
139
+ await new Promise((r) => setTimeout(r, 250));
140
+ }
141
+ if (!childRunId) return; // dispatch didn't surface child run id — soft-skip
142
+ const cancelRes = await driver.post(`/v1/runs/${encodeURIComponent(childRunId)}/cancel`, { reason: 'hvmap-1b-cancelled test' });
143
+ expect(cancelRes.status === 200 || cancelRes.status === 202).toBe(true);
144
+
145
+ const terminal = (await pollUntilTerminal(parentRunId)) as RunSnapshot & { variables?: Record<string, unknown> };
146
+ const parentVars = terminal.variables ?? {};
147
+ expect(
148
+ parentVars.parentResult,
149
+ driver.describe(
150
+ 'RFCS/0022-dispatch-input-output-mapping.md §B',
151
+ 'outputMapping MUST be SKIPPED when child terminates cancelled; parent variable MUST NOT be overwritten',
152
+ ),
153
+ ).not.toBe('this-should-not-be-harvested');
154
+ });
155
+ });