# BW Auth feedback from Scout — the first full-suite integration

**Audience: the id-auth/BW Auth build session.** Scout (M2–M3) is the first app
to exercise the accounts kit end to end: storage-pluggable store, per-project
levels, level editing, View As, invites, resets, cross-app access, the matrix.
This file records where the standard worked, where it fought us, and what we
had to invent that should probably become standard. Newest findings at the top
of each section.

Format: **works** (keep it), **tension** (works but fought us), **gap** (no
standard exists; Scout invented), **bug** (wrong behavior), each with the
concrete Scout artifact to look at.

---

## Works — keep these exactly as they are

- **Storage pluggability.** The ~16-method store contract mapped onto Scout's
  existing tables (`app/services/accounts_store.py`) without one schema
  compromise. The DictStore reference in the self-check made the contract
  unambiguous. This is the single best design decision in the kit.
- **Assignability as data.** "An admin cannot mint another admin" enforced by
  the kit, not by every app remembering. Tests confirmed it holds through every
  mutation path we exposed.
- **Owner-as-code.** The synthesized, immutable super admin means there is no
  deletable/demotable root row. It has one sharp edge (below) but the model is
  right.
- **`invite_user` guardrails.** Always-external, email-only link delivery,
  `existing_with_password` short-circuit — exactly the right shape; Scout's
  invite endpoint is a thin wrapper.
- **`verify()` in bw_view_as v2.** Per-request fail-closed re-authorization
  caught our deactivate-mid-impersonation case with zero Scout code beyond
  wiring the hooks.
- **Verbatim-vendor discipline.** Byte-identical drop-ins are easy to verify
  (`diff` against the kit) and easy to trust. Keep shipping it this way.

## Tensions — worked, but fought us

- **T1: Standard permissions are implicitly app-wide; Scout resolves
  per-project.** The kit's `accounts.add` / `instances.grant` have no notion of
  "…but only on the projects where you hold a level that grants it". Rian's
  requirement — "a level that can invite users and manage members of exactly
  one assigned project" — forced Scout to invent `scout.project.members` and an
  either/or route gate (`project_with_any_permission`). The kit's
  `effective_level` supports per-instance resolution beautifully, but its own
  permission VOCABULARY doesn't say which permissions are meant to be resolved
  per-instance. **Ask:** annotate the standard vocabulary with scope
  (app|instance), and bless the "any of [broad, narrow]" route-gate pattern in
  ACCOUNTS.md, or apps will each invent incompatible versions.
- **T2: No metadata for permission strings.** The kit accepts arbitrary
  permission strings (good) but ships no labels/descriptions (rough). A level
  editor cannot render `instances.default_all` meaningfully without app-side
  copy, so Scout hand-wrote a catalog (`PERMISSION_CATALOG` in
  `app/constants.py`) — which every app will now re-write, differently.
  **Ask:** ship `PERMISSION_INFO` (label + one-line description per standard
  name) in `bw_accounts.py` so apps extend rather than duplicate.
