Sloppy Voice DevelopersSloppy Voice ↗

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 owns server issuance, validation, change capture, relevance, pacing and lifecycle. SPEC-005 owns client state, scheduling, platform transport and budget behaviour. SPEC-003 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:


{"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.

DirectionFrameMeaning
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
Clientexactly {"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.

OwnerNameValueNotes
SPEC-027Capabilityevent-hints/1Fixed
SPEC-027Ticket path / socket path/api/events/ticket / /api/eventsUnder the mounted service base
SPEC-027Subprotocolsloppy-events.1, ticket token ticket.<43 base64url>Fixed
SPEC-027Frame limit512 bytesBoth directions
SPEC-027Watch set≤ 8 unique room identifiersFixed
SPEC-008Server timing, caps and pacingSee SPEC-008 ConfigurationEngineering selections
SPEC-005Client timing, caps and schedulingSee SPEC-005 ConfigurationEngineering selections

Testing

Unit and integration coverage is specified by component: SPEC-008 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 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.