diff --git a/.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md b/.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md
new file mode 100644
index 0000000..b8ea7f9
--- /dev/null
+++ b/.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md
@@ -0,0 +1,233 @@
+# Phase 19.1: Chat satellites - Context
+
+**Gathered:** 2026-10-09
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+The five remaining shared plugins that Wavepath's app plugin requires run on SummerCMS, each in its own `sm-*-plugin` repo under `git.golem15.com/golem15/`:
+
+| PHP plugin | Go repo / plugin ID | What it is | PHP size |
+|---|---|---|---|
+| `Golem15.ChatBots` | `sm-chatbots-plugin` / `golem15.chatbots` | Slash-command registry and dispatcher over chat, the `bot_flags` table, the `BotAuthResolver` seam | ~1.4k LOC |
+| `Golem15.ChatVideo` | `sm-chatvideo-plugin` / `golem15.chatvideo` | LiveKit video sessions per conversation, call links (`join_code`), `video:*` realtime events | ~1.1k LOC |
+| `Golem15.Forum` | `sm-forum-plugin` / `golem15.forum` | Context-scoped categories, threads and posts in Markdown, read marks, a deny-all access resolver | ~2.1k LOC |
+| `Golem15.Herald` | `sm-herald-plugin` / `golem15.herald` | Web Push delivery core: subscriptions, payload, send engine, 404/410 pruning, `HeraldProfile` and `PresenceGate` seams | ~1.5k LOC |
+| `Golem15.UserFriends` | `sm-userfriends-plugin` / `golem15.userfriends` | Friendships, friend groups (members, invites, bans), the friends chat context adapter | ~2.1k LOC |
+
+Each plugin provides its schema, models, a typed Go service API, a generic headless JSON API (D-05) and its events.
+
+Out of scope:
+- Wavepath's own `/_wave/api/v1/community/...` and `/_wave/api/v1/push/...` routes, its controllers, and its host bindings: `WaveForumAccessResolver`, `WaveBotAuthResolver`, `WaveHeraldProfile`, `WavePresenceGate`, `WaveNotificationDispatcher`, `CommunityVideoService` and the group-lifecycle wiring in `wavepath/Plugin.php`.
+- The Vue frontend and the Wavepath app port itself.
+
+**Repos touched:**
+- The five new plugin repos.
+- `sm-user-plugin`, only for additive user-lifecycle events that Phase 19 has not already added.
+- `summercms.go`, for the framework Markdown module (D-17, delivered as a separate quick task), and only if `lighthouse`, `conga`, `fetchguard` or `festival` lack something.
+
+**Roadmap corrections** (make them in the first planning commit):
+- Success criterion 2: drop "Chatbots is wired to `sm-golem-plugin` where the PHP original calls Golem". The PHP chatbots plugin never calls Golem; it requires only Chat and User.
+- Success criterion 2: herald's decision note names `webpush-go` (D-09). Add decision notes for LiveKit (D-13) and bluemonday (D-16).
+- Success criterion 3 is already met. Fonoteka's and Wavepath's userfriends are the same commit (`182b514`, no diff), fonoteka's PHP code never references it, and fonoteka.go has no port. Wavepath's copy is the single source.
+- "Repos" line: add `summercms.go` for the Markdown module.
+
+
+
+
+## Implementation Decisions
+
+### Carried forward from Phase 19 (not re-decided)
+- **D-01:** **The frozen port contract is Wavepath's tree.**
+ - The commits are chatbots `1ecf8c8`, chatvideo `25135b4`, forum `ad10b54`, herald `a749f83` and userfriends `182b514`, read from `/media/nvme/dev/jz/wavepath.org/plugins/golem15/`.
+ - The P15/P18/P19 frozen-source rule applies. These names and shapes are binding: tables and columns, migration-equivalent schema, realtime event names, channels and payloads, serializer output, console command names, config keys, env names and lang keys.
+ - **Reversibility:** costly. The names are copied into the database and clients of every app.
+- **D-02:** **One squashed, idempotent baseline per plugin,** equal to the final PHP schema, that adopts an existing Wavepath database unchanged (Phase 5, P18.1 D-04, P19 D-04). The researcher diffs each baseline against a real Wavepath database.
+ - The demo-seed migrations (chatvideo `seed_demo_users`, userfriends `seed_demo_friends`) are not ported as behavior (D-07). Their version IDs stay recorded so that adopted databases stay consistent, and fresh databases get no demo users.
+ - **Reversibility:** one-way. Migration IDs and table shapes become the shared contract.
+- **D-03:** **The P19 seam rules apply to every satellite.**
+ - Contexts register through the build-time morph registry (P19 D-11). Stored PHP class names in morph columns are rewritten to aliases on adoption (P19 D-12). That covers `golem15_forum_categories.context_type` and any satellite morph column the researcher finds.
+ - Cross-plugin hooks are typed `festival` events, and no plugin patches another's model (P19 D-13). The GDPR user-delete cascades listen to `sm-user-plugin` events:
+ - chatbots deletes the `bot_flags` row;
+ - forum sets `user_id` to null on threads and posts and keeps the bodies;
+ - userfriends hands group ownership to the oldest admin, else the oldest member, else hard-deletes the empty group, then removes the user's member and ban rows.
+ - Every access seam is deny-all by default and has a setter, and Boot logs a Warn while the default is active (P19 D-14). That covers `ForumAccessResolver`, `BotAuthResolver` and herald's `PresenceGate`.
+ - Realtime goes through `lighthouse`, and user-targeted pushes go to the `user` namespace that `sm-user-plugin` registers (P19 D-16, D-17).
+ - Nothing names QuestStream, family, parent or child (P19 D-02), and READMEs never name a consuming app.
+- **D-04:** **A test-only host per plugin repo** (P18 D-03, P19 D-21): testcontainers Postgres, the lighthouse memory driver, fakes for external services (a LiveKit Twirp fake and a push-service fake), neutral `acme` fixture contexts, and `sm-user-plugin`, plus `sm-chat-plugin` where the plugin requires it. Every PHP test method maps to a Go case or to an explicit "not applicable" note. Golden payloads and events are captured from Wavepath. The mapping is completed in the last plan.
+
+### HTTP surface
+- **D-05:** **Every satellite ships a typed Go service API plus a generic headless JSON API under its own prefix,** following P19 D-06 to D-09:
+ - `/_forum/api/v1` mirrors `/_wave/api/v1/community/forums/{groupId}/...` (categories with create, rename, reorder and delete; threads, posts, edit and delete, read marks, unread count), re-rooted on context alias and id: `/_forum/api/v1/contexts/{type}/{id}/categories`. Access is decided by `ForumAccessResolver`.
+ - `/_chatvideo/api/v1` mirrors `/_wave/api/v1/community/groups/{id}/video` (start, join, leave, end, status) and `community/call-links/{code}`, addressed by conversation, or by context through chat's context registry.
+ - `/_userfriends/api/v1` mirrors `/_wave/api/v1/community/friends*` (list, requests, sent, request, accept, reject, cancel, unfriend) and the group routes (`community/groups*`: groups, members, invites, bans).
+ - `/_herald/api/v1/push/subscription` with POST, PATCH and DELETE, plus `GET /_herald/api/v1/push/vapid-public-key`, mirroring `/_wave/api/v1/push/*`.
+ - `/_chatbots/api/v1` covers the command catalog, filtered by `isAvailable`, and command dispatch, mirroring Wavepath's `CommunityCommandController`. The invoker is always the authenticated actor (PHP CMD-06).
+ - Auth is `jwt.auth`, mounted with the `lighthouse.Mount` surfaces pattern so the app picks the guard. PHP's named rate buckets carry over: `video-initiate` at 5 per minute per user, the friend-request bucket (`community-join`), and a general bucket per API. The actor id always comes from the token, never from the request body.
+ - One serializer set serves both the generic API and the future `/_wave` aliases, so payloads are identical.
+ - **Reversibility:** one-way. These are published APIs that apps will consume.
+- **D-06:** **This reverses the PHP "herald ships no routes" rule** (herald README, D-78-04) for consistency with the other plugins. The herald README's reuse contract is rewritten around the generic API and the Go seams.
+- **D-07:** **Dropped, as in P19 D-10:**
+ - the legacy plugin-owned routes `/api/video/*` and `/api/friends/*`, which no client calls;
+ - the WinterCMS components `VideoCall`, `FriendsList` and `FriendRequests` and their templates;
+ - chatvideo's bundled `livekit-client.umd.min.js`, `video-chat.js`, `video-vue.js` and CSS;
+ - the demo seeders (D-02).
+- **D-08:** **Satellites couple through generic context events, never through each other's types.**
+ - userfriends fires typed festival events for group created, group deleted, member joined and member left.
+ - forum exposes a generic `DeleteContext(type, id)`, a soft-delete cascade over categories, threads and posts. It does not import userfriends.
+ - The app, or a small optional bridge in the app, wires group-deleted to `forum.DeleteContext`. This replaces PHP forum's `class_exists(FriendGroup)` hook.
+ - The other group hooks in `wavepath/Plugin.php` (spawning a chat or channel on group create, ending video on group delete) stay app wiring for the Wavepath port.
+ - userfriends registers its chat context adapter (the `FriendContext` provider) softly, only when chat is registered, through an optional-capability check rather than a hard requirement. This keeps PHP's "chat is the lower layer" rule.
+
+### Herald (Web Push)
+- **D-09:** **Web Push uses `github.com/SherClockHolmes/webpush-go`,** which covers VAPID (RFC 8292) and aes128gcm (RFC 8291).
+ - The decision note required by the roadmap goes in `.planning/notes/` before any herald code lands. It records the version, maintenance state, transitive dependencies (it should already share `golang-jwt/jwt/v5`), and how its HTTP client is swapped for fetchguard's (D-10).
+ - The researcher verifies that the library's `Options.HTTPClient` accepts a `fetchguard.Client`-backed doer. If it doesn't, the note records the adapter.
+ - **Reversibility:** reversible. It sits behind the plugin's own sender.
+- **D-10:** **Every push send goes through a `fetchguard` PublicOnly client** (HTTPS only, private and reserved IPs refused at dial time).
+ - There is no `EndpointGuard` seam. Dial-time checks close the subscribe-then-send DNS-rebind gap that PHP's IP pinning (`CURLOPT_RESOLVE`) and the host-bound `SsrfGuard` existed for.
+ - Subscribe time also validates the endpoint URL (HTTPS, public host).
+ - This is fail-closed by default, where PHP's `AllowAllEndpointGuard` was fail-open.
+- **D-11:** **Sends run as a `conga` job, one per send.**
+ - `SendEvent` and `SendUnconditional` enqueue a River job inside the caller's transaction, so a rolled-back write pushes nothing.
+ - The job runs `PresenceGate.ShouldSend` (for `SendEvent` only), builds the payload with `HeraldProfile`, sends to every subscription the user has, and prunes 404 and 410 endpoints.
+ - River provides retries and backoff. PHP's rate-limited OBS-01 warning for other delivery failures carries over.
+ - The PHP defaults carry over: TTL 2419200 (4 weeks) and urgency `normal`, both overridable per send.
+- **D-12:** **VAPID keys are config keys** `golem15.herald.vapid.{subject,public_key,private_key}` with the default subject `mailto:admin@example.com`.
+ - The PHP env names `VAPID_SUBJECT`, `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are mapped so an adopted `.env` works unchanged.
+ - `summer herald:vapid-generate` (the PHP `herald:vapid-generate`) prints a new keypair. The private key is never logged and never appears in a payload.
+ - Boot warns when the keys are missing. Keys are never generated automatically, because rotating them invalidates every subscription.
+
+### ChatVideo (LiveKit)
+- **D-13:** **chatvideo uses the official `github.com/livekit/server-sdk-go`** for access tokens and RoomService (create, delete, remove participant, participant count, list rooms).
+ - A decision note in `.planning/notes/`, written before the code lands, records the version, the transitive dependency footprint (`livekit/protocol`, twirp, protobuf; flag anything heavy such as pion), and how tests fake it.
+ - The token claims must match PHP exactly, verified by a golden test: HS256, `iss` set to the API key, `sub` set to the user id as a string, `name`, `iat`/`nbf`/`exp` with `token_ttl` defaulting to 900, a `video` grant with `room`, `roomJoin`, `canPublish` and `canSubscribe`, and an optional JSON `metadata` string carrying the avatar.
+ - The admin URL keeps PHP's fallback: `livekit_admin_url`, else `livekit_url` with `wss://` or `ws://` rewritten to `https://` or `http://`. An error for a missing room is swallowed as in PHP (`participantCount` returns 0, `deleteRoom` is a no-op).
+ - Config keys and env names carry over: `livekit_url` and `LIVEKIT_URL`, the API key, the secret, `max_duration` (3600), `token_ttl` (900), `livekit_admin_url`, `max_participants` (8) and `screen_share`.
+ - **Reversibility:** reversible. It sits behind the plugin's LiveKit service.
+- **D-14:** **The stale-session reconciler moves from the Wavepath app into chatvideo.**
+ - A generic reconcile service ends sessions whose LiveKit room is gone or empty, or that have run past `max_duration`. It is exposed as `summer chatvideo:reconcile-sessions` and as a `conga` periodic job (every 5 minutes, as Wavepath schedules it).
+ - The Wavepath port's `wave:reconcile-video-sessions` becomes a thin alias. The researcher reads `wavepath/console/ReconcileVideoSessions.php` and keeps only its generic part.
+- **D-15:** **Realtime events are the PHP events, published through lighthouse.**
+ - `video:call:incoming` goes to recipients' `user:{id}` channels.
+ - `video:call:accepted`, `declined`, `ended`, `video:participant:joined` and `left` go to `video:session:{id}`.
+ - chatvideo registers the `video` namespace authorizer. It allows `video:session:{id}` only to participants of the session's conversation and grants capability `["prs"]`. It replaces `VideoChannelAuthorizer`.
+ - A publish failure is logged and not raised, as in PHP.
+ - The one-active-session-per-conversation unique index and the `card_message_id` and `join_code` columns carry over.
+
+### Forum (Markdown)
+- **D-16:** **Sanitizing uses `github.com/microcosm-cc/bluemonday`,** recorded in a decision note.
+ - The policy is the PHP HTMLPurifier config, 1:1:
+ - Allowed tags: `p, br, strong, em, b, i, h1-h6, ul, ol, li, blockquote, pre, code, a[href|rel|target], table, thead, tbody, tr, th, td, hr, del, ins, sub, sup`.
+ - URL schemes: `http`, `https` and `mailto`.
+ - Links get `target=_blank` and `rel=nofollow`.
+ - Conversion uses `goldmark`, already a dependency, with GFM. That covers tables, strikethrough and autolinks (the external-link behavior), with raw HTML input stripped (PHP `html_input=strip`) and unsafe links disallowed.
+- **D-17:** **The renderer is a framework module, not forum code.**
+ - It is a small shared package in `summercms.go` (or an extension of cabana's markdown support; the quick task decides) that exports `Render(markdown)` and `Purify(html)` with a named policy.
+ - The user's direction: "Framework module, done with gsd-quick by summer agent. We'll need that in multiple places." It is delivered as a separate `/msd-quick` task in `summercms.go` by the summer agent. It is not a plan of this phase. The forum plans consume it, and the Wavepath port's `purify()` callers (trainings) use it too.
+ - If the quick task hasn't landed when forum execution starts, the forum plan blocks on it. The forum plan does not grow its own renderer.
+ - The module's README and `docs/` page follow the Documentation rules in CLAUDE.md.
+- **D-18:** **`content_html` parity is semantic, and stored rows are kept.**
+ - Adopted `content_html` rows are served as stored and never re-rendered on read. New and edited posts render in Go, on write, as in PHP.
+ - Golden tests compare a normalized DOM (the same tags, attributes and text) against PHP output for a corpus that covers the PHP `ForumMarkdownRenderTest` XSS cases (`