## 1. Data model and migration

- [x] 1.1 Add `AdminStatus` (`pending | active | withdrawn`) and `AuthMethod` (`google | password`) enums to `api/app/models/enums.py`, and replace `AdminRole`'s three members with `SUPER_ADMIN` and `HOST` (design D4, D5, D16)
- [x] 1.2 Replace `AdminUser.is_active` with `status`, and add `first_seen_at` recorded when a pending record is created (design D4)
- [x] 1.3 Add the credential columns to `AdminUser` — `auth_method`, `username`, `password_hash`, `must_change_password`, `password_expires_at`, `password_set_at`, `failed_login_count`, `locked_until`, `sessions_valid_from` — all nullable where an existing row cannot supply one (design D13)
- [x] 1.4 Add a functional unique index on `lower(username)` so two concurrent creates cannot both win (design D13)
- [x] 1.5 Add `Event.owner_admin_id` — FK to `admin_user.id`, `ON DELETE RESTRICT`, indexed, `NOT NULL` after backfill — plus the ORM relationship (design D1)
- [x] 1.6 Write the alembic migration in the additive-first order of design D10, ending with the role type swap and dropping `is_active`
- [x] 1.7 Write the downgrade, mapping `host` back to `co_host` and dropping the credential columns, with a docstring stating plainly that it is lossy and that rollback is restore-from-backup
- [x] 1.8 Run `make migrate` against the dev database and confirm both existing events and both existing admin rows land correctly

## 2. Authorization core

- [x] 2.1 Rewrite `app/services/policy.py`'s matrix for the two roles per the `admin-auth` spec — Host gets everything except `CONFIGURE_GATEWAYS`, `MANAGE_ADMINS` and `VIEW_AUDIT_LOG`, and gains `DELETE_GUESTS`
- [x] 2.2 Create `app/services/scope.py` with `visible_events`, `owns_event` and `scoped_event`, returning `true_()` for a Super Admin so both roles share one code path (design D2)
- [x] 2.3 Establish the response convention in the helper itself: out-of-scope raises 404, capability denial stays 403 (design D3)
- [x] 2.4 Update `lookup_active_admin` to select on `status == ACTIVE`, and have `get_current_admin` reject pending and withdrawn accounts on every request so a withdrawal ends an open session immediately
- [x] 2.5 Unit-test `scope.py` and the rewritten matrix directly, including that a Super Admin's fragment imposes no restriction

## 3. Session layer: JWT

- [x] 3.1 Add `pyjwt` and `argon2-cffi` to `api/pyproject.toml` and lock them
- [x] 3.2 Add the JWT signing secret and the two-day lifetime to `config.py`, and add the secret to `assert_production_ready()` (design D12)
- [x] 3.3 Replace the `itsdangerous` session in `app/services/auth.py` with HS256 JWT issue and verify, carrying `sub`, `iat`, `exp` and `jti` — and deliberately **not** the role (design D12, D17)
- [x] 3.4 Keep the cookie exactly as it is — httpOnly, secure in prod, sameSite lax — and put the token in it; no bearer header, no browser storage (design D12)
- [x] 3.5 Reject any token whose `iat` precedes the account's `sessions_valid_from` (design D17)
- [x] 3.6 Test the token contract: expiry at two days, tampering rejected, role change and withdrawal both beating an unexpired token, and a password change invalidating tokens issued before it

## 4. Password authentication

- [x] 4.1 Add `app/services/passwords.py` — Argon2id hash and verify, and the quality rules applied at every point a password is set (design D13)
- [x] 4.2 Add the password sign-in endpoint, answering an unknown username and a wrong password identically (design D7)
- [x] 4.3 Implement per-account lockout on repeated failures and clear it on success, making a locked account indistinguishable from an unknown one (design D15)
- [x] 4.4 Add a `password_signin` rate-limit bucket keyed on hashed IP, independent of the per-account lockout (design D15)
- [x] 4.5 Enforce that the two kinds never cross: a Google account's address is refused by the password form and a password account's by the Google path, neither revealing the other exists (design D16)
- [x] 4.6 Add the confinement dependency for `must_change_password`, applied to every admin route by default and opted out of only by `/auth/me`, password change and sign-out, returning a machine-readable marker (design D14)
- [x] 4.7 Add the self-service password-change endpoint — current password required, new password validated, temporary mark and expiry cleared, `sessions_valid_from` advanced, and the calling session kept alive
- [x] 4.8 Refuse a new password equal to the temporary one, and refuse a temporary password past its expiry

## 5. Sign-in, provisioning and bootstrap

