diff --git a/.planning/STATE.md b/.planning/STATE.md
index 74b46b74..1d7e41c8 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -5,10 +5,10 @@ current_phase: "19.7"
current_phase_name: Generic MCP module (INSERTED)
status: executing
stopped_at: Completed 19.7-01-PLAN.md
-last_updated: "2026-10-22T14:44:55.276Z"
+last_updated: "2026-10-22T19:32:24.420Z"
last_activity: 2026-10-22
last_activity_desc: Phase 19.7 execution started
-state_head: fab99e0a00bf4277ab4e659998267970b4a9aeb6
+state_head: cb33c497be31f678b474e71c8c4e3fbed3baa44e
progress:
total_phases: 45
completed_phases: 37
@@ -945,6 +945,7 @@ Recent decisions affecting current work:
- [Phase 19.6.1]: Administrators and Roles are cabana screens behind backend.manage_users / backend.manage_roles with no default role grant; privilege rules run in the save tx; last-superuser uses pg_advisory_xact_lock; contributed Administrators Runs are wrapped with guardOutranked.
- [Phase 19.7]: 19.7-01: planner decisions P-01..P-04 implemented: bouncer.Dispatch context-only marker honoured by Registry.Middleware and cabana.ResolveBackend; account contributions as pact.HasAccountContributions; prompted record actions post payload as a JSON string; pact MCP types carry the principal as any and stay SDK-free
- [Phase 19.7]: 19.7-01: wristband authorize allow-list is exactly Options.ScopesSupported (empty keeps the PHP default); surf stream routes recover with an opaque 500 only before the first byte; account contribution Run runs without a transaction only when no database is published
+- [Phase 19.7]: Framework release is v0.5.0 (19.7-02 Task 5), carrying 19.6.1, 19.7 plans 01-02 and a set of 7 msd-quick patches; tag only after all 7 are committed — User decision 2026-10-22: minor bump for the new pier module and pact capabilities; hold the tag until the quick patches land
### Pending Todos
@@ -1043,6 +1044,7 @@ Recent decisions affecting current work:
| 261020-sdt | Rotate the site-cache purge token and rename the layer variable (demo + summercms.io) | 2026-10-20 | 4270d05 | [261020-sdt-rotate-the-purge-token-and-rename-the-la](./quick/261020-sdt-rotate-the-purge-token-and-rename-the-la/) |
| 261022-4z8 | Fix the open 19.8 re-review findings (WR-09..WR-14, IN-14, IN-15) | 2026-10-22 | b1fba7e | [261022-4z8-fix-the-open-19-8-re-review-findings-wr-](./quick/261022-4z8-fix-the-open-19-8-re-review-findings-wr-/) |
| 261022-k9k | Admin keyboard shortcuts: Ctrl+S, Ctrl+Enter, Ctrl+Shift+S (Create and new), / search, Esc, ? help | 2026-10-22 | 604fee7 | [261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl](./quick/261022-k9k-keyboard-shortcuts-in-the-admin-spa-ctrl/) |
+| 261022-tb4 | Password /login refuses a not-activated account while require_activation is on (sm-user-plugin 15ff979, unreleased: hold until the escalation batch lands) | 2026-10-22 | 15ff979 (sm-user-plugin) | [261022-tb4-password-login-must-refuse-is-activated-](./quick/261022-tb4-password-login-must-refuse-is-activated-/) |
## Deferred Items
diff --git a/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-PLAN.md b/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-PLAN.md
new file mode 100644
index 00000000..6e00cda3
--- /dev/null
+++ b/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-PLAN.md
@@ -0,0 +1,277 @@
+---
+phase: quick-261022-tb4
+plan: 01
+quick_id: 261022-tb4
+slug: password-login-must-refuse-is-activated-
+type: execute
+mode: quick
+wave: 1
+depends_on: []
+autonomous: false
+requirements:
+ - tb4-1 # G2: password POST /login refuses is_activated=false while golem15.user.activation.require_activation is true (default), with the opaque invalid-login answer; deleted_at restore-on-login unchanged
+files_modified:
+ # sm-user-plugin (module golem15.user), worked in the sm-demo-app submodule checkout
+ - ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
+ - ../sm-demo-app/plugins/golem15/user/session_test.go
+ - ../sm-demo-app/plugins/golem15/user/account_test.go
+ - ../sm-demo-app/plugins/golem15/user/email_verified_test.go
+ - ../sm-demo-app/plugins/golem15/user/admin_users_test.go
+ - ../sm-demo-app/plugins/golem15/user/README.md
+ # only after the user's go-ahead at Task 3
+ - ../sm-demo-app/plugins/golem15/user (submodule pointer)
+ - ../sm-demo-app/go.mod
+ - ../sm-demo-app/go.sum
+ - .planning/notes/2026-10-22-external-user-plugin-escalations.md
+ - .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md
+ - .planning/todos/done/2026-10-22-user-login-ignores-is-activated.md
+
+estimate:
+ tokens: 110000
+ raw_tokens: 110000
+ tasks: 3
+ confidence: low
+
+must_haves:
+ truths:
+ - "With golem15.user.activation.require_activation unset (default true) or true, POST /_user/api/v1/login with the correct password of an account whose is_activated is false answers 401 with the same status, body bytes and Content-Type/Cache-Control headers as a wrong password for that account ({\"error\":true,\"message\":\"Invalid email or password\"} by default, the invalid_login translation otherwise), carries no token and stamps no last_seen."
+ - "That refusal counts against the login throttle exactly like a wrong password: it adds one attempt to the user's user_throttle row, and attempt_limit such refusals suspend the account."
+ - "With require_activation false, a not-activated account signs in with its password (200, token, user payload) exactly as before this change."
+ - "An activated account signs in unchanged, and a deactivated (deleted_at set) activated account is still restored by its owner's password login, gets the reactivate mail and a working token."
+ - "An account that is both deactivated and not activated is refused under require_activation true before the restore runs: deleted_at stays set and no reactivate mail is sent. With require_activation false it is restored and signed in as before."
+ - "Login checks in this order: ban or suspension (RejectIfThrottled), password (bcrypt), activation, deleted_at restore, success bookkeeping, token. The Login doc comment and the plugin README state it, and the README says that activation gates login and deactivation does not."
+ - "The plugin's go vet and full Docker-backed go test are green at the single fix commit on sm-user-plugin master; nothing is pushed, tagged or pointer-bumped before the Task 3 decision."
+ artifacts:
+ - path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
+ provides: "Login activation gate and refuseLogin, the one refusal path shared by the wrong-password and not-activated branches"
+ contains: "requireActivation && !user.IsActivated"
+ - path: ../sm-demo-app/plugins/golem15/user/session_test.go
+ provides: "TestLoginRequiresActivation; insertUser creates an activated account; insertInactiveUser and inactiveSessionToken helpers"
+ contains: "func TestLoginRequiresActivation"
+ - path: ../sm-demo-app/plugins/golem15/user/README.md
+ provides: "activation gates login, deactivation does not; require_activation row; Testing entry"
+ contains: "TestLoginRequiresActivation"
+ key_links:
+ - from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Login)
+ to: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (registrationSettings, :810-831)
+ via: "Login reads the flag through the same parser /register uses, default true"
+ pattern: "requireActivation := registrationSettings\\(app\\)"
+ - from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Login refusal branches)
+ to: ../sm-demo-app/plugins/golem15/user/classes/throttle.go (CheckAndRecordLogin, :107)
+ via: "refuseLogin records a failed attempt (ok=false) then writes writeInvalidLogin; used by both the wrong-password and the not-activated branch"
+ pattern: "refuseLogin\\("
+ - from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (activation gate)
+ to: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (deleted_at restore in Login)
+ via: "the gate sits after the password check and before the user.DeletedAt.Valid restore block, so a refused account is never restored"
+ pattern: "requireActivation && !user.IsActivated"
+---
+
+
+Close escalation G2: the user plugin's password login hands a token to an account that is not activated, although golem15.user.activation.require_activation defaults to true and is documented as "must be activated before login". After this plan, POST /_user/api/v1/login refuses such an account with the same opaque invalid-login answer a wrong password gets, while require_activation false keeps today's behaviour and deactivation (deleted_at) stays self-service reversible.
+
+Purpose: admin-created users start not activated (users_admin_controller.go:266) and could sign in with the password the administrator typed; /register in admin mode already withholds the token, /login did not.
+Output: one fix commit on sm-user-plugin master (code, tests, README together), then a blocking human decision on tag, push and the sm-demo-app pointer bump, then the escalation note and the todo closed with the shipped version.
+
+Decisions (planner's, within the user's brief; the executor does not revisit them):
+- QD-1 Check order. Login runs: LookupByEmail (includes soft-deleted rows), RejectIfThrottled (ban, active suspension), bcrypt password check, then the activation gate, then the existing deleted_at restore, CheckAndRecordLogin(ok=true), rehash, TouchLastSeen, mint. The gate goes after the password check so a wrong password and a not-activated account both pay the bcrypt cost and cannot be told apart by timing. This matches WinterCMS: Storm's validateInternal checks the throttle and the credentials, then login() refuses a not-activated user before afterLogin(), and afterLogin() is where Golem15's User model restores a trashed account and sends the reactivate mail (Winter starter: vendor/winter/storm/src/Auth/Manager.php:403-457 and :656-683, plugins/golem15/user/models/User.php:421-433).
+- QD-2 Throttle. The activation refusal records a failed attempt, exactly like a wrong password (classes.CheckAndRecordLogin with ok=false). This deviates on purpose from Storm, which clears the attempts once the password matches and only then refuses. If a correct password reset the counter or left it alone, the throttle state, and the quick pre-bcrypt answer a suspended account gets, would show that a guess was right for a not-activated account. Counting the refusal keeps the two refusals indistinguishable and keeps guessing bounded. The cost is that a not-activated owner who retries attempt_limit times (default 5) is suspended for suspension_minutes (default 15). That account cannot sign in before activation anyway, and an administrator can unsuspend it.
+- QD-3 Deactivated and not activated. Under require_activation true such an account is refused before the restore, so it stays deactivated and gets no reactivate mail (Winter parity, see QD-1). Restore-on-login itself is unchanged: an activated account, and any account when require_activation is false, is restored exactly as today.
+- QD-4 Config read. Login reads the flag through the existing registrationSettings(app), the parser /register uses (key golem15.user.activation.require_activation, default true). No new config key and no second parser.
+- QD-5 Same answer. A new helper, refuseLogin, records the failed attempt and calls writeInvalidLogin. Both refusal branches use it, so status 401, the Cache-Control header, the JSON and the invalid_login translation key are identical by construction. Winter's own text ("Cannot login user ... as they are not activated") is not ported: the escalation asks for an opaque answer, and the Go port already gives invalid-login for bans and suspensions.
+- QD-6 Scope is POST /login only. Not changed: /refresh, the jwt guard and the API-token guard (a session minted earlier for a not-activated account keeps working until it expires, or until an administrator deactivates or bans the user, which revokes it through RevokeUserAccess); sociallogin (its checkCanSignIn already refuses every not-activated account whatever the key says); controllers.FinishLogin (its callers do the activation check, as documented); /register; the unknown-email path, which skips bcrypt (pre-existing). Record the first and the last as follow-up candidates in the SUMMARY; do not implement them here.
+- QD-7 Test fixtures. insertUser (session_test.go:382) now creates an activated account, because nearly all of its ~30 callers model an ordinary account that signs in. Tests whose subject is activation use two new helpers: insertInactiveUser, which has the previous insertUser shape, and inactiveSessionToken, which mints the session of a not-activated account with controllers.IssueToken, the way /register does when require_activation is false. The shared harness config (sessionConfig, newSessionApp, sessionApp, newAdminEnv) keeps require_activation at its default, so the suite tests the default.
+
+
+
+@~/.claude/msd-core/workflows/execute-plan.md
+@~/.claude/msd-core/templates/summary.md
+
+
+
+@CLAUDE.md
+@.planning/STATE.md
+@.planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md
+@.planning/notes/2026-10-22-external-user-plugin-escalations.md
+@../sm-demo-app/plugins/golem15/user/README.md
+
+Working copies (observed at planning time, 2026-10-22):
+- sm-user-plugin: ../sm-demo-app/plugins/golem15/user (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user). Detached HEAD at tag v0.1.8 = 930b158 = local master = origin/master, working tree clean. Latest tag v0.1.8. sm-demo-app's go.work does not list the plugin in `use` (it only replaces the module), so every go command in this checkout runs with GOWORK=off; the plugin's go.mod:176 replaces summercms with ../../../../summercms.go, so it builds against summercms.go's working tree.
+- The escalation note says the six user-plugin quicks run sequentially, never in parallel on one tree. If master or origin/master has moved past 930b158 when execution starts, another escalation quick has landed: build on top of it (fast-forward only), and record the base and git log --oneline v0.1.8..master in the SUMMARY. The Task 3 release would then carry those commits too, and the checkpoint lists them.
+- sm-demo-app (../sm-demo-app): another session has an uncommitted edit in main.go. Leave it alone. Stage only the paths named in Task 3.
+- summercms.go: quick 261022-k9k (admin SPA keyboard shortcuts) is executing concurrently. This plan touches only .planning files here (the escalation note, the todo move, this quick directory), never admin/ or modules/boardwalk. Stage by explicit path, never git add -A, and update STATE.md only through msd_run query state.* handlers.
+- Docker: docker info answered (server 29.9.1) from this user at planning time. If the socket is denied, run go test through sudo -u golem (CLAUDE.md).
+
+Source facts (file:line in the plugin checkout):
+- controllers/api_controller.go: invalidLoginBody const :44; Login :46-129: LookupByEmail :61, RejectIfThrottled block :67-76, wrong-password/unknown-email branch :77-86 (CheckAndRecordLogin ok=false only when user != nil, then writeInvalidLogin), deleted_at restore and reactivate mail :87-93, CheckAndRecordLogin ok=true :94-101, rehash :102-110, TouchLastSeen :111-114, mintFor/apiArray :115-127; registrationSettings :810-831 (require defaults to true, read from golem15.user.activation.require_activation); Register uses require at :782 (mode == "auto" || !require gets a token); writeInvalidLogin :1170-1175 (401, accountMessage "invalid_login").
+- classes/throttle.go: RejectIfThrottled :67 (ban or active suspension, no attempt recorded); CheckAndRecordLogin :107 (a nil user or ID 0 is a no-op, ok=false increments attempts and suspends at attempt_limit, ok=true resets). classes/user_lookup.go:116 LookupByEmail loads Unscoped.
+- models/user.go:21 IsActivated; updates/202609220005_extend_users.go:15 is_activated BOOLEAN NOT NULL DEFAULT FALSE; controllers/users_admin_controller.go:266 admin-created users start not activated; controllers/registration.go:107-132 FinishLogin (no activation check by contract), :185-195 IssueToken(app, user, r).
+- Tests (package user, plugin root): session_test.go TestLogin :54 (fixtures ada-login :60, throttled :119, soft :133 with the restore assertions :144-156), TestLoginSixthAttempt :160 (fixture :166), postJSON :395, parseToken, captureMail, loginToken :364-380, insertUser :382-393, sessionApp :446, sessionConfig. The sessionDB Postgres container starts even under -short, so these tests need Docker either way. account_test.go TestActivate :82-96 (loginToken "activate-auth" :84; "activate-already" then sets is_activated true), TestActivateByCode :98 (insertUser :100), bearerJSON :152. email_verified_test.go TestEmailVerifiedAt :72 with subtests activate_by_code_stamps :131 (insertUser :132), activate_stamps :147 (loginToken), activate_wrong_code_no_proof :164 (loginToken), userIDByEmail :29. admin_users_test.go TestAdminUserPassword :977: the user created through the admin API is asserted not activated at :994, and :1003-1006 asserts its password login answers 200 (this is G2 as reported); the final check at :1097-1099 asserts the reset did not activate. Admin harness (admin_harness_test.go): seedUser :468 defaults IsActivated true, userLogin :637, call, bulk :603, invalidLoginBody const in session_test.go :40.
+- Consumers checked: the demo seed inserts activated users (sm-demo-app/plugins/demo/console/seed.go:151-153); fonoteka creates users with RegisterOptions{Activate: true} (onboarding.go:119, console/dev_seed.go:362-367); no app config sets require_activation, so every app runs with the default true. sm-demo-app workspace tests that need a user session mint it with IssueToken or bouncer.Mint (demo_api_test.go:82, sociallogin harness_test.go:237, quizzes harness_test.go:195) and do not go through password login.
+- Winter reference (../examples/golem15-wintercms-starter): vendor/winter/storm/src/Auth/Manager.php:403-457 (throttle check, credentials, addLoginAttempt on failure, clearLoginAttempts on success), :656-683 (login() refuses a not-activated user before afterLogin); plugins/golem15/user/models/User.php:421-433 (afterLogin restores a trashed user and sends the reactivate mail); plugins/golem15/user/classes/AuthManager.php:25 (requireActivation from the require_activation setting).
+
+
+
+
+
+ Task 1 (tracer): /login refuses a not-activated account under require_activation, pinned end to end on Postgres
+ ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/session_test.go
+ Docker is reachable for testcontainers (directly or through sudo -u golem), and git -C ../sm-demo-app/plugins/golem15/user status --short is empty.
+
+ New TestLoginRequiresActivation in session_test.go. Each case uses its own fixed email, and each app comes from sessionApp(t, "0s"). Cases that need require_activation false get their own app with app.Config.Set("golem15.user.activation.require_activation", false), so the setting never leaks into another case. Fixtures are models.User literals with an explicit IsActivated value (bcrypt of "correct-horse"), so this test does not depend on the insertUser change in Task 2.
+ - default_refuses: require_activation unset; a not-activated account with the correct password gets 401 and a body equal to invalidLoginBody, with no token key; its users.last_seen stays NULL; its user_throttle row has attempts 1.
+ - same_as_wrong_password: for one not-activated account, compare the correct-password refusal with a wrong-password refusal. The status, the body bytes and the Content-Type and Cache-Control headers are equal, and each refusal adds one attempt.
+ - explicit_true_refuses: require_activation explicitly true gives the same 401 invalid-login answer.
+ - refusals_suspend: attempt_limit (default 5) correct-password refusals of a not-activated account leave its throttle row is_suspended true, as five wrong passwords do.
+ - off_allows: require_activation false; a not-activated account with the correct password gets 200 with a token whose sub is the user's id.
+ - activated_allows: an activated account gets 200 with a token (default config).
+ - deactivated_activated_restores: an activated account with deleted_at set gets 200 with a token; deleted_at is cleared; captureMail (published on that app) holds golem15.user::mail.reactivate-en for preferred_locale "en".
+ - deactivated_inactive_refused: require_activation unset; a not-activated account with deleted_at set gets the 401 invalid-login answer; deleted_at stays set; captureMail is empty.
+ - deactivated_inactive_off_restores: require_activation false; the same kind of account gets 200 with a token, deleted_at is cleared and the reactivate mail is sent.
+
+
+Branch setup first, because the checkout is detached. In ../sm-demo-app/plugins/golem15/user run git fetch origin, git switch master and git merge --ff-only origin/master. Never rebase or reset someone else's commits. If master is no longer 930b158 after this, follow the rule in the context section and continue. Every go command here runs with GOWORK=off.
+
+RED: write TestLoginRequiresActivation per the behavior block. Reuse postJSON, parseToken, captureMail, sessionApp and the invalidLoginBody const, read the users row (last_seen, deleted_at) and the user_throttle row with gdb, and give each case a t.Run name. Run it. The default_refuses, same_as_wrong_password, explicit_true_refuses, refusals_suspend and deactivated_inactive_refused cases must fail against today's code, which answers 200. Do not commit a red test: the project keeps go test green at every commit.
+
+GREEN, in controllers/api_controller.go:
+- Add refuseLogin(ctx, w, r, app, gdb, user, ip) next to writeInvalidLogin, implementing QD-5. It calls classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); an error that classes.IsLoginRejected does not cover answers writeOpaque500; otherwise it answers writeInvalidLogin. CheckAndRecordLogin already ignores a nil user, so an unknown email still creates no throttle row.
+- In Login, replace the body of the wrong-password/unknown-email branch (:77-86) with a call to refuseLogin and a return. Behaviour there is unchanged.
+- In Login, read the flag once with _, _, _, requireActivation := registrationSettings(app) (QD-4). Directly after the password branch and before the user.DeletedAt.Valid restore block, add the gate: when requireActivation && !user.IsActivated, call refuseLogin and return (QD-1, QD-2, QD-3).
+- Leave the RejectIfThrottled block, the restore block, CheckAndRecordLogin(ok=true), the rehash, TouchLastSeen and the token mint as they are.
+- Rewrite the Login doc comment to state the check order from QD-1. Say that with require_activation on (the default) a not-activated account gets the same 401 invalid-login answer as a wrong password and counts as a failed attempt, and that an account that is also deactivated stays deactivated. Say that require_activation off signs such an account in as before.
+
+Run TestLoginRequiresActivation until it passes, then also run TestLogin and TestLoginSixthAttempt. Those two may now fail because their fixtures are not activated; Task 2 fixes them. Do not commit in this task: the commit comes at the end of Task 2, once the whole suite is green.
+
+
+ cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^TestLoginRequiresActivation$' .
+
+ Under the default config, a not-activated account's correct password gets the byte-identical invalid-login 401 and adds one throttle attempt, and a deactivated not-activated account stays deactivated without a mail. require_activation false and activated accounts sign in as before, and the deactivated-activated restore still runs. TestLoginRequiresActivation is green; changes are uncommitted on sm-user-plugin master.
+
+
+
+ Task 2: existing suite on the new default, README, one plugin commit
+ ../sm-demo-app/plugins/golem15/user/session_test.go, ../sm-demo-app/plugins/golem15/user/account_test.go, ../sm-demo-app/plugins/golem15/user/email_verified_test.go, ../sm-demo-app/plugins/golem15/user/admin_users_test.go, ../sm-demo-app/plugins/golem15/user/README.md (plus any further *_test.go file that the full run in this task shows failing for the activation reason, and only those)
+ Task 1 is done: TestLoginRequiresActivation passes on the uncommitted sm-user-plugin master.
+
+Fixtures (QD-7). The sites below were found by reading the code at planning time:
+- session_test.go: insertUser creates the account with IsActivated true. Its doc comment says it is an activated account that can sign in. Add insertInactiveUser(t, gdb, email, password), which has the previous insertUser shape (is_activated false). Add inactiveSessionToken(t, app, gdb, email) string: it inserts with insertInactiveUser and mints the token with controllers.IssueToken. The request is an httptest POST to /_user/api/v1/login with Host example.com, so the iss claim matches what parseToken and the guard expect. Its doc comment says this is the session such an account has when require_activation is false. loginToken keeps its shape; update its doc comment (it signs in an activated account).
+- session_test.go TestLogin: add IsActivated: true to the ada-login (:60), throttled (:119) and soft (:133) literals, so success, the throttle and the restore stay the subjects of those assertions. TestLoginSixthAttempt: add IsActivated: true to the sixth literal (:166).
+- account_test.go: TestActivate's activate-auth account (:84) uses inactiveSessionToken. activate-already keeps loginToken; its explicit is_activated update stays and is now redundant, which is harmless. TestActivateByCode (:100) uses insertInactiveUser.
+- email_verified_test.go TestEmailVerifiedAt: activate_by_code_stamps (:132) uses insertInactiveUser; activate_stamps (:147) and activate_wrong_code_no_proof (:164) use inactiveSessionToken.
+- admin_users_test.go TestAdminUserPassword: replace the 200 assertion at :1003-1006 and its comment. The account the administrator created, still not activated, now gets a 401 for its correct password: use env.call to POST /_user/api/v1/login and compare rec.Body.String() with invalidLoginBody. Then the administrator activates it with env.bulk(t, token, "activate", user) and expects 200, and the password login answers 200. The comment says this is the escalation G2 case and that a request still cannot turn the activation on. At :1097-1099, the final check becomes "the reset kept the activation and the email": after.IsActivated must be true and the email unchanged. Update its comment. Change nothing else in the test.
+- These callers need no edit, because the insertUser change covers them: mail_test.go softDelete, account_test.go TestResetPassword, profile_test.go, last_seen_test.go, avatar_test.go and the remaining loginToken users.
+
+Then run the full suite. For each further failure, read the test and classify it:
+(1) The fixture of an ordinary account is now refused: activate the fixture (IsActivated: true in the literal, or the seedUser mutate).
+(2) The test's subject is a not-activated account signing in with a password: assert the 401 invalid-login refusal instead.
+(3) The test needs a session of a not-activated account: use inactiveSessionToken.
+Never set require_activation false in sessionConfig, newSessionApp, sessionApp or newAdminEnv. A failure unrelated to activation is not to be patched here: stop and report it. List every touched test with its class in the SUMMARY.
+
+README.md (in the same commit as the code):
+- Features, "Registration and activation" bullet (:19): with require_activation on (the default), POST /login refuses a not-activated account with the same 401 invalid-login answer as a wrong password and counts the attempt for the throttle. Such an account is activated by the mailed code (POST /activate-by-code answers with a token), by the invitation link or by an administrator. POST /activate serves sessions of not-activated accounts, which exist when require_activation is off.
+- "Deactivation and bans" bullet (:26): activation gates login, deactivation does not. While require_activation is on, an account that is both deactivated and not activated is refused and stays deactivated.
+- Configuration row for golem15.user.activation.require_activation (:210): when true, /login refuses a not-activated account (opaque invalid-login, counted as a failed attempt) and /register in admin mode returns no token; when false, both sign such an account in.
+- Testing section: one sentence on TestLoginRequiresActivation and the cases it pins.
+- Check that every identifier the README names exists in the package.
+
+Verify, then commit in the plugin checkout. Stage by explicit path: controllers/api_controller.go, session_test.go, account_test.go, email_verified_test.go, admin_users_test.go, README.md, plus any test file from the classification. Commit message subject: fix: login refuses a not-activated account while require_activation is on. The body has two or three plain lines: same 401 invalid-login answer as a wrong password, counted by the throttle; checked after the password and before the deactivation restore; require_activation false keeps the previous behaviour. No Co-Authored-By or any other trailer. No push, no tag. After this commit the sm-demo-app working tree shows plugins/golem15/user as modified; leave it uncommitted until Task 3.
+
+
+ cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestLogin|TestActivate|TestResetPassword|TestEmailVerifiedAt|TestMail|TestAdminUserPassword' ./... && GOWORK=off go test -count=1 ./... && ST=$(git status --short) && test -z "$ST" && S=$(git log -1 --format=%s) && grep -q 'not-activated account while require_activation' <<<"$S"
+
+ go vet and the full Docker-backed plugin suite are green. The plugin working tree is clean, with one new fix commit on master (code, tests, README) and no trailer. Nothing is pushed or tagged. The SUMMARY lists every test touched and why.
+
+
+
+ Task 3: Go-ahead to release sm-user-plugin and bump sm-demo-app, then close G2
+ ../sm-demo-app/plugins/golem15/user (submodule pointer), ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, .planning/notes/2026-10-22-external-user-plugin-escalations.md, .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md, .planning/todos/done/2026-10-22-user-login-ignores-is-activated.md (all conditional on the option chosen)
+ Whether to publish the fix: push sm-user-plugin master with a patch tag, commit (and optionally push) the sm-demo-app pointer bump, or keep everything local.
+
+ Present to the user: git -C ../sm-demo-app/plugins/golem15/user log --oneline origin/master..master (expected: the Task 2 fix commit only, plus any earlier escalation-quick commits recorded in Task 1) and the latest tag (v0.1.8 at planning time).
+ Behaviour change for consumers: every app runs with require_activation at its default true, so after a bump an account that is not activated and has a password can no longer sign in with it. Before bumping any app's pointer, the user may want to count such accounts in that app's database: SELECT count(*) FROM users WHERE is_activated = false AND deleted_at IS NULL AND password IS NOT NULL AND password != ''. The demo seed creates activated users (plugins/demo/console/seed.go:151-153).
+ Only sm-demo-app is in scope for the pointer bump. fonoteka.go (pinned at v0.1.6), sm-bm-app, sm-grzybyfunkcjonalne-app and sm-summercmsio-app (v0.1.8) are separate decisions; list them in the SUMMARY as follow-ups.
+ Changelog: the CLAUDE.md rule names framework release tags. Ask whether this plugin tag gets a summercms.io/changelog post. If yes, follow CLAUDE.md's changelog-post.py steps from ../sm-summercmsio-app and never print the token.
+
+
+
+
+
+
+
+After the user's answer, carry out only the chosen option.
+
+release / release-push-demo:
+1. Plugin, in ../sm-demo-app/plugins/golem15/user: git fetch origin and confirm origin/master is an ancestor of master. Pick the next free patch tag, called TAG below: v0.1.9 unless git tag shows it taken, in which case the next one. Push master. Create an annotated tag whose message is a user-facing note in the style of v0.1.8's: "Password login refuses an account that is not activated while golem15.user.activation.require_activation is on (the default), with the same invalid-login answer as a wrong password; a deactivated account is still restored by its owner's login." Push the tag.
+2. sm-demo-app, in ../sm-demo-app: the plugin checkout already sits at the tagged commit; confirm with git -C plugins/golem15/user describe --exact-match. Run GOPRIVATE='git.golem15.com/*' GOWORK=off go get git.golem15.com/golem15/sm-user-plugin@TAG. Check that git diff go.mod go.sum touches only sm-user-plugin lines, then run go build ./... and go vet ./... (workspace). Commit, staging only plugins/golem15/user, go.mod and go.sum, with the message chore: bump user to TAG and no trailer. Leave the other session's main.go edit alone. Push sm-demo-app only for release-push-demo.
+3. Planning docs, in summercms.go:
+ - In .planning/notes/2026-10-22-external-user-plugin-escalations.md, set tracker row 1's Status to done and Shipped to the tag. Replace the G2 TODO line of the reply with one line: "Shipped in sm-user-plugin TAG: with require_activation on (the default), /login answers an account that is not activated with the same opaque invalid-login response as a bad password (counted as a failed attempt); deactivation stays self-service."
+ - git mv the todo from todos/pending/ to todos/done/ and append a "## Closed 2026-10-22" note: plugin commit sha, tag, sm-demo-app bump sha, pushed or not.
+ - Commit both with docs(quick-261022-tb4): close G2, sm-user-plugin TAG, staging these paths explicitly. If the quick workflow commits this quick directory and STATE.md itself, leave those to it.
+ - g15office: if a g15office tool is available, comment on task 3r4or1ex (shipped in TAG) and move it to Live. Otherwise record it as pending in the SUMMARY.
+
+hold: change no planning doc. The todo stays pending, and the G2 row and reply paragraph stay as they are. Record the fix commit sha and "unreleased" in the SUMMARY. If a g15office tool is available, comment on 3r4or1ex that the fix is committed locally and awaits release.
+
+
+ P=/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user; if T=$(git -C "$P" describe --exact-match 2>/dev/null); then R=$(git -C "$P" ls-remote --tags origin) && grep -q "refs/tags/$T" <<<"$R" && L=$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app log -5 --format=%s) && grep -q "^chore: bump user to $T" <<<"$L" && test -f /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/todos/done/2026-10-22-user-login-ignores-is-activated.md; else O=$(git -C "$P" log --oneline origin/master..master) && grep -q "not-activated account while require_activation" <<<"$O"; fi
+
+ release: the plugin tag is on origin, sm-demo-app holds one bump commit (pushed only for release-push-demo), the G2 row and reply name the tag, the todo is in todos/done/ with a Closed note, and planning docs are committed separately from code. hold: nothing pushed or tagged, and the SUMMARY records the local fix commit as unreleased.
+ Select: release, release-push-demo, or hold (optionally: whether the plugin tag gets a changelog post).
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| anonymous client -> POST /_user/api/v1/login | untrusted email and password; the answer and its side effects must not reveal account state |
+| administrator -> Users screen | creates accounts that start not activated and may carry a password the administrator chose |
+| session JWT holder -> jwt.auth routes, /refresh | sessions minted before this fix for accounts that are not activated |
+| executor -> git remotes | tag and push of a core plugin used by every app |
+
+## STRIDE Threat Register (ASVS L1, block on high)
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-tb4-01 | E | controllers.Login issues a token to a not-activated account (G2; admin-created accounts with a password) | high | mitigate | Task 1: activation gate under require_activation (default true) after the password check; TestLoginRequiresActivation default_refuses, explicit_true_refuses; Task 2 flips TestAdminUserPassword to assert the refusal |
+| T-tb4-02 | I | account-state enumeration through the /login answer (activated or not) | medium | mitigate | QD-5: both refusals go through refuseLogin and writeInvalidLogin; same_as_wrong_password compares status, body bytes and headers |
+| T-tb4-03 | I | timing difference between a wrong password and a not-activated account | low | mitigate | QD-1: the gate runs only after bcrypt CheckPassword, and both refusals do the same throttle write |
+| T-tb4-04 | I | password oracle for not-activated accounts through throttle state (an attempt reset on a correct password) | medium | mitigate | QD-2: the refusal records a failed attempt like a wrong password; refusals_suspend pins it |
+| T-tb4-05 | E | restore-on-login reviving an account that is deactivated and not activated | medium | mitigate | QD-3: the gate sits before the deleted_at restore; deactivated_inactive_refused checks deleted_at and the absent mail |
+| T-tb4-06 | D | a not-activated owner retrying until suspended | low | accept | Such an account cannot sign in before activation anyway; the suspension lifts after suspension_minutes or an admin unsuspend (QD-2) |
+| T-tb4-07 | E | sessions minted earlier for not-activated accounts keep working (jwt guard and /refresh do not check activation) | medium | accept | Out of the G2 scope (QD-6); the sessions expire within the refresh window, and an administrator's deactivate or ban revokes them (RevokeUserAccess); recorded as a follow-up candidate in the SUMMARY |
+| T-tb4-08 | I | an unknown email skips bcrypt, so it answers faster than a known one (pre-existing) | low | accept | Unchanged by this plan (QD-6); recorded as a follow-up candidate in the SUMMARY |
+| T-tb4-09 | T | unreviewed publish of a core plugin (tag, push, pointer bump) | medium | mitigate | Task 3 is checkpoint:decision with gate blocking-human; no tag or push before the user's answer |
+| T-tb4-SC | T | npm/pip/cargo/go installs | low | accept | No new dependency; only existing packages of the plugin are used |
+
+
+
+- sm-user-plugin (../sm-demo-app/plugins/golem15/user): GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... green with Docker at the fix commit; TestLoginRequiresActivation covers the nine cases in Task 1's behavior block.
+- The README names only identifiers that exist in the plugin; the require_activation row, the activation and deactivation bullets and the Testing entry are updated in the fix commit.
+- Only the Task 2 paths are in the plugin commit; summercms.go gets only .planning edits; no file under admin/ or modules/boardwalk is touched.
+- No push, tag, changelog post, pointer bump or planning-doc closure before the Task 3 answer.
+
+
+
+- tb4-1: with require_activation on (the default), POST /login refuses a not-activated account with the byte-identical opaque invalid-login 401 a wrong password gets and counts it as a failed attempt; with it off, such an account signs in as before.
+- Deactivation restore-on-login is unchanged for activated accounts (and for every account when require_activation is off); an account that is also not activated is refused before any restore while the gate is on.
+- The existing suite passes on the new default without switching the harness to require_activation false.
+- After a release decision: the plugin tag exists on origin, sm-demo-app has its bump commit, the escalation note's G2 row and reply paragraph name the tag, and the todo is closed. After a hold decision: everything stays local and is recorded as unreleased.
+
+
+
diff --git a/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-SUMMARY.md b/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-SUMMARY.md
new file mode 100644
index 00000000..8fa65dea
--- /dev/null
+++ b/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-SUMMARY.md
@@ -0,0 +1,119 @@
+---
+phase: quick-261022-tb4
+plan: 01
+quick_id: 261022-tb4
+subsystem: sm-user-plugin (golem15.user) login
+tags: [auth, login, activation, throttle, escalation-G2, core-plugin]
+status: complete
+requires:
+ - sm-user-plugin v0.1.8 (930b158)
+provides:
+ - POST /_user/api/v1/login activation gate under golem15.user.activation.require_activation (default true)
+ - controllers.refuseLogin, the single /login refusal path (failed attempt + invalid-login 401)
+ - test helpers insertInactiveUser, inactiveSessionToken; insertUser now creates an activated account
+affects:
+ - every app that bumps sm-user-plugin past v0.1.8 (behaviour change for not-activated accounts with a password)
+tech-stack:
+ added: []
+ patterns:
+ - "one refusal helper shared by both refusal branches so answers are identical by construction"
+key-files:
+ created: []
+ modified:
+ - sm-demo-app/plugins/golem15/user/controllers/api_controller.go
+ - sm-demo-app/plugins/golem15/user/session_test.go
+ - sm-demo-app/plugins/golem15/user/account_test.go
+ - sm-demo-app/plugins/golem15/user/email_verified_test.go
+ - sm-demo-app/plugins/golem15/user/admin_users_test.go
+ - sm-demo-app/plugins/golem15/user/README.md
+key-decisions:
+ - "QD-1..QD-7 applied as planned: gate after bcrypt and before the deleted_at restore; the refusal counts as a failed attempt; flag read through registrationSettings; scope is POST /login only"
+duration: 4min
+completed: 2026-10-22
+estimate:
+ tokens: 110000
+ tasks: 3
+actuals:
+ tokens: 7500 # chars/4 over the realized plugin diff (30150 bytes)
+ tasks: 2 # Tasks 1-2 done; Task 3 is the pending release checkpoint
+ commits: 1 # measured in the sm-user-plugin repo: git rev-list --count 930b158..HEAD
+plan_head_before: 930b1587a0390310f049695efd35075cc41d0b0e # sm-user-plugin repo (code lives there, not in summercms.go)
+plan_head_after: 15ff9793bac2da6a0d4236c7efa3cb4c6f5f5044 # sm-user-plugin repo
+---
+
+# Quick 261022-tb4: Password login must refuse is_activated=false Summary
+
+**POST /login now refuses a not-activated account under require_activation (default true) with the byte-identical, throttle-counted invalid-login 401 of a wrong password, checked after bcrypt and before the deactivation restore; one unpushed, untagged fix commit `15ff979` on sm-user-plugin master. Release (Task 3) awaits the user's decision.**
+
+## Base and commits
+
+- sm-user-plugin checkout: `/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user`, switched from detached v0.1.8 to `master` (fast-forward check: already up to date).
+- Base: `930b158` (= tag v0.1.8 = origin/master). No other escalation quick had landed; `git log --oneline v0.1.8..master` before this plan was empty.
+- Fix commit: `15ff979 fix: login refuses a not-activated account while require_activation is on` (code, tests and README together, no trailer).
+- `git log --oneline origin/master..master` = `15ff979` only. Nothing pushed or tagged. The sm-demo-app working tree shows `plugins/golem15/user` modified (pointer), left uncommitted; the other session's `main.go` edit untouched.
+
+## Tasks
+
+| Task | Name | Commit | Notes |
+|------|------|--------|-------|
+| 1 | (tracer, TDD) /login refuses a not-activated account under require_activation | `15ff979` (combined with Task 2, per plan) | RED: default_refuses, same_as_wrong_password, explicit_true_refuses, refusals_suspend, deactivated_inactive_refused failed (200) against v0.1.8; GREEN after the gate. Tracer gate re-ran verify: pass. |
+| 2 | Existing suite on the new default, README, one plugin commit | `15ff979` | go vet and full Docker-backed `go test -count=1 ./...` green. |
+| 3 | checkpoint:decision (blocking-human): release / release-push-demo / hold | — | **Pending the user's decision.** Not executed. |
+
+## What changed
+
+- `controllers/api_controller.go`: `Login` reads `requireActivation` via `registrationSettings(app)`; after the password check and before the `user.DeletedAt.Valid` restore, `requireActivation && !user.IsActivated` calls `refuseLogin` and returns. The wrong-password/unknown-email branch now also calls `refuseLogin` (behaviour unchanged: a nil user records nothing). New `refuseLogin` records `CheckAndRecordLogin(ok=false)` (non-rejection error -> opaque 500) and answers `writeInvalidLogin`. Login doc comment states the check order (throttle, password, activation, restore, success bookkeeping, token).
+- `README.md`: Registration and activation bullet (refusal, how such accounts get activated, `/activate` serves sessions of not-activated accounts when the key is off, check order); Deactivation and bans bullet ("Activation gates login, deactivation does not"); `require_activation` config row; Testing paragraph on `TestLoginRequiresActivation`.
+
+## Tests touched (with class)
+
+| Test | Change | Class |
+|------|--------|-------|
+| `session_test.go` `TestLoginRequiresActivation` | new, nine subtests per the behavior block | new |
+| `session_test.go` `insertUser` | now creates an activated account (via new `createUser`); doc comment | fixture (QD-7) |
+| `session_test.go` `insertInactiveUser`, `inactiveSessionToken`, `createUser` | new helpers | fixture (QD-7) |
+| `session_test.go` `loginToken` | doc comment only | — |
+| `session_test.go` `TestLogin` (ada-login, throttled, soft) | `IsActivated: true` | (1) ordinary account fixture |
+| `session_test.go` `TestLoginSixthAttempt` (sixth) | `IsActivated: true` | (1) ordinary account fixture |
+| `account_test.go` `TestActivate` (activate-auth) | `inactiveSessionToken` | (3) needs a not-activated session |
+| `account_test.go` `TestActivateByCode` | `insertInactiveUser` | (3) subject is a not-activated account |
+| `email_verified_test.go` `activate_by_code_stamps` | `insertInactiveUser` | (3) |
+| `email_verified_test.go` `activate_stamps`, `activate_wrong_code_no_proof` | `inactiveSessionToken` | (3) |
+| `admin_users_test.go` `TestAdminUserPassword` | created (not activated) user's password login now asserted 401 `invalidLoginBody`; admin bulk `activate` then login 200; final check "the reset kept the activation and the email" | (2) subject is a not-activated account signing in (the G2 case) |
+
+The full run showed no further failures, so no other test file needed classification. The harness (`sessionConfig`, `newSessionApp`, `sessionApp`, `newAdminEnv`) keeps `require_activation` at its default.
+
+## Deviations from Plan
+
+- `insertUser` and `insertInactiveUser` share a small private helper `createUser(t, gdb, email, password, activated)` instead of duplicating the body. Same shapes and behaviour as planned.
+- Commit message: the system-supplied attribution trailer was omitted per the user's global rule (no co-author/attribution trailers), as the plan and the orchestrator required.
+
+Otherwise the plan ran as written.
+
+## Task 3 (pending)
+
+Release checkpoint not executed: no tag, push, sm-demo-app pointer bump, escalation-note G2 fill, todo move or g15office update. Fix `15ff979` is **unreleased** until the user chooses release / release-push-demo / hold. Next free patch tag would be v0.1.9 (latest v0.1.8).
+
+## Follow-up candidates (QD-6, not implemented)
+
+- Activation is not checked by `/refresh`, the `jwt` guard or the API-token guard: a session minted before this fix for a not-activated account keeps working until it expires or an administrator deactivates or bans the user (T-tb4-07, accepted).
+- The unknown-email path of `/login` skips bcrypt, so it answers faster than a known email (pre-existing timing difference, T-tb4-08, accepted).
+
+## Other apps' pointer bumps (follow-ups, separate decisions)
+
+fonoteka.go (pinned at v0.1.6), sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app (v0.1.8). Before bumping any, count affected accounts in that app's database: `SELECT count(*) FROM users WHERE is_activated = false AND deleted_at IS NULL AND password IS NOT NULL AND password != '';`
+
+## g15office
+
+Not touched (no tool used; Task 3 not reached). Pending: comment on task 3r4or1ex after the release decision.
+
+## Known Stubs
+
+None.
+
+## Self-Check: PASSED
+
+- FOUND: controllers/api_controller.go contains `requireActivation && !user.IsActivated` and `refuseLogin(`
+- FOUND: session_test.go contains `func TestLoginRequiresActivation`
+- FOUND: README.md contains `TestLoginRequiresActivation`
+- FOUND: commit 15ff979 on sm-user-plugin master; plugin working tree clean
diff --git a/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-VERIFICATION.md b/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-VERIFICATION.md
new file mode 100644
index 00000000..28d1d3f5
--- /dev/null
+++ b/.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-VERIFICATION.md
@@ -0,0 +1,114 @@
+---
+phase: quick-261022-tb4
+verified: 2026-10-22T21:35:00Z
+status: passed
+score: 7/7 must-haves verified
+covered_files:
+ - .planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-PLAN.md
+ - .planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-SUMMARY.md
+covered_digest: "v2:sha256:b084c33c4390bda9e81e881d28e50759708e20c33bd9eefd847aef2e2fd604bf"
+# The implementation lives outside this repo root (sm-user-plugin, checked out at
+# ../sm-demo-app/plugins/golem15/user), so the fingerprint tool refuses those paths.
+# They are pinned by commit instead: sm-user-plugin 15ff9793bac2da6a0d4236c7efa3cb4c6f5f5044
+# (controllers/api_controller.go, session_test.go, account_test.go,
+# email_verified_test.go, admin_users_test.go, README.md).
+covered_external_commit: "sm-user-plugin@15ff9793bac2da6a0d4236c7efa3cb4c6f5f5044"
+behavior_unverified: 0
+overrides_applied: 0
+---
+
+# Quick 261022-tb4: Password login must refuse is_activated=false (verification)
+
+**Goal:** Password `/login` must refuse `is_activated=false` when `golem15.user.activation.require_activation` is true (the default), with the same opaque invalid-login body as a bad password. The `deleted_at` restore-on-login must not change.
+**Verified:** 2026-10-22T21:35:00Z
+**Status:** passed
+**Re-verification:** No, this is the first verification.
+**Code under test:** sm-user-plugin `15ff979` on local master (`/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user`). The working tree is clean, HEAD = 15ff979, `origin/master..master` = 15ff979 only, and no tag points at it.
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | With require_activation unset or true, a not-activated account's correct password gets 401 with the same status, body bytes, Content-Type and Cache-Control as a wrong password. The response carries no token and stamps no last_seen. | VERIFIED | `api_controller.go:95`: `if _, _, _, requireActivation := registrationSettings(app); requireActivation && !user.IsActivated { refuseLogin(...); return }`. Both refusal branches call `refuseLogin` (`:89`, `:96`), which ends in `writeInvalidLogin` (same 401, same `invalid_login` translation key). Subtests `default_refuses`, `same_as_wrong_password` and `explicit_true_refuses` pass. The return comes before `TouchLastSeen` and `mintFor`. |
+| 2 | The refusal counts against the throttle like a wrong password, and attempt_limit such refusals suspend the account. | VERIFIED | `refuseLogin` calls `classes.CheckAndRecordLogin(ctx, gdb, user, ip, false)`. `default_refuses` asserts attempts=1, `same_as_wrong_password` asserts 1 then 2, and `refusals_suspend` asserts attempts=5 with `IsSuspended` after 5 refusals. All pass. |
+| 3 | With require_activation false, a not-activated account signs in with 200, a token and the user payload. | VERIFIED | The gate is conditional on `requireActivation`. `off_allows` passes and checks that the token's `sub` matches the user. |
+| 4 | Activated accounts sign in unchanged, and a deactivated activated account is restored with the reactivate mail and a working token. | VERIFIED | The restore block (`:99-105`) is untouched in the diff. `activated_allows` and `deactivated_activated_restores` pass: deleted_at is cleared and the mail is `golem15.user::mail.reactivate-en`. The existing `TestLogin` soft-delete assertions also stay green. |
+| 5 | A deactivated, not-activated account is refused before the restore under true (deleted_at stays set, no mail). Under false it is restored and signed in. | VERIFIED | The gate sits before `if user.DeletedAt.Valid`. `deactivated_inactive_refused` and `deactivated_inactive_off_restores` pass. |
+| 6 | The check order is throttle, bcrypt, activation, restore, success bookkeeping, token. The Login doc comment and the README state this order, and the README says activation gates login and deactivation does not. | VERIFIED | The code order is confirmed in `Login` (`:76-129`). The doc comment (`:47-57`) states the full order. The README "Registration and activation" bullet states the order, and the "Deactivation and bans" bullet says "Activation gates login, deactivation does not". The `require_activation` config row is updated, and a Testing paragraph names `TestLoginRequiresActivation`. |
+| 7 | Plugin go vet and the full Docker-backed go test are green at the single fix commit. Nothing is pushed, tagged or pointer-bumped before the Task 3 decision. | VERIFIED | `GOWORK=off go vet ./...` passes. `GOWORK=off go test -count=1 ./...` (run once, with Docker 29.9.1) passes all packages: root 43.4s, classes, models, updates. `git tag --points-at 15ff979` is empty and `git describe` gives `v0.1.8-1-g15ff979`. Task 3 is on HOLD by the user's decision, which was expected. |
+
+**Score:** 7/7 truths verified (0 present but behavior-unverified)
+
+All truths that depend on runtime behavior (state transitions on throttle, deleted_at, last_seen and mail) are covered by `TestLoginRequiresActivation` subtests, which I ran and saw pass.
+
+### Fail-first evidence (the test is not vacuous)
+
+I extracted the pre-fix tree (`git archive 930b158`, which is v0.1.8) into the scratchpad, swapped in `session_test.go` from 15ff979, and pointed the summercms replace at an absolute path. I then ran `TestLoginRequiresActivation`. The five refusal cases failed with HTTP 200 and a token: `default_refuses`, `same_as_wrong_password`, `explicit_true_refuses`, `refusals_suspend` and `deactivated_inactive_refused`. The four allow and restore cases passed. This is exactly the RED split the SUMMARY claims. The scratch copy was deleted afterwards, and nothing in the repo was modified.
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+|----------|----------|--------|---------|
+| `controllers/api_controller.go` | Activation gate plus `refuseLogin` shared by both refusal branches | VERIFIED | Contains `requireActivation && !user.IsActivated`. `refuseLogin` is defined at `:1193` and used at `:89` and `:96`. |
+| `session_test.go` | `TestLoginRequiresActivation`, an activated `insertUser`, and the `insertInactiveUser` / `inactiveSessionToken` helpers | VERIFIED | All present. `insertUser` and `insertInactiveUser` both go through the private `createUser` helper (a deviation the SUMMARY records; harmless). |
+| `README.md` | Activation gates login, require_activation row, Testing entry | VERIFIED | All three are present. The identifiers it names (`Login`, `RejectIfThrottled`, `TestLoginRequiresActivation`) exist. |
+
+### Key Link Verification
+
+| From | To | Via | Status | Details |
+|------|----|-----|--------|---------|
+| Login | registrationSettings (`:822`) | same parser as /register, default true | WIRED | `registrationSettings(app)` is called in the gate. It defaults `require=true`, reads `golem15.user.activation.require_activation` only when the key is present, and `config/config.yaml:9` also sets `true`. |
+| Login refusal branches | `classes.CheckAndRecordLogin` (ok=false) | refuseLogin | WIRED | Both branches go through `refuseLogin`. A nil user (unknown email) still records nothing. |
+| Activation gate | deleted_at restore | gate placed before the restore | WIRED | The gate is at `:95` and `if user.DeletedAt.Valid` is at `:99`. |
+
+### Behavioral Spot-Checks
+
+| Behavior | Command | Result | Status |
+|----------|---------|--------|--------|
+| All 9 gate cases | `GOWORK=off go test -count=1 -v -run '^TestLoginRequiresActivation$' .` | 9/9 subtests PASS | PASS |
+| Related suites | `GOWORK=off go test -count=1 -run 'TestLogin\|TestAdminUserPassword\|TestActivate\|TestEmailVerifiedAt' ./...` | ok | PASS |
+| Full suite (once) | `GOWORK=off go test -count=1 ./...` | all packages ok | PASS |
+| Static | `GOWORK=off go vet ./...` | clean | PASS |
+| Fail-first | new test against the v0.1.8 code (scratch copy) | the 5 refusal cases FAIL with 200 | PASS (the test discriminates) |
+
+### Probe Execution
+
+Not applicable. The plan declares no probe scripts.
+
+### Requirements Coverage
+
+| Requirement | Source Plan | Description | Status | Evidence |
+|-------------|-------------|-------------|--------|----------|
+| tb4-1 | 261022-tb4-PLAN | /login refuses is_activated=false under require_activation (default true) with the opaque invalid-login answer; deleted_at restore-on-login unchanged | SATISFIED | Truths 1-5 |
+
+### Test fixture changes (checked for scope creep)
+
+- `TestAdminUserPassword`: previously asserted that the G2 bug happens (a not-activated, admin-created user logs in with 200). It now asserts the 401 `invalidLoginBody`, then an admin bulk `activate`, then a 200 login. The final check is inverted to `!after.IsActivated` → fail, which is correct because the test now activates the user mid-way. The intent is kept: a request still cannot turn activation on by itself, because the 401 shows that.
+- `TestActivate`, `TestActivateByCode` and the three `TestEmailVerifiedAt` activation subtests now use not-activated fixtures or sessions (`insertInactiveUser`, `inactiveSessionToken` via `controllers.IssueToken`). The subject of each test is unchanged.
+- `TestLogin` and `TestLoginSixthAttempt` fixtures gain `IsActivated: true`. Their subjects (success, throttle, restore) are unchanged.
+- The shared harness configuration does not set `require_activation`, so the suite tests the default.
+
+### Anti-Patterns Found
+
+None. I scanned the added lines of 15ff979 for TBD, FIXME, XXX, TODO, HACK and placeholder markers and found no matches.
+
+### Info (not gaps)
+
+- sm-demo-app's `go.work` replaces `sm-user-plugin` with the local checkout, so the sm-demo-app workspace already builds against 15ff979 even though the pointer is not committed. I checked its login references: `turnstile_test.go` expects a 422 from Turnstile before Login runs, and the sociallogin, quizzes and demo harnesses mint tokens with `IssueToken` or `bouncer.Mint` rather than password login. The only other `CheckPassword` callers are the demo seed and `/change-password` (authenticated). I did not run the workspace tests; they are outside this quick's must-haves.
+- The QD-6 follow-up candidates are recorded in the SUMMARY: `/refresh` and the jwt and API-token guards do not check activation, and the unknown-email path skips bcrypt. Both are accepted risks (T-tb4-07, T-tb4-08) outside the G2 scope.
+- Task 3 (tag v0.1.9, push, sm-demo-app pointer bump, closing the escalation note and the todo) is on HOLD by the user's decision. That is expected and is not a gap.
+
+### Human Verification Required
+
+None.
+
+### Gaps Summary
+
+None. The code achieves the goal. Under the default `require_activation`, `/login` refuses a not-activated account with the byte-identical invalid-login 401 of a wrong password, after bcrypt, counted by the throttle, and before the deleted_at restore. Restore-on-login is unchanged for activated accounts and for every account when the key is off. Tests that fail on the pre-fix code and pass on 15ff979 pin this behavior.
+
+---
+
+_Verified: 2026-10-22T21:35:00Z_
+_Verifier: Claude (msd-verifier)_