@openwop/openwop-conformance 1.24.0 → 1.26.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 +26 -0
- package/README.md +2 -2
- package/api/openapi.yaml +102 -0
- package/coverage.md +2 -0
- package/dist/lib/profiles.js +16 -7
- package/fixtures/trigger-events/trigger-event-email.json +18 -0
- package/fixtures/trigger-events/trigger-subscription-registration-email.json +6 -0
- package/fixtures.md +13 -0
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/a2a-task-state.schema.json +78 -0
- package/schemas/capabilities.schema.json +25 -1
- package/schemas/envelopes/ui.a2ui-surface.schema.json +154 -0
- package/schemas/trigger-event.schema.json +149 -0
- package/schemas/trigger-subscription-registration.schema.json +67 -0
- package/src/lib/profiles.ts +16 -7
- package/src/scenarios/a2a-task-roundtrip.test.ts +136 -0
- package/src/scenarios/a2ui-surface-degrades.test.ts +54 -0
- package/src/scenarios/a2ui-surface-replay.test.ts +84 -0
- package/src/scenarios/a2ui-surface-shape.test.ts +162 -0
- package/src/scenarios/a2ui-surface-version-refusal.test.ts +77 -0
- package/src/scenarios/a2ui-untrusted-blocks-approval.test.ts +68 -0
- package/src/scenarios/fixtures-valid.test.ts +38 -0
- package/src/scenarios/trigger-ingestion.test.ts +235 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/a2a-task-state.schema.json",
|
|
4
|
+
"title": "A2ATaskState",
|
|
5
|
+
"description": "RFC 0100 §2. The durable, persisted projection of an A2A `Task` an OpenWOP host keeps per backing run when it advertises `capabilities.a2a.durableTasks: true` — durable for the run's whole lifecycle (surviving caller disconnect, host restart within retention, and HITL pauses) so `tasks/get` returns live state after a disconnect. It is the persisted form of the `a2a-integration.md` §\"State projection (forward)\" mapping, NOT a new mapping. Content-free of run inputs/outputs/artifacts/credential material (SR-1 / `a2a-integration.md` trust boundary) — artifacts project to A2A `Artifact`s over the A2A transport, not into this record.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["taskId", "runId", "state", "updatedAt"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"taskId": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"minLength": 1,
|
|
13
|
+
"description": "The A2A Task.id. MUST equal the backing OpenWOP `runId` (a2a-integration.md §2 — 'the returned runId becomes the A2A Task.id')."
|
|
14
|
+
},
|
|
15
|
+
"runId": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "The backing OpenWOP run. Bound 1:1 to `taskId`."
|
|
19
|
+
},
|
|
20
|
+
"contextId": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The A2A context_id (carried as the run tag `a2a:ctx_*` per a2a-integration.md §2)."
|
|
23
|
+
},
|
|
24
|
+
"state": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"enum": ["submitted", "working", "input-required", "auth-required", "completed", "failed", "canceled", "rejected"],
|
|
27
|
+
"description": "The A2A 0.3 JSON-RPC wire form (lowercase-hyphen — a2a-integration.md §'Wire-shape spelling drift'). Projected from `run.status` per a2a-integration.md §'State projection (forward)': pending→submitted, running→working, paused→working, waiting-approval/waiting-input→input-required, completed→completed, failed→failed, cancelled→canceled. `auth-required` is carried for reverse-direction fidelity only; the forward projection never sets it (openwop has no native auth interrupt — a2a-integration.md drift point #3)."
|
|
28
|
+
},
|
|
29
|
+
"interruptKind": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": ["approval", "clarification"],
|
|
32
|
+
"description": "Present iff `state == 'input-required'`. Disambiguates a2a-integration.md drift point #2 (both approval + clarification project to INPUT_REQUIRED) — carried in `Task.metadata.openwop.interrupt.kind`, the `metadata.openwop.*` shape the doc's Future-work asks to codify."
|
|
33
|
+
},
|
|
34
|
+
"updatedAt": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"format": "date-time",
|
|
37
|
+
"description": "When the projected state last changed."
|
|
38
|
+
},
|
|
39
|
+
"pushConfig": { "$ref": "#/$defs/PushConfig" }
|
|
40
|
+
},
|
|
41
|
+
"$defs": {
|
|
42
|
+
"PushConfig": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"required": ["url"],
|
|
46
|
+
"description": "RFC 0100 §4. A caller-registered A2A push-notification config for this Task.",
|
|
47
|
+
"properties": {
|
|
48
|
+
"url": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"format": "uri",
|
|
51
|
+
"description": "Caller-registered push target. The host MUST validate it through the RFC 0093 webhook-egress SSRF guard before any push (no private/loopback/link-local target) — SECURITY invariant `a2a-push-egress-ssrf`."
|
|
52
|
+
},
|
|
53
|
+
"tokenFingerprint": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"maxLength": 32,
|
|
56
|
+
"description": "MAY — a truncated/salted digest of the caller's push-auth token (NEVER the raw token; same SR-1 rule as RFC 0083's `secretFingerprint`). The A2A push HMAC details (A2A §4.3.3) stay inside the A2A layer per a2a-integration.md."
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"examples": [
|
|
62
|
+
{
|
|
63
|
+
"taskId": "run_x",
|
|
64
|
+
"runId": "run_x",
|
|
65
|
+
"contextId": "ctx_42",
|
|
66
|
+
"state": "input-required",
|
|
67
|
+
"interruptKind": "approval",
|
|
68
|
+
"updatedAt": "2026-06-13T19:00:00Z",
|
|
69
|
+
"pushConfig": { "url": "https://caller.example.com/a2a/push", "tokenFingerprint": "a1b2c3d4e5f6" }
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"taskId": "run_y",
|
|
73
|
+
"runId": "run_y",
|
|
74
|
+
"state": "completed",
|
|
75
|
+
"updatedAt": "2026-06-13T19:05:00Z"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
@@ -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,154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/envelopes/ui.a2ui-surface.schema.json",
|
|
4
|
+
"title": "UiA2uiSurfacePayload",
|
|
5
|
+
"description": "Payload for the OPTIONAL, advertised `ui.a2ui-surface` AI Envelope kind (RFC 0102). Carries a declarative A2UI interface surface — a closed component tree the consumer renders with native widgets, routing user actions back to the producing agent WITHOUT executing any agent-supplied code. `ui.*` is a core, un-namespaced content-primitive family beside `media.*` (ai-envelope.md §\"A2UI surfaces\" / §\"Vendor-namespaced kinds\"). Not one of the four MUST-recognize universal kinds; an unrecognizing consumer falls back to store-without-render.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["catalogVersion", "surface"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"reasoning": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "OPTIONAL per RFC 0030 §A — the model's reasoning, conventionally the first property of a kind whose payload benefits from multi-step composition."
|
|
13
|
+
},
|
|
14
|
+
"catalogVersion": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"enum": ["0.9.1"],
|
|
17
|
+
"description": "REQUIRED — the A2UI catalog version the surface targets. This enum IS the host's supported-version set (ai-envelope.md §\"A2UI surfaces\"); it is never a free string the producer invents. A consumer MUST refuse an unknown/higher version with `unknown_schema_version`. Pinning the version in the payload keeps `:fork`/replay deterministic after the external A2UI standard ships a breaking version."
|
|
18
|
+
},
|
|
19
|
+
"surface": {
|
|
20
|
+
"$ref": "#/$defs/surface",
|
|
21
|
+
"description": "REQUIRED — the A2UI surface document: a closed component tree (ai-envelope.md §\"A2UI surfaces\"). Self-contained: renderable from the payload alone, never a live reference into an external catalog."
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"$defs": {
|
|
25
|
+
"surface": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["components"],
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"properties": {
|
|
30
|
+
"title": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "OPTIONAL display title for the surface."
|
|
33
|
+
},
|
|
34
|
+
"components": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"description": "The flat, ordered component list. Each element is one of the host's day-1 catalog components, discriminated by the single-string-enum `component` field. `anyOf` (not `oneOf`) per ai-envelope.md §\"Schema discipline\".",
|
|
37
|
+
"items": { "$ref": "#/$defs/component" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"component": {
|
|
42
|
+
"description": "A single A2UI component. Closed set (day-1 catalog 0.9.1); the `component` discriminator is a single-string-enum per branch.",
|
|
43
|
+
"anyOf": [
|
|
44
|
+
{ "$ref": "#/$defs/heading" },
|
|
45
|
+
{ "$ref": "#/$defs/text" },
|
|
46
|
+
{ "$ref": "#/$defs/fieldText" },
|
|
47
|
+
{ "$ref": "#/$defs/fieldDate" },
|
|
48
|
+
{ "$ref": "#/$defs/fieldSelect" },
|
|
49
|
+
{ "$ref": "#/$defs/fieldCheckbox" },
|
|
50
|
+
{ "$ref": "#/$defs/actionButton" }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"heading": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"required": ["component", "text"],
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"properties": {
|
|
58
|
+
"component": { "type": "string", "enum": ["heading"] },
|
|
59
|
+
"text": { "type": "string" },
|
|
60
|
+
"level": { "type": "integer", "minimum": 1, "maximum": 6 }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"text": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"required": ["component", "text"],
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"properties": {
|
|
68
|
+
"component": { "type": "string", "enum": ["text"] },
|
|
69
|
+
"text": { "type": "string" }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"fieldText": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"required": ["component", "id", "label"],
|
|
75
|
+
"additionalProperties": false,
|
|
76
|
+
"properties": {
|
|
77
|
+
"component": { "type": "string", "enum": ["field.text"] },
|
|
78
|
+
"id": { "type": "string", "description": "Binding key; the collected value is keyed by `id` in the resume value." },
|
|
79
|
+
"label": { "type": "string" },
|
|
80
|
+
"placeholder": { "type": "string" },
|
|
81
|
+
"required": { "type": "boolean" }
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"fieldDate": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"required": ["component", "id", "label"],
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"properties": {
|
|
89
|
+
"component": { "type": "string", "enum": ["field.date"] },
|
|
90
|
+
"id": { "type": "string" },
|
|
91
|
+
"label": { "type": "string" },
|
|
92
|
+
"required": { "type": "boolean" }
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"fieldSelect": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"required": ["component", "id", "label", "options"],
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"properties": {
|
|
100
|
+
"component": { "type": "string", "enum": ["field.select"] },
|
|
101
|
+
"id": { "type": "string" },
|
|
102
|
+
"label": { "type": "string" },
|
|
103
|
+
"required": { "type": "boolean" },
|
|
104
|
+
"options": {
|
|
105
|
+
"type": "array",
|
|
106
|
+
"minItems": 1,
|
|
107
|
+
"items": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"required": ["value", "label"],
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"properties": {
|
|
112
|
+
"value": { "type": "string" },
|
|
113
|
+
"label": { "type": "string" }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"fieldCheckbox": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"required": ["component", "id", "label"],
|
|
122
|
+
"additionalProperties": false,
|
|
123
|
+
"properties": {
|
|
124
|
+
"component": { "type": "string", "enum": ["field.checkbox"] },
|
|
125
|
+
"id": { "type": "string" },
|
|
126
|
+
"label": { "type": "string" },
|
|
127
|
+
"default": { "type": "boolean" }
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"actionButton": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"required": ["component", "id", "label", "action"],
|
|
133
|
+
"additionalProperties": false,
|
|
134
|
+
"properties": {
|
|
135
|
+
"component": { "type": "string", "enum": ["action.button"] },
|
|
136
|
+
"id": { "type": "string" },
|
|
137
|
+
"label": { "type": "string" },
|
|
138
|
+
"action": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"required": ["target"],
|
|
141
|
+
"additionalProperties": false,
|
|
142
|
+
"description": "Confined action (ai-envelope.md §\"A2UI surfaces\" rule 2). `target` resolves to exactly one host-allowlisted destination — a run interrupt resume or a conversation exchange — never an arbitrary URL, endpoint, or network egress. Enforced by the `a2ui-action-confinement` + `a2ui-surface-no-network-egress` SECURITY invariants.",
|
|
143
|
+
"properties": {
|
|
144
|
+
"target": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"enum": ["resume", "exchange"],
|
|
147
|
+
"description": "`resume` → the collected field values become the interrupt `resumeValue` (interrupt.md). `exchange` → the values become a conversation exchange message (RFC 0005)."
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/profiles.ts
CHANGED
|
@@ -236,15 +236,18 @@ export function isMemory(c: DiscoveryPayload): boolean {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
* `openwop-trigger-bridge` predicate (RFC 0083). Host
|
|
240
|
-
* inbound-work contract: advertises the `triggerBridge`,
|
|
241
|
-
* sink for exhausted deliveries, and has at least one
|
|
242
|
-
* (queue bus, durable webhooks,
|
|
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
|
|