# Sync architecture

<!-- @covers packages/sync/src -->

Sync is a room-scoped request and fan-out model. Server-side work is performed once for a request; optional per-recipient work can filter or tailor what each receiver sees. Ordinary fan-out and live chunked streaming solve different needs. Consult installed sync declarations for handler types and its README for adoption; the [framework guide](https://github.com/ItsLucky23/LuckyStack-v2/blob/main/packages/sync/README.md) does not require a monorepo checkout.

## Model and costs

- Do not add a per-recipient handler that only passes data through: it spends work for every room member without changing authorization or output. Add one only for receiver-specific policy or transformation. The recipient's session must be looked up deliberately when needed rather than fetched for every receiver by default.
- Pick a streaming audience intentionally. Originator-only progress should not broadcast to the whole room; a collaborative stream must reach remote members, not only the sender's local process. High-frequency tiny chunks may need coalescing to avoid needless socket traffic. For multi-instance delivery see [multi-instance architecture](./ARCHITECTURE_MULTI_INSTANCE.md).
- The browser's one socket still carries room membership and recipient callbacks when invocation is routed over HTTP/SSE. Shared Redis fans out delivery across instances but cannot execute a sync handler absent from the owning service.
- Authorization and receiver policy must hold across both invocation transports. A room address alone is not authority to send or receive. Run authentication before exposing validation or route details to unauthenticated clients.
- Error codes are stable translation keys, not raw exception text. Wire inputs and stream outputs must be JSON-safe; a declared `Date` input is not a `Date` instance after transport, and binary payloads differ by transport. Validate and convert at the boundary.
- An offline replay is not proof of exactly-once processing. Use application-level deduplication for changes that must not repeat, and do not hide generated route typing behind permissive wrapper functions.

## Guarantees across transports

Auth, receiver policy, rate limits, validation, typing, cancellation, timeout and offline-queue semantics are the same on both transports, with one deliberate exception: room membership.

- **An empty target room is an error on every transport** (`sync.noReceiversFound`). Every room bug looks the same from outside: a wrong room name, a socket that did not rejoin after a reconnect, or a formatter mismatch between the join and send sides. A broadcast that silently reaches nobody hides that whole class of bug. Routed HTTP used to answer `success` and was the silent transport. Keeping that asymmetry, an opt-in strict flag and a zero-recipient count on success were all rejected because each keeps the blind spot as the default. See ADR 0058.
- **Recipients resolve before the `_server` handler runs.** The cross-instance lookup can fail on its own, for example when an instance on the same adapter channel does not answer. If it ran after the handler, the caller could see an error about a change that had already been saved, and a retry would run the mutation twice. An empty room or a failed recipient lookup is now a rejection before commit: nothing was persisted and a retry is safe. Reporting success with a delivery warning was rejected because it says "success" about something that partly failed. Rolling back was rejected because the framework does not own the handler's side effects. Trade-off: a socket that joins during `_server` misses that one fan-out. **Upgrade consequence:** UI that read `sync.noReceiversFound` as "saved, but nobody was listening" must now treat it as "not saved". See ADR 0063.
- The originator is answered once `_server` saved the change and `preSyncFanout` allowed delivery, on both transports; delivery continues in the background, because a serial fan-out on a large room outlasted the HTTP request timeout and a retry applied the saved change twice (ADR 0071).
- **Room membership is checked from a different source per transport.** On the socket transport, the requirement means *physical* membership: the originating socket is in the formatted room the fan-out will deliver to. Routed HTTP has no originating socket and may land on an instance that holds none of the caller's sockets. It therefore checks *logical* membership: the session's persisted room codes plus the caller's own token room. An anonymous HTTP caller has no membership and fails closed. **On routed HTTP, a successful sync does not prove that the caller is in the room or will receive the fan-out.** A session can list a room that its socket has not rejoined after a reconnect. The reverse can also happen: a socket can join a room that the session does not list. Rejoining after reconnect therefore remains the application's job. Development logging warns about the first case. Both unification options were rejected. Using session codes everywhere would refuse real physical members. Using live sockets everywhere is impossible over HTTP without a cross-instance lookup before authorization. Refusing HTTP callers without a socket would break workers and pure HTTP clients, which are legitimate originators. See ADR 0059.

Error meanings per code live in `packages/sync/docs/error-states.md`; fan-out ordering in `packages/sync/docs/room-fanout.md`.

Route versioning and source discovery are explained in [routing](./ARCHITECTURE_ROUTING.md); transport and CSRF boundaries in [API](./ARCHITECTURE_API.md) and [HTTP](./ARCHITECTURE_HTTP.md).
