@openwop/openwop-conformance 1.24.0 → 1.25.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.
@@ -1514,7 +1514,31 @@
1514
1514
  "backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
1515
1515
  }
1516
1516
  },
1517
- "sources": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"] }, "description": "Which trigger sources bridge uniformly. A source listed here MUST have a registerable `TriggerSubscription` driven through the four-state machine AND emit the two `trigger.*` events for that source — the list MUST NOT over-claim a source the host has as a feature but does not wire as a durable trigger subscription. A consumer MUST tolerate any subset." }
1517
+ "sources": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"] }, "description": "Which trigger sources bridge uniformly. A source listed here MUST have a registerable `TriggerSubscription` driven through the four-state machine AND emit the two `trigger.*` events for that source — the list MUST NOT over-claim a source the host has as a feature but does not wire as a durable trigger subscription. A consumer MUST tolerate any subset." },
1518
+ "ingestion": {
1519
+ "type": "object",
1520
+ "additionalProperties": false,
1521
+ "description": "RFC 0099 §F.3 (additive). External-event ingestion advertisement — which of `sources[]` the host actually ingests from EXTERNALLY-originated events (`webhook`/`email`/`form`), normalizing each to a `TriggerEvent` (`trigger-event.schema.json`) and starting a run. Absent ⇒ the host does NOT externally-ingest (today's behavior — schedule/queue only). A source in `externalSources[]` MUST actually accept an external event, normalize it, and start a run — over-claiming is a dishonest advertisement. A consumer MUST tolerate any subset.",
1522
+ "properties": {
1523
+ "externalSources": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["webhook", "email", "form"] }, "description": "Which of `sources[]` are EXTERNALLY ingested per RFC 0099. The honesty gate — each MUST normalize to a `TriggerEvent` and start a run." },
1524
+ "maxBodyBytes": { "type": "integer", "minimum": 1, "description": "Inbound body cap (webhook body / email / form), reusing the RFC 0076 §B response-cap discipline." },
1525
+ "verification": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["webhook-signature", "email-dmarc", "form-origin"] }, "description": "Which source-authenticity checks the host performs. An advertised check MUST actually be performed (RFC 0099 §F.3 / UQ1)." },
1526
+ "registrationEndpoint": { "type": "boolean", "description": "`true` ⇒ the host serves `POST /v1/trigger-subscriptions` (RFC 0099 §F.2) for portable external-event subscription creation." }
1527
+ }
1528
+ }
1529
+ }
1530
+ },
1531
+ "a2a": {
1532
+ "type": "object",
1533
+ "additionalProperties": false,
1534
+ "description": "RFC 0100 (`Active`). The host exposes itself as an A2A (Agent2Agent) agent. `supported: true` alone ⇒ the SYNCHRONOUS `message/send` → poll `tasks/get` round-trip already specified by `a2a-integration.md` (today's behavior — no regression). The optional `streaming`/`pushNotifications`/`durableTasks` flags gate the RFC 0100 async/durable additions (resubscribe re-attach, push config, persisted `A2ATaskState` so `tasks/get` returns live state after disconnect). Absent block ⇒ no A2A advertisement.",
1535
+ "required": ["supported", "agentCardUrl"],
1536
+ "properties": {
1537
+ "supported": { "type": "boolean", "description": "Host exposes itself as an A2A agent." },
1538
+ "agentCardUrl": { "type": "string", "format": "uri", "description": "The A2A 0.3 well-known agent card URL (`/.well-known/agent-card.json`)." },
1539
+ "streaming": { "type": "boolean", "description": "Host supports `message/stream` + `tasks/resubscribe` (A2A `capabilities.streaming`). Gates the RFC 0100 §3 resubscribe re-attach." },
1540
+ "pushNotifications": { "type": "boolean", "description": "Host supports A2A push-notification config (A2A `capabilities.push_notifications`). Gates the RFC 0100 §4 push contract; a caller-supplied `pushConfig.url` is SSRF-validated (`a2a-push-egress-ssrf`)." },
1541
+ "durableTasks": { "type": "boolean", "description": "RFC 0100 §2. Host PERSISTS the projected Task (`A2ATaskState`) per backing run; `tasks/get` returns live state after disconnect. Absent/false ⇒ synchronous round-trip only." }
1518
1542
  }
1519
1543
  },
