diff --git a/.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
new file mode 100644
index 00000000..ec516f58
--- /dev/null
+++ b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@@ -0,0 +1,692 @@
+# Phase 20: Demo instance with every shared plugin - Research
+
+**Researched:** 2026-10-28
+**Domain:** Mounting the chat family (chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends) into `sm-demo-app`, new shared Nuxt layers, a self-hosted LiveKit on rome, a new shared moderation plugin
+**Confidence:** HIGH for the backend and repo state (read and compiled this session), MEDIUM for LiveKit deployment, MEDIUM for the layer port effort
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Chat frontend
+- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
+- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
+- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
+- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
+- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
+
+#### Chat context and access
+- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
+- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
+- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
+- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
+
+#### Video calls
+- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
+- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
+- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
+- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
+
+#### Forum, friends, push
+- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
+- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
+- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
+
+#### Moderation
+- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
+- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
+
+#### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
+- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
+
+#### Folded Todos
+- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump to ` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
+
+### Claude's Discretion
+- Seeded channel names and persona users, and how much EN/PL history to seed.
+- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
+- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
+- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
+- How `vue-chat-layer` exposes extension points for app-specific embeds.
+
+### Deferred Ideas (OUT OF SCOPE)
+- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
+- User-created channels (public or private) and ad-hoc group DMs with group calls.
+- A live bot persona answering in channels.
+- A read-only `/chat` preview for anonymous visitors.
+
+#### Reviewed Todos (not folded)
+- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
+- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
+
+
+
+## Phase Requirements
+
+No requirement IDs are mapped (ROADMAP: "Requirements: TBD"). The success criteria and D-01..D-19 are the source of truth.
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| SC-1 | `sm-demo-app` builds with every shared plugin incl. chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends; `demo:seed` fills each | §Repo state and tagging, §Demo host wiring, §Seeding |
+| SC-2 | Public Nuxt pages show chat/channels with realtime, video calls, presentations, journal, others through shared layers | §Layers, §Realtime, §LiveKit |
+| SC-3 | Deployed publicly; frontend open; `/backend` unreachable; "contact Golem15" CTA | §rome deployment, Open Question 1 |
+| SC-4 | New code unit-tested in the last plan | §Validation Architecture |
+
+
+## Summary
+
+The backend half is in better shape than the repo layout suggests, but every chat-family plugin needs release work before `sm-demo-app` can require it. Only `sm-chat-plugin` has tags (v0.1.0-v0.1.3 on origin). `chatchannels`, `chatbots`, `chatvideo`, `forum`, `herald` and `userfriends` have none. Three sibling checkouts (`../sm-chat-plugin`, `../sm-chatchannels-plugin`, `../sm-chatbots-plugin`) are **behind origin**; the current code lives in `sm-bm-app/plugins/golem15/*`. `chatvideo` still requires `sm-chat-plugin v0.0.0`, and `chatvideo` and `herald` keep sibling `replace` lines. I copied all seven trees to a scratch workspace, set their requires to tags (chat v0.1.3, user v0.1.13, summercms v0.5.1) and dropped the sibling replaces. With that, all seven `go build`, `go vet` and `go test -short` pass against framework **v0.5.1**, the version `sm-demo-app` pins. Without the go.mod fix, module resolution fails with `unknown revision v0.0.0`. So the gap is release hygiene, not framework drift.
+
+The frontend half is larger than D-01's "port about 4.6k lines" implies. bm's chat SPA is built around **one** channel (`# ogólny`) plus DMs. Its store has `general`/`generalId` throughout. It has **no thread UI, no pins, no channel list**, and its embeds are bm entity types (`'deadline' | 'class' | 'reading'`), not link embeds. It also depends on a bm host bootstrap endpoint (`/_bm/api/v1/chat` and `/chat/members`). D-02 and D-07 (several seeded channels, threads, pins) therefore need new UI: a multi-channel store, a thread pane and a pins view. The demo also needs its own bootstrap endpoints in `golem15.demo`.
+
+The backend gaps are small but each one blocks something:
+- `chatchannels` does not register the `channel` chat alias. Messages in channels made with `CreateChannel` resolve through a host "channel-as-context adapter", which `golem15.demo` must register.
+- The demo's realtime token route is limited to presentation owners. Chat needs every member to get a token.
+- Centrifugo needs the `chat`, `user` and `video` namespaces.
+- No chat-family plugin has admin screens. "Admins deleting messages from the backend" (D-17) has no UI today.
+
+On rome, a WavePath LiveKit 1.13.1 already runs on 127.0.0.1:7880, 7881/tcp and 50000-60000/udp. Its media ports are defined in Vuurmuur but **not applied in the firewall rules**. The demo needs its own instance on free ports and a Vuurmuur rule.
+
+**Primary recommendation:** Plan in three stages:
+1. Release work in the shared plugin repos: fast-forward the siblings, fix the go.mod files, add the pre-save seams, create `sm-moderation-plugin`, tag v0.1.x.
+2. Demo host wiring and seed: the context, the channel adapter, bootstrap endpoints, the realtime guard, Centrifugo namespaces, local LiveKit.
+3. Layers, with the realtime client living in `vue-chat-layer` and reused by `vue-chatvideo-layer`, then the rome deploy and the tests plan.
+
+Set `golem15.chatvideo.max_participants: 2` so the server itself enforces D-11 (DM-only calls).
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Chat persistence, permissions, realtime publish | API / Backend (`golem15.chat`) | Database | Shared plugin; host only supplies context |
+| Demo community membership, channel adapter, channel/forum access policy | API / Backend (`golem15.demo`) | — | Host-specific policy (D-06); plugins stay neutral |
+| Chat bootstrap (channels with unread/mentions, DMs, members, limits) | API / Backend (`golem15.demo` `/_demo/api/v1/chat*`) | — | bm keeps this host-side (`/_bm/api/v1/chat`); generic APIs lack a "my conversations across contexts" call |
+| Pre-save content filter | API / Backend (`sm-moderation-plugin` service, wired by `golem15.demo` into chat/forum seams) | — | Must run before persistence; must not couple moderation to chat/forum modules |
+| Slash commands | API / Backend (`golem15.chatbots` registry, demo registers commands) | Browser (composer picker) | Dispatch runs as JWT actor server-side |
+| Video room lifecycle, tokens | API / Backend (`golem15.chatvideo`) | LiveKit SFU (rome) | Plugin mints tokens; media never touches Go |
+| Media transport | LiveKit SFU on rome (UDP mux / ICE-TCP) | Browser (`livekit-client`) | WebRTC needs direct ports, not nginx/Cloudflare |
+| Realtime fan-out | Centrifugo (demo instance) | API (subscribe proxy authorizers) | Already deployed in 19.4.1 |
+| Chat/forum/friends/video UI | Browser / Client (layers, client-only) | Frontend Server (SSR shell, `/forum` may SSR) | Chat and video touch window/WebSocket; SSR only the shell |
+| Anonymous prompts, tour cards | Frontend Server (vue-demo-app) | — | App-specific copy, EN/PL |
+
+## Project Constraints (from CLAUDE.md)
+
+- **Stdlib first.** A new Go dependency needs a research doc or decision note naming it. No new Go deps are needed beyond what the chat family already carries (`livekit/server-sdk-go/v2` and `SherClockHolmes/webpush-go` come in with `chatvideo` and `herald`). `sm-moderation-plugin` should be stdlib-only (+ `gorm`, framework modules).
+- **YAML** is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
+- **Framework code and shared plugins/layers never name a consuming application.** READMEs use neutral names (`blog`, `acme`). The pre-save seam, `sm-moderation-plugin` and every `vue-*-layer` follow this. `golem15.demo` may name itself.
+- **Core plugins: no breaking changes** (user, blog, pages, payment… and here chat/forum: WavePath parity, Phase 31). Seams are additive and ship as patch tags (D-18).
+- **Submodule commit order:** commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. One `chore: bump to ` per plugin.
+- **go.mod requires tags only** (`TestGoModRequiresTags`, `scripts/check-pins.sh`); `go.work` handles local checkouts.
+- **Module/plugin READMEs** follow the standard structure (H1, summary, import, `**Name:**` mnemonic, Overview, Features, Usage, API reference, Configuration, CLI, Dependencies, Testing); every identifier must exist (docs checker for framework; plugin READMEs by hand).
+- **Lean planning:** few, large plans; present the plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** STATE.md via `msd_run query state.*` handlers. g15office sync on phase advance.
+- **Commits:** no co-author tags (user's global CLAUDE.md overrides the harness attribution); stage files by explicit path; planning docs and code in separate commits. Don't push, tag or deploy without the user's go-ahead.
+- **Pushed release tags `vX.Y.Z`** of the framework get a changelog post on summercms.io the same day (only if this phase cuts a framework tag — not expected).
+- **Memory:** tag only at phase completion for the framework (interim plugin tags are fine). Add new shared plugins to the demo (this phase's whole point).
+- **Docker tests:** if the Docker socket is denied, run through `sudo -u golem`.
+
+## Repo State and Tagging (verified this session)
+
+| Plugin | Where current code lives | Origin `main` | Tags on origin | go.mod problems for module mode |
+|--------|-------------------------|---------------|----------------|----------------------------------|
+| `sm-chat-plugin` | `sm-bm-app/plugins/golem15/chat` (51c9e17 = v0.1.3) | 51c9e17 | v0.1.0, v0.1.1, v0.1.2, v0.1.3 | none (requires user v0.1.2, summercms v0.4.0) |
+| `sm-chatchannels-plugin` | `sm-bm-app/plugins/golem15/chatchannels` (a30f04a) | a30f04a | **none** | none (requires chat v0.1.0) |
+| `sm-chatbots-plugin` | `sm-bm-app/plugins/golem15/chatbots` (d0b9fb1 "drop sibling replaces") | d0b9fb1 | **none** | none (requires chat v0.1.0) |
+| `sm-chatvideo-plugin` | `../sm-chatvideo-plugin` (be688fe) | be688fe | **none** | `require sm-chat-plugin v0.0.0`; `replace sm-chat-plugin => ../sm-chat-plugin`; `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
+| `sm-forum-plugin` | `../sm-forum-plugin` (2779341) | 2779341 | **none** | none |
+| `sm-herald-plugin` | `../sm-herald-plugin` (74c889c) | 74c889c | **none** | `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
+| `sm-userfriends-plugin` | `../sm-userfriends-plugin` (57db2ba) | 57db2ba | **none** | requires chat pseudo-version `v0.0.0-20261017200243-4d0e764ca443` |
+
+[VERIFIED: `git ls-remote origin` per repo; `git -C … log`; go.mod files read]
+
+- **Sibling checkouts are stale:** `../sm-chat-plugin` is at 5fea654 (origin 51c9e17). `../sm-chatchannels-plugin` is at f3ac5d1 (origin a30f04a). `../sm-chatbots-plugin` is at 9dfe8b7 (origin d0b9fb1). Fast-forward (`git pull --ff-only`) before any edit.
+- **Compatibility proof:** in a scratch `go.work` I used framework v0.5.1 (from `git archive v0.5.1`) and user v0.1.13 (the demo's submodule), set every chat-family go.mod to `sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`, and removed the sibling replaces. All seven pass `go build ./...`, `go vet ./...` and `go test -short ./...`. Before the go.mod fix, every build failed with `git.golem15.com/golem15/sm-chat-plugin@v0.0.0: reading … go.mod at revision v0.0.0: unknown revision v0.0.0`. [VERIFIED: scratch build output]
+- **Suggested tags** (one-way, D-03 of 19.4.1):
+
+ | Plugin | Tag |
+ |--------|-----|
+ | chat | v0.1.4: pre-save seam, additive (patch) |
+ | chatchannels | v0.1.0 |
+ | chatbots | v0.1.0 |
+ | chatvideo | v0.1.0, after the go.mod fix |
+ | forum | v0.1.0, with the seam (its first tag) |
+ | herald | v0.1.0, after dropping the replace |
+ | userfriends | v0.1.0, chat requirement → v0.1.3 or v0.1.4 |
+ | sm-moderation-plugin | v0.1.0 |
+
+ Raising the minimal `summercms` / `sm-user-plugin` requires in plugin go.mod files is optional (MVS picks the demo's v0.5.1 / v0.1.13). Keep the `replace git.golem15.com/golem15/summercms => ../summercms.go` line, the standard plugin pattern.
+- **bm is unaffected:** `sm-bm-app` pins chatchannels and chatbots as `v0.0.0` with local `replace` lines. It keeps building whatever the new tags are.
+- **Folded todo (oauthserver):** the `sm-demo-app` submodule is at b6a9586 (`v0.1.2-7-gb6a9586`, uncommitted move). The index has 8bbec72 = v0.1.2, and go.mod requires v0.1.2. Tag v0.1.3 = df2eddd ("require SummerCMS v0.5.0 and sm-user-plugin v0.1.11") contains b6a9586. Move the checkout to v0.1.3, run `GOWORK=off go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` then `go mod tidy`, and commit `chore: bump sm-oauthserver-plugin to v0.1.3`. The journal half of the todo is already done (`11d485f chore: bump journal to v0.2.5`). [VERIFIED: `git submodule status`, `git log`]
+
+## Standard Stack
+
+### Core (backend, all already chosen in Phases 19/19.1)
+| Library / module | Version | Purpose | Why Standard |
+|------------------|---------|---------|--------------|
+| `git.golem15.com/golem15/summercms` | v0.5.1 (demo pin; v0.5.2 exists) | Framework | Demo already on it; chat family proven on it this session |
+| `sm-chat-plugin` | v0.1.3 → v0.1.4 (seam) | Conversations, messages, reactions, attachments | Phase 19 port |
+| `sm-chatchannels-plugin` | first tag v0.1.0 | Channels, pins, mentions | Phase 19 port |
+| `sm-chatvideo-plugin` | first tag v0.1.0 | LiveKit sessions, tokens, reconcile | Phase 19.1 |
+| `sm-chatbots-plugin` | first tag v0.1.0 | Slash command registry/dispatch | Phase 19.1 |
+| `sm-forum-plugin` | first tag v0.1.0 (seam) | Forum | Phase 19.1 |
+| `sm-herald-plugin` | first tag v0.1.0 | Web Push (mounted, no UI) | Phase 19.1 |
+| `sm-userfriends-plugin` | first tag v0.1.0 | Friends, groups | Phase 19.1 |
+| `github.com/livekit/server-sdk-go/v2` | v2.19.0 (transitive via chatvideo) | RoomService client | Already in chatvideo go.mod |
+| LiveKit server | 1.13.1 (installed on rome `/usr/local/bin/livekit-server` and locally) | SFU | Same binary WavePath runs; no install needed |
+| Centrifugo | v6.5.1 (rome and local) | Realtime | Already the demo's (D-17) |
+
+### Frontend
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `nuxt` | 4.4.8 (pinned across layers) | Layers and app | All layers `peerDependencies: { nuxt: 4.4.8 }` |
+| `vue` | 3.5.35 | — | — |
+| `centrifuge` | 5.7.3 (already pinned in vue-demo-app; audited in 19.4.1) | Centrifugo client | Move/duplicate into `vue-chat-layer` deps at the same pin |
+| `livekit-client` | 2.23.0 latest (published 2026-10-16) `[WARNING: flagged as suspicious — verify before using.]` | WebRTC client for `vue-chatvideo-layer` | Pin an exact version after the human-verify checkpoint |
+| `vitest` | 3.2.7 (vue-demo-app's pin) | Layer unit tests for the ported bm tests | bm's chat tests are vitest |
+
+**No Pinia.** 19.4.1 established "Pinia stores become `useState` composables" (19.4.1-PATTERNS.md:259; 19.4.1-04-PLAN.md:134). `@pinia/nuxt` 1.0.3 peers `pinia ^4.1.0` and depends on `@nuxt/kit ^4.6.0`, against the repo's `nuxt` 4.4.8. bm's store is a setup store (`defineStore('chat', () => {…})`), so converting it to a module-level factory or composable is mechanical. [VERIFIED: npm view @pinia/nuxt peerDependencies dependencies]
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Own LiveKit instance on rome | Add a key pair to WavePath's LiveKit (`/etc/livekit/livekit.yaml`) | Any API key can list/delete every room on that server; cross-tenant blast radius; D-10 says own keys → use own instance |
+| UDP mux (`rtc.udp_port`) | Port range (`port_range_start/end`) | Range needs a wide firewall hole and overlaps WavePath's 50000-60000; mux needs one port |
+| Embedded TURN/TLS on 443 via nginx `stream` `ssl_preread` | No TURN, ICE-TCP fallback (`rtc.tcp_port`) | `ssl_preread` would restructure every 443 vhost on a shared host; ICE-TCP covers most NAT'd browsers |
+| Festival "-ing" event as pre-save seam | Validator registry on the services (`Service.AddValidator`) | Both additive; festival mirrors `user.Deleting`/`user.Deactivating` and needs no new type on the service |
+
+**Installation (layers):**
+```bash
+# vue-chat-layer
+pnpm add centrifuge@5.7.3
+pnpm add -D vitest@3.2.7 nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
+# vue-chatvideo-layer (after checkpoint:human-verify on livekit-client)
+pnpm add livekit-client@
+```
+
+**Version verification:** `npm view livekit-client version` → 2.23.0 (2026-10-16). `npm view centrifuge version` → 5.8.0 (2026-10-20); the demo stays on the audited 5.7.3. [VERIFIED: npm registry]
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age (latest) | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| `livekit-client` | npm | latest 12 days | 5.67M/wk | github.com/livekit/client-sdk-js | [SUS] reason `too-new` | Flagged — planner must add checkpoint:human-verify; pin an exact version |
+| `centrifuge` | npm | latest 8 days | 1.61M/wk | github.com/centrifugal/centrifuge-js | [SUS] reason `too-new` (latest only) | Keep the already-audited 5.7.3 pin from 19.4.1; no new install decision |
+| `pinia`, `@pinia/nuxt` | npm | — | — | github.com/vuejs/pinia | [SUS] `too-new` | Not recommended (see above) |
+
+No postinstall scripts on `livekit-client` or `centrifuge` (`npm view … scripts.postinstall` empty). `centrifuge` pulls `protobufjs`, whose postinstall is already disabled in `pnpm-workspace.yaml` (`protobufjs: false`).
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** `livekit-client`: the planner inserts checkpoint:human-verify before the install. The "too-new" verdict is about the latest release, not the package. It is LiveKit's official JS SDK, but its name came to me from training and the chatvideo README, not from Context7, so treat it as `[ASSUMED]` until verified.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Browser (vue-demo-app + layers, client-only chat/video)
+ │ JWT (DEMO cookie) as Bearer
+ ├─ GET /_demo/api/v1/chat ─────────────► golem15.demo bootstrap ──► chat.Service.GetConversationsForUser
+ │ chatchannels.ChannelService (channels, mention counts)
+ ├─ /_chat/api/v1/... (send/edit/react/replies/upload) ──► chat.Service
+ │ └─ [NEW] Fire MessageSaving (pre-save) ──► demo listener ──► moderation.Filter.Check ──► 422 or continue
+ │ └─ tx: insert ─► Fire MessageCreated ─► chatchannels mentions; lighthouse Bind ─► broadcasts queue ─► Centrifugo publish
+ ├─ /_chatchannels/api/v1/contexts/demo/1/channels/... (join, pins, mentions)
+ ├─ /_chatbots/api/v1/commands | /dispatch ─► chatbots Registry (demo-registered commands, ephemeral answers)
+ ├─ /_forum/api/v1/contexts/demo/1/... ─► forum.Service ─► [NEW] Fire PostSaving ─► moderation ─► persist (breakwater render)
+ ├─ /_userfriends/api/v1/...
+ ├─ /_chatvideo/api/v1/conversations/{dm}/video/start ─► chatvideo (participants ≤ max_participants=2 ⇒ DMs only)
+ │ └─ RoomAPI ─► http://127.0.0.1: (loopback, TrustedMode) ; returns {token, livekit_url}
+ ├─ GET /api/realtime/token ─► demo guard (any member) ─► lighthouse token
+ ├─ wss /ws ─► nginx ─► Centrifugo (demo) ──subscribe proxy──► Go authorizers: chat / user / video / presentations
+ └─ wss /livekit/rtc ─► Cloudflare ─► nginx ─► LiveKit (demo instance) signal
+ media: UDP / TCP direct to rome public IP (bypasses Cloudflare)
+```
+
+### Recommended Project Structure
+
+```
+sm-moderation-plugin/ # new shared plugin (golem15.moderation)
+├── plugin.go admin.go # pact.HasSettings, HasMigrations, HasConfig, HasLang
+├── classes/filter.go # Filter.Check(ctx, text) -> *Violation (words, links)
+├── models/settings.go + models/settings/fields.yaml
+├── updates/ lang/ config/
+sm-demo-app/plugins/demo/
+├── chat.go # registry aliases "demo" + "channel", resolvers, limits, seam listeners, chatbots commands
+├── classes/ (or chat_context.go) # DemoContext, ChannelContext adapter, access resolvers (chatchannels, forum)
+├── chat_api.go # GET /_demo/api/v1/chat, /chat/members (bootstrap)
+├── console/seed_chat.go seed_forum.go seed_friends.go
+vue-chat-layer/ # new
+├── app/components/chat/… # ported + ChannelList, ThreadPane, PinsPanel, CommandPicker
+├── app/composables/useChat.ts # ported store (no Pinia)
+├── app/lib/realtime.ts # shared Centrifuge client + onUserEvent hook (used by chatvideo layer)
+├── app/lib/chat{Events,Format,Text,Unread}.ts (+ tests)
+├── app/pages/chat.vue # optional; or app provides page and renders
+vue-chatvideo-layer/ # extends vue-chat-layer
+vue-forum-layer/ vue-userfriends-layer/
+```
+
+### Pattern 1: Host context and channel-as-context adapter (golem15.demo)
+**What:** Register two aliases on `*chatcontracts.Registry`:
+- `demo`: one context, id 1. Every registered, non-deleted, non-banned user is a member.
+- `channel`: loads a `chatchannels` channel by id. Its members are the demo community's members, since every channel is public (D-07).
+
+Install `chatchannels` `SetAccessResolver` (any member may access `demo/1`, nobody manages it) and `forum.SetAccessResolver`.
+**Why the `channel` alias is required:** `CreateChannel` writes the backing conversation with `ContextType: &alias` where `alias := models.ChannelAlias` (`"channel"`) [VERIFIED: sm-bm-app/plugins/golem15/chatchannels/classes/channel_service.go:168-174]. No production code registers that alias. The only `Alias: models.ChannelAlias` registration is the test fixture [VERIFIED: chatchannels/fixture_plugin_test.go:172]. Without it, chat resolves the conversation's context to `ErrUnknownContextType` (404).
+**Lazy join:** `JoinChannel` only upserts a pref row (`row.HasLeft = false`) and adds no chat participant [VERIFIED: channel_service.go:959-969]. Sending needs `isParticipant` [VERIFIED: chat/classes/service.go:463-468], while reading falls back to `host.HasMember` [VERIFIED: service.go:630-640]. The client gets participant rows by calling `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, where `GetGroupChat` "still lazy-joins a context member" (chat README). Alternatively, the demo bootstrap adds the participant. Choose one and test it.
+**Example (mirror bm):**
+```go
+// Source: sm-bm-app/plugins/jz/bm/chat.go:35-90 (pattern), adapted
+func (p *Plugin) registerChat(app *backpack.App) error {
+ reg, ok := app.Lookup[*chatcontracts.Registry]()
+ if !ok || reg == nil { return errors.New("golem15.demo: golem15.chat must boot first") }
+ if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: "demo", Loader: p.loadDemoContext}); err != nil { return err }
+ if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, Loader: p.loadChannelContext, LegacyPHPClass: chanmodels.ChannelMorphName}); err != nil { return err }
+ return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
+ ch, _ := app.Lookup[*chanclasses.ChannelService]()
+ ch.SetAccessResolver(p.channelAccess) // CanAccessContext: member && demo/1; CanManageContext: false
+ f, _ := app.Lookup[*forum.Plugin]()
+ f.SetAccessResolver(p.forumAccess) // CanRead/CanPost: member; CanManage: moderator group
+ // chatbots registry is published inside its own OnDatabase hook (chatbots/boot.go bootService)
+ bots, _ := app.Lookup[*botclasses.Registry]()
+ return registerDemoCommands(bots)
+ })
+}
+```
+`BroadcastChannel()` must be non-empty for both contexts ("an empty host channel silences the conversation channels too", bm chat_context.go:31-35). Use names no client can subscribe to, for example `chat:context:1` and `chat:channel:{id}`. `AuthorizeChat` admits only `chat:conversation:{id}`.
+Implement `contracts.LiveMembership` (`RequireLiveMembership() true`) and `contracts.MessageModerator`, as bm does, so that banned users lose access at the next check and a moderator group can delete messages in group conversations.
+
+### Pattern 2: Additive pre-save seam (D-18), recommended shape
+**What:** Use a festival "-ing" event, fired **before** the write transaction, mirroring `user.Deleting` / `user.Deactivating` (sm-user-plugin/classes/events.go: "A listener error rolls the deactivation back").
+- chat: `events.MessageSaving{ConversationID, ActorID, RootID, Content, Metadata, Op}`, where `Op` is `"create"` or `"update"`. Fire it in `SendMessageWith` after the participant and empty checks, and in `EditMessage` after `CanEditMessage`.
+- forum: `events.PostSaving{ContextType, ContextID, ActorID, ThreadID, PostID, Title, Body, Op}`. Fire it in `CreateThread`, `Reply` and `EditPost` before `breakwater.Render`.
+- A listener rejects with a typed error, for example `contracts.Rejection{Field, Message}`, implementing `error`. The services return it wrapped. The API maps `errors.As(err, &contracts.Rejection{})` to the **existing** 422 envelope `{"error":"Validation failed","errors":{field:[message]}}` (chat api/errors.go:39-44, forum api/errors.go). Every existing route, payload and status stays the same. Only a request a listener rejects gets the new 422.
+- With no listener, behaviour is byte-identical (WavePath parity; the PHP method map tests stay green).
+
+**Why not the existing events:** `MessageCreated` fires inside the transaction after `tx.Create(msg)` (service.go SendMessageWith). A listener error there rolls back, but it maps to 500 (the `writeChatError` default) and burns an id. **Forum fires no festival events at all.** `grep "Fire("` over sm-forum-plugin finds none, and `ForumService` has a `Bus` field (classes/forum_service.go:57-58) that is never used. CONTEXT's "chat and forum fire their festival events after the save" is accurate only for chat.
+
+**Coupling:** `sm-moderation-plugin` must not import chat or forum. It publishes a `*classes.Filter`, and `golem15.demo` (which already imports both) wires it:
+```go
+// Source: pattern from festival bus.go:43 (Listen[T]) and user events
+app.Events.Listen[*chatevents.MessageSaving](p.ID(), func(ctx context.Context, ev *chatevents.MessageSaving) error {
+ if v := filter.Check(ctx, ev.Content); v != nil {
+ return chatcontracts.Rejection{Field: "content", Message: v.Message}
+ }
+ return nil
+})
+```
+Note: `festival.Bus.Fire` returns `errors.Join` of the listener errors (bus.go:74-75), so callers must use `errors.As`, not `==`.
+
+### Pattern 3: One shared realtime connection across layers
+bm's `lib/realtime.ts` keeps a single Centrifuge client (`connect`, `subscribe`, `presence`). The chat store subscribes to `user:{me}` for the whole session. chatvideo publishes `video:call:incoming` to `user:{id}` (chatvideo README). One client cannot hold two subscriptions to the same channel, so `vue-chatvideo-layer` must **not** open its own `user:{me}` subscription. Put the realtime client in `vue-chat-layer` and add `onUserEvent(handler)` so unknown `user:` events (`video:*`) reach other layers. `vue-chatvideo-layer` `extends` `vue-chat-layer` and subscribes to `video:session:{id}` through the same client.
+
+### Pattern 4: Layer conventions (from vue-faq-layer, vue-paymentgateway-layer, vue-oauth-layer)
+- `nuxt.config.ts` builds absolute paths from `fileURLToPath(new URL('.', import.meta.url))`.
+- runtimeConfig has a server `apiBase` (`NUXT__API_BASE`) and a public same-origin `apiBase`.
+- `peerDependencies: { nuxt: 4.4.8 }` and `engines.node >=22.18`.
+- Tests run with `node --test --test-reporter=tap tests/*.test.ts`.
+- Each layer is consumed as `"vue-x-layer": "git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z"`.
+- **Auth hook:** the app provides a token getter through a Nuxt plugin (`provide: { paymentGatewayAuth: () => useCookie(DEMO_TOKEN_COOKIE).value ?? null }`, vue-demo-app/app/plugins/auth.ts). The chat layer needs more than a getter: a 401 refresh (bm's client calls `/_user/api/v1/fetch` to renew) and an `onUnauthorized`. Define a `chatAuth` hook with `token()`, `refresh()` and `unauthorized()`, or let the app inject the whole HTTP client.
+- **Host bootstrap contract:** the layer reads `public.chat.hostApiBase` (for example `/_demo/api/v1`) and documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the layer's contract. Any host implements them, so the layer names no app.
+- **SSR:** `routeRules` `ssr: false` for `/chat/**` and the call page. The floating panel goes in `` in the app's layout, because a layer cannot inject into the app's layout.
+
+### Pattern 5: DM-only calls enforced server-side (D-11)
+`VideoService.Start` checks only that the actor is a participant and that the conversation's participant count is at most `golem15.chatvideo.max_participants` (`max := s.intKey("golem15.chatvideo.max_participants", 8); if max > 0 && len(memberIDs) > max { return nil, ErrTooMany }`) [VERIFIED: sm-chatvideo-plugin/classes/video_service.go:109-112]. Conversation type is not checked, so **the plugin does not restrict calls to DMs.** `GetOrCreateDm` creates two-person DMs only. With `max_participants: 2` and every seeded channel holding three or more participants, `Start` on any channel or group conversation answers 422 `errTooMany` [VERIFIED: chatvideo/api/errors.go:42-43]. That is server-side enforcement of D-11 with no plugin change. The UI shows the call button only in DMs. LiveKit's `room.max_participants: 4` is a second cap. `Join` does not check counts (video_service.go:171-195), but only conversation participants can get a session (`sessionForActor`).
+
+### Anti-Patterns to Avoid
+- **Seeding channels through `CreateChannel` with the demo's resolver.** It is gated by `CanManageContext` [VERIFIED: channel_service.go:136], which the demo answers false (D-07). Seed with a separate trusted instance instead, `chanclasses.New(chanclasses.Options{DB: tx, Resolver: seedResolver})`, or with raw SQL in the existing seed style.
+- **Seeding chat messages through `chat.Service`.** Its `SendMessageWith` emits realtime and enqueues jobs inside the tx. The seed already uses raw SQL (`insertID`, `exists`) and stays idempotent.
+- **Announce-mode chatbots commands.** HTTP dispatch passes `classes.CommandContext{}` [VERIFIED: chatbots/api/commands.go:57], so `announce` never persists (`cmdCtx.ConversationID == 0` returns early, dispatcher.go:52-56). Demo commands must be `Ephemeral`.
+- **Opening a second Centrifuge client** in the video layer (see Pattern 3).
+- **Editing `../vue-demo-app`.** That sibling checkout is stale (32049f7, no remote). The real app is the submodule `sm-demo-app/vue-demo-app` (44a5229, origin `golem15/vue-demo-app`).
+- **Putting the moderation filter in `golem15.demo`.** The user asked for a shared plugin (CONTEXT specifics).
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Chat persistence, permissions, unread, reactions, tombstones | Demo-side message tables or endpoints | `sm-chat-plugin` generic API `/_chat/api/v1` | PHP-parity contracts; WavePath target |
+| Channel pins, mentions, joins | Custom pin/mention tables | `/_chatchannels/api/v1/contexts/demo/1/channels/{id}/pins`, `/mentions` | Already ported, with mention accrual listeners |
+| Video tokens / room cleanup | Own JWT minting or room GC | `chatvideo` `Start/Join/Leave/End` + `chatvideo:reconcile-sessions` (every 5 min) + LiveKit `room.empty_timeout` | HS256 golden tested |
+| WebRTC | Raw `RTCPeerConnection` | `livekit-client` | SFU signalling, reconnection, simulcast |
+| Realtime | Own WebSocket server | Centrifugo + lighthouse authorizers | Already deployed |
+| Markdown → safe HTML for forum | Client-side markdown rendering of user input | Server `content_html` from `breakwater.Render` (stored on write) | Sanitized server-side; render with `v-html` only from that field |
+| Web Push | Custom VAPID | `herald` + `herald:vapid-generate` | — |
+| Settings screen for word lists | Custom admin page | `pact.HasSettings` + `fields.yaml` singleton (pattern: sm-feedback-plugin admin.go) | cabana renders it |
+| Firewall changes on rome | Raw `iptables` | Vuurmuur (`vuurmuur_conf`/`vuurmuur_script`): rome's INPUT chain is Vuurmuur-managed (`vrmr:` NFLOG prefixes) | Raw rules get overwritten on Vuurmuur reload |
+
+**Key insight:** The chat family is complete server-side. The work is host glue (context, adapter, resolvers, bootstrap), release hygiene and UI. Adding parallel endpoints or tables would fork the WavePath contract.
+
+## Common Pitfalls
+
+### Pitfall 1: Stale sibling checkouts
+**What goes wrong:** Editing `../sm-chat-plugin` (13+ commits behind) and tagging an old tree, or a seam patch that conflicts with v0.1.3.
+**How to avoid:** `git -C ../sm-chat-plugin pull --ff-only` (same for chatchannels and chatbots) as task 1, then assert `git rev-parse HEAD == $(git ls-remote origin refs/heads/main)`.
+
+### Pitfall 2: `v0.0.0` and pseudo-version requires break module mode
+**What goes wrong:** `GOWORK=off go build` in sm-demo-app fails with `unknown revision v0.0.0` (verified).
+**How to avoid:** Fix chatvideo and userfriends go.mod before tagging. `TestGoModRequiresTags` checks only the app's own go.mod, not transitive ones, so also build `GOWORK=off` in a clean temp dir.
+
+### Pitfall 3: Missing `channel` alias, so messages in seeded channels 404
+**How to avoid:** Pattern 1. Test sending to a seeded channel conversation through the HTTP API.
+
+### Pitfall 4: Realtime token route limited to presentation owners
+**What goes wrong:** Chat users get 403 from `/api/realtime/token`. The demo mounts `UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime)` and mounts nothing unless presentations is active [VERIFIED: sm-demo-app/plugins/demo/routes.go:78-87]. The vue-demo-app realtime client treats 401/403/404 as final (`UNAVAILABLE = new Set([401, 403, 404])`, app/lib/realtime.ts) and stops for good.
+**How to avoid:** Port bm's `jz.bm.realtime-access` guard (sm-bm-app/plugins/jz/bm/realtime_access.go): a demo member passes, otherwise the presentations owner guard decides.
+
+### Pitfall 5: Centrifugo namespaces missing
+**What goes wrong:** Subscribes to `chat:…`, `user:…` and `video:…` fail. The demo config has only `presentations` (deploy/centrifugo.json.example).
+**How to avoid:** Add `chat` (`presence`, `join_leave`, `force_push_join_leave`, `allow_presence_for_subscriber`, subscribe proxy), `user` and `video` (subscribe proxy), copying bm's namespace block (sm-bm-app/deploy/centrifugo/bm-config.json). "`centrifugo checkconfig` does not catch misspelt option names" (sm-bm-app DEPLOY.md:395). Compare against `centrifugo defaultconfig`. The `user` authorizer comes from sm-user-plugin v0.1.13 (`RegisterUserAuthorizer`, classes/authorizer.go:35-47). Restart `summercms-demo-centrifugo` after changing `centrifugo.json`.
+
+### Pitfall 6: LiveKit media ports not open on rome
+**What goes wrong:** Signalling works but the call shows no remote video (ICE fails). rome's INPUT policy is DROP under Vuurmuur. A Vuurmuur service `Livekit` exists (`RANGE="6;7881*1024:65535"`, `RANGE="17;50000:60000*1024:65535"`) but no rule in `/etc/vuurmuur/rules/rules.conf` uses it, and `iptables -S` shows no rule for 7881 or 50000-60000 [VERIFIED: read-only ssh probe 2026-10-28].
+**How to avoid:** Define a demo service in Vuurmuur (TCP ICE port plus UDP mux port) and a rule allowing it from the internet to the firewall, then apply it through Vuurmuur. This needs the user's go-ahead (shared production host). Verify with `ss -lunp` and an external `nc -u` / a real two-browser call.
+
+### Pitfall 7: Port collisions with WavePath's LiveKit
+**What goes wrong:** LiveKit defaults (7880, 7881, 50000-60000) collide with the running `wavepath-livekit` program.
+**How to avoid:** Use distinct ports: for example signal `127.0.0.1:7890`, `rtc.tcp_port: 7891`, `rtc.udp_port: 7892` (mux, no `port_range_*`). Check them free with `ss -ltnup` at deploy time. The ss probe today showed 7890-7892 unused. Extend `scripts/check-deploy.sh` port agreement checks to LiveKit.
+
+### Pitfall 8: LiveKit behind Cloudflare and nginx
+**What goes wrong:** The signal WebSocket drops or the URL path is wrong.
+**How to avoid:**
+- Keep the signal on the same origin under a prefix, for example `livekit_url: wss://demo.summercms.io/livekit`. livekit-client appends paths to the existing pathname ("`wss://host/livekit` becomes `wss://host/livekit/rtc`", client-sdk-js src/api/utils.ts `appendUrlPath`) [CITED: github.com/livekit/client-sdk-js/blob/main/src/api/utils.ts].
+- nginx: `location ^~ /livekit/ { proxy_pass http://127.0.0.1:7890/; proxy_http_version 1.1; Upgrade/Connection headers; proxy_read_timeout 7d; proxy_buffering off; }`, as lk.wavepath.org does.
+- Set `livekit_admin_url: http://127.0.0.1:7890` so the Go RoomService calls stay on loopback. fetchguard TrustedMode allows loopback.
+- Media goes direct to the public IP, so ICE candidates reveal rome's IP (54.36.173.176) despite Cloudflare. It is accepted risk, since rome already exposes game servers on that IP.
+- Newer livekit-client tries `/rtc/v1` and falls back to `/rtc`. Server 1.13.1 support is `[ASSUMED]`. Test locally against the same 1.13.1 binary.
+
+### Pitfall 9: Floating panel and SSR
+**What goes wrong:** Hydration mismatches or `window` errors.
+**How to avoid:** Render the floating panel in ``. Set `ssr: false` for `/chat/**` and the call route. Load `livekit-client` with a dynamic `import()`, as `centrifuge` is in realtime.ts.
+
+### Pitfall 10: Reset leaves LiveKit rooms and chat rows behind
+**What goes wrong:** `demo:reset` deletes `users`. Chat tables cascade from users in parts only, `golem15_chatchannels_*` rows have no FK to messages, and active LiveKit rooms survive.
+**How to avoid:** Add every chat-family table to `resetTables` in FK order, children first, before `users`:
+- chatvideo participants and sessions
+- chat reactions, thread mutes, thread summaries, messages, participants and conversations
+- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels and categories
+- forum reads, posts, threads and categories
+- userfriends bans, invites, group members, groups and friendships (keep the singleton context id=1)
+- herald subscriptions
+- chatbots bot flags
+
+Keep `golem15_moderation_settings`. Set LiveKit `room.empty_timeout` and `departure_timeout` so orphaned rooms close. `TestResetKeepsPlans` must grow to cover the new tables.
+
+### Pitfall 11: robots.txt and allow-lists drift
+**What goes wrong:** New API prefixes are not routed by nginx, or they show up in robots.txt.
+**How to avoid:** Add the new paths in three places:
+- nginx Go allow-list: `/_chat/`, `/_chatchannels/`, `/_chatvideo/`, `/_chatbots/`, `/_forum/`, `/_userfriends/`, and `/livekit/` to LiveKit.
+- `vue-demo-app` `goPaths`: dev proxy and robots.
+- The SEO plugin's robots.txt in the admin (DEPLOY.md "SEO": the Disallow lines are copied by hand).
+
+`/_herald/` stays unrouted: there is no UI (D-16).
+
+### Pitfall 12: Forum and friends APIs are JWT-only
+Every forum route is behind `jwt.auth` (forum README). An anonymous `/forum` cannot list threads without a new public endpoint. See Open Question 3.
+
+### Pitfall 13: No admin screens for chat/forum content
+None of the seven plugins implements `HasAdminControllers` or `HasSettings` (grep). D-17's "admins deleting messages … from the backend" has no backend UI. See Open Question 2.
+
+## Code Examples
+
+### Demo context (verbatim interface members bm implements)
+```go
+// Source: sm-bm-app/plugins/jz/bm/classes/chat_context.go:12-17,101-110
+var (
+ _ chatcontracts.Context = (*ChatContext)(nil)
+ _ chatcontracts.MessageModerator = (*ChatContext)(nil)
+ _ chatcontracts.LiveMembership = (*ChatContext)(nil)
+ _ chancontracts.AccessResolver = (*ChatAccess)(nil)
+)
+func (c *ChatContext) ID() uint { return ChatContextID }
+func (c *ChatContext) Type() string { return ChatContextType }
+func (c *ChatContext) ChatEnabled() bool { return true }
+func (c *ChatContext) GroupName() string { return ChatGeneralName }
+func (c *ChatContext) BroadcastChannel() string { return ChatBroadcastChannel }
+// + CanActFor, CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members
+```
+
+### Chatbots command (ephemeral)
+```go
+// Source: contracts in sm-bm-app/plugins/golem15/chatbots/contracts/slash_command.go
+type demoCommand struct{}
+func (demoCommand) Trigger() string { return "demo" }
+func (demoCommand) Description() string { return "What this demo shows" }
+func (demoCommand) IsAvailable(actor uint, _ contracts.CommandContext) bool { return actor != 0 }
+func (demoCommand) Handle(args string, actor uint, _ contracts.CommandContext) contracts.CommandResult {
+ return classes.Ephemeral("This is the SummerCMS demo: chat, channels, calls, forum …")
+}
+```
+Catalog JSON: `GET /_chatbots/api/v1/commands` → `{"commands":[{"trigger","description"}]}`. Dispatch: `POST /dispatch {"input":"/demo"}` → `{"mode","payload"}`; unknown → 422 `errors.input` [VERIFIED: chatbots/api/commands.go:21-66; classes/dispatcher.go:89-99].
+
+### Chatvideo config for the demo
+```yaml
+# config/golem15.chatvideo.yaml (keys verbatim from sm-chatvideo-plugin/config/config.yaml:4-11)
+livekit_url: "wss://demo.summercms.io/livekit"
+livekit_admin_url: "http://127.0.0.1:7890"
+max_duration: 900 # D-12 ≈15 min (reconcile runs every 5 min, so up to ~20)
+max_participants: 2 # DM-only calls server-side (Pattern 5)
+token_ttl: 900
+screen_share: false # minimal call page (D-13)
+# secrets: SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY / __LIVEKIT_API_SECRET in .env (or PHP names LIVEKIT_API_KEY/SECRET)
+```
+
+### LiveKit config for the demo instance (keys from LiveKit config-sample)
+```yaml
+# /srv/summercms-demo/livekit.yaml (root:summercms-demo 0640)
+port: 7890
+bind_addresses: [127.0.0.1]
+rtc:
+ tcp_port: 7891
+ udp_port: 7892 # UDP mux; port_range_start/end must NOT be set
+ use_external_ip: true
+room:
+ max_participants: 4
+ empty_timeout: 120
+ departure_timeout: 20
+keys:
+ CHANGE_ME_KEY: CHANGE_ME_SECRET
+```
+[CITED: raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml]. The `udp_port` comment there says "port_range_start & end must not be set for this config to take effect". `bind_addresses` is used by rome's working WavePath config [VERIFIED: /etc/livekit/livekit.yaml on rome].
+
+### Supervisor program (pattern from rome's wavepath-livekit.conf)
+```ini
+[program:summercms-demo-livekit]
+command=/usr/local/bin/livekit-server --config /srv/summercms-demo/livekit.yaml
+directory=/srv/summercms-demo
+user=summercms-demo
+autostart=true
+autorestart=true
+startsecs=5
+stopsignal=INT
+stopwaitsecs=10
+redirect_stderr=true
+stdout_logfile=/var/log/supervisor/summercms-demo-livekit.log
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| bm SPA: one channel + DMs, host bootstrap | Multi-channel sidebar over chatchannels + host bootstrap | This phase | New store shape (`channels[]` replaces `general`) |
+| Pinia stores | `useState`/module composables | 19.4.1 | No Pinia in layers |
+| LiveKit port range | UDP mux single port | LiveKit ≥1.x | One firewall hole |
+| livekit-client `/rtc` | `/rtc/v1` with v0 fallback | client 2.17+ (per community reports) | Test against server 1.13.1 |
+
+**Deprecated/outdated:**
+- The sibling `../sm-chat-plugin`, `../sm-chatchannels-plugin` and `../sm-chatbots-plugin` trees are behind origin. Treat origin `main` as the truth.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `livekit-client` (latest 2.x) connects to LiveKit server 1.13.1 through `/livekit` prefix behind Cloudflare | Pitfall 8 | Calls fail; fallback: dedicated `lk.demo.summercms.io` host (DNS + cert) |
+| A2 | UDP mux + ICE-TCP without TURN is enough for "typical NAT'd browsers" | Alternatives | Some corporate networks fail; fallback: embedded TURN on UDP 3478 / TLS 5349 with a DNS-only hostname |
+| A3 | Chat `GetGroupChat` on alias `channel` lazy-joins a channel member as participant (README statement; not traced through the HTTP handler this session) | Pattern 1 | Users cannot post in channels; fallback: bootstrap adds participants |
+| A4 | Forum `PostSaving` and chat `MessageSaving` names/shape | Pattern 2 | Discretion area; any additive shape is fine |
+| A5 | Persona users without passwords are acceptable (no login) | Seeding | None material |
+| A6 | Centrifugo v6 namespace option names copied from bm's running config are correct for the demo's config format (`channel.proxy.subscribe` + `subscribe_proxy_enabled`, unlike bm's named `proxies`) | Pitfall 5 | Subscribes denied; `centrifugo checkconfig` + `defaultconfig` comparison catches most |
+| A7 | `livekit-client` is the official LiveKit package (name from chatvideo README/training, registry repo github.com/livekit/client-sdk-js) | Legitimacy | Human-verify checkpoint covers it |
+
+## Open Questions
+
+1. **Is the admin public or not?**
+ - What we know: ROADMAP's goal says "The admin backend is not public". 19.4.1-02's DECISION CHANGE (user, 2026-10-20) made the admin public at `/smbackend` with a login required, throttled to 5/min, while `/backend` returns 404. SC-3 says "`/backend` is not reachable". That holds literally.
+ - Recommendation: keep `/smbackend` as the user decided, and confirm in the plan's checkpoint.
+2. **How do admins delete chat messages and forum posts (D-17)?**
+ - What we know: no chat-family plugin has admin screens.
+ - Options:
+ - (a) A demo moderator user group. `DemoContext.CanModerateMessage` (delete in group conversations) and the forum resolver's `CanManage` grant deletion. bm's `DeleteDialog` already supports moderators, and admins add users to the group in the user admin.
+ - (b) cabana list screens in `golem15.demo` or `sm-moderation-plugin` over chat messages and forum posts, with delete actions. That puts chat/forum coupling into moderation, or app code into demo.
+ - Recommendation: (a) now, and (b) as a backlog item.
+3. **Anonymous `/forum`.** D-14 does not say whether anonymous visitors see the forum, and the forum API is JWT-only. Recommendation: use the D-04/D-18 pattern (description plus "register to read and post") unless the user wants a public read endpoint in `golem15.demo`.
+4. **D-02 scope vs. bm reality.** bm has no threads, pins, multi-channel list or link embeds. Recommendation:
+ - Threads: replies through `/messages/{id}/replies`, plus `PUT /threads/{rootId}/mute`.
+ - Pins: chatchannels pins API.
+ - Link embeds: linkified URLs. No unfurl backend exists, so no preview card. Add a slot or extension point for host embed renderers (bm's entity embeds become a host extension).
+
+ Confirm with the user that link embeds without previews is enough.
+5. **New git.golem15.com repos.** `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` and `sm-moderation-plugin` must be created on git.golem15.com (as for vue-oauth-layer in 32.1). The layers must be public or pnpm-installable from the build machine (existing layers install via `git+https`). The plan should start with a user-run or token-backed repo creation step.
+6. **Firewall change on a shared production host.** Opening the LiveKit ports through Vuurmuur needs the user's go-ahead. Plan it as a `checkpoint:human-action`.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go | all Go repos | ✓ | go1.27.2 | — |
+| Docker | testcontainers tests | ✓ | 29.9.1 | `-short` tests |
+| Node | layers, vue-demo-app | ✓ | v24.22.0 local (rome site runs Node 22.22.0) | — |
+| pnpm | layers | ✓ | 12.14.0 | — |
+| livekit-server (local) | dev.sh video, tests | ✓ | 1.13.1 | `classes.FakeRooms` in Go tests |
+| livekit-server (rome) | D-10 | ✓ binary present | 1.13.1 | — |
+| centrifugo (local + rome) | realtime | ✓ | v6.5.1 | null driver |
+| rome free ports 7890-7892 | demo LiveKit | ✓ (not listening today) | — | other free ports |
+| rome firewall opening | media | ✗ (Vuurmuur rule needed) | — | none for media; blocks calls |
+| ssh rome | deploy | ✓ (`debian@10.8.3.118 -p 2222`, passwordless sudo) | — | — |
+
+**Missing dependencies with no fallback:** the Vuurmuur rule for the demo LiveKit media ports (user-approved action).
+**Missing with fallback:** none.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework (Go) | `go test` (testcontainers Postgres for full runs; `-short` skips Docker) |
+| Framework (layers) | `node --test` (existing layers); **vitest 3.2.7** recommended for `vue-chat-layer` (bm's chat tests are vitest, `vi.mock`) |
+| Framework (app site) | vitest 3.2.7 (`sm-demo-app/vue-demo-app/vitest.config.ts`) |
+| Quick run (plugin) | `go vet ./... && go test -short ./...` in the plugin checkout |
+| Quick run (demo) | `cd ../sm-demo-app && go vet ./... && go test -short ./...` |
+| Full suite (demo) | `scripts/check-all.sh` (Go app + summercms_mcp tag, user/journal plugin tests, vitest, sync-roadmap, check-pins, build release, smoke, check-deploy, browser checks, check-live) |
+
+### Phase Requirements → Test Map
+| Req | Behavior | Test Type | Automated Command | File Exists? |
+|-----|----------|-----------|-------------------|-------------|
+| SC-1 | go.mod requires tags for all chat-family modules and summer.yaml lists them | unit | `go test -run TestGoModRequiresTags .` (sm-demo-app) | ✅ (extend wantPlugins) |
+| SC-1 | checkouts on required tags | script | `scripts/check-pins.sh` | ✅ |
+| SC-1 | every plugin activates, routes mounted (`/_chat/api/v1`, `/_chatchannels/api/v1`, `/_chatvideo/api/v1`, `/_chatbots/api/v1`, `/_forum/api/v1`, `/_userfriends/api/v1`, `/_herald/api/v1`, `/_demo/api/v1/chat`) | unit | `go test -short -run TestBootAllPlugins .` | ✅ extend |
+| SC-1 | release builds without go.work | script | `scripts/build.sh release` | ✅ |
+| SC-1 | demo:seed fills chat/channels/threads/reactions/pins/forum/friends EN+PL, idempotent | integration (Docker) | `go test -run 'TestSeed' .` | ❌ Wave 0: `seed_chat_test.go` |
+| SC-1 | reset wipes chat family and reseeds | integration | `go test -run TestResetKeepsPlans .` | ✅ extend |
+| D-06 | demo context membership, live membership, banned user excluded; channel adapter resolves | unit/integration | `go test -run 'TestDemoChat' ./plugins/demo/...` | ❌ Wave 0 |
+| D-07 | members cannot create channels (403/404 on POST channels) | integration | `go test -run TestDemoChannelsSeededOnly .` | ❌ |
+| D-11 | `video/start` on a channel conversation → 422; on DM → 201 with FakeRooms | integration | `go test -run TestDemoCallsDMOnly .` | ❌ |
+| Pitfall 4 | `/api/realtime/token` 200 for any member, 401 without JWT | unit | `go test -run TestRealtimeRoutes .` | ✅ extend |
+| D-18 | seam: no listener → identical behaviour; rejecting listener → 422 errors.content / errors.body, nothing persisted, no event | unit + Docker | `go test -short ./...` + `go test -run 'TestMessageSaving|TestPostSaving' .` in sm-chat-plugin / sm-forum-plugin | ❌ |
+| D-18 | PHP parity untouched | unit | existing `php_method_map_test.go`, `golden_test.go` | ✅ |
+| D-17 | filter: words (case-insensitive, word boundary), links (scheme/host/bare domain), settings round-trip | unit | `go test ./...` in sm-moderation-plugin | ❌ new repo |
+| D-05 | demo commands listed and dispatched ephemeral | unit | `go test -run TestDemoCommands ./plugins/demo/...` | ❌ |
+| Centrifugo | namespaces chat/user/video/presentations valid | script | `scripts/check-deploy.sh` (`centrifugo checkconfig`) | ✅ extend |
+| LiveKit | config valid, ports agree across supervisor/nginx/config | script | `scripts/check-deploy.sh` | ❌ extend |
+| D-01/02 | store: channels, DMs, threads, unread, mentions, reactions, typing, events parsing | unit | `pnpm test` in vue-chat-layer (ported `chat.test.ts`, `chatEvents/Format/Text/Unread.test.ts`) | ❌ port |
+| D-13 | call page state machine (join/leave/mute/camera, token errors) with a mocked Room | unit | `pnpm test` in vue-chatvideo-layer | ❌ |
+| D-14/15 | forum and friends API clients (URL building, error envelopes) | unit | `pnpm test` in each layer | ❌ |
+| SC-2/3 | live: /chat prompt anonymous, /forum, CTA, /backend 404, /_chat 401 without JWT, `/livekit/rtc/validate` reachable, /ws 101 | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend |
+| SC-2 | two-browser chat realtime + DM call | manual (browser) | `scripts/check-local.sh chat` (Playwright, as check-remote.cjs) + human UAT on live | ❌ |
+
+### Sampling Rate
+- **Per task commit:** the quick run of the repo touched (`go test -short ./...` or `pnpm test`)
+- **Per wave merge:** sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
+- **Phase gate:** `scripts/check-all.sh` green, then `/msd-verify-work`
+
+### Wave 0 Gaps
+- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, adapter, resolvers, DM-only calls, bootstrap JSON)
+- [ ] `sm-chat-plugin` seam tests; `sm-forum-plugin` seam tests
+- [ ] `sm-moderation-plugin` package + tests (new repo)
+- [ ] `vue-chat-layer` vitest config + ported bm tests (rewrite `setActivePinia(createPinia())` setup for the composable store)
+- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`
+- [ ] `scripts/dev.sh`: start a local `livekit-server` (dev keys, ports 7890-7892 or `--dev`) like Centrifugo; `scripts/check-local.sh chat`
+
+## Security Domain
+
+### Applicable ASVS Categories
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | `jwt.auth` on every chat-family route; Turnstile on register (existing) |
+| V3 Session Management | yes | user JWT + refresh; realtime tokens (lighthouse, TTL 3600); LiveKit join tokens (HS256, `token_ttl` 900) |
+| V4 Access Control | yes | Host resolvers (demo context membership, live membership), chat participant checks, IDOR→404 in plugins, DM-only calls via `max_participants: 2` |
+| V5 Input Validation | yes | Plugin validators + new pre-save moderation seam; forum Markdown sanitized on write (`breakwater.Render`); chat content rendered as text (bm `chatText`), never `v-html` of raw content |
+| V6 Cryptography | yes | No hand-rolled crypto: LiveKit keys and VAPID keys generated (`openssl rand`, `herald:vapid-generate`), stored in `.env` 0640 |
+| V12 Files | yes | Chat attachments private, served only to participants; upload body limit 1 MiB default (keep, nginx `client_max_body_size 2m`) |
+
+### Known Threat Patterns
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Spam/abuse in public chat and forum | Tampering / DoS | Moderation filter (pre-save), plugin buckets (`chat-api` 120/min, `chat-dm` 10/min, `chat-upload` 20/min, `forum-api`, `video-initiate` 5/min), Turnstile, reset job, user ban |
+| Stored XSS via messages/posts/names | Tampering | Text rendering for chat; server-sanitized `content_html` only for forum; no `v-html` on user fields |
+| Subscribing to others' conversations | Information disclosure | Centrifugo subscribe proxy → `AuthorizeChat` (participants only), `AuthorizeVideo`, user authorizer (`user:{self}`) |
+| LiveKit key co-tenancy with WavePath | Elevation | Separate LiveKit instance and keys (not a second key on WavePath's server) |
+| Origin IP disclosure via ICE | Information disclosure | Accepted (rome already exposes services on its IP); document in DEPLOY.md |
+| Call abuse (long or large rooms) | DoS | `max_duration` 900 + reconcile, `room.max_participants` 4, `empty_timeout`, `video-initiate` bucket |
+| Upload abuse | DoS | chat pending quota (10 files), prune job hourly, 1 MiB body limit |
+| Secrets in logs/payloads | Information disclosure | Same discipline as 19.4.1: keys only by name; never in `NUXT_PUBLIC_*` except public ones (VAPID public key) |
+
+## Sources
+
+### Primary (HIGH confidence)
+- Repo reads this session:
+ - `sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots}` (README, classes, api, boot, events, fixture tests)
+ - `sm-{chatvideo,forum,herald,userfriends}-plugin` (README, config, classes, api)
+ - `sm-bm-app/plugins/jz/bm/{chat.go,classes/chat_context.go,classes/chat_sync.go,controllers/api/chat.go,realtime_access.go}`
+ - `sm-bm-app/frontend/src/{api/chat.ts,api/types.ts,stores/chat.ts,lib/chatEvents.ts,components/chat/*}`
+ - `sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules,DEPLOY.md,README.md,scripts/check-pins.sh,go_mod_test.go,boot_test.go,plugins/demo/*,deploy/*,config/*}`
+ - `vue-{faq,paymentgateway,oauth}-layer`
+ - `sm-demo-app/vue-demo-app`
+ - `summercms.go/modules/{festival,lighthouse,fetchguard,party,conga}` README and source
+- Scratch compile and tests of all seven plugins against framework v0.5.1 (go build/vet/test -short)
+- `git ls-remote` per plugin repo; npm registry (`npm view`); msd package-legitimacy seam
+- Read-only ssh probe of rome (ports, supervisor programs, LiveKit config with keys redacted, Vuurmuur service, iptables)
+
+### Secondary (MEDIUM confidence)
+- LiveKit config sample: https://raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml
+- livekit-client URL handling: https://raw.githubusercontent.com/livekit/client-sdk-js/main/src/api/utils.ts
+
+### Tertiary (LOW confidence)
+- [upd.dev patch notes on /rtc/v1 fallback](https://upd.dev/slopus/happy/compare/4533ef5666..c97ea347f1.patch)
+- [LiveKit community: v1.13.1 signalling drops behind nginx ssl_preread](https://community.livekit.io/t/livekit-v1-13-1-signaling-ws-drops-mid-call-with-client-request-leave-full-reconnect-media-udp-unaffected-behind-nginx-ssl-preread/1481)
+- [client-sdk-js PR #1785 (new rtc path)](https://app.semanticdiff.com/gh/livekit/client-sdk-js/pull/1785/overview)
+- [livekit-client 2.18.4 release notes](https://newreleases.io/project/npm/livekit-client/release/2.18.4)
+- [LiveKit firewall tips](https://kb.livekit.io/articles/1724892785-establishing-media-connection-firewall-troubleshooting)
+
+## Suggested Plan Split (for the lean-planning confirmation)
+
+| # | Scope (one line) | Repos |
+|---|------------------|-------|
+| 01 | Shared plugin release: fast-forward siblings; fix chatvideo/herald/userfriends go.mod; chat v0.1.4 and forum pre-save seams; new `sm-moderation-plugin`; tag all eight (with go-ahead) | 8 plugin repos |
+| 02 | Demo backend: oauthserver bump (folded todo); mount 8 plugins (submodules, go.work, go.mod, summer.yaml); demo context + channel adapter + resolvers; bootstrap endpoints; realtime guard; chatbots commands; moderation wiring; seed + reset; Centrifugo namespaces; local LiveKit in dev.sh | sm-demo-app |
+| 03 | `vue-chat-layer` v0.1.0 (port, multi-channel, threads, pins, command picker, shared realtime) and its use in vue-demo-app (`/chat`, floating panel, Chat tour card, EN/PL) | vue-chat-layer, vue-demo-app |
+| 04 | `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` v0.1.0 and their pages and tour cards in vue-demo-app; herald VAPID config | 3 layers, vue-demo-app, sm-demo-app |
+| 05 | rome deploy: LiveKit program, Vuurmuur rule (checkpoint), nginx locations, `.env` keys (LiveKit, VAPID), Centrifugo restart, release, DEPLOY.md, check-deploy and check-live extensions | sm-demo-app, rome |
+| 06 | Unit tests (Go, layers, site) and live verification incl. a two-browser chat and DM call | all |
+
+Plans 03 and 04 can run in parallel after 02. 05 can merge into 04 if the user prefers fewer plans.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH. Versions come from go.mod files and the registry, and compatibility was compiled this session.
+- Architecture: HIGH for the backend glue (code read), MEDIUM for the layer design (new multi-channel work).
+- LiveKit deployment: MEDIUM. rome was probed, but the client path behaviour on server 1.13.1 behind Cloudflare is assumed.
+- Pitfalls: HIGH for repo, realtime and seed issues, MEDIUM for the network ones.
+
+**Research date:** 2026-10-28
+**Valid until:** 2026-11-27 for the repo state (re-check `git ls-remote` before tagging); 7 days for the livekit-client version pick
diff --git a/.planning/phases/20-demo-instance-with-every-shared-plugin/20-VALIDATION.md b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-VALIDATION.md
new file mode 100644
index 00000000..f9ac1cdf
--- /dev/null
+++ b/.planning/phases/20-demo-instance-with-every-shared-plugin/20-VALIDATION.md
@@ -0,0 +1,80 @@
+---
+phase: "20"
+slug: "demo-instance-with-every-shared-plugin"
+# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
+# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
+status: draft
+nyquist_compliant: false
+wave_0_complete: false
+created: "2026-10-28"
+---
+
+# Phase 20 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | go test (plugins, sm-demo-app; testcontainers Postgres on full runs); vitest 3.2.7 (vue-demo-app, vue-chat-layer); node --test or vitest (other layers) |
+| **Config file** | `../sm-demo-app/vue-demo-app/vitest.config.ts`; new layers: none — Wave 0 installs |
+| **Quick run command** | `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer |
+| **Full suite command** | `cd ../sm-demo-app && scripts/check-all.sh` |
+| **Estimated runtime** | ~30 seconds quick, ~300 seconds full |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run the quick run command of the repo touched
+- **After every plan wave:** Run sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
+- **Before `/msd-verify-work`:** Full suite must be green
+- **Max feedback latency:** 60 seconds
+
+---
+
+## Per-Task Verification Map
+
+Filled by the planner from the plans; source map in `20-RESEARCH.md` § Validation Architecture.
+
+| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| 20-xx-xx | — | — | SC-1..SC-4, D-01..D-18 | — | see RESEARCH Security Domain | unit/integration/live | see RESEARCH test map | ❌ W0 | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, channel adapter, resolvers, DM-only calls, bootstrap JSON)
+- [ ] `sm-chat-plugin` and `sm-forum-plugin` pre-save seam tests
+- [ ] `sm-moderation-plugin` package and tests (new repo)
+- [ ] `vue-chat-layer` vitest config and ported bm chat tests (composable store setup)
+- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`
+- [ ] `scripts/dev.sh` local livekit-server; `scripts/check-local.sh chat`
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Two-browser chat realtime and a DM video call on the live demo | SC-2, D-13 | Real media over NAT'd browsers and the rome firewall | Two accounts on https://demo.summercms.io: send a message, see it arrive live; start a DM call, see and hear both tiles, mute, leave |
+| Vuurmuur firewall rule on rome | D-10 | Shared production host change | Apply with the user's go-ahead; confirm with `/livekit/rtc/validate` and a call |
+
+---
+
+## Validation Sign-Off
+
+- [ ] All tasks have `` verify or Wave 0 dependencies
+- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
+- [ ] Wave 0 covers all MISSING references
+- [ ] No watch-mode flags
+- [ ] Feedback latency < 60s
+- [ ] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending