diff --git a/.planning/STATE.md b/.planning/STATE.md index 4c4065d3..0397f674 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,16 +3,16 @@ msd_state_version: "1.0" milestone: v1.0 current_phase: "19.1" current_phase_name: Chat satellites -status: planning +status: executing stopped_at: Phase 19.7 context gathered -last_updated: "2026-10-18T10:41:48.616Z" +last_updated: "2026-10-18T10:56:18.421Z" last_activity: 2026-10-17 last_activity_desc: Phase 19 complete, transitioned to Phase 19.1 -state_head: b8f90749c714a0e83c8648315729d05a2639888a +state_head: cddf3a5008d9832a881f89900a961aa6281c05d8 progress: total_phases: 41 completed_phases: 33 - total_plans: 198 + total_plans: 204 completed_plans: 198 gsd_state_version: "1.0" milestone_name: milestone @@ -29,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-10-12) ## Current Position -Phase: 19.1 — Chat satellites +Phase: 19.1 (Chat satellites) — READY TO EXECUTE Plan: Not started -Status: Ready to plan +Status: Ready to execute Last activity: 2026-10-17 — Phase 19 complete, transitioned to Phase 19.1 Progress: [████████░░] 80% diff --git a/.planning/phases/19.1-chat-satellites/19.1-PATTERNS.md b/.planning/phases/19.1-chat-satellites/19.1-PATTERNS.md new file mode 100644 index 00000000..0e765da7 --- /dev/null +++ b/.planning/phases/19.1-chat-satellites/19.1-PATTERNS.md @@ -0,0 +1,619 @@ +# Phase 19.1: Chat satellites - Pattern Map + +**Mapped:** 2026-10-17 +**Files analyzed:** 52 (grouped by the RESEARCH six-plan split) +**Analogs found:** 51 / 52 (1 with no analog: D-17 markdown consume — forum plan blocks; 8 are role-match / partial) + +All analog paths are git-tracked (`git ls-files` this session in each repo). No gitignored mirror is named. + +Path roots (siblings under `/media/nvme/dev/golem15/summercms.io/summercms/` unless noted): + +| Prefix | Path | Role this phase | +|--------|------|-----------------| +| `FW/` | `summercms.go/` | ROADMAP SC2/SC3/Repos, three `.planning/notes/` decision notes, `scripts/check-phase19.1.sh`. **No lighthouse / fetchguard / conga / festival / seaglass / flare change** (RESEARCH: no satellite gap). D-17 markdown module is a **separate `/msd-quick`**, not these plans. | +| `CHAT/` | `sm-chat-plugin/` | Primary analog (Phase 19 shipped). Winter dirs, squashed `IF NOT EXISTS` baseline, `/_x/api/v1`, jwt.auth groups, Buckets, lighthouse authorizer that splits segment 2, `user.Deleting` listener, test-only `acme` host. | +| `CH/` | `sm-chatchannels-plugin/` | Primary analog for deny-all seam + setter + Boot Warn, `Requires()` of chat+user, context-scoped generic API, `SetAccessResolver`. | +| `USR/` | `fonoteka.go/plugins/golem15/user/` | Tracked checkout of `sm-user-plugin` (`git ls-files` from inside the submodule). **Do not edit.** `CreatedEventName` / `DeletingEventName` already exist. Satellites only `Listen[*userclasses.DeletingEvent]`. | +| `PG/` | `sm-paymentgateway-plugin/` | `pact.HasSchedule` + `pact.Every` (video reconciler 5 min). Decision-note analog for a plugin third-party dep. | +| `UF/` | `sm-userfriends-plugin/` | **Does not exist yet.** Create with `tea repos create --owner golem15 sm-userfriends-plugin`. | +| `FORUM/` | `sm-forum-plugin/` | **Does not exist yet.** Same. Execution **blocks on D-17**. | +| `BOTS/` | `sm-chatbots-plugin/` | **Does not exist yet.** Same. | +| `HERALD/` | `sm-herald-plugin/` | **Does not exist yet.** Same. Decision note **before** importing webpush-go. | +| `VIDEO/` | `sm-chatvideo-plugin/` | **Does not exist yet.** Same. Decision note **before** importing livekit SDK. | +| `PHP-*` | `/media/nvme/dev/jz/wavepath.org/plugins/golem15/{chatbots,chatvideo,forum,herald,userfriends}` | Frozen contract (read-only). HEADs: chatbots `1ecf8c8`, chatvideo `25135b4`, forum `ad10b54`, herald `a749f83`, userfriends `182b514`. | + +**Replace paths (locked, Phase 19 pitfall 9 / `CHAT/go.mod:147`):** sibling plugin `go.mod` uses `replace git.golem15.com/golem15/summercms => ../summercms.go`. Do **not** copy `core-plugins-own-repos.md`'s `../../../../summercms.go` into the sibling checkout (that path is for an app submodule mount). `go.work` also replaces `sm-user-plugin` → `../fonoteka.go/plugins/golem15/user`. Plugins that hard-require chat add `replace …/sm-chat-plugin => ../sm-chat-plugin` (`CH/go.work:7`). + +**Plugin IDs / packages (discretion, RESEARCH default):** `golem15.userfriends` / `userfriends`; `golem15.forum` / `forum`; `golem15.chatbots` / `chatbots`; `golem15.herald` / `herald`; `golem15.chatvideo` / `chatvideo`. Tables keep PHP prefixes. `models/` is a leaf (`.planning/notes/plugin-layout-winter-directories.md`). + +**D-03 / P19 D-02:** no production identifier, string, comment or README names QuestStream, family, parent, child, Wavepath, or Fonoteka. Copy `CHAT/fixture_plugin_test.go` `TestFixtureAbsentFromProduction` (retired-token grep) into every satellite. + +Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): **19.1-01** userfriends repo + baseline + services + events + generic API tracer; **19.1-02** forum repo + baseline + ForumService + DeleteContext + generic API tracer (**blocks on D-17**); **19.1-03** chatbots repo + flags + registry/dispatcher + generic API tracer; **19.1-04** **webpush-go decision note** then herald repo + subscriptions + sender job + generic push tracer; **19.1-05** **LiveKit (+ bluemonday if D-17 has not written it) decision notes** then chatvideo repo + sessions + wrapper + authorizer + reconciler + generic video tracer; **19.1-06** unit tests + goldens + PHP maps + `scripts/check-phase19.1.sh`. First planning commit also patches ROADMAP Repos + SC2 (drop Golem; name webpush-go / LiveKit / bluemonday notes) + SC3 (already met). + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| **Plan 1 — userfriends** | | | | | +| `UF/go.mod`, `UF/go.work` | config | — | `CHAT/go.mod` + `CHAT/go.work` (sibling replace `../summercms.go`; user → fonoteka submodule) | exact | +| `UF/README.md` | docs | — | `CHAT/README.md` (neutral names; never Wavepath / Fonoteka) | exact | +| `UF/plugin.go` | provider | request-response | `CH/plugin.go` (`HasRoutes`/`Buckets`/`HasCommands` optional; **no** hard `golem15.chat` in `Requires`) | exact | +| `UF/boot.go` | provider | event-driven (boot) | `CHAT/boot.go` `registerBootStep` + `CH/boot.go` `Listen[*userclasses.DeletingEvent]` | exact | +| `UF/models/{context,friendship,group,group_member,group_invite,group_ban}.go` | model | CRUD | `CHAT/models/conversation.go` (`TableName`, `init(){Register}`, PHP column tags) | exact | +| `UF/models/registry.go` | utility | — | `CHAT/models/registry.go` | exact | +| `UF/classes/{friendship,group}_service.go` | service | CRUD | `CHAT/classes/service.go` (`New`, `lagoon.Transaction`, typed Fire, `WithDB`) | exact | +| `UF/classes/{group_policy,group_visibility_gate}.go` | service | request-response | `CH/classes/channel_service.go` access checks + frozen PHP GroupPolicy matrix | role-match | +| `UF/classes/friends_context_adapter.go` | provider | — | `CHAT/fixture_plugin_test.go` `reg.Add` + `CHAT/contracts/registry.go` `SetProvider` (soft: `app.Lookup[*chatcontracts.Registry]`, no `Requires` chat) | exact (soft) | +| `UF/events/events.go` | model (event types) | pub-sub | `CHAT/events/events.go` (in-tx Fire, `Tx` field, grep-able `…EventName`) | exact | +| `UF/routes.go` + `UF/api/*.go` | route/controller | request-response | `CHAT/routes.go` + `CHAT/api/{auth,errors,json,conversations}.go` | exact | +| `UF/updates/2026*_create_golem15_userfriends_baseline.go` | migration | CRUD | `CHAT/updates/202610160001_create_golem15_chat_baseline.go` (`IF NOT EXISTS`; demo-seed v1.0.1 ID as comment, **no rows**) | exact | +| `UF/config/config.yaml` + `UF/lang/{en,pl}/lang.yaml` | config | transform | `CHAT/config/config.yaml` + `CHAT/lang/en/lang.yaml` (plugin.name `Friends` / `Przyjaciele`) | exact | +| **Plan 2 — forum (blocks on D-17)** | | | | | +| `FORUM/go.mod` … `plugin.go` | config/provider | — | same as userfriends; `Requires()` = `golem15.user` only (never `sm-userfriends-plugin`) | exact | +| `FORUM/contracts/access_resolver.go` + `FORUM/classes/deny_all.go` | model/interface | request-response | `CH/contracts/access_resolver.go` + `CH/classes/deny_all.go` | exact | +| `FORUM/classes/forum_service.go` (`DeleteContext`) | service | CRUD | `CHAT/classes/service.go` + `CH/boot.go` `onUserDeleting` cascade. **Go adds** `DeleteContext` (PHP had a `class_exists(FriendGroup)` hook) | role-match | +| `FORUM/console/rewrite_context_types.go` | utility | batch | `CHAT/console/rewrite_context_types.go` + `CHAT/classes/writes.go` `RewriteContextTypes` (forum **owns** `golem15_forum_categories.context_type`) | exact | +| `FORUM/routes.go` + `FORUM/api/*.go` | route/controller | request-response | `CH/routes.go` (`/_x/api/v1/contexts/{type}/{id}/…`) | exact | +| `FORUM/models/{category,thread,post,thread_read}.go` + baseline | model/migration | CRUD | `CHAT/models/*` + chat baseline (soft deletes; `user_id` nullable SET NULL) | exact | +| Forum write-path `Render`/`Purify` | service | transform | **none in-tree.** D-17 not landed. Do **not** copy `FW/modules/cabana/field_markdown.go` or `FW/modules/seaglass/seaglass.go` | none | +| **Plan 3 — chatbots** | | | | | +| `BOTS/plugin.go` | provider | request-response | `CH/plugin.go`; `Requires()` = `golem15.chat`, `golem15.user`. **No Golem.** | exact | +| `BOTS/contracts/{slash_command,bot_auth_resolver}.go` + `BOTS/classes/deny_all.go` | model/interface | request-response | `CH/contracts/access_resolver.go` + `CH/classes/deny_all.go` (`BotAuthResolver` deny-all + setter + Boot Warn) | exact | +| `BOTS/classes/{registry,dispatcher}.go` | service | request-response | `CHAT/contracts/registry.go` (`Add` rejects duplicates) + PHP reserved `help` / `registerBuiltin` | role-match | +| `BOTS/models/bot_flag.go` + GDPR listener | model | event-driven | `CHAT/models/conversation.go` + `CHAT/boot.go` `onUserDeleting` (delete `bot_flags` row) | exact | +| `BOTS/routes.go` + `BOTS/api/*.go` | route/controller | request-response | `CHAT/api/auth.go` (`frontendUser`; actor from JWT, never body — CMD-06) | exact | +| **Plan 4 — herald (note first)** | | | | | +| `FW/.planning/notes/2026-10-*-webpush-go.md` | docs | — | `FW/.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` (plugin dep, version, why not hand-roll). **Not** `FW/modules/flare`. | exact | +| `HERALD/plugin.go` | provider | request-response | `CHAT/plugin.go` (`HasJobs` + `HasCommands` + `HasRoutes`). PHP `$require` Apparatus → **empty** `Requires()`; no user-model import beyond id | exact | +| `HERALD/contracts/{herald_profile,presence_gate}.go` + deny-all PresenceGate | model/interface | request-response | `CH/classes/deny_all.go` for PresenceGate. HeraldProfile default is PHP `NeutralHeraldProfile` (app-name-only), **not** deny-all | role-match | +| `HERALD/classes/subscription_service.go` | service | CRUD | `CHAT/classes/service.go` (subscribe validates HTTPS+public; unique `(user_id, endpoint)`) | exact | +| `HERALD/jobs/send.go` | service (job) | event-driven | `CHAT/jobs/maintain_thread_summary.go` + `CHAT/classes/service.go:336-343` `Enqueue` on caller `tx` | exact | +| `HERALD/console/vapid_generate.go` | utility | — | `CHAT/console/rewrite_context_types.go` (bonfire.Command). Name `herald:vapid-generate`. **Not** `flare.GenerateVAPIDKeysCommandName` | role-match | +| `HERALD/routes.go` + `HERALD/api/*.go` | route/controller | request-response | `CHAT/routes.go` (`/_herald/api/v1/push/…`) | exact | +| Herald outbound HTTP | service | request-response | `FW/modules/fetchguard/{client,policy}.go` `PublicOnlyMode`; `Client.Do` is webpush-go `HTTPClient` (no adapter type) | exact | +| **Plan 5 — chatvideo (notes first)** | | | | | +| `FW/.planning/notes/2026-10-*-livekit-*.md` | docs | — | hashids note + RESEARCH LiveKit v2 path `github.com/livekit/server-sdk-go/v2` v2.19.0; flag pion/media-sdk/buildkit | exact | +| `FW/.planning/notes/2026-10-*-bluemonday-forum-*.md` | docs | — | `FW/.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` for version pin **only**. Forum policy is **not** seaglass. Skip if D-17 already wrote the named-policy note | role-match | +| `VIDEO/plugin.go` | provider | request-response | `CHAT/plugin.go` (`HasJobs` + `HasCommands` + `HasSchedule` + `HasRoutes`). `Requires()` = `golem15.chat` (PHP WebSockets → lighthouse, not a plugin require) | exact | +| `VIDEO/classes/livekit.go` (`RoomAPI`) | service | request-response | new SDK behind an interface; HTTP through `fetchguard.TrustedMode`. Golden JWT analog is PHP MapClaims, mint with jwt/v5 if SDK grant JSON differs | role-match | +| `VIDEO/classes/authorizer.go` | middleware | request-response | `CHAT/classes/authorizer.go` (`parseChatConversationChannel` three segments; **do not** `lighthouse.ChannelID`) | exact (with footgun) | +| `VIDEO/classes/{video_service,notifier}.go` | service | pub-sub | `CHAT/classes/{service,emit}.go`; publish failure **logged not raised** (PHP VideoCallNotifier) — chat `SendMessage` returns Emit errors; video swallows | role-match | +| `VIDEO/jobs/reconcile.go` + `VIDEO/console/reconcile.go` + `HasSchedule` | service (job) | batch | `CHAT/jobs/maintain_thread_summary.go` + `PG/schedule.go` `pact.Every(5*time.Minute)`. Generic: empty/gone room or `max_duration` only (D-14). No GuestCall / call cards | exact | +| `VIDEO/models/{session,participant}.go` + baseline | model/migration | CRUD | `CHAT/models/*` + chat baseline. Record demo-seed v1.0.1 ID as comment. `active_conversation_id` unique; no `updated_at`; `CREATED_AT=started_at` | exact | +| `VIDEO/routes.go` + `VIDEO/api/*.go` | route/controller | request-response | `CHAT/routes.go` (named buckets: `video-initiate` 5/min on **start only**, `call-link` 10/min, general API bucket) | exact | +| **Plan 6 — tests + gate** | | | | | +| `{UF,FORUM,BOTS,HERALD,VIDEO}/testmain_test.go` | test | — | `CHAT/testmain_test.go` (`flag.Parse` + `testing.Short`) | exact | +| `{…}/harness_test.go` | test | — | `CHAT/harness_test.go` + `CH/harness_test.go` (`realtime.driver=memory`; chatbots/chatvideo also boot `golem15.chat`) | exact | +| `{…}/fixture_plugin_test.go` (`acme`) | test | — | `CHAT/fixture_plugin_test.go` (`acme.teams`, `TestFixtureAbsentFromProduction`) | exact | +| `{…}/http_tracer_test.go` | test | request-response | `CHAT/http_tracer_test.go` (`bootWeb`, `frontendToken`, `call`, IDOR → 404) | exact | +| `{…}/testdata/goldens/` + `golden_test.go` | test | transform | `CHAT/golden_test.go` + `CHAT/testdata/goldens/` | exact | +| `{…}/testdata/PHP_METHOD_MAP.md` + `php_method_map_test.go` | test | — | `CHAT/php_method_map_test.go` + `CHAT/testdata/PHP_METHOD_MAP.md` | exact | +| `{…}/updates/baseline_test.go` | test | — | `CHAT/updates/baseline_test.go` | exact | +| LiveKit Twirp fake + push `HTTPClient` stub | test | request-response | `FW/modules/fetchguard/client.go` `WithTransport` for push; LiveKit is a new `RoomAPI` fake (do not import pion) | role-match | +| `FW/scripts/check-phase19.1.sh` | test gate | batch | `FW/scripts/check-phase19.sh` (stages, sibling env overrides, fail-closed named tests; five plugin roots) | exact | +| `FW/.planning/ROADMAP.md` Repos + SC2 + SC3 | docs | — | same file (first planning commit) | exact (extend) | +| `USR/classes/events.go` | — | — | **do not touch** (Pitfall 14) | n/a | + +## Pattern Assignments + +### `UF|FORUM|BOTS|HERALD|VIDEO/go.mod` + `go.work` (config) + +**Analog:** `CHAT/go.mod` lines 1–14 and 147; `CHAT/go.work` whole file; `CH/go.work` for chat replace. + +``` +module git.golem15.com/golem15/sm-userfriends-plugin +go 1.27.0 +require ( + git.golem15.com/golem15/sm-user-plugin v0.1.1 + git.golem15.com/golem15/summercms v0.2.2 + github.com/go-gormigrate/gormigrate/v2 v2.1.7 + github.com/jackc/pgx/v5 v5.10.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + gorm.io/gorm v1.31.2 +) +replace git.golem15.com/golem15/summercms => ../summercms.go +``` + +`go.work`: + +``` +go 1.27.0 +use . +replace git.golem15.com/golem15/summercms => ../summercms.go +replace git.golem15.com/golem15/sm-user-plugin => ../fonoteka.go/plugins/golem15/user +``` + +- chatbots and chatvideo also `require` `sm-chat-plugin` and `replace … => ../sm-chat-plugin` (`CH/go.work:7`). +- userfriends **must not** require chat in `go.mod` (soft adapter). Test files may import chat only behind build tags / the optional Lookup; if the adapter test needs chat, the **test host** `go.work` may replace chat without the production `plugin.go` `Requires()`. +- herald `go.mod` adds `github.com/SherClockHolmes/webpush-go v1.4.0` **after** the decision note commit. +- chatvideo `go.mod` adds `github.com/livekit/server-sdk-go/v2 v2.19.0` **after** the decision note. Production sources talk to `RoomAPI` only; tests must not import `pion`. +- forum `go.mod` must **not** require bluemonday or goldmark; it consumes D-17. + +### `*/plugin.go` (provider) + +**Analog:** `CHAT/plugin.go` (112 lines) and `CH/plugin.go` (95 lines). + +Interface assertions (`CHAT/plugin.go:29-39`); drop `HasJobs`/`HasCommands`/`HasSchedule` when the plugin has none: + +```go +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) +) +``` + +- `const PluginID = "golem15."` (`CHAT/plugin.go:42`). +- `//go:embed config` / `//go:embed lang` (`CHAT/plugin.go:48-52`). +- `Register` stores `p.app`; `Boot` → `p.runBootSteps(app)` (`CHAT/plugin.go:66-75`). +- `Migrations() → updates.All()`, `Models() → models.All()`. +- `init() { party.Register(&Plugin{}) }`. +- Package doc: one paragraph, no consuming-app name. + +**Requires() (PHP → Go, RESEARCH verified):** + +| Plugin | `Requires()` | +|--------|----------------| +| userfriends | `{"golem15.user"}` only — chat is soft | +| forum | `{"golem15.user"}` | +| chatbots | `{"golem15.chat", "golem15.user"}` — **never** Golem | +| herald | `nil` / empty (PHP Apparatus dissolved; no user-model import beyond id) | +| chatvideo | `{"golem15.chat"}` (PHP also `$require` WebSockets → lighthouse, not a plugin) | + +**Setter analog** (`CH/plugin.go:83-90`) for ForumAccessResolver, BotAuthResolver, PresenceGate: + +```go +func (p *Plugin) SetAccessResolver(r contracts.AccessResolver) { + if p == nil || p.service == nil { + return + } + p.service.SetAccessResolver(r) +} +``` + +**Jobs analog:** `CHAT/plugin.go:85-100` + `FW/modules/conga/job.go:53-64`: + +```go +func (p *Plugin) Jobs() []pact.Job { + return []pact.Job{ + conga.Job(p.maintainThreadSummary, conga.OnQueue(jobs.Queue), conga.MaxAttempts(1)), + } +} +``` + +Herald: Kind `golem15.herald.send`, queue `default` or `push`. Video reconcile job: Kind `golem15.chatvideo.reconcile_sessions`, `MaxAttempts(1)`. + +**Schedule analog:** `PG/plugin.go:41` `_ pact.HasSchedule` and `PG/schedule.go:15-22`. Video: + +```go +func (p *Plugin) Schedule() []pact.ScheduledCommand { + return []pact.ScheduledCommand{{ + Command: "chatvideo:reconcile-sessions", + Cadence: pact.Every(5 * time.Minute), + }} +} +``` + +`pact.Every` must divide 24h (`FW/modules/pact/capabilities.go:131-136`; `FW/modules/conga/schedule.go:84-86`). 5 minutes does. + +### `*/boot.go` (provider, boot) + +**Analog:** `CHAT/boot.go:22-52` (`registerBootStep` / `runBootSteps`, errors `"%s: boot %s: %w"`) and `CH/boot.go:49-68` (deny-all Warn + `lagoon.OnDatabase`). + +```go +func (p *Plugin) runBootSteps(app *backpack.App) error { + for _, step := range bootSteps { + if err := step.fn(p, app); err != nil { + return fmt.Errorf("%s: boot %s: %w", PluginID, step.name, err) + } + } + return nil +} +``` + +Deny-all Warn (`CH/boot.go:64-66`): + +```go +if _, ok := svc.Resolver().(classes.DenyAll); ok { + slog.Default().Warn(PluginID + ": ChannelAccessResolver is deny-all; the host must SetAccessResolver") +} +``` + +Copy that string shape for `ForumAccessResolver`, `BotAuthResolver`, `PresenceGate`. HeraldProfile default is Neutral (app-name-only) — **no** Warn for the profile; Warn only while PresenceGate is DenyAll. + +**GDPR listener analog:** `CHAT/boot.go:176-193`: + +```go +app.Events.Listen[*userclasses.DeletingEvent](PluginID, p.onUserDeleting) +``` + +Import `userclasses "git.golem15.com/golem15/sm-user-plugin/classes"`. Use `svc.WithDB(ev.Tx)` (`CHAT/classes/service.go:95-108`). Cascades (PHP, D-03): + +- chatbots: delete `golem15_chatbots_bot_flags` for that `user_id` +- forum: `user_id = null` on threads and posts; keep bodies +- userfriends: heir oldest admin else oldest member else hard-delete empty group; then delete member and ban rows (order by `role`, not MySQL `FIELD()`) + +**Soft chat adapter (userfriends only):** `CHAT/README.md:44-50` + `CHAT/contracts/registry.go:132-165`. In Boot, `reg, ok := app.Lookup[*chatcontracts.Registry]()`; if `!ok` skip. If ok, `reg.Add(PluginID, Registration{Alias: "friendcontext", …})` and optionally `reg.SetProvider(...)`. Never `Requires()` chat. + +**Video authorizer registration analog:** `CHAT/boot.go:83-86`: + +```go +return svc.Registry().Register("video", classes.AuthorizeVideoLookup(func() *gorm.DB { + gdb, _ := app.Lookup[*gorm.DB]() + return gdb +})) +``` + +`user:{id}` for `video:call:incoming` is already authorized by `USR/classes/authorizer.go:16-30`. Satellites must **not** re-register `user`. + +### Deny-all seams + +**Analog:** `CH/contracts/access_resolver.go` (whole file) and `CH/classes/deny_all.go` (whole file). Short-test analog: `CH/tracer_test.go:67-76` `TestAccessResolverDefault`. + +```go +type DenyAll struct{} +var _ contracts.AccessResolver = DenyAll{} +func (DenyAll) CanAccessContext(context.Context, uint, string, uint) bool { return false } +func (DenyAll) CanManageContext(context.Context, uint, string, uint) bool { return false } +``` + +`CH/classes/channel_service.go:60-117`: `New` installs DenyAll when Resolver is nil; `SetAccessResolver(nil)` becomes DenyAll. + +Apply: + +| Seam | Default | Methods | +|------|---------|---------| +| `ForumAccessResolver` | DenyAll | can-view / can-moderate (names at executor discretion; PHP ForumAccessResolver) | +| `BotAuthResolver` | DenyAll | resolve bearer → user id (deny = no bot) | +| `PresenceGate` | DenyAll | `ShouldSend` always false until host installs | +| `HeraldProfile` | Neutral (app name only) | **not** DenyAll | + +### `*/classes/*_service.go` (service, CRUD) + +**Analog:** `CHAT/classes/service.go:1-108` and `CH/classes/channel_service.go:1-117`. + +- Package doc: published as `*classes.Service` (or named type), found with `app.Lookup[*classes.Service]()`. +- Sentinel errors prefixed `golem15.:` (`CHAT/classes/service.go:26-40`). +- `Options{DB, Bus, …}`; `New` refuses nil DB. +- Writes go through `lagoon.Transaction`. Fire festival events **inside** the tx (`CHAT/classes/service.go:175-201`): + +```go +err = lagoon.Transaction(ctx, s.db, func(ctx context.Context, tx *gorm.DB) error { + if err := tx.Create(msg).Error; err != nil { + return err + } + if s.bus != nil { + if err := s.bus.Fire(ctx, &events.MessageCreated{MessageEvent: events.MessageEvent{ + MessageID: msg.ID, /* … */ Tx: tx, + }}); err != nil { + return err + } + } + return s.jobs.Enqueue(ctx, tx, jobs.MaintainThreadSummaryArgs{RootID: *rootID}, conga.EnqueueOpts{ + Queue: jobs.Queue, MaxAttempts: 1, + }) +}) +``` + +Herald `SendEvent` / `SendUnconditional` must Enqueue on the **caller's tx** the same way (`CHAT/classes/service.go:336-343`) so a rolled-back write pushes nothing (D-11). + +**Forum `DeleteContext`:** no PHP method. Implement on ForumService: soft-delete categories (and cascaded threads/posts) for morph `(type, id)`. Do not import userfriends. Analog for the cascade style: `CH/boot.go:85-101` (`SeverUserFromContext` loop) — own the SQL in forum. + +### `*/events/events.go` (userfriends) + +**Analog:** `CHAT/events/events.go` whole file. + +```go +const GroupCreatedEventName = "userfriends.GroupCreated" +const GroupDeletedEventName = "userfriends.GroupDeleted" +const MemberJoinedEventName = "userfriends.MemberJoined" +const MemberLeftEventName = "userfriends.MemberLeft" + +type GroupEvent struct { + GroupID uint + ActorID uint + MemberID uint + Tx *gorm.DB +} +``` + +Grep-able one-constant-per-line. Fire inside the write tx. RESEARCH field set: GroupID, ActorID, MemberID. + +**Do not add** `user.Created` / `user.Deleting` — already at `USR/classes/events.go:9-12`: + +```go +const CreatedEventName = "user.Created" +const DeletingEventName = "user.Deleting" +``` + +### `*/models/*.go` + `updates/*baseline.go` + +**Analog:** `CHAT/models/conversation.go` (whole file) + `CHAT/models/registry.go` + `CHAT/updates/registry.go` + `CHAT/updates/202610160001_create_golem15_chat_baseline.go:12-28`. + +```go +func (Conversation) TableName() string { return "golem15_chat_conversations" } +func init() { Register(&Conversation{}) } +``` + +Baseline: one gormigrate ID, every statement `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`, PHP table/column/index/constraint names. Types TIMESTAMPTZ / JSONB. Enums → `varchar` + `CHECK`. `CHAT/updates/registry.go:27-36` `run(stmts...)`. `init() { Register(createBaseline...) }`. + +Demo-seed version IDs (chatvideo `v1.0.1`, userfriends `v1.0.1`): comment in the baseline file; **do not** `INSERT` demo users (D-02 / D-07). + +Herald FK to `users` only if `users` exists — PHP `create_push_subscriptions_table.php` is conditional; Go test host always has `golem15.user` so the FK can stay. Production adopt of a DB without users is out of scope (herald still must not import user **models**). + +Forum `golem15_forum_categories`: `context_type` NOT NULL, **no FK**; unique `(context_type, context_id, slug)`. + +Chatvideo session: no `updated_at`; GORM `CREATED_AT=started_at` (PHP VideoSession). Unique `active_conversation_id`. `join_code` alphabet `abcdefghijklmnopqrstuvwxyz0123456789` length 10. + +**Baseline short test analog:** `CHAT/updates/baseline_test.go` — grep table names in the source file; runs under `-short`. + +### `*/routes.go` + `*/api/*.go` (controller, request-response) + +**Analog:** `CHAT/routes.go` (whole file) + `CH/routes.go`; handlers `CHAT/api/auth.go`, `CHAT/api/errors.go`, `CHAT/api/json.go`, `CHAT/api/conversations.go`. + +```go +const APIPrefix = "/_userfriends/api/v1" +const APIBucket = "userfriends-api" + +func (p *Plugin) Routes(r pact.Router) error { + r.Group(APIPrefix, surf.Use("jwt.auth", "throttle:"+APIBucket), func(g pact.Router) { + // … + }) + return nil +} +``` + +Discretion paths (RESEARCH defaults): + +| Plugin | Prefix / routes | +|--------|-----------------| +| userfriends | `/_userfriends/api/v1/friends` (list, requests, sent, request, accept, reject, cancel, unfriend) + `/groups` (members, invites, bans) | +| forum | `/_forum/api/v1/contexts/{type}/{id}/categories` + threads/posts/read/unread | +| chatbots | `GET /_chatbots/api/v1/commands`, `POST /_chatbots/api/v1/dispatch` body `{input}` | +| herald | `POST/PATCH/DELETE /_herald/api/v1/push/subscription`, `GET /_herald/api/v1/push/vapid-public-key` | +| chatvideo | `/_chatvideo/api/v1/conversations/{id}/video/{start,join,leave,end,status}` + `/_chatvideo/api/v1/call-links/{code}` (conversation id primary; optional context lookup through chat registry) | + +**Auth analog** (`CHAT/api/auth.go:16-45`): actor id from `bouncer.User`; backend principal is not an actor; missing JWT → 401 `"Authentication required"`. CMD-06: never take invoker id from the body. + +**Error analog** (`CHAT/api/errors.go:32-50`, `CH/api/errors.go:41-46`): `wire.WriteJSON`; missing / unauthorized / unknown type → **404** `"Not found"` (IDOR collapse); 422 validation map; 500 logs `golem15. api:`. + +**Buckets analog** (`CHAT/routes.go:54-70`). Key = frontend user id else IP. RESEARCH rate table: + +| Bucket | Max/min | Where | +|--------|---------|-------| +| general per API | 120 (chat analog) | remaining routes | +| `community-join` | **10** | friend request; group join/preview if in generic API. **Not** 5 | +| `video-initiate` | 5 | generic video **start only** (PHP lived on dropped `/api/video/initiate`; D-05 still carries it) | +| `call-link` | 10 | `GET call-links/{code}` | + +`lighthouse.Mount` (`FW/modules/lighthouse/route.go:62-90`) is the **app's** job: plugin only declares `surf.Use("jwt.auth")`. Guest-call buckets are out of scope. + +### lighthouse video authorizer (do not use ChannelID) + +**Analog:** `CHAT/classes/authorizer.go:17-99`. Footgun: `FW/modules/lighthouse/channel.go:35-46` — `ChannelID("video:session:9")` reads segment 1 `"session"` → 0. + +```go +func parseVideoSessionChannel(channel string) (uint, bool) { + channel = strings.TrimPrefix(channel, "presence:") + parts := strings.Split(channel, ":") + if len(parts) != 3 || parts[0] != "video" || parts[1] != "session" { + return 0, false + } + // digits-only parts[2], ParseUint, id != 0 — copy parseChatConversationChannel +} +``` + +Allow only participants of the session's **conversation**; capabilities `["prs"]` (`CHAT/classes/authorizer.go:42-48`). Register namespace `"video"` (`FW/modules/lighthouse/registry.go:64-84` — second Register of the same namespace errors). + +**Emit analog:** `CHAT/classes/emit.go:21-26` + `FW/modules/lighthouse/suppress.go:56-81`. Video notifier: if `Emit` fails, `slog` and return nil (PHP VideoCallNotifier 73-75). Events/channels from RESEARCH table (`video:call:incoming` → `user:{id}`; others → `video:session:{id}`). + +### Morph rewrite (`FORUM/console/rewrite_context_types.go`) + +**Analog:** `CHAT/console/rewrite_context_types.go` whole file + `CHAT/classes/writes.go:220-241`. + +Forum ships `forum:rewrite-context-types` for `golem15_forum_categories.context_type` so forum can adopt without chat. Walk `LegacyPHPClass` → alias. Do not extend chat's command. + +userfriends FriendContext adapter registers alias `friendcontext` with `LegacyPHPClass` `Golem15\UserFriends\Models\FriendContext` (and groups `friendgroup` / `Golem15\UserFriends\Models\FriendGroup` if it registers those). Chat's rewrite already covers conversation rows; forum's covers category rows. + +### `*/console/*.go` (commands) + +**Analog:** `CHAT/console/rewrite_context_types.go:16-35` (`bonfire.Command{Name, Description, Run}`). + +| PHP / D-ID | Go name | +|------------|---------| +| `herald:vapid-generate` | `summer herald:vapid-generate` — prints `VAPID_PUBLIC_KEY=` / `VAPID_PRIVATE_KEY=` lines; **never log** the private key | +| (none in chatvideo plugin) | `summer chatvideo:reconcile-sessions` | +| `wave:reconcile-video-sessions` | **not this phase** (Wavepath thin alias later) | + +Anti-analog: `FW/modules/flare/commands.go:25-28` `websockets:generate-vapid-keys`. Herald must not import `modules/flare`. + +### fetchguard (herald send + LiveKit HTTP) + +**Analog:** `FW/modules/fetchguard/policy.go:17-30` and `FW/modules/fetchguard/client.go:40-51, 79-86`. + +```go +public, err := fetchguard.NewClient(fetchguard.Policy{Mode: fetchguard.PublicOnlyMode}, cfg) +// webpush-go Options.HTTPClient = public // Do(*http.Request) (*http.Response, error) +// NO wrapper type (Pitfall 8) +``` + +Subscribe-time: reject non-HTTPS / non-public hosts with the same policy (fail-closed; PHP `AllowAllEndpointGuard` was fail-open). Tests: `fetchguard.WithTransport` (`client.go:54-68`). EndpointGuard PHP tests → N/A + pointer to fetchguard's own tests (Pitfall 13). + +LiveKit admin HTTP: `Policy{Mode: fetchguard.TrustedMode}` (operator URL, may be `http://localhost:7880`). Never TrustedMode for a user-supplied push endpoint. + +### LiveKit wrapper (`VIDEO/classes/livekit.go`) + +No in-tree SDK analog. Copy the **service wrapper** shape from `CHAT/classes/service.go` (`Options`, refuse nil, log). RESEARCH interface: + +```go +type RoomAPI interface { + CreateRoom(ctx context.Context, name string) error + DeleteRoom(ctx context.Context, name string) error // missing-room swallow (D-13), even though PHP LiveKitService::deleteRoom does not + RemoveParticipant(ctx context.Context, room, identity string) error + ParticipantCount(ctx context.Context, name string) (int, error) // missing-room → 0 (PHP does swallow) + ActiveRoomNames(ctx context.Context) ([]string, error) +} +``` + +Token claims golden vs PHP `LiveKitService.php:80-99` (HS256, `iss` API key, `sub` user id string, `name`, `iat`/`nbf`/`exp`, nested `video` grant, optional JSON `metadata`). If `protocol/auth` `ToJWT` differs, mint with `jwt/v5` MapClaims **inside the same wrapper**. Admin URL fallback: `livekit_admin_url` else rewrite `wss://`/`ws://` → `https://`/`http://`. Room name: `chat-conversation-{id}-{16 hex}`. Do not construct `lksdk.Room`. Tests ship a fake; do not import pion. + +D-14 reconciler: if room missing/empty or `started_at + max_duration` elapsed → end session + deleteRoom (swallowed) + `video:call:ended`. **No** GuestCall, call cards, CallNotifier, `start_grace_seconds` (Wavepath-only). + +### Decision notes (before importing commits) + +**Analog:** `FW/.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` (plugin-owned library: version, license, why not hand-roll, exact import path, consequences for `go.mod`). + +| Note | Must name | Must not | +|------|-----------|----------| +| webpush-go | `github.com/SherClockHolmes/webpush-go` **v1.4.0**; `Options.HTTPClient` = `*fetchguard.Client`; TTL 2419200 / urgency `normal`; prune 404/410 | `flare`, `push.*`, `websockets:generate-vapid-keys` | +| LiveKit | module path **`github.com/livekit/server-sdk-go/v2` v2.19.0** (not v1); `RoomAPI` interface; golden JWT; pion/media-sdk/buildkit flagged | constructing `lksdk.Room` in tests | +| bluemonday forum policy | v1.0.27 already in framework `go.mod`; **named forum policy** (AllowElements PHP list, schemes http/https/mailto, `RequireNoFollowOnLinks(true)`, `AddTargetBlankToFullyQualifiedLinks(true)`) | `seaglass.UGCPolicy`, `cabana.RenderMarkdown`, forum-owned goldmark | + +Seaglass anti-analog (`FW/modules/seaglass/seaglass.go:22-32`): `RequireNoFollowOnLinks(false)`, allows `tel:`. Cabana anti-analog (`FW/modules/cabana/field_markdown.go:14-34`): goldmark **without** GFM + regex gate. Existing `2026-10-11-framework-html-sanitizer-bluemonday.md` is the richeditor/seaglass decision — do not reuse it as the forum policy note. + +### Test host + +**Analog:** `CHAT/testmain_test.go` (whole file) + `CHAT/harness_test.go:51-134` + `CH/harness_test.go` (adds `golem15.chat` + `search.driver=null`) + `CHAT/fixture_plugin_test.go:24-125, 141-185`. + +```go +func TestMain(m *testing.M) { + flag.Parse() + if !testing.Short() { + // testcontainers postgres + } + code := m.Run() + stopPostgres() + os.Exit(code) +} +``` + +Default plugins: + +| Repo | `party.Activate` ids | +|------|----------------------| +| userfriends | `golem15.user`, `golem15.userfriends`, `acme.*` (fixture if groups need a context) | +| forum | `golem15.user`, `golem15.forum`, `acme.*` (allow-all test resolver installed after Boot) | +| chatbots | `golem15.user`, `golem15.chat`, `golem15.chatbots` | +| herald | `golem15.user` (JWT only), `golem15.herald` — plugin itself still must not import user models | +| chatvideo | `golem15.user`, `golem15.chat`, `golem15.chatvideo` | + +Env: `SUMMER_REALTIME__DRIVER=memory`. Fixture plugin ID `acme.teams` / `acme` — `TestFixtureAbsentFromProduction` greps it out of production sources plus QuestStream/family/parent/child. + +**HTTP tracer analog:** `CHAT/http_tracer_test.go:35-79, 91-149` (`surf.Assemble`, `usercontrollers.IssueToken`, `Authorization: Bearer`, stranger → 404). RESEARCH tracers: userfriends A→B request/accept; forum GFM table + no `