From f179ec060c27184451188f641654e34fd35c6bdb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 17 Oct 2026 23:36:34 +0200 Subject: [PATCH] docs(19.1): research phase domain --- .../19.1-chat-satellites/19.1-RESEARCH.md | 915 ++++++++++++++++++ 1 file changed, 915 insertions(+) create mode 100644 .planning/phases/19.1-chat-satellites/19.1-RESEARCH.md diff --git a/.planning/phases/19.1-chat-satellites/19.1-RESEARCH.md b/.planning/phases/19.1-chat-satellites/19.1-RESEARCH.md new file mode 100644 index 00000000..8f60fc19 --- /dev/null +++ b/.planning/phases/19.1-chat-satellites/19.1-RESEARCH.md @@ -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 (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 (``, `[x](javascript: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] + + + +## 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 + + +## 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 `` for GFM and no `