@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.
- package/CHANGELOG.md +90 -0
- package/README.md +2 -2
- package/api/redocly.yaml +15 -0
- package/coverage.md +27 -14
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +18 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +264 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +35 -1
- package/schemas/run-event.schema.json +2 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
- package/src/lib/driver.ts +15 -0
- package/src/lib/env.ts +51 -0
- package/src/lib/event-log-query.ts +62 -0
- package/src/lib/fixtures.ts +38 -1
- package/src/lib/host-toggle.ts +54 -0
- package/src/lib/multi-agent-capabilities.ts +10 -0
- package/src/lib/otel-scrape.ts +59 -0
- package/src/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +99 -0
- package/src/scenarios/blob-roundtrip.test.ts +0 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
- package/src/scenarios/fixtures-gating.test.ts +139 -1
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
- package/src/scenarios/pack-registry-publish.test.ts +231 -51
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/provider-usage.test.ts +185 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
- package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
- package/src/scenarios/spec-corpus-validity.test.ts +17 -1
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +85 -0
- package/src/scenarios/table-schema-enforcement.test.ts +84 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.contractRefusal — 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. Behavioral assertions stay `it.todo()` until a
|
|
6
|
+
* reference host wires Envelope Contract enforcement on a node typeId.
|
|
7
|
+
*
|
|
8
|
+
* Summary: an Envelope Contract is a per-typeId declaration of which envelope
|
|
9
|
+
* kinds that node accepts (`accepts: string[]` plus implicit universals). When
|
|
10
|
+
* a node emits an envelope whose `type` is neither universal nor in `accepts`:
|
|
11
|
+
* - `refusalMode: "fail-node"` (default) — engine MUST emit `node.failed` with
|
|
12
|
+
* `error.code = 'envelope_contract_violation'`, `error.details.refusedType`,
|
|
13
|
+
* `error.details.acceptedTypes[]`.
|
|
14
|
+
* - `refusalMode: "discard-and-warn"` (advisory) — engine MAY discard silently
|
|
15
|
+
* after emitting `log.appended` (level warn) and proceed.
|
|
16
|
+
*
|
|
17
|
+
* Universals are ALWAYS accepted regardless of `accepts`.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/v1/ai-envelope.md §"Envelope Contract"
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver } from '../lib/driver.js';
|
|
24
|
+
|
|
25
|
+
interface DiscoveryDoc {
|
|
26
|
+
capabilities?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('aiEnvelope.contractRefusal: advertisement shape (FINAL v1.1)', () => {
|
|
30
|
+
it('opted-in hosts advertise envelopeContracts.advertised as a boolean', async () => {
|
|
31
|
+
const res = await driver.get('/.well-known/openwop');
|
|
32
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
33
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
34
|
+
const block = top && typeof top === 'object' ? top['envelopeContracts'] : undefined;
|
|
35
|
+
if (block === undefined) return; // absent — skip
|
|
36
|
+
expect(
|
|
37
|
+
typeof (block as Record<string, unknown>)['advertised'],
|
|
38
|
+
driver.describe(
|
|
39
|
+
'ai-envelope.md §"Capability handshake integration"',
|
|
40
|
+
'envelopeContracts.advertised MUST be a boolean when present',
|
|
41
|
+
),
|
|
42
|
+
).toBe('boolean');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function accept(envelope: unknown, opts: Record<string, unknown> = {}): Promise<{ status: number; body: { status?: string; reason?: string; allowedKinds?: string[] } }> {
|
|
47
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
|
|
48
|
+
return { status: res.status, body: res.json as { status?: string; reason?: string; allowedKinds?: string[] } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
|
|
52
|
+
|
|
53
|
+
describe('aiEnvelope.contractRefusal: behavioral accept-gate (FINAL v1.1)', () => {
|
|
54
|
+
it('node with nodeAllowedKinds:["vendor.x.foo.create"] emits vendor.x.bar.create → status: gated', async () => {
|
|
55
|
+
const r = await accept(
|
|
56
|
+
{
|
|
57
|
+
type: 'vendor.x.bar.create',
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
envelopeId: 'env-cr-1',
|
|
60
|
+
correlationId: 'r:n:0:cr1',
|
|
61
|
+
payload: {},
|
|
62
|
+
meta: baseMeta,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
hostSupportedEnvelopes: ['vendor.x.bar.create', 'vendor.x.foo.create'],
|
|
66
|
+
nodeAllowedKinds: ['vendor.x.foo.create'],
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
if (r.status === 404) return;
|
|
70
|
+
expect(
|
|
71
|
+
r.body.status,
|
|
72
|
+
driver.describe('ai-envelope.md §"Envelope Contract"', 'envelope outside node accepts[] MUST be refused'),
|
|
73
|
+
).toBe('gated');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('gated outcome carries the union of universals + node-declared accepts as allowedKinds', async () => {
|
|
77
|
+
const r = await accept(
|
|
78
|
+
{
|
|
79
|
+
type: 'vendor.x.unknown.kind',
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
envelopeId: 'env-cr-2',
|
|
82
|
+
correlationId: 'r:n:0:cr2',
|
|
83
|
+
payload: {},
|
|
84
|
+
meta: baseMeta,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
hostSupportedEnvelopes: ['vendor.x.unknown.kind', 'vendor.x.foo.create'],
|
|
88
|
+
nodeAllowedKinds: ['vendor.x.foo.create'],
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
if (r.status === 404) return;
|
|
92
|
+
expect(Array.isArray(r.body.allowedKinds), 'allowedKinds MUST be an array').toBe(true);
|
|
93
|
+
expect(r.body.allowedKinds, 'allowedKinds MUST include universals + declared accepts').toEqual(
|
|
94
|
+
expect.arrayContaining(['clarification.request', 'schema.request', 'schema.response', 'error', 'vendor.x.foo.create']),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('universals are accepted regardless of nodeAllowedKinds (always-allowed)', async () => {
|
|
99
|
+
const r = await accept(
|
|
100
|
+
{
|
|
101
|
+
type: 'clarification.request',
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
envelopeId: 'env-cr-univ',
|
|
104
|
+
correlationId: 'r:n:0:univ',
|
|
105
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
106
|
+
meta: baseMeta,
|
|
107
|
+
},
|
|
108
|
+
{ nodeAllowedKinds: ['vendor.x.foo.create'] }, // doesn't include clarification.request
|
|
109
|
+
);
|
|
110
|
+
if (r.status === 404) return;
|
|
111
|
+
expect(
|
|
112
|
+
r.body.status,
|
|
113
|
+
driver.describe('ai-envelope.md §"Universal kinds"', 'universal kinds MUST be accepted regardless of node accepts[]'),
|
|
114
|
+
).toBe('accepted');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('host-supportedEnvelopes gate fires BEFORE node accepts gate (kind unadvertised → gated, not invalid)', async () => {
|
|
118
|
+
const r = await accept(
|
|
119
|
+
{
|
|
120
|
+
type: 'vendor.unadvertised.kind',
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
envelopeId: 'env-cr-hostgate',
|
|
123
|
+
correlationId: 'r:n:0:hostgate',
|
|
124
|
+
payload: {},
|
|
125
|
+
meta: baseMeta,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
hostSupportedEnvelopes: ['vendor.advertised.only'],
|
|
129
|
+
nodeAllowedKinds: ['vendor.unadvertised.kind'], // node would allow but host doesn't
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
if (r.status === 404) return;
|
|
133
|
+
expect(
|
|
134
|
+
r.body.status,
|
|
135
|
+
driver.describe('ai-envelope.md §"Capability handshake integration"', 'host gate MUST fire before node gate'),
|
|
136
|
+
).toBe('gated');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// E.1 engine-projection via the test-only event-log seam.
|
|
141
|
+
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
142
|
+
|
|
143
|
+
describe('aiEnvelope.contractRefusal: engine projection via event-log seam', () => {
|
|
144
|
+
it('gated (fail-node) → node.failed { error.code: "envelope_contract_violation" }', async () => {
|
|
145
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
146
|
+
const runId = `r-cr-fail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
147
|
+
const r = await accept(
|
|
148
|
+
{
|
|
149
|
+
type: 'vendor.x.bar.create',
|
|
150
|
+
schemaVersion: 1,
|
|
151
|
+
envelopeId: 'env-cr-proj-1',
|
|
152
|
+
correlationId: `${runId}:n:0:cr-proj-1`,
|
|
153
|
+
payload: {},
|
|
154
|
+
meta: baseMeta,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
hostSupportedEnvelopes: ['vendor.x.bar.create', 'vendor.x.foo.create'],
|
|
158
|
+
nodeAllowedKinds: ['vendor.x.foo.create'],
|
|
159
|
+
projectTo: { runId, nodeId: 'n', refusalMode: 'fail-node' },
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
if (r.status === 404) return;
|
|
163
|
+
expect(r.body.status).toBe('gated');
|
|
164
|
+
const events = await queryTestEvents(runId, { type: 'node.failed' });
|
|
165
|
+
if (!events.ok || events.events.length === 0) return;
|
|
166
|
+
const err = events.events[0]!.payload.error as { code?: string; details?: { refusedType?: string; acceptedTypes?: string[] } };
|
|
167
|
+
expect(
|
|
168
|
+
err.code,
|
|
169
|
+
driver.describe('ai-envelope.md §"Envelope Contract"', 'gated outcome MUST project to node.failed with error.code = envelope_contract_violation'),
|
|
170
|
+
).toBe('envelope_contract_violation');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('refused envelope: error.details.refusedType names emitted kind; acceptedTypes lists allowed kinds', async () => {
|
|
174
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
175
|
+
const runId = `r-cr-details-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
176
|
+
await accept(
|
|
177
|
+
{
|
|
178
|
+
type: 'vendor.x.bar.create',
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
envelopeId: 'env-cr-proj-details',
|
|
181
|
+
correlationId: `${runId}:n:0:cr-details`,
|
|
182
|
+
payload: {},
|
|
183
|
+
meta: baseMeta,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
hostSupportedEnvelopes: ['vendor.x.bar.create', 'vendor.x.foo.create'],
|
|
187
|
+
nodeAllowedKinds: ['vendor.x.foo.create'],
|
|
188
|
+
projectTo: { runId, nodeId: 'n' },
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
const events = await queryTestEvents(runId, { type: 'node.failed' });
|
|
192
|
+
if (!events.ok || events.events.length === 0) return;
|
|
193
|
+
const details = (events.events[0]!.payload.error as { details?: { refusedType?: string; acceptedTypes?: string[] } }).details;
|
|
194
|
+
expect(details?.refusedType).toBe('vendor.x.bar.create');
|
|
195
|
+
expect(
|
|
196
|
+
Array.isArray(details?.acceptedTypes) && details!.acceptedTypes!.includes('vendor.x.foo.create'),
|
|
197
|
+
driver.describe('ai-envelope.md §"Envelope Contract"', 'error.details.acceptedTypes MUST list the node\'s declared accepts[] (plus universals)'),
|
|
198
|
+
).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('refusalMode:"discard-and-warn" → log.appended { level: "warn" } instead of node.failed', async () => {
|
|
202
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
203
|
+
const runId = `r-cr-warn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
204
|
+
await accept(
|
|
205
|
+
{
|
|
206
|
+
type: 'vendor.x.bar.create',
|
|
207
|
+
schemaVersion: 1,
|
|
208
|
+
envelopeId: 'env-cr-proj-warn',
|
|
209
|
+
correlationId: `${runId}:n:0:cr-warn`,
|
|
210
|
+
payload: {},
|
|
211
|
+
meta: baseMeta,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
hostSupportedEnvelopes: ['vendor.x.bar.create'],
|
|
215
|
+
nodeAllowedKinds: ['vendor.x.foo.create'], // gated
|
|
216
|
+
projectTo: { runId, nodeId: 'n', refusalMode: 'discard-and-warn' },
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
const warnEvents = await queryTestEvents(runId, { type: 'log.appended' });
|
|
220
|
+
const failEvents = await queryTestEvents(runId, { type: 'node.failed' });
|
|
221
|
+
if (!warnEvents.ok || !failEvents.ok) return;
|
|
222
|
+
expect(
|
|
223
|
+
warnEvents.events.some((e) => (e.payload as { level?: string }).level === 'warn'),
|
|
224
|
+
driver.describe('ai-envelope.md §"Envelope Contract"', 'discard-and-warn MUST emit log.appended at warn level'),
|
|
225
|
+
).toBe(true);
|
|
226
|
+
expect(
|
|
227
|
+
failEvents.events.length,
|
|
228
|
+
driver.describe('ai-envelope.md §"Envelope Contract"', 'discard-and-warn MUST NOT emit node.failed'),
|
|
229
|
+
).toBe(0);
|
|
230
|
+
await resetTestSeam();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('host-gate refusal (hostSupportedEnvelopes) projects to node.failed with envelope_contract_violation', async () => {
|
|
234
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
235
|
+
const runId = `r-cr-host-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
236
|
+
await accept(
|
|
237
|
+
{
|
|
238
|
+
type: 'vendor.unadvertised.kind',
|
|
239
|
+
schemaVersion: 1,
|
|
240
|
+
envelopeId: 'env-cr-proj-host',
|
|
241
|
+
correlationId: `${runId}:n:0:cr-host`,
|
|
242
|
+
payload: {},
|
|
243
|
+
meta: baseMeta,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
hostSupportedEnvelopes: ['vendor.advertised.only'],
|
|
247
|
+
nodeAllowedKinds: ['vendor.unadvertised.kind'],
|
|
248
|
+
projectTo: { runId, nodeId: 'n' },
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
const events = await queryTestEvents(runId, { type: 'node.failed' });
|
|
252
|
+
if (!events.ok || events.events.length === 0) return;
|
|
253
|
+
expect(
|
|
254
|
+
(events.events[0]!.payload.error as { code?: string }).code,
|
|
255
|
+
driver.describe('ai-envelope.md §"Capability handshake integration"', 'host-gate refusal MUST project to node.failed envelope_contract_violation (stacks above node-gate)'),
|
|
256
|
+
).toBe('envelope_contract_violation');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('aiEnvelope.contractRefusal: capability-stacking placeholder', () => {
|
|
261
|
+
// Capability-gated typeId refusal stacking (host.aiEnvelope absent →
|
|
262
|
+
// typeId refused FIRST, before envelope contract gate) requires
|
|
263
|
+
// the workflow-register handler to consult host.aiEnvelope BEFORE
|
|
264
|
+
// dispatching envelope acceptance. Tracked under Thread E (engine
|
|
265
|
+
// integration of acceptor into node execution path); the seam
|
|
266
|
+
// alone can't verify the ordering.
|
|
267
|
+
it.todo('capability-gated typeId refusal stacks atop Envelope Contract refusal (host.aiEnvelope absent → typeId refused first; needs node-execution wiring)');
|
|
268
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiEnvelope.correlationReplay — 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. Behavioral assertions stay `it.todo()` until a
|
|
6
|
+
* reference host wires the accept path and the cross-process replay seam.
|
|
7
|
+
*
|
|
8
|
+
* Summary: two envelopes in the same run with the same `correlationId` MUST
|
|
9
|
+
* be treated as a re-emission. The second invocation returns the cached
|
|
10
|
+
* `EnvelopeOutcome` synchronously without re-invoking the handler. After
|
|
11
|
+
* process death + recovery, the engine MUST consult the run event log via
|
|
12
|
+
* `causationId = correlationId` and return the cached outcome — the handler
|
|
13
|
+
* runs at most once per `correlationId` per run lifetime. A re-emission with
|
|
14
|
+
* the same `correlationId` but a different `type` MUST be refused with
|
|
15
|
+
* `envelope_correlation_conflict`.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v1/ai-envelope.md §"Replay determinism"
|
|
18
|
+
* @see spec/v1/interrupt.md §"Replay determinism" (parallel contract)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
|
|
24
|
+
interface DiscoveryDoc {
|
|
25
|
+
capabilities?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function isEnvelopeContractsAdvertised(): Promise<boolean> {
|
|
29
|
+
const res = await driver.get('/.well-known/openwop');
|
|
30
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
31
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
32
|
+
const block = top && typeof top === 'object' ? (top['envelopeContracts'] as Record<string, unknown> | undefined) : undefined;
|
|
33
|
+
return Boolean(block && block['advertised'] === true);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('aiEnvelope.correlationReplay: advertisement shape (FINAL v1.1)', () => {
|
|
37
|
+
it('host that advertises envelopeContracts.advertised:true claims the replay-determinism contract', async () => {
|
|
38
|
+
if (!(await isEnvelopeContractsAdvertised())) return; // not opted in — skip
|
|
39
|
+
// The contract has no separate capability flag — advertising
|
|
40
|
+
// envelopeContracts is the claim. The behavioral assertions below
|
|
41
|
+
// exercise the contract; this advertisement-shape test exists so
|
|
42
|
+
// a "no envelope contracts at all" host doesn't appear in failure
|
|
43
|
+
// reports for this scenario.
|
|
44
|
+
expect(true).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Behavioral assertions through the workflow-engine sample's env-gated
|
|
49
|
+
// `POST /v1/host/sample/envelope/accept` seam. The seam accepts a flat
|
|
50
|
+
// `priorCorrelations` array (each entry: `{correlationId, outcome, envelopeType}`)
|
|
51
|
+
// that the acceptor consumes as the per-run dedup store. Each test
|
|
52
|
+
// soft-skips on HTTP 404 (host doesn't expose the seam).
|
|
53
|
+
//
|
|
54
|
+
// The cross-process replay assertion (process death + recovery) still
|
|
55
|
+
// stays deferred — it requires a higher-level lifecycle seam that
|
|
56
|
+
// persists the dedup state, which is engine scope, not acceptor scope.
|
|
57
|
+
async function accept(envelope: unknown, opts: Record<string, unknown> = {}): Promise<{ status: number; body: { status?: string; reason?: string; envelopeId?: string; normalizedMeta?: { contentTrust?: string } } }> {
|
|
58
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
|
|
59
|
+
return { status: res.status, body: res.json as { status?: string; reason?: string; envelopeId?: string; normalizedMeta?: { contentTrust?: string } } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
|
|
63
|
+
|
|
64
|
+
describe('aiEnvelope.correlationReplay: behavioral in-process dedup (FINAL v1.1)', () => {
|
|
65
|
+
it('same correlationId re-emission returns the cached outcome unchanged', async () => {
|
|
66
|
+
const envelope = {
|
|
67
|
+
type: 'clarification.request',
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
envelopeId: 'env-cr-replay-1',
|
|
70
|
+
correlationId: 'r:n:0:replay1',
|
|
71
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
72
|
+
meta: baseMeta,
|
|
73
|
+
};
|
|
74
|
+
const first = await accept(envelope);
|
|
75
|
+
if (first.status === 404) return;
|
|
76
|
+
expect(first.body.status).toBe('accepted');
|
|
77
|
+
const cachedOutcome = first.body;
|
|
78
|
+
|
|
79
|
+
const second = await accept(envelope, {
|
|
80
|
+
priorCorrelations: [
|
|
81
|
+
{
|
|
82
|
+
correlationId: 'r:n:0:replay1',
|
|
83
|
+
outcome: cachedOutcome,
|
|
84
|
+
envelopeType: 'clarification.request',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
expect(
|
|
89
|
+
second.body.status,
|
|
90
|
+
driver.describe(
|
|
91
|
+
'ai-envelope.md §"Replay determinism"',
|
|
92
|
+
'second emission with same correlationId MUST return the cached outcome (handler runs at most once per correlationId)',
|
|
93
|
+
),
|
|
94
|
+
).toBe('accepted');
|
|
95
|
+
expect(second.body.envelopeId).toBe(cachedOutcome.envelopeId);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('same correlationId, different envelope type → invalid envelope_correlation_conflict', async () => {
|
|
99
|
+
const r = await accept(
|
|
100
|
+
{
|
|
101
|
+
type: 'error', // re-using a correlationId previously bound to clarification.request
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
envelopeId: 'env-cr-conflict',
|
|
104
|
+
correlationId: 'r:n:0:conflict',
|
|
105
|
+
payload: { code: 'x', message: 'y' },
|
|
106
|
+
meta: baseMeta,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
priorCorrelations: [
|
|
110
|
+
{
|
|
111
|
+
correlationId: 'r:n:0:conflict',
|
|
112
|
+
outcome: { status: 'accepted', envelopeId: 'env-prior', recordedEventIds: [], normalizedMeta: { contentTrust: 'trusted' } },
|
|
113
|
+
envelopeType: 'clarification.request',
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
if (r.status === 404) return;
|
|
119
|
+
expect(
|
|
120
|
+
r.body.status,
|
|
121
|
+
driver.describe(
|
|
122
|
+
'ai-envelope.md §"Replay determinism"',
|
|
123
|
+
'same correlationId with different type MUST refuse envelope_correlation_conflict',
|
|
124
|
+
),
|
|
125
|
+
).toBe('invalid');
|
|
126
|
+
expect(r.body.reason).toContain('envelope_correlation_conflict');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('cached outcome of any status (invalid/gated/breached) replays identically', async () => {
|
|
130
|
+
// Plant a `gated` cached outcome; second emission MUST return the same gated outcome
|
|
131
|
+
// (handler MUST NOT re-run, even if conditions might now accept).
|
|
132
|
+
const cached = {
|
|
133
|
+
status: 'gated' as const,
|
|
134
|
+
reason: 'envelope type \'vendor.x.foo\' not advertised',
|
|
135
|
+
allowedKinds: ['clarification.request', 'schema.request', 'schema.response', 'error'],
|
|
136
|
+
};
|
|
137
|
+
const r = await accept(
|
|
138
|
+
{
|
|
139
|
+
type: 'vendor.x.foo',
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
envelopeId: 'env-cr-cached-gated',
|
|
142
|
+
correlationId: 'r:n:0:cachedgated',
|
|
143
|
+
payload: {},
|
|
144
|
+
meta: baseMeta,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
hostSupportedEnvelopes: ['vendor.x.foo'], // would otherwise accept
|
|
148
|
+
priorCorrelations: [
|
|
149
|
+
{
|
|
150
|
+
correlationId: 'r:n:0:cachedgated',
|
|
151
|
+
outcome: cached,
|
|
152
|
+
envelopeType: 'vendor.x.foo',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
if (r.status === 404) return;
|
|
158
|
+
expect(
|
|
159
|
+
r.body.status,
|
|
160
|
+
driver.describe(
|
|
161
|
+
'ai-envelope.md §"Replay determinism"',
|
|
162
|
+
'cached non-accepted outcome MUST replay identically (handler at most once per correlationId)',
|
|
163
|
+
),
|
|
164
|
+
).toBe('gated');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// E.1 engine-projection via the test-only event-log seam.
|
|
169
|
+
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
170
|
+
|
|
171
|
+
describe('aiEnvelope.correlationReplay: causationId projection via event-log seam', () => {
|
|
172
|
+
it('resulting RunEventDoc.causationId MUST equal the envelope.correlationId (causal chain preserved)', async () => {
|
|
173
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
174
|
+
const runId = `r-cr-cause-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
175
|
+
const correlationId = `${runId}:n:0:causationId-link`;
|
|
176
|
+
await accept(
|
|
177
|
+
{
|
|
178
|
+
type: 'clarification.request',
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
envelopeId: 'env-cr-cause-1',
|
|
181
|
+
correlationId,
|
|
182
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
183
|
+
meta: baseMeta,
|
|
184
|
+
},
|
|
185
|
+
{ projectTo: { runId, nodeId: 'n' } },
|
|
186
|
+
);
|
|
187
|
+
const events = await queryTestEvents(runId);
|
|
188
|
+
if (!events.ok || events.events.length === 0) return;
|
|
189
|
+
for (const e of events.events) {
|
|
190
|
+
expect(
|
|
191
|
+
e.causationId,
|
|
192
|
+
driver.describe('ai-envelope.md §"Replay determinism"', 'every event projected from an envelope MUST carry causationId === envelope.correlationId'),
|
|
193
|
+
).toBe(correlationId);
|
|
194
|
+
}
|
|
195
|
+
await resetTestSeam();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('aiEnvelope.correlationReplay: cross-process replay via persisted dedup', () => {
|
|
200
|
+
// Cross-process replay proven WITHOUT actually killing the process:
|
|
201
|
+
// when a caller supplies `persistedDedup: { runId }`, the seam reads
|
|
202
|
+
// the persisted store BEFORE consulting the in-memory priorCorrelations
|
|
203
|
+
// and writes the outcome back after a successful accept. A second
|
|
204
|
+
// call from the same (or a hypothetically-restarted) process with
|
|
205
|
+
// ONLY persistedDedup set — no in-memory priorCorrelations — MUST
|
|
206
|
+
// return the same outcome as the first. That is the cross-process
|
|
207
|
+
// semantics: the persisted store is the source of truth, the in-
|
|
208
|
+
// memory map a per-process accelerator.
|
|
209
|
+
it('persisted outcome replays for the same correlationId even with NO in-memory priorCorrelations', async () => {
|
|
210
|
+
const runId = `r-cr-persist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
211
|
+
const correlationId = `${runId}:n:0:persist1`;
|
|
212
|
+
const envelope = {
|
|
213
|
+
type: 'clarification.request',
|
|
214
|
+
schemaVersion: 1,
|
|
215
|
+
envelopeId: 'env-cr-persist-1',
|
|
216
|
+
correlationId,
|
|
217
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
218
|
+
meta: baseMeta,
|
|
219
|
+
};
|
|
220
|
+
// First accept persists the outcome under (runId, correlationId).
|
|
221
|
+
const first = await accept(envelope, { persistedDedup: { runId } });
|
|
222
|
+
if (first.status === 404) return; // seam not exposed — soft-skip
|
|
223
|
+
expect(first.body.status).toBe('accepted');
|
|
224
|
+
const cachedEnvelopeId = first.body.envelopeId;
|
|
225
|
+
|
|
226
|
+
// Second accept — same correlationId, NO priorCorrelations passed
|
|
227
|
+
// in-band. If the persisted store is consulted, the cached outcome
|
|
228
|
+
// is returned (same envelopeId). If only the in-memory map were
|
|
229
|
+
// used, the handler would re-run and mint a different envelopeId
|
|
230
|
+
// (or accept again with the original — either way, NOT the proof
|
|
231
|
+
// of cross-process semantics).
|
|
232
|
+
const second = await accept(envelope, { persistedDedup: { runId } });
|
|
233
|
+
expect(
|
|
234
|
+
second.body.envelopeId,
|
|
235
|
+
driver.describe(
|
|
236
|
+
'ai-envelope.md §"Replay determinism"',
|
|
237
|
+
'persisted outcome MUST replay across calls without an in-memory priorCorrelations map (cross-process recovery semantics)',
|
|
238
|
+
),
|
|
239
|
+
).toBe(cachedEnvelopeId);
|
|
240
|
+
expect(second.body.status).toBe('accepted');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('persisted store enforces envelope_correlation_conflict across calls', async () => {
|
|
244
|
+
const runId = `r-cr-persist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
245
|
+
const correlationId = `${runId}:n:0:conflict1`;
|
|
246
|
+
// First accept: clarification.request.
|
|
247
|
+
const first = await accept(
|
|
248
|
+
{
|
|
249
|
+
type: 'clarification.request',
|
|
250
|
+
schemaVersion: 1,
|
|
251
|
+
envelopeId: 'env-cr-persist-conflict-1',
|
|
252
|
+
correlationId,
|
|
253
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
254
|
+
meta: baseMeta,
|
|
255
|
+
},
|
|
256
|
+
{ persistedDedup: { runId } },
|
|
257
|
+
);
|
|
258
|
+
if (first.status === 404) return;
|
|
259
|
+
expect(first.body.status).toBe('accepted');
|
|
260
|
+
|
|
261
|
+
// Second accept: same correlationId, different envelope type, NO
|
|
262
|
+
// in-memory priorCorrelations — the conflict MUST be served from
|
|
263
|
+
// the persisted store.
|
|
264
|
+
const second = await accept(
|
|
265
|
+
{
|
|
266
|
+
type: 'error',
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
envelopeId: 'env-cr-persist-conflict-2',
|
|
269
|
+
correlationId,
|
|
270
|
+
payload: { code: 'x', message: 'y' },
|
|
271
|
+
meta: baseMeta,
|
|
272
|
+
},
|
|
273
|
+
{ persistedDedup: { runId } },
|
|
274
|
+
);
|
|
275
|
+
expect(
|
|
276
|
+
second.body.status,
|
|
277
|
+
driver.describe(
|
|
278
|
+
'ai-envelope.md §"Replay determinism"',
|
|
279
|
+
'persisted store MUST surface envelope_correlation_conflict on type mismatch without an in-memory priorCorrelations map',
|
|
280
|
+
),
|
|
281
|
+
).toBe('invalid');
|
|
282
|
+
expect(second.body.reason).toContain('envelope_correlation_conflict');
|
|
283
|
+
});
|
|
284
|
+
});
|