@openwop/openwop-conformance 1.2.0 → 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 (53) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -2
  3. package/api/redocly.yaml +15 -0
  4. package/coverage.md +2 -1
  5. package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
  6. package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
  7. package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
  8. package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
  9. package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
  10. package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
  11. package/fixtures.md +6 -0
  12. package/package.json +1 -1
  13. package/schemas/capabilities.schema.json +16 -0
  14. package/schemas/core-conformance-mock-agent-config.schema.json +5 -0
  15. package/schemas/run-event-payloads.schema.json +35 -1
  16. package/schemas/run-event.schema.json +2 -0
  17. package/src/lib/driver.ts +15 -0
  18. package/src/lib/env.ts +51 -0
  19. package/src/lib/event-log-query.ts +62 -0
  20. package/src/lib/fixtures.ts +38 -1
  21. package/src/lib/host-toggle.ts +54 -0
  22. package/src/lib/multi-agent-capabilities.ts +10 -0
  23. package/src/lib/otel-scrape.ts +59 -0
  24. package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
  25. package/src/scenarios/aiEnvelope.capBreached.test.ts +97 -9
  26. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +128 -10
  27. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +236 -21
  28. package/src/scenarios/aiEnvelope.redaction.test.ts +204 -24
  29. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +158 -19
  30. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +59 -8
  31. package/src/scenarios/aiEnvelope.universalKinds.test.ts +100 -9
  32. package/src/scenarios/blob-presign-expiry.test.ts +35 -2
  33. package/src/scenarios/blob-roundtrip.test.ts +0 -0
  34. package/src/scenarios/cache-ttl-expiry.test.ts +28 -2
  35. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +34 -3
  36. package/src/scenarios/dispatch-input-mapping.test.ts +75 -6
  37. package/src/scenarios/dispatch-output-mapping.test.ts +96 -6
  38. package/src/scenarios/fixtures-gating.test.ts +139 -1
  39. package/src/scenarios/kv-ttl-expiry.test.ts +33 -2
  40. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
  41. package/src/scenarios/pack-registry-publish.test.ts +231 -51
  42. package/src/scenarios/provider-usage.test.ts +185 -0
  43. package/src/scenarios/queue-ack-nack-dlq.test.ts +57 -3
  44. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +43 -3
  45. package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
  46. package/src/scenarios/search-bm25-roundtrip.test.ts +47 -2
  47. package/src/scenarios/sql-transaction-atomicity.test.ts +31 -2
  48. package/src/scenarios/stream-subscribe-from-beginning.test.ts +39 -2
  49. package/src/scenarios/subworkflow-input-mapping.test.ts +77 -7
  50. package/src/scenarios/table-cursor-pagination.test.ts +40 -2
  51. package/src/scenarios/table-schema-enforcement.test.ts +39 -2
  52. package/src/scenarios/vector-knn-roundtrip.test.ts +43 -3
  53. package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
@@ -65,23 +65,162 @@ describe('aiEnvelope.schemaDrift: advertisement shape (FINAL v1.1)', () => {
65
65
  });
66
66
  });
67
67
 