1520
1544
  "budget": {
@@ -0,0 +1,149 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/trigger-event.schema.json",
4
+ "title": "TriggerEvent",
5
+ "description": "RFC 0099 §F.1. The normalized external-event envelope a host hands a started run as `ctx.triggerData` when an externally-originated event (`webhook`/`email`/`form`) is delivered to an `active` TriggerSubscription (RFC 0083). This is an IN-RUN payload only — it never appears on the durable event log; the `trigger.delivery.attempted` event stays content-free (RFC 0083 §C / SECURITY `trigger-ingestion-content-redaction`). A TriggerEvent carries exactly the per-source sub-object matching its `source`. All content is `untrusted` (SECURITY `threat-model-prompt-injection.md`).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["source", "subscriptionId", "deliveryId", "receivedAt"],
9
+ "properties": {
10
+ "source": {
11
+ "type": "string",
12
+ "enum": ["webhook", "email", "form"],
13
+ "description": "Which external source originated the event. A TriggerEvent MUST carry exactly the per-source sub-object matching this value and MUST NOT carry the others (RFC 0099 §F.1)."
14
+ },
15
+ "subscriptionId": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "The RFC 0083 §B subscription this event was delivered to."
19
+ },
20
+ "deliveryId": {
21
+ "type": "string",
22
+ "minLength": 1,
23
+ "description": "Stable per-delivery id; equals the `causationId` stamped on `run.started` (RFC 0083 §C-3 / RFC 0040)."
24
+ },
25
+ "dedupKey": {
26
+ "type": "string",
27
+ "description": "The host-opaque dedup key (RFC 0083 §C-1). Present iff the subscription's `dedupEnabled`. MUST NOT embed inbound body/header content in cleartext (SECURITY `trigger-ingestion-content-redaction`)."
28
+ },
29
+ "receivedAt": {
30
+ "type": "string",
31
+ "format": "date-time",
32
+ "description": "When the host received the external event."
33
+ },
34
+ "verified": {
35
+ "type": "boolean",
36
+ "description": "Whether the host verified the source's authenticity (webhook signature / email DMARC / form CSRF+origin) before delivery per the subscription's `verification` policy (RFC 0099 §F.2). A run MUST be able to gate on this."
37
+ },
38
+ "contentTrust": {
39
+ "type": "string",
40
+ "enum": ["untrusted"],
41
+ "description": "Always `untrusted`. Inbound external content reaching an LLM node MUST be wrapped per `threat-model-prompt-injection.md` §UNTRUSTED; a TriggerEvent MUST NOT directly advance a HITL approval gate (the `prompt-injection-mcp-no-approval` invariant generalizes)."
42
+ },
43
+ "webhook": { "$ref": "#/$defs/WebhookEvent" },
44
+ "email": { "$ref": "#/$defs/EmailEvent" },
45
+ "form": { "$ref": "#/$defs/FormEvent" }
46
+ },
47
+ "allOf": [
48
+ {
49
+ "description": "RFC 0099 §F.1 — a TriggerEvent MUST carry exactly the per-source sub-object matching its `source` and MUST NOT carry the others.",
50
+ "if": { "properties": { "source": { "const": "webhook" } } },
51
+ "then": { "not": { "anyOf": [{ "required": ["email"] }, { "required": ["form"] }] } }
52
+ },
53
+ {
54
+ "if": { "properties": { "source": { "const": "email" } } },
55
+ "then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["form"] }] } }
56
+ },
57
+ {
58
+ "if": { "properties": { "source": { "const": "form" } } },
59
+ "then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["email"] }] } }
60
+ }
61
+ ],
62
+ "$defs": {
63
+ "WebhookEvent": {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "description": "Present iff `source == \"webhook\"`.",
67
+ "properties": {
68
+ "method": { "type": "string", "enum": ["POST", "PUT", "PATCH"] },
69
+ "headers": {
70
+ "type": "object",
71
+ "additionalProperties": { "type": "string" },
72
+ "description": "Host-allowlisted headers only — a host MUST NOT pass through `Authorization`, `Cookie`, `Proxy-Authorization`, or any header carrying credential material (RFC 0099 §F.1 / SECURITY `trigger-ingestion-content-redaction`)."
73
+ },
74
+ "body": {
75
+ "description": "The parsed JSON body, or a string for non-JSON. Bounded by `triggerBridge.ingestion.maxBodyBytes`."
76
+ }
77
+ }
78
+ },
79
+ "EmailEvent": {
80
+ "type": "object",
81
+ "additionalProperties": false,
82
+ "description": "Present iff `source == \"email\"`.",
83
+ "properties": {
84
+ "from": { "type": "string" },
85
+ "to": { "type": "array", "items": { "type": "string" } },
86
+ "subject": { "type": "string" },
87
+ "text": { "type": "string" },
88
+ "html": { "type": "string" },
89
+ "attachments": { "type": "array", "items": { "$ref": "#/$defs/AttachmentRef" } }
90
+ }
91
+ },
92
+ "FormEvent": {
93
+ "type": "object",
94
+ "additionalProperties": false,
95
+ "description": "Present iff `source == \"form\"`.",
96
+ "properties": {
97
+ "fields": { "type": "object", "additionalProperties": true, "description": "The submitted field map." },
98
+ "files": { "type": "array", "items": { "$ref": "#/$defs/AttachmentRef" } }
99
+ }
100
+ },
101
+ "AttachmentRef": {
102
+ "type": "object",
103
+ "additionalProperties": false,
104
+ "required": ["ref"],
105
+ "description": "A host-internal opaque handle to a resolved attachment/upload — NEVER a raw external URL the run is expected to fetch itself (RFC 0099 §F.1/§F.4). The host resolves and ingests attachments through its SSRF guard (SECURITY `trigger-ingestion-ssrf`), then hands the run an internal ref.",
106
+ "properties": {
107
+ "ref": {
108
+ "type": "string",
109
+ "minLength": 1,
110
+ "description": "A host-internal opaque handle (e.g. a `host.blobStorage` key)."
111
+ },
112
+ "filename": { "type": "string" },
113
+ "mediaType": { "type": "string" },
114
+ "bytes": { "type": "integer", "minimum": 0 }
115
+ }
116
+ }
117
+ },
118
+ "examples": [
119
+ {
120
+ "source": "email",
121
+ "subscriptionId": "sub_7",
122
+ "deliveryId": "dlv_a1b2",
123
+ "dedupKey": "msgid:abc@in.example.com",
124
+ "receivedAt": "2026-06-13T18:04:11Z",
125
+ "verified": true,
126
+ "contentTrust": "untrusted",
127
+ "email": {
128
+ "from": "customer@example.org",
129
+ "to": ["triage-support+sub_7@in.example.com"],
130
+ "subject": "Cannot log in",
131
+ "text": "I keep getting a 403.",
132
+ "attachments": [{ "ref": "blob_a1", "filename": "screenshot.png", "mediaType": "image/png", "bytes": 20481 }]
133
+ }
134
+ },
135
+ {
136
+ "source": "webhook",
137
+ "subscriptionId": "sub_9",
138
+ "deliveryId": "dlv_c3d4",
139
+ "receivedAt": "2026-06-13T18:10:00Z",
140
+ "verified": true,
141
+ "contentTrust": "untrusted",
142
+ "webhook": {
143
+ "method": "POST",
144
+ "headers": { "X-Event-Type": "issue.created" },
145
+ "body": { "issue": { "id": 42, "title": "Bug" } }
146
+ }
147
+ }
148
+ ]
149
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/trigger-subscription-registration.schema.json",
4
+ "title": "TriggerSubscriptionRegistration",
5
+ "description": "RFC 0099 §F.2. The portable create request for an external-event trigger subscription, served by `POST /v1/trigger-subscriptions`. Binds an external source (`webhook`/`email`/`form`) to a Workflow to start, with a dedup config and a source-authenticity verification policy. This is the unified create surface RFC 0083 UQ1 left per-source. The response returns the created `TriggerSubscription` (RFC 0083 §B) plus a source-specific `binding`; the binding secret/URL is returned ONCE at creation and is not re-fetchable in cleartext (SR-1).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["source", "workflowId"],
9
+ "properties": {
10
+ "source": {
11
+ "type": "string",
12
+ "enum": ["webhook", "email", "form"],
13
+ "description": "The external source this subscription ingests. MUST appear in the host's `capabilities.triggerBridge.ingestion.externalSources[]`."
14
+ },
15
+ "workflowId": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "The Workflow a delivered event starts (the run's `workflowId`). MUST resolve under the caller's RFC 0048 owner triple — a registration MUST NOT bind a workflow the caller cannot start (RFC 0049 scope check)."
19
+ },
20
+ "dedupEnabled": {
21
+ "type": "boolean",
22
+ "default": true,
23
+ "description": "Per RFC 0083 §C-1. Defaults true for external sources — at-least-once inbound delivery without dedup is a footgun."
24
+ },
25
+ "retryPolicy": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "description": "Delivery retry policy (RFC 0083 §C-2). Same shape as `trigger-subscription.schema.json#/properties/retryPolicy`. On exhaustion the delivery transitions to `dead-lettered` (RFC 0053).",
29
+ "properties": {
30
+ "maxAttempts": { "type": "integer", "minimum": 1, "description": "Maximum delivery attempts before dead-lettering." },
31
+ "backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
32
+ }
33
+ },
34
+ "verification": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "description": "Source-authenticity policy. The host MUST verify per this policy BEFORE delivery and stamp `TriggerEvent.verified`; an event failing a `required` verification MUST NOT start a run (it transitions `trigger.delivery.attempted{outcome:'dead-lettered'}` with `trigger.subscription.state.changed.reason:'signature-invalid'`).",
38
+ "properties": {
39
+ "mode": {
40
+ "type": "string",
41
+ "enum": ["required", "best-effort", "none"],
42
+ "default": "required",
43
+ "description": "`required`: an unverified event is dead-lettered, no run. `best-effort`: the host verifies when it can and stamps `verified` accordingly but still delivers. `none`: no verification."
44
+ }
45
+ }
46
+ },
47
+ "inputMapping": {
48
+ "type": "object",
49
+ "additionalProperties": true,
50
+ "description": "OPTIONAL host-extension hint mapping `TriggerEvent` fields onto the workflow's `inputs`. Non-normative shape (host-specific); absent ⇒ the run receives the whole `TriggerEvent` as `ctx.triggerData`."
51
+ }
52
+ },
53
+ "examples": [
54
+ {
55
+ "source": "email",
56
+ "workflowId": "triage-support",
57
+ "dedupEnabled": true,
58
+ "verification": { "mode": "required" }
59
+ },
60
+ {
61
+ "source": "webhook",
62
+ "workflowId": "ingest-jira-issue",
63
+ "verification": { "mode": "required" },
64
+ "retryPolicy": { "maxAttempts": 5, "backoff": "exponential" }
65
+ }
66
+ ]
67
+ }
@@ -236,15 +236,18 @@ export function isMemory(c: DiscoveryPayload): boolean {
236
236
  }
