Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,54 @@ All EEP events MUST be valid CloudEvents v1.0.2 envelopes with EEP-specific exte
| `eep_reputation_score` | integer | MAY | On-chain ERC-8004 reputation score at event time (0–100) |
| `eep_on_chain_did` | string | MAY | On-chain DID linked to the entity (e.g. via ERC-8004 NFT token) |

> **Attribute naming (open issue).** CloudEvents v1.0.2 restricts context
> attribute names to lowercase ASCII letters and digits — the underscore is
> excluded. The `eep_`-prefixed names above are what every deployed
> implementation emits, but they do not satisfy that rule. The divergence is
> harmless in structured mode and becomes load-bearing in binary content mode,
> where attributes are carried as `ce-`-prefixed HTTP headers. Tracked for
> resolution before v1.0; see the editor's note in
> [`draft-eep-protocol-core-00.md`](../standards/draft-eep-protocol-core-00.md).

### 7.1 Standard CloudEvents attributes (normative)

EEP previously defined eight `eep_`-prefixed extensions while using none of the
optional CloudEvents attributes that solve the same problems. Publishers SHOULD
populate the following, and subscribers MUST tolerate their presence:

| Attribute | Type | Level | Purpose |
|-----------|------|-------|---------|
| `subject` | string | SHOULD | Which thing inside `source` changed. Lets a subscriber filter without parsing `data`. |
| `dataschema` | URI | SHOULD | Schema describing `data`, so a subscriber can validate or typed-decode before use. |
| `dataref` | URI | MAY | Claim Check: retrieve the payload from this URI instead of inlining it. |
| `traceparent` | string | SHOULD | W3C Trace Context, propagated across the delivery boundary. |
| `tracestate` | string | MAY | Vendor trace data accompanying `traceparent`. |

**`subject`.** Set it whenever an event concerns one addressable thing. It is
the difference between a subscriber discarding an unwanted event after parsing
its body and a publisher never sending it (see the filtering rules in §5.1).

**`dataschema`.** Without it, a subscriber cannot validate a payload it has not
seen before, and an agent must be told the payload contract out of band. With
it, the contract travels with the event.

**`dataref` (Claim Check).** When `dataref` is present and `data` is absent, the
subscriber MUST fetch `dataref` to obtain the payload. This keeps a large
payload off the delivery path — the publisher sends a reference, and only
subscribers that need the body pay for it. Retrieval is an ordinary Layer 1
request and is subject to the entity's gates (§3.4), so a claim check does not
bypass access control. Publishers MUST NOT send both `data` and a `dataref`
that resolve to different content.

**`traceparent` / `tracestate`.** EEP is multi-hop by design: agent → publisher
→ subscriber → downstream agent. Without propagation the causal chain breaks at
every boundary and a misbehaving pipeline cannot be correlated back to the
originating event. Publishers SHOULD set `traceparent` from the context that
produced the event, and MUST NOT invent one that implies a trace they did not
participate in. Over HTTP these MUST also appear as the corresponding
`traceparent` / `tracestate` headers, per the CloudEvents Distributed Tracing
extension.

---

## 8. Event type naming convention
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,54 @@ describe("WebhookDispatcher", () => {
expect(DEFAULT_RETRY_SCHEDULE_MS).toEqual([0, 5_000, 30_000, 120_000, 900_000, 3_600_000, 21_600_000]);
});

// SPECIFICATION.md §7.1 — W3C Trace Context is mirrored into HTTP headers
// per the CloudEvents Distributed Tracing extension. EEP is multi-hop by
// design, and without propagation the causal chain breaks at every
// publisher/subscriber boundary.
describe("trace context propagation (§7.1)", () => {
const TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";

const deliver = async (overrides: Partial<CloudEvent>) => {
const db = new InMemoryDBAdapter();
await db.saveSubscription(subscription());
const { client, calls } = mockClient([200]);
const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY });
await dispatcher.dispatch(event(overrides));
return calls[0]!.headers;
};

it("forwards a well-formed traceparent as an HTTP header", async () => {
const headers = await deliver({ traceparent: TRACEPARENT } as Partial<CloudEvent>);
expect(headers.traceparent).toBe(TRACEPARENT);
});

it("forwards tracestate alongside traceparent", async () => {
const headers = await deliver({
traceparent: TRACEPARENT,
tracestate: "vendor=abc123"
} as Partial<CloudEvent>);
expect(headers.tracestate).toBe("vendor=abc123");
});

it("sends no trace headers when the event carries none", async () => {
const headers = await deliver({});
expect(headers.traceparent).toBeUndefined();
expect(headers.tracestate).toBeUndefined();
});

