@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,362 @@
1
+ /**
2
+ * ai-envelope-shape — RFC 0021 §D wire-shape conformance.
3
+ *
4
+ * Asserts:
5
+ * 1. `/.well-known/openwop` `supportedEnvelopes` is a `string[]` (advertisement contract).
6
+ * 2. The top-level `schemas/ai-envelope.schema.json` is Ajv2020-compileable.
7
+ * 3. Each universal kind that appears in the host's `supportedEnvelopes`
8
+ * has a corresponding Ajv2020-compileable payload schema at
9
+ * `schemas/envelopes/<kind>.schema.json`.
10
+ * 4. The top-level envelope schema accepts a positive AIEnvelope fixture
11
+ * AND rejects a negative fixture (missing required `meta` block).
12
+ *
13
+ * Capability-gated: skips when the host doesn't advertise
14
+ * `aiProviders.supported: true` (envelopes are emitted by LLM-call nodes).
15
+ *
16
+ * @see RFCS/0021-ai-envelope-primitive.md
17
+ * @see spec/v1/ai-envelope.md
18
+ * @see schemas/ai-envelope.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import Ajv2020 from 'ajv/dist/2020.js';
23
+ import { readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { driver } from '../lib/driver.js';
26
+ import { SCHEMAS_DIR } from '../lib/paths.js';
27
+
28
+ interface DiscoveryDoc {
29
+ capabilities?: {
30
+ aiProviders?: { supported?: unknown };
31
+ supportedEnvelopes?: unknown;
32
+ };
33
+ supportedEnvelopes?: unknown;
34
+ }
35
+
36
+ const UNIVERSAL_KINDS = [
37
+ 'clarification.request',
38
+ 'schema.request',
39
+ 'schema.response',
40
+ 'error',
41
+ ] as const;
42
+
43
+ async function readDiscovery(): Promise<DiscoveryDoc | null> {
44
+ const res = await driver.get('/.well-known/openwop');
45
+ if (res.status !== 200) return null;
46
+ return res.json as DiscoveryDoc;
47
+ }
48
+
49
+ function aiProvidersSupported(d: DiscoveryDoc | null): boolean {
50
+ if (!d?.capabilities?.aiProviders?.supported) return false;
51
+ // aiProviders.supported can be `true` or an array per the capabilities schema.
52
+ const v = d.capabilities.aiProviders.supported;
53
+ return v === true || (Array.isArray(v) && v.length > 0);
54
+ }
55
+
56
+ function supportedEnvelopes(d: DiscoveryDoc | null): string[] {
57
+ // supportedEnvelopes is at the top level per the capabilities schema, but
58
+ // some hosts nest it under capabilities — tolerate both.
59
+ const top = d?.supportedEnvelopes;
60
+ const nested = d?.capabilities?.supportedEnvelopes;
61
+ const raw = Array.isArray(top) ? top : Array.isArray(nested) ? nested : [];
62
+ return raw.filter((s): s is string => typeof s === 'string');
63
+ }
64
+
65
+ // HTTP-driven blocks soft-skip when no base URL is configured (gate's
66
+ // server-free subset runs offline; HTTP-driven assertions activate only
67
+ // when an operator points the suite at a live host).
68
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
69
+
70
+ describe.skipIf(HTTP_SKIP)('ai-envelope-shape: advertisement contract (RFC 0021 §C)', () => {
71
+ it('capabilities.supportedEnvelopes is an array of strings (when present)', async () => {
72
+ const d = await readDiscovery();
73
+ if (d === null) return;
74
+ const env = supportedEnvelopes(d);
75
+ // No assertion if absent — the field is optional. When present, each entry MUST be a string.
76
+ for (const k of env) {
77
+ expect(typeof k, driver.describe('capabilities.md §supportedEnvelopes', 'each entry MUST be a string')).toBe('string');
78
+ }
79
+ // Re-affirm shape: array if present.
80
+ if (d.capabilities?.supportedEnvelopes !== undefined) {
81
+ expect(Array.isArray(d.capabilities.supportedEnvelopes), 'supportedEnvelopes MUST be an array').toBe(true);
82
+ }
83
+ });
84
+ });
85
+
86
+ describe('ai-envelope-shape: schema compile (RFC 0021 §A + §B)', () => {
87
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
88
+ function load(relPath: string): Record<string, unknown> {
89
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, relPath), 'utf8')) as Record<string, unknown>;
90
+ }
91
+
92
+ it('top-level ai-envelope.schema.json compiles under Ajv2020', () => {
93
+ const schema = load('ai-envelope.schema.json');
94
+ const validate = ajv.compile(schema);
95
+ expect(validate, 'RFC 0021 §A: ai-envelope.schema.json MUST compile').toBeTypeOf('function');
96
+ });
97
+
98
+ for (const kind of UNIVERSAL_KINDS) {
99
+ it(`universal-kind schema envelopes/${kind}.schema.json compiles under Ajv2020`, () => {
100
+ const schema = load(`envelopes/${kind}.schema.json`);
101
+ const validate = ajv.compile(schema);
102
+ expect(
103
+ validate,
104
+ `RFC 0021 §B: envelopes/${kind}.schema.json MUST compile`,
105
+ ).toBeTypeOf('function');
106
+ });
107
+ }
108
+ });
109
+
110
+ describe('ai-envelope-shape: round-trip validation (RFC 0021 §A)', () => {
111
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
112
+ const envelopeSchema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'ai-envelope.schema.json'), 'utf8')) as Record<string, unknown>;
113
+ const validate = ajv.compile(envelopeSchema);
114
+
115
+ it('accepts a positive AIEnvelope fixture', () => {
116
+ const positive = {
117
+ type: 'clarification.request',
118
+ schemaVersion: 1,
119
+ envelopeId: 'env-test-positive-1',
120
+ correlationId: 'run-1:node-2:turn-0:abc123',
121
+ payload: {
122
+ questions: [{ id: 'q1', question: 'Which provider?' }],
123
+ },
124
+ meta: {
125
+ source: 'ai-generation',
126
+ ts: '2026-05-18T10:00:00Z',
127
+ contentTrust: 'trusted',
128
+ },
129
+ };
130
+ const ok = validate(positive);
131
+ expect(ok, `positive fixture MUST validate; errors: ${JSON.stringify(validate.errors)}`).toBe(true);
132
+ });
133
+
134
+ it('rejects a negative AIEnvelope fixture missing required `meta`', () => {
135
+ const negative = {
136
+ type: 'error',
137
+ schemaVersion: 1,
138
+ envelopeId: 'env-test-negative-1',
139
+ correlationId: 'run-1:node-2:turn-1:def456',
140
+ payload: { code: 'validation_failed', message: 'no go' },
141
+ // meta omitted on purpose
142
+ };
143
+ const ok = validate(negative);
144
+ expect(ok, 'RFC 0021 §A: envelope MUST require meta block').toBe(false);
145
+ });
146
+
147
+ it('rejects an envelope with unknown top-level property (additionalProperties:false)', () => {
148
+ const negative = {
149
+ type: 'error',
150
+ schemaVersion: 1,
151
+ envelopeId: 'env-test-extra',
152
+ correlationId: 'run-1:node-2:turn-2:xyz789',
153
+ payload: { code: 'validation_failed', message: 'no go' },
154
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z' },
155
+ unknownTopLevel: 'should reject',
156
+ };
157
+ const ok = validate(negative);
158
+ expect(ok, 'RFC 0021 §A: top-level additionalProperties:false MUST reject unknown fields').toBe(false);
159
+ });
160
+ });
161
+
162
+ describe.skipIf(HTTP_SKIP)('ai-envelope-shape: behavioral acceptEnvelope round-trip (RFC 0021 §A)', () => {
163
+ it('valid clarification.request envelope → status: accepted', async () => {
164
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
165
+ envelope: {
166
+ type: 'clarification.request',
167
+ schemaVersion: 1,
168
+ envelopeId: 'env-conformance-positive-1',
169
+ correlationId: 'run-conf:node-1:turn-0:abc',
170
+ payload: { questions: [{ id: 'q1', question: 'Which provider?' }] },
171
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z' },
172
+ },
173
+ });
174
+ if (res.status === 404) return; // host doesn't expose the seam
175
+ expect(res.status).toBe(200);
176
+ const body = res.json as { status?: string };
177
+ expect(
178
+ body.status,
179
+ driver.describe('RFC 0021 §A point 1-3', 'valid envelope MUST be accepted'),
180
+ ).toBe('accepted');
181
+ });
182
+
183
+ it('envelope missing required meta block → status: invalid', async () => {
184
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
185
+ envelope: {
186
+ type: 'error',
187
+ schemaVersion: 1,
188
+ envelopeId: 'env-conformance-negative-1',
189
+ correlationId: 'run-conf:node-1:turn-1:def',
190
+ payload: { code: 'validation_failed', message: 'no go' },
191
+ // meta omitted
192
+ },
193
+ });
194
+ if (res.status === 404) return;
195
+ expect(res.status).toBe(200);
196
+ const body = res.json as { status?: string; details?: unknown[] };
197
+ expect(
198
+ body.status,
199
+ driver.describe('RFC 0021 §A point 1', 'envelope missing required field MUST be invalid'),
200
+ ).toBe('invalid');
201
+ expect(Array.isArray(body.details), 'invalid outcome MUST carry validation details').toBe(true);
202
+ });
203
+
204
+ it('vendor-namespaced kind not in supportedEnvelopes → status: gated', async () => {
205
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
206
+ envelope: {
207
+ type: 'vendor.acme.unknown.kind',
208
+ schemaVersion: 1,
209
+ envelopeId: 'env-conformance-gated-1',
210
+ correlationId: 'run-conf:node-1:turn-2:ghi',
211
+ payload: {},
212
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z' },
213
+ },
214
+ hostSupportedEnvelopes: ['vendor.acme.prd.create'], // does not include the gated kind
215
+ });
216
+ if (res.status === 404) return;
217
+ expect(res.status).toBe(200);
218
+ const body = res.json as { status?: string; allowedKinds?: string[] };
219
+ expect(
220
+ body.status,
221
+ driver.describe('RFC 0021 §A point 2', 'unadvertised kind MUST be gated'),
222
+ ).toBe('gated');
223
+ expect(Array.isArray(body.allowedKinds), 'gated outcome MUST list the allowed kinds').toBe(true);
224
+ });
225
+
226
+ it('counter at cap → status: breached', async () => {
227
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
228
+ envelope: {
229
+ type: 'error',
230
+ schemaVersion: 1,
231
+ envelopeId: 'env-conformance-breached-1',
232
+ correlationId: 'run-conf:node-1:turn-3:jkl',
233
+ payload: { code: 'x', message: 'y' },
234
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z' },
235
+ },
236
+ counters: { envelopesPerTurn: { current: 32, cap: 32 } },
237
+ });
238
+ if (res.status === 404) return;
239
+ expect(res.status).toBe(200);
240
+ const body = res.json as { status?: string; capKind?: string };
241
+ expect(
242
+ body.status,
243
+ driver.describe('RFC 0021 §"Universal kinds"', 'envelope at cap MUST be breached'),
244
+ ).toBe('breached');
245
+ expect(body.capKind).toBe('envelopes');
246
+ });
247
+
248
+ it('accepted outcome carries normalizedMeta.contentTrust per RFC 0021 §A point 6', async () => {
249
+ // No meta.contentTrust on inbound + runTrustBoundary='untrusted'
250
+ // → host propagates per RFC 0021 §A point 6 (trust-boundary normalization).
251
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
252
+ envelope: {
253
+ type: 'clarification.request',
254
+ schemaVersion: 1,
255
+ envelopeId: 'env-norm-1',
256
+ correlationId: 'run-norm:node-1:turn-0:abc',
257
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
258
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z' },
259
+ },
260
+ runTrustBoundary: 'untrusted',
261
+ });
262
+ if (res.status === 404) return;
263
+ expect(res.status).toBe(200);
264
+ const body = res.json as { status?: string; normalizedMeta?: { contentTrust?: string } };
265
+ expect(body.status).toBe('accepted');
266
+ expect(
267
+ body.normalizedMeta?.contentTrust,
268
+ driver.describe(
269
+ 'RFC 0021 §A point 6',
270
+ 'host MUST propagate runTrustBoundary onto normalizedMeta.contentTrust when envelope meta.contentTrust is absent',
271
+ ),
272
+ ).toBe('untrusted');
273
+ });
274
+
275
+ it('envelope-supplied meta.contentTrust takes precedence over runTrustBoundary', async () => {
276
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
277
+ envelope: {
278
+ type: 'clarification.request',
279
+ schemaVersion: 1,
280
+ envelopeId: 'env-norm-2',
281
+ correlationId: 'run-norm:node-1:turn-1:def',
282
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
283
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z', contentTrust: 'trusted' },
284
+ },
285
+ runTrustBoundary: 'untrusted', // explicitly conflicts; envelope wins
286
+ });
287
+ if (res.status === 404) return;
288
+ const body = res.json as { status?: string; normalizedMeta?: { contentTrust?: string } };
289
+ expect(body.status).toBe('accepted');
290
+ expect(
291
+ body.normalizedMeta?.contentTrust,
292
+ driver.describe(
293
+ 'RFC 0021 §A point 6',
294
+ 'envelope meta.contentTrust MUST take precedence over runTrustBoundary',
295
+ ),
296
+ ).toBe('trusted');
297
+ });
298
+
299
+ it('non-ISO-8601 meta.ts → status: invalid (format defense-in-depth)', async () => {
300
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
301
+ envelope: {
302
+ type: 'error',
303
+ schemaVersion: 1,
304
+ envelopeId: 'env-bad-ts',
305
+ correlationId: 'run-bad-ts:node-1:turn-0:abc',
306
+ payload: { code: 'x', message: 'y' },
307
+ meta: { source: 'ai-generation', ts: 'tomorrow' },
308
+ },
309
+ });
310
+ if (res.status === 404) return;
311
+ const body = res.json as { status?: string; reason?: string };
312
+ expect(body.status, 'malformed timestamp MUST be rejected').toBe('invalid');
313
+ expect(body.reason).toContain('ISO 8601');
314
+ });
315
+
316
+ it('universal kind allowed regardless of nodeAllowedKinds (always-allowed per §"Universal kinds")', async () => {
317
+ const res = await driver.post('/v1/host/sample/envelope/accept', {
318
+ envelope: {
319
+ type: 'clarification.request',
320
+ schemaVersion: 1,
321
+ envelopeId: 'env-conformance-universal-1',
322
+ correlationId: 'run-conf:node-1:turn-4:mno',
323
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
324
+ meta: { source: 'ai-generation', ts: '2026-05-18T10:00:00Z' },
325
+ },
326
+ nodeAllowedKinds: ['vendor.acme.prd.create'], // doesn't include clarification.request
327
+ });
328
+ if (res.status === 404) return;
329
+ expect(res.status).toBe(200);
330
+ const body = res.json as { status?: string };
331
+ expect(
332
+ body.status,
333
+ driver.describe(
334
+ 'RFC 0021 §"Universal kinds (normative)"',
335
+ 'universal kinds MUST be allowed even when node allowlist excludes them',
336
+ ),
337
+ ).toBe('accepted');
338
+ });
339
+ });
340
+
341
+ describe.skipIf(HTTP_SKIP)('ai-envelope-shape: universal-kind advertisement (RFC 0021 §C)', () => {
342
+ it('host advertising any universal kind has a matching schema on disk', async () => {
343
+ const d = await readDiscovery();
344
+ if (d === null || !aiProvidersSupported(d)) return; // capability-gated
345
+ const env = supportedEnvelopes(d);
346
+ const advertisedUniversals = env.filter((k): k is (typeof UNIVERSAL_KINDS)[number] =>
347
+ (UNIVERSAL_KINDS as readonly string[]).includes(k),
348
+ );
349
+ if (advertisedUniversals.length === 0) return; // host doesn't claim any universal
350
+ // Each advertised universal MUST have its schema compileable (already
351
+ // covered by the per-kind compile test above; this assertion just makes
352
+ // the linkage between advertisement + schema explicit).
353
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
354
+ for (const k of advertisedUniversals) {
355
+ const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, `envelopes/${k}.schema.json`), 'utf8')) as Record<string, unknown>;
356
+ expect(
357
+ ajv.compile(schema),
358
+ `RFC 0021 §B: advertised universal ${k} MUST resolve to a compileable schema`,
359
+ ).toBeTypeOf('function');
360
+ }
361
+ });
362
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * aiEnvelope.capBreached — FINAL v1.1 advertisement-shape verification + behavioral placeholders.
3
+ *
4
+ * Status: DRAFT (advertisement-shape). `spec/v1/ai-envelope.md` landed
5
+ * 2026-05-17 as DRAFT v1.x. The envelope-specific behavior of the existing
6
+ * `cap.breached` event surface is asserted here; the underlying event shape
7
+ * is already locked by `capabilities.md` §"Engine-enforced limits and the
8
+ * `cap.breached` event" and `run-event-payloads.schema.json #/$defs/capBreached`.
9
+ *
10
+ * Summary: exceeding any of the three envelope-related limits MUST emit
11
+ * `cap.breached` and fail the node:
12
+ *
13
+ * - `limits.envelopesPerTurn` → `cap.breached { kind: "envelopes" }`
14
+ * - `limits.schemaRounds` → `cap.breached { kind: "schema" }`
15
+ * - `limits.clarificationRounds` → `cap.breached { kind: "clarification" }`
16
+ *
17
+ * No new event surface is introduced by this spec; the envelope work reuses
18
+ * the existing capBreached surface with the existing `kind` discriminator
19
+ * enum.
20
+ *
21
+ * @see spec/v1/ai-envelope.md §"Engine-enforced limits"
22
+ * @see spec/v1/capabilities.md §"Engine-enforced limits and the `cap.breached` event"
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { driver } from '../lib/driver.js';
27
+
28
+ interface DiscoveryDoc {
29
+ limits?: Record<string, number>;
30
+ capabilities?: { limits?: Record<string, number> };
31
+ }
32
+
33
+ async function readLimits(): Promise<Record<string, number> | null> {
34
+ const res = await driver.get('/.well-known/openwop');
35
+ const body = res.json as DiscoveryDoc | undefined;
36
+ const limits = body?.limits ?? body?.capabilities?.limits ?? null;
37
+ return limits && typeof limits === 'object' ? (limits as Record<string, number>) : null;
38
+ }
39
+
40
+ describe('aiEnvelope.capBreached: advertisement shape (already required v1)', () => {
41
+ it('limits.envelopesPerTurn is a non-negative integer', async () => {
42
+ const limits = await readLimits();
43
+ expect(
44
+ limits,
45
+ driver.describe(
46
+ 'capabilities.schema.json §limits',
47
+ 'limits MUST be present on /.well-known/openwop (required v1)',
48
+ ),
49
+ ).not.toBeNull();
50
+ expect(
51
+ Number.isInteger(limits!.envelopesPerTurn) && limits!.envelopesPerTurn >= 0,
52
+ driver.describe(
53
+ 'capabilities.schema.json §limits.envelopesPerTurn',
54
+ 'envelopesPerTurn MUST be a non-negative integer (required v1)',
55
+ ),
56
+ ).toBe(true);
57
+ });
58
+
59
+ it('limits.schemaRounds is a non-negative integer', async () => {
60
+ const limits = await readLimits();
61
+ if (limits === null) return;
62
+ expect(
63
+ Number.isInteger(limits.schemaRounds) && limits.schemaRounds >= 0,
64
+ driver.describe(
65
+ 'capabilities.schema.json §limits.schemaRounds',
66
+ 'schemaRounds MUST be a non-negative integer (required v1)',
67
+ ),
68
+ ).toBe(true);
69
+ });
70
+
71
+ it('limits.clarificationRounds is a non-negative integer', async () => {
72
+ const limits = await readLimits();
73
+ if (limits === null) return;
74
+ expect(
75
+ Number.isInteger(limits.clarificationRounds) && limits.clarificationRounds >= 0,
76
+ driver.describe(
77
+ 'capabilities.schema.json §limits.clarificationRounds',
78
+ 'clarificationRounds MUST be a non-negative integer (required v1)',
79
+ ),
80
+ ).toBe(true);
81
+ });
82
+ });
83
+
84
+ async function accept(envelope: unknown, opts: Record<string, unknown>): Promise<{ status: number; body: { status?: string; capKind?: string; reason?: string } }> {
85
+ const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
86
+ return { status: res.status, body: res.json as { status?: string; capKind?: string; reason?: string } };
87
+ }
88
+
89
+ const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
90
+
91
+ describe('aiEnvelope.capBreached: behavioral cap enforcement (FINAL v1.1)', () => {
92
+ it('envelopesPerTurn at cap → status: breached { capKind: "envelopes" }', async () => {
93
+ const r = await accept(
94
+ {
95
+ type: 'error',
96
+ schemaVersion: 1,
97
+ envelopeId: 'env-cap-1',
98
+ correlationId: 'r:n:0:cap1',
99
+ payload: { code: 'x', message: 'y' },
100
+ meta: baseMeta,
101
+ },
102
+ { counters: { envelopesPerTurn: { current: 32, cap: 32 } } },
103
+ );
104
+ if (r.status === 404) return;
105
+ expect(
106
+ r.body.status,
107
+ driver.describe('ai-envelope.md §"Engine-enforced limits"', 'over-cap envelope MUST be breached'),
108
+ ).toBe('breached');
109
+ expect(r.body.capKind).toBe('envelopes');
110
+ });
111
+
112
+ it('clarificationRounds at cap → status: breached { capKind: "clarification" }', async () => {
113
+ const r = await accept(
114
+ {
115
+ type: 'clarification.request',
116
+ schemaVersion: 1,
117
+ envelopeId: 'env-cap-2',
118
+ correlationId: 'r:n:0:cap2',
119
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
120
+ meta: baseMeta,
121
+ },
122
+ { counters: { clarificationRounds: { current: 5, cap: 5 } } },
123
+ );
124
+ if (r.status === 404) return;
125
+ expect(r.body.status).toBe('breached');
126
+ expect(r.body.capKind).toBe('clarification');
127
+ });
128
+
129
+ it('schemaRounds at cap → status: breached { capKind: "schema" }', async () => {
130
+ const r = await accept(
131
+ {
132
+ type: 'schema.request',
133
+ schemaVersion: 1,
134
+ envelopeId: 'env-cap-3',
135
+ correlationId: 'r:n:0:cap3',
136
+ payload: { envelopeType: 'vendor.acme.prd.create' },
137
+ meta: baseMeta,
138
+ },
139
+ { counters: { schemaRounds: { current: 3, cap: 3 } } },
140
+ );
141
+ if (r.status === 404) return;
142
+ expect(r.body.status).toBe('breached');
143
+ expect(r.body.capKind).toBe('schema');
144
+ });
145
+
146
+ it('breached reason cites the limit and cap value', async () => {
147
+ const r = await accept(
148
+ {
149
+ type: 'error',
150
+ schemaVersion: 1,
151
+ envelopeId: 'env-cap-reason',
152
+ correlationId: 'r:n:0:capreason',
153
+ payload: { code: 'x', message: 'y' },
154
+ meta: baseMeta,
155
+ },
156
+ { counters: { envelopesPerTurn: { current: 32, cap: 32 } } },
157
+ );
158
+ if (r.status === 404) return;
159
+ expect(r.body.reason).toContain('envelopesPerTurn');
160
+ expect(r.body.reason).toContain('32');
161
+ });
162
+ });
163
+
164
+ // E.1 engine-projection via the test-only event-log seam. The acceptor
165
+ // returns the breached outcome; the seam projects it onto cap.breached +
166
+ // node.failed per capabilities.md §"Engine-enforced limits". Tests
167
+ // soft-skip on HTTP 404 when the seam isn't exposed.
168
+ import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
169
+
170
+ describe('aiEnvelope.capBreached: engine projection via event-log seam (capabilities.md §"cap.breached")', () => {
171
+ it('breached outcome projects to cap.breached { kind: "envelopes" } event with causationId chain', async () => {
172
+ if (!(await isEventLogSeamAvailable())) return;
173
+ const runId = `r-cap-env-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
174
+ const correlationId = `${runId}:node-1:turn-0:cap-env`;
175
+ const r = await accept(
176
+ {
177
+ type: 'error',
178
+ schemaVersion: 1,
179
+ envelopeId: 'env-proj-cap-env',
180
+ correlationId,
181
+ payload: { code: 'x', message: 'y' },
182
+ meta: baseMeta,
183
+ },
184
+ {
185
+ counters: { envelopesPerTurn: { current: 32, cap: 32 } },
186
+ projectTo: { runId, nodeId: 'node-1' },
187
+ },
188
+ );
189
+ if (r.status === 404) return;
190
+ expect(r.body.status).toBe('breached');
191
+
192
+ const events = await queryTestEvents(runId, { type: 'cap.breached' });
193
+ if (!events.ok) return;
194
+ expect(
195
+ events.events.length,
196
+ driver.describe('capabilities.md §"Engine-enforced limits and the cap.breached event"', 'breached outcome MUST project to exactly one cap.breached event'),
197
+ ).toBe(1);
198
+ const evt = events.events[0]!;
199
+ expect(evt.payload.kind).toBe('envelopes');
200
+ expect(evt.causationId).toBe(correlationId);
201
+ await resetTestSeam();
202
+ });
203
+
204
+ it('cap.breached payload includes limit, observed, and nodeId per capabilities.md', async () => {
205
+ if (!(await isEventLogSeamAvailable())) return;
206
+ const runId = `r-cap-payload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
207
+ await accept(
208
+ {
209
+ type: 'clarification.request',
210
+ schemaVersion: 1,
211
+ envelopeId: 'env-proj-cap-clar',
212
+ correlationId: `${runId}:node-2:turn-0:cap`,
213
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
214
+ meta: baseMeta,
215
+ },
216
+ {
217
+ counters: { clarificationRounds: { current: 5, cap: 5 } },
218
+ projectTo: { runId, nodeId: 'node-2' },
219
+ },
220
+ );
221
+ const events = await queryTestEvents(runId, { type: 'cap.breached' });
222
+ if (!events.ok || events.events.length === 0) return;
223
+ const evt = events.events[0]!;
224
+ expect(evt.payload.kind).toBe('clarification');
225
+ expect(
226
+ typeof evt.payload.limit,
227
+ driver.describe('capabilities.md §"cap.breached"', 'payload.limit MUST be present as a number'),
228
+ ).toBe('number');
229
+ expect(evt.payload.nodeId).toBe('node-2');
230
+ await resetTestSeam();
231
+ });
232
+
233
+ it('cap.breached MUST be paired with a terminal node.failed transition', async () => {
234
+ if (!(await isEventLogSeamAvailable())) return;
235
+ const runId = `r-cap-fail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
236
+ await accept(
237
+ {
238
+ type: 'schema.request',
239
+ schemaVersion: 1,
240
+ envelopeId: 'env-proj-cap-fail',
241
+ correlationId: `${runId}:node-3:turn-0:cap`,
242
+ payload: { envelopeType: 'vendor.acme.foo' },
243
+ meta: baseMeta,
244
+ },
245
+ {
246
+ counters: { schemaRounds: { current: 3, cap: 3 } },
247
+ projectTo: { runId, nodeId: 'node-3' },
248
+ },
249
+ );
250
+ const breached = await queryTestEvents(runId, { type: 'cap.breached' });
251
+ const failed = await queryTestEvents(runId, { type: 'node.failed' });
252
+ if (!breached.ok || !failed.ok) return;
253
+ expect(breached.events.length).toBe(1);
254
+ expect(
255
+ failed.events.length,
256
+ driver.describe('capabilities.md §"cap.breached"', 'cap.breached MUST be paired with a terminal node.failed event'),
257
+ ).toBe(1);
258
+ expect((failed.events[0]!.payload.error as { code?: string }).code).toBe('cap_breached');
259
+ await resetTestSeam();
260
+ });
261
+ });