237
237
 
238
238
  /**
239
- * `openwop-trigger-bridge` predicate (RFC 0083). Host composes the durable
240
- * inbound-work contract: advertises the `triggerBridge`, has a `deadLetter`
241
- * sink for exhausted deliveries, and has at least one durable inbound source
242
- * (queue bus, durable webhooks, or scheduling). Capability families are
239
+ * `openwop-trigger-bridge` predicate (RFC 0083, widened by RFC 0099). Host
240
+ * composes the durable inbound-work contract: advertises the `triggerBridge`,
241
+ * has a `deadLetter` sink for exhausted deliveries, and has at least one
242
+ * durable inbound source (queue bus, durable webhooks, scheduling, OR per
243
+ * RFC 0099 — externally-ingested `email`/`form` via
244
+ * `triggerBridge.ingestion.externalSources[]`). Capability families are
243
245
  * document-root properties (RFC 0073), so this reads `c.triggerBridge` /
244
- * `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`.
246
+ * `c.deadLetter` / `c.queueBus` / `c.webhooks` / `c.scheduling`. The RFC 0099
247
+ * widening only ADDS disjuncts — a host already in the profile stays in it.
245
248
  *
246
249
  * @see spec/v1/profiles.md §`openwop-trigger-bridge`
247
- * @see spec/v1/trigger-bridge.md
250
+ * @see spec/v1/trigger-bridge.md §D / §F
248
251
  */
