@openwop/openwop-conformance 1.3.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 (118) hide show
  1. package/CHANGELOG.md +91 -1
  2. package/README.md +3 -2
  3. package/api/asyncapi.yaml +8 -0
  4. package/api/openapi.yaml +371 -1
  5. package/coverage.md +25 -5
  6. package/fixtures/conformance-envelope-nl-to-format-engaged.json +41 -0
  7. package/fixtures/conformance-envelope-recovery-applied.json +39 -0
  8. package/fixtures/conformance-envelope-refusal.json +38 -0
  9. package/fixtures/conformance-envelope-retry-attempted.json +39 -0
  10. package/fixtures/conformance-envelope-retry-exhausted.json +38 -0
  11. package/fixtures/conformance-envelope-truncated.json +39 -0
  12. package/fixtures/conformance-envelope-truncation-cap-exhaustion.json +39 -0
  13. package/fixtures/conformance-model-capability-insufficient.json +25 -0
  14. package/fixtures/conformance-multi-agent-confidence-escalation.json +49 -0
  15. package/fixtures/conformance-multi-agent-handoff-child.json +27 -0
  16. package/fixtures/conformance-multi-agent-handoff.json +49 -0
  17. package/fixtures/conformance-prompt-all-four-kinds.json +39 -0
  18. package/fixtures/conformance-prompt-end-to-end.json +33 -0
  19. package/fixtures/conformance-subworkflow-mid-run-mutation-child.json +31 -0
  20. package/fixtures/conformance-subworkflow-mid-run-mutation.json +33 -0
  21. package/fixtures/openwop-smoke-cost-emit.json +37 -0
  22. package/fixtures/prompt-templates/conformance-prompt-few-shot-2.json +14 -0
  23. package/fixtures/prompt-templates/conformance-prompt-few-shot.json +14 -0
  24. package/fixtures/prompt-templates/conformance-prompt-schema-hint.json +14 -0
  25. package/fixtures/prompt-templates/conformance-prompt-secret-redaction.json +23 -0
  26. package/fixtures/prompt-templates/conformance-prompt-trust-marker.json +23 -0
  27. package/fixtures/prompt-templates/conformance-prompt-writer-system.json +15 -0
  28. package/fixtures/prompt-templates/conformance-prompt-writer-user.json +15 -0
  29. package/fixtures.md +39 -0
  30. package/package.json +1 -1
  31. package/schemas/README.md +5 -0
  32. package/schemas/agent-manifest.schema.json +16 -0
  33. package/schemas/capabilities.schema.json +375 -1
  34. package/schemas/envelopes/clarification.request.schema.json +9 -0
  35. package/schemas/envelopes/error.schema.json +4 -0
  36. package/schemas/envelopes/schema.request.schema.json +4 -0
  37. package/schemas/envelopes/schema.response.schema.json +1 -1
  38. package/schemas/node-pack-manifest.schema.json +28 -0
  39. package/schemas/orchestrator-decision.schema.json +12 -0
  40. package/schemas/prompt-kind.schema.json +8 -0
  41. package/schemas/prompt-pack-manifest.schema.json +80 -0
  42. package/schemas/prompt-ref.schema.json +40 -0
  43. package/schemas/prompt-template.schema.json +149 -0
  44. package/schemas/registry-version-manifest.schema.json +5 -0
  45. package/schemas/run-ancestry-response.schema.json +54 -0
  46. package/schemas/run-event-payloads.schema.json +479 -11
  47. package/schemas/run-event.schema.json +15 -1
  48. package/schemas/run-snapshot.schema.json +3 -2
  49. package/schemas/workflow-definition.schema.json +19 -1
  50. package/src/lib/llm-cache-key-recipe.ts +68 -0
  51. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +104 -13
  52. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +32 -15
  53. package/src/scenarios/aiEnvelope.redaction.test.ts +6 -5
  54. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +5 -5
  55. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +211 -12
  56. package/src/scenarios/aiEnvelope.universalKinds.test.ts +7 -7
  57. package/src/scenarios/blob-presign-expiry.test.ts +7 -7
  58. package/src/scenarios/cache-ttl-expiry.test.ts +6 -6
  59. package/src/scenarios/cost-attribution.test.ts +124 -11
  60. package/src/scenarios/cross-engine-append-ordering.test.ts +99 -0
  61. package/src/scenarios/cross-host-ancestry-endpoint.test.ts +136 -0
  62. package/src/scenarios/cross-host-causation-shape.test.ts +117 -0
  63. package/src/scenarios/cross-host-traceparent-propagation.test.ts +60 -0
  64. package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +223 -0
  65. package/src/scenarios/envelope-nl-to-format-engaged.test.ts +152 -0
  66. package/src/scenarios/envelope-reasoning-secret-redaction.test.ts +343 -0
  67. package/src/scenarios/envelope-reasoning-shape.test.ts +190 -0
  68. package/src/scenarios/envelope-recovery-applied.test.ts +229 -0
  69. package/src/scenarios/envelope-refusal-shape.test.ts +289 -0
  70. package/src/scenarios/envelope-retry-attempted.test.ts +258 -0
  71. package/src/scenarios/envelope-retry-exhausted.test.ts +168 -0
  72. package/src/scenarios/envelope-tier-one-subset-static.test.ts +229 -0
  73. package/src/scenarios/envelope-truncated.test.ts +136 -0
  74. package/src/scenarios/envelope-truncation-cap-exhaustion.test.ts +144 -0
  75. package/src/scenarios/envelope-variant-discriminator-static.test.ts +152 -0
  76. package/src/scenarios/fixtures-valid.test.ts +123 -15
  77. package/src/scenarios/kv-ttl-expiry.test.ts +7 -7
  78. package/src/scenarios/model-capability-insufficient.test.ts +221 -0
  79. package/src/scenarios/model-capability-substituted.test.ts +203 -0
  80. package/src/scenarios/multi-agent-confidence-escalation.test.ts +164 -0
  81. package/src/scenarios/multi-agent-handoff-state-machine.test.ts +167 -0
  82. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +124 -0
  83. package/src/scenarios/multi-region-idempotency.test.ts +58 -0
  84. package/src/scenarios/node-module-required-capabilities-shape.test.ts +185 -0
  85. package/src/scenarios/prompt-all-four-kinds-events.test.ts +198 -0
  86. package/src/scenarios/prompt-composed-secret-redaction.test.ts +178 -0
  87. package/src/scenarios/prompt-composed-trust-marker.test.ts +165 -0
  88. package/src/scenarios/prompt-end-to-end-events.test.ts +202 -0
  89. package/src/scenarios/prompt-list-and-fetch.test.ts +207 -0
  90. package/src/scenarios/prompt-mutable-lifecycle.test.ts +216 -0
  91. package/src/scenarios/prompt-pack-install.test.ts +187 -0
  92. package/src/scenarios/prompt-render-deterministic.test.ts +240 -0
  93. package/src/scenarios/prompt-resolution-chain-agent-intrinsic.test.ts +140 -0
  94. package/src/scenarios/prompt-resolution-chain-fallback-cascade.test.ts +172 -0
  95. package/src/scenarios/prompt-resolution-chain-node-wins.test.ts +144 -0
  96. package/src/scenarios/prompt-template-shape.test.ts +359 -0
  97. package/src/scenarios/queue-ack-nack-dlq.test.ts +7 -7
  98. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +7 -7
  99. package/src/scenarios/replay-divergence-at-refusal.test.ts +134 -0
  100. package/src/scenarios/replay-llm-cache-key-portable.test.ts +197 -0
  101. package/src/scenarios/replay-llm-cache-key.test.ts +1 -40
  102. package/src/scenarios/replay-observable-sequence-determinism.test.ts +80 -0
  103. package/src/scenarios/sandbox-capability-gate-respected.test.ts +31 -0
  104. package/src/scenarios/sandbox-memory-cap.test.ts +61 -0
  105. package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +35 -0
  106. package/src/scenarios/sandbox-no-host-env-leak.test.ts +38 -0
  107. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +91 -0
  108. package/src/scenarios/sandbox-no-host-process-escape.test.ts +30 -0
  109. package/src/scenarios/sandbox-no-network-escape.test.ts +49 -0
  110. package/src/scenarios/sandbox-timeout-cap.test.ts +61 -0
  111. package/src/scenarios/search-bm25-roundtrip.test.ts +7 -7
  112. package/src/scenarios/spec-corpus-validity.test.ts +34 -6
  113. package/src/scenarios/sql-transaction-atomicity.test.ts +6 -6
  114. package/src/scenarios/stream-subscribe-from-beginning.test.ts +7 -7
  115. package/src/scenarios/subworkflow-input-mapping.test.ts +70 -4
  116. package/src/scenarios/table-cursor-pagination.test.ts +7 -7
  117. package/src/scenarios/table-schema-enforcement.test.ts +7 -7
  118. package/src/scenarios/vector-knn-roundtrip.test.ts +7 -7
