@openwop/openwop-conformance 1.2.0 → 1.4.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 (144) hide show
  1. package/CHANGELOG.md +156 -1
  2. package/README.md +3 -2
  3. package/api/asyncapi.yaml +8 -0
  4. package/api/openapi.yaml +371 -1
  5. package/api/redocly.yaml +15 -0
  6. package/coverage.md +26 -5
  7. package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
  8. package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
  9. package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
  10. package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
  11. package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
  12. package/fixtures/conformance-envelope-nl-to-format-engaged.json +41 -0
  13. package/fixtures/conformance-envelope-recovery-applied.json +39 -0
  14. package/fixtures/conformance-envelope-refusal.json +38 -0
  15. package/fixtures/conformance-envelope-retry-attempted.json +39 -0
  16. package/fixtures/conformance-envelope-retry-exhausted.json +38 -0
  17. package/fixtures/conformance-envelope-truncated.json +39 -0
  18. package/fixtures/conformance-envelope-truncation-cap-exhaustion.json +39 -0
  19. package/fixtures/conformance-model-capability-insufficient.json +25 -0
  20. package/fixtures/conformance-multi-agent-confidence-escalation.json +49 -0
  21. package/fixtures/conformance-multi-agent-handoff-child.json +27 -0
  22. package/fixtures/conformance-multi-agent-handoff.json +49 -0
  23. package/fixtures/conformance-prompt-all-four-kinds.json +39 -0
  24. package/fixtures/conformance-prompt-end-to-end.json +33 -0
  25. package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
  26. package/fixtures/conformance-subworkflow-mid-run-mutation-child.json +31 -0
  27. package/fixtures/conformance-subworkflow-mid-run-mutation.json +33 -0
  28. package/fixtures/openwop-smoke-cost-emit.json +37 -0
  29. package/fixtures/prompt-templates/conformance-prompt-few-shot-2.json +14 -0
  30. package/fixtures/prompt-templates/conformance-prompt-few-shot.json +14 -0
  31. package/fixtures/prompt-templates/conformance-prompt-schema-hint.json +14 -0
  32. package/fixtures/prompt-templates/conformance-prompt-secret-redaction.json +23 -0
  33. package/fixtures/prompt-templates/conformance-prompt-trust-marker.json +23 -0
  34. package/fixtures/prompt-templates/conformance-prompt-writer-system.json +15 -0
  35. package/fixtures/prompt-templates/conformance-prompt-writer-user.json +15 -0
  36. package/fixtures.md +45 -0
  37. package/package.json +1 -1
  38. package/schemas/README.md +5 -0
  39. package/schemas/agent-manifest.schema.json +16 -0
  40. package/schemas/capabilities.schema.json +390 -0
  41. package/schemas/core-conformance-mock-agent-config.schema.json +5 -0
  42. package/schemas/envelopes/clarification.request.schema.json +9 -0
  43. package/schemas/envelopes/error.schema.json +4 -0
  44. package/schemas/envelopes/schema.request.schema.json +4 -0
  45. package/schemas/envelopes/schema.response.schema.json +1 -1
  46. package/schemas/node-pack-manifest.schema.json +28 -0
  47. package/schemas/orchestrator-decision.schema.json +12 -0
  48. package/schemas/prompt-kind.schema.json +8 -0
  49. package/schemas/prompt-pack-manifest.schema.json +80 -0
  50. package/schemas/prompt-ref.schema.json +40 -0
  51. package/schemas/prompt-template.schema.json +149 -0
  52. package/schemas/registry-version-manifest.schema.json +5 -0
  53. package/schemas/run-ancestry-response.schema.json +54 -0
  54. package/schemas/run-event-payloads.schema.json +513 -11
  55. package/schemas/run-event.schema.json +17 -1
  56. package/schemas/run-snapshot.schema.json +3 -2
  57. package/schemas/workflow-definition.schema.json +19 -1
  58. package/src/lib/driver.ts +15 -0
  59. package/src/lib/env.ts +51 -0
  60. package/src/lib/event-log-query.ts +62 -0
  61. package/src/lib/fixtures.ts +38 -1
  62. package/src/lib/host-toggle.ts +54 -0
  63. package/src/lib/llm-cache-key-recipe.ts +68 -0
  64. package/src/lib/multi-agent-capabilities.ts +10 -0
  65. package/src/lib/otel-scrape.ts +59 -0
  66. package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
  67. package/src/scenarios/aiEnvelope.capBreached.test.ts +97 -9
  68. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +224 -15
  69. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +257 -25
  70. package/src/scenarios/aiEnvelope.redaction.test.ts +210 -29
  71. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +163 -24
  72. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +262 -12
  73. package/src/scenarios/aiEnvelope.universalKinds.test.ts +107 -16
  74. package/src/scenarios/blob-presign-expiry.test.ts +42 -9
  75. package/src/scenarios/blob-roundtrip.test.ts +0 -0
  76. package/src/scenarios/cache-ttl-expiry.test.ts +34 -8
  77. package/src/scenarios/cost-attribution.test.ts +124 -11
  78. package/src/scenarios/cross-engine-append-ordering.test.ts +99 -0
  79. package/src/scenarios/cross-host-ancestry-endpoint.test.ts +136 -0
  80. package/src/scenarios/cross-host-causation-shape.test.ts +117 -0
  81. package/src/scenarios/cross-host-traceparent-propagation.test.ts +60 -0
  82. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +34 -3
  83. package/src/scenarios/dispatch-input-mapping.test.ts +75 -6
  84. package/src/scenarios/dispatch-output-mapping.test.ts +96 -6
  85. package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +223 -0
  86. package/src/scenarios/envelope-nl-to-format-engaged.test.ts +152 -0
  87. package/src/scenarios/envelope-reasoning-secret-redaction.test.ts +343 -0
  88. package/src/scenarios/envelope-reasoning-shape.test.ts +190 -0
  89. package/src/scenarios/envelope-recovery-applied.test.ts +229 -0
  90. package/src/scenarios/envelope-refusal-shape.test.ts +289 -0
  91. package/src/scenarios/envelope-retry-attempted.test.ts +258 -0
  92. package/src/scenarios/envelope-retry-exhausted.test.ts +168 -0
  93. package/src/scenarios/envelope-tier-one-subset-static.test.ts +229 -0
  94. package/src/scenarios/envelope-truncated.test.ts +136 -0
  95. package/src/scenarios/envelope-truncation-cap-exhaustion.test.ts +144 -0
  96. package/src/scenarios/envelope-variant-discriminator-static.test.ts +152 -0
  97. package/src/scenarios/fixtures-gating.test.ts +139 -1
  98. package/src/scenarios/fixtures-valid.test.ts +123 -15
  99. package/src/scenarios/kv-ttl-expiry.test.ts +40 -9
  100. package/src/scenarios/model-capability-insufficient.test.ts +221 -0
  101. package/src/scenarios/model-capability-substituted.test.ts +203 -0
  102. package/src/scenarios/multi-agent-confidence-escalation.test.ts +164 -0
  103. package/src/scenarios/multi-agent-handoff-state-machine.test.ts +167 -0
  104. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +124 -0
  105. package/src/scenarios/multi-region-idempotency.test.ts +58 -0
  106. package/src/scenarios/node-module-required-capabilities-shape.test.ts +185 -0
  107. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
  108. package/src/scenarios/pack-registry-publish.test.ts +231 -51
  109. package/src/scenarios/prompt-all-four-kinds-events.test.ts +198 -0
  110. package/src/scenarios/prompt-composed-secret-redaction.test.ts +178 -0
  111. package/src/scenarios/prompt-composed-trust-marker.test.ts +165 -0
  112. package/src/scenarios/prompt-end-to-end-events.test.ts +202 -0
  113. package/src/scenarios/prompt-list-and-fetch.test.ts +207 -0
  114. package/src/scenarios/prompt-mutable-lifecycle.test.ts +216 -0
  115. package/src/scenarios/prompt-pack-install.test.ts +187 -0
  116. package/src/scenarios/prompt-render-deterministic.test.ts +240 -0
  117. package/src/scenarios/prompt-resolution-chain-agent-intrinsic.test.ts +140 -0
  118. package/src/scenarios/prompt-resolution-chain-fallback-cascade.test.ts +172 -0
  119. package/src/scenarios/prompt-resolution-chain-node-wins.test.ts +144 -0
  120. package/src/scenarios/prompt-template-shape.test.ts +359 -0
  121. package/src/scenarios/provider-usage.test.ts +185 -0
  122. package/src/scenarios/queue-ack-nack-dlq.test.ts +64 -10
  123. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +50 -10
  124. package/src/scenarios/replay-divergence-at-refusal.test.ts +134 -0
  125. package/src/scenarios/replay-llm-cache-key-portable.test.ts +197 -0
  126. package/src/scenarios/replay-llm-cache-key.test.ts +127 -25
  127. package/src/scenarios/replay-observable-sequence-determinism.test.ts +80 -0
  128. package/src/scenarios/sandbox-capability-gate-respected.test.ts +31 -0
  129. package/src/scenarios/sandbox-memory-cap.test.ts +61 -0
  130. package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +35 -0
  131. package/src/scenarios/sandbox-no-host-env-leak.test.ts +38 -0
  132. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +91 -0
  133. package/src/scenarios/sandbox-no-host-process-escape.test.ts +30 -0
  134. package/src/scenarios/sandbox-no-network-escape.test.ts +49 -0
  135. package/src/scenarios/sandbox-timeout-cap.test.ts +61 -0
  136. package/src/scenarios/search-bm25-roundtrip.test.ts +54 -9
  137. package/src/scenarios/spec-corpus-validity.test.ts +34 -6
  138. package/src/scenarios/sql-transaction-atomicity.test.ts +37 -8
  139. package/src/scenarios/stream-subscribe-from-beginning.test.ts +46 -9
  140. package/src/scenarios/subworkflow-input-mapping.test.ts +146 -10
  141. package/src/scenarios/table-cursor-pagination.test.ts +47 -9
  142. package/src/scenarios/table-schema-enforcement.test.ts +46 -9
  143. package/src/scenarios/vector-knn-roundtrip.test.ts +50 -10
  144. package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * envelope-truncated — RFC 0032 §B.4 + RFC 0033 §B runtime behavior.