68
- describe('aiEnvelope.schemaDrift: engine-strictness placeholders', () => {
69
- // The 4 assertions below require the engine to read both:
70
- // (a) `Capabilities.schemaVersions[<kind>]` the advertised floor
71
- // version the host implements for the kind, AND
72
- // (b) `Capabilities.envelopeStrictness` the run-level knob that
73
- // decides whether below-floor versions warn or refuse.
74
- //
75
- // The reference workflow-engine sample's `acceptEnvelope` validates
76
- // `schemaVersion` as a top-level structural field but does NOT yet
77
- // cross-reference it against the host's advertised floor or apply
78
- // the strictness knob. Promoting these to behavioral requires
79
- // threading both pieces of state through `AcceptOptions` (or making
80
- // the acceptor close over a discovery snapshot). Tracked as host-
81
- // impl follow-up; the OTel span attribute (`envelope_schema_version_drift`)
82
- // is engine-projection scope.
83
- it.todo('emit envelope with schemaVersion below advertised floor under strictness:"warn" → warn-and-continue');
84
- it.todo('emit envelope with schemaVersion below advertised floor under strictness:"strict" → refuse unknown_schema_version');
85
- it.todo('emit envelope with schemaVersion ABOVE advertised floor → refuse regardless of strictness');
86
- it.todo('drift logs include envelope_schema_version_drift attribute on the OTel span');
68
+ // Behavioral assertions through the workflow-engine sample's env-gated
69
+ // `POST /v1/host/sample/envelope/accept` seam. The seam threads
70
+ // `schemaVersionFloor` + `envelopeStrictness` into AcceptOptions so the
71
+ // pure-function acceptor can apply the §"Schema discipline" gate.
72
+ // Each test soft-skips on HTTP 404 (host doesn't expose the seam).
73
+ async function accept(envelope: unknown, opts: Record<string, unknown> = {}): Promise<{ status: number; body: { status?: string; reason?: string; details?: unknown[] } }> {
74
+ const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
75
+ return { status: res.status, body: res.json as { status?: string; reason?: string; details?: unknown[] } };
76
+ }
77
+
78
+ const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
79
+
80
+ describe('aiEnvelope.schemaDrift: behavioral strictness gate (FINAL v1.1)', () => {
81
+ it('schemaVersion below advertised floor under strictness:"warn" accepted (warn-and-continue)', async () => {
82
+ const r = await accept(
83
+ {
84
+ type: 'clarification.request',
85
+ schemaVersion: 0, // below the v1 floor
86
+ envelopeId: 'env-drift-warn',
87
+ correlationId: 'r:n:0:driftwarn',
88
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
89
+ meta: baseMeta,
90
+ },
91
+ {
92
+ schemaVersionFloor: { 'clarification.request': 1 },
93
+ envelopeStrictness: 'warn',
94
+ },
95
+ );
96
+ if (r.status === 404) return;
97
+ expect(
98
+ r.body.status,
99
+ driver.describe(
100
+ 'ai-envelope.md §"Schema discipline"',
101
+ 'below-floor schemaVersion under strictness:warn MUST be accepted (drift projected at engine level)',
102
+ ),
103
+ ).toBe('accepted');
104
+ });
105
+
106
+ it('schemaVersion below advertised floor under strictness:"strict" → invalid unknown_schema_version', async () => {
107
+ const r = await accept(
108
+ {
109
+ type: 'clarification.request',
110
+ schemaVersion: 0,
111
+ envelopeId: 'env-drift-strict',
112
+ correlationId: 'r:n:0:driftstrict',
113
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
114
+ meta: baseMeta,
115
+ },
116
+ {
117
+ schemaVersionFloor: { 'clarification.request': 1 },
118
+ envelopeStrictness: 'strict',
119
+ },
120
+ );
121
+ if (r.status === 404) return;
122
+ expect(
123
+ r.body.status,
124
+ driver.describe(
125
+ 'ai-envelope.md §"Schema discipline"',
126
+ 'below-floor schemaVersion under strictness:strict MUST refuse with unknown_schema_version',
127
+ ),
128
+ ).toBe('invalid');
129
+ expect(r.body.reason).toContain('unknown_schema_version');
130
+ });
131
+
132
+ it('schemaVersion ABOVE advertised floor → invalid regardless of strictness (host doesn\'t know future version)', async () => {
133
+ for (const strictness of ['warn', 'strict'] as const) {
134
+ const r = await accept(
135
+ {
136
+ type: 'clarification.request',
137
+ schemaVersion: 99,
138
+ envelopeId: `env-drift-above-${strictness}`,
139
+ correlationId: `r:n:0:driftabove-${strictness}`,
140
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
141
+ meta: baseMeta,
142
+ },
143
+ {
144
+ schemaVersionFloor: { 'clarification.request': 1 },
145
+ envelopeStrictness: strictness,
146
+ },
147
+ );
148
+ if (r.status === 404) return;
149
+ expect(
150
+ r.body.status,
151
+ driver.describe(
152
+ 'ai-envelope.md §"Schema discipline"',
153
+ `above-floor schemaVersion MUST refuse regardless of strictness (got ${strictness})`,
154
+ ),
155
+ ).toBe('invalid');
156
+ expect(r.body.reason).toContain('unknown_schema_version');
157
+ }
158
+ });
159
+
160
+ it('refused above-floor envelope carries instancePath /schemaVersion in details', async () => {
161
+ const r = await accept(
162
+ {
163
+ type: 'error',
164
+ schemaVersion: 5,
165
+ envelopeId: 'env-drift-details',
166
+ correlationId: 'r:n:0:driftdetails',
167
+ payload: { code: 'x', message: 'y' },
168
+ meta: baseMeta,
169
+ },
170
+ {
171
+ schemaVersionFloor: { error: 1 },
172
+ envelopeStrictness: 'warn', // above-floor → invalid regardless
173
+ },
174
+ );
175
+ if (r.status === 404) return;
176
+ expect(r.body.status).toBe('invalid');
177
+ expect(Array.isArray(r.body.details)).toBe(true);
178
+ const paths = (r.body.details ?? []).map((d: unknown) => (d as { instancePath?: string }).instancePath);
179
+ expect(
180
+ paths.includes('/schemaVersion'),
181
+ driver.describe(
182
+ 'ai-envelope.md §"Schema discipline"',
183
+ 'schema-drift refusal MUST cite /schemaVersion as the violating field',
184
+ ),
185
+ ).toBe(true);
186
+ });
187
+ });
188
+
189
+ // E.2 OTel scrape seam.
190
+ import { queryTestSpans, isOtelSeamAvailable } from '../lib/otel-scrape.js';
191
+ import { resetTestSeam } from '../lib/event-log-query.js';
192
+
193
+ describe('aiEnvelope.schemaDrift: OTel drift attribute projection (E.2)', () => {
194
+ it('below-floor + strictness:warn → OTel span MUST carry envelope_schema_version_drift attribute', async () => {
195
+ if (!(await isOtelSeamAvailable())) return;
196
+ const runId = `r-drift-otel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
197
+ const r = await accept(
198
+ {
199
+ type: 'clarification.request',
200
+ schemaVersion: 0, // below the v1 floor
201
+ envelopeId: 'env-drift-otel-1',
202
+ correlationId: `${runId}:n:0:drift-otel`,
203
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
204
+ meta: baseMeta,
205
+ },
206
+ {
207
+ schemaVersionFloor: { 'clarification.request': 1 },
208
+ envelopeStrictness: 'warn',
209
+ projectTo: { runId, nodeId: 'n' },
210
+ },
211
+ );
212
+ if (r.status === 404) return;
213
+ expect(r.body.status).toBe('accepted');
214
+
215
+ const spans = await queryTestSpans({ runId });
216
+ if (!spans.ok) return;
217
+ expect(
218
+ spans.data.some((s) => s.attributes.envelope_schema_version_drift === true),
219
+ driver.describe(
220
+ 'ai-envelope.md §"Schema discipline"',
221
+ 'below-floor accept under strictness:warn MUST project envelope_schema_version_drift attribute on the OTel span',
222
+ ),
223
+ ).toBe(true);
224
+ await resetTestSeam();
225
+ });
87
226
  });
@@ -132,12 +132,63 @@ describe('aiEnvelope.trustBoundaryPropagation: behavioral normalization (FINAL v
132
132
  });
133
133
  });
134
134
 
135
- describe('aiEnvelope.trustBoundaryPropagation: engine-integration placeholders', () => {
136
- // These require the engine to project normalizedMeta.contentTrust
137
- // onto RunEventDoc.contentTrust + enforce the approval-gate refusal
138
- // path. The pure-function acceptor surfaces normalizedMeta; engine
139
- // wiring is host-impl scope.
140
- it.todo('engine projects normalizedMeta.contentTrust onto RunEventDoc.contentTrust');
141
- it.todo('approval gate refuses to advance on untrusted envelope with untrusted_content_blocks_approval');
142
- it.todo('downstream LLM node re-consuming untrusted RunEventDoc applies <UNTRUSTED> wrap per prompt-injection invariant');
135
+ // E.1 engine-projection via the test-only event-log seam.
136
+ import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
137
+
138
+ describe('aiEnvelope.trustBoundaryPropagation: engine projection via event-log seam', () => {
139
+ it('normalizedMeta.contentTrust:"untrusted" MUST project onto RunEventDoc.contentTrust', async () => {
140
+ if (!(await isEventLogSeamAvailable())) return;
141
+ const runId = `r-tb-proj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
142
+ await accept(
143
+ {
144
+ type: 'clarification.request',
145
+ schemaVersion: 1,
146
+ envelopeId: 'env-tb-proj-1',
147
+ correlationId: `${runId}:n:0:tb-proj`,
148
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
149
+ meta: { ...baseMeta, contentTrust: 'untrusted' },
150
+ },
151
+ { projectTo: { runId, nodeId: 'n' } },
152
+ );
153
+ const events = await queryTestEvents(runId, { type: 'interrupt.requested' });
154
+ if (!events.ok || events.events.length === 0) return;
155
+ expect(
156
+ events.events[0]!.contentTrust,
157
+ driver.describe(
158
+ 'ai-envelope.md §"Trust boundary"',
159
+ 'engine MUST project normalizedMeta.contentTrust:"untrusted" onto every consequent RunEventDoc.contentTrust',
160
+ ),
161
+ ).toBe('untrusted');
162
+ await resetTestSeam();
163
+ });
164
+
165
+ it('trusted envelope projects RunEventDoc.contentTrust:"trusted" (default + explicit both verified)', async () => {
166
+ if (!(await isEventLogSeamAvailable())) return;
167
+ const runId = `r-tb-trusted-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
168
+ await accept(
169
+ {
170
+ type: 'clarification.request',
171
+ schemaVersion: 1,
172
+ envelopeId: 'env-tb-proj-trusted',
173
+ correlationId: `${runId}:n:0:tb-trusted`,
174
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
175
+ meta: baseMeta, // no contentTrust → default 'trusted'
176
+ },
177
+ { projectTo: { runId, nodeId: 'n' } },
178
+ );
179
+ const events = await queryTestEvents(runId, { type: 'interrupt.requested' });
180
+ if (!events.ok || events.events.length === 0) return;
181
+ expect(events.events[0]!.contentTrust).toBe('trusted');
182
+ await resetTestSeam();
183
+ });
184
+ });
185
+
186
+ describe('aiEnvelope.trustBoundaryPropagation: approval-gate refusal placeholder', () => {
187
+ // Approval-gate refusal (`untrusted_content_blocks_approval`) requires
188
+ // wiring the acceptor's normalizedMeta onto the engine's approval-gate
189
+ // resume handler. Tracked under Thread E.4 of the test-coverage plan
190
+ // (approval-gate refusal seam); the projection seam alone can't drive
191
+ // a resume-with-untrusted assertion.
192
+ it.todo('approval gate refuses to advance on untrusted envelope with untrusted_content_blocks_approval (needs approval-gate resume seam)');
193
+ it.todo('downstream LLM node re-consuming untrusted RunEventDoc applies <UNTRUSTED> wrap per prompt-injection invariant (needs node-execution seam)');
143
194
  });
@@ -164,13 +164,104 @@ describe('aiEnvelope.universalKinds: behavioral accept via /v1/host/sample/envel
164
164
  });
165
165
  });
166
166
 
167
- describe('aiEnvelope.universalKinds: engine-integration placeholders', () => {
168
- // These assert behaviors beyond the pure-function acceptor — they
169
- // need the engine to lift envelopes into interrupts / re-inject
170
- // schemas / emit log.appended events. Tracked separately; the
171
- // acceptor seam above covers the 5 wire-level assertions.
172
- it.todo('lift clarification.request to kind:"clarification" interrupt per interrupt.md');
173
- it.todo('schema.request triggers next-turn schema re-injection (host responsibility)');
174
- it.todo('schema.response counted (or exempt) against limits.envelopesPerTurn per host policy');
175
- it.todo('error envelope projects to log.appended (level: "error"), NOT node.failed');
167
+ // E.1 engine-projection via the test-only event-log seam.
168
+ import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
169
+
170
+ describe('aiEnvelope.universalKinds: engine projection via event-log seam', () => {
171
+ it('clarification.request MUST be lifted to interrupt.requested { kind: "clarification" } per interrupt.md', async () => {
172
+ if (!(await isEventLogSeamAvailable())) return;
173
+ const runId = `r-uk-clar-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
174
+ const r = await accept(
175
+ {
176
+ type: 'clarification.request',
177
+ schemaVersion: 1,
178
+ envelopeId: 'env-uk-proj-clar',
179
+ correlationId: `${runId}:n:0:uk-clar`,
180
+ payload: { questions: [{ id: 'q1', question: 'why?' }] },
181
+ meta: baseMeta,
182
+ },
183
+ { projectTo: { runId, nodeId: 'n' } },
184
+ );
185
+ if (r.status === 404) return;
186
+ expect(r.body.status).toBe('accepted');
187
+ const events = await queryTestEvents(runId, { type: 'interrupt.requested' });
188
+ if (!events.ok) return;
189
+ expect(
190
+ events.events.length,
191
+ driver.describe('ai-envelope.md §"Universal kinds"', 'accepted clarification.request MUST project to interrupt.requested per interrupt.md'),
192
+ ).toBe(1);
193
+ expect((events.events[0]!.payload as { kind?: string }).kind).toBe('clarification');
194
+ await resetTestSeam();
195
+ });
196
+
197
+ it('error envelope MUST project to log.appended { level: "error" } — NOT node.failed', async () => {
198
+ if (!(await isEventLogSeamAvailable())) return;
199
+ const runId = `r-uk-err-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
200
+ await accept(
201
+ {
202
+ type: 'error',
203
+ schemaVersion: 1,
204
+ envelopeId: 'env-uk-proj-err',
205
+ correlationId: `${runId}:n:0:uk-err`,
206
+ payload: { code: 'validation_failed', message: 'cannot produce JSON' },
207
+ meta: baseMeta,
208
+ },
209
+ { projectTo: { runId, nodeId: 'n' } },
210
+ );
211
+ const logs = await queryTestEvents(runId, { type: 'log.appended' });
212
+ const fails = await queryTestEvents(runId, { type: 'node.failed' });
213
+ if (!logs.ok || !fails.ok) return;
214
+ expect(
215
+ logs.events.some((e) => (e.payload as { level?: string }).level === 'error'),
216
+ driver.describe('ai-envelope.md §"Universal kinds"', 'LLM-emitted error envelope MUST project to log.appended at error level'),
217
+ ).toBe(true);
218
+ expect(
219
+ fails.events.length,
220
+ driver.describe('ai-envelope.md §"Universal kinds"', 'LLM-emitted error envelope MUST NOT project to node.failed (distinct from terminal node failure)'),
221
+ ).toBe(0);
222
+ await resetTestSeam();
223
+ });
224
+
225
+ it('schema.request projects to log.appended (host implements next-turn injection out-of-band)', async () => {
226
+ if (!(await isEventLogSeamAvailable())) return;
227
+ const runId = `r-uk-sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
228
+ await accept(
229
+ {
230
+ type: 'schema.request',
231
+ schemaVersion: 1,
232
+ envelopeId: 'env-uk-proj-sr',
233
+ correlationId: `${runId}:n:0:uk-sr`,
234
+ payload: { envelopeType: 'vendor.acme.foo' },
235
+ meta: baseMeta,
236
+ },
237
+ { projectTo: { runId, nodeId: 'n' } },
238
+ );
239
+ const events = await queryTestEvents(runId, { type: 'log.appended' });
240
+ if (!events.ok) return;
241
+ expect(
242
+ events.events.length,
243
+ driver.describe('ai-envelope.md §"Universal kinds"', 'schema.request MUST project to log.appended (the schema delivery itself happens out-of-band via the host\'s next-turn system prompt)'),
244
+ ).toBeGreaterThan(0);
245
+ await resetTestSeam();
246
+ });
247
+ });
248
+
249
+ describe('aiEnvelope.universalKinds: schema.response counter-policy advertisement (ai-envelope.md §"Universal kinds")', () => {
250
+ it('host MAY count or exempt schema.response against envelopesPerTurn; when advertised, the policy field MUST be a documented enum value', async () => {
251
+ // Per ai-envelope.md §"Universal kinds": "Engines MAY count this against
252
+ // Capabilities.limits.envelopesPerTurn or exempt it; conformance does
253
+ // not lock this choice." The conformance test only verifies that hosts
254
+ // advertising a policy field use a documented value.
255
+ const res = await driver.get('/.well-known/openwop');
256
+ const body = res.json as { capabilities?: { aiEnvelope?: { schemaResponseCounterPolicy?: string } } } | undefined;
257
+ const policy = body?.capabilities?.aiEnvelope?.schemaResponseCounterPolicy;
258
+ if (policy === undefined) return; // no policy advertised — host MAY omit
259
+ expect(
260
+ ['counted', 'exempt'].includes(policy),
261
+ driver.describe(
262
+ 'ai-envelope.md §"Universal kinds"',
263
+ 'when advertised, schemaResponseCounterPolicy MUST be either "counted" or "exempt"',
264
+ ),
265
+ ).toBe(true);
266
+ });
176
267
  });
@@ -61,6 +61,39 @@ describe('blob-presign-expiry: advertisement shape (RFC 0019)', () => {
61
61
  });
62
62
  });