249
252
  export function isTriggerBridge(c: DiscoveryPayload): boolean {
250
253
  if (!isCore(c)) return false;
@@ -253,10 +256,16 @@ export function isTriggerBridge(c: DiscoveryPayload): boolean {
253
256
  if (!supported(c.triggerBridge)) return false;
254
257
  if (!supported(c.deadLetter)) return false;
255
258
  const webhooks = c.webhooks as { durable?: unknown } | undefined;
259
+ // RFC 0099: an externally-ingested email/form source is a durable inbound
260
+ // source for the profile (it rides the same four-state machine + dead-letter).
261
+ const ingestion = (c.triggerBridge as { ingestion?: { externalSources?: unknown } } | undefined)?.ingestion;
262
+ const externalSources = Array.isArray(ingestion?.externalSources) ? (ingestion!.externalSources as unknown[]) : [];
263
+ const externalEmailOrForm = externalSources.includes('email') || externalSources.includes('form');
256
264
  const durableSource =
257
265
  supported(c.queueBus) ||
258
266
  supported(c.scheduling) ||
259
- (webhooks != null && typeof webhooks === 'object' && webhooks.durable === true);
267
+ (webhooks != null && typeof webhooks === 'object' && webhooks.durable === true) ||
268
+ externalEmailOrForm;
260
269
  return durableSource;
261
270
  }
262
271
 
@@ -36,12 +36,20 @@
36
36
  */
37
37
 
38
38
  import { describe, it, expect } from 'vitest';
39
+ import { readFileSync } from 'node:fs';
40
+ import { join } from 'node:path';
41
+ import Ajv2020 from 'ajv/dist/2020.js';
42
+ import addFormats from 'ajv-formats';
39
43
  import { driver } from '../lib/driver.js';
40
44
  import { getA2AFakePeer } from '../lib/a2a-fake-peer.js';
41
45
  import { isFixtureAdvertised } from '../lib/fixtures.js';
42
46
  import { pollUntilTerminal, pollUntilStatus } from '../lib/polling.js';
47
+ import { SCHEMAS_DIR } from '../lib/paths.js';
48
+ import { behaviorGate } from '../lib/behavior-gate.js';
49
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
43
50
 
44
51
  const ROUNDTRIP_FIXTURE = 'conformance-a2a-task-roundtrip';
52
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
45
53
 
46
54
  /** Resolve the A2A endpoint to probe: real-peer env wins; otherwise the in-process fake. */
47
55
  function probePeer(): { url: string; isReal: boolean } | null {
@@ -266,3 +274,131 @@ describe('a2a-task-roundtrip: drift point #4 — REJECTED projects to failed', (
266
274
  )).toBe(true);
267
275
  });
