From c68bd8517b3efb3dbbdcf6971f1c5b5421ee7dc8 Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 26 Aug 2026 22:54:50 +0300 Subject: [PATCH] feat(spec,middleware): batched webhook delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.2 mandated one event per POST. A high-frequency entity therefore paid a TLS handshake, a signature computation and a 10-second acknowledgement round-trip per event — the dominant per-delivery cost, and one that does not shrink with a smaller payload. This is the other half of audit finding O4; the filtering half shipped separately. The wire format is the CloudEvents batched JSON encoding (`application/cloudevents-batch+json`), so this is a media type change rather than an EEP invention. Four rules that are easy to get wrong and are stated explicitly: - **Acknowledgement is all-or-nothing.** A 2xx acknowledges every event in the batch; anything else fails the whole batch, retried whole. A subscriber that cannot process one event MUST NOT return 2xx and drop it. That is precisely why batching is opt-in. - **A failed batch is ONE failed delivery** against the §10 counter, not one per event — otherwise a single failure of a 100-event batch would pause a subscription instantly. - **Deduplication stays per event.** Each envelope keeps its own `id`, so on a retry a subscriber discards what it already processed and handles the rest. - **A batching subscription always receives the batch media type**, even when one event is ready. Switching format based on how many events happened to be available would give the subscriber two parsing paths and no way to predict which it gets. `max_batch_wait_ms` bounds the latency batching adds: without it a low-traffic subscription could hold an event indefinitely waiting for a batch that never fills. Batching is structured-mode only. Binary content mode maps attributes to headers, and a batch carries many events with different attribute values, so there is nowhere for them to go. `dispatchBatch` is the delivery half; accumulation policy belongs to a deployment's queue rather than an in-process dispatcher, which is noted rather than pretended otherwise. Refs: EEP audit 2026-08 finding O4 (batching) Signed-off-by: Ugur Cekmez --- docs/current/SPECIFICATION.md | 57 +++++++++ .../middleware/src/core/eep-server.ts | 8 ++ .../middleware/src/core/request-handler.ts | 7 ++ .../middleware/src/dispatcher/content-mode.ts | 18 +++ .../src/dispatcher/webhook-dispatcher.test.ts | 116 ++++++++++++++++++ .../src/dispatcher/webhook-dispatcher.ts | 107 ++++++++++++++-- packages/@eep-dev/middleware/src/index.ts | 9 +- schemas/v0.1/subscription.request.json | 14 +++ tests/types/eep-schemas.d.ts | 8 ++ 9 files changed, 336 insertions(+), 8 deletions(-) diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index a7ebc6b..5b2b0ad 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -673,6 +673,63 @@ conditional requests (§3.2.1), compression (§3.2.2) and subscription filters (§5.1.3) first: those remove whole responses and whole deliveries rather than shrinking them. +### 5.2.2 Batched delivery (normative) + +§5.2 mandates one event per `POST`. A high-frequency entity therefore pays a +TLS handshake, a signature computation and a 10-second acknowledgement +round-trip **per event** — the dominant per-delivery cost, and one that does +not shrink with a smaller payload. + +Publishers MAY offer batching; subscribers opt in with `max_batch_size` on the +subscription request. The wire format is the CloudEvents batched JSON +encoding, so this is a media type change rather than an EEP invention: + +```http +POST /hooks/eep +content-type: application/cloudevents-batch+json +webhook-id: msg_batch_01HN3QK7GX +webhook-timestamp: 1708123456 +webhook-signature: v1,… + +[ { "specversion": "1.0", "id": "evt-1", … }, { "specversion": "1.0", "id": "evt-2", … } ] +``` + +- The body is a JSON array of envelopes. A batch MUST NOT be empty. +- `max_batch_size` bounds the array. `1`, or omitted, preserves today's + one-event-per-POST behaviour exactly. +- A subscription that opted into batching MUST always receive + `application/cloudevents-batch+json`, even when only one event is ready. + Switching format based on how many events happened to be available would + give the subscriber two parsing paths and no way to predict which it gets. +- `max_batch_wait_ms` bounds the latency batching adds: the publisher MUST + deliver once it elapses even if the batch is not full. Without it, a + low-traffic subscription could hold an event indefinitely waiting for a + batch that never fills. +- Events within a batch MUST be ordered as they were emitted for a given + `source`. +- All events in one batch MUST belong to one subscription. Batching is a + transport optimisation and MUST NOT become a way to deliver events across + subscription boundaries. + +**Signing.** The signature covers the raw body — the whole array — exactly as +in §5.3. The `webhook-id` identifies the *batch*, not any single event. + +**Acknowledgement is all-or-nothing.** A `2xx` acknowledges every event in the +batch; any other response fails the whole batch, which is retried whole. A +subscriber that cannot process one event in a batch MUST NOT return `2xx` and +then drop it. This is why batching is opt-in: a subscriber must be able to +process a batch atomically, or ask for `max_batch_size: 1`. + +**Deduplication is per event, not per batch.** Each envelope keeps its own +`id`, so the idempotency rule in +[delivery_guarantees.md](./delivery_guarantees.md) §6 applies unchanged: on a +retry a subscriber discards the events it already processed and processes the +rest. + +**Failure accounting.** A failed batch counts as **one** failed delivery +against the §10 counter, not one per event — otherwise a single failure of a +100-event batch would pause a subscription instantly. + ### 5.3 Webhook signature verification The `webhook-signature` header contains an HMAC-SHA256 signature over the concatenation of: diff --git a/packages/@eep-dev/middleware/src/core/eep-server.ts b/packages/@eep-dev/middleware/src/core/eep-server.ts index bb00ea6..630ade2 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.ts @@ -430,6 +430,14 @@ export class EEPServer { failure_count: 0, expires_at: expiresAt.toISOString(), ...(filter ? { filter } : {}), + ...(typeof body.max_batch_size === "number" && body.max_batch_size > 1 + ? { + max_batch_size: Math.min(Math.floor(body.max_batch_size), 500), + ...(typeof body.max_batch_wait_ms === "number" + ? { max_batch_wait_ms: Math.max(0, Math.min(Math.floor(body.max_batch_wait_ms), 30_000)) } + : {}) + } + : {}), ...(body.delivery_format === "cloudevents/v1.0-binary" ? { delivery_format: "cloudevents/v1.0-binary" as const } : {}), diff --git a/packages/@eep-dev/middleware/src/core/request-handler.ts b/packages/@eep-dev/middleware/src/core/request-handler.ts index 257e127..7f3ea63 100644 --- a/packages/@eep-dev/middleware/src/core/request-handler.ts +++ b/packages/@eep-dev/middleware/src/core/request-handler.ts @@ -94,6 +94,13 @@ export type SubscriptionRecord = { * Defaults to structured. */ delivery_format?: DeliveryFormat; + /** + * Maximum events combined into one delivery (SPECIFICATION.md §5.2.2). + * 1 or absent preserves one-event-per-POST. + */ + max_batch_size?: number; + /** Milliseconds the publisher may hold an event waiting for the batch to fill. */ + max_batch_wait_ms?: number; /** Requested access tier; matched against gate config on delivery. */ tier?: string; created_at: string; diff --git a/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts b/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts index ca9ceeb..470c075 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts @@ -66,3 +66,21 @@ export function renderDelivery(event: CloudEvent, format: DeliveryFormat | undef return { body, headers }; } + +/** CloudEvents batched JSON media type (SPECIFICATION.md §5.2.2). */ +export const BATCH_CONTENT_TYPE = "application/cloudevents-batch+json"; + +/** + * Render a batch of events as one delivery. + * + * Batching is structured-mode only: binary content mode maps attributes to + * headers, and a batch has many events with different attribute values, so + * there is nowhere for them to go. A subscriber asking for both gets + * structured batches. + */ +export function renderBatch(events: CloudEvent[]): RenderedDelivery { + return { + body: JSON.stringify(events), + headers: { "content-type": BATCH_CONTENT_TYPE }, + }; +} diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts index ac582ac..25be2a2 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { EEPSigner, generateSigningKeyPair, verifyEd25519 } from "@eep-dev/signer"; import { WebhookDispatcher, DEFAULT_RETRY_SCHEDULE_MS, type WebhookHttpClient } from "./webhook-dispatcher.js"; +import { BATCH_CONTENT_TYPE } from "./content-mode.js"; import { InMemoryDBAdapter } from "../db/in-memory.js"; import { InMemoryEventBusAdapter } from "../event-bus/in-memory.js"; import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js"; @@ -59,6 +60,121 @@ 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 §5.2.2 — one event per POST means a high-frequency + // entity pays a TLS handshake, a signature and a 10s ack round-trip per + // event. Batching amortises all three. + describe("batched delivery (§5.2.2)", () => { + const setup = async (maxBatchSize: number, script: Array = [200]) => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ max_batch_size: maxBatchSize })); + const { client, calls } = mockClient(script); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + return { db, dispatcher, calls }; + }; + + const batch = (n: number) => Array.from({ length: n }, (_, i) => event({ id: `evt_${i + 1}` })); + + it("combines events into one POST with the CloudEvents batch media type", async () => { + const { dispatcher, calls } = await setup(10); + const results = await dispatcher.dispatchBatch(batch(3), "sub_1"); + + expect(calls).toHaveLength(1); + expect(calls[0]!.headers["content-type"]).toBe(BATCH_CONTENT_TYPE); + expect(JSON.parse(calls[0]!.body)).toHaveLength(3); + // One result per event, so callers still see per-event outcomes. + expect(results).toHaveLength(3); + expect(results.every((r) => r.delivered)).toBe(true); + }); + + it("keeps each envelope's own id so per-event dedup still works", async () => { + const { dispatcher, calls } = await setup(10); + await dispatcher.dispatchBatch(batch(3), "sub_1"); + const sent = JSON.parse(calls[0]!.body) as Array<{ id: string }>; + expect(sent.map((e) => e.id)).toEqual(["evt_1", "evt_2", "evt_3"]); + }); + + it("signs the whole array once", async () => { + const { dispatcher, calls } = await setup(10); + await dispatcher.dispatchBatch(batch(3), "sub_1"); + const call = calls[0]!; + expect( + new EEPSigner(SECRET).verify( + call.headers["webhook-id"]!, + call.headers["webhook-timestamp"]!, + call.headers["webhook-signature"]!, + call.body + ) + ).toBe(true); + }); + + it("splits into several deliveries at max_batch_size", async () => { + const { dispatcher, calls } = await setup(2, [200, 200]); + const results = await dispatcher.dispatchBatch(batch(4), "sub_1"); + expect(calls).toHaveLength(2); + expect(JSON.parse(calls[0]!.body)).toHaveLength(2); + expect(JSON.parse(calls[1]!.body)).toHaveLength(2); + expect(results).toHaveLength(4); + }); + + // max_batch_size 1 must be byte-identical to the unbatched path. + it("sends a single event unbatched when max_batch_size is 1", async () => { + const { dispatcher, calls } = await setup(1); + await dispatcher.dispatchBatch(batch(1), "sub_1"); + expect(calls[0]!.headers["content-type"]).toBe("application/json"); + expect(JSON.parse(calls[0]!.body)).toMatchObject({ id: "evt_1" }); + }); + + it("applies the subscription filter before batching", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription( + subscription({ + max_batch_size: 10, + filter: { match: "all", conditions: [{ path: "id", op: "eq", value: "evt_2" }] } + }) + ); + const { client, calls } = mockClient([200]); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + await dispatcher.dispatchBatch(batch(3), "sub_1"); + const sent = JSON.parse(calls[0]!.body) as Array<{ id: string }>; + expect(sent).toHaveLength(1); + expect(sent[0]!.id).toBe("evt_2"); + // Still the batch media type: a subscriber that opted into batching gets + // one parsing path regardless of how many events were ready. + expect(calls[0]!.headers["content-type"]).toBe(BATCH_CONTENT_TYPE); + }); + + // A failed batch is ONE failed delivery, not one per event — otherwise a + // single failure of a large batch would pause a subscription instantly. + it("counts a failed batch as one failed delivery", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ max_batch_size: 10 })); + const { client } = mockClient(["throw"]); + const dispatcher = new WebhookDispatcher({ + db, + httpClient: client, + retryScheduleMs: NO_DELAY, + pauseAfterFailures: 2 + }); + + await dispatcher.dispatchBatch(batch(5), "sub_1"); + const after = await db.getSubscription("sub_1"); + expect(after?.failure_count).toBe(1); + expect(after?.status).toBe("active"); + }); + + it("delivers nothing for an unknown subscription", async () => { + const { dispatcher, calls } = await setup(10); + expect(await dispatcher.dispatchBatch(batch(2), "sub_missing")).toEqual([]); + expect(calls).toHaveLength(0); + }); + + it("delivers nothing for an empty batch", async () => { + const { dispatcher, calls } = await setup(10); + expect(await dispatcher.dispatchBatch([], "sub_1")).toEqual([]); + expect(calls).toHaveLength(0); + }); + }); + // SPECIFICATION.md §5.2.1 — binary content mode relocates attributes to // ce-* headers so a subscriber can route without parsing the body. describe("content modes (§5.2.1)", () => { diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts index 5bea863..1ffbc47 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts @@ -3,7 +3,7 @@ import { matchesAnyPattern } from "@eep-dev/validator"; import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js"; import type { EventStore } from "../core/event-store.js"; import { eventMatchesFilter } from "../core/event-filter.js"; -import { renderDelivery } from "./content-mode.js"; +import { renderDelivery, renderBatch } from "./content-mode.js"; import type { CloudEvent, DBAdapter, @@ -40,6 +40,9 @@ export const DEFAULT_PAUSE_AFTER_FAILURES = 5; * Per-attempt HTTP timeout. A response slower than this counts as a failure * (delivery_guarantees.md §2: "No response received within 10 seconds"). */ +/** Upper bound on `max_batch_size`, per SPECIFICATION.md §5.2.2. */ +export const MAX_BATCH_SIZE = 500; + export const DEFAULT_DELIVERY_TIMEOUT_MS = 10_000; /** Minimal HTTP response shape the dispatcher needs to judge an attempt. */ @@ -215,6 +218,49 @@ export class WebhookDispatcher { return Promise.all(targets.map((sub) => this.deliverWithRetry(event, sub))); } + /** + * Deliver several events to one subscription as a single batch (§5.2.2). + * + * Callers accumulate events per subscription — the accumulation policy + * (`max_batch_wait_ms`, flush triggers) belongs to the deployment's queue, + * not to an in-process dispatcher. This method is the delivery half: it + * enforces `max_batch_size`, signs the whole array once, and treats the + * acknowledgement as all-or-nothing. + * + * A failed batch counts as ONE failed delivery against the §10 counter, not + * one per event — otherwise a single failure of a 100-event batch would + * pause a subscription instantly. + */ + async dispatchBatch(events: CloudEvent[], subscriptionId: string): Promise { + if (events.length === 0) return []; + const sub = (await this.db.listSubscriptions()).find( + (s) => s.subscription_id === subscriptionId + ); + if (!sub) return []; + + const eligible = events.filter((event) => this.isTarget(sub, event)); + if (eligible.length === 0) return []; + + const limit = Math.max(1, Math.min(sub.max_batch_size ?? 1, MAX_BATCH_SIZE)); + + // A subscription that opted into batching always receives the batch media + // type, even when a chunk happens to hold one event. Switching format + // based on how many events happened to be ready would give the subscriber + // two parsing paths and no way to predict which it will get. + const batched = limit > 1; + + const results: DeliveryResult[] = []; + for (let i = 0; i < eligible.length; i += limit) { + const chunk = eligible.slice(i, i + limit); + results.push( + ...(batched + ? await this.deliverBatchWithRetry(chunk, sub) + : [await this.deliverWithRetry(chunk[0]!, sub)]) + ); + } + return results; + } + private isTarget(sub: SubscriptionRecord, event: CloudEvent): boolean { const deliverable = sub.delivery_method === "webhook" && @@ -256,6 +302,47 @@ export class WebhookDispatcher { return expiresAt <= Date.now(); } + private async deliverBatchWithRetry( + events: CloudEvent[], + sub: SubscriptionRecord + ): Promise { + // The batch is identified as a whole; each envelope keeps its own `id` so + // per-event deduplication on the subscriber side is unchanged. + const batchId = `msg_batch_${events[0]!.id}_${events.length}`; + let lastStatus: number | undefined; + + const outcomeFor = (delivered: boolean, attempts: number, aborted?: boolean): DeliveryResult[] => + events.map((event) => + this.report({ + subscription_id: sub.subscription_id, + event_id: event.id, + delivered, + attempts, + ...(lastStatus === undefined ? {} : { last_status: lastStatus }), + ...(aborted ? { aborted: true } : {}) + }) + ); + + for (let attempt = 0; attempt < this.retrySchedule.length; attempt++) { + const delay = this.retrySchedule[attempt] ?? 0; + if (delay > 0) await sleep(delay); + if (this.stopped) return outcomeFor(false, attempt, true); + + const outcome = await this.attemptDelivery(events, sub, batchId); + lastStatus = outcome.status; + // All-or-nothing: a 2xx acknowledges every event in the batch, anything + // else fails the whole batch and it is retried whole. + if (outcome.ok) { + await this.recordSuccess(sub.subscription_id); + return outcomeFor(true, attempt + 1); + } + } + + // One failed batch is one failed delivery, not one per event. + await this.recordFailure(sub.subscription_id); + return outcomeFor(false, this.retrySchedule.length); + } + private async deliverWithRetry(event: CloudEvent, sub: SubscriptionRecord): Promise { let lastStatus: number | undefined; @@ -305,8 +392,9 @@ export class WebhookDispatcher { } private async attemptDelivery( - event: CloudEvent, - sub: SubscriptionRecord + event: CloudEvent | CloudEvent[], + sub: SubscriptionRecord, + batchId?: string ): Promise<{ ok: boolean; status?: number }> { const secret = sub.delivery_secret ?? this.fallbackSecret; const url = sub.callback_url; @@ -318,10 +406,15 @@ export class WebhookDispatcher { // §5.2.1 — render in the subscription's content mode. The signature is // computed over whatever body this produces, so a binary-mode subscriber // verifies exactly what it received without reassembling an envelope. - const { body, headers: contentHeaders } = renderDelivery(event, sub.delivery_format); + // Batches (§5.2.2) are structured-mode only: binary mode maps attributes + // to headers, and a batch has many events with different values. + const { body, headers: contentHeaders } = Array.isArray(event) + ? renderBatch(event) + : renderDelivery(event, sub.delivery_format); // Stable across retries so subscribers can deduplicate; the event `id` - // is the idempotency key per delivery_guarantees.md §1. - const webhookId = `msg_${event.id}`; + // is the idempotency key per delivery_guarantees.md §1. For a batch the + // id identifies the batch, and each envelope keeps its own. + const webhookId = batchId ?? `msg_${(event as CloudEvent).id}`; // Re-signed per attempt with a current timestamp so a late retry still // lands inside the subscriber's replay window. const timestamp = Math.floor(Date.now() / 1000).toString(); @@ -360,7 +453,7 @@ export class WebhookDispatcher { // §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) + ...(Array.isArray(event) ? {} : traceHeaders(event)) }, body, signal: controller.signal diff --git a/packages/@eep-dev/middleware/src/index.ts b/packages/@eep-dev/middleware/src/index.ts index f6b5aa5..1bf4199 100644 --- a/packages/@eep-dev/middleware/src/index.ts +++ b/packages/@eep-dev/middleware/src/index.ts @@ -19,6 +19,7 @@ export { DEFAULT_RETRY_SCHEDULE_MS, DEFAULT_PAUSE_AFTER_FAILURES, DEFAULT_DELIVERY_TIMEOUT_MS, + MAX_BATCH_SIZE, type WebhookDispatcherOptions, type WebhookHttpClient, type WebhookHttpResponse, @@ -48,7 +49,13 @@ export { InMemoryEventBusAdapter } from "./event-bus/in-memory.js"; export { RedisEventBusAdapter, type RedisClientLike } from "./event-bus/redis.js"; export { KafkaEventBusAdapter, type KafkaProducerLike, type KafkaConsumerLike } from "./event-bus/kafka.js"; -export { renderDelivery, toBinaryAttributeName, type RenderedDelivery } from "./dispatcher/content-mode.js"; +export { + renderDelivery, + renderBatch, + toBinaryAttributeName, + BATCH_CONTENT_TYPE, + type RenderedDelivery +} from "./dispatcher/content-mode.js"; export { validateFilter, diff --git a/schemas/v0.1/subscription.request.json b/schemas/v0.1/subscription.request.json index 43813f1..818c95c 100644 --- a/schemas/v0.1/subscription.request.json +++ b/schemas/v0.1/subscription.request.json @@ -124,6 +124,20 @@ ], "default": "cloudevents/v1.0" }, + "max_batch_size": { + "type": "integer", + "description": "Maximum events the publisher may combine into one delivery (SPECIFICATION.md \u00a75.2.2). 1, or omitted, means one event per POST. Batching amortises the TLS handshake, the signature computation and the 10-second acknowledgement round-trip across N events, which is the dominant per-delivery cost for a high-frequency entity.", + "minimum": 1, + "maximum": 500, + "default": 1 + }, + "max_batch_wait_ms": { + "type": "integer", + "description": "How long the publisher may hold an event waiting for the batch to fill. Bounds the latency batching adds; the publisher MUST deliver once this elapses even if the batch is not full. Ignored when `max_batch_size` is 1.", + "minimum": 0, + "maximum": 30000, + "default": 0 + }, "lease_seconds": { "type": "integer", "description": "Optional. Requested subscription lifetime in seconds, starting from successful intent verification (SPECIFICATION.md \u00a710.2). The publisher MAY clamp this to its own policy and reports the value actually granted as `expires_at` on the subscription. Omit to accept the publisher's default lease.", diff --git a/tests/types/eep-schemas.d.ts b/tests/types/eep-schemas.d.ts index 0f5b8c7..f4033fe 100644 --- a/tests/types/eep-schemas.d.ts +++ b/tests/types/eep-schemas.d.ts @@ -5494,6 +5494,14 @@ export interface EEPSubscriptionRequest { * The event envelope format for delivery. `cloudevents/v1.0` is structured mode: the whole envelope is one JSON document in the body. `cloudevents/v1.0-binary` is binary content mode, where context attributes travel as protocol metadata and the body carries only `data` (SPECIFICATION.md §5.2.1). Defaults to structured. */ delivery_format?: 'cloudevents/v1.0' | 'cloudevents/v1.0-binary'; + /** + * Maximum events the publisher may combine into one delivery (SPECIFICATION.md §5.2.2). 1, or omitted, means one event per POST. Batching amortises the TLS handshake, the signature computation and the 10-second acknowledgement round-trip across N events, which is the dominant per-delivery cost for a high-frequency entity. + */ + max_batch_size?: number; + /** + * How long the publisher may hold an event waiting for the batch to fill. Bounds the latency batching adds; the publisher MUST deliver once this elapses even if the batch is not full. Ignored when `max_batch_size` is 1. + */ + max_batch_wait_ms?: number; /** * Optional. Requested subscription lifetime in seconds, starting from successful intent verification (SPECIFICATION.md §10.2). The publisher MAY clamp this to its own policy and reports the value actually granted as `expires_at` on the subscription. Omit to accept the publisher's default lease. */