63
63
 
64
- describe('blob-presign-expiry: behavioral assertions (placeholders need host test seam)', () => {
65
- it.todo("presign with ttl=60 URL works during the window, returns 403 after");
64
+ async function call(op: string, args: Record<string, unknown>) {
65
+ return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'blob', op, args });
66
+ }
67
+
68
+ describe('blob-presign-expiry: behavioral (RFC 0019 §B point 1)', () => {
69
+ it('presigned URL MUST resolve to the blob inside its TTL window and return 403 after expiry', async () => {
70
+ const probe = await call('get', { key: '__probe__' });
71
+ if (probe.status === 404) return; // seam not exposed
72
+ const key = `pre-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
73
+ const contentBase64 = Buffer.from('presigned-payload').toString('base64');
74
+ await call('put', { key, contentBase64, contentType: 'text/plain' });
75
+
76
+ // presign with TTL=2s
77
+ const presign = await call('presign', { key, expiresInSeconds: 2 });
78
+ expect(presign.status).toBe(200);
79
+ const body = presign.json as { url?: string; expiresAtMs?: number };
80
+ expect(typeof body.url, 'presign MUST return a URL').toBe('string');
81
+
82
+ // Fetch within the window — MUST return 200 + the bytes
83
+ const within = await driver.get(body.url!);
84
+ if (within.status === 404) return; // host doesn't expose the resolver route — soft-skip the expiry side too
85
+ expect(
86
+ within.status,
87
+ driver.describe('RFC 0019 §B point 1', 'presigned URL MUST resolve to 200 within its TTL window'),
88
+ ).toBe(200);
89
+
90
+ // Wait past expiry (TTL=2s + 1s buffer)
91
+ await new Promise((r) => setTimeout(r, 3000));
92
+
93
+ const after = await driver.get(body.url!);
94
+ expect(
95
+ after.status,
96
+ driver.describe('RFC 0019 §B point 1', 'presigned URL MUST return 403 after TTL expiry'),
97
+ ).toBe(403);
98
+ });
66
99
  });
@@ -42,6 +42,32 @@ describe('cache-ttl-expiry: advertisement shape (RFC 0019)', () => {
42
42
  });
43
43
  });
44
44
 
45
- describe('cache-ttl-expiry: behavioral assertions (placeholders need host test seam)', () => {
46
- it.todo("put with ttl=2 hit within window; miss after");
45
+ async function call(op: string, args: Record<string, unknown>) {
46
+ return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'cache', op, args });
47
+ }
48
+
49
+ describe('cache-ttl-expiry: behavioral (RFC 0019 §B point 2 — 1s TTL drift)', () => {
50
+ it('put with ttlSeconds=2 → hit within window; miss after expiry', async () => {
51
+ const probe = await call('get', { key: '__cache-probe__' });
52
+ if (probe.status === 404) return;
53
+ const key = `c-ttl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
+ const putRes = await call('put', { key, value: 'evicts-soon', ttlSeconds: 2 });
55
+ expect(putRes.status).toBe(200);
56
+
57
+ const within = await call('get', { key });
58
+ const withinBody = within.json as { value?: unknown; found?: boolean };
59
+ expect(
60
+ withinBody.value,
61
+ driver.describe('RFC 0019 §B point 2', 'cache get within TTL MUST return the stored value'),
62
+ ).toBe('evicts-soon');
63
+
64
+ await new Promise((r) => setTimeout(r, 3000));
65
+
66
+ const after = await call('get', { key });
67
+ const afterBody = after.json as { value?: unknown; found?: boolean };
68
+ expect(
69
+ afterBody.found,
70
+ driver.describe('RFC 0019 §B point 2', 'cache get after TTL expiry MUST surface as found:false (≤1s drift)'),
71
+ ).toBe(false);
72
+ });
47
73
  });