// A malformed traceparent is worse than none: it silently roots the
// subscriber's spans under a trace that does not exist.
it("drops a malformed traceparent rather than forwarding it", async () => {
const headers = await deliver({ traceparent: "not-a-trace-context" } as Partial<CloudEvent>);
expect(headers.traceparent).toBeUndefined();
});

it("drops tracestate when traceparent is absent or invalid", async () => {
const headers = await deliver({ tracestate: "vendor=abc123" } as Partial<CloudEvent>);
expect(headers.tracestate).toBeUndefined();
});
});

// SPECIFICATION.md §10.2: an elapsed lease means no more deliveries. Checked
// at delivery time rather than by a sweeper, so the rule holds in a
// deployment with no background job — an unenforced lease is no lease.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,29 @@ function sleep(ms: number): Promise<void> {
* promise so it never blocks the event bus. Deployments that need durable,
* restart-surviving retries should back the event bus with a queue.
*/
/**
* Mirror an event's trace context into HTTP headers.
*
* Only well-formed values are forwarded: a malformed `traceparent` is worse
* than none, because it silently roots the subscriber's spans under a trace
* that does not exist.
*/
const TRACEPARENT_PATTERN = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/;

function traceHeaders(event: CloudEvent): Record<string, string> {
const headers: Record<string, string> = {};
const candidate = (event as { traceparent?: unknown }).traceparent;
if (typeof candidate === "string" && TRACEPARENT_PATTERN.test(candidate)) {
headers.traceparent = candidate;
const state = (event as { tracestate?: unknown }).tracestate;
// `tracestate` is meaningless without a `traceparent` to accompany.
if (typeof state === "string" && state.length > 0 && state.length <= 512) {
headers.tracestate = state;
}
}
return headers;
}

