# Changelog — `@ai-matrx/realtime`

## 0.10.9
### Unreleased

Adds `@ai-matrx/realtime/collab` and `@ai-matrx/realtime/collab/react` (office-suite W1-B, BUILD-BOOK §2.3.2).
Moved from matrx-frontend `lib/collab` and `features/workbooks/collab/autosave-writer.ts`:

- `openCollabSession({ channel, manager?, me, tabTieBreak })` -> `{ doc, awareness, isSaver, onSaverChange, destroy }`.
- `SupabaseYjsProvider` (unchanged wire protocol, channel `yjs:<noun>:<id>`). The anti-entropy period is no longer
  read from the app's knob store: pass `antiEntropyIntervalMs` or `readAntiEntropyIntervalMs`; the default is 30 s.
- `electCollabHost`, `mayAutosave`, `remoteOriginOf`, `REMOTE_PARAM_TAG` — byte-for-byte the live saver election.
- `/collab/react`: `RemoteCursorsLayer` (positioned carets) and `RemotePeerChips` (the old "who is here" strip).
- New optional peers: `yjs`, `y-protocols` (needed only by the `/collab` sections).

## 0.10.8

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.7..npm/realtime/v0.10.8 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.7

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.6..npm/realtime/v0.10.7 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.6

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.5..npm/realtime/v0.10.6 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.5

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.4..npm/realtime/v0.10.5 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.4

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.3..npm/realtime/v0.10.4 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.3

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.2..npm/realtime/v0.10.3 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.2

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.10.1..npm/realtime/v0.10.2 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.10.1 — a channel that never joins stops retrying, and says so once

- Reconnect was unbounded: a channel for a subject that does not exist (a conversation whose first
  send was refused) retried every 30 s forever, one console/Error Inspector entry per attempt.
  After `RECONNECT_GIVE_UP_ATTEMPTS` (10) consecutive failures with no stable connection between,
  the manager abandons the channel: status `disconnected`, no timer, ONE
  `channel.reconnect.abandoned` error diagnostic. A tab wake or network restore starts a fresh
  bounded round. New `giveUpAfterAttempts` manager option (0 / Infinity = unbounded).

Consumer action: none.

## 0.10.0 — global slots come from kit

- The process-global slots (client id, topic registry, metrics, ambient manager, React contexts)
  use `@ai-matrx/kit/global-slot`, the one home for the primitive; the slot keys are unchanged
  (`ai-matrx.realtime.*`), so mixed versions in one page still share one registry. realtime now
  depends on `@ai-matrx/kit` (layer 0 -> 1) (architecture D9).

Consumer action: none — kit installs with it.

## 0.9.2

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.9.1..npm/realtime/v0.9.2 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.9.1 — 2026-10-07

- `defineChannelNamespace`: outside production, the same channel (same parts and foreign topic)
  re-declared with a new description replaces the old declaration and warns instead of throwing.
  A long-running dev server keeps its globalThis registry across a package reinstall, so chat
  0.3.19's in-place description change made every page on it return 500. A different shape under
  the same name is still refused everywhere; in production a description mismatch is still refused.
- No consumer action.

## 0.9.0 — 2026-10-04

- Same-tab holders of one topic now hear each other. They share one client id and one channel
  opened `broadcast.self:false`, so a frame sent by one holder never reached its sibling (the
  wire does not echo it; the receive side would drop it as "ours"). The manager now fans every
  `send()` out locally to the other open holders of the topic. Cross-tab behaviour is unchanged.
- New `ChannelHandle.isLocalLeader()` and `ChannelSpec.onLeaderChange`: exactly one holder per
  topic per manager (tab) is the leader — the longest-open one — and leadership hands over when it
  closes. Use it for once-per-tab work such as autosave or CRDT host election.
- Additive; no consumer action required. Consumers that elect a host among same-tab holders
  should gate it on `isLocalLeader()`.