@@ -92,7 +92,38 @@ describe.skipIf(SKIP)('dispatch-cross-worker-handoff: sequential child→parent
92
92
  )).toBe('hello');
93
93
  });
94
94
 
95
- it.todo(
96
- 'HVMAP-1c-override: per-worker mapping overrides default mapping. dispatch.inputMapping={input:"defaultX"}; perWorkerInputMappings.child-b={input:"sharedVar"}; child-b MUST receive inputs.input from sharedVar, NOT defaultX. Requires a fixture variant carrying both default + per-worker mappings.',
97
- );
95
+ it('HVMAP-1c-override: per-worker mapping overrides default mapping per §A effectiveInputMapping precedence', async () => {
96
+ const PARENT_OVERRIDE = 'conformance-dispatch-per-worker-override';
97
+ if (!isFixtureAdvertised(PARENT_OVERRIDE)) return; // fixture not seeded — soft-skip
98
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_OVERRIDE });
99
+ expect(create.status).toBe(201);
100
+ const parentRunId = (create.json as { runId: string }).runId;
101
+ await pollUntilTerminal(parentRunId);
102
+
103
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
104
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
105
+ const dispatchedA = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_A);
106
+ const dispatchedB = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_B);
107
+ if (!dispatchedA || !dispatchedB) return;
108
+
109
+ const childARes = await driver.get(`/v1/runs/${encodeURIComponent(dispatchedA.payload!.childRunId!)}`);
110
+ const childBRes = await driver.get(`/v1/runs/${encodeURIComponent(dispatchedB.payload!.childRunId!)}`);
111
+ const childAInputs = (childARes.json as { inputs?: Record<string, unknown> }).inputs ?? {};
112
+ const childBInputs = (childBRes.json as { inputs?: Record<string, unknown> }).inputs ?? {};
113
+
114
+ expect(
115
+ childAInputs.input,
116
+ driver.describe(
117
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
118
+ 'child-a uses the DEFAULT inputMapping; input MUST come from parent.defaultX',
119
+ ),
120
+ ).toBe('default-x-value');
121
+ expect(
122
+ childBInputs.input,
123
+ driver.describe(
124
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
125
+ 'child-b uses the per-worker OVERRIDE; input MUST come from parent.sharedVar (NOT defaultX)',
126
+ ),
127
+ ).toBe('shared-value');
128
+ });
98
129
  });