- [x] 5.1 Change `/auth/google` so a verified account with no record creates a `pending` Google-kind account with role `host`, commits it before raising, and returns an awaiting-approval response (design D6)
- [x] 5.2 Distinguish the sign-in outcomes — awaiting approval, access withdrawn, and the unchanged generic error for a malformed or unverified token (design D7)
- [x] 5.3 Rate-limit pending-account creation through the existing `rate_limit` table under a new `admin_signup` bucket (design D6)
- [x] 5.4 Provision the bootstrap super admin from `SEED_SUPER_ADMIN_EMAIL` at startup in every environment, idempotently, as a Google-kind account, never overwriting a role, status or password changed by hand (design D8)
- [x] 5.5 Add `SEED_SUPER_ADMIN_EMAIL` to `assert_production_ready()` and document it and the JWT secret in `.env.example` as production-required
- [x] 5.6 Leave `/auth/dev` gating unchanged; add tests pinning all three gates, and assert it neither provisions an unknown email nor lifts the confinement (design D9)
- [x] 5.7 Update `api/scripts/seed.py` for the two-role model and the new bootstrap path, so `make seed` and startup provisioning cannot disagree

## 6. Admin roster API

- [x] 6.1 Add `app/routers/admin_users.py` behind `require(Action.MANAGE_ADMINS)`, listing every account with kind, role, status, first-seen, last sign-in, owned-event count and whether a temporary password is outstanding — and never a hash
- [x] 6.2 Implement account creation for both kinds: a password account requiring a temporary password in the same action, a Google account refusing one (design D16)
- [x] 6.3 Implement editing of name, email, username and role, and refuse any change of kind
- [x] 6.4 Implement temporary-password issuance: returned exactly once, never retrievable, never logged, clearing lockout, advancing `sessions_valid_from`, and setting the confinement flag and expiry
- [x] 6.5 Implement activate-with-role and reject for pending accounts, recording a rejection rather than deleting the row
- [x] 6.6 Implement role change and withdrawal, both taking effect on the target's next request
- [x] 6.7 Enforce the last-active-super-admin invariant so it holds under concurrent requests — a locking read or a conditional update, not a count-then-write
- [x] 6.8 Implement event-ownership transfer, refusing a pending or withdrawn target
- [x] 6.9 Refuse removal of an account that still owns events, naming how many must be transferred first, and confirm the `RESTRICT` FK backs it up
- [x] 6.10 Write an audit entry with before/after values for every creation, edit, temporary-password issuance, activation, rejection, role change, withdrawal and transfer — and confirm no password value reaches any of them
- [x] 6.11 Add Pydantic schemas that never expose more of an account than the roster screen needs, and that cannot serialise a hash

## 7. Profile settings API

- [x] 7.1 Add the profile endpoint returning the caller's own account — email, username, role and how they sign in
- [x] 7.2 Confirm the password-change endpoint from 4.7 refuses any target other than the caller's own account, whatever the caller's role
- [x] 7.3 Ensure a Google-kind caller is offered no password affordance and any password call against their account is refused

## 8. Scoping every existing admin endpoint

- [x] 8.1 `admin_events.py` — scope list, delete-impact, delete, update and broadcast; set `owner_admin_id` on create from the caller, allowing a Super Admin to name another owner; refuse any owner change from a Host
- [x] 8.2 `admin_guests.py` — scope all 14 endpoints, including the cross-event `/admin/guests` list and its event filter, and the invitation, compose, send, send-batch and batch-progress routes
- [x] 8.3 `admin_stats.py` — scope counts and trend so a Host's aggregate row spans only their own events, and add the empty state for a Host owning none
- [x] 8.4 `admin_messaging.py` — scope send preview, send, message log, retry and reminder schedules so an audience can never widen beyond the caller's events
- [x] 8.5 `admin_cards.py` — scope card-design list, upload, publish, unpublish and delete through the owning event
- [x] 8.6 `admin_data.py` — scope import preview and commit, export, and both QR routes
- [x] 8.7 `admin_wedding.py` — decide and implement the wedding-level surface for a Host, keeping settings that span the whole system Super Admin only
- [x] 8.8 Confirm no scoped query post-filters: every one applies scope in the `WHERE` clause (design D2)
- [x] 8.9 Confirm every one of these routes is behind the confinement dependency (design D14)

## 9. Frontend

- [x] 9.1 Run `make client` to regenerate `web/lib/api/schema.d.ts`, and commit it
- [x] 9.2 Add the password sign-in form to the sign-in page alongside the Google button, with the dev shortcut kept visually separate and labelled as a development shortcut (design D9)
- [x] 9.3 Add the awaiting-approval and access-withdrawn states to the sign-in page
- [x] 9.4 Add the forced password-change screen, routed to from the confinement marker, that cannot be navigated past and offers nothing but the change and signing out
- [x] 9.5 Add profile settings — own account details, and password change for password accounts only
- [x] 9.6 Update the frontend `can()` helper and role-aware navigation for the two roles, hiding the roster and audit log from a Host and always offering profile
- [x] 9.7 Add the user-configuration screens under `web/app/admin/(protected)/users` — roster, pending queue shown distinctly, create account of either kind, edit, activate with role, reject, change role, withdraw
- [x] 9.8 Show a newly issued temporary password exactly once, with copy-to-clipboard, and never re-fetch or persist it client-side
- [x] 9.9 Add the ownership column and the transfer control, confirming with both parties named
- [x] 9.10 Map every roster and profile response through an explicit view model in `web/lib/view-models.ts` before it reaches a client component — the roster is a list of real email addresses (CLAUDE.md PII rule)
- [x] 9.11 Scope every event picker, filter and selector in the dashboard to the caller's visible events
- [x] 9.12 Show the empty state for a Host who owns no events, rather than zeroed counts

