@openwop/openwop-conformance 1.52.0 → 1.53.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 +10 -0
- package/README.md +2 -2
- package/coverage.md +3 -1
- package/fixtures/trigger-events/trigger-event-change.json +17 -0
- package/fixtures/trigger-events/trigger-event-stream.json +16 -0
- package/fixtures.md +2 -0
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +19 -3
- package/schemas/run-event-payloads.schema.json +1 -1
- package/schemas/trigger-event.schema.json +80 -7
- package/schemas/trigger-subscription-registration.schema.json +1 -1
- package/schemas/trigger-subscription.schema.json +1 -1
- package/schemas/workflow-definition.schema.json +1 -1
- package/src/lib/workflow-chain-expansion.ts +23 -7
- package/src/scenarios/purpose-propagation.test.ts +200 -0
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +215 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +112 -0
|
@@ -470,6 +470,22 @@
|
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
472
|
},
|
|
473
|
+
"purposePropagation": {
|
|
474
|
+
"type": "object",
|
|
475
|
+
"additionalProperties": false,
|
|
476
|
+
"description": "RFC 0128 (`Active`). Purpose-propagation — the host reads and RE-EMITS `permittedPurposes` labels (opaque purpose strings riding the A2A `metadata.openwop.permittedPurposes` extension and the `TriggerEvent.permittedPurposes` field) on onward OpenWOP-envelope hops of the same data, narrowing at most and never widening; a `[]` label is honored as no-onward-use. This family advertises label PROPAGATION only — it does NOT claim the host enforces purpose limits on its own internal use (that is declared intent under the receiver's local governance, RFC 0128 §4, deliberately not conformance-gated). A host MUST NOT advertise `supported: true` until RFC 0128 reaches `Accepted` and propagation is honored (truthful-advertisement, `OPENWOP_REQUIRE_BEHAVIOR=true`). Absent block ⇒ labels pass through unread as unknown metadata (pre-RFC behavior).",
|
|
477
|
+
"required": ["supported"],
|
|
478
|
+
"properties": {
|
|
479
|
+
"supported": {
|
|
480
|
+
"type": "boolean",
|
|
481
|
+
"description": "RFC 0128. When `true`, the host preserves and re-emits received `permittedPurposes` labels per RFC 0128 §3 (re-emit on OpenWOP-envelope onward hops; MAY narrow; MUST NOT widen; derived outputs never carry a purpose absent from any contributing labelled input; `[]` ⇒ no onward hop)."
|
|
482
|
+
},
|
|
483
|
+
"propagatesOnward": {
|
|
484
|
+
"type": "boolean",
|
|
485
|
+
"description": "RFC 0128. When `true`, the host's onward OpenWOP-envelope hops (A2A forwards, trigger/sync events to OpenWOP peers) re-emit the label. Distinct from `supported` only for hosts that read labels but have no onward-hop surface at all; a host WITH onward hops advertising `supported: true` MUST also propagate."
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
},
|
|
473
489
|
"credentials": {
|
|
474
490
|
"type": "object",
|
|
475
491
|
"description": "RFC 0046 (`Draft`). Portable credential resolution + lifecycle contract — sibling to `secrets`, first-class store-at-rest + workspace sharing + two-key-overlap rotation. A pack references a credential by `{ ref, scope }` (see `credential-reference.schema.json`); the host resolves it into the node sandbox ONLY — never into inputs, persisted variables, channels, any run.* event payload, the debug bundle, or replay state (SECURITY invariant `credential-payload-redaction`). Supersedes the informal BYOK annex; the `secrets` advertisement stays valid.",
|
|
@@ -1699,13 +1715,13 @@
|
|
|
1699
1715
|
"backoff": { "type": "string", "enum": ["none", "fixed", "exponential"], "description": "Backoff strategy between attempts." }
|
|
1700
1716
|
}
|
|
1701
1717
|
},
|
|
1702
|
-
"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." },
|
|
1718
|
+
"sources": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form", "stream", "change"] }, "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." },
|
|
1703
1719
|
"ingestion": {
|
|
1704
1720
|
"type": "object",
|
|
1705
1721
|
"additionalProperties": false,
|
|
1706
|
-
"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.",
|
|
1722
|
+
"description": "RFC 0099 §F.3 (additive). External-event ingestion advertisement — which of `sources[]` the host actually ingests from EXTERNALLY-originated events (`webhook`/`email`/`form`/`stream`/`change`), 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.",
|
|
1707
1723
|
"properties": {
|
|
1708
|
-
"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." },
|
|
1724
|
+
"externalSources": { "type": "array", "uniqueItems": true, "items": { "type": "string", "enum": ["webhook", "email", "form", "stream", "change"] }, "description": "Which of `sources[]` are EXTERNALLY ingested per RFC 0099 (`stream`/`change` per RFC 0127). The honesty gate — each MUST normalize to a `TriggerEvent` and start a run." },
|
|
1709
1725
|
"maxBodyBytes": { "type": "integer", "minimum": 1, "description": "Inbound body cap (webhook body / email / form), reusing the RFC 0076 §B response-cap discipline." },
|
|
1710
1726
|
"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)." },
|
|
1711
1727
|
"registrationEndpoint": { "type": "boolean", "description": "`true` ⇒ the host serves `POST /v1/trigger-subscriptions` (RFC 0099 §F.2) for portable external-event subscription creation." }
|
|
@@ -1739,7 +1739,7 @@
|
|
|
1739
1739
|
"required": ["subscriptionId", "source", "fromState", "toState"],
|
|
1740
1740
|
"properties": {
|
|
1741
1741
|
"subscriptionId": { "type": "string", "minLength": 1, "description": "The TriggerSubscription this state change is for." },
|
|
1742
|
-
"source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"], "description": "The subscription source." },
|
|
1742
|
+
"source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form", "stream", "change"], "description": "The subscription source." },
|
|
1743
1743
|
"fromState": { "type": "string", "enum": ["active", "paused", "failed", "dead-lettered"], "description": "Prior state." },
|
|
1744
1744
|
"toState": { "type": "string", "enum": ["active", "paused", "failed", "dead-lettered"], "description": "New state." },
|
|
1745
1745
|
"reason": { "type": "string", "enum": ["retry-exhausted", "operator-paused", "signature-invalid", "backpressure", "source-removed", "provenance-unevaluable"], "description": "MAY — a machine-stable reason code (a CLOSED enum — a free-form reason is a leak vector that would let a host spill an inbound URL/host/header into it). The load-bearing field is `toState`." }
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://openwop.dev/spec/v1/trigger-event.schema.json",
|
|
4
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`).",
|
|
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`/`stream`/`change`) 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
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": ["source", "subscriptionId", "deliveryId", "receivedAt"],
|
|
9
9
|
"properties": {
|
|
10
10
|
"source": {
|
|
11
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)."
|
|
12
|
+
"enum": ["webhook", "email", "form", "stream", "change"],
|
|
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; `stream`/`change` per RFC 0127)."
|
|
14
14
|
},
|
|
15
15
|
"subscriptionId": {
|
|
16
16
|
"type": "string",
|
|
@@ -40,23 +40,38 @@
|
|
|
40
40
|
"enum": ["untrusted"],
|
|
41
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
42
|
},
|
|
43
|
+
"permittedPurposes": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"items": { "type": "string" },
|
|
46
|
+
"description": "RFC 0128 — OPTIONAL declared uses the receiver is permitted to make of the accompanying data. Opaque purpose strings mapped to the host's local vocabulary; carries purpose categories only, never subject identifiers or consent records. Absent ⇒ unlabelled (no constraint asserted); `[]` ⇒ no onward use permitted (NOT the same as absent). A host advertising `purposePropagation` MUST honor the RFC 0128 §3 propagation rules for this label."
|
|
47
|
+
},
|
|
43
48
|
"webhook": { "$ref": "#/$defs/WebhookEvent" },
|
|
44
49
|
"email": { "$ref": "#/$defs/EmailEvent" },
|
|
45
|
-
"form": { "$ref": "#/$defs/FormEvent" }
|
|
50
|
+
"form": { "$ref": "#/$defs/FormEvent" },
|
|
51
|
+
"stream": { "$ref": "#/$defs/StreamEvent" },
|
|
52
|
+
"change": { "$ref": "#/$defs/ChangeEvent" }
|
|
46
53
|
},
|
|
47
54
|
"allOf": [
|
|
48
55
|
{
|
|
49
56
|
"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
57
|
"if": { "properties": { "source": { "const": "webhook" } } },
|
|
51
|
-
"then": { "not": { "anyOf": [{ "required": ["email"] }, { "required": ["form"] }] } }
|
|
58
|
+
"then": { "not": { "anyOf": [{ "required": ["email"] }, { "required": ["form"] }, { "required": ["stream"] }, { "required": ["change"] }] } }
|
|
52
59
|
},
|
|
53
60
|
{
|
|
54
61
|
"if": { "properties": { "source": { "const": "email" } } },
|
|
55
|
-
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["form"] }] } }
|
|
62
|
+
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["form"] }, { "required": ["stream"] }, { "required": ["change"] }] } }
|
|
56
63
|
},
|
|
57
64
|
{
|
|
58
65
|
"if": { "properties": { "source": { "const": "form" } } },
|
|
59
|
-
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["email"] }] } }
|
|
66
|
+
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["email"] }, { "required": ["stream"] }, { "required": ["change"] }] } }
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"if": { "properties": { "source": { "const": "stream" } } },
|
|
70
|
+
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["email"] }, { "required": ["form"] }, { "required": ["change"] }] } }
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"if": { "properties": { "source": { "const": "change" } } },
|
|
74
|
+
"then": { "not": { "anyOf": [{ "required": ["webhook"] }, { "required": ["email"] }, { "required": ["form"] }, { "required": ["stream"] }] } }
|
|
60
75
|
}
|
|
61
76
|
],
|
|
62
77
|
"$defs": {
|
|
@@ -98,6 +113,31 @@
|
|
|
98
113
|
"files": { "type": "array", "items": { "$ref": "#/$defs/AttachmentRef" } }
|
|
99
114
|
}
|
|
100
115
|
},
|
|
116
|
+
"StreamEvent": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"additionalProperties": false,
|
|
119
|
+
"description": "Present iff `source == \"stream\"` (RFC 0127). One message consumed from a streaming broker (Kafka / Kinesis / Pub-Sub); the host is the consumer and each delivered message normalizes to one TriggerEvent. The broker connection is a credential-brokered egress (RFC 0095), never a wire field.",
|
|
120
|
+
"properties": {
|
|
121
|
+
"topic": { "type": "string", "description": "The broker topic/stream/subscription name the message was consumed from." },
|
|
122
|
+
"partition": { "type": "integer", "minimum": 0, "description": "Broker partition/shard, where the broker has one. With `offset`, feeds the RFC 0127 §4 dedup-key guidance `(topic, partition, offset)`." },
|
|
123
|
+
"offset": { "type": "string", "description": "Broker offset / sequence number as an opaque string (brokers disagree on integer width)." },
|
|
124
|
+
"key": { "type": "string", "description": "The message key, where the broker has one." },
|
|
125
|
+
"message": { "description": "The consumed message payload — parsed JSON, or a string for non-JSON. Bounded by `triggerBridge.ingestion.maxBodyBytes`." }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"ChangeEvent": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"additionalProperties": false,
|
|
131
|
+
"required": ["op"],
|
|
132
|
+
"description": "Present iff `source == \"change\"` (RFC 0127). One change-data-capture record from a warehouse/database changelog. `op` is REQUIRED. The warehouse connection is a credential-brokered egress (RFC 0095), never a wire field.",
|
|
133
|
+
"properties": {
|
|
134
|
+
"op": { "type": "string", "enum": ["insert", "update", "delete"], "description": "The change operation. REQUIRED (RFC 0127 §2 / resolved Q1 — one `change` source with an `op` discriminator, not per-verb sources)." },
|
|
135
|
+
"table": { "type": "string", "description": "The source table/collection/entity name. With `changelogId`, feeds the RFC 0127 §4 dedup-key guidance `(table, changelog-id)`." },
|
|
136
|
+
"changelogId": { "type": "string", "description": "The changelog position/LSN/row-version as an opaque string." },
|
|
137
|
+
"before": { "description": "The pre-image row where the feed provides one (typically `update`/`delete`). Parsed JSON. Bounded by `triggerBridge.ingestion.maxBodyBytes`." },
|
|
138
|
+
"after": { "description": "The post-image row where the feed provides one (typically `insert`/`update`). Parsed JSON. Bounded by `triggerBridge.ingestion.maxBodyBytes`." }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
101
141
|
"AttachmentRef": {
|
|
102
142
|
"type": "object",
|
|
103
143
|
"additionalProperties": false,
|
|
@@ -144,6 +184,39 @@
|
|
|
144
184
|
"headers": { "X-Event-Type": "issue.created" },
|
|
145
185
|
"body": { "issue": { "id": 42, "title": "Bug" } }
|
|
146
186
|
}
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"source": "stream",
|
|
190
|
+
"subscriptionId": "sub_12",
|
|
191
|
+
"deliveryId": "dlv_e5f6",
|
|
192
|
+
"dedupKey": "events:3:88412",
|
|
193
|
+
"receivedAt": "2026-07-06T12:00:00Z",
|
|
194
|
+
"verified": true,
|
|
195
|
+
"contentTrust": "untrusted",
|
|
196
|
+
"stream": {
|
|
197
|
+
"topic": "events",
|
|
198
|
+
"partition": 3,
|
|
199
|
+
"offset": "88412",
|
|
200
|
+
"key": "user_77",
|
|
201
|
+
"message": { "type": "page_view", "path": "/pricing" }
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"source": "change",
|
|
206
|
+
"subscriptionId": "sub_13",
|
|
207
|
+
"deliveryId": "dlv_a7b8",
|
|
208
|
+
"dedupKey": "contacts:lsn:0/1C4F9D0",
|
|
209
|
+
"receivedAt": "2026-07-06T12:01:00Z",
|
|
210
|
+
"verified": true,
|
|
211
|
+
"contentTrust": "untrusted",
|
|
212
|
+
"permittedPurposes": ["analytics"],
|
|
213
|
+
"change": {
|
|
214
|
+
"op": "update",
|
|
215
|
+
"table": "contacts",
|
|
216
|
+
"changelogId": "0/1C4F9D0",
|
|
217
|
+
"before": { "id": 77, "tier": "free" },
|
|
218
|
+
"after": { "id": 77, "tier": "pro" }
|
|
219
|
+
}
|
|
147
220
|
}
|
|
148
221
|
]
|
|
149
222
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"properties": {
|
|
10
10
|
"source": {
|
|
11
11
|
"type": "string",
|
|
12
|
-
"enum": ["webhook", "email", "form"],
|
|
12
|
+
"enum": ["webhook", "email", "form", "stream", "change"],
|
|
13
13
|
"description": "The external source this subscription ingests. MUST appear in the host's `capabilities.triggerBridge.ingestion.externalSources[]`."
|
|
14
14
|
},
|
|
15
15
|
"workflowId": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"required": ["subscriptionId", "source", "state"],
|
|
9
9
|
"properties": {
|
|
10
10
|
"subscriptionId": { "type": "string", "minLength": 1, "description": "Stable host-unique id for the subscription. Correlates the §C delivery events + the management surface." },
|
|
11
|
-
"source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form"], "description": "Which trigger source backs the subscription. Channels beyond these (Slack/Discord/SMS) bridge as a vendor extension by registering a subscription of the closest source kind (§E)." },
|
|
11
|
+
"source": { "type": "string", "enum": ["webhook", "schedule", "queue", "email", "form", "stream", "change"], "description": "Which trigger source backs the subscription. Channels beyond these (Slack/Discord/SMS) bridge as a vendor extension by registering a subscription of the closest source kind (§E)." },
|
|
12
12
|
"state": { "type": "string", "enum": ["active", "paused", "failed", "dead-lettered"], "description": "The §B state. `active`: accepting + delivering; `paused`: retained, not delivering (operator-held); `failed`: delivery failing past policy (the webhooks.md circuit-breaker generalized); `dead-lettered`: terminal, deliveries routed to the RFC 0053 sink." },
|
|
13
13
|
"dedupEnabled": { "type": "boolean", "description": "When true, the host de-duplicates inbound events by `dedupKey` within the retention window (§C-1; the idempotency.md Layer-1 model applied to inbound triggers)." },
|
|
14
14
|
"retryPolicy": {
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"type": "object",
|
|
112
112
|
"required": ["id", "typeId", "name", "position", "config", "inputs"],
|
|
113
113
|
"properties": {
|
|
114
|
-
"id": { "type": "string", "minLength": 1 },
|
|
114
|
+
"id": { "type": "string", "minLength": 1, "description": "Node identifier, unique within the workflow. Intentionally has NO `maxLength` — RFC 0013 chain expansion mints ids of the form `${chainIdSlug}_${expansionId}_${fragmentId}` where `chainIdSlug` is an unbounded reverse-DNS string, so an expanded node id can exceed any short cap. A host MUST NOT reject or truncate a node id by length (no host-imposed `maxLength` stricter than this schema)." },
|
|
115
115
|
"typeId": {
|
|
116
116
|
"type": "string",
|
|
117
117
|
"description": "Canonical node type ID (e.g., 'core.ai.callPrompt', 'core.chat.approvalGate'). Reserved prefixes: 'core.*' for spec-canonical, 'vendor.<org>.*' for third-party.",
|
|
@@ -126,17 +126,33 @@ export class ChainUnresolvableTypeIdError extends Error {
|
|
|
126
126
|
|
|
127
127
|
const PARAM_PATTERN = /\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
128
128
|
/** A value that is EXACTLY a single `{{params.<name>}}` token (whole-value),
|
|
129
|
-
* distinct from a token embedded in a larger string.
|
|
130
|
-
*
|
|
131
|
-
* (WCP2 raw-typed rule);
|
|
132
|
-
*
|
|
129
|
+
* distinct from a token embedded in a larger string. Under expansion-time
|
|
130
|
+
* substitution a whole-value token resolves to the RAW TYPED parameter value
|
|
131
|
+
* rather than a string coercion (WCP2 raw-typed rule); under deferred mode it
|
|
132
|
+
* is the only non-prompt position deferrable to a variable-sourced PortValue.
|
|
133
|
+
* An embedded non-prompt token has no runtime `{{}}` construct and MUST resolve
|
|
134
|
+
* at expansion time. */
|
|
133
135
|
const WHOLE_VALUE_PATTERN = /^\{\{params\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}$/;
|
|
134
136
|
|
|
135
|
-
/** Recursive
|
|
136
|
-
*
|
|
137
|
-
*
|
|
137
|
+
/** Recursive substitution of `{{params.<name>}}` placeholders in any string
|
|
138
|
+
* field. Two cases per `workflow-chain-packs.md` §"Parameter substitution":
|
|
139
|
+
* - WHOLE-VALUE: a string that is exactly one `{{params.x}}` token resolves
|
|
140
|
+
* to the RAW typed parameter value (object / array / number / boolean
|
|
141
|
+
* survive as their JSON type instead of being stringified).
|
|
142
|
+
* - EMBEDDED: one or more tokens inside surrounding text do literal string
|
|
143
|
+
* substitution.
|
|
144
|
+
* Non-string values pass through unchanged; nested arrays/objects are walked. */
|
|
138
145
|
function substitute(value: unknown, params: Record<string, unknown>): unknown {
|
|
139
146
|
if (typeof value === 'string') {
|
|
147
|
+
const whole = WHOLE_VALUE_PATTERN.exec(value);
|
|
148
|
+
if (whole) {
|
|
149
|
+
// Whole-value token — return the raw typed value so a param typed
|
|
150
|
+
// `object` / `array` / `number` / `boolean` reaches the node config
|
|
151
|
+
// intact. `undefined` (undeclared param) collapses to the empty string,
|
|
152
|
+
// matching the embedded-token convention below.
|
|
153
|
+
const v = params[whole[1] as string];
|
|
154
|
+
return v === undefined ? '' : v;
|
|
155
|
+
}
|
|
140
156
|
return value.replace(PARAM_PATTERN, (_match, name: string) => {
|
|
141
157
|
const v = params[name];
|
|
142
158
|
// Per the spec, parameter values are validated against the chain's
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose-propagation — permitted-use labels (RFC 0128) —
|
|
3
|
+
* `capabilities.md` §purposePropagation + `a2a-integration.md`
|
|
4
|
+
* §"Purpose-propagation labels".
|
|
5
|
+
*
|
|
6
|
+
* The conformance-testable core of RFC 0128 §3: a host advertising
|
|
7
|
+
* `capabilities.purposePropagation` MUST re-emit a received
|
|
8
|
+
* `permittedPurposes` label on onward OpenWOP-envelope hops (MAY narrow,
|
|
9
|
+
* MUST NOT widen), a derived output MUST NOT carry a purpose absent from any
|
|
10
|
+
* contributing labelled input, and `[]`-labelled data MUST NOT be forwarded
|
|
11
|
+
* onward at all. The internal-use restriction (§4) is deliberately NOT tested
|
|
12
|
+
* — it is not observable over the wire.
|
|
13
|
+
*
|
|
14
|
+
* Two layers:
|
|
15
|
+
*
|
|
16
|
+
* A. Always-on, server-free schema probes:
|
|
17
|
+
* - a labelled TriggerEvent validates; a non-array label fails;
|
|
18
|
+
* - absent vs `[]` are distinct wire states (both validate — the
|
|
19
|
+
* semantic difference is behavioral, asserted in layer B);
|
|
20
|
+
* - the `capabilities.purposePropagation` family shape: `supported`
|
|
21
|
+
* REQUIRED, `additionalProperties:false`.
|
|
22
|
+
*
|
|
23
|
+
* B. Capability-gated behavioral legs via the two-hop seam
|
|
24
|
+
* `POST /v1/host/sample/purpose-propagation/forward` (the suite plays
|
|
25
|
+
* hop A — the labelled sender — and hop C — the onward receiver — around
|
|
26
|
+
* the host at B). Soft-skips when the seam is unwired (404/405);
|
|
27
|
+
* REQUIRED once `purposePropagation.supported` is advertised
|
|
28
|
+
* (advertise-only-what-you-honor):
|
|
29
|
+
* 1. survive/narrow — a forwarded label arrives ⊆ what B received;
|
|
30
|
+
* 2. never-widen — strictly no purpose beyond the input set;
|
|
31
|
+
* 3. derived output — a merge of two labelled inputs arrives ⊆ their
|
|
32
|
+
* intersection (multi-input never-widen — transformation does not
|
|
33
|
+
* launder a grant);
|
|
34
|
+
* 4. unlabelled inputs add no constraint to a merge;
|
|
35
|
+
* 5. `[]` fail-closed — a `[]`-labelled record is dropped from onward
|
|
36
|
+
* emission, with an unlabelled twin as the positive control
|
|
37
|
+
* (non-arrival is evidence, not a timeout artifact).
|
|
38
|
+
*
|
|
39
|
+
* @see RFCS/0128-purpose-propagation-permitted-use-labels.md §3
|
|
40
|
+
* @see spec/v1/capabilities.md §purposePropagation
|
|
41
|
+
* @see spec/v1/a2a-integration.md §"Purpose-propagation labels"
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { describe, it, expect } from 'vitest';
|
|
45
|
+
import { readFileSync } from 'node:fs';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
48
|
+
import addFormats from 'ajv-formats';
|
|
49
|
+
import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
50
|
+
import { driver } from '../lib/driver.js';
|
|
51
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
52
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
53
|
+
|
|
54
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
55
|
+
const SEAM = '/v1/host/sample/purpose-propagation/forward';
|
|
56
|
+
|
|
57
|
+
interface OnwardEmission {
|
|
58
|
+
recordId?: string;
|
|
59
|
+
surface?: string;
|
|
60
|
+
permittedPurposes?: string[];
|
|
61
|
+
}
|
|
62
|
+
interface ForwardResponse {
|
|
63
|
+
onward?: OnwardEmission[];
|
|
64
|
+
dropped?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function subsetOf(actual: string[] | undefined, allowed: string[]): boolean {
|
|
68
|
+
return (actual ?? []).every((p) => allowed.includes(p));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('purpose-propagation: label schema (always-on, server-free)', () => {
|
|
72
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
73
|
+
addFormats(ajv);
|
|
74
|
+
const validate = ajv.compile(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'trigger-event.schema.json'), 'utf8')));
|
|
75
|
+
const FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-change.json');
|
|
76
|
+
|
|
77
|
+
it('a labelled TriggerEvent validates; a non-array label fails', () => {
|
|
78
|
+
const ev = JSON.parse(readFileSync(FIXTURE, 'utf8'));
|
|
79
|
+
expect(validate(ev), `a labelled TriggerEvent MUST validate (RFC 0128 §1). Errors: ${JSON.stringify(validate.errors)}`).toBe(true);
|
|
80
|
+
|
|
81
|
+
ev.permittedPurposes = 'analytics';
|
|
82
|
+
expect(validate(ev), 'RFC 0128 §1 — permittedPurposes MUST be string[]').toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('absent and [] are BOTH wire-valid — distinct states (unlabelled vs no-onward-use)', () => {
|
|
86
|
+
const ev = JSON.parse(readFileSync(FIXTURE, 'utf8'));
|
|
87
|
+
delete ev.permittedPurposes;
|
|
88
|
+
expect(validate(ev), 'RFC 0128 §1 — an unlabelled event (absent label) MUST validate').toBe(true);
|
|
89
|
+
ev.permittedPurposes = [];
|
|
90
|
+
expect(validate(ev), 'RFC 0128 §1 — a []-labelled event MUST validate (behavioral meaning: no onward use)').toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('the capabilities.purposePropagation family requires `supported` and closes its shape', () => {
|
|
94
|
+
const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8'));
|
|
95
|
+
const fam = caps.properties?.purposePropagation;
|
|
96
|
+
expect(fam, 'capabilities.schema.json MUST define the purposePropagation family (RFC 0128 §2)').toBeDefined();
|
|
97
|
+
expect(fam.required, 'purposePropagation MUST require `supported`').toContain('supported');
|
|
98
|
+
expect(fam.additionalProperties, 'purposePropagation MUST close its shape').toBe(false);
|
|
99
|
+
const ajvFam = new Ajv2020({ allErrors: true, strict: false });
|
|
100
|
+
const vf = ajvFam.compile(fam);
|
|
101
|
+
expect(vf({ supported: true, propagatesOnward: true }), 'the canonical advert MUST validate').toBe(true);
|
|
102
|
+
expect(vf({ propagatesOnward: true }), 'an advert without `supported` MUST fail').toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe.skipIf(HTTP_SKIP)('purpose-propagation: two-hop onward behavior (capability-gated)', () => {
|
|
107
|
+
async function seamPost(body: Record<string, unknown>): Promise<ForwardResponse | null> {
|
|
108
|
+
const res = await driver.post(SEAM, body);
|
|
109
|
+
if (res.status === 404 || res.status === 405) return null; // seam unwired — soft-skip
|
|
110
|
+
return (res.json as ForwardResponse | undefined) ?? {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function gate(): Promise<boolean> {
|
|
114
|
+
const fam = await readCapabilityFamily<{ supported?: boolean }>('purposePropagation');
|
|
115
|
+
return behaviorGate('purposePropagation', fam?.supported === true);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it('a forwarded label survives ⊆ the received set (re-emit; MAY narrow, MUST NOT widen)', async () => {
|
|
119
|
+
if (!(await gate())) return;
|
|
120
|
+
const input = ['analytics', 'marketing-email'];
|
|
121
|
+
const res = await seamPost({ mode: 'forward', records: [{ id: 'r1', permittedPurposes: input, data: { k: 1 } }] });
|
|
122
|
+
if (res === null) return;
|
|
123
|
+
|
|
124
|
+
const onward = res.onward ?? [];
|
|
125
|
+
expect(
|
|
126
|
+
onward.length > 0,
|
|
127
|
+
driver.describe('RFC 0128 §3', 'an advertising host MUST re-emit a received label on the onward hop — silent loss fails (the promise is propagation)'),
|
|
128
|
+
).toBe(true);
|
|
129
|
+
for (const o of onward) {
|
|
130
|
+
expect(
|
|
131
|
+
Array.isArray(o.permittedPurposes) && subsetOf(o.permittedPurposes, input),
|
|
132
|
+
driver.describe('RFC 0128 §3', `the onward label MUST be a subset of what the host received — got ${JSON.stringify(o.permittedPurposes)} vs input ${JSON.stringify(input)} (widening is the testable violation)`),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('a derived (merged) output arrives ⊆ the intersection of contributing labelled inputs', async () => {
|
|
138
|
+
if (!(await gate())) return;
|
|
139
|
+
const res = await seamPost({
|
|
140
|
+
mode: 'merge',
|
|
141
|
+
records: [
|
|
142
|
+
{ id: 'a', permittedPurposes: ['analytics', 'marketing-email'], data: { k: 1 } },
|
|
143
|
+
{ id: 'b', permittedPurposes: ['analytics'], data: { k: 2 } },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
if (res === null) return;
|
|
147
|
+
const onward = res.onward ?? [];
|
|
148
|
+
expect(onward.length > 0, driver.describe('RFC 0128 §3', 'a merge of forwardable labelled inputs MUST produce an onward emission')).toBe(true);
|
|
149
|
+
for (const o of onward) {
|
|
150
|
+
expect(
|
|
151
|
+
subsetOf(o.permittedPurposes, ['analytics']),
|
|
152
|
+
driver.describe('RFC 0128 §3', `a derived output MUST NOT carry a purpose absent from any contributing labelled input — transformation does not launder a grant; got ${JSON.stringify(o.permittedPurposes)}, allowed ⊆ ["analytics"]`),
|
|
153
|
+
).toBe(true);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('an unlabelled input adds no constraint to a merge', async () => {
|
|
158
|
+
if (!(await gate())) return;
|
|
159
|
+
const res = await seamPost({
|
|
160
|
+
mode: 'merge',
|
|
161
|
+
records: [
|
|
162
|
+
{ id: 'a', permittedPurposes: ['analytics'], data: { k: 1 } },
|
|
163
|
+
{ id: 'b', data: { k: 2 } },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
if (res === null) return;
|
|
167
|
+
for (const o of res.onward ?? []) {
|
|
168
|
+
expect(
|
|
169
|
+
subsetOf(o.permittedPurposes, ['analytics']),
|
|
170
|
+
driver.describe('RFC 0128 §3', 'an unlabelled input asserts no constraint — the derived label is still bounded by the labelled input(s)'),
|
|
171
|
+
).toBe(true);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('[]-labelled data is fail-closed dropped from onward emission (positive control: unlabelled twin forwards)', async () => {
|
|
176
|
+
if (!(await gate())) return;
|
|
177
|
+
const res = await seamPost({
|
|
178
|
+
mode: 'forward',
|
|
179
|
+
records: [
|
|
180
|
+
{ id: 'blocked', permittedPurposes: [], data: { k: 1 } },
|
|
181
|
+
{ id: 'control', data: { k: 1 } },
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
if (res === null) return;
|
|
185
|
+
|
|
186
|
+
const onwardIds = (res.onward ?? []).map((o) => o.recordId);
|
|
187
|
+
expect(
|
|
188
|
+
!onwardIds.includes('blocked'),
|
|
189
|
+
driver.describe('RFC 0128 §3', 'permittedPurposes: [] means no onward use — a conformant host MUST NOT forward []-labelled data to a further sink at all'),
|
|
190
|
+
).toBe(true);
|
|
191
|
+
expect(
|
|
192
|
+
(res.dropped ?? []).includes('blocked'),
|
|
193
|
+
driver.describe('RFC 0128 §3', 'the []-labelled record MUST be reported dropped (fail-closed, observable)'),
|
|
194
|
+
).toBe(true);
|
|
195
|
+
expect(
|
|
196
|
+
onwardIds.includes('control'),
|
|
197
|
+
driver.describe('RFC 0128 §3', 'the unlabelled twin (positive control) MUST forward — proving the non-arrival of the []-labelled record is the rule firing, not a dead seam'),
|
|
198
|
+
).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
});
|