diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index a4b50af..f623441 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -547,6 +547,8 @@ Cursors are opaque strings managed by the server. They represent a position in t **Initial cursor:** Passing `cursor: null` in any mode means "start from now." The server returns a fresh cursor representing the current position (or `null` if the event type does not support replay). No historical events are replayed. +**Absent means `null`.** `cursor` is optional and nullable everywhere it appears — requests, responses, and delivered payloads. An absent `cursor` field MUST be treated identically to an explicit `cursor: null`: a sender MAY omit the field instead of writing `null`, and a receiver MUST NOT fail because it is missing. This holds in both directions for both SDKs, so an emit-only server with no replay history can simply never emit the field — and a client that has nothing persisted can omit it on subscribe/poll — and both remain conformant without special-casing the field just to write an explicit `null`. + **Cursor advancement without events.** The client's persisted cursor must advance during quiet periods so it doesn't fall outside the upstream's retention window when nothing is happening. Poll covers this inherently (every response carries `cursor`, including when `events: []`). For push, the heartbeat carries `cursor` (see *Heartbeat* under Push-Based Delivery). For webhook, each `events/subscribe` refresh response carries `cursor`. In all three the value is safe to persist (for webhook it is the same watermark as in delivery payloads, never ahead of unacked events); the client persists it exactly as it would a cursor from a delivered event. **Bounding replay with `maxAgeMs`.** All three modes accept an optional `maxAgeMs` (integer milliseconds) alongside `cursor`. When present, the server begins replay from whichever is *later*: the supplied `cursor`, or `now − maxAgeMs`. This lets a client resubscribe with a stale cursor without receiving an unbounded backlog — e.g., a host that was offline for a week can pass its persisted cursor with `maxAgeMs: 300000` to get at most the last five minutes. If the floor advances past the cursor, the server SHOULD set `truncated: true` on the first response (poll result / `notifications/events/active` / webhook subscribe response) so the client knows older events were skipped. Servers whose upstream supports time-addressed reads (e.g., Gmail `after:`, Slack `oldest=`, Kafka `offsetsForTimes`) SHOULD seek directly; others MAY honour `maxAgeMs` by replaying from `cursor` and discarding events whose `timestamp` is older than the floor — or, if that would be prohibitively expensive (e.g., a week-old cursor with `maxAgeMs: 300000`), MAY instead reset to now and return `truncated: true`, which yields the same client-visible outcome without the wasted scan. Servers MAY also apply their own replay ceiling independent of `maxAgeMs` and MUST signal it via `truncated: true` when they do. `maxAgeMs` is ignored when `cursor` is `null` (null already means "now"; replay from before the subscription's start is out of scope per *What Is NOT in v1*) and for event types that do not support replay.