## 0.8.7

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.8.6..npm/realtime/v0.8.7 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.8.6

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.8.5..npm/realtime/v0.8.6 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.8.5

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.8.4..npm/realtime/v0.8.5 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.8.4 — 2026-09-16

- Reconnect failures remain warning-level for attempts one through five and
  escalate only at attempt six. A short socket interruption therefore cannot
  create a red capture for every open logical channel, while a sustained
  outage remains loud after the default capped backoff has had time to recover.

## 0.8.3

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.8.2..npm/realtime/v0.8.3 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.8.2 — 2026-09-13

Two findings from the third adversarial pass:

- **The unsettled-fingerprint set released the wrong string.** `begin` recorded the content at
  save time; `settle` released the content at settle time — and a keystroke mid-save makes those
  different strings, so the in-flight fingerprint stayed "ours" forever and a later collaborator
  write carrying that exact content at a higher revision was swallowed by R2b. Now each write's
  begin fingerprint is keyed by its request id and released on settle, abandon, or expiry —
  exactly what begin added.
- **Row entries were immortal.** A settled entry is only a monotonic floor; one idle for an hour
  is now forgotten (forgetting a floor costs at most one delivered event — the safe direction).
  Entries with a pending write are never forgotten.

No consumer action.

## 0.8.1 — 2026-09-13

Four holes an adversarial review found in 0.8.0, each with a proving test:

- **R2 no longer matches on the number alone.** A pending write expecting revision N and an
  incoming row at N from a DIFFERENT actor with no content to compare was suppressed — a
  collaborator who won the race for our number was dropped and the ledger advanced as if we
  had written it. Now content must agree, or, with no content to compare, the actor must.
- **The revision lane has a content fallback (R2b).** A row newer than we hold, carrying
  exactly the content of a write whose REST response has NOT come back yet, is our echo (our
  write landed above the number `begin` expected, after a compare-and-swap rebase). A write the
  server has already answered can never claim a higher number — that number is someone else's,
  however familiar its content (the matrx-local bump was exactly that shape). The 0.8.0
  changelog's "falls back to the content test" sentence was false for settled writes; it is
  true now only for unsettled ones, by design.
- **DELETE events are never classified.** A hard DELETE carries the row's last state (REPLICA
  IDENTITY FULL) — the revision and content we hold — so the ledger read it as our own echo and
  the row lived on in every list until a backfill (0.7.x did the same by timestamp). Removing a
  row is idempotent; every DELETE is delivered.
- `abandon` also releases the write's unsettled fingerprint.

No consumer action.

## 0.8.0 — 2026-09-13

### The write ledger is revision-aware: a `version` it never saw is never an echo

**The phantom conflict (notes, 2026-09-13).** Every platform table's `version` moves on EVERY
update of the row — a desktop sync stamping `file_path`, an ingest job writing metadata, a
folder move — not only on the fields an editor edits. Such a write lands under the same actor
with identical content, and the ledger's "same actor + same fingerprint + our write pending ⇒
own echo" test swallowed it. The client kept the old number; its next save's compare-and-swap
missed on a row whose text equalled the client's own base, and the user was shown a conflict
dialog for an edit nobody made. The client had suppressed the one event that carried the
number it needed.

- `classify` now runs a **revision lane first** (`origin.ts` R1–R4): older revision → `stale`;
  the revision an unsettled write of ours will produce, with matching content → `own-echo`
  (this is the echo that beats the REST response, now recognized by number); an already
  settled revision with matching content → `own-echo`; **anything newer → `remote`, always** —
  same actor, same content, pending write or not. The timestamp/fingerprint/actor doctrine is
  unchanged and applies only when no revision is present on either side.
- `LocalWrite.revision`, `settle(ticket, { revision })`, `observe({ revision })` and
  `ClassifyInput.revision` carry the number. `WriteLedger.heldRevision(table, id)` exposes it.
- `PostgresChangeBinding.revisionField` (default `"version"`, `null` to opt out) — the manager
  passes the row's revision into `classify` for every binding **without a consumer change**.
