diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md b/.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md
new file mode 100644
index 0000000..ebfde4a
--- /dev/null
+++ b/.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md
@@ -0,0 +1,154 @@
+# Phase 8: OAuth2.1 authorization server - Context
+
+**Gathered:** 2026-09-23
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Port Płytarium's MCP OAuth 2.1 authorization server so fonoteka-mcp, Claude, ChatGPT and Grok connect to the Go backend unchanged. The surface is the unauthenticated RFC group already declared raw in Phase 6 (`GET /.well-known/oauth-authorization-server`, `GET /oauth/mcp/authorize`, `POST /oauth/mcp/token`, `POST /oauth/mcp/register`) plus the five JWT-group endpoints the Nuxt `/connect` page and settings page call (`GET oauth/request/{request_id}`, `POST oauth/consent`, `POST oauth/deny`, `GET oauth/connected-apps`, `DELETE oauth/connected-apps/{id}`), the `fonoteka:oauth-client` console command, and the `scope_ceiling` logic Phase 7 C-03 deferred here.
+
+The PHP server is hand-rolled inside the `golem15/fonoteka` plugin (not a separate `oauthserver` plugin and not league/oauth2-server): `OAuthCodeManager` plus four API controllers, about 1,000 lines with exact bodies. Every OAuth access token is an ordinary `inv_` personal token minted by `ApiTokenManager` and verified by the existing `inv_token` guard. Grant types are `authorization_code` and `refresh_token` only; there is no client-credentials or token-exchange grant. This closes the STATE.md blocker about `ClientCredentialsStorage`/`TokenExchangeStorage`: neither is needed.
+
+Out of scope: social login (`/oauth/{provider}`, `oauth-identities` routes, Phase 7 deferred), any change to fonoteka-mcp or the Nuxt app, live vendor connects, a River sweep job (Phase 11).
+
+
+
+
+## Implementation Decisions
+
+### Server engine
+- **D-01:** Direct port on the standard library (`crypto/rand`, `crypto/sha256`, `crypto/subtle`, `net/url`, `encoding/base64` RawURLEncoding). zitadel/oidc is **not** added. It was evaluated and dropped because it is an OIDC provider whose metadata document, error bodies and token model differ from the PHP bytes and cannot mint `inv_` tokens; PITFALLS.md already warned against trusting its defaults. A decision note (`.planning/notes/`) records this, and the "on zitadel/oidc" wording in ROADMAP.md Phase 8 and REQUIREMENTS.md AUTH-05 is corrected at plan time. STACK.md/ARCHITECTURE.md rows naming zitadel are superseded by this decision, not edited.
+- **D-02:** Parameter sources follow PHP per endpoint. `/token` reads the urlencoded body plus the query string (Go `ParseForm` semantics, matching Laravel `input()` for the clients that exist); a JSON body on `/token` is `invalid_request`. `/register` is JSON-only: a non-JSON content type is `invalid_client_metadata` "Request must be application/json". `/authorize` reads the query only.
+- **D-03:** Lifetimes and caps are config keys with PHP values as defaults (pending request 600 s, code 600 s, access token 3600 s, refresh token 30 days, DCR cap 200 clients, unconsented-client sweep after 24 h). Issuer is `app.url` with trailing slash trimmed; the expected RFC 8707 resource is `fonoteka.mcp.resource` (default `https://mcp.plytarium.com/mcp`); the consent URL is `app.url` + `/connect?request=`. Exact key layout (the option discussed was `plugins.golem15.fonoteka.oauth.*` for the app side, with wristband taking an options struct) is Claude's discretion.
+- **D-04:** Security-load-bearing treatment as in Phases 3 and 6: every plan carries `T-08-xx` threats (PKCE bypass, code replay, refresh replay and lineage kill, open redirect on authorize, client-secret timing, scope-ceiling escalation, cross-user consent, request_id leakage in logs, DCR flooding) and the phase closes with `08-SECURITY-REVIEW.md` mapping each threat to a failing-when-broken test. Client-secret comparison is `crypto/subtle.ConstantTimeCompare` over the sha256 hex, PKCE S256 comparison is constant-time too.
+
+### Package home and interfaces
+- **D-05:** A new framework package **`summercms.go/wristband`** (festival wristband, pairs with `bouncer`) owns the RFC surface: metadata document, authorize validation and pending-request creation, token endpoint (client authentication with `client_secret_post`/`client_secret_basic`/`none`, code exchange, refresh rotation with lineage kill on replay), RFC 7591 registration with its validation rules and caps, PKCE, scope parsing and scope ceiling, redirect-URI allow-list rules, the unconsented-client sweep and the expiry sweep (D-16). It is app-agnostic: scope list, endpoint paths, issuer, resource, consent-URL builder and TTLs are options; it never imports fonoteka.
+- **D-06:** PHP's RFC-minimal response shapes are wristband's defaults. There are no response hooks; fonoteka adds nothing to reach byte parity. Metadata field values (`service_documentation`, `scopes_supported`, auth methods, `authorization_response_iss_parameter_supported`) are options with the PHP values as fonoteka's config.
+- **D-07:** Storage seam: the app implements `ClientStore`, `AuthCodeStore`, `RefreshTokenStore` (backed by the Phase 5 GORM models `OAuthClient`, `OAuthAuthCode`, `OAuthRefreshToken`) and an `AccessTokenIssuer` that fonoteka satisfies with its Phase 7 `ApiTokenManager` (mint with name, scopes, expiry, collection ids; revoke by id), then stamps `oauth_client_id`. Code exchange and refresh rotation run in one transaction with row locks as PHP's `lockForUpdate` does, so the store seam must expose a transaction boundary. Exact interface names and signatures are Claude's discretion; wristband ships an in-memory store for its own tests.
+- **D-08:** The app keeps what touches app shapes: consent show/store/deny on the JWT group (using `ActiveCollectionResolver` for `collection_name` and `collection_ids`, `MINTABLE_SCOPES` intersection, `consented_at` stamping), connected-apps index/destroy (Phase 7 `serializeToken` plus `client_name`, revoke cascading through wristband's lineage kill), the `/connect` URL and route mounting. Consent calls wristband operations (issue code, deny pending) rather than touching code rows directly.
+- **D-09:** Routes mount on the Phase 6 raw group from `fonoteka.go` `routes.go`, with `throttle:fonoteka-oauth-token` on `/token` and `throttle:fonoteka-oauth-register` on `/register` as per-route middleware (P6 D-01 buckets already exist). Handlers are net/http handlers with swag annotations like the rest of the app.
+- **D-10:** The `oauth` guard reserved by Phase 6 D-09 is **retired**. OAuth-issued tokens are `inv_` tokens the `inv_token` guard already authenticates; `oauth_client_id` is what distinguishes them in connected-apps. Wristband registers no guard. The Phase 6 reservation is closed with a note in the plan and in the Phase 6 docs wording.
+- **D-11:** The `inv_` prefix stays for Płytarium because fonoteka-mcp's install script rejects any other prefix and its HTTP transport accepts only `Bearer inv_`. The Phase 7 token manager reads its prefix from config with `inv_` as Płytarium's value, so nothing on the wire changes. A summer-themed framework default is deferred.
+
+### End-to-end proof and the header contract
+- **D-12:** Success criterion 3 is reworded at plan time. The rich `WWW-Authenticate: Bearer ... resource_metadata=...` header and the `/.well-known/oauth-protected-resource` document are emitted by fonoteka-mcp (the resource server), not by the backend. The backend's contract is: exactly `WWW-Authenticate: Basic realm="OAuth"` on `invalid_client` at `/token`, and the unchanged Phase 6 token-surface 401 `{"error":"Invalid token"}` with no new headers. Adding RFC 6750 headers to backend 401s is rejected as a contract change.
+- **D-13:** Recorded MCP flows (`mcp-oauth`, `mcp-tools`, and the new `mcp-lifecycle` from D-15) replay in the Go test suite in `fonoteka.go/parity`, the way `nuxt_flow_test.go` replays `nuxt-auth`.
+- **D-14:** A `scripts/check-phase8.sh` gate (same family as `check-phase2.sh`/`check-phase3.sh`) boots the Go app on Postgres, starts the real Node fonoteka-mcp from `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp` with `FONOTEKA_API_URL`, `FONOTEKA_MCP_PUBLIC_URL` and `FONOTEKA_MCP_AUTH_SERVER` pointed at the Go backend, and drives the discovery chain with a scripted client: protected-resource metadata, the MCP's 401 hint, authorization-server metadata, DCR, PKCE authorize, login and consent through the JWT API, token, an MCP tool call with the issued `inv_` token, then refresh. fonoteka-mcp is not modified. The scripted client's language and home are Claude's discretion (`parity/capture_clients.mjs` exists as a precedent).
+- **D-15:** No live vendor connect (Claude, ChatGPT, Grok) is part of acceptance; the automated gates cover the same DCR-plus-PKCE chain those apps use. The first real vendor connect happens at cutover.
+
+### Parity corpus and lifecycle
+- **D-16 (corpus):** A second PHP flow, `mcp-lifecycle`, is recorded against the isolated PHP instance with the Phase 2 `tide` tooling (capture rules, private 0600 vars store, `pkce.vars`, no live secrets in git): DCR → authorize → consent → token → refresh → replay of the spent refresh token (`invalid_grant`, lineage dead) → connected-apps list showing the app → revoke → refresh after revoke fails → a deny path; plus a confidential client issued by `fonoteka:oauth-client` with a scope ceiling using `client_secret_basic`, showing ceiling truncation and the `invalid_scope` redirect. The four RFC route entries and five JWT-group entries in `parity/manifest.yaml` flip pending → ported through the usual gate; pending never counts as passing.
+- **D-17 (sweep):** Wristband adds an expiry sweep that PHP lacks, run where PHP runs its client sweep (`/register`) and also on `/token`, deleting only rows past `expires_at`: pending requests, codes (used or not) and refresh rows. Revoked or rotated rows that have not expired stay, so replay detection and connected-apps semantics match PHP. No timer, no goroutine; Phase 11 may move it into a River job. It is unobservable in recorded replays because nothing expires within a run; the schema-diff and db-capture harness must not be affected.
+- **D-18 (tests):** All PHP OAuth tests are ported (functional: OAuthAuthorizeTest, OAuthClientCommandTest, OAuthMetadataTest, OAuthMigrationTest, OAuthRegisterTest, OAuthTokenTest; security: OAuthConsentScopeCeilingTest, OAuthRefreshRotationTest, OAuthRevocationTest, TokenSurfaceIsolationTest). Framework behaviour tests run on wristband with the in-memory store; app tests run on real Postgres through the existing `classes` TestMain harness, and route-surface isolation tests inspect the surf route table as Phase 6 did. Each PHP test method maps to a named Go test so coverage can be audited.
+- **D-19 (command):** `fonoteka:oauth-client` is ported in `fonoteka.go` as a bonfire command scaffolded the Phase 4 way with the same signature (`name`, `--redirect-uri=*`, `--scope=*`, `--auth-method`, `--client-id`, `--list`) and the same output lines (`client_id=`, `client_secret=` printed once, the non-recoverable warning, `--list` never printing a secret). It is thin over wristband's client issuing helper and the app `ClientStore`.
+
+### Claude's Discretion
+- Interface names and signatures in wristband, the transaction seam, in-memory store design, and where shared helpers (base64url, sha256 hex, constant-time compare) live.
+- Config key layout for wristband options on the fonoteka side (D-03).
+- The gate's scripted client (Go program vs Node script) and how the gate obtains Postgres.
+- Logging policy inside wristband (never log `request_id`, codes, secrets, verifiers), error-text constants, and how `client_name` control characters are stripped (port PHP's regex).
+- How `Content-Type` is judged for `/register` (PHP `isJson()` is "contains /json").
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### PHP source of truth (byte contract)
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` §"Phase 07.9 MCP OAuth 2.1 authorization server" (lines ~520-570) and the JWT-group block (lines ~277-289) — route surface, middleware, throttles, RFC 6750 layering note.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/OAuthCodeManager.php` — pending request, code issue, PKCE S256, code exchange, refresh rotation and lineage kill, TTL constants.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthMetadataController.php` — exact RFC 8414 body.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthAuthorizeController.php` — validation order, local text/plain 400s, error 302s with `iss`, scope ceiling (Phase 07.13), resource check, consent redirect.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthTokenController.php` — grant dispatch, client authentication (Basic overrides form), bare `{"error":code}` bodies, `WWW-Authenticate: Basic realm="OAuth"`, `Cache-Control: no-store` + `Pragma: no-cache`.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthRegisterController.php` — RFC 7591 validation, 200-client cap, orphan sweep, 201 body.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthConsentController.php` — show/store/deny bodies, `pendingFor` rules, `untrustedName`.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ConnectedAppController.php` — list and revoke bodies, `manual_tokens_count`.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/OAuthClient.php` — `isUsable`, `isPublic`, `ceilingScopes`, `rejectRedirectUri` rules.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/console/IssueOAuthClient.php` — command signature and output lines (D-19).
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php` — `mcp.resource` default.
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/create_oauth_tables.php`, `updates/v1.1.7/add_oauth_client_id_to_api_tokens.php`, `updates/v1.1.9/add_scope_ceiling_to_oauth_clients.php` — schema (already ported in Phase 5).
+- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuth*.php` and `tests/security/{OAuthConsentScopeCeilingTest,OAuthRefreshRotationTest,OAuthRevocationTest,TokenSurfaceIsolationTest}.php` — the tests to port (D-18).
+
+### The unchanged clients
+- `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts` — protected-resource metadata, the 401 `WWW-Authenticate` hint, `inv_`-only bearer extraction (D-11, D-12).
+- `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/config.ts` — `FONOTEKA_API_URL`, `FONOTEKA_MCP_PUBLIC_URL`, `FONOTEKA_MCP_AUTH_SERVER` (D-14).
+- `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/install.ts` — `^inv_` token check in the install script.
+- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useFonoteka.ts` and `app/stores/fonoteka.ts` — the Nuxt calls to `oauth/request`, `consent`, `deny`, `connected-apps`.
+
+### Parity harness
+- `../fonoteka.go/parity/manifest.yaml` — the nine pending OAuth entries (`auth_group: oauth` and the JWT-group `oauth/*` routes).
+- `../fonoteka.go/parity/fixtures/mcp/mcp-oauth.yaml`, `mcp-tools.yaml` — recorded MCP flows (20 and 3 steps).
+- `../fonoteka.go/parity/nuxt_flow_test.go`, `capture_clients.mjs`, `capture-rules.yaml`, `php_parity.sh` — recording and replay precedent for D-13/D-16.
+- `scripts/check-phase2.sh`, `scripts/check-phase3.sh` — gate script family for D-14.
+
+### Prior phase decisions that bind this phase
+- `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md` — D-01 buckets, D-06 guard registry, D-07/D-08 `inv_token` guard and `inv.scope`, D-09 reserved `oauth` guard (now retired), D-16 raw group, D-17 response helpers.
+- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md` — C-02 token manager and Bearer-only extraction (D-09), C-03 scope ceiling deferred here.
+- `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md` — format for `08-SECURITY-REVIEW.md` (D-04).
+- `.planning/research/PITFALLS.md` — zitadel default-shape pitfall and the header-contract pitfall (Pitfall 10).
+- `.planning/research/STACK.md` §zitadel/oidc and `.planning/research/ARCHITECTURE.md` bouncer row — superseded by D-01; read for what was assumed, not as instructions.
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- `../fonoteka.go/plugins/golem15/fonoteka/models/{oauth_client,oauth_auth_code,oauth_refresh_token}.go` — ported Phase 5 models with `lagoon.Jsonable` JSON columns; `api_token.go` already carries `OAuthClientID` and `CollectionIDs`.
+- `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go` — Phase 7 mint/revoke, the `AccessTokenIssuer` implementation (D-07); `token_guard.go` — the `inv_token` guard that authenticates OAuth-issued tokens (D-10).
+- `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go` `serializeToken` — reused by connected-apps (D-08).
+- `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` — `fonoteka-oauth-token` and `fonoteka-oauth-register` buckets already declared.
+- `surf/router.go` `GroupRaw` — the raw group API; `../fonoteka.go/plugins/golem15/fonoteka/routes.go` line ~41 holds the empty raw group comment "throttles attach per-route in Phase 8".
+- `../fonoteka.go/config/http.yaml` — CORS already lists `oauth/mcp/*`.
+- `wire` package JSON writer and Carbon-format time helpers (P6 D-17) for the consent `expires_at` ISO 8601 value; `lagoon.Validate` for the consent `request_id`/`scopes` rules.
+- `bonfire` command registry and the Phase 4 `summer make:command` scaffold for D-19.
+
+### Established Patterns
+- Framework stays app-agnostic (`summercms.go` never imports fonoteka); app-specific tables and resolvers stay in `fonoteka.go`.
+- Response conventions are helpers, not middleware; raw groups refuse house middleware and emit bare 500s on panic.
+- Security phases end with a threat-to-test review document; high-severity threats are closed with failing-when-broken tests.
+- Recorded PHP flows drive Go replay tests; manifest entries flip pending → ported only on a passing subtest.
+- Bearer-only token extraction; tokens never in URLs or logs.
+
+### Integration Points
+- `routes.go` raw group (mount wristband handlers) and the JWT group (consent and connected-apps).
+- `plugin.go` Boot: construct wristband with the app stores, token issuer and options; register the console command.
+- `parity/manifest.yaml` and `parity/fixtures/mcp/` for the new flow; `parity/` replay tests.
+- `scripts/check-phase8.sh` next to the existing gates.
+
+
+
+
+## Specific Ideas
+
+Contract facts verified in PHP source during discussion, to be treated as bytes:
+- Metadata: `issuer`, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, `response_types_supported: ["code"]`, `grant_types_supported: ["authorization_code","refresh_token"]`, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["none","client_secret_post","client_secret_basic"]`, `scopes_supported: ["read","write","ai","offline_access"]`, `service_documentation: /help`, `authorization_response_iss_parameter_supported: true`. No `jwks_uri`, no OIDC fields, no envelope.
+- Token success: `{access_token, token_type: "Bearer", expires_in, refresh_token, scope}` where `scope` is the granted scopes joined by space plus ` offline_access` when requested; headers `Cache-Control: no-store`, `Pragma: no-cache`. Errors: `{"error":"invalid_request"|"unsupported_grant_type"|"invalid_grant"}` 400 with no description; `{"error":"invalid_client"}` 401 with `WWW-Authenticate: Basic realm="OAuth"`.
+- Authorize: unknown client or unregistered `redirect_uri` → 400 `text/plain; charset=UTF-8` "Unknown client." / "Unregistered redirect URI." with `Cache-Control: no-store` and no `Location`; later failures 302 to `redirect_uri` with `error`, `error_description`, `iss` and `state` (RFC 3986 encoding); `code_challenge` length 43..128, method must be `S256`; default scope `["read"]`; ceiling truncation keeps `offline_access` outside the ceiling; `resource` optional and must equal the configured value; success 302 to `/connect?request=` with `Cache-Control: no-store`.
+- Register: 201 `{client_id, client_id_issued_at, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method[, client_secret, client_secret_expires_at: 0]}` with `Cache-Control: no-store`; errors 400 `{error, error_description}`; default name "MCP client"; at most 5 unique URIs, each https or http on 127.0.0.1/localhost, ≤512 chars; `client_id` = base64url(16 random bytes), secret = base64url(32) stored as sha256 hex; `registration_ip` set (artisan clients have it null and are never swept).
+- Consent: `GET oauth/request/{id}` → `{"data":{client_name, redirect_host, scopes_requested, collection_name, expires_at}}` or 404 `{"error":"Request not found"}`; `POST consent` validates `request_id` and `scopes.*` in `read,write,ai`, 422 `{"error":"No grantable scopes"}` when the intersection is empty, returns `{"data":{"redirect_to": ?code=&iss=[&state=]}}` and stamps `consented_at` once; `POST deny` marks the pending row used and returns `redirect_to` with `error=access_denied&iss[&state]`.
+- Connected apps: `{"data":[serializeToken + client_name], "manual_tokens_count": n}` ordered by `created_at` desc; `DELETE` → 404 `{"error":"Token not found"}` for foreign or manual tokens, else revokes the access token and the whole refresh lineage, `{"data":{"revoked":true}}`.
+- Refresh rotation: presenting a spent (rotated) refresh token revokes the whole lineage and returns `invalid_grant`; rotation revokes the old access token and mints a new `inv_` token named after the client (≤120 chars) with the same scopes and collection ids.
+- Codes, refresh secrets and PKCE share one base64url + sha256 transform; codes are single-use with `used_at`; `request_id` is nulled when the code is issued.
+
+
+
+
+## Deferred Ideas
+
+- Summer-themed default token prefix for the framework and other projects; `inv_` is the Inventory-era legacy and Płytarium must keep it while fonoteka-mcp is the client (D-11).
+- Moving the on-request expiry sweep into a River job — Phase 11.
+- A generic framework-side client-issuing command (the app-side port was chosen, D-19).
+- Live Claude, ChatGPT and Grok connects against the Go backend — cutover UAT, not this phase (D-15).
+- Social login `/oauth/{provider}` and the `oauth-identities` JWT routes — still deferred from Phase 7; not part of this OAuth server.
+
+
+
+---
+
+*Phase: 08-oauth2-1-authorization-server*
+*Context gathered: 2026-09-23*
diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-DISCUSSION-LOG.md b/.planning/phases/08-oauth2-1-authorization-server/08-DISCUSSION-LOG.md
new file mode 100644
index 0000000..6636fe7
--- /dev/null
+++ b/.planning/phases/08-oauth2-1-authorization-server/08-DISCUSSION-LOG.md
@@ -0,0 +1,185 @@
+# Phase 8: OAuth2.1 authorization server - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
+
+**Date:** 2026-09-23
+**Phase:** 08-oauth2-1-authorization-server
+**Areas discussed:** Server engine, Package home and the oauth guard, End-to-end proof and the header contract, Parity corpus and lifecycle coverage
+
+---
+
+## Server engine
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Direct port on stdlib | Port OAuthCodeManager and the four controllers on crypto/rand, sha256, subtle, net/url; decision note drops zitadel/oidc | ✓ |
+| zitadel/oidc with overrides | Implement AuthStorage/OPStorage and override metadata, error and token handlers to reach parity | |
+| zitadel/oidc only where it fits | Use it for PKCE and request validation only | |
+
+**User's choice:** Direct port on stdlib.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Form body plus query, like PHP | r.FormValue merges urlencoded body and query; JSON on /token is invalid_request; register stays JSON-only | ✓ |
+| Strict form body only | Ignore the query string | |
+| Match Laravel fully including JSON | Also accept application/json on /token | |
+
+**User's choice:** Form body plus query.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Config keys with PHP defaults | TTLs and caps under plugin config with PHP values; issuer from app.url, resource from fonoteka.mcp.resource | ✓ |
+| Constants exactly as PHP | Go constants in the code manager | |
+| You decide | | |
+
+**User's choice:** Config keys with PHP defaults.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Yes, threat model plus review | T-08-xx threats per plan and a closing 08-SECURITY-REVIEW.md mapping threats to tests | ✓ |
+| Review only | Security-review agent at the end, no threat IDs | |
+| You decide | | |
+
+**User's choice:** Threat model plus review.
+
+---
+
+## Package home and the oauth guard
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Plugin owns it, framework gets primitives | Server in fonoteka.go, framework gains PKCE/base64url/compare/form helpers | |
+| Framework oauth package with storage interfaces | Generic RFC 6749/7591/8414 server in summercms.go; fonoteka plugs its models in | ✓ |
+| Everything in the plugin | No framework additions | |
+
+**User's choice:** Framework oauth package with storage interfaces (against the recommendation).
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| New package, PHP shapes are the defaults | New package beside bouncer emitting PHP's RFC-minimal bodies; metadata, scopes, paths, TTLs are config | ✓ |
+| Inside bouncer | Extend bouncer | |
+| New package with response hooks | Generic shapes plus app override hooks | |
+
+**User's choice:** New package with PHP shapes as defaults; user asked for name suggestions before creation.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| wristband | Festival wristband, checked by the bouncer, issued at the gate after approval | ✓ |
+| visa | Permission an outside party applies for and a consenting authority stamps | |
+| lanyard | Delegated credential naming issuer and grant | |
+
+**User's choice:** wristband.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Wristband owns RFC surface, app owns consent and tokens | Stores and AccessTokenIssuer implemented by the app; consent, connected apps, /connect URL, collection ids stay in the app | ✓ |
+| Wristband also owns consent and connected apps | More interfaces, fuller reuse | |
+| You decide | | |
+
+**User's choice:** Wristband owns the RFC surface, app owns consent and tokens.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Retire it, document why | No oauth guard; inv_token guard authenticates OAuth-issued tokens | ✓ |
+| Register oauth as a restricted alias | Guard accepting only inv_ tokens with oauth_client_id, unmounted | |
+| You decide | | |
+
+**User's choice:** Retire it. **Notes:** the user added that `inv_` is a legacy prefix from the Inventory app Płytarium was forked from and could become summer-themed.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Keep inv_ for Płytarium, prefix becomes app config | Phase 7 token manager reads the prefix from config; summer-themed default deferred | ✓ |
+| Keep inv_ hardcoded, note the rename | No code change | |
+| Pick the new prefix now as the framework default | Choose it in this discussion | |
+
+**User's choice:** Keep inv_ for Płytarium, prefix becomes app config.
+
+---
+
+## End-to-end proof and the header contract
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Correct the roadmap, prove the real chain | SC3 reworded; backend emits only Basic realm="OAuth" on invalid_client; no new headers on backend 401s | ✓ |
+| Add RFC 6750 headers to backend 401s | Contract deviation with a harness allow-list | |
+| Keep the wording, satisfy it via the MCP | | |
+
+**User's choice:** Correct the roadmap, prove the real chain.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Both: tide replay in go test, live MCP in a gate script | Recorded flows in go test plus check-phase8.sh running the real Node fonoteka-mcp against the Go backend | ✓ |
+| Tide replay only | | |
+| Live MCP only, in go test | | |
+
+**User's choice:** Both.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Manual UAT step with Claude, ChatGPT if available | | |
+| Automated gates only | First vendor connect at cutover | ✓ |
+| Required before phase close | | |
+
+**User's choice:** Automated gates only.
+
+---
+
+## Parity corpus and lifecycle coverage
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Record a second PHP flow covering the full lifecycle | mcp-lifecycle: DCR, authorize, consent, token, refresh, replay kill, connected-apps, revoke, refresh-after-revoke, deny, ceiling client with client_secret_basic | ✓ |
+| Existing fixtures plus Go-only tests | | |
+| You decide | | |
+
+**User's choice:** Record a second PHP flow.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Match PHP now, Phase 11 job later | No cleanup this phase | |
+| Add an expiry sweep now | Additive cleanup of expired rows | ✓ |
+| You decide | | |
+
+**User's choice:** Add an expiry sweep now.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| On /register and /token, expired rows only | Delete only rows past expires_at; revoked or rotated unexpired rows stay; no timer | ✓ |
+| Background ticker in plugin Boot | | |
+| You decide | | |
+
+**User's choice:** On /register and /token, expired rows only.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Port all as Go tests, split framework vs app | Wristband tests on in-memory store; app tests on Postgres; each PHP method maps to a named Go test | ✓ |
+| Port security classes only | | |
+| You decide | | |
+
+**User's choice:** Port all as Go tests.
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Port it in fonoteka.go with the same flags | bonfire command, thin over wristband | ✓ |
+| Generic wristband command in the framework | | |
+| Defer the command | | |
+
+**User's choice:** Port it in fonoteka.go with the same flags.
+
+---
+
+## Claude's Discretion
+
+- Wristband interface names and signatures, transaction seam, in-memory store, helper placement.
+- Config key layout on the fonoteka side.
+- The gate's scripted client and Postgres provisioning.
+- Logging policy, error-text constants, control-character stripping, Content-Type judgement for /register.
+
+## Deferred Ideas
+
+- Summer-themed default token prefix (inv_ stays for Płytarium).
+- River sweep job — Phase 11.
+- Framework-side client-issuing command.
+- Live vendor connects — cutover UAT.
+- Social login and oauth-identities routes — still deferred from Phase 7.