@@ -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
+ });
@@ -16,20 +16,30 @@ import { FIXTURES_DIR, SCHEMAS_DIR } from '../lib/paths.js';
16
16
  // checkouts (schemas one level above the conformance package) and the
17
17
  // published tarball (schemas vendored at the package root by `prepack`).
18
18
  const PACK_MANIFEST_FIXTURES_DIR = join(FIXTURES_DIR, 'pack-manifests');
19
+ const PROMPT_TEMPLATE_FIXTURES_DIR = join(FIXTURES_DIR, 'prompt-templates');
19
20
  const SCHEMA_PATH = join(SCHEMAS_DIR, 'workflow-definition.schema.json');
20
21
  const PACK_MANIFEST_SCHEMA_PATH = join(SCHEMAS_DIR, 'node-pack-manifest.schema.json');
22
+ const PROMPT_TEMPLATE_SCHEMA_PATH = join(SCHEMAS_DIR, 'prompt-template.schema.json');
21
23
 
22
24
  describe('fixtures: workflow-definition schema validity', () => {
23
25
  const ajv = new Ajv2020({ allErrors: true, strict: false });
24
26
  addFormats(ajv);
25
- // Pre-load the agent-ref peer schema so cross-schema `$ref` in
26
- // workflow-definition (Phase 1 — `WorkflowNode.agent`) resolves.
27
- // The relative file-name `agent-ref.schema.json` is how
28
- // workflow-definition references it; register under that name so
29
- // Ajv's $ref resolver finds it.
30
- const agentRefPath = join(SCHEMAS_DIR, 'agent-ref.schema.json');
31
- const agentRefSchema = JSON.parse(readFileSync(agentRefPath, 'utf8'));
27
+ // Pre-load peer schemas that workflow-definition cross-`$ref`s:
28
+ // - agent-ref.schema.json — `WorkflowNode.agent` (Phase 1 multi-agent)
29
+ // - prompt-ref.schema.json `WorkflowDefinition.defaults.promptRefs.*`
30
+ // (RFC 0029 §B resolution-chain layer 3)
31
+ // - prompt-kind.schema.json transitively referenced by prompt-ref's
32
+ // object form when validating PromptRef variants
33
+ // Register each under both the canonical $id and the relative file
34
+ // name so Ajv resolves either way the host schema spelled the ref.
35
+ const agentRefSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'agent-ref.schema.json'), 'utf8'));
36
+ const promptRefSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'prompt-ref.schema.json'), 'utf8'));
37
+ const promptKindSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'prompt-kind.schema.json'), 'utf8'));
32
38
  ajv.addSchema(agentRefSchema, 'agent-ref.schema.json');
