From 946fb2d3ea2939c6035a243e82ad84d7cd7b1c4d Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 18:53:31 +0200 Subject: [PATCH] docs(19.7-09): security review corrected and extended for the gap closure Corrects the verdicts the code review disproved (T-19.7-22 first, then T-19.7-03, -08, -17, -18, -23, -31, -35 and cross-cutting findings 1 and 6), adds the Method step that a high or critical control is verified only by a test that fails without it, the revert-checks of the three critical fixes, and the register rows T-19.7-42..T-19.7-65. --- .../19.7-SECURITY-REVIEW.md | 90 ++++++++++++++++--- 1 file changed, 79 insertions(+), 11 deletions(-) diff --git a/.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md b/.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md index 925d536c..a8a64b28 100644 --- a/.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md +++ b/.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md @@ -3,7 +3,7 @@ **Date:** 2026-10-23 **Level:** OWASP ASVS L1 (RESEARCH "Security Domain") **Scope:** the framework seams of plan 01 (bouncer dispatch marker, cabana `ResolveBackend` and the account and prompted-action seams, surf's compiled-handler lookup and stream route kind, wristband's scope allow-list), the `pier` module of plan 02, sm-user-plugin's account link, frontend permission catalog and MCP guard (plan 03), sm-oauthserver-plugin's MCP verifier and consent JSON, sm-journal-plugin's MCP tools and the demo wiring (plan 04), and plan 05's gate. -**Threats reviewed:** T-19.7-01..T-19.7-41 and T-19.7-SC from the threat models of plans 01-05. +**Threats reviewed:** T-19.7-01..T-19.7-41 and T-19.7-SC from the threat models of plans 01-05; T-19.7-42..T-19.7-65 of the gap-closure plans 06-09 in the section "Gap closure (plans 06-09, 2026-10-23)", which also corrects the verdicts the phase code review (`19.7-REVIEW.md`) disproved. **Result:** every threat is verified, fixed in a commit or accepted with a reason. The review found two production defects (a frontend dispatch marker honoured by cabana's `backend` middleware, and an ended session written back by a request still in flight) and fixed both in their own commits. The superuser verdict (D-22, T-19.7-34) is "accepted as designed with audit and ceiling"; no switch was added and no tag is needed for it. Repositories and abbreviations used below: **F** = `summercms.go` (framework, paths under `modules/`), **U** = sm-user-plugin (`sm-demo-app/plugins/golem15/user`), **O** = `sm-oauthserver-plugin`, **J** = `sm-journal-plugin`, **D** = `sm-demo-app`, **V** = `sm-demo-app/vue-demo-app`. @@ -15,6 +15,7 @@ Repositories and abbreviations used below: **F** = `summercms.go` (framework, pa 3. Where the code was wrong, fix it in its own `fix(` commit in the owning repository, then add or extend the pinning test. 4. Cross-cutting sweeps (below): link proof, dispatch marker, session binding, scope ceilings, superuser reach, logs, `no-store`. 5. Run the phase gate `bash scripts/check-phase19.7.sh --all` on a Docker host: every stage PASS. +6. (Added in the gap closure, plan 09.) A high or critical control is "verified" only with a test that fails without it. Reading the code and finding a passing test is not enough: the plan 05 review marked T-19.7-22 verified from a test that never refreshed a token. For the three critical findings of the code review this is shown by a revert-check: the fix is reverted in a scratch worktree, the pinning test fails, and it passes at HEAD (see "Revert-checks" below). ## Gaps fixed during the review @@ -37,12 +38,12 @@ A third fix found by the tests is functional, not a security gap: c29d228 `fix(p |--------|---------------------|----------------|-------------|--------| | T-19.7-01 | The marker lives under an unexported context key; `Dispatched` is the only reader and answers only for an exact guard name with a principal; no header, cookie or query is read for it; the only non-test caller of `WithDispatch` is pier's dispatcher (the gate's `marker` stage); cabana's `backend` middleware refuses a frontend marker. | F `bouncer/dispatch.go:5`, `:29`, `:40`; F `bouncer/registry.go:86`; F `pier/dispatch.go:159`; F `cabana/http.go:252` | `TestDispatchMarker` (bouncer: forged headers, cookies and query; nil and unlisted markers), `TestDispatchMarker` (cabana: over HTTP the marker never arrives), gate stage `marker` (F) | fixed in commit c49983d | | T-19.7-02 | `ResolveBackend` honours a marker only for guard `backend` with `Principal.Backend`; a frontend marker is `ErrUnauthenticated`; a scoped principal is still checked against the cover codes; the `backend` middleware applies the same rule. | F `cabana/token_guard.go:209-216`; F `cabana/http.go:252` | `TestDispatchMarkerSmoke`, `TestDispatchMarker` (cabana, incl. the in-process admin API case) (F) | fixed in commit c49983d | -| T-19.7-03 | `Router.Handler` returns the fully compiled chain (constraints, named middleware, body limit, recovery, CORS profile, pattern), the same chain the mux serves. | F `surf/routetable.go:49`; F `surf/router.go:373` | `TestDispatchSmoke` (F), `TestDispatchMarker` (cabana, Journal-shaped route through `Handler`) (F) | verified | +| T-19.7-03 | `Router.Handler` returns the fully compiled chain (constraints, named middleware, body limit, recovery, CORS profile, pattern), the same chain the mux serves. | F `surf/routetable.go:49`; F `surf/router.go:373` | `TestDispatchSmoke` (F), `TestDispatchMarker` (cabana, Journal-shaped route through `Handler`) (F) | corrected: fixed in 104db99 (F); was verified (see Corrections) | | T-19.7-04 | `recoverStream` writes the opaque 500 only before the first header, byte or flush; afterwards the body is cut short and the panic value is never written. | F `surf/streaming.go:63-80` | `TestStreamingRouteSmoke`, `TestStreamingRoute` (F) | verified | | T-19.7-05 | `mcp.enabled: true` without the build tag fails boot naming `summercms_mcp`. | F `surf/router.go:701` | `TestPierMountSmoke`, `TestPierMount` (untagged branch) (F) | verified | | T-19.7-06 | Account contribution routes sit behind the `backend` guard and `interactiveOnly` (personal tokens refused), `requireAjax` on POST, `allowAction` for the action's permissions, `Applies` re-checked before `Run` (409), `Run` inside a transaction with only the signed-in admin's id. | F `cabana/http.go:319`; F `cabana/account_contributions.go:169-181`, `:189` | `TestAccountContributionsSmoke`, `TestAccountContributions` (incl. the Postgres transaction case) (F) | verified | | T-19.7-07 | `payload` is accepted only for an action with a Prompt, only as a JSON string, trimmed, non-empty and capped in runes; every other record action still refuses a payload; a plain action runs with an empty Value. | F `cabana/actions.go:217`, `:256` | `TestRecordActionPromptSmoke`, `TestRecordActionPrompt`, `TestRecordActionPromptRun` (F) | verified | -| T-19.7-08 | Contributed labels, statuses, messages and URLs render as text and attributes (no `v-html`); a URL opens with `noopener` and the visible link carries `rel="noopener noreferrer"`. | F `admin/src/components/account/AccountContributions.vue` | `admin/tests/account/AccountContributions.test.ts` ("Text, never HTML", URL attributes) (F) | verified | +| T-19.7-08 | Contributed labels, statuses, messages and URLs render as text and attributes (no `v-html`); a URL opens with `noopener` and the visible link carries `rel="noopener noreferrer"`. | F `admin/src/components/account/AccountContributions.vue` | `admin/tests/account/AccountContributions.test.ts` ("Text, never HTML", URL attributes) (F) | corrected: fixed in 44835e4, 88559d5 (F); was verified (see Corrections) | | T-19.7-09 | wristband's authorize allow-list is exactly `Options.ScopesSupported`; the default server refuses `backend`; `openid` and unlisted scopes stay refused on the MCP flavour. | F `wristband/authorize.go:31`, `:231` | `TestMCPScopesSmoke`, `TestMCPScopes` (F) | verified | | T-19.7-10 | Every request re-verifies its bearer (`auth.RequireBearerToken` with pier's resolver); a session never replaces it; a failure is 401 with the RFC 9728 challenge and `scope="read"`. | F `pier/pier.go:235-260`; F `pier/identity.go:144` | `TestPierTracerSmoke` (bad bearer), `TestEndpointGuards` (stream ends after revocation), `TestMCPEndToEnd` "revoked token" (F, D) | verified | | T-19.7-11 | The session record stores only the binding (`oauth::` or the personal token id), compared on every POST, GET and DELETE (403 `session_binding`); ids from `rand.Text()`; sliding idle TTL; per-binding cap. | F `pier/session.go:143`, `:211`, `:242`, `:139` | `TestPierSessionsSmoke`, `TestSessions` (Redis and memory) (F) | verified | @@ -51,14 +52,14 @@ A third fix found by the tests is functional, not a security gap: c29d228 `fix(p | T-19.7-14 | One SDK server per identity; the receiving middleware re-filters `tools/list` and refuses `tools/call` and `prompts/get` outside the current request's list (JSON-RPC error, the route is never reached); stateless servers are keyed by binding. | F `pier/session.go:493`; F `pier/stateless.go:80` | `TestToolsList` (per principal; unlisted call never reaches the route), `TestPierStatelessSmoke` (F) | verified | | T-19.7-15 | Rehydration builds the server from the current bearer's identity, never from stored state; `revoked`/`deleted` events delete the record and close local copies on every instance; a request finishing after the end no longer writes the record back; link events drop the link cache and recompute. | F `pier/session.go:230-280`, `:320-360`; F `pier/events.go:42-60`; F `pier/store_redis.go` (`Update`) | `TestFanOut` (revocation, deletion, link change, cache bypass), `TestSessions`, `TestMCPEndToEnd` "revoked grant ends the session" (F, D) | fixed in commit 1111828 | | T-19.7-16 | Only `oas_at_` tokens reach the verifier; the verifier accepts only rows of a kind-`mcp` client; PRM `resource` is `mcp.resource` and a boot warning names a mismatch with the verifier's resource. | F `pier/identity.go:149`; F `pier/prm.go:23-31`; O `classes/mcp_verifier.go:79-88` | `TestMCPTokenVerifierSmoke` (OIDC client's token refused), `TestMCPEndToEnd` "protected resource metadata" (O, D) | verified | -| T-19.7-17 | Arguments are validated against the reflected schema (additionalProperties false, required fields); a single-segment value may not contain `/`; path and query keys are removed from the forwarded body; the route's own constraints and body limit still run. | F `pier/dispatch.go:85`, `:102`; F `pier/catalog.go:209`, `:340` (`inputFields`) | `TestDispatch` (undeclared key refused, `a/b` refused, omitted fields absent), `TestJournalMCP` (input keys are the route's keys) (F, J) | fixed in commit 0b8e027 (test pin; code unchanged) | -| T-19.7-18 | Inline `mcp.throttle` on the endpoint routes, `max_per_binding` (429), idle TTL, 4 MiB body cap, `mcp.call_timeout`, `mcp.max_result_bytes`, stream re-verification every minute. | F `pier/pier.go:122`, `:225`; F `pier/session.go:139`, `:369`; F `pier/dispatch.go:145`, `:186` | `TestPierMount` (tagged: `throttle:120,1`), `TestStreamingRoute` (throttle on a stream route), `TestSessions` (429), `TestDispatch` (timeout, size cap), `TestEndpointGuards` (413, stream re-verification) (F) | fixed in commit 0b8e027 (test pin; code unchanged) | +| T-19.7-17 | Arguments are validated against the reflected schema (additionalProperties false, required fields); a single-segment value may not contain `/`; path and query keys are removed from the forwarded body; the route's own constraints and body limit still run. | F `pier/dispatch.go:85`, `:102`; F `pier/catalog.go:209`, `:340` (`inputFields`) | `TestDispatch` (undeclared key refused, `a/b` refused, omitted fields absent), `TestJournalMCP` (input keys are the route's keys) (F, J) | corrected: fixed in 104db99 (F); was fixed in commit 0b8e027 (test pin; code unchanged) (see Corrections) | +| T-19.7-18 | Inline `mcp.throttle` on the endpoint routes, `max_per_binding` (429), idle TTL, 4 MiB body cap, `mcp.call_timeout`, `mcp.max_result_bytes`, stream re-verification every minute. | F `pier/pier.go:122`, `:225`; F `pier/session.go:139`, `:369`; F `pier/dispatch.go:145`, `:186` | `TestPierMount` (tagged: `throttle:120,1`), `TestStreamingRoute` (throttle on a stream route), `TestSessions` (429), `TestDispatch` (timeout, size cap), `TestEndpointGuards` (413, stream re-verification) (F) | corrected: fixed in 2bf9dca, c1c0c77 (F); was fixed in commit 0b8e027 (test pin; code unchanged) (see Corrections) | | T-19.7-19 | `originCheck` answers 403 for a browser Origin outside `mcp.allowed_origins` (default: the app's own origin) before the bearer or the body is read; no Origin passes. | F `pier/pier.go:201-220` | `TestEndpointGuards` "origin check" (F) | fixed in commit 0b8e027 (test pin; code unchanged) | | T-19.7-20 | The audit line carries tool, guard, user/admin/client ids, token kind, an 8-character session prefix, status and duration; never a token, argument or body. | F `pier/audit.go:40-66` | `TestPierTracerSmoke` "audit line" (F) | verified | | T-19.7-21 | Descriptions are plugin-authored static text, capped at 1024 runes at boot. | F `pier/catalog.go:21` | `TestCatalog` (boot refusals) (F) | accepted (see Accepted risks) | | T-19.7-SC | The user approved go-sdk v1.8.0 and jsonschema-go v0.4.3 before `go get`; versions pinned in go.mod; the decision note lists the transitive graph and govulncheck; the SDK links only into tagged binaries. | F `.planning/notes/2026-10-22-mcp-go-sdk.md`; F `.planning/research/STACK.md:56-57`; F `go.mod` | gate stages `docs` (note and STACK rows) and `go` (the untagged hello binary links no SDK) (F) | verified | -| T-19.7-22 | The self-service path writes a link only inside `ConfirmLinkRequest`: a one-shot hashed code bound to the admin, row lock, `jwt.auth` principal equal to the token's subject, `iat` not before the request's `created_at` (in seconds); no email match and no user list. | U `classes/backend_links.go:213-262` (`:227` lock, `:239` iat); U `controllers/backend_link_controller.go:60-106` | `TestBackendLinkSmoke`, `TestBackendLink` ("fresh sign-in at the second") (U) | verified | -| T-19.7-23 | 64-hex code stored as SHA-256, `used_at` set in the linking transaction, expired, used and unknown codes all 404; the show route answers only the admin's login and name and the expiry. | U `classes/backend_links.go:105`, `:129`; U `controllers/backend_link_controller.go:47` | `TestBackendLinkSmoke` (replay, expiry), `TestBackendLink` (show route keys) (U) | verified | +| T-19.7-22 | The self-service path writes a link only inside `ConfirmLinkRequest`: a one-shot hashed code bound to the admin, row lock, `jwt.auth` principal equal to the session token's subject, and the token's `auth_time` (the sign-in, which a refresh copies and never renews) not before the request's `created_at` (in seconds); a token without `auth_time` is `stale_session`; no email match and no user list | U `classes/backend_links.go:250` (`ConfirmLinkRequest`, `sessionAuthTime`); U `controllers/backend_link_controller.go:102` (`bouncer.VerifyAuthTime`); F `bouncer/jwt.go:214`, `bouncer/refresh.go:92` | `TestBackendLinkAuthTime` (U, revert-checked), `TestAuthTime` (F), `TestBackendLinkAuthTimeSmoke`, `TestBackendLink` (U) | corrected: fixed in c14238b (F), 32f9259 (U); was verified (see Corrections) | +| T-19.7-23 | 64-hex code stored as SHA-256, `used_at` set in the linking transaction, expired, used and unknown codes all 404; the show route answers only the admin's login and name and the expiry. | U `classes/backend_links.go:105`, `:129`; U `controllers/backend_link_controller.go:47` | `TestBackendLinkSmoke` (replay, expiry), `TestBackendLink` (show route keys) (U) | corrected: fixed in 32f9259 (U), 0125e29 (V); was verified (see Corrections) | | T-19.7-24 | Admin-set links need `golem15.users.manage_backend_links` (no default role), match an exact email, record `linked_by`, fire `BackendLinkedEvent` and both subject events after commit; cabana's outranking check runs first; 1:1 refuses a second link. | U `backend_links_admin.go:33`, `:50`, `:99`; U `classes/backend_links.go:269-325` | `TestBackendLinkAdminSmoke`, `TestBackendLink` ("SetLink and RemoveLink events", none after a rollback) (U) | verified | | T-19.7-25 | Confirm is a POST behind `jwt.auth` with the bearer re-read for `iat`; My account link and unlink run behind the backend guard, `interactiveOnly` and `requireAjax`; no state change on GET. | U `controllers/backend_link_controller.go:60-74`; F `cabana/http.go:319` | `TestBackendLinkSmoke` (anonymous confirm 401), `TestAccountContributions` (personal token refused) (U, F) | verified | | T-19.7-26 | `LinkedBackendUser` is one indexed query per call (no plugin cache) excluding deleted or deactivated admins and deleted users; user deletion and deactivation remove the link in their transaction and fire events after commit. | U `classes/backend_links.go:382-412`, `:455-490` | `TestBackendLink` ("deactivated or deleted administrator", "deactivating the frontend user"), `TestBackendLinkSmoke` (delete) (U) | verified | @@ -66,11 +67,11 @@ A third fix found by the tests is functional, not a security gap: c29d228 `fix(p | T-19.7-28 | Personal tokens resolve only for the plugin's prefix, by hash, `IsUsableAt` (revoked and expired refused) and a loadable user; the subject carries the token's own scopes; the dispatch credential is in memory (ID 0); personal tokens at `/mcp` need `mcp.allow_personal_tokens` (off by default and in the demo). | U `classes/mcp_guard.go:143-172`; F `pier/identity.go:152`; D `config/mcp.yaml:19` | `TestUserMCPGuard` (expired token, deleted user, ID 0 credential), `TestMCPEndToEnd` "personal tokens are off" (U, D) | verified | | T-19.7-29 | `linked_by` on admin-set links, `created_at` on links and requests, link and unlink events, cabana's record action audit. | U `classes/backend_links.go:293-322` | `TestBackendLinkAdminSmoke`, `TestBackendLink` (`linked_by`) (U) | verified | | T-19.7-30 | The verifier gate: `oas_at_` prefix, hash lookup, not revoked, not expired, client of kind `mcp`, active and not revoked, user loadable. | O `classes/mcp_verifier.go:55-110` | `TestMCPTokenVerifierSmoke`, `TestMCPTokenVerifier` (deactivated client, user deactivated after minting, conditional stamp) (O) | verified | -| T-19.7-31 | The consent JSON shows the client name (capped), the redirect host and the linked admin only when `backend` was requested and a link exists; `backend` is granted only with a link at consent. | O `controllers/mcp_consent_api.go:54-92`, `:134-142` | `TestMCPConsentSmoke` ("no link", "linked") (O) | verified | +| T-19.7-31 | The consent JSON shows the client name (capped), the redirect host and the linked admin only when `backend` was requested and a link exists; `backend` is granted only with a link at consent. | O `controllers/mcp_consent_api.go:54-92`, `:134-142` | `TestMCPConsentSmoke` ("no link", "linked") (O) | corrected: fixed in 671513d (O), 970719c (U); was verified (see Corrections) | | T-19.7-32 | `PendingRequest`, `IssueCode` and `DenyPending` resolve the request for the acting JWT principal only; a foreign or replayed request is 404; answers are `no-store, private`. | F `wristband/consent.go:47`, `:74`, `:106`, `:130`; O `controllers/consent_api.go:123-133` | `TestMCPConsentSmoke` (foreign request), `TestMCPConsent` (replayed decision, no-store) (O) | verified | | T-19.7-33 | granted = submitted ∩ requested, minus `backend` without a link; an empty grant is 422 `no_grantable_scopes`; a non-JSON body is 400. | O `controllers/mcp_consent_api.go:105-165` | `TestMCPConsentSmoke`, `TestMCPConsent` (extra scopes dropped, `scopes: []`, form body) (O) | verified | | T-19.7-34 | The backend principal is the admin's real principal, superuser included (D-22). Reach is bounded by: the explicit `backend` scope on the consent page with the admin's name, the fresh-sign-in link proof, the read/write ceiling, the tools plugins declare (no generic admin API), the per-call audit line with admin_id and client_id, and per-request re-checks. | F `pier/backend_guard.go:59-100`; F `pier/audit.go:40` | `TestToolsList` (ceiling, union), `TestScopeLimitForMethods` (an admin token's scope per method), `TestMCPEndToEnd` (read grant hides write tools) (F, D) | accepted (see Superuser reach) | -| T-19.7-35 | Revocation from the authorized-apps list and the user cascade fire `revoked`/`deleted` events that end sessions everywhere; the verifier refuses a revoked token on the next request; an ended session is no longer written back. MCP grants have no consent row, so the authorized-apps list cannot revoke them yet (a revoked client or deleted user still ends every grant). | O `controllers/authorizations.go:101-122`; O `classes/cascade.go:68`; F `pier/events.go:42`; F `pier/session.go:320` | `TestMCPConsentSmoke` ("revocation fires revoked", "cascade fires deleted"), `TestMCPEndToEnd` ("revoked token", "revoked grant ends the session"), `TestFanOut` (O, D, F) | fixed in commit 1111828; the missing self-service revoke is accepted (see Accepted risks) | +| T-19.7-35 | Revocation from the authorized-apps list and the user cascade fire `revoked`/`deleted` events that end sessions everywhere; the verifier refuses a revoked token on the next request; an ended session is no longer written back. MCP grants have no consent row, so the authorized-apps list cannot revoke them yet (a revoked client or deleted user still ends every grant). | O `controllers/authorizations.go:101-122`; O `classes/cascade.go:68`; F `pier/events.go:42`; F `pier/session.go:320` | `TestMCPConsentSmoke` ("revocation fires revoked", "cascade fires deleted"), `TestMCPEndToEnd` ("revoked token", "revoked grant ends the session"), `TestFanOut` (O, D, F) | corrected: fixed in 970719c (U), 671513d (O); was fixed in commit 1111828 (see Corrections); the missing self-service revoke stays accepted | | T-19.7-36 | Journal's update input fields are pointers or `omitempty`; pier forwards the agent's raw JSON minus path keys, so omitted fields stay omitted. | J `mcp.go:52` (`mcpUpdatePostInput`); F `pier/dispatch.go:91-123` (path and query keys removed from the forwarded body) | `TestDispatch` ("argument split": omitted `note` absent), `TestJournalMCP` (input keys) (F, J) | verified | | T-19.7-37 | pier's inline throttle, idle TTL and per-binding cap; nginx opens exactly `= /mcp` (unbuffered, 1 h read timeout), the two MCP metadata paths and `/oauth/mcp/`; `check-deploy.sh` refuses anything else. | D `deploy/nginx/demo.summercms.io.conf:213-224`; D `scripts/check-deploy.sh` | gate stage `consumers` runs `check-deploy.sh`; `TestPierMount` (tagged throttle) (D, F) | fixed in commit 0b8e027 (gate pin; config unchanged) | | T-19.7-38 | The consent page navigates only to the `redirect_uri` the server answers, which wristband builds from the client's registered URI; the login hop keeps a same-origin `next`. | V `app/pages/connect.vue:48`, `:57`, `:70-75`; F `wristband/consent.go:74-128` | `TestMCPConsentSmoke` ("deny": the redirect carries the registered URI's state) (O) | verified | @@ -80,12 +81,12 @@ A third fix found by the tests is functional, not a security gap: c29d228 `fix(p ## Cross-cutting findings -1. **Link proof.** The only writers of `golem15_user_backend_links` are `insertLink` (called by `ConfirmLinkRequest` and `SetLink`) and the deletes in `RemoveLink` (U `classes/backend_links.go:293`, `:325`); `SetLink` has one non-test caller, the permission-guarded Administrators record action (U `backend_links_admin.go:99`, behind `golem15.users.manage_backend_links` and cabana's outranking check). The confirm compares the token's `iat` with the request's `created_at` in whole seconds (U `classes/backend_links.go:239`), and the route requires the token's subject to equal the `jwt.auth` principal. +1. **Link proof** (corrected in gap closure, C-CR-02). The only writers of `golem15_user_backend_links` are `insertLink` (called by `ConfirmLinkRequest` and `SetLink`) and the deletes in `RemoveLink`; `SetLink` has one non-test caller, the permission-guarded Administrators record action (U `backend_links_admin.go:99`, behind `golem15.users.manage_backend_links` and cabana's outranking check). The plan 05 review accepted a confirm that compared the token's `iat` with the request's `created_at`; a `POST /refresh` renews `iat`, so a browser signed in long before the request passed after one refresh. Since c14238b (F) every sign-in token carries `auth_time`, which refresh copies unchanged, and since 32f9259 (U) the confirm reads it with `bouncer.VerifyAuthTime` (U `controllers/backend_link_controller.go:102`) and compares it in whole seconds with `created_at` (U `classes/backend_links.go:250`); a token without `auth_time` is `stale_session`, and the token's subject must equal the `jwt.auth` principal. Pinned by `TestBackendLinkAuthTime` (refresh after the request stays stale; revert-checked) and `TestAuthTime`. 2. **Dispatch marker.** The only non-test caller of `bouncer.WithDispatch` is F `pier/dispatch.go:159` (gate stage `marker`). Nothing reads a header, cookie or query string for it (`TestDispatchMarker` sends all three). `ResolveBackend` and, since c49983d, the `backend` middleware refuse a frontend marker. The inner request carries no Authorization or Cookie (`TestDispatch`). 3. **Session binding.** The binding is compared on every stateful POST, GET and DELETE (F `pier/session.go:211`, `:242`); DELETE and revocation remove the record through the store and close every instance's copy (`TestSessions`, `TestFanOut`); rehydration takes identity from the current bearer (the stored record holds only binding, protocol state and creation time). Since 1111828 an ended session cannot be written back by a late request. 4. **Scope ceilings.** `read` hides tools whose annotations are not read-only (F `pier/filter.go:37`, `TestToolsList`, `TestMCPEndToEnd` "read grant"); `backend` without a link yields no backend subject (F `pier/identity.go:187`, `TestToolsList`); consent drops `backend` without a link (O `controllers/mcp_consent_api.go:134`, `TestMCPConsentSmoke`); personal tokens carry their own scopes and are off by default (F `pier/config.go`, `TestPierTracerSmoke`, `TestMCPEndToEnd`); an admin token's scope is filtered per dispatched method (`TestScopeLimitForMethods`, `TestGuardRules`). 5. **Superuser reach (P-23, D-22).** See the section below. -6. **Logs.** pier logs ids, names, statuses, durations, an 8-character session prefix and the binding key (`oauth::`, which holds no secret); store errors carry cooler's fixed texts, not the Redis URL (F `cooler/redis.go:28-32`). The verifier's only log line names the token id (O `classes/mcp_verifier.go:119`). The link code is never logged. No token, argument or body appears in any line (`TestPierTracerSmoke` "audit line"). +6. **Logs** (corrected in gap closure, C-WR-01). pier logs ids, names, statuses, durations, an 8-character session prefix and the binding key (`oauth::`, which holds no secret); store errors carry cooler's fixed texts, not the Redis URL (F `cooler/redis.go:28-32`). The verifier's only log line names the token id (O `classes/mcp_verifier.go`). The plan 05 review said the link code stays out of every log; that was false: My account answered `/link-admin?code=`, so the code reached the site's nginx and request logs, the browser history and the `GET /backend-link/{code}` access line. Since 32f9259 (U) and 0125e29 (V) the code travels only in the URL fragment (`#code=`), which a browser never sends, and in the body of `POST /backend-link/show` (`Cache-Control: no-store, private`); the page keeps it in sessionStorage across the sign-in and removes the fragment (U `account_contributions.go:120`, `controllers/backend_link_controller.go:45`; V `app/lib/linkCode.ts:20`, `app/pages/link-admin.vue:55`). The GET show route stays for compatibility and is documented as deprecated. No token, argument or body appears in any line (`TestPierTracerSmoke` "audit line"); the link transport is pinned by `TestBackendLinkShowPost` and `app/lib/linkCode.test.ts`. 7. **`no-store`.** Every consent answer (show, decide, refusals) carries `Cache-Control: no-store, private` (`TestMCPConsent`). The link show and confirm answers carry `no-cache, private` (U `controllers/api_controller.go:1266`) and hold neither the code nor a token. The one answer that carries a link code, My account's "link" action, is a POST response of the admin API, which shared caches do not store without explicit freshness; accepted at L1. ## Superuser reach @@ -121,3 +122,70 @@ Revisit when a plugin declares a superuser-only or destructive backend tool beyo ## Suites run for this review `bash scripts/check-phase19.7.sh --all` on a Docker host: self-test, go (framework `go vet` untagged and tagged, `go test -short ./...`, examples/hello, the tagged build, the required names untagged and tagged), plugins (vet, short tests and additive checks of the user, OAuth server and blog plugins), consumers (fonoteka.go, sm-bm-app, sm-demo-app untagged and tagged, the pin check, the deploy check), admin (typecheck, vitest, OpenAPI, dist), docs, marker, docker (cabana, user plugin, OAuth server and demo Postgres suites) all PASS. + +## Gap closure (plans 06-09, 2026-10-23) + +The deep code review after plan 05 (`19.7-REVIEW.md`, three lanes: A pier, B framework auth, cabana and the admin SPA, C the plugins and the demo) found 3 critical, 17 warning and 20 info findings. Plans 06-08 fixed every critical and warning finding and the info findings that were cheap; plan 09 added a named test for every fix, extended the gate and wrote this section. Two escalations from an application team rode along: E-1 (an organisation logo) and E-2 (plugin-declared scopes for member `usr_` tokens, decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`). + +One production defect surfaced while plan 09 wrote its tests and was fixed in the user plugin: deleting an organisation left its avatar and logo attachment rows and blobs behind. 25b9f2e fixed it before the test commit; 38551a4 moved the cleanup to a new `FormAfterDelete` hook so that `FormBeforeDelete` keeps its declaration and the plugin stays additive. Pinned by `TestOrganisationLogo`. + +### Corrections + +Earlier verdicts of this review that the code review disproved. The register rows above carry the new status. + +| Threat | Earlier verdict | Finding | What was wrong | Fix commit(s) | Pinning test | +|--------|-----------------|---------|----------------|---------------|--------------| +| T-19.7-22 | verified | C-CR-02 | The link proof compared the session token's `iat` with the request's `created_at`. A `POST /refresh` renews `iat`, so a browser signed in long before the request (or a stolen refresh-capable token) passed the "fresh sign-in" check after one refresh. The test that "verified" it never refreshed a token. | c14238b (F: `auth_time` at sign-in, copied by refresh, `bouncer.VerifyAuthTime`), 32f9259 (U: the confirm reads `auth_time`) | `TestBackendLinkAuthTime` (U, revert-checked), `TestAuthTime` (F) | +| cross-cutting 1 | link proof sound | C-CR-02 | Same defect, stated as a property of every link: the proof rested on `iat`. Rewritten in place above. | c14238b, 32f9259 | `TestBackendLinkAuthTime` | +| cross-cutting 6 | no log line carries the link code | C-WR-01 | The review stated that the link code stays out of logs. My account handed the code over in the query string of the site URL, so it reached nginx and site request logs, the browser history and the GET show route's access line. Rewritten in place above. | 32f9259 (U: `#code=` fragment, `POST /backend-link/show`, `no-store`), 0125e29 (V: the page reads the fragment and keeps the code in sessionStorage) | `TestBackendLinkShowPost` (U), `app/lib/linkCode.test.ts` (V) | +| T-19.7-23 | verified | C-WR-01 | The one-shot code was treated as secret while its transport leaked it (see cross-cutting 6); whoever read a log line could link their own signed-in account in the 10-minute window. | 32f9259, 0125e29 | `TestBackendLinkShowPost`, `TestBackendLinkAuthTimeSmoke` (U) | +| T-19.7-03 | verified | A-WR-01, A-WR-02 | `Router.Handler` hands pier the compiled route chain, but the mux's path cleaning is not part of that chain: dispatched path values reached routes with `..`, `.` and empty segments, and an optional path field left `{key}` literal with an empty path value. | 104db99 (F) | `TestDispatchHardening`, `TestCatalogPathFields` (F) | +| T-19.7-17 | fixed in 0b8e027 (test pin; code unchanged) | A-WR-01, A-WR-02 | The same gap seen from argument injection: the schema check and the `/` refusal left traversal segments and missing path values through. | 104db99 | `TestDispatchHardening`, `TestCatalogPathFields` | +| T-19.7-18 | fixed in 0b8e027 (test pin; code unchanged) | A-CR-01, A-WR-03 | Stateful sessions whose record expired were never closed on the instance holding them (an unbounded server and goroutine leak; the idle TTL only removed the record), and `max_per_binding` was check-then-act, so concurrent initializes exceeded it. | 2bf9dca (reaper), c1c0c77 (atomic reservation) (F) | `TestSessionReaper` (revert-checked), `TestSessionReserve` (F) | +| T-19.7-08 | verified | B-WR-03 | Contributed text rendered as text, but an action's URL went into `href` and `window.open` unchecked: a `javascript:` URL would execute on the admin origin. | 44835e4 (cabana keeps http(s) and same-origin paths only), 88559d5 (SPA `safeUrl` on both sinks) (F) | `TestAccountContributionURL` (F), `admin/tests/account/AccountContributions.test.ts` | +| T-19.7-31 | verified | C-WR-02 | Consent showed the linked administrator, but the `backend` grant was bound to the user, not to that administrator: unlinking and linking to another administrator silently re-targeted a live grant. | 671513d (O: backend-scoped MCP tokens revoked on every link change, client-specific events), 970719c (U: the link events the cascade listens to) | `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (b), `TestMCPEndToEnd` "a link change ends the backend grant" (D) | +| T-19.7-35 | fixed in 1111828 | C-CR-01 | Revocation was pinned for client revocation, deletion and deactivation only. A ban or a password reset left every MCP grant (and the linked administrator's reach) alive: the verifier and the refresh grant did not look at bans or `tokens_valid_after`. | 970719c (U: `AccessRevokedEvent` from ban, reset, change and admin-set password; banned users lose the link), 671513d (O: verifier and refresh refuse; MCP and OIDC grants revoked) | `TestMCPVerifierRevocation` (O, revert-checked), `TestMCPGrantRevocation` (O), `TestAccessRevocation` (U), `TestMCPRevocationEndToEnd` (a) (D) | + +### Revert-checks + +P-41: each critical finding's fix was reverted in a scratch worktree under the session scratch directory (`git worktree add --detach … HEAD`, then `git revert --no-commit `), the test files kept at HEAD, and the single pinning test run; the worktree was then removed (`git worktree remove --force`, `git worktree prune`). Nothing was committed from a worktree. At HEAD all three pass (gate run below). + +| Repository | Fix | Test | With the fix reverted | At HEAD | +|------------|-----|------|-----------------------|---------| +| F `summercms.go` | 2bf9dca (A-CR-01, the session reaper) | `TestSessionReaper` | FAIL `reaper_test.go:79: reaped = 0, want 1` (memory store); the Redis case keeps B's copy past the expiry, the GET stream outlives its record, the store-error and `ephemeral` lines are missing. Worktree at ccf77b9; the revert's conflicts in `pier.go` and `export_test.go` were resolved keeping the later commits (the Redis warm-up of 2404572, the later test seams) and the `ReapNow` seam answers 0 because `reapOnce` is gone. | PASS | +| O `sm-oauthserver-plugin` | 671513d (C-CR-01, verifier and grant revocation) | `TestMCPVerifierRevocation` | FAIL `mcp_revocation_test.go:98: created two seconds before the cutoff: verify = , want pact.ErrMCPTokenInvalid`; all five subtests fail (same-second cutoff, untruncated `Before` "0 revoked, 2 live", banned user accepted, a deleted user's refresh answered 200). Worktree at 65f71dc with a scratch go.work (framework and user plugin by absolute path). | PASS | +| U `sm-user-plugin` | 32f9259 (C-CR-02, the confirm reads `auth_time`): the confirm handler only, restored to its pre-fix `iat` body | `TestBackendLinkAuthTime` | FAIL `backend_link_auth_time_test.go:64: confirm = 200 …, want 409 with "stale_session"` (login, request, refresh, confirm) and `:86` (a token without `auth_time` links). The framework's `auth_time` cannot be reverted from the plugin worktree, so only the plugin side was reverted. Worktree at 38551a4, `go mod edit -replace` to the framework's absolute path, `GOWORK=off`. | PASS | + +### New threats + +| Threat | Mitigation as built | Implementation | Test (repo) | Status | +|--------|---------------------|----------------|-------------|--------| +| T-19.7-42 | A reaper started at Mount closes every local session copy whose store record is gone, every min(idle_ttl/2, 1m); a store error keeps the copy; the GET stream re-check ends a stream whose record is gone; `expired` and `ephemeral` audit labels. | F `pier/session.go:501` (`reapOnce`), F `pier/pier.go:145` | `TestSessionReaper` (revert-checked), `TestPierReaperSmoke` (F) | fixed in 2bf9dca | +| T-19.7-43 | The per-binding slot is reserved atomically before the server is built (memory store under its lock, Redis in one Lua script) and released when connect or initialize fails; a store without the reservation falls back to Count then Put. | F `pier/session.go:145`, F `pier/store_memory.go:102`, F `pier/store_redis.go:165` | `TestSessionReserve` (50 concurrent initializes on memory and on miniredis across two instances) (F) | fixed in c1c0c77 | +| T-19.7-44 | Path values the mux never delivers (`..`, `.`, empty segments, `/` in one segment) and missing path values are refused before dispatch; a path field must be required at boot; an embedded struct on a GET or DELETE tool fails boot. | F `pier/dispatch.go:126`, `:146`; F `pier/catalog.go:223`, `:251` | `TestDispatchHardening`, `TestCatalogPathFields` (F) | fixed in 104db99 | +| T-19.7-45 | A provider or database error answers the fixed text `pier: the tool could not be run for this caller`; the error is logged server-side only. | F `pier/dispatch.go:30`, `:187` | `TestDispatchHardening` "a provider error answers the fixed text and is only logged" (F) | fixed in 104db99 | +| T-19.7-46 | A personal-token subject without a TokenID is refused (401), so no two users share `pat:user:0`. | F `pier/identity.go:270` | `TestPersonalTokenBinding` (F) | fixed in 2404572 | +| T-19.7-47 | The Redis store is opened and subscribed at Mount (warm-up with backoff) and kicked by every request; a `subscriptions/listen` stream re-checks its bearer; an LRU eviction closes the evicted server's streams. | F `pier/store_redis.go:279`; F `pier/stateless.go:116`, `:166` | `TestStatelessFanOut` (F) | fixed in 2404572 | +| T-19.7-48 | Servers are built and connected outside the router-wide locks and inserted with a re-check, so one slow guard check stalls no other session. | F `pier/session.go:174`; F `pier/stateless.go:137` | `TestLockScope` (call, DELETE and stateless request finish while an initialize blocks in a guard), `go test -race ./modules/pier` (F) | fixed in c1c0c77 | +| T-19.7-49 | A dispatch whose guard list or principal kind contradicts the tool's guard is refused with the fixed text and logged. | F `pier/dispatch.go:198` | `TestDispatchHardening` "a dispatch contradicting the tool's guard is refused" (F) | fixed in 104db99 | +| T-19.7-50 | `pact.MCPPrompt.Writes` puts a prompt under the read/write ceiling; Journal's `draft_post` declares it. | F `pier/filter.go:52`, F `pact/mcp.go:86`; J `mcp.go:162` | `TestPromptWriteCeiling` (F), `TestJournalPromptWrites` (J), `TestMCPRevocationEndToEnd` (c) (D) | fixed in 211cec1 (F), b129902 (J) | +| T-19.7-51 | `auth_time` (OIDC name) is stamped equal to `iat` at every sign-in mint and copied unchanged by every refresh path; a token without it refreshes without it; `bouncer.VerifyAuthTime` keeps the signature, audience and expiry checks; the link confirm reads it (P-29; the user decision that sessions issued before the upgrade sign in again before linking, 19.7-GAP-INPUTS.md). | F `bouncer/mint.go:45`, `bouncer/refresh.go:92`, `bouncer/jwt.go:214`; U `controllers/backend_link_controller.go:102` | `TestAuthTime`, `TestAuthTimeSmoke` (F), `TestBackendLinkAuthTime` (U, revert-checked) | fixed in c14238b (F), 32f9259 (U) | +| T-19.7-52 | Member token scopes are a separate capability, `pact.HasFrontendTokenScopes`, which cabana's admin catalog never reads; both catalogs share one name rule, `pact.ValidTokenScopeName` (decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`, P-30). | F `pact/capabilities.go:676`, `:687`; F `cabana/token_scopes.go:107` | `TestFrontendTokenScopes`, `TestTokenScopeNameParity` (F), `TestMemberTokenScopesBoot` (U) | fixed in 9eedfbb | +| T-19.7-53 | cabana keeps an account action URL only for http(s) with a host or a same-origin path (`//` and `/\` refused) and logs a refusal without the URL; the SPA applies `safeUrl` to `href` and `window.open`. | F `cabana/account_contributions.go:217`, `:233`; F `admin/src/components/account/AccountContributions.vue:31` | `TestAccountContributionURL` (F), `admin/tests/account/AccountContributions.test.ts` "a javascript: URL answer renders no link" | fixed in 44835e4, 88559d5 | +| T-19.7-54 | A revoked event names the token (TokenID) or the OAuth client (ClientID) and pier ends only the matching sessions; cabana's token revoke, the user plugin's token delete and the authorized-apps revoke fire the narrowed events. | F `pier/events.go:79`, F `cabana/subject_events.go:51`; U `controllers/api_tokens.go:142`; O `controllers/authorizations.go:124` | `TestTokenScopedRevoke` (across two instances), `TestAccountTokenRevokeEvent` (F), `TestAPITokenRevokeEvent` (U), `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (d) (D) | fixed in 24cf6cd (F), 970719c (U), 671513d (O) | +| T-19.7-55 | pier puts the client address on the resolve context (surf's trusted-proxy answer, else the RemoteAddr host); `ResolveBackendToken` logs it as `remote` on every `admin.auth` line. | F `towel/context.go:70`, F `pier/pier.go:254`, F `cabana/token_resolver.go:29` | `TestClientIP`, `TestResolveClientAddress`, `TestResolveBackendTokenRemote` (F) | fixed in 24cf6cd | +| T-19.7-56 | A failing Status marks only its section `error: true`, a failing Applies drops only that action, the list answers 200; the SPA shows the section error line and a toast for a failed GET, keeping the earlier sections. | F `cabana/account_contributions.go:119`; F `admin/src/components/account/AccountContributions.vue:125` | `TestAccountContributionsIsolation` (F), `admin/tests/account/AccountContributions.test.ts` (section error, 500 on reload) | fixed in 44835e4, 88559d5 | +| T-19.7-57 | A ban, a password reset, an administrator setting a password and the user's own change fire `AccessRevokedEvent` in the write's transaction (ban: newly banned users only) and a revoked subject event per user after the commit; the OAuth server revokes MCP and OIDC tokens created before the untruncated `Before`, deletes consents and unused codes; the verifier and the MCP refresh grant refuse banned users and tokens created before `tokens_valid_after + 1s`; `LinkedBackendUser` excludes banned users (user decision 2026-10-23: both MCP and OIDC grants end). | O `classes/mcp_verifier.go:96`, `:99`, `:139`; O `classes/mcp_store.go:281`, `:308`; O `classes/cascade.go:95`, `:161`; U `classes/access_revoked.go:34`, `classes/backend_links.go:423`; U `controllers/users_admin_controller.go:253`, `:651`; U `controllers/api_controller.go:463`, `:732` | `TestMCPVerifierRevocation` (O, revert-checked), `TestMCPGrantRevocation`, `TestMCPRevocationSmoke` (O), `TestAccessRevocation` (U), `TestMCPRevocationEndToEnd` (a) (D) | fixed in 970719c (U), 671513d (O) | +| T-19.7-58 | The link code travels in the `#code=` fragment and the body of `POST /backend-link/show` (`no-store, private`), is kept in sessionStorage across the sign-in and removed from the URL; the page shows the signed-in account before the button; the GET show route stays for compatibility, documented as deprecated. | U `controllers/backend_link_controller.go:45`, `:75`; U `account_contributions.go:120`; V `app/lib/linkCode.ts:20`; V `app/pages/link-admin.vue:55` | `TestBackendLinkShowPost`, `TestBackendLinkAuthTimeSmoke` (U), `app/lib/linkCode.test.ts` (V) | fixed in 32f9259 (U), 0125e29 (V) | +| T-19.7-59 | Every link and unlink revokes the user's `backend`-scoped MCP tokens (only those) and fires a revoked event per affected client after the commit, so a grant never acts as an administrator the user did not consent to. | O `classes/cascade.go:175`, `:184`, `:198` | `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (b), `TestMCPEndToEnd` "a link change ends the backend grant" (D) | fixed in 671513d | +| T-19.7-60 | The member token scope catalog is compiled at boot from the activated plugins (pattern, built-ins reserved, duplicates name both plugins, labels and phrase keys checked); `POST /tokens` and `MintAPITokenFor` check the application's own catalog, so another application's declaration answers 422; `middleware.Scope` checks declared scopes like built-ins; the legacy `MintAPIToken` uses a documented process-wide fallback (decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`; E-2's acceptance list, 19.7-GAP-INPUTS.md). | U `classes/api_token_scopes.go:87`, `classes/api_tokens.go:66`, `plugin.go:126`, `controllers/api_tokens.go:57` | `TestMemberTokenScopes` (E-2's acceptance list item by item), `TestMemberTokenScopesBoot`, `TestMemberTokenScopesSmoke` (U) | fixed in ee5c096 | +| T-19.7-61 | The organisation logo is an image-mode fileupload through lagoon/attach (the avatar's validation), behind `golem15.users.access_users`; deleting an organisation removes its avatar and logo attachments. | U `models/organisation.go:44`, `models/organisation/fields.yaml:25`, `controllers/organisations_admin_controller.go:130` | `TestOrganisationLogo` (U) | fixed in f20a8d3, 25b9f2e, 38551a4 | +| T-19.7-62 | nginx's `location = /mcp` allows 4 MiB bodies, pier's own cap, enforced by the deploy check. | D `deploy/nginx/demo.summercms.io.conf:217`, D `scripts/check-deploy.sh:254` | gate stage `consumers` (`check-deploy.sh`) (D) | fixed in 3b569a1 | +| T-19.7-63 | `MCPResolvePersonalToken` stamps `last_used_ip` from `towel.ClientIP` and leaves it unchanged without an address. | U `classes/mcp_guard.go:174` | `TestUserMCPGuardClientIP` (U) | fixed in 970719c | +| T-19.7-64 | The gate's workspace check refuses a repository resolving the framework (or, for the OAuth server plugin, the user plugin) anywhere but the expected checkout; the unreleased user plugin is built with fonoteka.go and sm-bm-app through scratch copies of their go.work; a demo submodule ahead of its pin is accepted only by test-only commits or by commits its canonical checkout holds, and reported; `PHASE197_RELEASED=1` refuses any drift. | F `scripts/check-phase19.7.sh:432` (`workspace_verdict`), `:446` (`workspace_check`), `:515` (`consumer_with_user_plugin`), `:946` (`demo_pins`), `:957` (released mode) | `--self-test` (match, symlinked match, mismatch naming both paths, empty answer; the planted go.work rewrite; test-only, unreleased accepted, unreleased refused and both released-mode refusals), `PHASE197_RELEASED=1 --consumers` exits 1 while the pins drift (F) | fixed in 0aa48ae | +| T-19.7-65 | A high or critical control is verified only with a test that fails without it (Method step 6); the three critical findings are revert-checked; every disproved verdict is listed under Corrections and its register row carries the corrected status. | this section | the Revert-checks table; `bash scripts/check-phase19.7.sh --all` (F) | verified | +| T-19.7-SC (gap closure) | No Go module or npm package was added in plans 06-09: go.mod, go.sum and the npm locks are unchanged in every repository (framework, the three plugins, the demo, vue-demo-app); vitest runs use the committed locks. | `git diff` of go.mod, go.sum, admin/package-lock.json and pnpm-lock.yaml over plans 06-09 | gate stage `admin` (`npm --prefix admin` with the committed lock), plan 09 check (F, U, O, J, D, V) | verified | + +### Suites run for the gap closure + +`bash scripts/check-phase19.7.sh --all` on a Docker host after the plan 09 commits: self-test, go, plugins, consumers, admin, docs, marker and docker all PASS, "Phase 19.7 gate passed" (the run is recorded in 19.7-09-SUMMARY.md). The consumers stage reports `plugins/golem15/journal`, `oauthserver` and `user` ahead of their pins by unreleased commits, which is expected until the release (deferred-items.md).