@@ -24,6 +24,7 @@ import { describe, it, expect } from 'vitest';
24
24
  import { driver } from '../lib/driver.js';
25
25
  import { pollUntilTerminal } from '../lib/polling.js';
26
26
  import { isFixtureAdvertised } from '../lib/fixtures.js';
27
+ import { setHostCapability, resetHostCapabilities, isToggleAvailable } from '../lib/host-toggle.js';
27
28
 
28
29
  const PARENT = 'conformance-dispatch-input-mapping';
29
30
  const CHILD = 'conformance-dispatch-input-mapping-child';
@@ -84,11 +85,79 @@ describe.skipIf(SKIP)('dispatch-input-mapping: parent → child variable project
84
85
  )).toBe('Alice');
85
86
  });
86
87
 
87
- it.todo(
88
- 'HVMAP-1a-null: parent variable unset → child input surfaces as `undefined` (NOT omitted, NOT `null`) per §A normative bullet. Requires a fixture variant omitting parentName.defaultValue.',
89
- );
88
+ it('HVMAP-1a-null: parent variable unset → child input surfaces as `undefined` per §A', async () => {
89
+ const PARENT_NO_DEFAULT = 'conformance-dispatch-input-mapping-no-default';
90
+ if (!isFixtureAdvertised(PARENT_NO_DEFAULT) || !isFixtureAdvertised(CHILD)) return; // fixture not seeded — soft-skip
91
+ const create = await driver.post('/v1/runs', { workflowId: PARENT_NO_DEFAULT });
92
+ expect(create.status).toBe(201);
93
+ const parentRunId = (create.json as { runId: string }).runId;
94
+ await pollUntilTerminal(parentRunId);
90
95
 
91
- it.todo(
92
- 'HVMAP-1a-refusal: host advertises capabilities.agents.dispatch: true but NOT capabilities.agents.dispatchMapping: true; workflow with non-empty inputMapping MUST fail registration with validation_error + details.requiredCapability === "agents.dispatchMapping". Requires a host-capability-toggle hook in the conformance harness.',
93
- );
96
+ const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
97
+ const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
98
+ const dispatched = events.find(
99
+ (e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD,
100
+ );
101
+ if (!dispatched) return; // host doesn't emit node.dispatched — soft-skip
102
+ const childRunId = dispatched.payload?.childRunId;
103
+
104
+ const childRes = await driver.get(`/v1/runs/${encodeURIComponent(childRunId!)}`);
105
+ const child = childRes.json as RunSnapshot;
106
+ // Per RFC 0022 §A: an unset parent variable MUST surface as `undefined`.
107
+ // On the wire, `undefined` becomes either omitted from the JSON object
108
+ // OR explicit `null`; the spec REJECTS the latter. We accept either
109
+ // "key absent" or "key === undefined" but FAIL on `null`.
110
+ const inputs = child.inputs ?? {};
111
+ const v = inputs.childGreeting;
112
+ expect(
113
+ v === undefined || !('childGreeting' in inputs),
114
+ driver.describe(
115
+ 'RFCS/0022-dispatch-input-output-mapping.md §A',
116
+ 'unset parent variable projection MUST surface as undefined (NOT null, NOT a default placeholder)',
117
+ ),
118
+ ).toBe(true);
119
+ expect(v).not.toBe(null);
120
+ });
121
+
122
+ });
123
+
124
+ describe('dispatch-input-mapping: registration refusal (RFC 0022 §C HVMAP-1a-refusal)', () => {
125
+ it('host with agents.dispatchMapping toggled OFF MUST refuse non-empty inputMapping at registration', async () => {
126
+ if (!(await isToggleAvailable())) return; // seam not exposed — soft-skip
127
+ await setHostCapability('agents.dispatchMapping', false);
128
+ try {
129
+ const workflow = {
130
+ workflowId: `hvmap-1a-refusal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
131
+ nodes: [
132
+ {
133
+ nodeId: 'dispatch-1',
134
+ typeId: 'core.dispatch',
135
+ config: {
136
+ nextWorkerIds: ['child-a'],
137
+ inputMapping: { childInput: 'parentVar' }, // non-empty — refusal trigger
138
+ },
139
+ },
140
+ ],
141
+ };
142
+ const res = await driver.post('/v1/host/sample/workflows', workflow);
143
+ expect(
144
+ res.status,
145
+ driver.describe(
146
+ 'RFCS/0022-dispatch-input-output-mapping.md §C',
147
+ 'workflow with non-empty inputMapping MUST be refused when capabilities.agents.dispatchMapping is not advertised',
148
+ ),
149
+ ).toBe(400);
150
+ const body = res.json as { error?: string; details?: { requiredCapability?: string } };
151
+ expect(body.error).toBe('validation_error');
152
+ expect(
153
+ body.details?.requiredCapability,
154
+ driver.describe(
155
+ 'RFCS/0022-dispatch-input-output-mapping.md §C',
156
+ 'refusal MUST surface requiredCapability: "agents.dispatchMapping"',
157
+ ),
158
+ ).toBe('agents.dispatchMapping');
159
+ } finally {
160
+ await resetHostCapabilities();
161
+ }
162
+ });
94
163
  });