diff --git a/.env.example b/.env.example
index baa5572..b1f9ccc 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,20 @@ ICHAT_DB_URL=jdbc:postgresql://localhost:5432/intellistream_chat
ICHAT_DB_USERNAME=ichat_role
ICHAT_DB_PASSWORD=CHANGE-ME
+# Optional read replica. Off by default, and off means there is no second pool at all. Enabled,
+# every @Transactional(readOnly = true) is served from the replica, while writes, Flyway and raw
+# JDBC access stay on the primary above.
+#
+# A replica lags, so a read-only transaction may see a slightly older world than the write that
+# just returned. That is fine for history and search, and it is why the Lucene reconcile sweeps
+# stay on the primary. Blank username/password inherit the primary's, which is what a streaming
+# replica normally wants. Size the read pool with SPRING_-style Hikari keys under
+# ichat.datasource.replica.hikari.*; the shipped config sets none, so it is Boot's default of 10.
+# ICHAT_DB_REPLICA_ENABLED=false
+# ICHAT_DB_REPLICA_URL=jdbc:postgresql://replica.internal:5432/intellistream_chat
+# ICHAT_DB_REPLICA_USERNAME=
+# ICHAT_DB_REPLICA_PASSWORD=
+
# --- Keycloak / OIDC ---
KEYCLOAK_ISSUER_URI=https://auth.your-domain/realms/ichat-realm
KEYCLOAK_CLIENT_ID=ichat-client
@@ -239,10 +253,12 @@ ICHAT_ASSETS_UNBUNDLED=false
# Optional: Vault / OpenBao
# =============================================================================================
-# Credentials only. The processor reads secret/data/${path} and maps exactly five keys —
-# db.username, db.password, keycloak.client-id, keycloak.client-secret, keycloak.issuer-uri —
-# into their Spring property names before anything consumes them. Any other key in the record is
-# ignored on purpose, so tuning stays here in the env file.
+# Connection details and credentials. The processor reads secret/data/${path} and maps exactly ten
+# keys — db.url, db.username, db.password, db.replica-enabled, db.replica-url,
+# db.replica-username, db.replica-password, keycloak.client-id, keycloak.client-secret,
+# keycloak.issuer-uri — into their Spring property names before anything consumes them. Any other
+# key in the record is ignored on purpose, so tuning stays here in the env file. Every one of them
+# is optional: a record holding only db.password overrides only that.
# ICHAT_VAULT_ENABLED=false
# ICHAT_VAULT_URI=
# ICHAT_VAULT_TOKEN=
diff --git a/AGENTS.md b/AGENTS.md
index e7c80cc..dd033dc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,6 +58,7 @@ src/main/java/ai/intellistream/chat/
│ CallService, TurnCredentialService, CallScheduler, CallSessionListener
├── config/ SecurityConfig (two filter chains), WebSocketConfig,
│ StompAuthorizationConfig, MultipartConfig, VaultEnvironmentPostProcessor,
+│ ReadReplicaDataSourceConfig / ReadReplicaProperties (optional replica),
│ RegistrationAuthorizationRequestResolver
├── domain/ JPA entities — User, Channel, Message, Conversation,
│ Attachment / ConversationAttachment, MessageReaction / ConversationReaction,
@@ -108,6 +109,8 @@ Several autoconfigurations that lived inside `spring-boot-autoconfigure` in 3.x
- A client subscribes once per **joined channel**, not once per rendered sidebar row, so notification coverage cannot narrow when the sidebar's rendering changes. The per-session SUBSCRIBE budget is 2000/min because over-budget frames are *dropped, not refused* — at the old 200 the tail of a large account's own subscriptions vanished with no error anywhere.
- **Authorisation happens once, at SUBSCRIBE.** The broker never re-checks, so revoking access needs the subscription taken away too: `ChannelSubscriptionRevoker` (channels) and its conversation sibling do that on leave, kick, and channel delete. Evicting the access cache alone only stops the *next* subscribe.
- **`CurrentUser`** is the single bridge between Spring Security principals and the domain `User`. It provisions/upserts a `User` row from the OIDC subject the first time it sees a principal. Always go through it; don't read JWT/OidcUser claims in controllers.
+ **It resolves in two steps, and the order matters.** `UserService.findUnchanged` is a read-only single-`select` lookup that answers only when the row already agrees with the token on *every* field the write path sets — handle, email, display name, admin. Anything else returns empty and falls through to `UserService.upsert`, which is unchanged and still the only thing that writes. This runs on every authenticated request, so the difference is the whole per-request database cost of being logged in: one replica-eligible read instead of two primary reads inside a writable transaction. Both halves read their claims through `UserService.claimsOf`, one shared `ClaimView` — a second copy of the claim-reading logic would let the two disagree about whether anything changed, which is a write on every request and no error anywhere. The fall-through is stable, not repeating: `upsert` settles the row into exactly the shape `findUnchanged` tests for. `UserProvisioningFastPathTest` pins the per-field cases, including that a revoked `admin` claim must not be served from the existing row.
+ One consequence worth knowing: with a read replica configured, the suspension backstop in `CurrentUser.resolve` reads a row that may be up to the replication lag old. A ban issued through `BanService` is unaffected — it updates `SuspensionRegistry` before writing the row, and the filter refuses the request before it reaches the backstop. What lags is what the registry cannot see: a `suspended_at` edited directly in psql, or a ban issued by another node.
- **Channel types.** `PUBLIC` channels are joinable by anyone via `ChannelService.join` **unless archived**. `PRIVATE` channels require `ChannelService.invite` by an admin. The creator becomes the first `ADMIN` member automatically, and `join` promotes the first person into an *empty* channel — otherwise a channel everyone left could never have an admin again. Members can `leave`; the last admin leaving hands the role to the longest-standing remaining member rather than being refused.
- **Slug rule.** `Channel.slug` is generated from the name in `ChannelService.create` **and `ChannelService.rename`**: lowercased, non-alphanumerics collapsed to `-`, trimmed to 80 chars. A name with no alphanumerics is rejected. Renaming moves the slug, which is safe because nothing user-facing resolves a channel by slug — pages are `/channels/{id}` and every API route is id-keyed.
- **UI icons come from the SVG sprite, never from emoji.** `templates/fragments/icon-sprite.html` holds every symbol; use it as `` (`.icon` = 20px buttons, `.icon-sm` = 14px inline markers), and add a new 24×24 symbol rather than reaching for a glyph. Emoji as icons look wrong for three reasons: they render in the font's own colours so they ignore the theme and can't be dimmed or turned red for a destructive action, they're drawn differently on every platform, and they vanish entirely on hosts with no emoji font. Real emoji stay emoji — reactions, the picker, and custom status are content. Letterforms (`B`, `I`, `S`, `{ }`) in the composer toolbar are labels, not icons; leave them. Note that a message's action row has **one builder and several readers**: `attachActions` in `js/chat/index.js` builds it for channel messages (including server-rendered ones — `channels.html` renders no action row at all) and `js/conversation.js` does the same for DMs. The desktop `⋯` overflow menu and the mobile long-press sheet in `chat-kit.js` both *derive* from the buttons that builder produced, reading their `data-action` and `title`. Add an action in the builder and the other two inherit it; add a second builder and they won't.
@@ -127,6 +130,9 @@ Several autoconfigurations that lived inside `spring-boot-autoconfigure` in 3.x
- **Sidebar.** `SidebarService.joinedFor(user)` returns a `SidebarView` of **every channel the user is a member of** — not a ranked shortlist — alphabetical (case-insensitive, ties by id), excluding archived ones, rendered as Favourites then Channels. Entries carry `joined` and `favourite`; there is no `admin` flag, because being a channel admin is not something you need in a list you scan fifty times a day (the members panel and the cog show it). The markup is `templates/fragments/sidebar.html`, one copy shared by `channels.html` *and* `conversation.html`, in the **left** `