docs(19.1): research phase domain

This commit is contained in:
Jakub Zych
2026-10-17 23:36:34 +02:00
parent 5f76e5bbd2
commit f179ec060c

View File

@@ -0,0 +1,915 @@
# Phase 19.1: Chat satellites - Research
**Researched:** 2026-10-17
**Domain:** PHP-to-Go port of five shared `sm-*-plugin` satellites (chatbots, chatvideo, forum, herald, userfriends) on lighthouse / lagoon / festival / conga / fetchguard, plus three locked third-party libraries (webpush-go, livekit/server-sdk-go, bluemonday)
**Confidence:** HIGH for frozen PHP contracts, analog plugin layout, live-PHP schema (migrations Read this session), and framework seams; MEDIUM for LiveKit JWT claim byte-identity vs `protocol/auth` (golden test is the gate); LOW for live Wavepath MySQL column types (this session: `ERROR 1698 Access denied for user 'root'@'localhost'`)
<user_constraints>
## User Constraints (from CONTEXT.md)
**CRITICAL:** Locked decisions from `/msd:discuss-phase` are NON-NEGOTIABLE. Copied verbatim.
### Locked 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 (`<script>`, `javascript:`, `<iframe>`, `onerror`), GFM tables, code blocks, links and autolinks.
- Byte-identical HTML is not required.
### Claude's Discretion
- Go package, type and identifier names for the services, seams, events and jobs. Exact generic API paths within D-05.
- How chatbots resolves bot bearer tokens through `BotAuthResolver` (deny-all default) on the generic API, and where `is_bot` and the bot flag show up in serializers. Follow Wavepath's `BotRestReachTest` and `WaveBotAuthResolverTest` for the expected behavior.
- How chatvideo addresses a call in the generic API (by conversation id, or by context alias through chat's registry), as long as Wavepath's group-video payloads are reproducible.
- Queue names and job shapes on `conga` (herald send, video reconcile).
- Lang strings port to phrasebook in EN and PL. Plugin display names are neutral.
- Whether the D-12-style alias rewrite for satellite morph columns extends chat's adoption command or each plugin ships its own.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Expect userfriends and forum early (no external services), then chatbots, then herald and chatvideo (new dependencies and their decision notes).
- Run the security-review agent. The phase adds public authenticated APIs, an outbound push sender, a token issuer for a media server, an HTML sanitizer boundary and GDPR delete cascades.
### Deferred Ideas (OUT OF SCOPE)
- The framework Markdown/sanitizer module (D-17): delivered by a separate `/msd-quick` task in `summercms.go`, not by this phase's plans.
- A `vue-community-layer` or similar frontend components for friends, forum, video and push: once a client other than Wavepath wants them.
- Wavepath's `/_wave` routes and host seam implementations: with the Wavepath app port.
### Reviewed Todos (not folded)
- The todo matcher returned only keyword noise (translate editor, cabana richeditor, faq port, mailblocker). None concern the satellites, so none were folded.
</user_constraints>
## Project Constraints (from CLAUDE.md)
Treat these with the same authority as locked CONTEXT decisions.
- One Go module for the framework (`git.golem15.com/golem15/summercms`); plugins live in their own `sm-*-plugin` repos (`git.golem15.com/golem15/sm-<name>-plugin`), plugin ID and package keep the plain names (`golem15.chatbots` / package `chatbots`).
- Compiled plugins only. A plugin implements `pact` capability interfaces; `party` runs `Register` then `Boot`; `backpack.App` is the container. No runtime plugin loading.
- Request path: `surf` + `bouncer` + `towel` + `wire`. Response shapes are not "improved" during a port; the existing frontend's requests define the contract.
- Data: GORM on Postgres only; per-plugin gormigrate; YAML via `goccy/go-yaml` never `gopkg.in/yaml.v3`.
- Standard library first. A new dependency needs a research doc or a phase/decision note (`.planning/research/STACK.md`). CONTEXT already locked: webpush-go (D-09), livekit/server-sdk-go (D-13), bluemonday (D-16). Goldmark is already in `go.mod`.
- Framework code and plugin READMEs never name a consuming application (use `blog` / `acme`). Wavepath may be named in this RESEARCH.md as the frozen PHP source.
- Module README structure and identifier-checked docs; API/config/CLI changes update README + `docs/` in the same commit.
- Lean planning: few large plans; present suggested plan count and wait for confirmation before writing PLAN.md files.
- Unit tests are the last plan of every phase.
- Update `.planning/STATE.md` only via `msd_run query state.*` handlers.
- No co-author tags. One logical change per commit; planning docs and code in separate commits.
- Stage files by path (never `git add -A`); leave other sessions' uncommitted edits alone.
- Don't push, tag or deploy without the user's go-ahead.
- `go vet` and `go test ./...` green at every commit. Docker-backed tests skip under `-short`. `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()`.
- Plugin layout: WinterCMS directories as Go packages; `models/` is a leaf (`.planning/notes/plugin-layout-winter-directories.md`).
- Core plugin contracts stay non-breaking unless the user asks (`.planning/notes/core-plugins-own-repos.md`).
- Commit and push the plugin repo first, then the pointer bump in an app as a separate commit.
## Summary
Phase 19.1 ports five Wavepath-frozen PHP plugins into five new `sm-*-plugin` repos, following the Phase 19 analog (`sm-chat-plugin` / `sm-chatchannels-plugin`): Winter directories, one squashed `CREATE TABLE IF NOT EXISTS` baseline per plugin, generic `/_x/api/v1`, deny-all resolvers with a Boot Warn, a test-only `acme` host, lighthouse `Bind`/`Emit`, and festival user-lifecycle listeners. Frozen HEADs this session: chatbots `1ecf8c8bb6b81102508c0b8e3a999aaacf9d4963`, chatvideo `25135b46c1571a6bc722fdc094157176d70ad717`, forum `ad10b54eb7be8e11de8e55dd85cfa0c92bbb15c9`, herald `a749f83912a7c88994eb5341740a60b3488463f9`, userfriends `182b5145561f0609ab54f59e24258e12a96332b1`.
The planner must treat this as five tracer-first vertical slices (userfriends → forum → chatbots → herald → chatvideo) plus a last-plan PHP-method map. Do not port Wavepath `/_wave` controllers, guest-call, CallNotifier, flipCallCardToEnded, or a forum-owned markdown renderer. D-17 has **not** landed: there is no dedicated markdown module; `seaglass` is the richeditor UGC policy (nofollow off, `tel:` allowed); `cabana.RenderMarkdown` is an admin regex gate without GFM. Forum execution **blocks** until the quick task exports `Render(markdown)` and `Purify(html)` with a named forum policy. ROADMAP SC2 still says "Chatbots is wired to `sm-golem-plugin`" — drop it in the first planning commit; PHP `$require` is exactly `Golem15.Chat` and `Golem15.User`. SC3 is already met (fonoteka PHP tree is byte-identical to Wavepath at 32 files, `Plugin.php` cmp equal, commit `182b514`).
**Primary recommendation:** Create the five repos on the P19 layout. Write three decision notes **before** herald/chatvideo/forum-sanitizer code: webpush-go v1.4.0 (`Options.HTTPClient` is the `Do` interface — pass `*fetchguard.Client` directly), livekit **v2** module path `github.com/livekit/server-sdk-go/v2` v2.19.0 (flag pion / media-sdk / buildkit; wrap RoomService behind an interface; golden-test PHP JWT claims rather than trusting SDK grant JSON), bluemonday v1.0.27 as a **named forum policy** (not `seaglass`). Do not use `flare` for herald (D-09 locked webpush-go; different config keys and command name). Do not call `lighthouse.ChannelID` for `video:session:{id}` (segment 1 is `"session"` → 0).
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| userfriends friendships / groups | API / Backend | Database / Storage | Typed service + `/_userfriends/api/v1`; GDPR on `user.Deleting` |
| FriendContext chat adapter | API / Backend | — | Soft optional-capability registration with `sm-chat-plugin` (D-08) |
| forum categories / threads / posts / reads | API / Backend | Database / Storage | `ForumAccessResolver` deny-all; `DeleteContext` generic cascade (D-08) |
| Markdown render + HTML purify | API / Backend | — | D-17 framework module (not this phase); forum consumes it on write (D-18) |
| chatbots slash-command registry / dispatch | API / Backend | Database / Storage | `bot_flags`; invoker is JWT actor (CMD-06); no Golem |
| herald subscriptions + send | API / Backend | Database / Storage | conga job + fetchguard PublicOnly + PresenceGate deny-all |
| Web Push network | API / Backend | — | webpush-go through fetchguard; prune 404/410 |
| chatvideo sessions + LiveKit tokens | API / Backend | Database / Storage | RoomService + HS256 tokens; TrustedMode HTTP to operator LiveKit URL |
| video realtime | API / Backend | — | lighthouse `video` namespace; `user:{id}` for incoming |
| video reconciler | API / Backend | Database / Storage | conga periodic 5 min; LiveKit empty/gone or `max_duration` only (D-14) |
| Vue friends/forum/video/push UI | Browser / Client | — | Deferred (`vue-community-layer`) |
| Wavepath `/_wave` + host seams | API / Backend | — | Out of scope (Wavepath app port) |
<phase_requirements>
## Phase Requirements
ROADMAP lists Requirements as TBD. Map the four success criteria and locked D-IDs so the planner can attach IDs later. Omit a REQUIREMENTS.md ID table.
| Success criterion (ROADMAP) | Locked support | Research support |
|-----------------------------|----------------|------------------|
| SC-19.1-1 Each plugin ports schema, models, routes and events 1:1 against its PHP original | D-01, D-02, D-05, D-15 | Frozen inventory below; generic `/_x/api/v1` not `/_wave` |
| SC-19.1-2 Herald decision note names VAPID/Web Push dep before code. (ROADMAP also says "Chatbots is wired to sm-golem-plugin" — **drop** that clause) | D-09, D-13, D-16 | PHP chatbots `$require = ['Golem15.Chat', 'Golem15.User']` [VERIFIED: chatbots/Plugin.php:21]. Add LiveKit + bluemonday notes |
| SC-19.1-3 Userfriends checked against fonoteka first | D-01 userfriends `182b514` | Already met: 32 files each, `Plugin.php` identical, `diff -rq` empty this session |
| SC-19.1-4 Unit tests in the phase's last plan | D-04 | PHP method counts: chatbots 22, forum 25, herald 14, userfriends 31, chatvideo 0 in-plugin |
First planning commit also patches ROADMAP: Repos line adds `summercms.go` for D-17; SC2 drops Golem and names webpush-go / LiveKit / bluemonday notes; SC3 marked already met.
</phase_requirements>
<standard_stack>
## Standard Stack
### Core (new in this phase — each needs a `.planning/notes/` decision note before code)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `github.com/SherClockHolmes/webpush-go` | v1.4.0 (2025-01-02, MIT, Go 1.13) | VAPID + aes128gcm send | D-09 locked. Official source [CITED: https://github.com/SherClockHolmes/webpush-go/blob/v1.4.0/webpush.go]. Transitive: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto` |
| `github.com/livekit/server-sdk-go/v2` | v2.19.0 (2026-10-08, Apache-2.0, Go 1.26.8) | RoomService + token helpers | D-13 locked official SDK. Current README install is **`/v2`** [CITED: https://github.com/livekit/server-sdk-go]. v1 path frozen at v1.1.8 (2024-01-25) — do not pin v1. Tokens actually live in `github.com/livekit/protocol/auth` |
| `github.com/microcosm-cc/bluemonday` | v1.0.27 (already in framework `go.mod`) | HTML allowlist | D-16 locked. Already used by `seaglass`. Forum needs a **named dedicated policy**, not `seaglass.UGCPolicy` |
### Supporting (already in the framework / analog plugins)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `github.com/yuin/goldmark` | v1.8.6 [VERIFIED: go.mod:34] | Markdown → HTML | D-16/D-17. `extension.GFM`; **do not** pass `html.WithUnsafe()` (default omits raw HTML = PHP `html_input=strip`) |
| `github.com/golang-jwt/jwt/v5` | v5.3.1 [VERIFIED: go.mod:16] | HS256 LiveKit tokens + VAPID JWT (shared with webpush-go) | Golden LiveKit claims may be minted with jwt/v5 to match PHP byte-for-byte if SDK grant JSON differs |
| `fetchguard` | in-tree | SSRF dial-time | D-10 PublicOnly for push; D-13 TrustedMode for operator LiveKit URL |
| `lighthouse` | in-tree | realtime | D-15 `video` namespace; `user:{id}` incoming; analog chat authorizer (do **not** use `ChannelID` on 3-segment channels) |
| `conga` / River | in-tree / STACK | jobs | D-11 one send job; D-14 periodic 5 min reconciler |
| `festival` | in-tree | typed events | D-03 GDPR; D-08 group lifecycle |
| `lagoon` / gormigrate | in-tree | Postgres | squashed IF NOT EXISTS baselines |
| `surf` / `bouncer` / `cooler` | in-tree | routes, jwt.auth, named buckets | D-05 |
| `testcontainers-go` | v0.44.0 (STACK.md) | plugin harness | D-04 |
| `flare` | in-tree | stdlib Web Push | **Do not use for herald.** D-09 locked webpush-go. flare config is `push.*`, command is `websockets:generate-vapid-keys` [VERIFIED: modules/flare/commands.go:26] |
### Alternatives Considered (locked out)
| Instead of | Could Use | Why not |
|------------|-----------|---------|
| webpush-go | `flare.VAPIDPusher` | D-09 locked; different config/command namespace; flare takes `*http.Client` not the `HTTPClient` interface |
| livekit SDK | hand-rolled jwt/v5 + Twirp HTTP | D-13 locked official SDK; jwt/v5 still allowed **inside** the plugin's LiveKit wrapper for the golden claim test |
| bluemonday forum policy | `seaglass` UGC | seaglass explicitly turns **off** nofollow, allows `tel:`, and does not add `target=_blank` [VERIFIED: modules/seaglass/seaglass.go:22-27] |
| goldmark GFM module in forum | `cabana.RenderMarkdown` | cabana uses `goldmark.New()` with no GFM and a regex gate [VERIFIED: modules/cabana/field_markdown.go:14-34]; D-17 forbids a forum-owned renderer |
| LiveKit v1 module path | `github.com/livekit/server-sdk-go` without `/v2` | Official README now installs `/v2`; v1 is stale. Decision note records the v2 path as the official current module |
**Installation (plugin go.mod, after decision notes):**
```bash
# sm-herald-plugin
go get github.com/SherClockHolmes/webpush-go@v1.4.0
# sm-chatvideo-plugin
go get github.com/livekit/server-sdk-go/v2@v2.19.0
# forum consumes framework D-17 module (bluemonday + goldmark already in summercms.go)
```
</standard_stack>
## Package Legitimacy Audit
`msd-tools package-legitimacy check` only supports `--ecosystem npm|pypi|crates`. A crates check of the three Go import paths returned `SLOP` / `does-not-exist` (wrong ecosystem). That verdict does **not** apply to Go modules.
Go confirmation this session (not a legitimacy-OK tag):
- `go list -m -versions github.com/SherClockHolmes/webpush-go` → v1.4.0 current; pkg.go.dev lists MIT, jwt/v5 + x/crypto.
- `go list -m -versions github.com/livekit/server-sdk-go/v2` → v2.19.0 current; official GitHub README `go get github.com/livekit/server-sdk-go/v2`.
- `github.com/microcosm-cc/bluemonday v1.0.27` already required by `summercms.go` [VERIFIED: go.mod:24].
Do not tag these `[VERIFIED: npm registry]`. Library **names** are CONTEXT-locked. Versions are `[CITED: proxy.golang.org / pkg.go.dev]` this session. classify-confidence for `webfetch --verified` returned **LOW**; planner should treat version pins as confirmation checkpoints inside the decision-note tasks, not as reopenable library choices.
## Architecture Patterns
### System Architecture Diagram
```
JWT actor
│
▼
surf /_x/api/v1 ──jwt.auth + named buckets──► plugin Service
│ │
│ ├─ lagoon/GORM (Postgres)
│ ├─ festival (user.Deleting, group.*)
│ ├─ lighthouse.Emit / Bind
│ └─ conga enqueue (same Tx)
│
├─ /_userfriends → Friendship/Group services → optional chat.Registry adapter
├─ /_forum → ForumService + ForumAccessResolver
│ └─ D-17 Render/Purify (framework) ──► content_html on write
├─ /_chatbots → SlashCommandRegistry + CommandDispatcher + BotAuthResolver
├─ /_herald → SubscriptionService
│ └─ SendEvent job → PresenceGate? → webpush-go
│ └─ fetchguard.PublicOnly Do
└─ /_chatvideo → VideoService
├─ LiveKit wrapper (TrustedMode HTTP)
├─ lighthouse video:session:{id} (parse segment 2)
└─ user:{id} video:call:incoming
```
### Recommended Project Structure
Each new repo mirrors `sm-chat-plugin` / `sm-chatchannels-plugin`:
```
sm-<name>-plugin/
├── plugin.go # ID, Requires, pact capabilities, embed config+lang
├── boot.go # deny-all default, Boot Warn, listeners, authorizer
├── routes.go # /_<name>/api/v1 + surf.BucketProvider
├── models/ # leaf
├── classes/ # services, deny-all resolvers
├── contracts/ # seams
├── events/ # festival types (userfriends, maybe others)
├── console/ # herald:vapid-generate, chatvideo:reconcile-sessions
├── jobs/ # herald send, video reconcile
├── updates/ # one squashed baseline + version ID comments
├── config/ lang/
├── harness_test.go # testcontainers, acme fixture, memory lighthouse
├── testmain_test.go # flag.Parse + testing.Short
└── testdata/goldens/
```
Plugin IDs: `golem15.chatbots`, `golem15.chatvideo`, `golem15.forum`, `golem15.herald`, `golem15.userfriends`.
Requires (PHP → Go):
- chatbots: `golem15.chat`, `golem15.user` [VERIFIED: chatbots/Plugin.php:21]
- chatvideo: `golem15.chat` (PHP also `$require` `Golem15.WebSockets` [VERIFIED: chatvideo/Plugin.php:7] → lighthouse, not a plugin require)
- forum: `golem15.user` [VERIFIED: forum/Plugin.php:23]
- herald: PHP `$require = ['Golem15.Apparatus']` [VERIFIED: herald/Plugin.php:20] → Go: **no user-model import beyond id**; no chat require
- userfriends: `golem15.user` only [VERIFIED: userfriends/Plugin.php:16]; chat is soft
### Pattern 1: Squashed idempotent baseline (P18.1 / P19 D-04)
**What:** One gormigrate ID equal to the final PHP version, `CREATE TABLE IF NOT EXISTS` + indexes matching PHP names.
**When:** Every satellite. Record demo-seed version IDs as comments, do not insert demo rows (D-02 / D-07).
**MySQL adopt vs Postgres:** enums become `varchar` + check if needed; `json`/`text` for keys; no `FIELD()` in GDPR heir query (PHP already SQLite-falls-back to `orderBy('role')` [VERIFIED: userfriends/Plugin.php:81-85]).
### Pattern 2: Deny-all seam + setter + Boot Warn (P19 D-14)
Analog: `sm-chatchannels-plugin/classes/deny_all.go` `CanAccessContext`/`CanManageContext` always false [VERIFIED: sm-chatchannels-plugin/classes/deny_all.go:16-20]. Apply to `ForumAccessResolver`, `BotAuthResolver`, `PresenceGate`. HeraldProfile default is the PHP `NeutralHeraldProfile` (app-name-only), not deny-all.
### Pattern 3: lighthouse authorizer parses segment 2
`lighthouse.ChannelID` returns segment 1 [VERIFIED: modules/lighthouse/channel.go:35-45]. `video:session:{id}` → `"session"` → 0. Chat already documents this and splits itself [VERIFIED: sm-chat-plugin/classes/authorizer.go:17-20]. Video authorizer copies that parser: three segments `video`, `session`, digits; capabilities `["prs"]` [VERIFIED: chatvideo/classes/VideoChannelAuthorizer.php:11-40].
### Pattern 4: Test-only acme host (D-04)
testcontainers Postgres, `realtime.driver=memory`, fakes for LiveKit Twirp and push HTTP, `sm-user-plugin`, plus `sm-chat-plugin` where required. Neutral fixture context alias `acme` (not FriendGroup FQCN).
### Pattern 5: Generic `/_x/api/v1` prefix
One serializer set. Actor id from token only. Mount via `lighthouse.Mount` surfaces so the app picks `jwt.auth`.
### Anti-Patterns to Avoid
- **Forum-owned goldmark/bluemonday:** D-17; block the forum plan instead.
- **Importing `seaglass` for forum posts:** opposite link policy.
- **Using `flare` for herald:** D-09.
- **`lighthouse.ChannelID` on `video:session:{id}`:** always 0.
- **Hard-requiring chat from userfriends:** PHP soft registration [VERIFIED: userfriends/Plugin.php:9-16, 40-58].
- **Wiring chatbots to Golem:** PHP never calls Golem.
- **Porting Wavepath reapIfOrphaned whole:** GuestCall, call cards, CallNotifier stay in the app (D-14).
- **Calling `html.WithUnsafe`:** would keep raw `<script>` and violate `html_input=strip`.
- **Re-rendering adopted `content_html` on read:** D-18.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Web Push encryption + VAPID | stdlib aes/gcm + ES256 JWT | webpush-go (D-09) | RFC 8291 record padding, HKDF, subscription key decode |
| LiveKit RoomService Twirp | hand-rolled protobuf client | official SDK behind an interface (D-13) | protocol + twirp already in the SDK; tests fake the interface |
| LiveKit join JWT matching PHP | trust SDK grant JSON blindly | jwt/v5 encode of the PHP payload **or** SDK + golden decode | D-13 lists exact claims |
| HTML XSS allowlist | regex (`cabana.RenderMarkdown`) | bluemonday named policy (D-16) | Journal note lists regex bypasses |
| SSRF on push endpoints | PHP `EndpointGuard` + CURLOPT_RESOLVE | fetchguard PublicOnly (D-10) | dial-time IP check closes rebind |
| Job retries | PHP queue + Cache::add OBS-01 only | conga/River (D-11) | transactional enqueue |
| Markdown GFM | roll a converter | goldmark `extension.GFM` in D-17 | already a framework dep |
**Key insight:** The three new libraries are locked; the work is adapters (fetchguard Doer, LiveKit interface + golden JWT, named bluemonday policy) and not library shopping.
## Common Pitfalls
### Pitfall 1: lighthouse.ChannelID is the wrong segment
**What goes wrong:** `video` authorizer allows nobody or the wrong session.
**Why:** ChannelID reads explode[1]; for `video:session:{id}` that is `"session"` → 0 [VERIFIED: modules/lighthouse/channel.go:35-45]. PHP explodes and uses `$parts[2]` [VERIFIED: chatvideo/classes/VideoChannelAuthorizer.php:11-21].
**How to avoid:** Copy `parseChatConversationChannel` (three named segments).
**Warning signs:** memory-driver subscribe tests pass with session id 0.
### Pitfall 2: D-13 deleteRoom swallow vs PHP LiveKitService
**What goes wrong:** planner copies PHP `LiveKitService::deleteRoom` which does **not** swallow [VERIFIED: chatvideo/classes/LiveKitService.php:34-37]. CONTEXT D-13 says missing-room is a no-op. The swallow lives in Wavepath `CommunityVideoService::endSession` try/catch [VERIFIED: wavepath/classes/video/CommunityVideoService.php:204-220]. `participantCount` swallows to 0 in the plugin [VERIFIED: chatvideo/classes/LiveKitService.php:49-56].
**How to avoid:** Go LiveKit wrapper swallows delete/missing-room per D-13 (locked), even though the PHP plugin method does not.
**Warning signs:** reconciler tests fail when the fake returns Twirp not-found.
### Pitfall 3: D-14 reconciler is not Wavepath ReconcileVideoSessions
**What goes wrong:** chatvideo imports GuestCallService, flips chat call cards, resyncs stuck cards, CallNotifier close-push.
**Why:** Wavepath command does all of that [VERIFIED: wavepath/console/ReconcileVideoSessions.php:23-50]. D-14 keeps only LiveKit empty/gone + `max_duration`. `start_grace_seconds` is Wavepath-only (`config('chatvideo.start_grace_seconds', 120)` [VERIFIED: CommunityVideoService.php:351]) — **not** in chatvideo `config/config.php`.
**How to avoid:** Generic service: if room missing/empty or `started_at + max_duration` elapsed → end session + deleteRoom (swallowed) + `video:call:ended`. No card metadata, no guests.
**Warning signs:** chatvideo go.mod imports a host plugin.
### Pitfall 4: video-initiate lives on a dropped route
**What goes wrong:** generic start has no 5/min bucket.
**Why:** PHP `video-initiate` is only on `/api/video/initiate` [VERIFIED: chatvideo/routes.php:11-27], which D-07 drops. Wavepath community video inherits `throttle:wave-api` (240/min). D-05 still carries `video-initiate` 5/min onto generic start.
**How to avoid:** `surf.BucketProvider` named `video-initiate` `Limit::perMinute(5)` on start only; `call-link` 10/min on resolve [VERIFIED: wavepath/routes.php:86-88]; general per-API bucket on the rest.
**Warning signs:** start unthrottled in routes.go.
### Pitfall 5: seaglass ≠ forum HTMLPurifier
**What goes wrong:** forum links lose `nofollow`/`target=_blank`; images/`tel:` leak in.
**Why:** seaglass starts from `UGCPolicy`, sets `RequireNoFollowOnLinks(false)`, allows `tel:` [VERIFIED: modules/seaglass/seaglass.go:29-32]. PHP sets `HTML.TargetBlank` true, `HTML.Nofollow` true, schemes http/https/mailto only, no `img` [VERIFIED: forum/classes/markdown/MarkdownRenderer.php:62-73].
**How to avoid:** D-17 named policy: `AllowElements` of the PHP list, `AllowURLSchemes("http","https","mailto")`, `RequireNoFollowOnLinks(true)`, `AddTargetBlankToFullyQualifiedLinks(true)`.
**Warning signs:** forum tests import `seaglass`.
### Pitfall 6: D-17 not landed
**What goes wrong:** forum plan grows a local renderer.
**Why:** this session: no `modules/*markdown*` directory; `cabana.RenderMarkdown` is the wrong gate.
**How to avoid:** forum plan first task is "require D-17 export"; if missing, **block**, do not substitute.
**Warning signs:** `forum/classes/markdown.go`.
### Pitfall 7: flare looks like herald
**What goes wrong:** executor uses `flare.From` / `push.*` / `websockets:generate-vapid-keys`.
**Why:** flare already implements RFC 8291/8292 in-tree. D-09 still locks webpush-go. PHP command is `herald:vapid-generate` [VERIFIED: herald/console/GenerateVapidKeys.php:18]; config `golem15.herald::herald.vapid.*` [VERIFIED: herald/config/herald.php:13-16].
**How to avoid:** decision note records the divergence in one paragraph.
**Warning signs:** herald importing `modules/flare`.
### Pitfall 8: fetchguard.Client is not `*http.Client`
**What goes wrong:** note claims an adapter is required, or code does `http.Client{Transport: fetchguard}`.
**Why:** webpush-go `HTTPClient` is `Do(*http.Request) (*http.Response, error)` [CITED: github.com/SherClockHolmes/webpush-go v1.4.0 webpush.go]. `fetchguard.Client.Do` matches [VERIFIED: modules/fetchguard/client.go:85-86]. Pass `*fetchguard.Client` as `Options.HTTPClient`. No wrapper type needed. *If* a future library required `*http.Client`, that would need an adapter — current lib does not.
**How to avoid:** set `Options.HTTPClient = publicOnlyClient`.
**Warning signs:** extra `type fetchguardDoer struct`.
### Pitfall 9: LiveKit v2 transitive bomb
**What goes wrong:** chatvideo tests import pion/webrtc; `go test` downloads buildkit.
**Why:** v2.19.0 go.mod requires `github.com/pion/webrtc/v4`, `github.com/livekit/media-sdk`, `github.com/moby/buildkit` (Read module cache this session).
**How to avoid:** decision note flags pion. Production code talks to a narrow `RoomAPI` interface. Tests ship a fake; do not construct `lksdk.Room` (that's the realtime client). Prefer `NewRoomServiceClient` + `protocol/auth` only.
**Warning signs:** test file imports `pion`.
### Pitfall 10: PHP morph FQCNs in forum (and chat) columns
**What goes wrong:** Go registry looks up alias `friendgroup` and adopted rows still say `Golem15\UserFriends\Models\FriendGroup`.
**Why:** P19 D-12 rewrite. Live MySQL not readable this session; PHP hook queries that FQCN [VERIFIED: forum/Plugin.php:114-116]. Chat conversations also store Channel / FriendContext / FriendGroup class names (P19 already owns that rewrite).
**How to avoid:** discretion: extend `chat:rewrite-context-types` **and/or** forum ships `forum:rewrite-context-types` for `golem15_forum_categories.context_type`. Recommend **forum ships its own** so forum can adopt without chat. Map PHP class → registered alias.
**Warning signs:** DeleteContext tests only use aliases and skip SQL rewrite.
### Pitfall 11: community-join is 10/min, not 5
**What goes wrong:** friend-request bucket copied from video-initiate.
**Why:** `RateLimiter::for('community-join'… Limit::perMinute(10)` [VERIFIED: wavepath/routes.php:76-78]; applied to `community/friends/request` [VERIFIED: wavepath/routes.php:621].
**How to avoid:** userfriends send-request uses `community-join` 10/min; groups join/preview too if those routes are in D-05.
**Warning signs:** 5/min on friends/request.
### Pitfall 12: DeleteContext is a new Go API
**What goes wrong:** planner looks for PHP `ForumService::deleteContext` and finds nothing.
**Why:** PHP uses `class_exists(FriendGroup)` model hook [VERIFIED: forum/Plugin.php:111-120]. D-08 replaces it with generic `DeleteContext(type, id)` that forum owns. Go **adds** this method.
**How to avoid:** implement on ForumService; no userfriends import.
**Warning signs:** forum/go.mod requires sm-userfriends-plugin.
### Pitfall 13: EndpointGuard PHP tests become N/A
**What goes wrong:** last plan tries to port `test_endpoint_guard_pins_validated_ip_into_curl_resolve`.
**Why:** D-10 removes the seam. Map those methods to fetchguard PublicOnly unit tests (or N/A + pointer to fetchguard's own tests).
**Warning signs:** herald reintroduces EndpointGuard.
### Pitfall 14: user.Deleting already exists — do not re-add
**What goes wrong:** satellite plans edit sm-user-plugin to add events Phase 19 already shipped.
**Why:** `CreatedEventName = "user.Created"`, `DeletingEventName = "user.Deleting"` [VERIFIED: fonoteka.go/plugins/golem15/user/classes/events.go:9-12]. Chat already listens. Satellites only `Listen[*userclasses.DeletingEvent]`.
**How to avoid:** no sm-user-plugin schema work in 19.1 unless a golden proves a gap.
**Warning signs:** satellite plan touches `sm-user-plugin/classes/events.go`.
<code_examples>
## Code Examples
### webpush-go + fetchguard (no adapter type)
```go
// Source: webpush-go v1.4.0 Options.HTTPClient [CITED: github.com/SherClockHolmes/webpush-go/blob/v1.4.0/webpush.go]
// fetchguard.Client.Do [VERIFIED: modules/fetchguard/client.go:85-86]
public, err := fetchguard.NewClient(fetchguard.Policy{Mode: fetchguard.PublicOnlyMode}, cfg)
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh},
}, &webpush.Options{
HTTPClient: public, // implements Do(*http.Request)
Subscriber: subject, // mailto:… from golem15.herald.vapid.subject
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
TTL: 2419200,
Urgency: webpush.Urgency("normal"),
})
```
TTL default `2419200` and urgency `normal` [VERIFIED: herald/classes/PushSender.php:111-113]. Prune when status is 404 or 410 (PHP `isSubscriptionExpired`) [VERIFIED: herald/classes/PushSender.php:182-185].
### LiveKit token claims (PHP payload to golden)
```php
// [VERIFIED: chatvideo/classes/LiveKitService.php:80-99]
$payload = [
'iss' => $this->apiKey,
'sub' => (string) $userId,
'name' => $userName,
'iat' => $now,
'exp' => $now + $ttl, // token_ttl default 900
'nbf' => $now,
'video' => [
'room' => $roomName,
'roomJoin' => true,
'canPublish' => true,
'canSubscribe' => true,
],
];
// optional: $payload['metadata'] = json_encode($metadata);
JWT::encode($payload, $this->apiSecret, 'HS256');
```
Admin URL fallback [VERIFIED: chatvideo/classes/LiveKitService.php:109-114]: `livekit_admin_url` else rewrite `wss://`/`ws://` → `https://`/`http://`. Room name: `'chat-conversation-' . $conversationId . '-' . bin2hex(random_bytes(8))` [VERIFIED: chatvideo/classes/LiveKitService.php:102-105].
SDK mint (official README) — **verify decoded claims against the PHP golden**, do not assume grant JSON keys match:
```go
// [CITED: https://github.com/livekit/server-sdk-go README Token creation]
at := auth.NewAccessToken(apiKey, apiSecret)
at.SetVideoGrant(&auth.VideoGrant{RoomJoin: true, Room: room}).
SetIdentity(identity).SetValidFor(time.Duration(ttl) * time.Second)
token, err := at.ToJWT()
```
If `ToJWT` omits `name` / `nbf` / nested `video` keys, mint with `jwt/v5` MapClaims copying the PHP payload instead, still behind the same wrapper.
LiveKit HTTP: `fetchguard.Policy{Mode: fetchguard.TrustedMode}` [VERIFIED: modules/fetchguard/policy.go:23-29] — operator URL, may be loopback `http://localhost:7880`.
### Forum bluemonday policy (D-16) — belongs in D-17 module
```go
// APIs [VERIFIED: bluemonday@v1.0.27/policy.go:620-693]
p := bluemonday.NewPolicy()
p.AllowElements("p","br","strong","em","b","i","h1","h2","h3","h4","h5","h6",
"ul","ol","li","blockquote","pre","code","a","table","thead","tbody","tr","th","td",
"hr","del","ins","sub","sup")
p.AllowAttrs("href","rel","target").OnElements("a")
p.AllowURLSchemes("http", "https", "mailto")
p.RequireNoFollowOnLinks(true)
p.AddTargetBlankToFullyQualifiedLinks(true)
```
goldmark: `goldmark.New(goldmark.WithExtensions(extension.GFM))` — GFM = Linkify+Table+Strikethrough+TaskList [VERIFIED: goldmark@v1.8.6/extension/gfm.go:10-18]. Do not pass `html.WithUnsafe()`.
D-17 interface the forum plan consumes (quick task owns names):
```go
Render(markdown string) string // convert + named policy
Purify(html string) string // policy only (PHP MarkdownRenderer::purify)
```
XSS corpus [VERIFIED: forum/tests/security/ForumMarkdownRenderTest.php:19-27]: `<script>alert(1)</script>`, `[x](javascript:alert(1))`, `<iframe src=x></iframe>`, `<img src=x onerror=alert(1)>`.
### video authorizer (do not use ChannelID)
```go
// PHP [VERIFIED: chatvideo/classes/VideoChannelAuthorizer.php:11-40]
// analog [VERIFIED: sm-chat-plugin/classes/authorizer.go:17-20]
parts := strings.Split(strings.TrimPrefix(ch, "presence:"), ":")
if len(parts) != 3 || parts[0] != "video" || parts[1] != "session" || !digits(parts[2]) {
return lighthouse.Denied("invalid video channel format")
}
```
Events [VERIFIED: chatvideo/classes/VideoCallNotifier.php:18-66]:
| Event | Channel | Payload keys |
|-------|---------|--------------|
| `video:call:incoming` | `user:{id}` via publishToUsers | session_id, conversation_id, caller_name, caller_avatar |
| `video:call:accepted` | `video:session:{id}` | session_id, user_id, user_name |
| `video:call:declined` | same | session_id, user_id |
| `video:call:ended` | same | session_id, ended_by, duration_seconds |
| `video:participant:joined` | same | session_id, user_id, user_name |
| `video:participant:left` | same | session_id, user_id |
Publish failure logged, not raised [VERIFIED: VideoCallNotifier.php:73-75].
### Slash commands
```go
// RESERVED [VERIFIED: chatbots/classes/SlashCommandRegistry.php:31]
const ReservedHelp = "help"
// register() rejects reserved + duplicates; registerBuiltin() skips reserved check
// [VERIFIED: SlashCommandRegistry.php:53-74]
// modes [VERIFIED: CommandResult.php:21-22]
const ModeEphemeral, ModeAnnounce = "ephemeral", "announce"
// CMD-06: actor from caller, never from input [VERIFIED: CommandDispatcher.php:20-22, 43]
```
Unrecognized trigger returns nil (fall through to a normal message) [VERIFIED: CommandDispatcher.php:54-56].
### GDPR listeners (festival)
```go
app.Events.Listen[*userclasses.DeletingEvent](PluginID, p.onUserDeleting)
```
- chatbots: delete `golem15_chatbots_bot_flags` for that user_id [VERIFIED: chatbots/Plugin.php:54-59]
- forum: `user_id = null` on threads and posts [VERIFIED: forum/Plugin.php:89-92]
- userfriends: heir oldest admin else oldest member else `forceDelete` group; then delete member and ban rows [VERIFIED: userfriends/Plugin.php:73-98]
</code_examples>
<sota_updates>
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Minishlink WebPush (PHP) | webpush-go v1.4.0 | D-09 / 2025-01-02 | HTTPClient interface fits fetchguard |
| Agence104 LiveKit PHP + firebase/jwt | livekit/server-sdk-go **v2** | D-13; v2 current 2026-10 | Pin `/v2`; golden-test claims |
| HTMLPurifier + League CommonMark GFM | goldmark GFM + bluemonday named policy | D-16/D-17 | Framework module, not forum |
| Golem15.WebSockets authorizer | lighthouse Registry | P19 | video namespace |
| PHP EndpointGuard + CURLOPT_RESOLVE | fetchguard PublicOnly | D-10 | fail-closed |
| User::extend beforeDelete | festival `user.Deleting` | P19 | already in sm-user-plugin |
| class_exists(FriendGroup) | forum.DeleteContext + app bridge | D-08 | no cross-plugin types |
**Deprecated/outdated:**
- `github.com/livekit/server-sdk-go` v1 (v1.1.8, 2024-01-25)
- ROADMAP SC2 Golem clause
- PHP herald "ships no routes" (D-06)
- `cabana.RenderMarkdown` as a UGC renderer
</sota_updates>
## Assumptions Log
| ID | Claim | Tag | Disposition |
|----|-------|-----|-------------|
| A1 | Live Wavepath MySQL matches PHP migrations | unverified this session (`ERROR 1698`) | Use PHP migrations as schema SoT; optional executor dump |
| A2 | livekit `protocol/auth` ToJWT equals PHP firebase payload | [ASSUMED] until golden | Decision-note task: decode both; if differ, mint with jwt/v5 |
| A3 | D-17 will export `Render`/`Purify` + named policy before forum execute | CONTEXT | Forum plan blocks if missing |
| A4 | classify-confidence webfetch=LOW even `--verified` | seam output | Library versions are checkpoints inside locked names |
## Open Questions
1. **LiveKit JWT: SDK vs jwt/v5**
- What we know: PHP payload is explicit MapClaims [VERIFIED: LiveKitService.php:80-99]. Official Go samples use `auth.AccessToken`.
- What's unclear: extra claims (`jti`, grant nesting).
- Recommendation: first chatvideo task is a golden round-trip; pick mint path that matches PHP. Do not reopen D-13.
2. **D-17 module shape**
- What we know: not landed; must not be cabana or seaglass.
- What's unclear: package name (beach-theme), whether `Purify` policies are a registry.
- Recommendation: forum consumes whatever the quick task exports; document the call in the forum plan, do not specify the package name here.
3. **Morph rewrite ownership**
- Discretion. Recommendation: forum owns `forum:rewrite-context-types` for `golem15_forum_categories.context_type`; chat already rewrites conversation context_type.
4. **chatvideo generic addressing**
- Discretion: conversation id vs context alias. Recommendation: **conversation id** as primary (`/_chatvideo/api/v1/conversations/{id}/video/...`) plus optional context lookup through chat's registry so Wavepath group-video payloads stay reproducible.
5. **Herald NeutralHeraldProfile vs deny-all**
- PHP binds NeutralHeraldProfile + DenyAllPresenceGate. Go should keep both defaults. PresenceGate is the fail-closed send gate.
6. **OBS-01 cooldown without Laravel Cache**
- PHP `Cache::add('obs:warn:herald.delivery', 1, 60)` [VERIFIED: PushSender.php:213]. Recommendation: process-local sync.Map 60s (PHP already has an in-process fallback [VERIFIED: PushSender.php:227-246]) plus optional cooler if already in the plugin test host. Do not add Redis as a herald dependency.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | All code | ✓ | 1.27.0-X:nodwarf5 | — |
| Docker | D-04 testcontainers | ✓ | 29.7.2 (`docker ps` ok) | — |
| Postgres (local) | optional | ✓ | pg_isready on /run/postgresql:5432 | testcontainers |
| tea | create sm-*-plugin repos | ✓ | 0.16.0 | Gitea UI |
| mysql/mariadb client | D-02 live diff | ✓ client 12.3.3 | dump **failed** this session (`ERROR 1698`) | PHP migrations |
| ctx7 CLI / Context7 MCP | research-plan provider | ✗ | — | GitHub raw + pkg.go.dev via WebFetch |
| graphify | research graph | disabled | — | skipped |
| LiveKit server | chatvideo e2e vs real SFU | ✗ in this audit | — | Twirp fake (D-04) |
| D-17 markdown module | forum | ✗ | — | **block forum plan** |
**Missing dependencies with no fallback:**
- D-17 framework markdown module (forum execution blocker, not a research blocker).
- Live Wavepath MySQL credentials (schema SoT = PHP migrations).
**Missing with fallback:**
- Context7: official GitHub/pkg.go.dev used.
- Real LiveKit: interface fake.
## Validation Architecture
`workflow.nyquist_validation` is `true` in `.planning/config.json` [VERIFIED: .planning/config.json:20].
Five plugin repos + three decision notes + last-plan PHP maps. Nyquist: sample **every repo that a task touches**, not a random subset. A task that only writes `sm-forum-plugin` still runs `go test -short ./...` **in that module**; it does not stand in for herald. Decision-note tasks have no Go tests — sample by `grep` that the note names the locked module path + version before the importing commit.
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go `testing` + testcontainers-go v0.44.0 (Postgres) |
| Config file | none at framework root — each plugin is its own module |
| Quick run | `go test -short ./...` inside the touched plugin (skip Docker) |
| Full suite | `go test ./...` (testcontainers + lighthouse memory + fakes) |
| Analog | `sm-chat-plugin` `harness_test.go` / `http_tracer_test.go` / `testmain_test.go` (`flag.Parse` + `testing.Short`) |
Per-plugin fakes: LiveKit Twirp (`RoomAPI` interface), push HTTP (`httptest` + webpush-go `HTTPClient` stub), D-17 renderer stub only if the forum plan is allowed to compile against a test fake **after** the real module exists (do not ship a forum-local stub as production).
### Phase Requirements → Test Map
| Req | Behavior | Test Type | Automated Command | File Exists? |
|-----|----------|-----------|-------------------|--------------|
| SC-19.1-1 | each baseline creates final PHP tables | integration | `go test -short ./updates -run TestBaseline` ×5 | ❌ Wave 0 |
| SC-19.1-1 | generic API tracer (one E2E per plugin) | integration | `go test ./... -run TestHTTPTracer` ×5 | ❌ Wave 0 |
| SC-19.1-2 | notes exist before importing commits | manual+grep | `test -f .planning/notes/2026-10-*-webpush-go.md` (etc.) | ❌ Wave 0 |
| SC-19.1-2 | chatbots has **no** Golem import | unit | `go test -short ./... -run TestNoGolem` | ❌ Wave 0 |
| SC-19.1-3 | userfriends source = Wavepath 182b514 | already met | `diff -rq` this session empty | ✓ research |
| SC-19.1-4 | PHP method map complete | unit (last plan) | `go test ./...` + map file | ❌ Wave 0 |
| D-10 | subscribe rejects http/private IP | unit | `go test -short ./classes -run TestSubscribeEndpoint` | ❌ Wave 0 |
| D-11 | SendEvent enqueues in Tx; rollback sends 0 | integration | `go test ./... -run TestSendEventRollback` | ❌ Wave 0 |
| D-13 | JWT claims golden vs PHP payload | unit | `go test -short ./classes -run TestLiveKitTokenClaims` | ❌ Wave 0 |
| D-15 | video authorizer segment 2 + prs | unit | `go test -short ./classes -run TestVideoAuthorizer` | ❌ Wave 0 |
| D-16/D-18 | XSS + GFM semantic HTML | unit | D-17 tests + forum write-path | ❌ Wave 0 (D-17) |
| D-08 | forum does not import userfriends | unit | `go test -short ./... -run TestNoUserfriendsImport` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `go vet ./...` and `go test -short ./...` in **each** plugin module the task modified. A decision-note-only commit: `git grep` the locked import in the note, no `go test`.
- **Per wave merge:** `go test ./...` in every plugin the wave touched (Docker).
- **Phase gate:** `scripts/check-phase19.1.sh` (create in last plan) listing named tests across the five modules + the three note paths, analog to `scripts/check-phase19.sh`.
Five repos means a task that "finishes forum" is not evidence for herald. Nyquist sampling advice: **n = number of independent deliverables (5 plugins + 3 notes + 1 map)**. Sample all 9 at the phase gate; per-commit sample the subset in the diff.
### Last-plan PHP-test-method mapping (counts Read this session)
| Plugin | `function test` | Test.php files | Go fate |
|--------|-----------------|----------------|---------|
| chatbots | **22** | 7 | all port (registry, dispatcher, help, deny-all, RODO, boundary, additivity) |
| forum | **25** | 6 | all port; markdown goldens consume D-17 |
| herald | **14** | 5 | port except EndpointGuard pin/reject → N/A (D-10) + fetchguard pointer. `test_default_endpoint_guard_pins_and_rejects_nothing` N/A |
| userfriends | **31** | 3 | all port (GroupPolicy, GroupVisibilityGate, GroupModels). Components N/A (D-07) |
| chatvideo | **0** in-plugin | 0 | No in-plugin PHP tests. Wavepath host tests (`CommunityChannelVideoTest` 11, `CommunityCallLinkTest` 8, `CommunityVideoSeverTest` 5, `CallNotificationLifecycleTest` 12, `GuestCallLinkTest` 8, `GuestLiveKitGatewayTest` 3, …) are **N/A** except the generic start/join/leave/end/status/call-link behaviors those files happen to cover — map each Wavepath method to generic-API Go or explicit N/A (guest, call cards, CallNotifier) |
### Tracer-first (one production-quality E2E per plugin)
| Plugin | Tracer |
|--------|--------|
| userfriends | JWT user A sends request to B → accept → list friends 200; send-request hits `community-join` 10/min |
| forum | allow-all test resolver; create category+thread+post; `content_html` has `<table>` for GFM and no `<script>`; mark read; unread count 0 |
| chatbots | register `/ping`; dispatch as JWT actor; ephemeral returns payload and inserts 0 chat messages; `/help` listed |
| herald | POST subscription (https public fake); `SendUnconditional` job after commit hits fake push; 410 prunes row; rollback sends 0 |
| chatvideo | start session (unique active_conversation_id); token claims golden; subscribe `video:session:{id}` as participant allowed / stranger denied; reconcile empty room → ended |
### Wave 0 Gaps
- [ ] Five repos: `go.mod` with `replace` to `../../../../summercms.go`, `plugin.go`, baseline, `harness_test.go` / `testmain_test.go`
- [ ] `testdata/goldens/` per plugin (serializers, events, LiveKit JWT, forum HTML)
- [ ] `acme` fixture plugin (test files only) where a context is needed (forum, userfriends adapter)
- [ ] LiveKit `RoomAPI` + push `HTTPClient` fakes
- [ ] Three decision notes before importing commits
- [ ] D-17 module (separate quick task) before forum execute
- [ ] PHP→Go method map markdown in the last plan
- [ ] `scripts/check-phase19.1.sh`
Existing fetchguard / lighthouse / seaglass / flare tests do **not** cover satellite behavior.
## Security Domain
`security_enforcement` is not set to false. CONTEXT requires the security-review agent.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | `jwt.auth`; bot bearer via `BotAuthResolver` deny-all; actor id from token (CMD-06) |
| V3 Session Management | no | JWT |
| V4 Access Control | yes | ForumAccessResolver, conversation participation for video, group policy matrix, IDOR → 404 |
| V5 Input Validation / Sanitization | yes | D-16 bluemonday; subscribe URL https+public; join_code alphabet |
| V6 Cryptography | yes | VAPID keys; LiveKit HS256 secret; private key never logged |
| V13 API | yes | named rate buckets; generic prefixes |
| V13 SSRF | yes | fetchguard PublicOnly on push; TrustedMode only for operator LiveKit URL |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SSRF via push endpoint | Elevation | D-10 PublicOnly at subscribe and send; no EndpointGuard |
| VAPID private key leak | Information disclosure | `herald:vapid-generate` prints env lines; never log [VERIFIED: GenerateVapidKeys.php:11-13] |
| LiveKit token as any identity | Elevation | `sub` = authenticated user id string; secret server-side |
| XSS in forum posts | Tampering | D-16 policy; render-on-write; adopted HTML served stored (D-18) not re-parsed unsafely |
| IDOR on session/join_code/friends | Information disclosure | JWT actor + participation/membership; call-link 10/min [VERIFIED: wavepath/routes.php:86-88] |
| video:session subscribe as stranger | Elevation | authorizer re-checks conversation participants; capabilities `prs` only |
| Slash command spoofed actor | Elevation | CMD-06: actor from token, never body [VERIFIED: CommandDispatcher.php:20-22] |
| GDPR leftover bot_flags / authorship | Information disclosure | user.Deleting listeners |
| DNS rebind FCM endpoint | Elevation | dial-time fetchguard (replaces CURLOPT_RESOLVE) |
| Enumerate join codes | Information disclosure | `call-link` 10/min; uniform 404 |
## Frozen PHP inventory (planner cheat-sheet)
Frozen HEADs this session (full SHA):
- chatbots `1ecf8c8bb6b81102508c0b8e3a999aaacf9d4963`
- chatvideo `25135b46c1571a6bc722fdc094157176d70ad717`
- forum `ad10b54eb7be8e11de8e55dd85cfa0c92bbb15c9`
- herald `a749f83912a7c88994eb5341740a60b3488463f9`
- userfriends `182b5145561f0609ab54f59e24258e12a96332b1`
Live Wavepath MySQL: **not dumped this session**. Schema below is PHP migrations.
### chatbots schema (v1.0.0)
[VERIFIED: chatbots/updates/version.yaml + create_bot_flags_table.php:13-26]
| Table | Columns / notes |
|-------|-----------------|
| `golem15_chatbots_bot_flags` | id, user_id unique `chatbots_bot_flags_user_unique`, is_bot bool default true, timestamps; FK user_id → users cascade |
### chatvideo schema (v1.0.4 final)
[VERIFIED: chatvideo/updates/version.yaml:1-16] Demo seed is v1.0.1 — record ID, no rows (D-02).
| Table | Columns / notes |
|-------|-----------------|
| `golem15_chatvideo_sessions` | id, conversation_id FK chat_conversations cascade, `card_message_id` nullable uint, `active_conversation_id` nullable unique `uniq_chatvideo_active_conversation`, `livekit_room_name` varchar(100) unique, `join_code` varchar(12) nullable unique, status enum `pending\|active\|ended` default pending, started_by FK users cascade, started_at, ended_at nullable, duration_seconds nullable; indexes conversation, status. Model: no updated_at; CREATED_AT=`started_at` [VERIFIED: VideoSession.php:24-26]. JOIN_CODE alphabet `abcdefghijklmnopqrstuvwxyz0123456789` length 10 [VERIFIED: VideoSession.php:17-18] |
| `golem15_chatvideo_participants` | id, video_session_id FK cascade, user_id FK cascade, status enum `invited\|joined\|declined\|left` default invited, joined_at, left_at; unique `(video_session_id, user_id)` `uq_chatvideo_session_user` |
Config [VERIFIED: chatvideo/config/config.php:4-11]: `livekit_url`/`LIVEKIT_URL` default `ws://localhost:7880`, `livekit_api_key`/`LIVEKIT_API_KEY`, `livekit_api_secret`/`LIVEKIT_API_SECRET`, `max_duration`/`VIDEO_MAX_DURATION` 3600, `token_ttl`/`VIDEO_TOKEN_TTL` 900, `livekit_admin_url`/`LIVEKIT_ADMIN_URL`, `max_participants`/`VIDEO_MAX_PARTICIPANTS` 8, `screen_share`/`VIDEO_SCREEN_SHARE` true.
Command: `summer chatvideo:reconcile-sessions` (D-14). Periodic every 5 minutes.
### forum schema (v1.1.0 final)
[VERIFIED: forum/updates/version.yaml:1-13]
| Table | Columns / notes |
|-------|-----------------|
| `golem15_forum_categories` | id, context_type varchar(255) **NOT NULL**, context_id uint, slug, title, description text nullable, sort_order default 0, timestamps, deleted_at; index `(context_type, context_id)` `forum_categories_context_index`; unique `(context_type, context_id, slug)` `forum_categories_owner_slug_unique`; **no FK** |
| `golem15_forum_threads` | id, category_id FK categories cascade, user_id nullable FK users **set null**, title, is_pinned default false, is_locked default false, last_post_at, post_count default 0, timestamps, deleted_at, deleted_by nullable **no FK**, deletion_reason nullable; indexes (category_id, last_post_at), user_id |
| `golem15_forum_posts` | id, thread_id FK threads cascade, user_id nullable FK users set null, content text, content_html text nullable, timestamps, deleted_at, deleted_by nullable no FK, deletion_reason nullable, edited_at nullable; indexes (thread_id, created_at), user_id |
| `golem15_forum_thread_reads` | id, user_id FK users cascade `forum_thread_reads_user_foreign`, thread_id FK threads cascade `forum_thread_reads_thread_foreign`, last_read_at, timestamps; unique `(user_id, thread_id)` `forum_thread_reads_user_thread_unique`; index `(thread_id, user_id)` |
deletion_reason: `'self' \| 'moderator' \| NULL` [VERIFIED: add_deletion_marker_to_forum_posts_and_threads.php:16-17].
### herald schema (v1.0.0)
[VERIFIED: herald/updates/create_push_subscriptions_table.php:20-53]
| Table | Columns / notes |
|-------|-----------------|
| `golem15_herald_push_subscriptions` | id, user_id, endpoint varchar(500), keys text (JSON `{p256dh, auth}`), user_agent varchar(500) nullable, subscribed_at, last_used_at nullable, timestamps; unique `(user_id, endpoint)` `herald_push_user_endpoint_unique`; index user_id `herald_push_user_id_idx`; FK user_id → users cascade **only if `users` table exists** |
Config [VERIFIED: herald/config/herald.php:13-16]: `vapid.subject` env `VAPID_SUBJECT` default `mailto:admin@example.com`, `vapid.public_key` `VAPID_PUBLIC_KEY`, `vapid.private_key` `VAPID_PRIVATE_KEY`. Go keys: `golem15.herald.vapid.{subject,public_key,private_key}` (D-12). Command `herald:vapid-generate` [VERIFIED: GenerateVapidKeys.php:18] prints `VAPID_PUBLIC_KEY=` / `VAPID_PRIVATE_KEY=` lines.
### userfriends schema (v1.1.0 final)
[VERIFIED: userfriends/updates/version.yaml] Demo seed v1.0.1 — record ID, no rows.
| Table | Columns / notes |
|-------|-----------------|
| `golem15_userfriends_contexts` | `id` unsigned PK; insert id=1 [VERIFIED: create_friendships_tables.php:12-16] — FriendContext singleton row |
| `golem15_userfriends_friendships` | id, requester_id FK users cascade, recipient_id FK users cascade, status varchar(20) default `pending`, timestamps; unique (requester_id, recipient_id); indexes (recipient_id, status), (requester_id, status) |
| `golem15_userfriends_groups` | id, owner_id FK users cascade, name, description text nullable, is_open bool default false, soft deletes, timestamps; index owner_id |
| `golem15_userfriends_group_members` | id, group_id FK groups cascade, user_id FK users cascade, role varchar(20) default `member`, timestamps; unique (group_id, user_id) `uf_group_member_unique`; index (group_id, role) `uf_group_member_role_idx` |
| `golem15_userfriends_group_invites` | id, group_id FK cascade, invite_code varchar(64) nullable unique, created_by nullable, timestamps; unique group_id `uf_group_invite_group_unique` |
| `golem15_userfriends_group_bans` | id, group_id FK cascade, user_id FK cascade, banned_by nullable, status varchar(20) default `banned`, timestamps; unique (group_id, user_id) `uf_group_ban_unique` |
Constants [VERIFIED: Friendship.php:15-17] `pending`/`accepted`/`declined`; [VERIFIED: GroupMember.php:17-19] `owner`/`admin`/`member`; GroupBan `banned`.
### Rate buckets (D-05)
| Bucket | Limit | Applies to |
|--------|-------|------------|
| `video-initiate` | 5/min per user id or IP [VERIFIED: chatvideo/routes.php:11-13] | generic video **start** (even though PHP only throttled dropped `/api/video/initiate`) |
| `community-join` | 10/min [VERIFIED: wavepath/routes.php:76-78] | friend request; group join/preview if in generic API |
| `call-link` | 10/min [VERIFIED: wavepath/routes.php:86-88] | GET call-links/{code} |
| general per API | analog chat general bucket | remaining routes |
| Guest call-* buckets | Wavepath host | **out of scope** (D-07 / deferred `/_wave`) |
### Commands
| PHP | Go |
|-----|-----|
| `herald:vapid-generate` | `summer herald:vapid-generate` |
| (none in chatvideo plugin) | `summer chatvideo:reconcile-sessions` + conga periodic 5 min |
| `wave:reconcile-video-sessions` | Wavepath port thin alias later; not this phase |
## Discretion recommendations (planner defaults)
| Item | Recommendation |
|------|----------------|
| Packages / IDs | `chatbots`, `chatvideo`, `forum`, `herald`, `userfriends`; IDs `golem15.<name>` |
| userfriends events | `userfriends.GroupCreated`, `GroupDeleted`, `MemberJoined`, `MemberLeft` (fields: GroupID, ActorID, MemberID) |
| forum DeleteContext | `ForumService.DeleteContext(ctx, type, id)` soft-deletes categories (and cascaded threads/posts) for that morph pair |
| Morph rewrite | forum ships `forum:rewrite-context-types`; do not require chat |
| chatvideo paths | `/_chatvideo/api/v1/conversations/{id}/video/{start,join,leave,end,status}` + `/_chatvideo/api/v1/call-links/{code}` |
| forum paths | `/_forum/api/v1/contexts/{type}/{id}/categories` + threads/posts/read/unread as D-05 |
| userfriends paths | `/_userfriends/api/v1/friends` (list, requests, sent, request, accept, reject, cancel, unfriend) + `/groups` (members, invites, bans) |
| herald paths | `POST/PATCH/DELETE /_herald/api/v1/push/subscription`, `GET /_herald/api/v1/push/vapid-public-key` |
| chatbots paths | `GET /_chatbots/api/v1/commands` (`isAvailable` filter), `POST /_chatbots/api/v1/dispatch` body `{input}` actor from JWT |
| Herald job | Kind `golem15.herald.send`, queue `default` or `push`, enqueue in caller Tx |
| Video job | Kind `golem15.chatvideo.reconcile_sessions`, periodic 5 min, MaxAttempts 1 |
| LiveKit wrapper | interface `RoomAPI` { CreateRoom, DeleteRoom, RemoveParticipant, ParticipantCount, ActiveRoomNames }; SDK client in prod; fake in tests |
| Token mint | Prefer jwt/v5 MapClaims copying PHP if SDK golden fails; still import official SDK for RoomService |
| D-17 consume | `Render` + `Purify`; forum never vendors goldmark/bluemonday |
| Lang | EN/PL phrasebook; plugin.name `Chat bots` / `Forum` / `Herald` / `Friends` / `Chat video` — never Wavepath/Fonoteka |
| sm-user-plugin | **no edit** (events already exist) |
| summercms.go | **only** if D-17 is missing (not this phase) or a lighthouse/fetchguard/conga/festival gap appears — none found for satellites |
| Repo create | `tea repos create --owner golem15 sm-<name>-plugin` then analog go.mod/replace |
| Plan split (present and wait) | **6 plans:** (1) userfriends repo + baseline + models + services + events + generic API tracer; (2) forum repo + baseline + models + ForumService + DeleteContext + generic API tracer (**blocks on D-17**); (3) chatbots repo + flags + registry/dispatcher + generic API tracer; (4) **webpush-go decision note** then herald repo + subscriptions + sender job + generic push tracer; (5) **LiveKit + (if not earlier) bluemonday decision notes** then chatvideo repo + sessions + wrapper + authorizer + reconciler + generic video tracer; (6) unit tests + goldens + PHP maps + `scripts/check-phase19.1.sh`. First planning commit also patches ROADMAP Repos + SC2 + SC3. Bluemonday note may land at the start of plan 2 if D-17 has not already written it; forum still must not vendor the sanitizer. |
| Security-review agent | Run once after plan 5 (APIs + push + tokens + HTML + GDPR all exist) or per CONTEXT at phase start of execute |
## Sources
### Primary (HIGH confidence)
- Frozen PHP trees under `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chatbots,chatvideo,forum,herald,userfriends}` (rev-parse + Read this session)
- Wavepath `routes.php` (rate buckets, friends/video/forum/push/command prefixes), `console/ReconcileVideoSessions.php`, `classes/video/CommunityVideoService.php`
- Analog: `sm-chat-plugin/{plugin.go,classes/authorizer.go,http_tracer_test.go}`, `sm-chatchannels-plugin/classes/deny_all.go`
- Framework: `modules/{lighthouse/channel.go,fetchguard/client.go,fetchguard/policy.go,cabana/field_markdown.go,seaglass/seaglass.go,flare/commands.go,flare/README.md}`
- `fonoteka.go/plugins/golem15/user/classes/events.go` (`user.Created` / `user.Deleting`)
- `.planning/notes/{core-plugins-own-repos.md,plugin-layout-winter-directories.md}`
- `.planning/research/STACK.md`, `go.mod` (goldmark, jwt/v5, bluemonday)
- Fonoteka PHP userfriends tree: 32 files, `diff -rq` empty vs Wavepath, `Plugin.php` identical
- bluemonday v1.0.27 `policy.go` (RequireNoFollowOnLinks, AddTargetBlankToFullyQualifiedLinks, AllowURLSchemes) Read from module cache
- goldmark v1.8.6 `extension/gfm.go` Read from module cache
### Secondary (MEDIUM confidence)
- webpush-go v1.4.0 source [CITED: https://raw.githubusercontent.com/SherClockHolmes/webpush-go/v1.4.0/webpush.go] — classify-confidence webfetch=LOW; claims cross-checked against fetchguard.Do
- LiveKit server-sdk-go README [CITED: https://github.com/livekit/server-sdk-go] Token creation + RoomServiceClient; v2.19.0 go.mod transitive list from proxy.golang.org
- pkg.go.dev webpush-go@v1.4.0 (MIT, jwt/v5, x/crypto)
- ASVS 5.0 V5 / V13 [CITED: https://github.com/OWASP/ASVS]
### Tertiary (LOW confidence)
- Live Wavepath MySQL `wave2` (this session: Access denied)
- livekit `auth.AccessToken` claim JSON vs PHP firebase payload (needs golden)
- `package-legitimacy check --ecosystem crates` on Go import paths (`SLOP`/`does-not-exist` — wrong ecosystem, ignore)
## Metadata
**Research scope:**
- Core technology: five WinterCMS plugins → compiled `sm-*-plugin` repos
- Ecosystem: webpush-go, livekit/server-sdk-go/v2, bluemonday, goldmark, fetchguard, lighthouse, conga, festival
- Patterns: P19 analog layout, deny-all seams, generic `/_x/api/v1`, squashed baselines, tracer-first
- Pitfalls: ChannelID segment, flare vs D-09, seaglass vs forum policy, D-17 missing, LiveKit transitives, Wavepath-only reconciler extras
**Confidence breakdown:**
- Standard stack: HIGH for locked names and in-repo versions; MEDIUM for livekit/webpush-go version pins (CITED); LOW webfetch seam
- Architecture: HIGH — PHP services/migrations and analog plugins Read this session
- Pitfalls: HIGH for ChannelID, D-14 scope, D-17, seaglass, Golem, EndpointGuard
- Code examples: HIGH for PHP quotes; MEDIUM for Go SDK mint until golden
**Research date:** 2026-10-17
**Valid until:** 2026-11-16 (30 days — LiveKit v2 moves fast; re-check v2.19.0 at execute)
---
*Phase: 19.1-chat-satellites*
*Research completed: 2026-10-17*
*Ready for planning: yes (forum execute blocked on D-17; planning may proceed)*