- **T3: "Create a permission" is only half an act.** Attaching a custom string
  to a level does nothing until app code checks it — obvious to us, invisible
  in the UI, and guaranteed to confuse a level editor ("I gave them
  `reports.export`, why can't they?"). Scout renders unknown strings as
  "custom — not enforced by Scout yet". **Ask:** put this trap in ACCOUNTS.md
  prominently; consider a kit helper apps call to register the permissions they
  actually enforce, so the honest badge is derivable rather than hand-kept.
- **T4: The owner synthesis needs a written contract.** `member(owner)` returns
  level `"super admin"`, for which `level_def()` is None. Every Scout helper
  that maps level→permissions had to short-circuit on `is_owner` first; miss
  one and the owner silently loses that capability (we caught one in review —
  agents.md now carries a standing rule). **Ask:** state this loudly in
  ACCOUNTS.md ("`level_def(member(owner)['level'])` is None — always check
  `is_owner` first"), or have `can()`-style helpers everywhere so apps never
  do the two-step themselves.
- **T5: Grants vs `all_instances` interact dangerously on migration.** A grant
  BEATS the app-wide level, so any code path that writes a default grant for an
  existing all-instances member silently DEMOTES them on that instance. Scout
  shipped this bug in its own migration (0003), repaired it in 0004, and now
  clamps in `add_member`. The kit cannot prevent an app's migration bug, but
  ACCOUNTS.md's "bring your own storage" section should carry the warning:
  **never auto-write a grant below the holder's app-wide level.**
- **T6: `send_reset` scoping is documented two ways.** The Scout handoff
  (2026-08-18) says it is gated on an unforgeable "actually signed into this
  app" signal with `srv-gw app-link-user` as the escape hatch; the current
  `bw_auth.py` docstring says it works for any username the app has
  **reported**. Those are materially different policies (Scout reports every
  member at startup via `sync_reports`, so under the docstring reading,
  everyone is eligible). Whichever is true, one of the two documents is wrong.
  **Ask:** pick one, document one. Scout's forgot-password UX depends on the
  answer (see G2).
- **T7: Level lists tempt hardcoding.** Scout initially hardcoded "assignable
  on projects = [lead, reviewer]" and had to rip it out the moment level
  editing became real (D28). The kit already has the right primitive
  (`assignable_by(actor)`); ACCOUNTS.md should tell UI builders explicitly:
  drive every level `<select>` from it, never from a constant.

- **T9: `assignable_by` is app-wide only — per-instance assignability has no
  kit story.** A scoped coordinator's authority comes from their level ON ONE
  instance, but `bwa.assignable_by(user)` reads the app-wide level, so the
  coordinator flow 403'd on `NOT_ASSIGNABLE`. Scout added `assignable_on(user,
  project)` (resolve effective level for the instance, read ITS assignable
  list) — app-side, in `services/levels.py`. **Ask:** `assignable_by(username,
  instance_id=None)` in the kit, symmetric with `effective_level`.
- **T10: rank-for-impersonation breaks the moment levels are dynamic.**
  bw_view_as's rank guard wants `rank_of(username) -> int`, and every example
  ranks by level NAME — but with editable levels a custom name has no rank, and
  the natural fallback (0) refuses everything, silently defeating delegated
  View As. Scout now derives rank from the permissions a level holds
  (`_PERMISSION_WEIGHT` in `services/view_as.py`). **Ask:** VIEW-AS.md should
  warn that name-keyed ranks and editable levels are incompatible, and the kit
  could ship a default permission-weighted `rank_of` next to the templates.
- **T11 (self-inflicted, instructive): catalog claims vs actual gates drift.**
  Scout's own catalog said `accounts.change_level` was enforced while the route
  actually gated on `accounts.view` — the suite caught the drift the same day
  it was written. This is T3's argument in miniature: enforcement claims need
  to be derivable, not hand-kept.

## Gaps — no standard existed; Scout invented one

- **G1: View As has no standard permission name.** bw_view_as leaves policy to
  hooks (right), but every app now invents its own permission string for "may
  impersonate" — Scout chose `scout.view_as`. **Ask:** add a standard
  `accounts.view_as` to the vocabulary so the hub can eventually show who holds
  impersonation power across apps.
- **G2: Anonymous "forgot password" has no home.** The central login page owns
  the credential form, but (as far as Scout can see) offers no reset entry
  point; per-app `send_reset` is admin/self-scoped and app-bound. Scout built
  `POST /auth/forgot` (public, throttled, constant-response) as a stopgap —
  which means every app would re-build exactly this, each with its own
  enumeration-safety mistakes. **Ask:** put "Forgot password?" on
  `auth.bowden.works`'s own login page, centrally, once. This is the highest-
  value item in this file for actual end users.
- **G3: No standard access-matrix view.** "Every user × every instance at a
  glance" is a generic need (rian asked for it on day one of levels). Scout
  computes it from `members()` + `instances()` in ~20 lines
  (`levels.access_matrix`) — trivial, but it re-derives resolution order,
  which is exactly the kind of logic that should live in one place. **Ask:**
  a kit helper `access_matrix()` next to `sync_reports()`.
- **G4: No self-service credential surface inside apps.** `profile_html` is
  server-rendered HTML — unusable in an SPA without dangerouslySetInnerHTML.
  Scout wanted: the account-page URL + "email me a reset link" as data.
  We built `account_url()` + self-`send_reset`. **Ask:** a JSON-shaped
  `profile_info(username)` (account URL, linked-Google flag, cross-app access)
  alongside the HTML helper.
- **G5: No standard audit sink.** The kit guards mutations but does not record
  them; bw_view_as has an `audit=` hook, `bw_accounts` has none. Scout logs to
  the app log only. **Ask:** an optional `audit=fn(action, actor, target,
  detail)` on `bw_accounts` mutations, symmetric with bw_view_as.

## Tensions — worked, but fought us (continued during build)

- **T8: every "add someone" flow is a potential level side door.** Scout's
  invite endpoint initially applied a requested app-wide level to an EXISTING
  account — silently bypassing the owner-immutability and no-self-change guards
  that live on the dedicated level route. Caught in self-review, closed by
  making invites level-setting only at CREATION. The kit's guards protect its
  OWN mutations; an app composing several of them (invite = create + level +
  grant) has to re-derive the invariant. **Ask:** ACCOUNTS.md should carry the
  rule as guidance: composite flows must route level changes through one
  guarded path, or refuse.

## Bugs

- **B1 (kit docs vs behavior, minor):** ACCOUNTS.md's store-contract summary
  omits that `get_member` must return `None` for members the app considers
  inactive if the app wants deactivation to take effect through `can()` —
  Scout's D22 depends on this and it works, but it is a convention we inferred,
  not one the contract states. Document it (or add an `active` field to the
  contract and let the kit filter).
- **B2 (docs):** the T6 contradiction above — one of handoff vs docstring is
  wrong about `send_reset` scoping.
- *(M2, already fixed Scout-side, kit blameless but worth the ACCOUNTS.md
  warning per T5: default-grant demotion.)*

## Still to verify live (will update after rian's manual pass)

- Invite email end-to-end (real mailbox, link lands back on Scout, password
  set, first sign-in) — not fired during automated verification on purpose.
- `send_reset` for a user who has never signed into Scout (T6 answer falls out
  of this test).
- Central Live page showing the amber impersonation row during a real View As.
- Whether `auth.bowden.works/account` offers password CHANGE for a signed-in
  user (profile_html implies yes; unconfirmed from here).
