diff --git a/.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md b/.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
new file mode 100644
index 00000000..c988d589
--- /dev/null
+++ b/.planning/phases/19.7-generic-mcp-module/19.7-RESEARCH.md
@@ -0,0 +1,688 @@
+# Phase 19.7: Generic MCP module - Research
+
+**Researched:** 2026-10-21
+**Domain:** MCP streamable HTTP server inside a Go binary (official go-sdk), in-process route dispatch through surf, OAuth resource-server verification, frontend/backend identity link
+**Confidence:** HIGH for in-repo seams and SDK behaviour (source read and probed this session); MEDIUM for MCP-ecosystem direction (spec 2026-07-28); LOW for client-compatibility claims (marked [ASSUMED])
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Prerequisites and sequencing
+- **D-01:** **Both prerequisite todos are folded into 19.7**, as early plans before the MCP module itself: `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`. One sm-user-plugin release, one pin bump per app, and one security review cover the whole identity chain.
+- **D-02:** **19.7 runs after 19.4 is complete.** OAuth for MCP comes from `sm-oauthserver-plugin`'s MCP `wristband.Server` (`/oauth/mcp/*`, DCR, 19.4 D-01/D-02/D-06). 19.7 adds no second OAuth adapter. It adds the `backend` scope (D-21) and resolves the backend principal next to the frontend one.
+- **D-03:** **The account link lives in sm-user-plugin.** That covers:
+ - the table, for example `golem15_user_backend_links` (name is the planner's call);
+ - the proof round-trip;
+ - a new backend permission (for example `golem15.user.manage_backend_links`);
+ - the admin set/remove action;
+ - an `app.Lookup` service the MCP module calls, for example `LinkedBackendUser(ctx, frontendUserID)` (name is the planner's call).
+
+ Only sm-user-plugin can verify a frontend sign-in (password, sociallogin, the mfa gate), and sm-oauthserver-plugin already `Requires()` it. The framework never imports the user plugin. — **Reversibility:** costly — the table and service become part of a core plugin's contract consumed by apps and pier.
+- **D-04:** **cabana gains an additive, generic seam** so that a plugin can contribute:
+ - a section or action to **My Profile** (`cabana/account.go` and the admin SPA account page), which sm-user-plugin uses for "Link frontend account";
+ - a record action on the **backend Users** screen (set/remove the link).
+
+ The seam is generic and names neither the user plugin nor MCP. Existing cabana payloads are unchanged. The researcher checks whether the 19.6 D-15 "Reset 2FA" action needs the same seam, and reuses it if so.
+- **D-05:** **The link is proven by a site sign-in round-trip.** "Link frontend account" creates a one-shot, signed, short-lived link request bound to the admin. The admin is sent to the site login with it. The frontend user signs in by any method the site supports (password, social login, 2FA), and the request is confirmed. Only then is the link saved. Linking by matching email, or by picking a user from a list without proof, is never allowed. — **Reversibility:** one-way — a link created without proof lets an admin act as that frontend user through MCP; unproven links cannot be told apart afterwards.
+- **D-06:** **Cardinality 1:1**, unique on both sides: a frontend user has at most one backend link, and a backend user has at most one frontend link.
+- **D-07:** **Admin management behind the new backend permission.** A holder can remove any link and can set one for editor onboarding. A link set by an admin records `linked_by` and fires an event. The self-service My Profile path always needs the D-05 proof.
+- **D-08:** **The link is resolved on every request, not stored in the OAuth grant.** Unlinking, deleting either user, or revoking the grant takes effect on the next MCP call with no token surgery. One indexed query per request, which may be cached briefly; the planner decides and states the staleness bound.
+
+#### Frontend permission codes (todo)
+- **D-09:** A new optional `pact` capability is the frontend counterpart of `pact.HasPermissions`. A plugin returns codes with label, tab and comment. sm-user-plugin offers them in the user and group permission editors next to the rows seeded in `golem15_user_frontend_permissions`. Additive only: existing seeded rows, the table, the codes and the payloads stay as they are (core-plugin rule).
+
+#### Tool declaration and dispatch
+- **D-10:** **A tool binds to a route by method + path** (for example `POST /_journal/api/v1/posts`), resolved at boot in surf's route table. **Boot fails** if the route does not exist. The call goes **in-process through that route's full middleware chain** (throttles, scope, permission), so it runs the same code path as HTTP. A tool carries no handler func.
+- **D-11:** **Arguments map through one input struct with tags.** `path:"id"` fills `{id}`, `query:"q"` goes to the query string, and the remaining `json` fields form the body. The input JSON schema is reflected from the same struct, with `jsonschema` tags supplying descriptions.
+- **D-12:** **Names are auto-prefixed with the plugin short code.** A plugin declares `create_post`, and pier exposes `journal_create_post`. A duplicate within one plugin is a boot error. Prompts follow the same rule. — **Reversibility:** costly — clients and saved agent configs reference tool names once shipped.
+- **D-13:** **Results pass the route's JSON through.**
+ - A 2xx body is returned as text content plus `structuredContent`.
+ - An optional Output struct produces an `outputSchema`.
+ - A 4xx/5xx becomes `isError: true` carrying the route's own error body, so the agent sees the real validation errors.
+- **D-14:** **Annotations are derived from the HTTP method and can be overridden.** GET gives `readOnlyHint`, DELETE gives `destructiveHint`, and PUT gives `idempotentHint`. A tool may override any hint.
+- **D-15:** **Only token-guarded routes can be targets.** At boot the route must either carry the guard's token-accepting middleware (`user.api_token` for `user`, the cabana backend token guard for `backend`), or its tool must explicitly declare a guard. The second case covers routes that authenticate inside the handler. A cookie/CSRF-only or public route can't become a tool by accident.
+- **D-16:** **Permissions = route middleware ∪ tool declaration.** pier derives the requirements from route middleware where present (`user.scope:*`, `user.permission:`, cabana scope/permission). A tool may also declare `Permissions`, which `tools/list` checks too. Journal is the case that needs this: `POST /_journal/api/v1/posts` has only `throttle:journal-api` and checks `cabana.Allows` inside `requireBackendPrincipal`. The declaration only filters the list, and the route still enforces on every call.
+- **D-17:** **Identity reaches the handler as a pre-resolved principal on the context.** pier sets the resolved principal (`bouncer.WithUser` / the user plugin's equivalent) behind a framework-owned marker that only an in-process pier dispatch can set; it must not be settable from an HTTP request. The following honour that marker before parsing the Authorization header, additively:
+ - bouncer;
+ - sm-user-plugin's `user.api_token` middleware;
+ - Journal's `requireBackendPrincipal`.
+
+ No credential is minted per call. Security review covers the marker. — **Reversibility:** costly — three auth paths in two core plugins learn the marker.
+- **D-18:** **The first tools ship in this phase.** Journal declares backend post tools (list/show/create/update, plus whatever research finds useful), and sm-user-plugin declares a few frontend tools (for example own profile). sm-demo-app enables pier and mounts both, which proves both guards end to end.
+
+#### Session credentials and filtering
+- **D-19:** **Personal tokens can open an MCP session behind a config flag**, off by default (spelling is the planner's call, for example `pier.allow_personal_tokens`).
+ - A `user_api_tokens` bearer yields frontend tools only.
+ - A cabana `g15_…` token yields backend tools only.
+ - Each is capped by that token's own scope, exactly as over HTTP.
+
+ OAuth is always available.
+- **D-20:** **OAuth `read`/`write` are a ceiling.** A grant with only `read` offers only tools with `readOnlyHint`, and `write` unlocks the rest. Permissions always filter on top: a scope never grants anything.
+- **D-21:** **Backend tools need a separate `backend` OAuth scope.** A client must request `backend`. The consent screen shows a line such as "also act as admin " only when the user has a link. A user can therefore connect a client to frontend tools only, and the scope is a visible, revocable ceiling. This adds the scope to sm-oauthserver-plugin's MCP Server (additive to 19.4's `read`/`write`/`ai`/`offline_access`). — **Reversibility:** costly — published scope name in the MCP metadata and in existing grants.
+- **D-22:** **The backend principal keeps the linked admin's real cabana permissions**, superuser included, capped by the grant's read/write scope (D-20). The security review covers superuser reach through MCP.
+- **D-23:** **A call runs as the tool's declared guard.** Each tool has exactly one guard: a `backend` tool runs as the linked admin, and a `user` tool runs as the frontend user. `tools/list` is the union of what each identity may call. Audit and actor attribution name the real actor.
+
+#### Transport, SDK and features
+- **D-24:** **The SDK is the official `github.com/modelcontextprotocol/go-sdk`.** A decision note under `.planning/notes/` names it and its pinned version before code lands, and `.planning/research/STACK.md` is updated (CLAUDE.md dependency rule). Research also:
+ - checks the transitive dependencies, including `google/jsonschema-go` for schema reflection;
+ - confirms that the streamable HTTP handler supports the session-store and auth hooks needed for D-25.
+- **D-25:** **Stateful streamable HTTP with Redis-backed sessions** (`Mcp-Session-Id`, SSE streams), shared across instances on 19.3's Redis.
+ - A session never replaces the bearer: **every request is authorized fresh**.
+ - A session is **bound to the principal(s) that opened it**. A different token presenting the same session ID is refused.
+ - DELETE on the endpoint ends a session.
+
+ — **Reversibility:** costly — session storage and the multi-instance fan-out shape the module's internals.
+- **D-26:** **`list_changed` is pushed.** These events fan out over Redis pub/sub to the instance holding the session, which sends `notifications/tools/list_changed` (and `prompts/list_changed`):
+ - frontend user or group permission changes;
+ - backend permission changes;
+ - link set or removed;
+ - grant or token revoked.
+
+ Every request still re-checks (D-08), so the push only shortens how long a client shows a stale list.
+- **D-27:** **Sessions use a sliding idle TTL** (configurable, for example 30 min) in Redis. Revoking the grant or token, deleting the user or removing the link ends or downgrades the session.
+- **D-28:** **The capability is designed for tools, prompts and resources; 19.7 ships tools and prompts.** A prompt is a template with arguments, filtered by the same guard and permissions and prefixed like tools (D-12). Resources (URI template → GET route) are deferred, but the `pact` capability's shape leaves room for them without a breaking change. — **Reversibility:** costly — the pact capability is a published framework API.
+- **D-29:** **Module name `pier`**, where outside boats (agents) dock to reach the beach. It gets a README in the standard structure with a `**Name:**` mnemonic and a row in the root `README.md` table.
+- **D-30:** **Off by default.** With pier disabled nothing is mounted and nothing changes, the same pattern as `http.api_docs.enabled` mounting `/api-docs` (`modules/surf/apidocs.go`).
+
+#### Quality gates (from the roadmap)
+- **D-31:** The phase gets a **security review**: link proof, the D-17 context marker, session binding, scope ceilings and superuser reach. **Unit tests are the last plan.** Commit order follows the existing pattern:
+ 1. framework release (pier, the cabana seam, the bouncer marker);
+ 2. sm-user-plugin;
+ 3. sm-oauthserver-plugin (`backend` scope, consent line);
+ 4. sm-journal-plugin (tools, marker);
+ 5. app pointer bumps as separate commits.
+
+ fonoteka.go and sm-bm-app must still build and pass at the bumped pins. Push, tag and repo work need the user's go-ahead.
+
+### Claude's Discretion
+- Exact Go identifiers: the pact capability and tool/prompt struct names, the link service, the context marker, the table name and the permission code.
+- Config key spellings (enable flag, endpoint path with default `/mcp`, the personal-token flag, the session TTL).
+- The link-request token format and TTL, within "one-shot, signed, short-lived, bound to the admin".
+- The cache bound for the per-request link lookup.
+- Which Journal and user tools ship beyond the minimum that proves both guards.
+- How the cabana seam is shaped (contribution interface vs extending `AdminController`), as long as it is generic and additive.
+
+### Deferred Ideas (OUT OF SCOPE)
+- **MCP resources** (URI template → GET route, guard-filtered) and `resources/subscribe`. The capability is shaped for them (D-28), so a follow-up phase adds them.
+- **Generated admin CRUD tools from `fields.yaml`.** Ruled out in the design note: agents use plugin management routes.
+
+#### Reviewed Todos (not folded)
+The other `todo.match-phase` hits matched keywords only and are unrelated:
+- `journal-author-profile-fields`
+- `user-plugin-commands-open-database` (already folded into 19.5)
+- `translate-messages-as-nuxt-i18n-json-editor`
+- `reload-open-apps-when-a-new-version-is-deployed`
+- `uploads-keep-client-extension-stored-xss`
+
+
+
+## Phase Requirements
+
+No requirement IDs are mapped to this phase (ROADMAP: "Requirements: TBD"). The planner maps the four roadmap success criteria and D-01..D-31 instead:
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| SC-1 | Module enabled in config serves an MCP endpoint; disabled (default) mounts nothing | Pattern 1 (config-gated mount like `mountAPIDocs`), Pitfall 1 (surf buffers responses, so the mount needs a streaming route kind), Open Question 2 (build tag vs always linked) |
+| SC-2 | Plugin declares tools through an optional `pact` capability (name, description, input struct → JSON schema, guard, route); none declared → none exposed | Pattern 2 (pact capability shape, SDK-free), Pattern 4 (schema reflection with jsonschema-go `ForType`), D-12 prefixing |
+| SC-3 | `tools/list` filtered per principal; a call runs through the route's own middleware | Pattern 3 (surf route handler lookup + in-process dispatch), Pattern 5 (guard providers and requirement parsing), Pitfall 3 (frontend permission semantics differ between `user.permission` and `MergedPermissions`) |
+| SC-4 | One OAuth sign-in as frontend user; verified link → backend tools as that admin; removing link/grant removes them; security review; SDK decision note; unit tests last | Pattern 6 (token verification: `oas_at_` verifier does not exist yet), Pattern 7 (link proof via fresh frontend JWT), Pattern 8 (sessions in Redis with rehydration), Security Domain |
+
+
+## Project Constraints (from CLAUDE.md)
+
+- **Standard library first.** A new dependency needs a research doc or a phase/decision note naming it (`.planning/research/STACK.md` has the decided set). The go-sdk and jsonschema-go need the decision note **before** code lands (D-24).
+- **YAML via `goccy/go-yaml` only**, never `gopkg.in/yaml.v3`.
+- **Core plugins (user, blog/journal, pages, payment) — no breaking changes.** Every change in sm-user-plugin, sm-journal-plugin and sm-oauthserver-plugin must be additive (existing payloads, routes, tables, codes unchanged).
+- **Framework code never names a consuming application** (READMEs, docs, examples use neutral names such as `blog`/`acme`). pier must not name sm-user-plugin, Journal or MCP clients in code or docs; guard providers come in through `app.Lookup`.
+- **One Go module**; a new module gets a beach name (`pier`, D-29), a README in the standard structure (H1, one-sentence summary, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration, Dependencies, Testing) and a row in root `README.md` reusing the summary sentence.
+- **Docs rule:** a change to a module's exported API, config keys, CLI commands or dependencies updates that module's `README.md` and the affected `docs/` pages in the same commit. Every identifier in a README/docs page must exist (docs checker `summer docs:build --check`); config keys are reviewed by hand. Expect new `docs/services/mcp.md` (or similar) and updates to `docs/services/oauth-server.md`, `docs/services/authentication.md`, `docs/plugins/registration.md`, `docs/backend/users-and-permissions.md`.
+- **Tests:** `go vet ./...` and `go test ./...` green at every commit; Docker tests skip under `-short`; a `TestMain` starting containers calls `flag.Parse()` then checks `testing.Short()`.
+- **Admin SPA:** a change in `admin/` (the My Profile seam) requires rebuilding and committing `modules/boardwalk/dist` and passing `scripts/check-admin-dist.sh`; swag annotation changes need `scripts/check-admin-openapi.sh` (cabana `admin_openapi.go` → `admin/openapi/admin.json` → `admin/src/api/schema.d.ts`).
+- **MSD rules:** lean planning (few large plans; present plan count + one-line scopes and wait for confirmation); unit tests are the last plan; STATE.md only through `msd_run query state.*`; g15office sync.
+- **Commits:** no co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path; several sessions work concurrently (19.6 and 19.8 are in flight — do not touch their uncommitted files). No push/tag/deploy without go-ahead; every pushed `vX.Y.Z` framework tag gets a changelog post the same day.
+- **Shared plugin repos:** commit and push the plugin first, then the pointer bump in the app as a separate commit (`.planning/notes/core-plugins-own-repos.md`).
+- **User memory:** never add co-author tags; core plugins must not break.
+
+## Summary
+
+The phase is buildable with the official Go SDK, but three facts discovered this session reshape the plan relative to what CONTEXT assumes:
+
+1. **The go-sdk (v1.8.0, 2026-09-04) holds stateful sessions only in process memory** — `StreamableHTTPHandler.sessions map[string]*sessionInfo` with no pluggable store, and an unknown `Mcp-Session-Id` answers `404 session not found` [VERIFIED: go-sdk@v1.8.0/mcp/streamable.go:47-55, 558-566 — `sessions map[string]*sessionInfo // keyed by session ID`, `http.Error(w, "session not found", http.StatusNotFound)`]. D-25's Redis-shared sessions are still achievable: pier writes a thin session router over the SDK's **exported** `StreamableServerTransport{SessionID}` and `Server.Connect(ctx, t, &ServerSessionOptions{State})`, persisting the JSON-serialisable `ServerSessionState` in Redis and **rehydrating** a session on whichever instance receives the request. A probe built this session proved it: a session initialised on instance A served its next `tools/call` on instance B after rehydration [VERIFIED: local probe, output "rehydrated sess-A on B … call2: map[id:9 served_by:B]"]. Per-session `*mcp.Server` instances give per-principal tool lists and targeted `notifications/tools/list_changed` (the SDK debounces and sends it to that server's sessions when tools are added/removed) [VERIFIED: local probe "list_changed received; tools now: 1"].
+2. **The MCP spec moved to a sessionless protocol.** Spec 2026-07-28 removed `initialize` and the `Mcp-Session-Id` session [CITED: blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/]. The SDK serves 2026-07-28 **only** in `Stateless` mode; a stateful server rejects such requests with `CodeUnsupportedProtocolVersion` listing the legacy versions, and SDK clients fall back to `initialize` at 2025-11-25 (probe: "stateful negotiated: 2025-11-25 session: true"). D-25 therefore pins pier to protocol ≤ 2025-11-25. This works today, but it is a forward-compatibility risk the user should confirm (Open Question 1).
+3. **Several pieces CONTEXT treats as existing do not exist yet:** (a) nothing verifies `oas_at_` MCP access tokens at a resource — the oauthserver README says "Verifying MCP access tokens at a resource arrives with the MCP module, not here"; (b) the MCP consent page is "the application's" (wristband redirects to `{issuer}/connect?request=`) and neither sm-oauthserver-plugin nor vue-demo-app ships one; (c) there is **no backend Users admin screen** in cabana (admins are CLI-managed), so D-04's "record action on the backend Users screen" has no host; (d) wristband's MCP authorize allow-list of scopes is a hard-coded map, so adding `backend` needs a framework change, not only a plugin option; (e) the user plugin has **no** `user.api_token`-guarded route for "own profile" (`/fetch` is JWT-only), so D-18's frontend tool needs a new additive route; (f) surf's per-route recovery **buffers the whole response and makes `Flush` a no-op**, so an SSE endpoint cannot be mounted through the normal route wrapper.
+
+**Primary recommendation:** Build pier as a framework module that (a) gets a compiled per-route handler lookup from surf (additive `Router` method plus `SetPathValue`-based dispatch, mirroring `overlap.go`), (b) mounts `/mcp` through a new non-buffering streaming route kind, (c) authenticates every HTTP request itself through guard/verifier providers published by plugins (`oas_at_` verifier in sm-oauthserver-plugin, user token resolver in sm-user-plugin, cabana built in), (d) runs a Redis-backed rehydrating session router over the SDK transport with one `*mcp.Server` per session, and (e) passes identity to routes through a context-only dispatch marker honoured in `bouncer.Registry.Middleware` and `cabana.ResolveBackend` — which covers `user.api_token` and Journal's `requireBackendPrincipal` with no change to their code (Journal needs only a one-line change in `optionalBackendPrincipal`).
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| MCP transport (POST/GET/DELETE `/mcp`, SSE, sessions) | API / Backend (pier) | Database/Storage (Redis session registry) | Protocol lives in the binary; Redis only stores session state and fans out events |
+| Bearer verification per request | API / Backend (pier, via published verifiers) | — | `oas_at_` verifier in sm-oauthserver-plugin, user personal tokens in sm-user-plugin, `g15_` in cabana; pier never parses another plugin's tables |
+| Tool declaration (name, schema, guard, route) | API / Backend (plugins via `pact`) | — | Compiled Go declarations, SDK-free contract |
+| Tool execution | API / Backend (surf in-process dispatch) | — | Runs the route's own middleware chain (D-10) |
+| Per-principal filtering | API / Backend (guard providers) | Database (permission reads) | The same code that enforces the route answers "may call" |
+| OAuth authorize/consent/token | API / Backend (wristband + sm-oauthserver-plugin) | Browser (consent page) | Consent UI is the application's SPA page or a plugin-rendered page |
+| Link proof round-trip | API / Backend (sm-user-plugin) | Browser (site sign-in page, admin SPA My Profile) | Only the user plugin can verify a frontend sign-in (D-03) |
+| My Profile contribution seam | Frontend admin SPA (boardwalk/admin) | API (cabana account endpoints) | Generic seam, rendered by the SPA from JSON |
+| Session store and event fan-out | Database / Storage (Redis via cooler) | API | Shared state across instances (D-25/D-26) |
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `github.com/modelcontextprotocol/go-sdk` (packages `mcp`, `auth`, `oauthex`) | v1.8.0 (2026-09-04) [VERIFIED: proxy.golang.org `@latest` → `{"Version":"v1.8.0","Time":"2026-09-04T08:08:52Z"}`] | MCP server, streamable HTTP transport, PRM types, test client | Locked by D-24; official SDK; README: "official Go software development kit (SDK) for the Model Context Protocol"; supports specs 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 [VERIFIED: go-sdk@v1.8.0/README.md] |
+| `github.com/google/jsonschema-go` (package `jsonschema`) | v0.4.3 (2026-04-17) [VERIFIED: proxy.golang.org info] | Reflect tool input/output schemas from Go structs; validate arguments | Already the SDK's own schema library; `ForType(reflect.Type, *ForOptions)` works on a type known only at runtime |
+| `github.com/redis/go-redis/v9` | v9.23.0 (already in framework go.mod) [VERIFIED: summercms.go/go.mod:33] | Session registry, sliding TTL, pub/sub fan-out | Already decided in 19.3 (`cooler`) |
+| Go stdlib `net/http`, `crypto/rand` (`rand.Text`), `net/http/httptest` (ResponseRecorder for in-process dispatch) | Go 1.27.2 installed [VERIFIED: `go version`] | Dispatch, session IDs | Stdlib first |
+
+Go version requirement: go-sdk `go 1.25.0`, jsonschema-go `go 1.23.0` [VERIFIED: go.mod of each]; the framework is on `go 1.27.0`, so no toolchain change.
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `github.com/alicebob/miniredis/v2` | v2.40.0 (already in go.mod) | Redis pub/sub and TTL in Docker-free tests | Session store and fan-out unit tests |
+| `github.com/testcontainers/testcontainers-go` + `modules/postgres` | v0.44.0 (already) | Postgres-backed link/permission/token tests | Docker suites (skipped under `-short`) |
+| go-sdk `mcp.Client` + `StreamableClientTransport` | v1.8.0 | End-to-end tests: connect, `ListTools`, `CallTool`, `ToolListChangedHandler` | Every pier integration test; its `HTTPClient` field carries the bearer |
+
+### Transitive modules linked by importing `mcp` + `auth` + `jsonschema`
+[VERIFIED: `go list -deps` on a probe module this session]
+`github.com/google/jsonschema-go v0.4.3`, `github.com/segmentio/asm v1.1.3`, `github.com/segmentio/encoding v0.5.4`, `github.com/yosida95/uritemplate/v3 v3.0.2`, `golang.org/x/oauth2 v0.35.0`, `golang.org/x/sync v0.20.0`, `golang.org/x/sys v0.41.0`, `golang.org/x/time v0.15.0`. The framework already requires newer `x/sync v0.23.0` and `x/sys v0.48.0`, so MVS keeps those; **new to the graph:** go-sdk, jsonschema-go, segmentio/asm, segmentio/encoding, uritemplate, x/oauth2, x/time. `golang-jwt/jwt/v5` and `x/tools` appear in go-sdk's go.mod but are **not linked** by these packages.
+
+**Binary size:** a stripped probe using `mcp`+`auth`+`jsonschema` is 8,835,335 bytes vs 5,832,967 for a bare `net/http` program — about **+2.9 MiB** [VERIFIED: local build probe]. The real increment in an app that already links x/sys etc. will be somewhat smaller. This matters for Open Question 2.
+
+**License:** go-sdk LICENSE states a transition from MIT to Apache-2.0 ("All new code … licensed under Apache-2.0 … Contributions … not yet granted explicit permission to relicense remain licensed under the MIT License") [VERIFIED: go-sdk@v1.8.0/LICENSE]; jsonschema-go is MIT [VERIFIED: jsonschema-go@v0.4.3/LICENSE]. Both permissive.
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| SDK `StreamableHTTPHandler` as-is | Own session router over `StreamableServerTransport` | The handler cannot share sessions across instances (in-memory map, 404 on miss). The own router (~250 lines) is required for D-25 rehydration; the transport, JSON-RPC, SSE and protocol negotiation stay the SDK's |
+| One shared `*mcp.Server` + receiving middleware filter | One `*mcp.Server` per session | The SDK has no exported "notify this session of list_changed"; per-session servers get it for free via `AddTool`/`RemoveTools` (probe verified). Middleware filtering is still added for the per-request re-check (D-08) |
+| Stateful only (D-25) | Hybrid: stateless SDK handler for 2026-07-28 requests, stateful router for ≤2025-11-25 | Forward-compatible with the current spec; needs user confirmation because D-25 is locked (Open Question 1) |
+| `mark3labs/mcp-go` | — | Not considered: D-24 locks the official SDK |
+
+**Installation (framework go.mod only; plugins must not require the SDK):**
+```bash
+go get github.com/modelcontextprotocol/go-sdk@v1.8.0 github.com/google/jsonschema-go@v0.4.3
+```
+
+**Version verification:** `go list -m -versions github.com/modelcontextprotocol/go-sdk` lists `… v1.7.0 v1.8.0-pre.1 v1.8.0-pre.2 v1.8.0`; no v1.9 pre-release exists yet [VERIFIED: go proxy, 2026-10-21]. The SDK notes the `allowsessionsinstateless` compatibility knob "will be removed in the 1.9.0 version" — irrelevant to a stateful server but a sign 1.9 is coming; re-check `@latest` before `go get` if executing after ~2026-11-20.
+
+## Package Legitimacy Audit
+
+The `package-legitimacy check` seam supports only npm/pypi/crates ("Usage: msd-tools package-legitimacy check --ecosystem "), so Go modules cannot get an `OK` verdict from it. Per the provenance rule the package names are tagged `[ASSUMED]` and need a `checkpoint:human-verify` before `go get`, as 19.6 did for its Go modules.
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| `github.com/modelcontextprotocol/go-sdk` [ASSUMED] | Go proxy (sum.golang.org verified by `go mod download`) | v1.8.0 published 2026-09-04; v1.x line since 2025 | n/a (Go) | github.com/modelcontextprotocol/go-sdk (proxy Origin URL, hash 3f3b699b) | seam: not supported | Flagged — planner adds `checkpoint:human-verify` before `go get` |
+| `github.com/google/jsonschema-go` [ASSUMED] | Go proxy | v0.4.3 published 2026-04-17 | n/a | github.com/google/jsonschema-go (proxy Origin URL, hash 8c4ab4f0) | seam: not supported | Flagged — same checkpoint |
+| `segmentio/asm`, `segmentio/encoding`, `yosida95/uritemplate/v3`, `x/oauth2`, `x/time` (transitive) [ASSUMED] | Go proxy | — | n/a | via go-sdk go.mod | seam: not supported | Listed in the decision note; human check covers them; run `govulncheck` (not installed here: `go run golang.org/x/vuln/cmd/govulncheck@latest ./...`) |
+
+**Packages removed due to [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none by the seam (unsupported ecosystem); all Go additions are gated by one human-verify checkpoint.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ MCP client (editor's agent)
+ │ POST/GET/DELETE /mcp Authorization: Bearer Mcp-Session-Id
+ ▼
+ surf mux ──(streaming route kind: no buffering, throttle:mcp, Origin check)──► pier HTTP layer
+ │
+ ┌──────────────────────────────────────────────────────────────────────────────┤
+ │ 1. verify bearer (EVERY request) │
+ │ oas_at_ ─► oauthserver verifier (token row, client kind=mcp, not revoked) │
+ │ usr_… ─► user plugin resolver (only if allow_personal_tokens) │
+ │ g15_… ─► cabana token resolver (only if allow_personal_tokens) │
+ │ fail ──► 401 + WWW-Authenticate resource_metadata, scope │
+ │ 2. build Identity: frontend principal (+creds), backend principal if │
+ │ scope∋backend AND user plugin LinkedBackendUser(uid) (D-08, per request) │
+ │ 3. session router │
+ │ no id ─► new id (crypto/rand.Text) ─► per-session *mcp.Server built from │
+ │ Identity's allowed tools/prompts ─► Redis SET pier:s:{bind, │
+ │ state, ttl} │
+ │ id ─► Redis GET ─► binding == Identity.binding ? else 403 │
+ │ local transport? else REHYDRATE (Connect with stored State) │
+ │ EXPIRE (sliding TTL) │
+ │ DELETE ─► Redis DEL + PUBLISH ended ─► local Close │
+ └──────────────────────────────────────────────────────────────────────────────┘
+ │ JSON-RPC (SDK transport/session)
+ ▼
+ receiving middleware: tools/list & prompts/list re-filtered for THIS request's Identity,
+ tools/call refused if not allowed (scope ceiling D-20, guard, permissions)
+ │ tools/call
+ ▼
+ tool handler: validate args (JSON schema) ─► split path/query/body ─► build *http.Request
+ (no Authorization/Cookie, RemoteAddr/XFF/Accept-Language copied, SetPathValue, Pattern)
+ ctx = bouncer dispatch marker{guard names, principal, credential}
+ │
+ ▼ surf compiled route handler (constraints, body limit, named middleware,
+ throttle, user.api_token/user.scope/user.permission, recovery) — D-10
+ ▼
+ ResponseRecorder ─► 2xx: text + structuredContent ; 4xx/5xx: isError + route body (D-13)
+
+ Event sources (user plugin perms/link/token revoke, oauthserver grant revoke, cabana token revoke)
+ ─► festival event ─► pier listener ─► Redis PUBLISH pier:events
+ ─► every instance: sessions bound to that subject recompute tool set (Add/RemoveTools
+ → SDK sends notifications/tools/list_changed) or Close on revocation (D-26/D-27)
+```
+
+### Recommended Project Structure
+
+```
+modules/pier/ # framework module; the only package importing go-sdk
+├── README.md # standard structure, **Name:** mnemonic
+├── pier.go # Config, Mount(r surf-adapter, app, plugins), boot validation
+├── catalog.go # compile pact declarations → tools/prompts, prefixing (D-12), schemas
+├── dispatch.go # args → *http.Request, ResponseRecorder → CallToolResult (D-11, D-13)
+├── identity.go # bearer classification, verifiers, Identity, binding key
+├── guards.go # guard provider registry ("backend" built in, others via app.Lookup)
+├── session.go # rehydrating session router over StreamableServerTransport
+├── store_redis.go / store_memory.go # session registry + pub/sub (memory for single instance/tests)
+├── prm.go # RFC 9728 protected resource metadata + WWW-Authenticate
+└── events.go # festival listeners → fan-out
+modules/pact/capabilities.go # + HasMCP (tools, prompts, room for resources), HasFrontendPermissions
+modules/bouncer/dispatch.go # + context-only dispatch marker, honoured in Registry.Middleware
+modules/surf/ # + RouteHandler lookup after compile, streaming route kind, pier mount (tagged or not)
+modules/cabana/ # + ResolveBackend honours marker, account contribution seam, BackendPrincipal(ctx, app, id), token resolver without method filter
+modules/wristband/authorize.go # + MCP allowed scopes from Options.ScopesSupported (default unchanged)
+modules/cooler/ # + expose/publish the Redis client when pier selects redis
+admin/src/views/AccountView.vue # + generic account sections/actions component; rebuild boardwalk/dist
+```
+
+### Pattern 1: Config-gated mount, after all routes exist
+**What:** pier mounts like `mountAPIDocs` — nothing when disabled. Unlike api-docs it must validate tool → route bindings, so it runs **after** every plugin route and after `cabana.Activate` in `BuildRouter` (routes 624-654), before the final `r.wrap` validation loop.
+**Existing code:** `if err := mountAPIDocs(r, app, plugins)` at `modules/surf/router.go:635`, `admin, err := cabana.Activate(app, plugins)` at :638 [VERIFIED: modules/surf/router.go:635-638]. `route:list` also calls `BuildRouter`, so a bad binding fails `route:list` too (consistent with "boot fails", D-10).
+**Import direction:** surf imports cabana, cooler and bouncer already. If surf imports pier, pier must not import surf: pier defines a small interface (`Routes() []RouteInfo`, `Handler(method, pattern) (http.Handler, bool)`), surf passes an adapter.
+
+### Pattern 2: SDK-free pact capability (D-28 shape)
+**What:** plugins import `pact` only; pact must not import the SDK or jsonschema-go (otherwise every app with the user plugin links the SDK). Use one method returning a struct so `Resources` can be added later as a field (additive):
+```go
+// pact (sketch; identifiers are the planner's call)
+type MCP struct {
+ Tools []MCPTool
+ Prompts []MCPPrompt
+ // Resources []MCPResource — added in a later phase, non-breaking
+}
+type HasMCP interface{ MCP() MCP }
+
+type MCPTool struct {
+ Name, Title, Description string
+ Guard string // "user" | "backend" — names a published guard provider
+ Method, Path string // the plugin's OWN route, e.g. POST /_journal/api/v1/posts
+ Input any // zero value of the input struct (reflected with jsonschema.ForType)
+ Output any // optional; nil → no outputSchema
+ Permissions []string // D-16: list filter on top of route-derived requirements
+ ReadOnly, Destructive, Idempotent, OpenWorld *bool // D-14 overrides; nil → derived from Method
+}
+type HasFrontendPermissions interface{ FrontendPermissions() []FrontendPermission } // D-09: Code, Label, Tab, Comment
+```
+Existing analogs: `pact.Permission{Code, Tab, Label, Roles}` and `HasPermissions` [VERIFIED: modules/pact/capabilities.go:512-522 — `type Permission struct { Code string; Tab string; Label string; Roles []string }`, `type HasPermissions interface { Permissions() []Permission }`].
+
+### Pattern 3: In-process dispatch through the compiled route handler
+**What:** surf already compiles one wrapped handler per route (`handlers[i] = h` inside `compile()`, `modules/surf/router.go:354-364`) but keeps them local. Add an additive accessor that records them (keyed by `method + " " + path`) and expose it after compile. Dispatch calls that handler **directly**, not the mux, after setting path values exactly as `overlap.go` does (`r.SetPathValue(s.param, vals[i])` … `r.Pattern = m.pattern` [VERIFIED: modules/surf/overlap.go:415-420]).
+**Why not the mux:** dispatching a concrete URL through ServeMux can match a *different*, more specific route (a `{id}` value `export` would hit `/posts/export`). Calling the bound route's handler makes the binding exact; `constrain` inside that handler still enforces `Where` constraints.
+**What the wrapped handler contains** (`wrap`, :408-491): constraints → org slot → named middleware (right to left) → body limit → locale → `recoverJSON`/`recoverBare` → CORS profile → `recordPattern`. Calling it runs throttles, `user.api_token`/`user.scope`/`user.permission`, and the handler — D-10's "same code path as HTTP". The outer access log is not on that path, so pier logs tool calls itself (D-23).
+**Request construction rules:** method/path from the binding; values for single-segment params must not contain `/`; query from `query:` fields; body = the agent's JSON object minus path/query keys (forward raw, see Pitfall 6); copy `RemoteAddr`, `X-Forwarded-For`/`X-Real-IP`, `Accept-Language`; set `Content-Type`/`Accept: application/json`; **drop `Authorization` and `Cookie`** (no token passthrough); attach the dispatch marker to the context; a per-call timeout.
+
+### Pattern 4: Schema reflection and validation
+`jsonschema.ForType(reflect.TypeOf(tool.Input), nil)` builds the input schema. Properties come from the JSON name; fields without `omitempty`/`omitzero` become `required`; the `jsonschema` tag is the description; structs get `additionalProperties: false` [VERIFIED: jsonschema-go@v0.4.3/jsonschema/infer.go:56-82, 329-343]. The SDK's low-level `Server.AddTool(t, h)` panics unless the input schema has `"type": "object"` and leaves validation to the caller ("Unmarshaling the arguments and validating them against the input schema are the caller's responsibility") [VERIFIED: go-sdk@v1.8.0/mcp/server.go:295-345]. pier therefore: reflects once at boot (fail boot on error), resolves the schema once, validates every call's arguments, then splits by the `path`/`query` struct tags.
+
+### Pattern 5: Guard providers keep the framework neutral
+pier must not hard-code `user.api_token`/`user.scope:`/`user.permission:` (sm-user-plugin names). Recommended shape: a framework interface each guard owner publishes via `app.Publish`:
+```go
+// framework, SDK-free package (pact or a small pier contract package) — names are the planner's call
+type MCPGuard interface {
+ Name() string // "user", "backend"
+ TokenGuarded(routeMiddleware []string) bool // D-15 check at boot
+ Requirements(routeMiddleware []string) (any, error) // parses its own names (user.scope:read, user.permission:x)
+ Allows(ctx context.Context, id Identity, req any, toolPerms []string) bool // the SAME logic the route enforces
+ DispatchGuardNames() []string // bouncer registry names to honour, e.g. ["api_token"]
+}
+```
+cabana's "backend" guard is built into pier (framework may use cabana). The user plugin publishes "user". Existing wiring: the user plugin registers bouncer guards `"jwt"` and `"api_token"` and exposes them as middleware `jwt.auth`/`user.api_token`; factories `user.scope`/`user.permission` [VERIFIED: fonoteka.go/plugins/golem15/user/plugin.go:100,108,244,247,254-255 — `reg.Register(p.ID(), "jwt", …)`, `reg.Register(p.ID(), "api_token", …)`, `out["jwt.auth"] = mw`, `out["user.api_token"] = mw`, `"user.scope": usermiddleware.Scope`, `"user.permission": usermiddleware.Permission`]. cabana registers bouncer guard `"backend"` [VERIFIED: modules/cabana/http.go:164 — `guards.Register("summercms.cabana", "backend", guard)`].
+
+### Pattern 6: The D-17 marker — where it must be honoured
+- **bouncer** `Registry.Middleware` calls `authenticate(ng.g, r)` and, for a guard with `UnauthorizedWriter`, writes 401 on failure [VERIFIED: modules/bouncer/registry.go:73-104]. Add before line 83: if the context carries a dispatch marker that lists this guard name, use its principal/credential and skip header parsing. This single change covers `user.api_token` (the bouncer `api_token` guard) and `jwt.auth` — **no sm-user-plugin code change is needed for the marker itself** (D-17's "user.api_token honours the marker" is satisfied in bouncer). Decide whether `jwt.auth` may honour it; D-15 says JWT-session routes are not tool targets, so honour only `api_token` for the user guard.
+- **cabana** `ResolveBackend` returns `ErrUnauthenticated` immediately when there is no bearer (`if app == nil || r == nil || authorizationBearer(r) == ""` [VERIFIED: modules/cabana/token_guard.go:199-200]). Put the marker check before that line. Journal's `requireBackendPrincipal` calls `cabana.ResolveBackend(app, r, journalCover)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:85], so it honours the marker with **no Journal change**.
+- **Journal** `optionalBackendPrincipal` short-circuits on `if r == nil || !hasBearer(r)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:70], so the list/show tools (public GET routes) would run anonymous (published posts only). Additive one-line fix: drop the `hasBearer` short-circuit (ResolveBackend already returns ErrUnauthenticated for a missing bearer, so anonymous behaviour is identical) or check the marker explicitly.
+- **Credential for `user.scope`:** `Scope` reads `bouncer.Credential` as `*models.APIToken` and calls `token.HasScope(scope)` [VERIFIED: fonoteka.go/plugins/golem15/user/middleware/api_token.go:90-101]. An OAuth session has no `user_api_tokens` row, so the user guard provider must build an **in-memory** `*models.APIToken{UserID, Scopes: grant scopes}` (never persisted) for the marker; user token scopes are `AllowedAPITokenScopes = []string{"read", "write", "ai"}` [VERIFIED: fonoteka.go/plugins/golem15/user/classes/api_tokens.go:23], the same vocabulary as MCP OAuth scopes. Handlers that read `credential.ID` must tolerate ID 0 (Pitfall 9).
+- **Unforgeability:** context values cannot be set from HTTP; the marker type lives in bouncer with an unexported key. Any compiled plugin could still call the setter, so restrict by convention plus a check-script grep that only `modules/pier` calls it.
+
+### Pattern 7: Link proof through a fresh frontend session (D-05)
+The 19.6 login gate (`Login` consults gates after `CheckAndRecordLogin`, before `mintFor`; 19.6 D-02) and sociallogin's `oauth-complete` both end in a frontend JWT. The proof therefore does not need its own login flow:
+1. Admin (interactive cabana session; `interactiveOnly` refuses `g15_` tokens) clicks "Link frontend account" → sm-user-plugin creates a link request row (random 32-byte code, stored hashed, `backend_user_id`, `expires_at` ~10 min, `used_at`) and returns a site URL with the code.
+2. The site page shows "Link this account to admin ?" (public `GET` showing only the admin display name and expiry), asks the user to sign in **fresh** (normal login page, so password, social login and 2FA gates all apply).
+3. `POST …/backend-link/confirm` with `jwt.auth` + the code: row-locked, one-shot, not expired, **JWT `iat` after the request's `created_at`** (forces a fresh sign-in rather than a stale browser session), uniqueness on both sides (D-06) → save link, fire event.
+19.6 adds `golem15.user.frontend_url` (19.6-01-PLAN artifacts) — reuse it for the site URL. The site page is app-specific (vue-demo-app has only `contact, faq, index, login, register, roadmap, subscriptions, p/, presentations/, quizzes/` pages [VERIFIED: ls sm-demo-app/vue-demo-app/app/pages]); add it there.
+
+### Pattern 8: Redis-backed rehydrating sessions (D-25..D-27)
+- New session: `crypto/rand.Text()` id; connect a per-session `*mcp.Server`; after `initialize`, store `{binding, ServerSessionState{InitializeParams, InitializedParams: new(…), LogLevel: "info"}, created}` under `pier:s:` with `PX ttl`. `ServerSessionState` is JSON-tagged for this purpose [VERIFIED: go-sdk@v1.8.0/mcp/session.go:18-36].
+- Existing id, local miss: load state, `server.Connect(ctx, &mcp.StreamableServerTransport{SessionID: id}, &mcp.ServerSessionOptions{State: st})`, then `t.ServeHTTP` (probe-verified). `ServerSessionOptions.onClose` is unexported, so track closure with a goroutine on `ss.Wait()`.
+- Binding: refuse with 403 when the request's binding key differs. **Binding key must survive access-token refresh** (1 h access TTL, rotation): bind to `(credential family, frontend user id)` — e.g. `oauth::`, `pat:user:`, `pat:backend:` — not the token hash (Open Question 5).
+- Every request: `PEXPIRE` (sliding TTL). Missing key → 404 (spec: client re-initialises).
+- DELETE: `DEL` + `PUBLISH pier:events {ended:id}` → every instance closes its local copy.
+- Fan-out: `PUBLISH` on events; each instance iterates its local sessions bound to the subject and either recomputes the allowed tool set (diff → `AddTool`/`RemoveTools` → SDK sends `notifications/tools/list_changed`, debounced 10 ms [VERIFIED: go-sdk@v1.8.0/mcp/server.go:740-770]) or closes the session on revocation.
+- Memory driver for single instance and tests (same interface), mirroring cooler's memory/redis split. cooler opens its Redis client only when `cache.driver` or `http.rate_limit.store` selects redis and keeps it unexported (`Handle.client`) [VERIFIED: modules/cooler/cooler.go:109-116, 184-245]; add an additive selector/accessor for pier.
+
+### Pattern 9: Protected resource metadata (required by the MCP auth spec)
+"MCP servers **MUST** implement OAuth 2.0 Protected Resource Metadata (RFC9728)"; serve it at `/.well-known/oauth-protected-resource/` and/or root, and include `resource_metadata` (and `scope`) in `WWW-Authenticate` on 401 [CITED: modelcontextprotocol.io/specification/2025-11-25/basic/authorization]. Nothing in the framework or oauthserver serves PRM today (grep found only fonoteka-mcp's Node server). The SDK provides `auth.ProtectedResourceMetadataHandler(*oauthex.ProtectedResourceMetadata)`; mount it through surf at both paths. `resource` must equal `golem15.oauthserver.mcp.resource`, because wristband refuses a `resource` parameter that differs from `Options.Resource` and clients MUST send `resource` ("MCP clients MUST send this parameter regardless") (Pitfall 8).
+
+### Anti-Patterns to Avoid
+- **Forwarding the MCP bearer to the route.** Token passthrough is forbidden ("The MCP server MUST NOT pass through the token it received") and would also make Journal try to parse an `oas_at_` token as a backend JWT. Identity goes through the marker only.
+- **Dispatching through the mux by concrete URL** (route confusion, see Pattern 3).
+- **Mounting `/mcp` as a normal surf route** (buffered, see Pitfall 1).
+- **pier parsing user-plugin middleware names or reading user-plugin tables** (framework must stay neutral; use guard providers).
+- **Putting the SDK in `pact`** or any package plugins import.
+- **Imperative or dynamic tool descriptions.** fonoteka-mcp keeps descriptions "STATIC factual `verb_noun` one-liners with NO imperative second-person text (auto-injected tool text is a prompt-injection surface)" and sets title plus explicit read/destructive/open-world hints because "both catalogs reject a submission that is missing any of those annotations" [VERIFIED: /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/server.ts:5-17].
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| JSON-RPC framing, SSE event streams, protocol negotiation, `initialize` handshake, 2026-07-28 rejection | Own MCP transport | go-sdk `StreamableServerTransport` + `Server.Connect` | Spec edge cases (batching, priming events, header validation, version negotiation) are already handled; pier only routes sessions |
+| JSON schema from Go structs + validation | Reflection code | `jsonschema.ForType` + `Schema.Resolve`/`Validate` | Same library the SDK uses; consistent `required`/`additionalProperties` |
+| PRM document and CORS for it | Handwritten JSON | `auth.ProtectedResourceMetadataHandler` + `oauthex.ProtectedResourceMetadata` | RFC 9728 field names |
+| Test MCP client | Raw HTTP in tests | `mcp.NewClient` + `StreamableClientTransport{HTTPClient}` | Exercises negotiation fallback, sessions, list_changed |
+| Session IDs | Custom generator | `crypto/rand.Text()` (SDK recommendation) | Unguessable IDs |
+| Rate limiting `/mcp` | New limiter | surf `throttle:` (Redis-backed via cooler when configured) | Existing shared limiter |
+| Permission checks for tools/list | Re-implementing permission logic in pier | The guard provider's own check (same function the route middleware uses) | List and call must agree (Pitfall 3) |
+| One-time link codes | Signed JWT with replay cache | DB row with hashed random code, row lock, `used_at` | Same pattern as the user plugin's codes (`newCode()` 32 random bytes hex) [VERIFIED: fonoteka.go/plugins/golem15/user/classes/codes.go:46-52] |
+
+**Key insight:** the SDK should own the protocol and pier should own identity and dispatch. The only part of the SDK pier bypasses is `StreamableHTTPHandler`'s private session map, and only because D-25 needs it shared.
+
+## Common Pitfalls
+
+### Pitfall 1: surf buffers every route response — SSE cannot stream
+**What goes wrong:** the GET standalone stream and SSE POST responses never reach the client; the GET never returns.
+**Why:** `wrap` always ends with `recoverJSON` or `recoverBare` (:479/:481), both of which write into a `bufferedResponse` that commits only after the handler returns, and `func (*bufferedResponse) Flush() {}` is a deliberate no-op ("Route output becomes visible only after the handler returns successfully") [VERIFIED: modules/surf/router.go:479-481, 851-853].
+**How to avoid:** add an additive streaming route kind (recovery that does not buffer — after headers are sent a panic can only abort; keep throttle, CORS profile, `recordPattern`). The outer layers (access log `statusRecorder` has Flush/Unwrap; `pathScopedCORS`, error-page fallback and request counter pass the writer through) are fine. No route streams today (no `text/event-stream`/`http.Flusher` use in framework or plugins).
+**Warning signs:** client connects, `initialize` works with `JSONResponse` but tool calls hang with SSE.
+
+### Pitfall 2: Protocol 2026-07-28 vs stateful sessions
+**What goes wrong:** a client that speaks only 2026-07-28 cannot use a stateful server.
+**Why:** "the streamable HTTP transport accepts requests at protocol version `2026-07-28` **only** when `Stateless = true`" [VERIFIED: go-sdk@v1.8.0/docs/protocol.md:352-353]. Stateful servers answer `CodeUnsupportedProtocolVersion` with legacy versions; SDK clients fall back (probe negotiated 2025-11-25).
+**How to avoid:** keep D-25 (works with dual-version clients) and ask the user about the hybrid in Open Question 1. Test with the SDK client (which tries `server/discover` first).
+
+### Pitfall 3: Frontend permission semantics differ between the route and `MergedPermissions`
+**What goes wrong:** `tools/list` shows a tool the call then refuses, or hides one the call would allow.
+**Why:** `user.permission:` checks `cabana.Allows(principal, []string{code})` [VERIFIED: fonoteka.go/plugins/golem15/user/middleware/api_token.go:106-110], and the principal's grants come from `UserPermissionGrants` — **group permissions only** (`PrincipalByID` → `grants, err := UserPermissionGrants(ctx, gdb, row.ID)` [VERIFIED: classes/user_lookup.go:35,54]; it reads `user_groups.permissions` joined through `users_groups` only, `user_lookup.go:65-99`). `MergedPermissions` (groups plus the user's tri-state override) exists but **no route uses it** (grep: only `classes/permissions.go` references it). The design note says tools/list uses `MergedPermissions`.
+**How to avoid:** the guard provider answers with the **same** check the route enforces (group grants). Whether routes should move to `MergedPermissions` is a core-plugin behaviour change — Open Question 4. (Backend differs: cabana's `BackendUsers.FindByID` already overlays the admin's own permissions [VERIFIED: modules/cabana/auth.go:39-68].)
+
+### Pitfall 4: No resource-side verifier for `oas_at_` tokens; OIDC tokens share the table
+**What goes wrong:** pier cannot authenticate OAuth sessions, or accepts OIDC userinfo tokens as MCP tokens.
+**Why:** MCP access tokens are `MCPAccessTokenPrefix = "oas_at_"` rows in `golem15_oauthserver_access_tokens` [VERIFIED: sm-oauthserver-plugin/classes/mcp_store.go:19]; the same table holds OIDC access tokens ("OIDC tokens are only usable at the userinfo endpoint", models/access_token.go). No verifier exists ("Verifying MCP access tokens at a resource arrives with the MCP module, not here" — sm-oauthserver-plugin/README.md:32).
+**How to avoid:** sm-oauthserver-plugin publishes a verifier implementing a framework interface: prefix `oas_at_`, hash lookup, not revoked, not expired, client exists with kind `mcp` and not revoked, user active; returns user id, scopes, client id, token id, expiry. Throttle `last_used_at` writes (cabana's `stampThrottle` pattern).
+
+### Pitfall 5: `backend` scope is rejected by wristband's hard-coded allow-list
+**What goes wrong:** a client requesting `backend` gets `invalid_scope` even after the plugin adds it to `ScopesSupported`.
+**Why:** `var authorizeAllowedScopes = map[string]bool{ "read": true, "write": true, "ai": true, "offline_access": true, }` [VERIFIED: modules/wristband/authorize.go:22-24]; `DefaultOptions().ScopesSupported` is `[]string{"read", "write", "ai", "offline_access"}` [VERIFIED: modules/wristband/server.go:99].
+**How to avoid:** framework change in wristband: the MCP flavour's allowed set = `Options.ScopesSupported` when set (default stays the four, PHP parity). Then sm-oauthserver-plugin appends `backend` to `mcpOpts.ScopesSupported` in `NewServers`.
+
+### Pitfall 6: Partial updates and zero values
+**What goes wrong:** an update tool sends `"published": false` or `""` for fields the agent never mentioned.
+**Why:** Journal's update uses key presence (`if _, has := body["published"]; has {` …) [VERIFIED: sm-journal-plugin/controllers/api/posts_helpers.go:579-580]; re-marshalling a Go struct emits zero values.
+**How to avoid:** use the struct only for the schema; forward the agent's **raw** JSON object minus path/query keys as the body. Mark optional fields with `omitempty`/pointers so the schema does not require them.
+
+### Pitfall 7: Method-filtered `g15_` token scopes at a POST endpoint
+**What goes wrong:** with `allow_personal_tokens`, a read-only cabana token cannot even open a session.
+**Why:** the composite guard filters scopes by the *request* method (`methodFilterScopes(g.reg.tokenScopes, stored, r.Method)` [VERIFIED: modules/cabana/token_guard.go:109]); every MCP request is POST/GET/DELETE to `/mcp`, not the tool's route method.
+**How to avoid:** additive cabana API that resolves a `g15_` token without method filtering and computes the `ScopeLimit` per dispatched route method (`ScopeLimit` semantics: "Only a token guard sets Scope, already filtered to scopes whose methods include the request method" [VERIFIED: modules/bouncer/context.go:10-19]).
+
+### Pitfall 8: Resource indicator mismatch breaks sign-in
+**What goes wrong:** authorize answers `invalid_target`.
+**Why:** wristband refuses a `resource` that differs from `Options.Resource`, which comes from `golem15.oauthserver.mcp.resource` [VERIFIED: sm-oauthserver-plugin/servers.go:123-124]; MCP clients always send `resource` = the canonical MCP URL.
+**How to avoid:** one value: pier's resource URL (default `app.url` + path, no trailing slash) must equal `mcp.resource`; pier's PRM `resource` uses the same; a boot warning when they differ.
+
+### Pitfall 9: In-memory credentials and handlers that read `credential.ID`
+**Warning:** a synthetic `*models.APIToken` (OAuth session) has `ID == 0` and `CollectionIDs` empty; route handlers that look up the token row by ID or use collection scoping (fonoteka-style) would misbehave. Audit token-guarded tool targets; Journal and the new user tool routes don't use the credential.
+
+### Pitfall 10: Long-lived GET streams outlive revocation
+**What goes wrong:** a revoked grant keeps receiving notifications on an already-open SSE stream (authorization happens at stream open).
+**How to avoid:** on revocation events close the session on every instance; additionally re-verify the stream's bearer periodically (e.g. every 60 s) and close when invalid. Shutdown: `serve` uses the signal context as `BaseContext`, so request contexts cancel on SIGTERM; streams end.
+
+### Pitfall 11: Name collisions and validity
+Short codes collide across vendors (`acme.blog` and `golem15.blog` → `blog_`); prefix collisions must fail boot (D-12 covers within one plugin only). The SDK only *logs* an invalid name (`AddTool: invalid tool name`); names over 128 chars or with invalid runes must fail boot in pier [VERIFIED: go-sdk@v1.8.0/mcp/tool.go:163-186].
+
+### Pitfall 12: `structuredContent` must be an object
+A route returning a JSON array or scalar cannot be passed as `structuredContent`. Wrap non-objects (e.g. `{"data": …}`) or omit `structuredContent`, and only declare an `outputSchema` when the 2xx body always matches it (the low-level API leaves output validation to the caller).
+
+### Pitfall 13: Concurrent work on sm-user-plugin
+19.6 plans 01-04 modify `controllers/users_admin_controller.go` (`AdminRecordActions` at :665), `controllers/users/config_form.yaml` (`recordActions: [activate, unban, unsuspend]` at :17), `admin_permissions.go`, `plugin.go`, `routes.go`, `controllers/api_controller.go`, `lang/*`, `README.md` and `admin_registration_test.go` in `../fonoteka.go/plugins/golem15/user` (19.6-01-PLAN files_modified). 19.6 is not executed (no SUMMARY files; the user plugin is at `35cf166`, tag v0.1.5). 19.7's user-plugin plan must start after 19.6's user-plugin release, rebase on it, and append to the same lists.
+
+## Code Examples
+
+### Rehydrating a session on another instance (probe-verified)
+```go
+// Source: local probe against go-sdk v1.8.0 (exported API only)
+st := &mcp.ServerSessionState{
+ InitializeParams: storedInitParams, // from ss.InitializeParams() on the first instance
+ InitializedParams: new(mcp.InitializedParams),
+ LogLevel: "info",
+}
+t := &mcp.StreamableServerTransport{SessionID: id}
+if _, err := perSessionServer.Connect(context.Background(), t, &mcp.ServerSessionOptions{State: st}); err != nil {
+ http.Error(w, "failed connection", http.StatusInternalServerError)
+ return
+}
+t.ServeHTTP(w, req) // serves POST/GET for this session on this instance
+```
+
+### Targeted list_changed by per-session server (probe-verified)
+```go
+// Source: local probe; the SDK debounces and notifies only this server's sessions
+srv.RemoveTools("journal_create_post") // after the link was removed
+// client side receives notifications/tools/list_changed via ClientOptions.ToolListChangedHandler
+```
+
+### Per-request identity into the SDK
+```go
+// Source: go-sdk@v1.8.0/auth/auth.go:96-125 and mcp/streamable.go:1463,1585-1586
+// RequireBearerToken is the only setter of the TokenInfo context key; the transport copies it
+// into every JSON-RPC request's Extra.TokenInfo, so tool handlers see the CURRENT request's identity.
+verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) {
+ id, err := identities.Resolve(ctx, token) // oas_at_ / personal tokens; per request (D-25)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err)
+ }
+ return &auth.TokenInfo{Scopes: id.Scopes, Expiration: id.ExpiresAt, UserID: id.BindingKey,
+ Extra: map[string]any{identityKey: id}}, nil
+}
+h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
+ ResourceMetadataURL: prmURL, Scopes: nil, // pier enforces read/write per tool, not per request
+})(sessionRouter)
+```
+Note: `RequireBearerTokenOptions.AllowMissingExpiration` exists for tokens without expiry (personal tokens with no `expires_at`) [VERIFIED: go-sdk@v1.8.0/auth/auth.go:48-64].
+
+### Annotations from method (D-14)
+```go
+// SDK type: ToolAnnotations{DestructiveHint *bool; IdempotentHint bool; OpenWorldHint *bool; ReadOnlyHint bool; Title string}
+// Source: go-sdk@v1.8.0/mcp/protocol.go:1964-1991
+ann := &mcp.ToolAnnotations{Title: tool.Title, ReadOnlyHint: method == http.MethodGet,
+ IdempotentHint: method == http.MethodPut || method == http.MethodGet}
+destructive := method == http.MethodDelete
+openWorld := false
+ann.DestructiveHint, ann.OpenWorldHint = &destructive, &openWorld // always explicit (catalog requirement)
+```
+
+### Journal backend tools (D-18 candidates, existing routes)
+Existing routes [VERIFIED: sm-journal-plugin/routes.go:16-34]:
+```
+GET /_journal/api/v1/posts (public group, throttle:journal-public-api; editor sees drafts)
+GET /_journal/api/v1/posts/{slug} (Where slug `[a-z0-9][a-z0-9\-/]*`; numeric slug falls back to id)
+GET /_journal/api/v1/categories | /tags
+POST /_journal/api/v1/posts (throttle:journal-api; requireBackendPrincipal + access_posts)
+PUT /_journal/api/v1/posts/{id} (Where id `[0-9]+`)
+DELETE /_journal/api/v1/posts/{id}
+POST /_journal/api/v1/media/upload | /posts/{id}/featured-images (multipart — not tool-friendly)
+```
+Permissions [VERIFIED: sm-journal-plugin/controllers/api/auth.go:26-30]: `permAccessPosts = "golem15.journal.access_posts"`, `permAccessOtherPosts = "golem15.journal.access_other_posts"`, `permAccessPublish = "golem15.journal.access_publish"`, `journalCover = "golem15.journal.*"`. Recommended tools: `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags` (read), `journal_create_post`, `journal_update_post` (write), `journal_delete_post` (destructive; optional). All declare `Guard: "backend"` and `Permissions: [access_posts]` (D-15/D-16 explicit-guard case). Multipart upload routes are out (JSON args only). The `journal-api` bucket keys by backend principal id when `bouncer.User` holds a backend principal [VERIFIED: sm-journal-plugin/plugin.go:137-146], so marker dispatch is throttled per admin.
+
+### Frontend tool (D-18) needs a new route
+`GET /_user/api/v1/fetch` authenticates the JWT inside the handler (`authenticate` → `bouncer.VerifyClaims`) [VERIFIED: fonoteka.go/plugins/golem15/user/controllers/api_controller.go:163-176, 795-825] and is not token-guarded, so it cannot be a target. Add an additive route such as `GET /_user/api/v1/token/me` with `user.api_token`, `user.scope:read` returning the same `{"user": …}` payload via `apiArray`. Other ready frontend targets exist in shared plugins: presentations' manage group (`user.api_token` + `user.scope:read|write`) and quizzes' import (`user.scope:write`, `user.permission:`).
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `initialize` handshake + `Mcp-Session-Id` sessions | Stateless: per-request `_meta` (protocol version, client capabilities), `server/discover`, no session header | Spec 2026-07-28 (RC locked 2026-05-21, final 2026-07-28) [CITED: blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/] | D-25 pins pier to ≤ 2025-11-25 (Open Question 1) |
+| GET SSE stream + `resources/subscribe` for change notifications | `subscriptions/listen` long-lived request (toolsListChanged, promptsListChanged…) and cacheable list results (`ttlMs`, `cacheScope`) | 2026-07-28 [VERIFIED: go-sdk@v1.8.0/docs/protocol.md:176-190] | Only relevant if the hybrid is adopted |
+| SSE resumability (`Last-Event-ID`, EventStore) | Removed in 2026-07-28; kept for ≤2025-11-25 | 2026-07-28 | pier can skip EventStore; a dropped stream loses in-flight responses (client retries) |
+| DCR as the main client registration | Client ID Metadata Documents preferred; DCR "for backwards compatibility" | Spec 2025-11-25 [CITED: modelcontextprotocol.io/specification/2025-11-25/basic/authorization] | wristband supports DCR only; acceptable, CIMD is a later improvement |
+
+**Deprecated/outdated:**
+- `StreamableHTTPOptions.CrossOriginProtection` is deprecated; wrap the handler with `http.NewCrossOriginProtection()` instead [VERIFIED: go-sdk@v1.8.0/mcp/streamable.go:184-195].
+- Roots, Sampling and Logging are deprecated in 2026-07-28 (still work for a year); pier uses none.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The go-sdk and jsonschema-go module names are legitimate (Go ecosystem not checkable by the legitimacy seam) | Standard Stack, Package Legitimacy Audit | Supply-chain risk; mitigated by the human-verify checkpoint and the proxy Origin URLs |
+| A2 | Mainstream MCP clients (Claude, ChatGPT, IDE agents) still fall back to 2025-11-25 `initialize` when a server rejects 2026-07-28 | Pitfall 2, Open Question 1 | A client speaking only 2026-07-28 cannot connect to a stateful pier |
+| A3 | Binding the session to `(client_id, user_id)` (not to the token hash) matches the intent of D-25's "a different token … is refused" | Pattern 8 | If the user means the literal token, sessions break every hour on refresh |
+| A4 | The admin set/remove path for links can live on the sm-user-plugin (frontend) Users screen and/or a CLI command, because cabana has no backend Users screen | Open Question 3 | If the user wants a new cabana backend-users screen, scope grows a lot |
+| A5 | Group-only frontend permission semantics should stay as is for routes (pier mirrors them) | Pitfall 3, Open Question 4 | If the user expects tri-state user overrides, list filtering differs from the design note |
+| A6 | `ai` scope plays no role in the read/write ceiling (D-20) | Open Question 6 | Tools might need `ai` gating |
+| A7 | A memory session store is acceptable for single-instance apps (sm-demo-app has no Redis: compose.yaml runs only Postgres) | Pattern 8, Environment | If Redis is mandatory, the demo deployment needs Redis on rome |
+| A8 | Granting `backend` only when a link exists at consent time (and dropping it otherwise) is acceptable UX | Security Domain | A later link would need re-consent (step-up) |
+| A9 | Per-call binary growth of ~2.9 MiB is acceptable if pier is always linked | Open Question 2 | Every app grows even with pier disabled |
+
+## Open Questions
+
+1. **Stateful-only (D-25) vs hybrid with the 2026-07-28 stateless protocol.**
+ - What we know: the current spec removed sessions; the SDK serves 2026-07-28 only statelessly; SDK clients fall back to 2025-11-25 against a stateful server (probe).
+ - What's unclear: whether the user wants pier to also answer 2026-07-28 clients (stateless dispatch per request, `subscriptions/listen` for push), which is additive to D-25's stateful path.
+ - Recommendation: ship D-25 as locked; design the identity/dispatch/filter layers so a stateless handler can reuse them; raise it at plan confirmation. Do not silently adopt the hybrid.
+2. **Always link pier, or behind a build tag?**
+ - What we know: linking the SDK costs ~2.9 MiB stripped; the framework already has the opt-in build-tag pattern (`summercms_s3`: `s3_on.go //go:build summercms_s3`, `s3_off.go //go:build !summercms_s3`, `summer.yaml build.tags`) [VERIFIED: modules/lagoon/attach/s3_on.go:1, s3_off.go:1, bucket.go:27-28].
+ - What's unclear: SC-1 says "enables the module in config"; a tag adds a build step.
+ - Recommendation: ask the user; if always linked, keep all plugin-facing types SDK-free anyway (they must be, for the tag option to remain open).
+3. **D-04 "backend Users screen" does not exist.** cabana mounts no backend-users controller; admins come from `admin:create`/`admin:reset-password` commands [VERIFIED: modules/cabana/http.go:247-330, commands.go:21,45]. 19.6's Reset 2FA uses the user plugin's **own** Users screen (`AdminRecordActions` + a published `TwoFactorAdmin` slot), not a generic cabana seam (19.6-01-PLAN :49, :270), so there is nothing to reuse beyond that pattern. Recommendation: My Profile gets the generic cabana seam (D-04 part 1); admin set/remove goes on the sm-user-plugin **frontend** Users screen as a record action "Remove backend link" (Danger, `Applies` only when linked, permission) plus a way to set it (a form field with `DropdownOptions` of backend admins, or an `AdminAction` widget), and/or a `user:link-backend` console command. Needs user confirmation.
+4. **Frontend permission semantics** (Pitfall 3): mirror the route (group grants) — recommended — or change `user.permission` to `MergedPermissions` (behaviour change in a core plugin).
+5. **Session binding granularity** (A3): `(client_id, user_id)` vs token hash.
+6. **`ai` scope**: ignore for tools (recommended) or require it for some tool class.
+7. **MCP consent surface.** wristband sends the browser to `{issuer}/connect?request=` [VERIFIED: modules/wristband/authorize.go:174 — `spa := s.opts.Issuer + "/connect?request=" + rfc3986Escape(requestID)`]. Recommendation: sm-oauthserver-plugin adds JSON consent endpoints for the MCP server (show/decide, `jwt.auth`, mirroring fonoteka's `ConsentShow`/`ConsentStore` contract) that include the linked admin's display name when `backend` is requested; vue-demo-app adds `/connect` and the link-proof page. The demo's nginx does not route `/oauth` or `/.well-known` to Go yet (sm-demo-app/DEPLOY.md:34), so the deploy plan must add `/mcp`, `/.well-known/oauth-protected-resource*`, `/.well-known/oauth-authorization-server` and `/oauth/mcp/*`.
+8. **Permission code spelling.** Existing backend codes are `golem15.users.*` (`golem15.users.access_users`, `…access_groups`, `…access_settings`, `…impersonate_user`, `…manage_privileged_groups` [VERIFIED: fonoteka.go/plugins/golem15/user/admin_permissions.go:17-41]); 19.6 adds `golem15.users.reset_two_factor`. Recommend `golem15.users.manage_backend_links` (CONTEXT's example `golem15.user.…` would break the pattern).
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Go toolchain | everything | ✓ | go1.27.2 | — |
+| Docker | testcontainers Postgres suites | ✓ | 29.9.1 | `-short` skips; `sudo -u golem` if socket denied (CLAUDE.md) |
+| redis-server | manual multi-instance smoke | ✓ | 7.2.5 | miniredis in unit tests |
+| psql | manual checks | ✓ | 18.6 | — |
+| Node / npm | admin SPA rebuild, vue-demo-app pages | ✓ | v22.23.3 / 12.2.1 | — |
+| govulncheck | dependency audit in the decision note | ✗ | — | `go run golang.org/x/vuln/cmd/govulncheck@latest ./...` |
+| Redis on the demo host (rome) | D-25 multi-instance | unknown (sm-demo-app compose has Postgres only; env.example has no `SUMMER_REDIS__URL`) | — | memory session store for the single-instance demo (A7) |
+
+**Missing dependencies with no fallback:** none.
+**Missing dependencies with fallback:** govulncheck (go run); Redis on rome (memory store).
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Go `testing` (go1.27.2) + testcontainers-go v0.44.0 (Postgres) + miniredis v2.40.0 + go-sdk v1.8.0 client; vitest 3.2.7 for the admin SPA seam |
+| Config file | none (Go); `admin/vitest.config.*` exists for the SPA |
+| Quick run command | `go test -short ./modules/pier ./modules/surf ./modules/bouncer ./modules/cabana ./modules/wristband ./modules/pact` |
+| Full suite command | `go vet ./... && go test ./...` (framework) + `go test ./...` in each touched plugin (through the app workspace for sm-user-plugin) + `npm --prefix admin run test` + `scripts/check-phase19.7.sh` |
+
+### Phase Requirements → Test Map
+| Req | Behavior | Test Type | Automated Command | File Exists? |
+|-----|----------|-----------|-------------------|-------------|
+| SC-1/D-30 | disabled → no `/mcp`, no PRM routes in `route:list`; enabled → mounted | unit | `go test -short ./modules/surf -run TestPierMount` | ❌ Wave 0 |
+| SC-1 | SSE stream flushes through the streaming route kind | unit (httptest, real flush) | `go test -short ./modules/surf -run TestStreamingRoute` | ❌ |
+| SC-2/D-10..D-12 | bad binding/foreign route/duplicate name/invalid name fail boot; prefixing | unit | `go test -short ./modules/pier -run TestCatalog` | ❌ |
+| D-11/D-13/D-14 | path/query/body split, raw body forwarding, isError with route body, annotations | unit (fake routes) | `go test -short ./modules/pier -run TestDispatch` | ❌ |
+| D-15/D-16 | public/cookie-only route refused unless guard declared; requirement union | unit | `go test -short ./modules/pier -run TestGuardRules` | ❌ |
+| D-17 | marker honoured by bouncer `api_token`/`backend` and `ResolveBackend`; header cannot forge it; frontend marker refused by backend guard | unit | `go test -short ./modules/bouncer ./modules/cabana -run TestDispatchMarker` | ❌ |
+| SC-3/D-20/D-23 | per-principal tools/list via SDK client; read-only grant hides write tools; call refused when not listed | integration (miniredis, fake guards) | `go test -short ./modules/pier -run TestToolsList` | ❌ |
+| D-25 | binding mismatch 403; DELETE ends session; rehydration on second router instance; sliding TTL expiry → 404 | integration (two routers, one miniredis) | `go test -short ./modules/pier -run TestSessions` | ❌ |
+| D-26/D-27 | event → PUBLISH → list_changed received by SDK client on the other instance; revocation closes session | integration | `go test -short ./modules/pier -run TestFanOut` | ❌ |
+| D-09 | plugin codes appear in user and group permission editors next to seeded rows; seeded rows win on duplicate code | Postgres | `go test ./... -run TestFrontendPermissionCatalog` (user plugin) | ❌ |
+| D-03..D-08 | link request one-shot/expiry/fresh-iat/1:1 uniqueness/admin set records linked_by/event; per-request lookup | Postgres | `go test ./... -run TestBackendLink` (user plugin) | ❌ |
+| D-04 | account seam: contributions listed, action runs, `interactiveOnly`, existing `/account*` and `/auth/me` payloads unchanged | unit + vitest | `go test -short ./modules/cabana -run TestAccountContributions`; `npm --prefix admin run test` | ❌ |
+| D-21 | `backend` accepted at authorize, unchanged default; consent drops `backend` without a link; consent payload has the admin line only with a link | unit + Postgres | `go test -short ./modules/wristband -run TestMCPScopes`; plugin `go test ./... -run TestMCPConsent` | ❌ |
+| SC-4 | `oas_at_` verifier: OIDC token refused, revoked/expired/foreign-kind refused | Postgres | plugin `go test ./... -run TestMCPTokenVerifier` | ❌ |
+| SC-4/D-18 | end-to-end in sm-demo-app: OAuth token → user tool works; with link + backend scope → Journal create_post as the admin; unlink → tool gone and call refused | e2e (testcontainers + SDK client) | `cd ../sm-demo-app && go test ./... -run TestMCPEndToEnd` | ❌ |
+| D-31 | fonoteka.go and sm-bm-app build and pass at bumped pins | build/test | `scripts/check-phase19.7.sh` stage `consumers` | ❌ |
+
+### Sampling Rate
+- **Per task commit:** the quick run command for the touched module(s).
+- **Per wave merge:** framework `go vet ./... && go test ./...` plus the touched plugin suites.
+- **Phase gate:** `scripts/check-phase19.7.sh` (fail-closed, stages like check-phase19.4.sh: go, plugins, userplugin additive check, consumers, docker, admin dist/openapi) green before `/msd-verify-work`.
+
+### Wave 0 Gaps
+- [ ] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
+- [ ] pier test helpers: fake guard provider, fake verifier, a two-instance harness on one miniredis, an SDK client with a bearer-injecting `http.Client`
+- [ ] user-plugin Postgres fixtures for links and frontend permission rows (extend `admin_harness_test.go`)
+- [ ] additive-API checks for sm-user-plugin, sm-journal-plugin and sm-oauthserver-plugin (no exported identifier removed since the last pre-phase commit, as check-phase19.4.sh does)
+- Frameworks: none to install (go-sdk is added by the decision-note plan after the human check).
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | Every request re-verifies the bearer (D-25); `oas_at_` verifier checks prefix, hash, expiry, revocation, client kind `mcp`, user active; personal tokens only behind the flag (D-19) |
+| V3 Session Management | yes | `crypto/rand.Text` session IDs; binding key check (403); sliding TTL; DELETE ends everywhere; revocation closes sessions; periodic re-verify on long GET streams |
+| V4 Access Control | yes | Scope ceiling (read → readOnly tools only, `backend` scope required for backend guard), guard provider checks equal to route enforcement, route middleware still enforces every call, marker only from pier |
+| V5 Input Validation | yes | JSON schema validation of arguments (`additionalProperties: false`), path value checks (no `/` in single-segment params), route `Where` constraints, body limits |
+| V6 Cryptography | yes | No hand-rolled crypto; link codes and tokens stored as SHA-256 of 32 random bytes; constant-time compare where hashes are compared |
+| V7 Error/Logging | yes | Audit log per tool call (tool, guard, frontend user id, backend admin id, client id, session id prefix, status, duration); never log tokens, arguments or bodies |
+| V13 API | yes | Origin validation / DNS-rebinding protection (`http.NewCrossOriginProtection` or an allow-list; the SDK's localhost protection applies only to loopback listeners), PRM and `WWW-Authenticate`, `throttle:` bucket on `/mcp` |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Forged dispatch marker (an HTTP request claiming a pre-resolved principal) | Spoofing | Context-only marker with unexported key in bouncer; nothing reads headers for it; test that a request with any header set is unaffected; grep gate that only pier calls the setter |
+| Frontend marker accepted by a backend guard | Elevation | Marker lists the exact bouncer guard names to honour; `ResolveBackend` honours only a backend-kind marker |
+| Token passthrough / confused deputy | Spoofing/Elevation | Inner request carries no `Authorization`/`Cookie`; spec "MUST NOT pass through the token" |
+| Audience confusion (OIDC userinfo token or another resource's token used at `/mcp`) | Spoofing | Verifier accepts only `oas_at_` tokens of `kind = mcp` clients; single resource per issuer, `resource` equal to `mcp.resource`; consider persisting `resource` on MCP token rows (additive column) |
+| Session hijack (stolen `Mcp-Session-Id` with another valid token) | Spoofing | Binding key check; IDs unguessable; spec "Binding session IDs to user information" (SDK `TokenInfo.UserID` semantics) |
+| Link without proof / replayed link code / CSRF on confirm | Elevation | One-shot hashed code bound to the admin, short TTL, row lock, `jwt.auth` confirm with fresh `iat`, POST only, page names the admin; never by email (D-05) |
+| Admin sets a link to any frontend user (D-07) | Elevation | Separate permission without a default role, `linked_by` recorded, event fired; documented as a privileged action in the security review |
+| Consent phishing: DCR client asks for `backend` | Elevation | Consent shows the client name, redirect host and "also act as admin "; grant `backend` only if a link exists at consent; `backend` revocable per grant |
+| Superuser reach through an agent (D-22) | Elevation | Read/write ceiling; audit log; security review weighs an optional config ceiling that refuses superuser backend principals over MCP |
+| Personal token at the MCP endpoint (spec says "MUST only accept tokens … valid for use with their own resources") | Spoofing | Off by default (D-19); tokens are this app's own; each capped by its own scope |
+| Prompt injection via tool metadata | Tampering | Static, factual descriptions; no data-derived text in descriptions or prompt templates |
+| DoS: many sessions/streams | DoS | `throttle:` on `/mcp`, session cap per binding key, idle TTL, SDK `MaxRequestBodyBytes` (4 MiB default), per-call timeout, cap on captured route response size |
+
+## Sources
+
+### Primary (HIGH confidence)
+- go-sdk v1.8.0 source in the module cache (proxy-verified): `mcp/streamable.go`, `mcp/streamable_server.go`, `mcp/server.go`, `mcp/session.go`, `mcp/shared.go`, `mcp/tool.go`, `mcp/protocol.go`, `auth/auth.go`, `docs/protocol.md`, `README.md`, `LICENSE`, `go.mod`, `examples/server/distributed/main.go`
+- jsonschema-go v0.4.3 source: `jsonschema/infer.go`, `LICENSE`, `go.mod`
+- Local probes (scratchpad): linked-module list, +2.9 MiB size, stateful negotiation 2025-11-25, cross-instance rehydration, per-session list_changed
+- Framework: `modules/surf/router.go`, `routetable.go`, `apidocs.go`, `serve.go`, `params.go`, `overlap.go`, `limiter.go`; `modules/bouncer/context.go`, `registry.go`, `guard.go`; `modules/cabana/contracts.go`, `token_guard.go`, `auth.go`, `http.go`, `account.go`; `modules/wristband/server.go`, `authorize.go`, `stores.go`; `modules/cooler/cooler.go`; `modules/pact/capabilities.go`; `modules/party/registry.go`; `internal/build/*`; `modules/lagoon/attach/s3_*.go`
+- sm-user-plugin (fonoteka.go checkout, commit 35cf166 = v0.1.5): `plugin.go`, `routes.go`, `middleware/api_token.go`, `classes/permissions.go`, `classes/user_lookup.go`, `classes/api_tokens.go`, `classes/codes.go`, `models/*`, `controllers/*admin_controller.go`, `admin_permissions.go`, `config/config.yaml`
+- sm-journal-plugin: `routes.go`, `plugin.go`, `controllers/api/auth.go`, `posts.go`, `posts_helpers.go`
+- sm-oauthserver-plugin: `servers.go`, `routes.go`, `middleware.go`, `classes/mcp_store.go`, `models/access_token.go`, `README.md`
+- 19.6 plans (files_modified, D-15 slot), 19.4 CONTEXT, design note and both folded todos
+
+### Secondary (MEDIUM confidence)
+- MCP authorization spec 2025-11-25 — https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization (PRM MUST, resource MUST, no passthrough)
+- MCP 2026-07-28 release candidate post — https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ (sessions and initialize removed)
+
+### Tertiary (LOW confidence)
+- Web search summaries on stateless MCP adoption ([appwrite.io](https://appwrite.io/blog/post/mcp-goes-stateless-in-the-2026-07-28-specification), [nango.dev](https://nango.dev/blog/stateless-mcp-how-it-changes-the-way-agents-call-tools), [betterstack](https://betterstack.com/community/guides/ai/mcp-stateless/)) — client behaviour claims not verified (A2)
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — versions from the Go proxy, source read, dependency graph and size measured locally; legitimacy unverifiable by the seam (Go), gated by a human check.
+- Architecture: HIGH for in-repo seams (all read this session) and for the SDK session/rehydration/notification mechanics (probe-verified); MEDIUM for the overall hybrid-protocol question.
+- Pitfalls: HIGH — each tied to a read line range or a probe.
+
+**Research date:** 2026-10-21
+**Valid until:** 2026-11-20 (go-sdk moves fast; re-check `@latest` before `go get`)