# Event hints

Document: SPEC-027. Revision: 0.1. Date: 2026-11-07. Lifecycle: Draft implementing D-221. The transport choice — WebSocket change hints with authenticated canonical synchronization and polling fallback — is CONFIRMED by the human's explicit selection recorded in D-221. Paths, frames, close codes, lifetimes, limits and pacing below are engineering selections under the D-024 delegation; they add no product behaviour beyond earlier delivery of reads the account could already make. Supersedes: none. Refines the SPEC-009 V1 event channel without replacing it.

## Purpose and principles

A connected device learns that its own authenticated read may now return something new, so it can read earlier than its next poll. The hint is advisory:

- A hint carries no content, identifiers, counts or timestamps. It never authorizes anything. All authority, ordering, history, admission, visitor-proof and eligibility checks remain on the existing `/sync`, `/server/sync`, `/direct/*` reads and commands.
- Polling at the existing cadence continues in every state, including while a socket is ready. A lost hint, closed socket, restart or deployment is recovered by the next poll and by one recovery read on every socket READY.
- A hint goes only to an account already entitled to observe the change through its own reads, and never discloses activity timing for hidden, private, unjoined or non-participant rooms or conversations.
- Old clients and old servers interoperate unchanged: a client subscribes only when the authority's `/info` it has already read lists `event-hints/1` (SPEC-005 owns where that knowledge comes from), so an old server receives no ticket request. A ticket-route refusal from an authority that advertises the capability (for example during rollback) is the fallback probe and disables that subscription. Every failure falls back to polling without a user-visible error.