268
276
  });
277
+
278
+ // ─── RFC 0100: async / durable A2A tasks ──────────────────────────────
279
+ // Capability-shape always-on + durable-get / resubscribe / push-SSRF gated.
280
+
281
+ describe('a2a-task-roundtrip: A2ATaskState + a2a capability shape (always-on, server-free; RFC 0100)', () => {
282
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
283
+ addFormats(ajv);
284
+ const taskStateSchema = JSON.parse(
285
+ readFileSync(join(SCHEMAS_DIR, 'a2a-task-state.schema.json'), 'utf8'),
286
+ );
287
+ const capabilitiesSchema = JSON.parse(
288
+ readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'),
289
+ );
290
+ const validateTaskState = ajv.compile(taskStateSchema);
291
+ const a2aBlockSchema = capabilitiesSchema.properties?.a2a;
292
+
293
+ it('a conforming A2ATaskState validates with the lowercase-hyphen state enum and taskId == runId', () => {
294
+ const ok = {
295
+ taskId: 'run_x',
296
+ runId: 'run_x',
297
+ contextId: 'ctx_42',
298
+ state: 'input-required',
299
+ interruptKind: 'approval',
300
+ updatedAt: '2026-06-13T19:00:00Z',
301
+ };
302
+ expect(
303
+ validateTaskState(ok),
304
+ `a2a-task-state.schema.json MUST accept a conforming record. Errors: ${JSON.stringify(validateTaskState.errors)}`,
305
+ ).toBe(true);
306
+ });
307
+
308
+ it('an UPPERCASE state fails (the persisted/wire form is the A2A v0.3 lowercase-hyphen variant)', () => {
309
+ expect(
310
+ validateTaskState({ taskId: 'r', runId: 'r', state: 'WORKING', updatedAt: '2026-06-13T19:00:00Z' }),
311
+ 'a2a-integration.md spelling-drift note — the persisted A2ATaskState.state MUST be the lowercase-hyphen form',
312
+ ).toBe(false);
313
+ });
314
+
315
+ it('an A2ATaskState carrying run inputs/artifacts inline fails (additionalProperties:false; SR-1)', () => {
316
+ expect(
317
+ validateTaskState({
318
+ taskId: 'r',
319
+ runId: 'r',
320
+ state: 'completed',
321
+ updatedAt: '2026-06-13T19:00:00Z',
322
+ inputs: { secret: 'x' },
323
+ }),
324
+ 'SECURITY a2a-push-egress-ssrf / SR-1 — the persisted record MUST NOT carry run inputs/outputs/artifacts inline',
325
+ ).toBe(false);
326
+ });
327
+
328
+ it('a PushConfig requires `url` and structurally rejects a raw (non-truncated) push token', () => {
329
+ const validatePush = ajv.compile({
330
+ $ref: 'https://openwop.dev/spec/v1/a2a-task-state.schema.json#/$defs/PushConfig',
331
+ $defs: taskStateSchema.$defs,
332
+ });
333
+ expect(validatePush({ tokenFingerprint: 'a1b2' }), 'PushConfig MUST require `url`').toBe(false);
334
+ expect(
335
+ validatePush({ url: 'https://caller.example.com/push', tokenFingerprint: 'a'.repeat(33) }),
336
+ 'SECURITY a2a-push-egress-ssrf — tokenFingerprint maxLength:32 structurally rejects a full-length raw token (SR-1)',
337
+ ).toBe(false);
338
+ expect(
339
+ validatePush({ url: 'https://caller.example.com/push', tokenFingerprint: 'a1b2c3d4' }),
340
+ 'a truncated fingerprint + uri url MUST validate',
341
+ ).toBe(true);
342
+ });
343
+
344
+ it('the capabilities.a2a block shape is declared (supported + agentCardUrl required; three optional booleans)', () => {
345
+ expect(a2aBlockSchema, 'capabilities.schema.json MUST declare the a2a block').toBeDefined();
346
+ expect(a2aBlockSchema.required).toEqual(expect.arrayContaining(['supported', 'agentCardUrl']));
347
+ expect(a2aBlockSchema.additionalProperties).toBe(false);
348
+ const validateA2A = ajv.compile({ ...a2aBlockSchema, $id: 'urn:test:a2a-block' });
349
+ expect(
350
+ validateA2A({ supported: true, agentCardUrl: 'https://example.com/.well-known/agent-card.json', durableTasks: true }),
351
+ `a conforming a2a block MUST validate. Errors: ${JSON.stringify(validateA2A.errors)}`,
352
+ ).toBe(true);
353
+ expect(validateA2A({ supported: true }), 'agentCardUrl is required').toBe(false);
354
+ });
355
+ });
356
+
357
+ describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: durable tasks/get after disconnect (gated on a2a.durableTasks; RFC 0100)', () => {
358
+ it('a paused-at-HITL run projects a live input-required task on a later tasks/get read', async () => {
359
+ const a2a = await readCapabilityFamily<{ durableTasks?: boolean }>('a2a');
360
+ if (!behaviorGate('a2a.durableTasks', a2a?.durableTasks === true)) return;
361
+
362
+ // Host-extension durable-task read seam (RFC 0100 §2). The host drives a
363
+ // backing run to a paused HITL state; we read the persisted projection
364
+ // WITHOUT holding the original connection.
365
+ const start = await driver.post('/v1/host/sample/a2a/tasks/start', {
366
+ scenario: 'paused-at-approval',
367
+ });
368
+ if (start.status === 404 || start.status === 403) return; // seam unwired — soft-skip
369
+ const taskId = (start.json as { taskId?: string })?.taskId;
370
+ if (!taskId) return;
371
+
372
+ const read = await driver.get(`/v1/host/sample/a2a/tasks/${encodeURIComponent(taskId)}`);
373
+ if (read.status === 404 || read.status === 403) return;
374
+ const state = read.json as { state?: string; runId?: string; metadata?: { openwop?: { interrupt?: { kind?: string } } } };
375
+ expect(
376
+ state.state,
377
+ driver.describe('a2a-integration.md §"Async / durable Tasks"', 'tasks/get after disconnect MUST return the live input-required projection (not a stale working)'),
378
+ ).toBe('input-required');
379
+ expect(
380
+ state.runId,
381
+ driver.describe('a2a-task-state.schema.json', 'taskId MUST equal the backing runId'),
382
+ ).toBe(taskId);
383
+ });
384
+ });
385
+
386
+ describe.skipIf(HTTP_SKIP)('a2a-task-roundtrip: push-config SSRF (gated on a2a.pushNotifications; RFC 0100)', () => {
387
+ it('registering a pushConfig.url at a private address is refused (a2a-push-egress-ssrf)', async () => {
388
+ const a2a = await readCapabilityFamily<{ pushNotifications?: boolean }>('a2a');
389
+ if (!behaviorGate('a2a.pushNotifications', a2a?.pushNotifications === true)) return;
390
+
391
+ const res = await driver.post('/v1/host/sample/a2a/tasks/push-config', {
392
+ taskId: 'run_x',
393
+ url: 'http://10.0.0.5/push',
394
+ });
395
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
396
+ expect(
397
+ res.status >= 400,
398
+ driver.describe(
399
+ 'a2a-integration.md §"Async / durable Tasks"',
400
+ 'a2a-push-egress-ssrf — a caller-supplied pushConfig.url at a private/loopback address MUST be refused before any push',
401
+ ),
402
+ ).toBe(true);
403
+ });
404
+ });
@@ -188,6 +188,44 @@ describe('fixtures: connection-pack-manifest schema validity', () => {
188
188
  }
189
189
  });