39
+ ajv.addSchema(promptRefSchema, 'prompt-ref.schema.json');
40
+ ajv.addSchema(promptRefSchema, './prompt-ref.schema.json');
41
+ ajv.addSchema(promptKindSchema, 'prompt-kind.schema.json');
42
+ ajv.addSchema(promptKindSchema, './prompt-kind.schema.json');
33
43
  const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
34
44
  const validate = ajv.compile(schema);
35
45
 
@@ -85,14 +95,19 @@ describe('fixtures: node-pack-manifest schema validity', () => {
85
95
  // `private.<host>.*` scope is accepted by the canonical schema).
86
96
  const ajv = new Ajv2020({ allErrors: true, strict: false });
87
97
  addFormats(ajv);
88
- // Pre-load the agent-manifest peer schema so the Phase 2 `agents[]`
89
- // $ref in node-pack-manifest resolves under the same name the
90
- // manifest schema uses.
91
- const agentManifestPath = join(SCHEMAS_DIR, 'agent-manifest.schema.json');
92
- ajv.addSchema(
93
- JSON.parse(readFileSync(agentManifestPath, 'utf8')),
94
- 'agent-manifest.schema.json',
95
- );
98
+ // Pre-load peer schemas. agent-manifest references prompt-ref (RFC 0029
99
+ // §B `AgentManifest.promptOverrides[kind]` + `promptLibraryRef`); prompt-ref
100
+ // transitively references prompt-kind. Register each under both the
101
+ // canonical $id and the relative file name so Ajv resolves either way
102
+ // the consumer schema spelled the ref.
103
+ const agentManifestSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'agent-manifest.schema.json'), 'utf8'));
104
+ const promptRefSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'prompt-ref.schema.json'), 'utf8'));
105
+ const promptKindSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'prompt-kind.schema.json'), 'utf8'));
106
+ ajv.addSchema(agentManifestSchema, 'agent-manifest.schema.json');
107
+ ajv.addSchema(promptRefSchema, 'prompt-ref.schema.json');
108
+ ajv.addSchema(promptRefSchema, './prompt-ref.schema.json');
109
+ ajv.addSchema(promptKindSchema, 'prompt-kind.schema.json');
110
+ ajv.addSchema(promptKindSchema, './prompt-kind.schema.json');
96
111
  const schema = JSON.parse(readFileSync(PACK_MANIFEST_SCHEMA_PATH, 'utf8'));