export class WebhookDispatcher {
private readonly db: DBAdapter;
private readonly fallbackSecret?: string;
Expand Down Expand Up @@ -281,7 +304,13 @@ export class WebhookDispatcher {
"content-type": "application/json",
"webhook-id": webhookId,
"webhook-timestamp": timestamp,
"webhook-signature": signature
"webhook-signature": signature,
// W3C Trace Context is mirrored into HTTP headers per the
// CloudEvents Distributed Tracing extension (SPECIFICATION.md
// §7.1). Without this the subscriber's spans are orphaned and a
// multi-hop agent workflow cannot be correlated back to the
// originating event.
...traceHeaders(event)
},
body,
signal: controller.signal
Expand Down
51 changes: 45 additions & 6 deletions schemas/v0.1/event.envelope.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://eep.dev/schemas/v0.1/event.envelope.json",
"title": "EEP Event Envelope",
"description": "Schema for a valid EEP/CloudEvents v1.0.2 event. All EEP events MUST conform to this schema. This is a superset of the CloudEvents v1.0.2 envelope with EEP-specific extensions. See SPECIFICATION.md §13 for the canonical event type registry.",
"description": "Schema for a valid EEP/CloudEvents v1.0.2 event. All EEP events MUST conform to this schema. This is a superset of the CloudEvents v1.0.2 envelope with EEP-specific extensions. See SPECIFICATION.md \u00a713 for the canonical event type registry.",
"type": "object",
"required": [
"specversion",
Expand Down Expand Up @@ -38,7 +38,7 @@
},
"type": {
"type": "string",
"description": "The event type in reverse-domain dot notation. Format: {reverse-domain}.{entity-type}.{action}. Well-known EEP event types are listed in eep_known_event_types below and documented normatively in SPECIFICATION.md §13.",
"description": "The event type in reverse-domain dot notation. Format: {reverse-domain}.{entity-type}.{action}. Well-known EEP event types are listed in eep_known_event_types below and documented normatively in SPECIFICATION.md \u00a713.",
"pattern": "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9_]*)+$",
"minLength": 5,
"examples": [
Expand All @@ -62,9 +62,48 @@
"description": "MIME type of the data field. MUST be 'application/json' for EEP events.",
"const": "application/json"
},
"subject": {
"type": "string",
"description": "CloudEvents v1.0.2 OPTIONAL context attribute. Identifies the subject of the event within the context of `source` \u2014 for example the specific resource that changed. Subscribers can filter on it without parsing `data`, which is why publishers SHOULD set it whenever an event concerns one addressable thing.",
"minLength": 1,
"maxLength": 256,
"examples": [
"profile/bio",
"listing/8241"
]
},
"dataschema": {
"type": "string",
"description": "CloudEvents v1.0.2 OPTIONAL context attribute. Absolute URI of a schema describing `data`. Lets a subscriber validate or typed-decode the payload before touching it, and lets an agent discover the payload contract without out-of-band documentation.",
"format": "uri",
"examples": [
"https://example.com/schemas/entity.updated/v2.json"
]
},
"dataref": {
"type": "string",
"description": "CloudEvents Claim Check extension. Absolute URI at which the full payload can be retrieved. Lets a publisher send a small reference instead of a large body; when present without `data`, the subscriber MUST fetch this URI to obtain the payload. Retrieval is subject to the entity's gates.",
"format": "uri",
"examples": [
"https://api.example.com/eep/payloads/01HN3QK7GX"
]
},
"traceparent": {
"type": "string",
"description": "CloudEvents Distributed Tracing extension, carrying a W3C Trace Context `traceparent`. Propagating it across the publisher/subscriber boundary is what keeps an agent's causal chain intact through a multi-hop workflow.",
"pattern": "^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$",
"examples": [
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
]
},
"tracestate": {
"type": "string",
"description": "CloudEvents Distributed Tracing extension, carrying a W3C Trace Context `tracestate`. Vendor-specific trace data accompanying `traceparent`.",
"maxLength": 512
},
"data": {
"type": "object",
"description": "The event payload. Structure varies by event type. Refer to the Event Catalog in SPECIFICATION.md §13 for the normative schema of each event type."
"description": "The event payload. Structure varies by event type. Refer to the Event Catalog in SPECIFICATION.md \u00a713 for the normative schema of each event type."
},
"eep_version": {
"type": "string",
Expand Down Expand Up @@ -125,9 +164,9 @@
]
},
"eep_known_event_types": {
"$comment": "NORMATIVE REFERENCE not a data field. This enum lists all canonical EEP event type suffixes as defined in SPECIFICATION.md §13 (Event Type Registry). The full type value prefixes these with a reverse-domain (e.g., 'com.example.session.revoked'). Publishers MAY define custom event types but MUST NOT reuse these suffixes with different semantics.",
"$comment": "NORMATIVE REFERENCE \u2014 not a data field. This enum lists all canonical EEP event type suffixes as defined in SPECIFICATION.md \u00a713 (Event Type Registry). The full type value prefixes these with a reverse-domain (e.g., 'com.example.session.revoked'). Publishers MAY define custom event types but MUST NOT reuse these suffixes with different semantics.",
"type": "string",
"description": "NORMATIVE EVENT TYPE REGISTRY (SPECIFICATION.md §13). The suffix portion of canonical EEP event types. Publishers use these as the action portion of their typed events.",
"description": "NORMATIVE EVENT TYPE REGISTRY (SPECIFICATION.md \u00a713). The suffix portion of canonical EEP event types. Publishers use these as the action portion of their typed events.",
"enum": [
"entity.updated",
"entity.state.changed",
Expand Down Expand Up @@ -165,4 +204,4 @@
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"valid": true,
"reason": "dataref without data is the CloudEvents Claim Check pattern; the subscriber fetches the payload (SPECIFICATION.md \u00a77.1)"
}
11 changes: 11 additions & 0 deletions tests/conformance-fixtures/envelope/claim-check-dataref.input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"specversion": "1.0",
"id": "01HN3QK7GX-1708123456000",
"source": "did:web:test.eep.dev:u:alice",
"type": "com.example.entity.updated",
"time": "2026-05-09T12:00:00Z",
"datacontenttype": "application/json",
"eep_version": "0.1",
"subject": "report/q4-2026",
"dataref": "https://api.example.com/eep/payloads/01HN3QK7GX"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"valid": false,
"reason": "traceparent must match the W3C Trace Context format; a malformed value roots subscriber spans under a trace that does not exist"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"specversion": "1.0",
"id": "01HN3QK7GX-1708123456000",
"source": "did:web:test.eep.dev:u:alice",
"type": "com.example.entity.updated",
"time": "2026-05-09T12:00:00Z",
"datacontenttype": "application/json",
"eep_version": "0.1",
"traceparent": "not-a-trace-context",
"data": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"valid": true,
"reason": "subject, dataschema, traceparent and tracestate are CloudEvents-defined attributes (SPECIFICATION.md \u00a77.1)"
}
18 changes: 18 additions & 0 deletions tests/conformance-fixtures/envelope/standard-attributes.input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"specversion": "1.0",
"id": "01HN3QK7GX-1708123456000",
"source": "did:web:test.eep.dev:u:alice",
"type": "com.example.entity.updated",
"time": "2026-05-09T12:00:00Z",
"datacontenttype": "application/json",
"eep_version": "0.1",
"subject": "profile/bio",
"dataschema": "https://example.com/schemas/entity.updated/v2.json",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"tracestate": "vendor=abc123",
"data": {
"field": "bio",
"previous": "Old bio",
"current": "New bio"
}
}
33 changes: 33 additions & 0 deletions tests/conformance-fixtures/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,39 @@
"shape": "json-pair",
"asserts_valid": false
},
{
"id": "envelope-standard-attributes",
"category": "envelope",
"tier": "Core",
"spec_section": "\u00a77.1 Standard CloudEvents attributes",
"schema": "schemas/v0.1/event.envelope.json",
"input": "envelope/standard-attributes.input.json",
"expected": "envelope/standard-attributes.expected.json",
"shape": "json-pair",
"asserts_valid": true
},
{
"id": "envelope-claim-check-dataref",
"category": "envelope",
"tier": "Core",
"spec_section": "\u00a77.1 Claim Check (dataref)",
"schema": "schemas/v0.1/event.envelope.json",
"input": "envelope/claim-check-dataref.input.json",
"expected": "envelope/claim-check-dataref.expected.json",
"shape": "json-pair",
"asserts_valid": true
},
{
"id": "envelope-malformed-traceparent",
"category": "envelope",
"tier": "Core",
"spec_section": "\u00a77.1 Distributed tracing",
"schema": "schemas/v0.1/event.envelope.json",
"input": "envelope/malformed-traceparent.input.json",
"expected": "envelope/malformed-traceparent.expected.json",
"shape": "json-pair",
"asserts_valid": false
},
{
"id": "signature-valid-fresh",
"category": "signature",
Expand Down
40 changes: 40 additions & 0 deletions tests/types/eep-schemas.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,26 @@ export interface EEPEventEnvelope {
* MIME type of the data field. MUST be 'application/json' for EEP events.
*/
datacontenttype: 'application/json';
/**
* CloudEvents v1.0.2 OPTIONAL context attribute. Identifies the subject of the event within the context of `source` — for example the specific resource that changed. Subscribers can filter on it without parsing `data`, which is why publishers SHOULD set it whenever an event concerns one addressable thing.
*/
subject?: string;
/**
* CloudEvents v1.0.2 OPTIONAL context attribute. Absolute URI of a schema describing `data`. Lets a subscriber validate or typed-decode the payload before touching it, and lets an agent discover the payload contract without out-of-band documentation.
*/
dataschema?: string;
/**
* CloudEvents Claim Check extension. Absolute URI at which the full payload can be retrieved. Lets a publisher send a small reference instead of a large body; when present without `data`, the subscriber MUST fetch this URI to obtain the payload. Retrieval is subject to the entity's gates.
*/
dataref?: string;
/**
* CloudEvents Distributed Tracing extension, carrying a W3C Trace Context `traceparent`. Propagating it across the publisher/subscriber boundary is what keeps an agent's causal chain intact through a multi-hop workflow.
*/
traceparent?: string;
/**
* CloudEvents Distributed Tracing extension, carrying a W3C Trace Context `tracestate`. Vendor-specific trace data accompanying `traceparent`.
*/
tracestate?: string;
/**
* The event payload. Structure varies by event type. Refer to the Event Catalog in SPECIFICATION.md §13 for the normative schema of each event type.
*/
Expand Down Expand Up @@ -1291,6 +1311,26 @@ export interface EEPEventEnvelope {
* MIME type of the data field. MUST be 'application/json' for EEP events.
*/
datacontenttype: 'application/json';
/**
* CloudEvents v1.0.2 OPTIONAL context attribute. Identifies the subject of the event within the context of `source` — for example the specific resource that changed. Subscribers can filter on it without parsing `data`, which is why publishers SHOULD set it whenever an event concerns one addressable thing.
*/
subject?: string;
/**
* CloudEvents v1.0.2 OPTIONAL context attribute. Absolute URI of a schema describing `data`. Lets a subscriber validate or typed-decode the payload before touching it, and lets an agent discover the payload contract without out-of-band documentation.
*/
dataschema?: string;
/**
* CloudEvents Claim Check extension. Absolute URI at which the full payload can be retrieved. Lets a publisher send a small reference instead of a large body; when present without `data`, the subscriber MUST fetch this URI to obtain the payload. Retrieval is subject to the entity's gates.
*/
dataref?: string;
/**
* CloudEvents Distributed Tracing extension, carrying a W3C Trace Context `traceparent`. Propagating it across the publisher/subscriber boundary is what keeps an agent's causal chain intact through a multi-hop workflow.
*/
traceparent?: string;
/**
* CloudEvents Distributed Tracing extension, carrying a W3C Trace Context `tracestate`. Vendor-specific trace data accompanying `traceparent`.
*/
tracestate?: string;
/**
* The event payload. Structure varies by event type. Refer to the Event Catalog in SPECIFICATION.md §13 for the normative schema of each event type.
*/
Expand Down