Files
windmill/docs/ai-session-backups.md
T
796b6e5297 feat: back AI sessions up to the workspace object storage (#11116)
* feat: back AI sessions up to the workspace object storage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp

* fix: bind the backup key to the user and pack pushes within the server caps

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp

* fix: keep refused and unavailable marks, one mark per key, stream the flush

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp

* fix: settle only fully sent sessions, keep removals while backups are off, cap pull bodies

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG5qEPM6Fmf7VerXS5nnWp

* fix: bound removal marks while backups are off and stale the sync rows instead of dropping them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: retry a lost lock, cap nested push lists and oversized pieces, drop a stale copy of a chat that outgrew the backup

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: cap pieces per push, size requests in UTF-8, keep a move's removal for an off workspace, disclose the restore counter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: file a move's removal only once the new copy landed, retire marks through the sync row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a session marked while deletes are carried over, drop only gone sessions' marks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: record what a refused flush already stored, stop early when every mark is retired

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: restore past another workspace's removal mark, file a move's removal before its row, bound the first pulled session

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: re-key the backups on workspace key rotation, accept only base64 images, carry a delete on the sync row when its mark cannot be written

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: durable conditional re-key of session backups, re-push on a storage switch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: fail a push the key rotated under, settle no session split across storages, narrow the re-key module

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a delete filed during a push, bound the pull and re-key listings

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: bound the session listing, mark the store's own user on a write that lands after a user switch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: list sessions through per-session index markers, hold a session's parts back after a failed one

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: record a rotation on every build, list a session only on the part that completes its push

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: leave an object larger than any push writes unread

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: read each object against its listed size, carry a dirty mark that cannot be written on the sync row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: note the storages the re-key walk completed on, reach another user's rows on a failed mark, read a head at its cap

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: read the replaced key under its row lock, carry a refused dirty bump on the sync row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: pull a session that outgrew one answer in pages, imported only whole

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: build a pull page from the smallest keys of the whole listing, stage each page as it lands

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: re-record a key rotated back to, admit earlier-page images, restage over a cut-short restore

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: delete the backups on key rotation instead of re-keying them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: end a pull page before an object that grew since the listing, prune what a cut-short restore staged

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: delete the backups before the key commits, skip a planted object whatever its listing says, lock a restore across tabs, prune stale artifact versions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep the backups under a prefix named by the key, delete the previous key's prefix after the commit

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: name the backup prefix by a generation the rotation bumps, never write an older record over a newer one on restore

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: retire a removal only against the storage holding the backup, restart a paged pull whose listing moved

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: fingerprint a pull page before reading it, answer the backup generation apart from the storage identity

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: answer needs_head for a headless session push, prune restaged pieces by id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: serialize a session's push and removal, open whole pushes with the head, prune only own restores

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: require a head on a whole push, prune before the record lands, restore only under Web Locks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: incremental pushes ride on a listed session, removals wait for every storage holding a copy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: a whole push replaces the backup under a per-push token, a pull page is checked after its reads

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: fingerprint a pull page by entity tag and version too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: a moved session's removal mark names the storages holding the old copy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: restore a workspace family together, the newest copy of a moved session winning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: name the marker by the session's move count, abort a family restore a listing failed in

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: list the family again before a restored record lands, require the pull fingerprint

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: list the whole family once per restored workspace, off members included

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: a push split over parts, incremental too, unlists the session until its last part

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: refuse a partial push part that names no push

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a refused bump for a session with no row yet, probe an off workspace again

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: backfill row-carried bumps after a reload, ask an off workspace again on a timer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: backfill a row for its bumps only when it carries some

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: a backfilled mark that cannot be written counts from the page's counter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: say an off workspace is asked again, in the mirror's comments

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: update ee-repo-ref to 1c1dab33563c4907aff8b0da825fb66db60af82a

This commit updates the EE repository reference after PR #796 was merged in windmill-ee-private.

Previous ee-repo-ref: 289b477ca3fc993da06ec09b11c8f55d5e4e39c1

New ee-repo-ref: 1c1dab33563c4907aff8b0da825fb66db60af82a

Automated by sync-ee-ref workflow.

* fix: unlist a session while an incremental push changes more than one object

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-15 15:48:33 +00:00

267 lines
20 KiB
Markdown

# AI session backups
AI sessions live in the browser: the session list (`windmill-sessions`), chat transcripts and
image blobs (`copilot-chat-history`) and artifacts (`copilot-artifacts`), all per-user IndexedDB
stores. This is the design of their backup in the workspace's object storage, and the
constraints future work on either side must keep.
Backend: `backend/windmill-api/src/ai_sessions.rs` (`/w/{w}/ai/sessions/{list,pull,push}`).
Frontend: `frontend/src/lib/components/sessions/sessionMirror*.ts`.
## Why it is lazy
A session changes at the local write rate: a transcript write every 2 s while streaming, a
session-record write per new message on screen. An object in S3 is replaced whole and every PUT
is billed, so the backup deliberately does not follow that rate. Local writes only mark a session
dirty (`sessionMirrorSignal.ts`, import-free so the stores never depend on the backup). A flush
runs 15 s after the marks go quiet, at most 2 min after the first unflushed mark, when the tab is
hidden, and 10 s after load for marks a crash left behind. Marks are persisted in localStorage
(shared by the user's tabs) for that reason, one key per mark: a shared blob would let two tabs
marking different sessions at once rewrite each other's mark away. A dirty mark is a counter
bumped on every write; a push retires it by recording the counter it covered on the session's
sync row rather than deleting the mark, since two localStorage calls cannot compare-and-delete
and a bump landing between them would be lost; retired marks are not reclaimed (one small key per
session ever backed up), and the marks of unsent drafts and of workspaces that are off stay too,
each costing one lookup per flush. Only a session gone from the store has its mark deleted. Losing the last
seconds of a device that never comes back is accepted; a tab that closes normally keeps its marks.
A signal names the user whose store the write landed in (read off the store's scoped name), so
a write that completes after the logged-in user changed marks that user's session, for their
next load, rather than the current user's.
A flush plans and sends one session at a time, filling requests of about 8 MB as it goes, so a
first backfill of a large history never holds more than one request's worth of records and
images in memory.
## What a push carries
The pure planner (`sessionMirrorPlan.ts`) compares each piece against the marker of what was
last pushed, kept per session in the `windmill-sessions-mirror` store:
| Piece | Object | Sent when |
|---|---|---|
| session record | `sessions/{sid}/head.json` | its signature changed |
| chat | `sessions/{sid}/chats/{cid}.json` | its `lastModified` moved |
| artifacts | `sessions/{sid}/artifacts.json` | their fingerprint changed |
| image | `images/{sid}/{cid}/{iid}` | never pushed before (write-once) |
| index marker | `index/{sid}/{epoch}` | last, by the part that completes a push of the session (empty; named by the record's move count) |
All under `windmill_ai_sessions/{w_id}/g{generation}/{sha256(email)}/` in the workspace's
primary storage (the generation is what a key rotation moves, see below).
The listing reads only `index/`: one object per session whatever the session holds, so a
session with many chats cannot crowd newer ones out of a bounded scan, and its
`last_modified` is the session's `updated_at`. Written last, and only by an entry no unsent
part follows (a session split over several entries says `partial` on all but the last, and
names the push on each, or the part is refused), it
lists a session only once a whole push landed; the parts of a session after a failed one are
not written either, on the server within one push and on the client across pushes, so the
marker on the last part never lists a session missing a chat, and a new session whose last part
never lands is not listed at all. A push of the session whole (no sync row, or a stale one,
`whole` on every part) opens with the head on the first: that part replaces the backup (the
marker goes first, then everything under the session), so what an old storage still held of
the session and the push does not carry is gone. An incremental part rides on a listed
session, and the server refuses it with `needs_whole`, writing nothing, when none is listed
(a removal deletes the marker first), rather than write a marker over a session missing what
earlier parts or earlier pushes carried; one that changes more than one object unlists the
session before its writes and lists it again after them, so a pull between two of the writes,
or after one failed, finds it absent rather than a mix of old and new pieces (one object
changing is one write, and stays listed). A push split over several parts, whole or
incremental, names itself on each with a token the browser draws (`push`, `opens` on the
first): the opening part unlists the session, so a pull between two parts finds it absent
rather than a mix of old and new pieces, the last part lists it again, and a later part is
written only while that token is the one there, so two devices pushing the session at once
cannot list a mix of their pieces (the push that opened later wins; the other is refused with
`needs_whole` and goes again), and one abandoned leaves the session unlisted, so the next
push of it goes whole. A push and a removal of one session
are serialized on the server by a Postgres advisory lock keyed on the session's prefix, so
the two never interleave object by object.
The head signature leaves out `name` (a per-browser counter the sessions page routes by),
the unsent-draft fields, `workspace_root_id` (recomputed on import), and the two fields reading
a session bumps (`lastSeenCount`, `lastActivityAt`). Reading a session must never cost a push;
keep that property when adding fields to `Session`.
Unsent drafts (no `workspace_id`) and attached files (Blobs, directory handles) are not backed up.
## Encryption and access
Every object is encrypted with a key derived from the workspace key and the user
(`build_crypt_with_key_suffix` with the email hash), because workspace storage credentials are
shared far more widely than a user's transcripts: `public_resource` storages and legacy-mode
READ/WRITE hand any member the bucket. The key is per user rather than per workspace so that a
member who copies another user's ciphertext under their own prefix gets nothing from `pull`; an
object that does not decrypt for its reader is treated as absent. Rotating the workspace key
(`set_encryption_key`) does not re-key the backups the way it re-encrypts the workspace's
secrets. The objects live under a prefix named by a generation
(`workspace_settings.ai_sessions_backup_generation`) that the rotation bumps in the
transaction committing the new key; once committed, the routes read and write under the new
generation's prefix, the answers name it (`backup_generation`, below; `storage_id` names the
storage and does not change), so every browser marks its sync rows stale and pushes its
sessions whole again there, and every older generation, which nothing writes to any more, is deleted off the
request at leisure (`windmill-api-workspaces/src/ai_session_backups.rs`). Sessions no browser
holds any more are lost. A generation is never reused, so no deletion, however late, can
touch live objects; a rotation that fails before its commit bumps nothing and deletes
nothing; two rotations racing serialize on the key row; the same key set again bumps
nothing. A rotation is rare, and the alternative,
rewriting every object in place while pushes, restarts, storage switches and further
rotations race the rewrite, is where the complexity would be; with this, nothing but the
current key ever reads an object. The
server builds every key from ids it validated
(`[A-Za-z0-9_-]{1,64}`) and the caller's own email; the client never names a key, and the
workspace storage permission rules are not consulted (the same stance as volumes). Only an
unscoped user token may reach the routes: a job token can carry an `on_behalf_of` identity and
every scoped token (guest, embed, app policy, MCP) was minted for something narrower.
The backup is keyed by the email like the browser's own stores are (`userScopedDb` scopes
IndexedDB by it): a user whose email changes starts from an empty history on both sides, and
the objects under the old hash stay in the bucket unread. Carrying them over would need a
server-side re-key (decrypt with the old suffix, encrypt with the new, move every object) in the
email-change flow, which this design leaves out.
An image is accepted only as a base64 data URL of at most 4 MB and stored verbatim, so it
serializes back into a pull answer at its stored size; anything JSON would escape could grow
several times and defeat the pull budget.
`push` carries `owner`, the email the browser prepared the batch for, and the server refuses a
mismatch with 409: an in-place account switch must not file one user's sessions under another's
prefix. The client captures its user at flush start and checks every store handle's name
against it for the same reason.
The feature is on wherever the workspace has primary storage, and off with
`ai_config.sessions_storage_disabled` (the `copilot_disabled` pattern: no migration, carried by
settings export and the CLI). A build without `parquet` has no routes (404), a workspace without
storage answers `enabled: false`; either turns the backup off for ten minutes, after which the
page asks again on its own (a flush for whatever is pending, and a restore), and the AI
settings page tells the mirror at once when the switch is saved there (the off state is
forgotten, the rows that went stale are marked again, a restore runs).
## Conflicts and deletion
Last write wins across devices. The head carries no manifest; `pull` lists the session's prefix
instead, so a stale device that renames or archives a session rewrites only the head and cannot
hide chats a newer device wrote. Two devices continuing the same chat still collide.
Restore brings back only sessions the browser does not have (`importSessions` is write-if-absent,
and skips ids the user deleted in this page) and never overwrites or deletes a local one from
remote state. It covers the workspace and its forks together, and only once every one of them
that keeps backups has listed (a listing that failed leaves the family for the next page load
or workspace switch, or the copy that did list could be the stale one): a session listed by
two of them (moved between them, the old copy not yet removed, since that mark is the moving
browser's, which may never come back) is brought back from the copy that moved last (`epoch`,
the record's move count, which names the marker), the storage's own modification time
deciding between two of the same count, and not from the other, which would otherwise take
the id first and keep the later copy out for good. A workspace's records land together once
its pulls are done, and just before they do the whole family is listed again (members whose
backups were off included, since a move from another device can land in a workspace between
the first listings and the pulls; a family of one, with nowhere else for a copy to show up, is
not): a session a later copy of which showed up elsewhere is left, with the family, for the
next time. Only a user-initiated `deleteSession` removes the backup; the next push from
another device that still has the session is refused with `needs_whole` (nothing of it is
written), its row goes stale without a backoff, and that device's next flush sends the session
whole; the workspace-lifecycle
removals (`reconcileSessionsLifecycle`, `deleteSessionsForWorkspace`) leave it, so a session
dropped by a wrong reconcile comes back on the next restore. Objects of deleted workspaces stay
in the bucket. A session moved to another workspace is pushed whole into the new one, and once
that push has landed the copy in the old one gets a removal mark of its own, naming the storages holding
that copy (the row that knew is the new workspace's by then), retried independently until
each of them has answered, even when the old workspace's backups are off at the time (they
may hold the copy still). Filing the removal only after the new copy is acknowledged keeps the
session backed up somewhere at every point.
A restore writes a session's artifacts and chats before its record, and records nothing for a
session whose pieces could not be written: recording it would let the next flush push the
half-empty local state over the backup.
Every answer names the storage it came from (`storage_id`, a hash of what locates the objects,
endpoint, region and bucket, not the credentials, which rotate) and the backup generation a
key rotation bumps (`backup_generation`). A sync row records both, and a row naming another
storage or generation goes stale and its session is marked again: a workspace pointed at a
new bucket, or whose key was rotated, holds nothing, and the server looks nowhere else, so
the next flush carries the session whole. A switch leaves the old copy where it was, so the
row rewritten under the new storage records the old one (`alsoIn`, one entry per storage
the workspace was on), and a removal is done only once every storage holding a copy answered
it, whatever the generation (a rotation deleted the older generation's copy anyway): each
answer narrows the row to the storages still holding one, and the mark waits for them to
answer, so a switch back never brings a deleted session back. That includes the rows a flush has just written, when a later answer of the
same flush names another storage or the session was pushed in part on top of a row from the
old one; a session whose own parts were answered from different storages is not settled at
all. The listing a restore starts with runs the same check, so a storage switch is noticed at
the first push after it or on the next page load, whichever comes first.
## Limits
Push bodies are packed to about 8 MB (UTF-8 bytes as sent), at most 100 entries, 200 removals and
4000 pieces each (the server's caps, with 32 MB on the body, and 100 chats, 500 images or 1000
deletes per entry, since every piece is an object-store call); an entry that
outgrows the target is split into chat-only parts (the artifacts and deletes on the last, the
head on the last too for an incremental push and on the first part, whatever it carries, for a
push of the session whole), and deletes
past the per-entry cap are carried over to the next push, which the session stays marked for. A chat above
16 MB or a session's artifacts above 8 MB are left out with a console warning; a chat that grew
past the cap after it was backed up has its copy deleted, so a restore never presents the old
transcript as the current one. A 413 fails only the sessions of that request. A
request the server refuses (any other 4xx but 404/403/409) stops the backup for the page but keeps
the marks and the sync state, so the next load tries again; a session the server reports it could
not store stays marked and is retried with backoff. A move files the old workspace's removal
mark before recording the new copy's row, so a mark that could not be written leaves the move
to be planned again. A workspace that answers `enabled: false`
marks its sync rows stale (the next push after storage returns carries every session whole,
since a new storage may be a new bucket) and leaves its dirty marks where they are (a move into
it must still remember the old copy); it keeps the removal marks of sessions that had been backed
up, so one deleted while backups are off does not come back once they are on, and drops the
removals of sessions never backed up from this browser, so a storage-less instance does not
collect one mark per deleted session forever. A user delete whose removal mark cannot be written
(localStorage full) is carried by the session's sync row instead (`removed`), which the flush
and the restore read like a mark; a session without a row yet (its first push may be in
flight) gets a row saying only that, and every row write keeps a removal filed meanwhile, so
the push's own row cannot erase it. Pull bodies are
capped at 64 KB. Pull answers up to 20 ids within a 32 MB
budget: a session's size is known from the listings before anything of it is read, one that
would not fit is deferred unless it is the first of the answer, in which case it comes in
pages: the answer carries what fits in key order (at least one object, so every page makes
progress) and names where the next picks up (`next`, a cursor the browser sends back as
`resume` with that session alone). Every page carries a fingerprint of the session's listing
(marker, keys, sizes, modification times, entity tags and versions, since a store reports
modification times coarsely and an object rewritten at the same size within that grain would
otherwise fingerprint the same) taken before anything of it is read, and the server
takes it again once the page is read: a page the backup moved under (a push landing object by
object) is read again, a few times, then answered as `moved`, and the browser starts the
session over on that or on two pages whose fingerprints differ. The browser writes each page's pieces as it arrives, over whatever
an earlier restore cut short had staged (the session is absent locally, so its pieces have no
local edits to keep, and the backup may have moved on), and the record, which is what makes
the session visible, only with the last page. A restore in progress keeps a staging row for
the session (the ids of every chat, image, artifact and version it wrote), which outlives it
if it is cut short; the next restore deletes the staged pieces the backup no longer has, by id
and never by clock, before the record lands (once the record is there no restore looks at the
session again, and a flush would push them back), and a prune that could not run leaves the
session, its pieces and its staging row for the restore after. A restore holds the user's tab
lock while it runs, so two tabs cannot each write the same absent session's pieces over the
other's, and runs only where Web Locks exist (a secure context: https, or localhost); on a plain
http origin the browser still backs up, and its sessions come back on a secure one. A
restore never writes an older record over a newer
one; between pages it holds nothing but the sync
row being assembled, whose chats also admit the images of a later page. Every page carries a
fingerprint of the session's listing (`listing`), taken before anything of the page is
listed or read, so an object landing after it is in the next page's; a session whose
fingerprint moved between two of its pages (a chat added by another device could sort before
the cursor and be missed) starts over, up to three times, then waits for the next restore. An object that grew
since the listing (a push replaced it) ends its page just before it and the answer names that
spot, so the next page sizes it anew rather than the session being imported without it. A pull sees every key of a session's listing but keeps the 5000 smallest
past its cursor (a page is defined by key order, and the store promises none), so a session
grown without bound by valid pushes cannot grow the answer's memory through its metadata
either; removing a prefix and a rotation's deletion stream their listings. `list` scans at most 50 000 index markers, keeps the newest 500 as it goes and answers with
them (`truncated` says when there were more); the restore takes 50 of them. Every read checks the object's size before buffering it: one larger than any push writes
(32 MB) is planted, whatever its listing said, and skipped, since whoever holds the bucket's
credentials can put anything at a predictable key; one larger than its listing said grew
since (a push replaced it) and ends its page, for the next pull to size anew. A dirty mark that cannot be written
(localStorage full) records its bump on the session's sync row instead (`extraV`, counted
with the mark's counter and kept by every row write, so a push in flight cannot retire it);
a session without a row yet (its first push in flight) keeps the bump in the page, and the
next row write takes it onto the row in the same transaction, so the row the push writes
cannot retire the mark with the bump unseen; every load's backfill marks again a session
without a row, with a stale one, or with one carrying bumps no push has covered.
A mark or removal for another user (a write that landed after a switch) reaches that user's
rows through a connection of its own, since the shared handle follows the current user. Nothing is read past the budget, whatever a session holds. A
restore takes the newest 50 sessions per workspace: every visible session gets a runtime, and
each runtime's history load reads the whole chat store. On CE the push checks the storage quota
and bumps usage by bytes written (an over-count on overwrites; the periodic recount settles it).