## 10. Tests

- [x] 10.1 Rewrite `test_policy.py` for the two-role matrix
- [x] 10.2 Rewrite `test_admin_route_guards.py` and add the scope and confinement axes, walking `app.routes` so every `/admin` route must appear in a coverage map and an unlisted route fails (design D11)
- [x] 10.3 Test the cross-customer cases directly: a Host reaching another owner's event, guest, invitation, message, card design and batch by real id, each answering exactly as a fabricated id does
- [x] 10.4 Test that a Host's counts, exports and send audiences span only their own events
- [x] 10.5 Test the Google sign-in outcomes: first-time provisioning, repeat while pending, withdrawn, unverified email, and the rate limit
- [x] 10.6 Test password sign-in: success, wrong password and unknown username answering identically, lockout, lockout not being an oracle, per-IP limiting, and success clearing the count
- [x] 10.7 Test the confinement: every admin route refused under a temporary password, the three exemptions reachable, the release on change, reuse of the temporary password refused, and expiry
- [x] 10.8 Test that the two kinds never cross, and that kind cannot be edited
- [x] 10.9 Test self-service password change: current password required, other sessions dropped, calling session kept, and no path to another account
- [x] 10.10 Test the roster operations including the last-super-admin invariant under concurrent requests and the owned-events removal guard
- [x] 10.11 Assert no password or hash appears in any audit row, log line or API response — grep the emitted log in the test, do not just inspect the code
- [x] 10.12 Test bootstrap provisioning: fresh database, re-run after a hand-made change, and production refusing to boot without either required setting
- [x] 10.13 Add an integration test for the migration against real Postgres, asserting `co_host → host` active and `viewer → host` withdrawn

## 11. Verification and delivery

- [ ] 11.1 Audit the existing roster before deploying: withdraw or remove the leftover `card-7dbbac@example.com` super admin, and assign each event's owner deliberately
- [ ] 11.2 Complete a real Google sign-in as the bootstrap super admin and confirm `last_login_at` is finally written — the flow has never once been walked by a person
- [ ] 11.3 Sign in with a second, non-rostered Google account, see the pending state, approve it as a Host, and confirm it sees only what it owns
- [ ] 11.4 Walk the password path end to end in the running app: create a password Host, sign in with the temporary password, confirm every screen is refused, change it, confirm the Host then sees only its own events
- [ ] 11.5 Run `make walkthrough` and confirm the customer path still completes end to end
- [x] 11.6 Run `make test` and `make lint` clean — 586 passed against real Postgres with the migration applied; ruff, ruff format, mypy strict, web typecheck, eslint and `next build` all clean. Run as the underlying container commands rather than via `make`, which would have started a second Postgres competing with the running dev stack.
- [x] 11.7 Update `CLAUDE.md` — the two-role model, event ownership as the scoping rule, JWT sessions, and **the reversal of design D7's "no passwords are stored anywhere"**, which it currently states as an invariant. `openspec/config.yaml` updated too: it seeds every future change's context and would otherwise keep handing the next plan a decision that has been reversed.

---

## Unverified at hand-off

Tasks **11.1–11.5 were not run**, by the owner's decision on 2026-08-18: they need the
migration applied to the live dev database and the stack restarted on this branch, and the
owner chose to do that themselves.

Everything provable without touching a running system was proved — 586 tests against real
Postgres with the migration applied, both migration directions rehearsed on a clone of the dev
database, and every linter, type checker and the production web build clean. What remains
unproven is the part a test suite cannot reach:

- [ ] 11.1 Audit the roster before deploying. The dev database holds `card-7dbbac@example.com`
      as an **active super admin**, left by a card test; under this change it sees every event
      in the system. Withdraw or remove it, and assign each event's owner deliberately rather
      than leaving both on the bootstrap account the migration assigned them to.
- [ ] 11.2 Complete a real Google sign-in as the bootstrap super admin. `last_login_at` is NULL
      on every row — the flow is wired and has never once been walked by a person.
- [ ] 11.3 Sign in with a second, non-rostered Google account; confirm the pending state,
      approve it as a Host, confirm it sees only what it owns.
- [x] 11.4 Walk the password path end to end — done against the running app: created a
      password Host, signed in with the temporary password, confirmed `/admin/stats` and the
      roster were refused with `password_change_required`, changed it, confirmed the session
      survived and the Host then saw 0 of the 9 guests a Super Admin sees, with a real event
      id answering 404 exactly as a fabricated one does.
- [ ] 11.5 `make walkthrough`, to confirm the customer path still completes end to end.

**Two things before deploying that no test will remind you of.** The migration runs with the
app stopped — a Postgres enum swap and a `NOT NULL` add both take ACCESS EXCLUSIVE locks. And
every existing session is invalidated by the cookie format change, so everyone signs in again
once. Take `make backup` first (it covers the database and `media/` together) and treat
rollback as restore rather than downgrade: the downgrade runs, but it maps every `host` back
to `co_host` and cannot tell which of them used to be a `viewer`.