- New `OriginReason` values: `older-revision`, `pending-write-same-revision`,
  `equal-revision-same-content`, `newer-revision`. `OriginReason` is exported.
- `canonicalRevision` is exported: a finite safe integer, or `undefined`.

**Consumer action.** A host that registers its own writes on the ledger (`begin`/`settle`/
`observe`) should pass `revision` — `expectedVersion + 1` on `begin`, the REST response's
`version` on `settle`, the read's `version` on `observe`. Without it, a higher revision is still
delivered (the fix holds), and (as of 0.8.1) the echo of a write still awaiting its REST
response is recognized by content; a settled write's echo is recognized by its settled number. Pair with `@ai-matrx/data` 0.14.0's `guardedUpdate({ rebase })` so a missed bump can never
reach the user as a conflict.

## 0.7.13

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.12..npm/realtime/v0.7.13 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.12

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.11..npm/realtime/v0.7.12 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.11

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.10..npm/realtime/v0.7.11 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.10

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.9..npm/realtime/v0.7.10 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.9

**The diagnostics counters are reachable on a live page.** `realtimeDiagnostics()` has always
been documented as callable "from a devtools console", but in a bundled host it was a
module-private binding inside a Turbopack/Vite chunk — unreachable. It is now published as
`globalThis.__matrxRealtimeDiagnostics` the moment a channel registers its metrics (installed
from `registerChannelMetrics`, not module scope, because the package declares
`sideEffects: false` and a bundler may drop a top-level call). `installDiagnosticsConsoleHandle`
and `DIAGNOSTICS_GLOBAL` are exported for hosts that want to install it eagerly. No behaviour
change to channels; no consumer action required.

## 0.7.8

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.7..npm/realtime/v0.7.8 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.7

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.6..npm/realtime/v0.7.7 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.6

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.7.5..npm/realtime/v0.7.6 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.7.5 — 2026-09-09

### Fixed

- Reconnect diagnostics retain the SDK callback error's bounded name and message, without copying arbitrary error properties. Broadcast-only channels receive connection/session/authorization guidance instead of unrelated Postgres publication instructions. Retry scheduling is unchanged; this does not establish the cause of historical disconnects.

## 0.7.4 — 2026-09-08

### Fixed

- **One physical disconnect now advances backoff exactly once.** Supabase can deliver
  `CHANNEL_ERROR`, `TIMED_OUT`, and `CLOSED` for the same channel generation. Once a reconnect is
  scheduled, later terminal callbacks are ignored instead of being counted as new failed attempts,
  delaying recovery, and emitting false repeated-failure errors across every channel on the socket.

### Consumer action

None required. Upgrade to stop duplicate terminal statuses from inflating reconnect incidents.

## 0.7.3 — 2026-09-08

### Fixed

- **Late presence readers now reuse the room's join-time handlers.** A shared room installs one
  Supabase handler for each presence event and fans it out to every local holder. Typing labels
  and online rosters can mount after the engine without forcing a channel rebuild or triggering
  `channel.room.binding.refused` on an already-subscribed channel.

### Consumer action

None required. Upgrade to remove the avoidable presence rejoin from shared conversation rooms.

## 0.7.2 — 2026-09-08

### Fixed

- **Presence and typing hooks now have an honest reconnect door.** These are ephemeral rosters,
  so reconnect clears the stale local members before Supabase's fresh presence sync repopulates
  them. The hooks no longer emit `channel.backfill.missing` while still avoiding a fake no-op
  handler that would hide stale state.

### Consumer action

None required. Upgrade to remove the false missing-backfill diagnostic and clear stale ephemeral
members during reconnect.

## 0.7.1 — 2026-09-08

### Fixed

- **A removed channel can no longer tear down its healthy replacement.** Supabase removes
  channels asynchronously and may deliver the old channel's terminal `CLOSED` callback after
  a reconnect has already subscribed a new channel. The manager now rejects status callbacks
  from stale connection generations, preventing the old callback from arming another retry and
  creating a self-sustaining reconnect loop.