97
112
  const validate = ajv.compile(schema);
98
113
 
@@ -138,3 +153,96 @@ describe('fixtures: node-pack-manifest schema validity', () => {
138
153
  ).toBeGreaterThan(0);
139
154
  });
140
155
  });
156
+
157
+ describe('fixtures: prompt-template schema validity', () => {
158
+ // PromptTemplate fixtures live in `fixtures/prompt-templates/` per
159
+ // RFC 0027 §A. Like pack manifests, they're schema-level proof points,
160
+ // not seeded into a workflow store. They exist so the conformance
161
+ // suite has canonical positive fixtures for the prompt-template-shape
162
+ // scenario, and so future RFCs (0028 prompt packs, 0029 resolution
163
+ // chain) can reference a stable fixture set.
164
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
165
+ addFormats(ajv);
166
+ // Pre-load prompt-kind so the cross-schema `$ref` in
167
+ // prompt-template.schema.json resolves. The template references
168
+ // prompt-kind via `./prompt-kind.schema.json` (relative URI; see
169
+ // RFC 0027 commit notes for the redocly compatibility rationale).
170
+ // Register under both the canonical `$id` and the relative form so
171
+ // Ajv resolves either way.
172
+ const promptKindPath = join(SCHEMAS_DIR, 'prompt-kind.schema.json');
173
+ const promptKindSchema = JSON.parse(readFileSync(promptKindPath, 'utf8'));
174
+ ajv.addSchema(promptKindSchema, 'prompt-kind.schema.json');
175
+ ajv.addSchema(promptKindSchema, './prompt-kind.schema.json');
176
+ const schema = JSON.parse(readFileSync(PROMPT_TEMPLATE_SCHEMA_PATH, 'utf8'));
177
+ const validate = ajv.compile(schema);
178
+
179
+ const files = readdirSync(PROMPT_TEMPLATE_FIXTURES_DIR)
180
+ .filter((f) => f.endsWith('.json'))
181
+ .sort();
182
+
183
+ it('finds at least one prompt-template fixture', () => {
184
+ expect(
185
+ files.length,
186
+ 'Expected at least one PromptTemplate fixture under fixtures/prompt-templates/',
187
+ ).toBeGreaterThan(0);
188
+ });
189
+
190
+ for (const file of files) {
191
+ it(`prompt-templates/${file} validates against prompt-template.schema.json`, () => {
192
+ const data = JSON.parse(
193
+ readFileSync(join(PROMPT_TEMPLATE_FIXTURES_DIR, file), 'utf8'),
194
+ );
195
+ const ok = validate(data);
196
+ const errors = (validate.errors ?? [])
197
+ .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
198
+ .join('\n');
199
+ expect(
200
+ ok,
201
+ `Fixture prompt-templates/${file} fails prompt-template schema:\n${errors}`,
202
+ ).toBe(true);
203
+ });
204
+ }
205
+
206
+ it('every fixture templateId matches its filename', () => {
207
+ // Filename convention: `<templateId-dot-form-with-dots-as-dashes>.json`.
208
+ // The fixture set uses dot-prefixed templateIds (e.g.,
209
+ // `conformance.prompt.writer-system`) which map directly to filenames
210
+ // with dots preserved (`conformance-prompt-writer-system.json`). The
211
+ // file→id mapping is loose (the suite doesn't enforce it) but we
212
+ // assert templateId presence so each fixture is self-describing.
213
+ for (const file of files) {
214
+ const data = JSON.parse(
215
+ readFileSync(join(PROMPT_TEMPLATE_FIXTURES_DIR, file), 'utf8'),
216
+ ) as { templateId: string };
217
+ expect(
218
+ typeof data.templateId,
219
+ `Fixture prompt-templates/${file} MUST declare a templateId`,
220
+ ).toBe('string');
221
+ expect(data.templateId.length).toBeGreaterThan(0);
222
+ }
223
+ });
224
+
225
+ it('every secret-source variable lives in a fixture tagged for the secret-redaction scenario', () => {
226
+ // SECURITY regression pin: a fixture that declares a `secret`-source
227
+ // variable but isn't visible to the prompt-composed-secret-redaction
228
+ // scenario could mask a redaction failure. We require every
229
+ // fixture carrying secret-source variables to advertise the
230
+ // `secret-redaction` tag so the scenario discovers it.
231
+ for (const file of files) {
232
+ const data = JSON.parse(
233
+ readFileSync(join(PROMPT_TEMPLATE_FIXTURES_DIR, file), 'utf8'),
234
+ ) as {
235
+ templateId: string;
236
+ variables?: Array<{ name: string; source?: string }>;
237
+ tags?: string[];
238
+ };
239
+ const hasSecretSource = (data.variables ?? []).some((v) => v.source === 'secret');
240
+ if (hasSecretSource) {
241
+ expect(
242
+ (data.tags ?? []).includes('secret-redaction'),
243
+ `Fixture prompt-templates/${file} declares a secret-source variable but lacks the 'secret-redaction' tag`,
244
+ ).toBe(true);
245
+ }
246
+ }
247
+ });
248
+ });
@@ -1,12 +1,12 @@
1
1
  /**
2
- * kv-ttl-expiry — RFC 0015 advertisement-shape verification + behavioral placeholders.
2
+ * kv-ttl-expiry — RFC 0015 advertisement-shape verification + behavioral roundtrip.
3
3
  *
4
- * Status: ACTIVE (advertisement-shape). RFC 0015 promoted to `Active`
5
- * 2026-05-17. The matching `capabilities.kvStorage` 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.
4
+ * Status: ACTIVE (advertisement-shape + behavioral). RFC 0015 promoted to
5
+ * `Active` 2026-05-17. The matching `capabilities.kvStorage` block has
6
+ * landed in `schemas/capabilities.schema.json`. This scenario asserts the
7
+ * advertisement shape against any host that boots the conformance suite, and
8
+ * exercises the behavioral surface through the `/v1/host/sample/test/surface`
9
+ * seam (soft-skip with HTTP 404 on hosts that don't expose it).
10
10
  *
11
11
  * Summary: TTL honored with at most a 1-second drift on expiry visibility.
12
12
  *