Canonical ownership: this specification owns the wire profile. [SPEC-008](008-server.md#event-hints-d-221) owns server issuance, validation, change capture, relevance, pacing and lifecycle. [SPEC-005](005-clients.md#event-hints-d-221) owns client state, scheduling, platform transport and budget behaviour. [SPEC-003](003-identity-security.md) session, device and visitor rules apply unchanged.

## Wire profile `event-hints/1`

Capability: `GET /api/info` lists `event-hints/1` only when the deployment adapter accepts event sockets (Cloudflare Worker and the Node local server).

Ticket: `POST <base>api/events/ticket` with the ordinary account authentication (bearer token, `x-sloppy-client`, and for visitors the usual request proof with current home approval). The request is charged to the existing `api:<account>` bucket and is allowed for conversation-only visitor connections. The body MUST be exactly `{}`; anything else is `400 event_ticket`. Response:

```json
{"profile":"event-hints/1","protocol":"sloppy-events.1","ticket":"<43 base64url>","expiresAt":<ms>}
```

The ticket is 256 random bits, single-use, valid for 30 seconds, stored only as its SHA-256 digest bound to the issuing session, account, device and (for visitors) grant digest. It is never a cookie, never in a URL, never logged.

Upgrade: `GET <base>api/events` with `Upgrade: websocket` and `Sec-WebSocket-Protocol: sloppy-events.1, ticket.<ticket>` — exactly these two tokens in this order. Browsers cannot set `Authorization` on a WebSocket, so the ticket travels only in this header. Any query string or fragment is `400`. The Origin rule is the HTTP rule: absent, configured, or federation enabled; otherwise `403 origin`. The server consumes the ticket before further validation, then requires an unexpired ticket and a current session (unexpired, not retired, not pending, signed-key still trusted), an enabled account, the device in the current roster and an unchanged visitor grant. Failures return the existing JSON error bodies (`400`, `401`, `403`, `404`, `426`, `503`) before any socket exists. On success the `101` response echoes only `sloppy-events.1`, never the ticket. No cookies or reusable credentials are used, including for cross-authority sockets.

Frames are UTF-8 JSON text of at most 512 bytes. Binary frames are a policy violation.

| Direction | Frame | Meaning |
| --- | --- | --- |
| Server | `{"t":"ready","v":1,"ttlMs":N,"heartbeatMs":30000}` | Accepted; `ttlMs` is the remaining socket lifetime |
| Server | `{"t":"hint","k":["sync"]}`, `["inbox"]` or `["sync","inbox"]` | Read the named loop early |
| Server | `{"t":"pong"}` | Heartbeat reply |
| Client | exactly `{"t":"ping"}` | Heartbeat |
| Client | `{"t":"watch","r":[ROOM…]}` | Replace the watched view set: 0–8 unique room identifiers (`lobby` or v4 UUID) |

`sync` means the account's selected-view synchronization read; `inbox` means its conversation list read. A client ignores unknown frame types and treats an unknown kind as every kind. A watch for a room the account cannot read is accepted silently and never produces a hint; the server never confirms whether a room exists. Watch selection replaces bounded ticket scopes so a view change needs no new ticket or request.

Close codes: `4001` lifetime reached (reticket now), `4401` authority lost, `4408` heartbeat idle, `4429` superseded by the session cap, `1008` invalid frame, `1009` oversized frame, `1012` restart/deploy, `1013` backpressure.

## Relevance and privacy

For each committed change and each connected account the server sends:

- `sync` when a watched room readable by the account changes (message or state), when a readable room changes structurally (state revision, creation, deletion, voice occupancy), when the account's own membership in a room changes, or when the account loses read access to a watched room;
- `inbox` when a readable hosted conversation changes, when a hosted direct conversation in which the account is an invited or joined member changes, when the account's own membership entry in one changes, and when a cross-home notice for the account changes.

Readable means admitted to an ordinary or thread room under its current permissions, or a hosted conversation the account may read. Visible but unjoined rooms, private rooms, rooms of conversation-only connections, conversations an account has left and other accounts' notices produce no frame. Message-only changes in readable rooms the account is not watching produce no frame. Cross-home activity reaches a device only through its own socket to the conversation authority; no per-message home notification is added. A visitor socket receives a hint only while its verified home approval is fresh under the request-authorization predicate; the hint path never refreshes or extends that approval, visitor freshness or any media deadline.

## Compatibility

New client and old server: no capability, so no ticket request and polling only; a `404` on the ticket route despite the capability (rollback window) disables hints after a bounded startup probe, polling only. Old client and new server: unchanged polling. Mixed federation: each authority is independent. Rollback leaves expired ticket digests that grant nothing and are pruned by a later forward version.

## Configuration

Every value is a fixed protocol or implementation rule classified per SPEC-000; none is an operator or user setting, there is no kill switch, and rollback is a redeploy. Shared constants live in `shared/event-hints.ts`.

| Owner | Name | Value | Notes |
| --- | --- | --- | --- |
| SPEC-027 | Capability | `event-hints/1` | Fixed |
| SPEC-027 | Ticket path / socket path | `/api/events/ticket` / `/api/events` | Under the mounted service base |
| SPEC-027 | Subprotocol | `sloppy-events.1`, ticket token `ticket.<43 base64url>` | Fixed |
| SPEC-027 | Frame limit | 512 bytes | Both directions |
| SPEC-027 | Watch set | ≤ 8 unique room identifiers | Fixed |
| SPEC-008 | Server timing, caps and pacing | See SPEC-008 Configuration | Engineering selections |
| SPEC-005 | Client timing, caps and scheduling | See SPEC-005 Configuration | Engineering selections |

## Testing

Unit and integration coverage is specified by component: [SPEC-008](008-server.md#testing) for `tests/event-hints-server.test.ts`, `tests/event-hints-node.test.ts`, `tests/event-hints-service.test.ts`, `tests/event-hints-sql-count-workerd.test.ts` and `tests/event-hints-worker.mjs`, plus explicit real-workerd forced-eviction qualification in `tests/worker-event-hibernation.mjs`; [SPEC-005](005-clients.md#testing) for `tests/event-hints-client.test.ts`, `tests/event-hints-budget.test.ts` and `tests/ios-event-socket.test.ts`. Shared wire helpers are tested for: exact protocol offers (order, count, ticket length) accepted and every other offer rejected; client-frame strictness (extra keys, duplicates, invalid identifiers, more than eight rooms, oversize); server-frame parsing (unknown type ignored, unknown kind widened, invalid ready rejected); URL mapping (`https`→`wss`, loopback `http`→`ws`, other `http` rejected); backoff bounds and the pacing allowance line.

## Acceptance criteria

- `GET /api/info` lists `event-hints/1` on Worker and Node deployments; a client against a server without it, or receiving `404` on the ticket route, polls exactly as before.
- No ticket, bearer token or other credential appears in any WebSocket URL, response header, stored record or log; the `101` echoes only `sloppy-events.1`.
- A ticket is refused on reuse, after 30 seconds, with any query string, with any other protocol offer, from a disallowed Origin, and after its session, device, account or grant is no longer current; it is consumed even when refused after lookup.
- A committed message in a watched readable room produces a `sync` hint and no frame contains message content, identifiers or counts.
- Private, hidden, visible-but-unjoined and non-participant activity, including voice and DM activity, produces zero frames for non-entitled sockets.
- Revocation (logout, device removal, disablement) closes affected sockets with `4401` before any further hint; no hint is sent after a socket's lifetime or session deadline.
- With the socket killed or never connected, every change is still observed within the existing polling cadence, and every socket READY triggers one recovery read.
- Two signed-in devices of one account stay within the existing 240/min account budget with hints active, as measured by the SPEC-005 budget scenarios.
