diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index b402c99..6442d37 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -1267,6 +1267,53 @@ Returns the platform-level EEP capabilities document conforming to `schemas/v0.1 } ``` +### 12.3.2 Event catalog (normative) + +The manifest tells an agent **that** a publisher speaks EEP and **where** its +endpoints are. Until now it did not tell the agent **what the publisher will +send** — so an agent had to read human documentation, or subscribe to a +wildcard and discover the event types empirically. + +That undercuts the autonomous-agent story directly. §9's "standard event +catalog" is a table in this document: not fetchable, not versioned per +publisher, and not machine-readable. `capabilities_query_url` (§12.4) covers +gated *capabilities*, not event types. The envelope's `eep_known_event_types` +puts a discovery concern on the per-event hot path, which is the wrong layer. + +Publishers SHOULD declare their event types in the manifest: + +```json +{ + "event_types": [ + { + "type": "com.example.entity.updated", + "description": "A field on the entity profile changed.", + "dataschema": "https://example.com/schemas/entity.updated/v2.json", + "since": "0.1" + }, + { + "type": "com.example.trust.changed", + "description": "The entity's trust score was recalculated.", + "deprecated": true + } + ] +} +``` + +- `type` is required and follows the reverse-DNS convention of §8. +- `dataschema` SHOULD be set, and SHOULD match the `dataschema` attribute the + publisher sets on events of that type (§7.1), so the payload contract is + discoverable *and* travels with each event. +- A publisher whose catalog is too large or too volatile to inline SHOULD + expose `event_catalog_url` instead, paginated like §12.4. When both are + present, `event_types` is a subset and the URL is authoritative. +- Declaring an event type is not a promise to emit it, and omitting one is not + a promise never to. Subscribers MUST tolerate an undeclared event type + rather than rejecting the delivery — a catalog is discovery metadata, not a + wire constraint. +- Publishers SHOULD mark a type `deprecated` and keep emitting it for a + documented period rather than removing it silently. + ### 12.4 Dynamic Capability Discovery For entities with large data catalogs or frequently changing capabilities, static manifests become stale. Publishers MAY expose a paginated capability query endpoint: diff --git a/packages/@eep-dev/middleware/src/core/eep-server.test.ts b/packages/@eep-dev/middleware/src/core/eep-server.test.ts index 11f00c7..fe86014 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.test.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.test.ts @@ -604,6 +604,49 @@ describe("EEPServer", () => { }); }); + // SPECIFICATION.md §12.3.2 — the manifest said where the endpoints are but + // not what the publisher emits, so an agent had to subscribe to a wildcard + // and discover the event types empirically. + describe("manifest event catalog (§12.3.2)", () => { + const manifest = async (eventTypes?: string[]) => { + const server = new EEPServer({ + baseUrl: "https://api.example.com", + did: "did:web:example.com", + ...(eventTypes ? { eventTypes } : {}) + }); + const res = await server.getManifestHandler()({ + method: "GET", + path: "/.well-known/eep.json", + headers: {} + }); + return res.body as { event_types?: Array<{ type: string }> }; + }; + + it("declares the publisher's configured event types", async () => { + const body = await manifest(["com.example.entity.updated", "com.example.trust.changed"]); + expect(body.event_types).toEqual([ + { type: "com.example.entity.updated" }, + { type: "com.example.trust.changed" } + ]); + }); + + it("declares the default event type when none is configured", async () => { + const body = await manifest(); + expect(body.event_types).toEqual([{ type: "entity.updated" }]); + }); + + // The catalog is only useful if it is valid: an event type that does not + // match §8's reverse-DNS form would make the whole manifest fail schema + // validation, which is a worse outcome than having no catalog. + it("emits types matching the §8 reverse-DNS pattern", async () => { + const pattern = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$/; + const body = await manifest(["com.example.entity.updated", "com.example.agent.task.completed"]); + for (const entry of body.event_types ?? []) { + expect(entry.type).toMatch(pattern); + } + }); + }); + // SPECIFICATION.md §5.1.2 — webhooks had no catch-up mechanism at all, // which bites hardest right after §10 pauses a subscription: the endpoint // was down, it missed the most, and resuming produced a silent hole. diff --git a/packages/@eep-dev/middleware/src/core/eep-server.ts b/packages/@eep-dev/middleware/src/core/eep-server.ts index e84ca0c..63d0546 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.ts @@ -183,6 +183,9 @@ export class EEPServer { layer3_ws: `${this.baseUrl.replace(/^http/, "ws")}/eep/pulse` }, supported_content_types: ["application/json", "text/markdown"], + // §12.3.2 — declare what this publisher emits so an agent does not + // have to subscribe to a wildcard and find out empirically. + event_types: this.eventTypes.map((type) => ({ type })), gates_url: `${this.baseUrl}/eep/gates`, services_url: `${this.baseUrl}/eep/services`, pqc_ready: false, diff --git a/schemas/v0.1/eep-manifest.json b/schemas/v0.1/eep-manifest.json index 0530074..c365322 100644 --- a/schemas/v0.1/eep-manifest.json +++ b/schemas/v0.1/eep-manifest.json @@ -84,6 +84,56 @@ ] } }, + "event_types": { + "type": "array", + "description": "Event types this publisher emits, with the payload contract for each. Without this an agent can discover THAT a publisher speaks EEP and WHERE its endpoints are, but not WHAT it will send \u2014 it must read human documentation or subscribe to a wildcard and find out empirically. Publishers with large or frequently-changing catalogs SHOULD use `event_catalog_url` instead.", + "maxItems": 500, + "items": { + "type": "object", + "required": [ + "type" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "description": "The event type, in the reverse-DNS form of SPECIFICATION.md \u00a78.", + "pattern": "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9_]*)+$", + "examples": [ + "com.example.entity.updated" + ] + }, + "description": { + "type": "string", + "description": "Human-readable summary of when this event is emitted.", + "maxLength": 512 + }, + "dataschema": { + "type": "string", + "description": "Absolute URI of a schema describing this event type's `data`. Publishers SHOULD set the matching `dataschema` attribute on the events themselves (\u00a77.1) so the contract also travels with each event.", + "format": "uri" + }, + "since": { + "type": "string", + "description": "EEP or publisher version in which this event type first appeared.", + "maxLength": 32 + }, + "deprecated": { + "type": "boolean", + "description": "Whether this event type is deprecated. Publishers SHOULD keep emitting a deprecated type for a documented period rather than removing it silently.", + "default": false + } + } + } + }, + "event_catalog_url": { + "type": "string", + "description": "Absolute https URL of a paginated event catalog, for publishers whose event list is too large or too volatile to inline. Follows the same pagination shape as `capabilities_query_url` (\u00a712.4). When both are present, `event_types` is a subset and the URL is authoritative.", + "format": "uri", + "examples": [ + "https://api.example.com/eep/event-types" + ] + }, "gates_url": { "type": "string", "format": "uri", diff --git a/tests/conformance-fixtures/discovery/event-catalog-bad-type.expected.json b/tests/conformance-fixtures/discovery/event-catalog-bad-type.expected.json new file mode 100644 index 0000000..e8200d7 --- /dev/null +++ b/tests/conformance-fixtures/discovery/event-catalog-bad-type.expected.json @@ -0,0 +1,4 @@ +{ + "valid": false, + "reason": "event_types[].type must match the reverse-DNS pattern of SPECIFICATION.md \u00a78" +} diff --git a/tests/conformance-fixtures/discovery/event-catalog-bad-type.input.json b/tests/conformance-fixtures/discovery/event-catalog-bad-type.input.json new file mode 100644 index 0000000..bfd9203 --- /dev/null +++ b/tests/conformance-fixtures/discovery/event-catalog-bad-type.input.json @@ -0,0 +1,25 @@ +{ + "did": "did:web:test.eep.dev:o:acme", + "eep_version": "0.1", + "eep_versions": [ + "0.1" + ], + "preferred_version": "0.1", + "layers": { + "layer1": "https://test.eep.dev/eep", + "layer2_sse": "https://test.eep.dev/eep/stream", + "layer2_webhook": "https://test.eep.dev/eep/subscribe", + "layer3_ws": "wss://test.eep.dev/eep/pulse" + }, + "supported_content_types": [ + "application/json", + "text/markdown" + ], + "pqc_ready": false, + "x402_enabled": false, + "event_types": [ + { + "type": "Com.Example.Entity.Updated" + } + ] +} diff --git a/tests/conformance-fixtures/discovery/well-known-eep-valid.input.json b/tests/conformance-fixtures/discovery/well-known-eep-valid.input.json index 7a1642d..189468c 100644 --- a/tests/conformance-fixtures/discovery/well-known-eep-valid.input.json +++ b/tests/conformance-fixtures/discovery/well-known-eep-valid.input.json @@ -1,7 +1,9 @@ { "did": "did:web:test.eep.dev:o:acme", "eep_version": "0.1", - "eep_versions": ["0.1"], + "eep_versions": [ + "0.1" + ], "preferred_version": "0.1", "layers": { "layer1": "https://test.eep.dev/eep", @@ -14,5 +16,17 @@ "text/markdown" ], "pqc_ready": false, - "x402_enabled": false + "x402_enabled": false, + "event_types": [ + { + "type": "com.example.entity.updated", + "description": "A field on the entity profile changed.", + "dataschema": "https://example.com/schemas/entity.updated/v2.json", + "since": "0.1" + }, + { + "type": "com.example.trust.changed", + "deprecated": true + } + ] } diff --git a/tests/conformance-fixtures/manifest.json b/tests/conformance-fixtures/manifest.json index fb409f7..cbe6689 100644 --- a/tests/conformance-fixtures/manifest.json +++ b/tests/conformance-fixtures/manifest.json @@ -243,6 +243,17 @@ "expected": "discovery/crosswalk-host/expected.json", "shape": "bundle", "asserts_valid": true + }, + { + "id": "discovery-event-catalog-bad-type", + "category": "discovery", + "tier": "Standard", + "spec_section": "\u00a712.3.2 Event catalog", + "schema": "schemas/v0.1/eep-manifest.json", + "input": "discovery/event-catalog-bad-type.input.json", + "expected": "discovery/event-catalog-bad-type.expected.json", + "shape": "json-pair", + "asserts_valid": false } ] } diff --git a/tests/types/eep-schemas.d.ts b/tests/types/eep-schemas.d.ts index 90040ec..cb53b7c 100644 --- a/tests/types/eep-schemas.d.ts +++ b/tests/types/eep-schemas.d.ts @@ -898,6 +898,37 @@ export interface EEPManifest { * @minItems 1 */ supported_content_types: [string, ...string[]]; + /** + * Event types this publisher emits, with the payload contract for each. Without this an agent can discover THAT a publisher speaks EEP and WHERE its endpoints are, but not WHAT it will send — it must read human documentation or subscribe to a wildcard and find out empirically. Publishers with large or frequently-changing catalogs SHOULD use `event_catalog_url` instead. + * + * @maxItems 500 + */ + event_types?: { + /** + * The event type, in the reverse-DNS form of SPECIFICATION.md §8. + */ + type: string; + /** + * Human-readable summary of when this event is emitted. + */ + description?: string; + /** + * Absolute URI of a schema describing this event type's `data`. Publishers SHOULD set the matching `dataschema` attribute on the events themselves (§7.1) so the contract also travels with each event. + */ + dataschema?: string; + /** + * EEP or publisher version in which this event type first appeared. + */ + since?: string; + /** + * Whether this event type is deprecated. Publishers SHOULD keep emitting a deprecated type for a documented period rather than removing it silently. + */ + deprecated?: boolean; + }[]; + /** + * Absolute https URL of a paginated event catalog, for publishers whose event list is too large or too volatile to inline. Follows the same pagination shape as `capabilities_query_url` (§12.4). When both are present, `event_types` is a subset and the URL is authoritative. + */ + event_catalog_url?: string; /** * Gate configuration endpoint URL */