190
190
 
191
+ describe('fixtures: trigger-event + registration schema validity', () => {
192
+ // External-event ingestion fixtures live in `fixtures/trigger-events/`
193
+ // (RFC 0099). They are schema-level proof points validated against the
194
+ // `TriggerEvent` / `TriggerSubscriptionRegistration` schemas — NOT seeded
195
+ // into a workflow store. A fixture is dispatched to the right schema by a
196
+ // filename convention: `trigger-event-*` → trigger-event.schema.json;
197
+ // `trigger-subscription-registration-*` → the registration schema.
198
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
199
+ addFormats(ajv);
200
+ // The registration schema $refs the subscription schema; register it.
201
+ ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-subscription.schema.json'), 'utf8')));
202
+ const validateEvent = ajv.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-event.schema.json'), 'utf8')));
203
+ const validateReg = ajv.compile(
204
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-subscription-registration.schema.json'), 'utf8')),
205
+ );
206
+
207
+ const dir = join(FIXTURES_DIR, 'trigger-events');
208
+ const files = readdirSync(dir)
209
+ .filter((f) => f.endsWith('.json'))
210
+ .sort();
211
+
212
+ it('finds at least one trigger-event fixture (RFC 0099 coverage)', () => {
213
+ expect(files.length).toBeGreaterThan(0);
214
+ });
215
+
216
+ for (const file of files) {
217
+ it(`trigger-events/${file} validates against its schema`, () => {
218
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
219
+ const validate = file.startsWith('trigger-subscription-registration') ? validateReg : validateEvent;
220
+ const ok = validate(data);
221
+ const errors = (validate.errors ?? [])
222
+ .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
223
+ .join('\n');
224
+ expect(ok, `Fixture trigger-events/${file} fails its schema:\n${errors}`).toBe(true);
225
+ });
226
+ }
227
+ });
228
+
191
229
  describe('fixtures: prompt-template schema validity', () => {
192
230
  // PromptTemplate fixtures live in `fixtures/prompt-templates/` per
193
231
  // RFC 0027 §A. Like pack manifests, they're schema-level proof points,