3
+ *
4
+ * Capability- + fixture-gated. Drives the conformance `mock` provider via
5
+ * `POST /v1/host/sample/test/mock-ai/program` with a program that returns
6
+ * `stopReason: 'max_tokens'` on attempt 1 then a valid envelope on attempt 2.
7
+ * The host's `dispatchStructured` retry loop MUST: (a) emit exactly one
8
+ * `envelope.truncated` event with `stopReason: 'max_tokens'`; (b) retry with
9
+ * a maxTokens value strictly greater than the original budget per RFC 0033
10
+ * §B `truncationBudgetMultiplier`; (c) NOT inject the corrective schema
11
+ * fragment on the truncation retry (truncation is an output-size problem,
12
+ * not a schema problem); (d) complete normally after attempt 2 succeeds.
13
+ *
14
+ * @see RFCS/0032-envelope-reliability-events.md §B.4
15
+ * @see RFCS/0033-envelope-completion-contract.md §B
16
+ * @see schemas/run-event-payloads.schema.json §envelopeTruncated
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+ import { pollUntilTerminal } from '../lib/polling.js';
22
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
23
+
24
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
25
+ const FIXTURE = 'conformance-envelope-truncated';
26
+ const NODE_ID = 'structured-call';
27
+
28
+ interface RunEvent {
29
+ type: string;
30
+ payload?: Record<string, unknown>;
31
+ nodeId?: string;
32
+ sequence: number;
33
+ }
34
+
35
+ async function programMock(program: Array<Record<string, unknown>>): Promise<{ status: number }> {
36
+ const res = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId: NODE_ID, program });
37
+ return { status: res.status };
38
+ }
39
+
40
+ async function startRunAndRead(): Promise<{ events: RunEvent[]; terminal: unknown } | null> {
41
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
42
+ if (create.status !== 201) return null;
43
+ const runId = (create.json as { runId: string }).runId;
44
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
45
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
46
+ if (eventsRes.status !== 200) return null;
47
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []) as RunEvent[];
48
+ return { events, terminal };
49
+ }
50
+
51
+ async function lastBudget(): Promise<number | null> {
52
+ const res = await driver.get(`/v1/host/sample/test/mock-ai/last-dispatch-budget?nodeId=${encodeURIComponent(NODE_ID)}`);
53
+ if (res.status !== 200) return null;
54
+ return (res.json as { maxTokens?: number | null }).maxTokens ?? null;
55
+ }
56
+
57
+ describe.skipIf(HTTP_SKIP)('envelope-truncated: runtime behavior (RFC 0032 §B.4 + RFC 0033 §B)', () => {
58
+ it('when mock returns stopReason: max_tokens on attempt 1, exactly one envelope.truncated event fires with stopReason: max_tokens', async () => {
59
+ if (!isFixtureAdvertised(FIXTURE)) return;
60
+ const seed = await programMock([
61
+ { stopReason: 'max_tokens', content: '{"valid":' },
62
+ { stopReason: 'end_turn', content: '{"valid":true}' },
63
+ ]);
64
+ if (seed.status === 404) return;
65
+ expect(seed.status).toBe(200);
66
+
67
+ const result = await startRunAndRead();
68
+ if (result === null) return;
69
+ const truncated = result.events.filter((e) => e.type === 'envelope.truncated');
70
+ expect(
71
+ truncated.length,
72
+ driver.describe(
73
+ 'RFCS/0032-envelope-reliability-events.md §B.4',
74
+ 'exactly one envelope.truncated event MUST fire when the provider returns finishReason corresponding to truncation',
75
+ ),
76
+ ).toBe(1);
77
+ expect(truncated[0]!.payload?.stopReason).toBe('max_tokens');
78
+ });
79
+
80
+ it('payload includes nodeId + provider + model + partialPayloadAvailable boolean', async () => {
81
+ if (!isFixtureAdvertised(FIXTURE)) return;
82
+ const seed = await programMock([
83
+ { stopReason: 'max_tokens', content: '{"partial' },
84
+ { stopReason: 'end_turn', content: '{"valid":true}' },
85
+ ]);
86
+ if (seed.status === 404) return;
87
+
88
+ const result = await startRunAndRead();
89
+ if (result === null) return;
90
+ const truncated = result.events.find((e) => e.type === 'envelope.truncated');
91
+ expect(truncated).toBeDefined();
92
+ const payload = truncated!.payload ?? {};
93
+ expect(payload.nodeId).toBe(NODE_ID);
94
+ expect(payload.provider).toBe('mock');
95
+ expect(typeof payload.model).toBe('string');
96
+ expect(typeof payload.partialPayloadAvailable).toBe('boolean');
97
+ });
98
+
99
+ it('retry attempt receives a maxTokens value strictly greater than the previous attempt (RFC 0033 §B truncationBudgetMultiplier)', async () => {
100
+ if (!isFixtureAdvertised(FIXTURE)) return;
101
+ const seed = await programMock([
102
+ { stopReason: 'max_tokens', content: '{"partial' },
103
+ { stopReason: 'end_turn', content: '{"valid":true}' },
104
+ ]);
105
+ if (seed.status === 404) return;
106
+
107
+ const result = await startRunAndRead();
108
+ if (result === null) return;
109
+ // After the run, the mock's most-recent budget is the SECOND (retry)
110
+ // attempt's maxTokens. Per RFC 0033 §B, this MUST exceed the fixture's
111
+ // initial maxTokens (50). The host's default multiplier is 2 — so the
112
+ // retry should see 100.
113
+ const budget = await lastBudget();
114
+ if (budget === null) return; // host doesn't expose the seam
115
+ expect(
116
+ budget,
117
+ driver.describe(
118
+ 'RFCS/0033-envelope-completion-contract.md §B',
119
+ 'truncation retry MUST issue with a strictly-increased maxTokens budget (host multiplies by capabilities.envelopes.reliability.completion.truncationBudgetMultiplier)',
120
+ ),
121
+ ).toBeGreaterThan(50);
122
+ });
123
+
124
+ it('run terminates `completed` after the second attempt succeeds', async () => {
125
+ if (!isFixtureAdvertised(FIXTURE)) return;
126
+ const seed = await programMock([
127
+ { stopReason: 'max_tokens', content: '{"partial' },
128
+ { stopReason: 'end_turn', content: '{"valid":true}' },
129
+ ]);
130
+ if (seed.status === 404) return;
131
+
132
+ const result = await startRunAndRead();
133
+ if (result === null) return;
134
+ expect((result.terminal as { status?: string }).status).toBe('completed');
135
+ });
136
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * envelope-truncation-cap-exhaustion — RFC 0033 §B DoS-bound assertion.
3
+ *
4
+ * Capability- + fixture-gated. Drives the conformance `mock` provider via
5
+ * `POST /v1/host/sample/test/mock-ai/program` with a program that returns
6
+ * `stopReason: 'max_tokens'` on EVERY attempt. The host's `dispatchStructured`
7
+ * retry loop MUST: (a) emit `envelope.truncated` per attempt (or at least the
8
+ * first); (b) double the budget each retry per RFC 0033 §B; (c) exhaust
9
+ * retries after `maxRetryAttempts`; (d) emit exactly one
10
+ * `envelope.retry.exhausted` with `finalReason: 'truncation'`; (e) fail the
11
+ * node with `error.code: 'envelope_truncation_unrecoverable'` per RFC 0033 §F;
12
+ * (f) NOT exceed `maxRetryAttempts` total LLM calls — DoS-bound assertion.
13
+ *
14
+ * @see RFCS/0033-envelope-completion-contract.md §B + §F
15
+ * @see spec/v1/rest-endpoints.md §"Common error codes" — envelope_truncation_unrecoverable
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+ import { pollUntilTerminal } from '../lib/polling.js';
21
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
22
+
23
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
24
+ const FIXTURE = 'conformance-envelope-truncation-cap-exhaustion';
25
+ const NODE_ID = 'structured-call';
26
+
27
+ interface RunEvent {
28
+ type: string;
29
+ payload?: Record<string, unknown>;
30
+ nodeId?: string;
31
+ sequence: number;
32
+ }
33
+
34
+ async function programMock(program: Array<Record<string, unknown>>): Promise<{ status: number }> {
35
+ const res = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId: NODE_ID, program });
36
+ return { status: res.status };
37
+ }
38
+
39
+ async function startRunAndRead(): Promise<{ events: RunEvent[]; terminal: unknown } | null> {
40
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
41
+ if (create.status !== 201) return null;
42
+ const runId = (create.json as { runId: string }).runId;
43
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
44
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
45
+ if (eventsRes.status !== 200) return null;
46
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []) as RunEvent[];
47
+ return { events, terminal };
48
+ }
49
+
50
+ // Seeded program: 16 truncation entries. The retry loop will only consume
51
+ // up to maxRetryAttempts; any unused entries are wasted — bound is
52
+ // confirmed by the events[] count, not by program exhaustion behavior.
53
+ const PERPETUAL_TRUNCATION = Array.from({ length: 16 }, () => ({
54
+ stopReason: 'max_tokens' as const,
55
+ content: '{"partial',
56
+ }));
57
+
58
+ describe.skipIf(HTTP_SKIP)('envelope-truncation-cap-exhaustion: DoS-bound retry budget (RFC 0033 §B + §F)', () => {
59
+ it('perpetual truncation → emits exactly one envelope.retry.exhausted with finalReason: "truncation"', async () => {
60
+ if (!isFixtureAdvertised(FIXTURE)) return;
61
+ const seed = await programMock(PERPETUAL_TRUNCATION);
62
+ if (seed.status === 404) return;
63
+ expect(seed.status).toBe(200);
64
+
65
+ const result = await startRunAndRead();
66
+ if (result === null) return;
67
+ const exhausted = result.events.filter((e) => e.type === 'envelope.retry.exhausted');
68
+ expect(
69
+ exhausted.length,
70
+ driver.describe(
71
+ 'RFCS/0032-envelope-reliability-events.md §B.2',
72
+ 'exactly one envelope.retry.exhausted event MUST fire when the truncation-retry budget is exhausted',
73
+ ),
74
+ ).toBe(1);
75
+ expect(
76
+ exhausted[0]!.payload?.finalReason,
77
+ driver.describe(
78
+ 'RFCS/0033-envelope-completion-contract.md §B',
79
+ 'finalReason MUST be "truncation" when the host exhausts truncation retries (distinguished from schema-violation per RFC 0033 §A)',
80
+ ),
81
+ ).toBe('truncation');
82
+ });
83
+
84
+ it('node fails with RunSnapshot.error.code: "envelope_truncation_unrecoverable" per RFC 0033 §F', async () => {
85
+ if (!isFixtureAdvertised(FIXTURE)) return;
86
+ const seed = await programMock(PERPETUAL_TRUNCATION);
87
+ if (seed.status === 404) return;
88
+
89
+ const result = await startRunAndRead();
90
+ if (result === null) return;
91
+ const code = (result.terminal as { error?: { code?: string } }).error?.code;
92
+ expect(
93
+ code,
94
+ driver.describe(
95
+ 'RFCS/0033-envelope-completion-contract.md §F',
96
+ 'truncation-retry-exhaustion MUST surface as RunSnapshot.error.code = envelope_truncation_unrecoverable (distinct from envelope_invalid which surfaces schema-violation-exhaustion)',
97
+ ),
98
+ ).toBe('envelope_truncation_unrecoverable');
99
+ });
100
+
101
+ it('total LLM calls bounded by maxRetryAttempts (DoS-bound — no infinite loop)', async () => {
102
+ if (!isFixtureAdvertised(FIXTURE)) return;
103
+ const seed = await programMock(PERPETUAL_TRUNCATION);
104
+ if (seed.status === 404) return;
105
+
106
+ const result = await startRunAndRead();
107
+ if (result === null) return;
108
+ // Count envelope.truncated events as a proxy for LLM-call count
109
+ // (each truncated attempt emits one).
110
+ const truncatedCount = result.events.filter((e) => e.type === 'envelope.truncated').length;
111
+ expect(
112
+ truncatedCount,
113
+ driver.describe(
114
+ 'RFCS/0033-envelope-completion-contract.md §B',
115
+ 'truncation-retry count MUST be bounded — host cannot loop indefinitely doubling budget; expected upper-bound matches advertised maxRetryAttempts',
116
+ ),
117
+ ).toBeLessThanOrEqual(16);
118
+ // The host advertises maxRetryAttempts (default 3) — at most 3 LLM calls
119
+ // = 3 envelope.truncated events. Allow a generous upper bound here to
120
+ // accommodate hosts with larger configured retry budgets, but assert
121
+ // strictly that it's finite.
122
+ expect(truncatedCount).toBeGreaterThan(0);
123
+ });
124
+
125
+ it('envelope.retry.exhausted is emitted BEFORE node.failed (cause precedes effect)', async () => {
126
+ if (!isFixtureAdvertised(FIXTURE)) return;
127
+ const seed = await programMock(PERPETUAL_TRUNCATION);
128
+ if (seed.status === 404) return;
129
+
130
+ const result = await startRunAndRead();
131
+ if (result === null) return;
132
+ const exhaustedIdx = result.events.findIndex((e) => e.type === 'envelope.retry.exhausted');
133
+ const failedIdx = result.events.findIndex((e) => e.type === 'node.failed');
134
+ expect(exhaustedIdx).toBeGreaterThanOrEqual(0);
135
+ expect(failedIdx).toBeGreaterThanOrEqual(0);
136
+ expect(
137
+ exhaustedIdx < failedIdx,
138
+ driver.describe(
139
+ 'RFCS/0032-envelope-reliability-events.md §B.2',
140
+ 'envelope.retry.exhausted MUST be emitted BEFORE node.failed',
141
+ ),
142
+ ).toBe(true);
143
+ });
144
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * envelope-variant-discriminator-static — RFC 0031 §A static schema-walker.
3
+ *
4
+ * Asserts (always-on):
5
+ * 1. For every envelope payload schema in `schemas/envelopes/*.schema.json`,
6
+ * `oneOf` MUST NOT appear at any nesting depth (Gemini silently drops
7
+ * `oneOf`, producing a looser-than-declared schema — silent correctness bug).
8
+ * 2. Where `anyOf` is present in a payload schema, every branch MUST declare
9
+ * a single-string-`enum` discriminator property in `required` per RFC 0031 §A.
10
+ *
11
+ * Capability-gated extension (when `OPENWOP_BASE_URL` is set AND
12
+ * `capabilities.supportedEnvelopes` is non-empty): same checks applied
13
+ * to the host's full envelope catalog where schemas can be resolved locally.
14
+ *
15
+ * @see RFCS/0031-envelope-variants-and-model-capabilities.md §A
16
+ * @see spec/v1/ai-envelope.md §"Variant payload discrimination (normative)"
17
+ * @see spec/v1/structured-output-subset.md (Tier-1 portability rationale)
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { SCHEMAS_DIR } from '../lib/paths.js';
24
+
25
+ interface DiscriminatorViolation {
26
+ path: string;
27
+ rule: string;
28
+ detail?: string;
29
+ }
30
+
31
+ function loadSchema(p: string): Record<string, unknown> {
32
+ return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>;
33
+ }
34
+
35
+ function walkForOneOf(schema: unknown, path: string, out: DiscriminatorViolation[]): void {
36
+ if (!schema || typeof schema !== 'object') return;
37
+ if (Array.isArray(schema)) {
38
+ schema.forEach((item, i) => walkForOneOf(item, `${path}/${i}`, out));
39
+ return;
40
+ }
41
+ const obj = schema as Record<string, unknown>;
42
+ if ('oneOf' in obj) {
43
+ out.push({
44
+ path,
45
+ rule: 'oneOf-forbidden',
46
+ detail: 'use `anyOf` with single-string-enum discriminator per RFC 0031 §A',
47
+ });
48
+ }
49
+ for (const key of Object.keys(obj)) {
50
+ walkForOneOf(obj[key], `${path}/${key}`, out);
51
+ }
52
+ }
53
+
54
+ interface AnyOfBranchValidation {
55
+ ok: boolean;
56
+ rule?: string;
57
+ detail?: string;
58
+ }
59
+
60
+ function validateAnyOfBranch(branch: Record<string, unknown>): AnyOfBranchValidation {
61
+ // A branch passes the discriminator rule if at least one of its `required` properties
62
+ // declares `type: string` + `enum` containing exactly one value.
63
+ const required = (branch.required as string[] | undefined) ?? [];
64
+ const properties = (branch.properties as Record<string, Record<string, unknown>> | undefined) ?? {};
65
+ for (const propName of required) {
66
+ const prop = properties[propName];
67
+ if (!prop) continue;
68
+ if (prop.type === 'string' && Array.isArray(prop.enum) && prop.enum.length === 1) {
69
+ return { ok: true };
70
+ }
71
+ }
72
+ // The branch may also be a `$ref` to a defined shape; ref-resolution is
73
+ // out of scope for this static walker — flag with a note rather than a hard fail.
74
+ if ('$ref' in branch) {
75
+ return { ok: true, detail: 'branch is a $ref; discriminator presence assumed in referenced $def' };
76
+ }
77
+ return {
78
+ ok: false,
79
+ rule: 'anyOf-branch-missing-discriminator',
80
+ detail: 'branch MUST declare a single-string-enum discriminator property in `required` per RFC 0031 §A',
81
+ };
82
+ }
83
+
84
+ function walkForAnyOfDiscriminators(
85
+ schema: unknown,
86
+ path: string,
87
+ out: DiscriminatorViolation[],
88
+ ): void {
89
+ if (!schema || typeof schema !== 'object') return;
90
+ if (Array.isArray(schema)) {
91
+ schema.forEach((item, i) => walkForAnyOfDiscriminators(item, `${path}/${i}`, out));
92
+ return;
93
+ }
94
+ const obj = schema as Record<string, unknown>;
95
+ if (Array.isArray(obj.anyOf)) {
96
+ obj.anyOf.forEach((branch, i) => {
97
+ const result = validateAnyOfBranch(branch as Record<string, unknown>);
98
+ if (!result.ok) {
99
+ out.push({
100
+ path: `${path}/anyOf/${i}`,
101
+ rule: result.rule ?? 'unknown',
102
+ ...(result.detail !== undefined ? { detail: result.detail } : {}),
103
+ });
104
+ }
105
+ });
106
+ }
107
+ for (const key of Object.keys(obj)) {
108
+ if (key === 'anyOf') continue; // handled above
109
+ walkForAnyOfDiscriminators(obj[key], `${path}/${key}`, out);
110
+ }
111
+ }
112
+
113
+ function listLocalEnvelopeSchemas(): { kind: string; path: string }[] {
114
+ const dir = join(SCHEMAS_DIR, 'envelopes');
115
+ if (!existsSync(dir)) return [];
116
+ return readdirSync(dir)
117
+ .filter((f) => f.endsWith('.schema.json'))
118
+ .map((f) => ({ kind: f.replace(/\.schema\.json$/, ''), path: join(dir, f) }));
119
+ }
120
+
121
+ describe('envelope-variant-discriminator-static (RFC 0031 §A)', () => {
122
+ const schemas = listLocalEnvelopeSchemas();
123
+
124
+ it('local envelope-schema directory is discoverable', () => {
125
+ expect(
126
+ schemas.length,
127
+ 'schemas/envelopes/*.schema.json MUST contain at least the four universal-kind schemas',
128
+ ).toBeGreaterThanOrEqual(4);
129
+ });
130
+
131
+ for (const { kind, path } of schemas) {
132
+ it(`${kind}.schema.json MUST NOT contain \`oneOf\` at any nesting depth (Gemini silently drops it)`, () => {
133
+ const schema = loadSchema(path);
134
+ const violations: DiscriminatorViolation[] = [];
135
+ walkForOneOf(schema, '#', violations);
136
+ expect(
137
+ violations,
138
+ `${kind}.schema.json contains \`oneOf\` — REFORMULATE as \`anyOf\` + single-string-enum discriminator per RFC 0031 §A. Violations: ${JSON.stringify(violations, null, 2)}`,
139
+ ).toEqual([]);
140
+ });
141
+
142
+ it(`${kind}.schema.json: every \`anyOf\` branch declares a single-string-enum discriminator in \`required\``, () => {
143
+ const schema = loadSchema(path);
144
+ const violations: DiscriminatorViolation[] = [];
145
+ walkForAnyOfDiscriminators(schema, '#', violations);
146
+ expect(
147
+ violations,
148
+ `${kind}.schema.json \`anyOf\` discriminator violations: ${JSON.stringify(violations, null, 2)}`,
149
+ ).toEqual([]);
150
+ });
151
+ }
152
+ });
@@ -18,7 +18,7 @@
18
18
  * @see RFCS/0003-fixture-gating.md
19
19
  */
20
20
 
21
- import { describe, it, expect, beforeEach } from 'vitest';
21
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
22
  import {
23
23
  isFixtureAdvertised,
24
24
  setAdvertisedFixtures,
@@ -26,6 +26,7 @@ import {
26
26
  isFixtureCacheReady,
27
27
  __resetForTests,
28
28
  } from '../lib/fixtures.js';
29
+ import { isScenarioOptedOut } from '../lib/env.js';
29
30
 
30
31
  beforeEach(() => {
31
32
  __resetForTests();
@@ -135,3 +136,140 @@ describe('fixtures: __resetForTests', () => {
135
136
  expect(getAdvertisedFixtures()).toBe(null);
136
137
  });
137
138
  });
139
+
140
+ describe('fixtures: OPENWOP_OPTED_OUT_FIXTURES env filtering', () => {
141
+ // The opt-out predicate is re-read inside setAdvertisedFixtures() on
142
+ // every call, so mutating process.env between cases (and re-calling
143
+ // setAdvertisedFixtures) re-evaluates the parse. afterEach restores
144
+ // the original env so other suites aren't affected.
145
+ const ORIGINAL = process.env.OPENWOP_OPTED_OUT_FIXTURES;
146
+ afterEach(() => {
147
+ if (ORIGINAL === undefined) delete process.env.OPENWOP_OPTED_OUT_FIXTURES;
148
+ else process.env.OPENWOP_OPTED_OUT_FIXTURES = ORIGINAL;
149
+ });
150
+
151
+ it('exact id is filtered out of the advertised set', () => {
152
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = 'conformance-dispatch-input-mapping';
153
+ setAdvertisedFixtures({
154
+ fixtures: ['conformance-noop', 'conformance-dispatch-input-mapping'],
155
+ });
156
+ expect(isFixtureAdvertised('conformance-noop')).toBe(true);
157
+ expect(isFixtureAdvertised('conformance-dispatch-input-mapping')).toBe(false);
158
+ });
159
+
160
+ it('trailing-* glob filters every matching id', () => {
161
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = 'conformance-dispatch-*';
162
+ setAdvertisedFixtures({
163
+ fixtures: [
164
+ 'conformance-noop',
165
+ 'conformance-dispatch-input-mapping',
166
+ 'conformance-dispatch-output-mapping',
167
+ 'conformance-dispatch-cross-worker-handoff',
168
+ ],
169
+ });
170
+ expect(isFixtureAdvertised('conformance-noop')).toBe(true);
171
+ expect(isFixtureAdvertised('conformance-dispatch-input-mapping')).toBe(false);
172
+ expect(isFixtureAdvertised('conformance-dispatch-output-mapping')).toBe(false);
173
+ expect(isFixtureAdvertised('conformance-dispatch-cross-worker-handoff')).toBe(false);
174
+ });
175
+
176
+ it('exact + glob entries mix in one env value', () => {
177
+ process.env.OPENWOP_OPTED_OUT_FIXTURES =
178
+ 'conformance-dispatch-*,conformance-subworkflow-input-mapping';
179
+ setAdvertisedFixtures({
180
+ fixtures: [
181
+ 'conformance-noop',
182
+ 'conformance-dispatch-input-mapping',
183
+ 'conformance-subworkflow-input-mapping',
184
+ 'conformance-subworkflow-parent',
185
+ ],
186
+ });
187
+ expect(isFixtureAdvertised('conformance-noop')).toBe(true);
188
+ expect(isFixtureAdvertised('conformance-dispatch-input-mapping')).toBe(false);
189
+ expect(isFixtureAdvertised('conformance-subworkflow-input-mapping')).toBe(false);
190
+ // subworkflow-parent is NOT subworkflow-input-mapping — exact match required.
191
+ expect(isFixtureAdvertised('conformance-subworkflow-parent')).toBe(true);
192
+ });
193
+
194
+ it('non-matching opt-out entries leave the advertised set intact', () => {
195
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = 'conformance-nonexistent';
196
+ setAdvertisedFixtures({ fixtures: ['conformance-noop'] });
197
+ expect(isFixtureAdvertised('conformance-noop')).toBe(true);
198
+ expect(getAdvertisedFixtures()?.size).toBe(1);
199
+ });
200
+
201
+ it('empty / whitespace-only entries are ignored', () => {
202
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = ', ,conformance-noop, ,';
203
+ setAdvertisedFixtures({ fixtures: ['conformance-noop', 'conformance-delay'] });
204
+ expect(isFixtureAdvertised('conformance-noop')).toBe(false);
205
+ expect(isFixtureAdvertised('conformance-delay')).toBe(true);
206
+ });
207
+
208
+ it('unset env behaves identically to no filtering', () => {
209
+ delete process.env.OPENWOP_OPTED_OUT_FIXTURES;
210
+ setAdvertisedFixtures({ fixtures: ['conformance-noop', 'conformance-delay'] });
211
+ expect(getAdvertisedFixtures()?.size).toBe(2);
212
+ });
213
+
214
+ it('whitespace-only env behaves identically to unset', () => {
215
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = ' ';
216
+ setAdvertisedFixtures({ fixtures: ['conformance-noop'] });
217
+ expect(isFixtureAdvertised('conformance-noop')).toBe(true);
218
+ });
219
+
220
+ it('env is re-read on each setAdvertisedFixtures call (no memoization)', () => {
221
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = 'conformance-noop';
222
+ setAdvertisedFixtures({ fixtures: ['conformance-noop', 'conformance-delay'] });
223
+ expect(isFixtureAdvertised('conformance-noop')).toBe(false);
224
+
225
+ // Mutate env and re-set — the new env value MUST take effect.
226
+ process.env.OPENWOP_OPTED_OUT_FIXTURES = 'conformance-delay';
227
+ setAdvertisedFixtures({ fixtures: ['conformance-noop', 'conformance-delay'] });
228
+ expect(isFixtureAdvertised('conformance-noop')).toBe(true);
229
+ expect(isFixtureAdvertised('conformance-delay')).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe('env: OPENWOP_OPTED_OUT_SCENARIOS predicate', () => {
234
+ const ORIGINAL = process.env.OPENWOP_OPTED_OUT_SCENARIOS;
235
+ afterEach(() => {
236
+ if (ORIGINAL === undefined) delete process.env.OPENWOP_OPTED_OUT_SCENARIOS;
237
+ else process.env.OPENWOP_OPTED_OUT_SCENARIOS = ORIGINAL;
238
+ });
239
+
240
+ it('unset env → every scenario id returns false', () => {
241
+ delete process.env.OPENWOP_OPTED_OUT_SCENARIOS;
242
+ expect(isScenarioOptedOut('otel-trace-propagation-subworkflow')).toBe(false);
243
+ expect(isScenarioOptedOut('any-scenario')).toBe(false);
244
+ });
245
+
246
+ it('exact scenario id match returns true', () => {
247
+ process.env.OPENWOP_OPTED_OUT_SCENARIOS = 'otel-trace-propagation-subworkflow';
248
+ expect(isScenarioOptedOut('otel-trace-propagation-subworkflow')).toBe(true);
249
+ expect(isScenarioOptedOut('otel-trace-propagation')).toBe(false);
250
+ });
251
+
252
+ it('CSV with multiple ids matches each entry exactly', () => {
253
+ process.env.OPENWOP_OPTED_OUT_SCENARIOS = 'scenario-a,scenario-b,scenario-c';
254
+ expect(isScenarioOptedOut('scenario-a')).toBe(true);
255
+ expect(isScenarioOptedOut('scenario-b')).toBe(true);
256
+ expect(isScenarioOptedOut('scenario-c')).toBe(true);
257
+ expect(isScenarioOptedOut('scenario-d')).toBe(false);
258
+ });
259
+
260
+ it('whitespace around entries is tolerated', () => {
261
+ process.env.OPENWOP_OPTED_OUT_SCENARIOS = ' scenario-a , scenario-b ';
262
+ expect(isScenarioOptedOut('scenario-a')).toBe(true);
263
+ expect(isScenarioOptedOut('scenario-b')).toBe(true);
264
+ });
265
+
266
+ it('env is re-read on each call (no memoization)', () => {
267
+ process.env.OPENWOP_OPTED_OUT_SCENARIOS = 'scenario-a';
268
+ expect(isScenarioOptedOut('scenario-a')).toBe(true);
269
+ expect(isScenarioOptedOut('scenario-b')).toBe(false);
270
+
271
+ process.env.OPENWOP_OPTED_OUT_SCENARIOS = 'scenario-b';
272
+ expect(isScenarioOptedOut('scenario-a')).toBe(false);
273
+ expect(isScenarioOptedOut('scenario-b')).toBe(true);
274
+ });
275
+ });