### Consumer action

None required. Upgrade to receive the reconnect-generation guard.

## 0.7.0 — 2026-09-07

**Supabase Database Broadcast could not ride this package, and the reason it could not
is the quietest failure in the system.** A private channel is authorized against
`realtime.messages` RLS at JOIN time, which is how `realtime.broadcast_changes()` from a
Postgres trigger delivers rows only to the users entitled to them. Two things are required
and neither is optional: the channel must be created with `config.private: true`, and the
socket must already be carrying the current access token — so `client.realtime.setAuth()`
has to happen BEFORE `subscribe()`. Skip the second and the join is refused, the channel
receives nothing forever, and it reports itself perfectly healthy. matrx-frontend's
scheduler client was hand-rolling exactly this dance because the package had no way to
express it.

### Added

- **`ChannelSpec.private`.** Creates the channel with `config.private: true` and authorizes
  the socket before subscribing. Everything else the manager does is unchanged.
- **`RealtimeClientLike.realtime?.setAuth()`** — an OPTIONAL member of the structural client
  contract, because the package must never require more of a client than it uses.
- **`channel.private.unsupported` (warn)** when a private channel is declared on a client
  with no `realtime.setAuth`, and **`channel.private.auth-failed` (error)** when the token
  cannot be attached. The channel still connects, so the failure is visible rather than a
  mystery — never a silent downgrade.

### Consumer action

None required. A consumer hand-rolling a `{ config: { private: true } }` channel plus its
own `realtime.setAuth()` should replace it with `private: true` on the spec.

## 0.6.0 — 2026-09-07

**The other half of the app was never able to ride this package, and nobody had said so out loud.**
The doctrine's own rule 4 says a feature's subscription SHOULD live in a Redux middleware — a
component effect re-subscribes on identity churn and multiplies channels across mounts. But
middleware cannot call a hook, so `useRealtimeManager()` was closed to exactly the owners the
doctrine prefers. matrx-frontend's remaining hand-rolled channels were concentrated there
(notes, files, transcripts, transcript-studio, scheduler, several plain service modules), and the
two ways out were both defects: construct a SECOND manager — a second write ledger, under which
the app's own optimistic writes classify as `remote` and every one costs a refetch — or keep
hand-rolling `client.channel(...)`, which is the thing this package exists to delete.

### Added

- **`currentRealtimeManager()` / `onRealtimeManagerChange(listener)` — the ambient door.**
  `<RealtimeProvider>` now PUBLISHES its manager (into a `globalThis` slot, per the
  module-level-mutable-state ban), and non-React owners read it. This is a pointer to the one
  manager the provider already built: there is no second manager and no second ledger.
- **`subscribeToRealtimeManager(specFactory)` — the whole recipe in one call.** Opens the channel
  when a manager exists, closes it when the manager goes away, and RE-OPENS on the new one when
  the provider rebuilds (the signed-in user changed). Returns a stop function. The spec is a
  factory so a middleware captures the current user id / `dispatch` at OPEN time, not at module
  load.
- **`src/core/ambient.test.ts` pins the failure class**, including the regression that states the
  cost in code: two managers do not share a write ledger, so the same payload classifies
  `own-echo` on one and `remote` on the other.

### Why `currentRealtimeManager()` alone is not the API

Reading it once at module load is almost always wrong, and the module header says so: the
manager is `null` before auth resolves (a cold start would hold null forever) and it is REBUILT
when the signed-in user changes (a holder that ignored the change would keep a DISPOSED manager,
silently, behind a screen that looks healthy). `subscribeToRealtimeManager` is the shape that
cannot get that wrong, and it is what every consumer should reach for.

### Consumer action

None required — nothing changed for existing consumers. A host that owns realtime in Redux
middleware, a thunk, or a module-level service should now migrate it onto
`subscribeToRealtimeManager` rather than hand-rolling a channel or building a second manager.

## 0.5.0 — 2026-09-07

**The last hand-rolled `.channel(` in matrx-frontend could not move here, and this is why.** The
matrx-extend bridge (`matrx-extension-bridge:<userId>`) carries a bare `BridgeEnvelope` that the
DEPLOYED Chrome extension parses with a Zod schema. Wrapping it in the Matrx envelope would have
changed the wire under a program we do not redeploy in the same breath — the extension would have
logged "malformed payload" and gone silent, with nothing failing loudly on either side. So the
package grew the capability instead of the host keeping a twin.

### Added

- **`ChannelSpec.wire: {mode:"raw", …}` — a raw-wire broadcast mode.** Send puts the consumer's
  payload on the wire VERBATIM (byte-identical to a hand-rolled `channel.send`); receive never
  reads an envelope, not even from a payload that happens to look enveloped
  (`BroadcastMessage.enveloped` is always `false`). Everything else the manager does — the shared
  room registry, the ordered handler queue, jittered reconnect, the backfill door, tab-sleep and
  network awareness, diagnostics — is unchanged.
- **The downgrade is explicit and announced.** Echo suppression rode `cid`, which a raw wire does
  not have: supply `wire.isOwnMessage` (the peer's shape identifies its sender) or
  `wire.acceptEchoFromSelf: true`. Neither → `channel.raw.echo-unguarded` (warn); the explicit
  opt-out still emits `channel.raw.echo-accepted` (info). Dedup rode `eid`: supply `eventKey`
  (it receives the raw payload) or take `channel.raw.dedup-unguarded` (warn). A Matrx-wire channel
  emits none of these.
- **`ChannelNamespaceSpec.foreignTopic` — the topic half of the same idea.** A peer-owned topic
  root replaces `mx:<namespace>` while the namespace stays declared, collision-checked and listed
  by the diagnostics surface. Re-declaring one with a different root throws, as any other
  namespace change does.
- **`useChannel` carries `wire` through**, keyed into the subscription shape, with
  `isOwnMessage` dereferenced late through the spec ref like every other handler — a forwarded
  closure would have pinned the first render's.

### Consumer action (C28)

**None for existing consumers** — `wire` defaults to `{mode:"matrx"}` and no behavior changes
without it. If your app still hand-rolls a `.channel(` because a foreign peer owns the payload
shape, that reason is now gone: declare the namespace with `foreignTopic`, open the channel with
`wire: {mode:"raw"}`, and supply `eventKey`. Read README § 9 before you do — raw mode is
transitional, and the peer's channel contract should record the coordinated release that ends it.

## 0.4.0 — 2026-09-07

🚨 **A presence holder joining a room that had already joined took the surface down.** Found on
the live `/messages/[conversationId]` route during the `@ai-matrx/messaging` adoption: the route
rendered its error boundary with `cannot add 'presence' callbacks for
realtime:mx:messaging-conversation:… after 'subscribed'`.

### Fixed

- **A presence binding is JOIN-TIME, exactly like a Postgres Changes binding.** supabase-js
  refuses `.on("presence", …)` on a channel the server has already acknowledged — and it throws
  synchronously, out of `.on()`, straight through the holder into whatever React tree called it.
  The shared-room registry (0.2.0) made that ordinary: one holder opens a conversation with
  Postgres Changes + broadcast and joins, then the same surface's typing indicator and online
  roster open the SAME declared topic a moment later. A late presence binding now marks the room
  for a rebuild instead, every holder rejoins, and each one's backfill door covers the gap.
- **A refused binding can never take a surface down.** `attach` catches, marks the room for a
  rebuild, and states the reason with a remedy (`channel.room.binding.refused`) rather than
  letting a client version's refusal escape into a render.
- **`FakeChannel` now refuses a late presence binding, exactly as the real client does.** This is
  the reason the class was invisible: every test here passed a fake that accepted anything, so a
  green suite proved nothing about the one call the real client rejects. The new regression test
  reproduces the live sequence and fails without the rule.

### Consumer action (C28)

None. A surface that opens presence after its room is live now costs one rejoin (with a
`channel.room.rebuild` diagnostic) instead of throwing. Opening all of one topic's holders
together at mount still avoids the rejoin entirely.

## 0.3.0 — 2026-09-07

**A sibling package can now tell whether a host already mounted a provider.** Found while
adopting `@ai-matrx/messaging` in matrx-frontend, which mounts `<RealtimeHost>` — the app's ONE
`<RealtimeProvider>` — and whose `<MessagingProvider>` unconditionally mounted a SECOND one
under it. Two managers is two write ledgers, so one manager's own optimistic echo looks remote
to the other.

### Added

- `useIsRealtimeProviderMounted()` (from `@ai-matrx/realtime/react`). True whenever a provider
  is mounted above — including while its `client` is still null. `useRealtimeManager() !== null`
  cannot answer this: it is also null while a mounted provider waits for its client, so a
  sibling that guessed from it would mount a provider and then tear it, and every channel under
  it, back down the instant the host's client arrived.

### Fixed

- **The React contexts moved into the `globalSlot`,** where every other piece of this package's
  process state already lives. They were created at module scope, and this package ships dual
  ESM + CJS: a host bundling ESM while a sibling package resolves CJS evaluates the module twice,
  and two `createContext` calls are two different contexts — the sibling reads `null` with the
  host's provider directly above it. Same defect class the module-level-state ban exists for.

### Consumer action (C28)

None. No existing API changed; `useIsRealtimeProviderMounted` is additive and only packages that
mount realtime of their own need it.

## 0.2.1

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.2.0..npm/realtime/v0.2.1 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.2.0 — 2026-08-31

**Broadcast and presence actually work now.** 0.1.x gave every channel a unique instance topic.
For Postgres Changes that is correct (the server pushes on the declared filter) — for broadcast
and presence **the topic IS the room**, so two browsers that both declared `mx:room:1` landed in
`mx:room:1#1` and `mx:room:1#2` and never saw each other, and neither did two `open()` calls
inside one client. Found by `@ai-matrx/messaging`, the first real consumer of that surface.

### Fixed

- **The topic-semantics split.** A channel carrying `broadcast` or `presence` now puts its
  **declared topic on the wire verbatim**. Pure Postgres-Changes channels are unchanged and keep
  the unique instance topic that guards the React-19 double-invoked-effect collision.
- **Shared, ref-counted channels** (`src/core/rooms.ts`). One underlying channel per declared
  topic: a second `open()` attaches its bindings to that channel, and the channel is removed only
  when the last holder closes. Teardown is deferred one turn so a React-19 remount re-attaches
  instead of racing an async `removeChannel` — that is what replaces the unique topic for these
  channels. `dispose()` still tears everything down immediately.
- **Presence is no longer untracked out from under a sibling holder.** One presence entry per
  channel, so only the last tracking holder untracks.
- A holder that closes stops receiving events at once, even though its bindings outlive it on the
  shared channel.

### Added

- **`presence.publish` — listen-only presence.** One channel carries ONE presence entry per key,
  so a topic needs exactly one publisher. `PresenceOptions.publish: false` (and `publish: false`
  on `useTyping`) gives a read-only holder the full roster without it announcing itself and
  overwriting the real publisher's state on every heartbeat. Default stays `true`.
- `channel.room.presence.multiple-publishers` (warn) — two holders of one topic are both
  publishing presence under the same key, so each heartbeat overwrites the other. Names
  `publish: false` as the remedy.
- `channel.room.config-mismatch` (warn) — two holders of one topic asked for different channel
  config; the first one's is in force, and it says so with a remedy instead of surprising you.
- `channel.room.rebuild` (info) — a holder brought Postgres Changes bindings to an already-joined
  shared channel. Those ride the JOIN payload and cannot be added after `subscribe()`, so the
  channel is rebuilt and every holder reconciles through its backfill door.

### Consumer action (C28)

- **`@ai-matrx/messaging`** — typing, presence and message broadcast begin working on upgrade.
  One change IS required and shipped in messaging 0.1.2: `useTypists` and `useOnlineUserIds` read
  the roster on the same topic the composer publishes on, so they now pass `publish: false`.
  Anything with a read-only presence surface needs the same edit.
- **matrx-frontend** — no code change required. Both live consumers (tasks, file-analysis
  annotations) are Postgres Changes only and are unaffected by design; upgrade and re-run.
- **Anyone opening two holders on one broadcast/presence topic** should read README rule 7's new
  consequences list: they now share one channel config and one presence entry.

## 0.1.1

Automatic changed-only republish (docs/metadata drift since the last tag — see
`git diff npm/realtime/v0.1.0..npm/realtime/v0.1.1 -- apps/shared/realtime`).
No source changes intended and no consumer action required.

## 0.1.0 — 2026-08-30

First release. The ONE Supabase Realtime layer for Matrx clients, built to the ratified work
order at `common-docs/systems/architecture/realtime/HANDOFF.md`.

### Added

- **Core (`@ai-matrx/realtime`)** — framework-free, zero runtime dependencies.
  - `createRealtimeManager` — owns channels: unique instance topics, enforced teardown, echo
    suppression, dedup, the decoupled handler queue, jittered reconnect with a stability
    reset, the backfill door on every recovery path, tab-sleep/network awareness, and live
    diagnostics.
  - `defineChannelNamespace` / `uniqueChannelTopic` — the channel-naming registry. A second,
    different declaration of a namespace throws; every connection attempt gets a unique
    instance topic (the React-19 double-effect collision guard).
  - `createWriteLedger` — timestamp-monotonic echo suppression and the named solution to
    autosave↔realtime coexistence: `own-echo | remote | stale` plus `localWritePending`.
  - `createEventQueue` — bounded, ordered, yielding handler queue; overflow sheds oldest,
    announces itself, and triggers a reconcile.
  - `createPresenceRuntime` — heartbeat + local-clock expiry, so a crashed client cannot leave
    a ghost participant.
  - `createTypingTracker` — typing as a renewed lease, so "X is typing…" cannot stick.
  - `createBackoff`, `createDedupe`, environment bridges, `realtimeDiagnostics()`.
- **React (`@ai-matrx/realtime/react`)** — `RealtimeProvider`, `useChannel`, `usePresence`,
  `useTyping`, `useRealtimeDiagnostics`, `useRealtimeHealth`.
- **React Native (`@ai-matrx/realtime/native`)** — `NativeRealtimeProvider` with the `AppState`
  seam wired, `createReactNativeEnvironment`.
- Regression tests for each named failure class: echo storm, duplicate delivery, reconnect
  gap, ghost presence, stuck typing, frozen-tab handler pileup. Plus a compile-time proof that
  a real `SupabaseClient` satisfies the package's structural interfaces, and a packed-tarball
  canary that exercises ESM + CJS, both platform entries, the `"use client"` banner per chunk,
  and the `globalThis` state slots across module graphs.

### Consumer action

None — this is a first release with no existing consumers.

Adopting it in a repo that hand-rolls `.channel(`:

- Wrap the tree in `<RealtimeProvider client={supabase} actorId={userId}>`.
- Declare a channel namespace per feature and delete the hand-written topic string.
- Replace the `.channel(...)` block with `useChannel` (or `manager.open` in Redux middleware).
- Move the catch-up read into `onBackfill`. A channel without one is warned about at runtime.
- Delete the module's own echo-suppression, dedup, and backoff code — a second copy beside the
  package is the named failure.
- `uniqueChannelTopic` also still ships from `@ai-matrx/data/db` for its existing 40 call
  sites; this package is its home and those sites move here per feature as they migrate.
  `@ai-matrx/data` is NOT changed by this release.


