Skip to main content

Admin Dashboard - milestone plan (independent Fable pass)

Status: independent design proposal (Fable). Produced from the repository source only, as a deliberately independent second pass; it has not read any parallel proposal. Bare apps/api module paths below live under apps/api/src/bloom/; bare apps/web paths under apps/web/src/. All file:line references were verified against main at the time of writing (2026-08-14, 31f53aa).

0. TL;DR

  • New apps/admin SPA (strict TS + React + Vite + TanStack Query + Tailwind + shadcn), plus a root pnpm workspace (none exists today) with three deliberately small shared packages: packages/ui, packages/api-client, packages/config. Feature code is duplicated on purpose; only the proven-stable 15% is extracted.
  • Deploy where the user dashboard actually deploys: a second Cloudflare Pages project (bloom-admin) with the same same-origin /api/* proxy Function, giving the admin surface its own origin (its own subdomain once a production domain exists) and its own first-party cookie jar. Helm admin blocks and an OpenTofu admin DNS record are added behind flags, aligned with the in-flight M24 k3s cutover - not as a prerequisite.
  • No separate admin backend service. The admin API is a router family inside apps/api (/api/admin/*) behind a require_admin dependency chain, with a deploy-time BLOOM_ADMIN_API_MODE = disabled | mounted | only switch. only lets operations later run a dedicated admin deployment of the same image with the user surface off and vice versa, so blast-radius isolation is a configuration decision, not a codebase split. Rationale in 3.3: the credential that matters (the Postgres DSN) is identical in every option, and ~all of the code a separate service would need already lives in the bloom package.
  • Admin rights are a grants table, not a column: bloom_admin_grants(user_id, role, granted_by, granted_at) with roles viewer | operator | superadmin; absence of a row means no rights. Sign-in to the admin origin runs the existing Google OIDC flow but only mints a session when a grant exists; admin sessions are a separate surface (own cookie name, host-only, 12h TTL vs the user surface's 14 days), and the grant is re-checked on every request so revocation is immediate.
  • Framework: shadcn-admin-kit (MIT, by Marmelab) - react-admin's battle-tested ra-core engine with shadcn/ui presentation, vendored into the repo via the shadcn registry. It matches the existing stack exactly and delivers the Django-admin mechanics (list/filter/sort/paginate, relations, optimistic CRUD, auth/canAccess, bulk actions) without adopting MUI or Ant. The Python admin-panel route (sqladmin, starlette-admin) is structurally impossible here: they require SQLAlchemy models, and this backend is raw asyncpg with hand-written SQL.
  • Writes are guarded actions, not generic row edits. Most domain state is one JSONB blob per project (bloom_runs.state); letting an admin PATCH it freely is a foot-gun aimed at the orchestrator. Mutations are a whitelist of audited verbs (revoke session, unlink Telegram, delete user with cascade preview, retry/cancel job, archive project, ...), with a superadmin-only raw-state editor as the schema-validated escape hatch.
  • New append-only bloom_audit_log table records every admin action (actor, verb, resource, before/after, request id, outcome), complementing the four existing trails (credential audit log-stream, bloom_project_events, outbound ledger, runtime correlations).
  • App-layer authz owns the security boundary, not RLS: the API connects to Postgres as a single privileged role, so row-level security demonstrably does not apply (persistence/supabase_store.py:5-6 says exactly this). Section 3.6.
  • Proposed as milestone M27 - "Admin Dashboard" (next free number after M26; confirm against GitHub when the milestone is created), broken into 14 dependency-ordered issues in section 6.

1. The repository as it actually is

Facts that drive every decision below. Where the brief's framing and the repo differ, the repo wins.

#FactEvidence
F1Monorepo is apps/api (FastAPI, uv) + apps/web (bloom-web); there is no root package.json, no pnpm-workspace.yaml, no packages/ - apps/web is a standalone pnpm package with its own lockfilerepo root listing; apps/web/package.json
F2Web stack: React 18.3, react-router-dom 6 (not TanStack Router), TanStack Query 5, Tailwind 3, shadcn (new-york, components.json), lucide; no table lib, no form libapps/web/package.json:21-59, apps/web/components.json
F3Auth is server-driven Google OIDC (auth-code + PKCE, JWKS-verified id_token) minting an opaque server-side session in an httpOnly cookie; no tokens ever reach JSauth/oidc.py:102-189, api/routes/auth.py:59-165, persistence/account_store.py:41-43
F4Any Google account can self-register; the "registered user registry" gates inbound Telegram, not the dashboardapi/routes/projects.py:120-121, persistence/supabase_account_store.py:111-145, api/routes/telegram.py:110-131
F5No admin/is_admin/authz-role concept exists; bloom_users is exactly id, email, name, created_at; every "role" in code is a chat/collaborator/specialist role, not authzgrep audit; domain/accounts.py:15-21, supabase_account_store.py:27-32
F6Authorization is ownership-only, failure is a 404 (resource-hiding), never 403api/routes/projects.py:80-87,145-164
F7Data layer is raw asyncpg + hand-written SQL constants; no SQLAlchemy, no Alembic, no supabase-py; schema is CREATE TABLE IF NOT EXISTS at bootpyproject.toml:20, persistence/supabase_store.py, scripts/provision_preview_db.py:5-10
F8The API connects as one privileged Postgres role; the code itself documents that RLS does not applypersistence/supabase_store.py:5-6
F911 tables; the project domain lives in bloom_runs.state, one JSONB blob per thread driven by the workflow engine; "project" is a virtual modelpersistence/supabase_store.py:18-24, workflow/engine.py:29-45
F10Four audit-adjacent trails exist: credential audit (log-stream only), durable bloom_project_events, outbound-message ledger (has list_recent, no HTTP route), runtime correlations. No admin-action or user-CRUD audit existscredential_audit.py, persistence/project_events.py, persistence/outbound_messages.py:97-130, persistence/runtime_correlations.py
F11CORS allows exactly one origin (BLOOM_DASHBOARD_URL) and only GET/POST - the dashboard's PUT/DELETE calls work only because the SPA is same-origin via a proxyapi/app.py:407-415
F12The user SPA deploys to Cloudflare Pages (bloom-dashboard / bloom-dashboard-prod) with a Pages Function reverse-proxying /api/* so the session cookie is first-party; per-PR previews rewrite preview-api-origin.tsapps/web/functions/api/[[path]].ts, .github/workflows/deploy-web-dev.yml, preview.yml:344-374
F13Helm chart has api/web/swarm deployables, but the web image is unpublished and dev deploys run --set web.enabled=false; k3s cutover (M24) is in flight; OpenTofu is authored but never applied, and prod domains are RFC-6761 placeholders (bloom.example)deploy/helm/bloom/values.yaml:109-119, scripts/deploy/helm-dev.sh:73, deploy/tofu/main.tf:1-5, values-prod.yaml:43-54
F14A server-reported capability-boolean pattern already exists for conditional UI (GET /api/auth/config -> {google, testLogin, ...})hooks/use-auth.ts:51-63, routes/login.tsx:30-41
F15The sidebar footer is where global nav lives; FooterLink wraps react-router NavLink only, so an external admin URL needs a sibling anchor variantcomponents/workspace-sidebar.tsx:88-105,145-172
F16A non-production test-login path exists (POST /api/auth/test-login), forbidden in production by a config validator; E2E and previews depend on itapi/routes/auth.py:180-209, config.py:517-531
F17BLOOM_SESSION_COOKIE_DOMAIN exists specifically to widen the session cookie to a parent domain so one cookie can cover SPA+API subdomainsconfig.py:166-168
F18The jobs queue includes engine_implement / engine_rework kinds that trigger Claude Code execution on the worker VM - job mutations are adjacent to remote code executionpersistence/jobs.py:29-48
F19AGENTS.md gates UX-heavy work behind Penpot design studies (desktop and mobile frames) approved before UI codeAGENTS.md ("UX design studies")

2. Requirements

Al's requirements, and where each is addressed:

  1. Separate deployable at a different subdomain -> 3.2.
  2. Separate app + shared packages/*, extract-vs-duplicate decided -> 3.1.
  3. Admin nav link in the user dashboard, which forces an admin-rights claim -> 3.4, issue M27-6.
  4. Django-admin parity with modern UX -> 3.5 (framework), 5 (parity matrix).
  5. Tight security: authN/authZ, RBAC, audit logging, session/CSRF/origin isolation, threat model -> 3.4, 4.
  6. Separate backend API: evaluated with recommendation -> 3.3.

3. Architecture decisions

3.1 App and package structure

Decision: apps/admin as a sibling SPA, plus a root pnpm workspace with three small packages.

There is no workspace today (F1), so "extract shared code into packages/*" starts with creating the workspace itself: root package.json + pnpm-workspace.yaml covering apps/* and packages/*, one root lockfile, and CI updated to install from the root. That is issue M27-4 and it is deliberately its own PR: it moves apps/web's lockfile and CI cache keys and should not be entangled with feature work.

The web explorer pass measured the coupling of every candidate module. The extraction rule applied: extract what is provably app-agnostic today; duplicate what is still evolving.

Extract now:

PackageContentsWhy safe
packages/uithe 8 components/ui/* primitives (313 lines), bloom-mark.tsx, theme-provider + theme-toggle (storage key becomes a prop), the Tailwind preset + CSS design tokens (index.css:5-50, tailwind.config.js theme block)every file imports only react, radix/cva, and lib/utils; zero app-state imports (verified)
packages/api-clientapiFetch/ApiError/apiUrl (lib/api.ts, 33 lines; base URL becomes injectable instead of reading VITE_API_BASE_URL at module scope), use-auth.ts (93 lines: useCurrentUser, useAuthConfig, useTestLogin, useLogout), the CurrentUser/AuthConfig types, and the test utilities (test/render.tsx, mock-event-source.ts)lib/api.ts has zero imports; use-auth imports only query + the fetch core
packages/configshared tsconfig base, eslint flat-config base, prettier config, Tailwind preset exportpure config; the two apps must not drift on strictness

Explicitly not extracted (duplicated or app-local on purpose):

  • The ~665 lines of use-project-* feature hooks and their hand-mirrored Pydantic types. The admin app consumes different endpoints (/api/admin/*) with different shapes; sharing the user-app read-model hooks would couple the two release cadences for zero reuse. If a concrete shared need appears later (e.g. the SSE reconciliation helper), extract it then.
  • App shells, routing, sidebar. Twenty lines of duplicated layout is cheaper than a premature packages/shell abstraction serving two very different information architectures.
  • The shadcn-admin-kit components themselves: the shadcn registry model vendors them into apps/admin (that is its design); they are admin-only and stay in the app.

Scored comparison (1-5, higher better):

Criterion (weight)A: standalone app, copy-paste shared bitsB: workspace + minimal packages (chosen)C: big-bang extraction (ui + api hooks + types + shell)
Delivery speed to first admin screen (x2)442
Drift risk between apps (x2)145
Blast radius of a shared-code change (x1)542
Honors "DRY where proven" without speculative generality (x1)252
Weighted total172518

3.2 Subdomain and deployment

Decision: a second Cloudflare Pages project (bloom-admin) with its own same-origin /api/* proxy Function, i.e. exactly the topology the user dashboard actually ships with today. Helm admin.* blocks and an OpenTofu admin DNS record are authored behind flags in the same milestone, activated when the M24 k3s web cutover lands - the admin milestone does not block on it.

The brief assumed "deploy is k8s (helm) + OpenTofu; subdomains via ingress". For the API that is becoming true (M24 in flight); for SPAs it is not the running reality: there is no web Docker image, dev helm deploys explicitly disable the web deployable, OpenTofu has never been applied, and production domains are placeholders (F13). An admin dashboard that only exists behind a not-yet-real ingress would ship never; one that ships on Pages today and gains its ingress/DNS blocks in the same PR series as the web cutover ships now and converges later. This is the main place this plan diverges from the obvious reading of the requirements, and it is a divergence about sequencing, not destination: the target state remains admin.<domain> beside app.<domain> and api.<domain>.

Concretely:

  • Origins. Dev: bloom-admin.pages.dev (Pages project bloom-admin), user dev dashboard stays on bloom-dashboard.pages.dev / bloom-server.exe.xyz. Production (when a real domain exists): admin.<domain> as a custom domain on the Pages project, or the helm admin ingress host if the SPA has moved into the cluster by then. Either way the admin surface is a different registrable origin from the user dashboard at every stage, which is what the security model needs (section 4).
  • Same-origin proxy. apps/admin/functions/api/[[path]].ts mirrors the existing web proxy (F12), pointing at the same API origin. This keeps the admin session cookie first-party, avoids touching the single-origin GET/POST-only CORS allowlist at all (F11), and inherits the per-PR preview mechanism (preview-api-origin.ts pattern) unchanged.
  • Helm/Tofu (flag-gated, follow the cutover). values.yaml gains an admin: block mirroring web: (values.yaml:101-150), ingress.admin.host, bloom.adminImage + bloom.adminTLSSecretName helpers, an admin-{deployment,service,ingress}.yaml template trio, a third cert-manager Certificate, helm-unittest coverage, and in Tofu a third digitalocean_record (admin_record_name, default "admin") beside the existing api/app records (modules/k8s-bootstrap/main.tf:48-66). All disabled by default; dev keeps admin.enabled=false until the web deployable itself is real.
  • CI/CD. deploy-admin-dev.yml mirrors deploy-web-dev.yml (workflow_run on CI success on main, wrangler pages deploy). The admin app joins ci-test.yml (lint/typecheck/vitest via the workspace) and ci-build.yml (bundle build). Preview stacks optionally add the admin SPA later; not in scope for M27.
Criterion (weight)A: k8s ingress firstB: Cloudflare Pages now + flag-gated helm/tofu (chosen)C: path-based /admin on the user dashboard
Shippable against today's infra (x2)155
Meets "separate subdomain/origin" isolation (x2)551
Converges with M24 target state (x1)542
Operational cost added now (x1)245
Weighted total192819

Option C is listed because it is the classic shortcut; it fails requirement 1 outright and collapses the origin-isolation story (one cookie jar, one XSS domain), so it was never really in play.

3.3 Separate admin backend?

Decision: no separate service. Admin endpoints live in apps/api as a router family (api/routes/admin/*.py, prefix /api/admin) behind a require_admin dependency chain, plus a deploy-time posture switch BLOOM_ADMIN_API_MODE = disabled | mounted | only (default mounted in dev, so one process serves both surfaces; disabled strips admin routes from the public API; only runs an admin-only instance of the same image).

The honest analysis of "blast radius" for this system:

  • The database credential is the blast radius that matters, and it is invariant. Any admin backend - router or separate service - must hold a Postgres DSN with read/write over every table (F7, F8: single privileged role, no RLS). A separate service does not shrink what a compromised admin backend can do; it only moves which process holds the same key.
  • Code reuse is near-total. The stores (SupabaseAccountStore, SupabaseStateStore, jobs, events, outbound, correlations), the domain models, Settings, telemetry middleware, logging and metrics - a separate FastAPI service would import essentially the whole bloom package or re-implement it. There is no packages/-style seam on the Python side, and inventing one for a second service is a large refactor with no security payoff (see previous point).
  • What a separate deployable does buy is exposure control: the user-facing API host not serving admin routes at all, and the admin instance being reachable only via the admin origin's proxy (plus, later, network policy in k8s). The mode switch buys exactly that benefit for the cost of one env var, because the deployable is the same image with a different flag - the same pattern the repo already uses for the swarm deployable (same image, different command/config).
  • Operational cost of a real second service in this repo is concrete and high: a second Dockerfile or entrypoint, a second helm deployable + secrets + sealed-secrets split + CI build + deploy workflow + rollback path + monitoring, on a team where the API deliberately runs as a single replica with in-process scheduling.

Recommended production posture once a production environment exists: user-facing API deployment runs BLOOM_ADMIN_API_MODE=disabled; a second small deployment of the same image runs only, targeted exclusively by the admin origin's proxy. Until then (dev), one process runs mounted and the boundary is the require_admin chain - which must be airtight anyway, because in every option it is the last line of defense.

Criterion (weight)A: admin router, same process (chosen baseline)B: separate admin codebase/serviceC: same code, mode-switched deployable (chosen prod posture)
Auth/blast-radius isolation (x2)344
Code reuse / no duplication (x2)515
Operational cost (x1)514
Deployment simplicity today (x1)524
Future exposure control (x1)355
Weighted total291831

A and C are the same code; the recommendation is "A now, C when production exists", and B is rejected.

Implementation notes:

  • Admin routers mount from a single build_admin_router(settings) factory so disabled genuinely omits the routes (404, indistinguishable from absent - matching the repo's resource-hiding convention, F6/F16) rather than answering 403.
  • In only mode the instance skips starting the scheduler/job-runner (those belong to the user API's single replica) - the lifespan already conditionalizes subsystem startup, this adds one more condition.
  • No API versioning is introduced; the repo has none (/api/... throughout) and an internal admin surface consumed by one first-party SPA does not need it.

3.4 The admin-rights model and admin sessions

Decision: an explicit grants table + a separate session surface.

CREATE TABLE IF NOT EXISTS bloom_admin_grants (
user_id text PRIMARY KEY REFERENCES bloom_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('viewer', 'operator', 'superadmin')),
granted_by text, -- user id or 'bootstrap'
granted_at timestamptz NOT NULL DEFAULT now()
);

Why a table and not an is_admin column on bloom_users:

  • Absence-as-default. No row means no rights; there is no default-false flag to accidentally flip in a generic user-update path (and the users resource in the admin UI itself will have an update verb - the grant living in a separate table keeps privilege escalation out of that verb's reach entirely).
  • The grant is itself auditable data (who granted, when), which Django models as scattered LogEntry rows; here it is intrinsic.
  • Word-collision hygiene. "role" already means collaborator/specialist/chat roles everywhere in this codebase (F5); a bloom_admin_grants.role scoped to one table is unambiguous in a way bloom_users.role would not be.
  • Full RBAC schema (roles/permissions/role_permissions tables) is rejected for v1: three fixed roles with a permission map in code (ADMIN_PERMISSIONS: dict[AdminRole, frozenset[str]]) is least-privilege enough for a surface with a handful of operators, is mypy-checkable, and can graduate to tables if roles ever become user-defined.

Roles:

RoleGrants
viewerread every resource (minus redacted fields); no mutations
operatorviewer + the guarded action whitelist (section 5.2)
superadminoperator + grant management, destructive verbs (user delete), raw-state editor

Bootstrap: BLOOM_ADMIN_EMAILS (comma-separated) seeds superadmin grants idempotently at startup for matching existing/future users, recorded with granted_by='bootstrap'. Thereafter grants are managed in the admin UI (superadmin only, audited). Guard: the last remaining superadmin grant cannot be revoked or downgraded (a self-lockout check in the store, not the UI).

Admin sessions are a separate surface, not a widened user session:

  • bloom_sessions gains surface text NOT NULL DEFAULT 'user' ('user' | 'admin'). Existing rows read as user via the default; boot-time ALTER TABLE ... ADD COLUMN IF NOT EXISTS matches the repo's idempotent-schema convention (F7).
  • Sign-in on the admin origin uses the same Google OIDC provider with an /api/admin/auth/google/login + /callback pair (thin wrappers over the existing flow helpers). The callback resolves the user, then requires a grant before creating any session; no grant -> redirect to an access-denied page, nothing minted. This means "logged into the user dashboard" and "logged into the admin dashboard" are unrelated states by construction.
  • Cookie: bloom_admin_session, HttpOnly, Secure, SameSite=Lax, host-only (the domain attribute is never set for it - BLOOM_SESSION_COOKIE_DOMAIN (F17) must not apply to the admin cookie, since a parent-domain cookie is exactly the cross-subdomain bleed this design exists to prevent), Path=/. TTL: 12 hours (BLOOM_ADMIN_SESSION_TTL_SECONDS), vs 14 days for user sessions.
  • get_current_admin dependency chain: admin cookie -> session row with surface='admin' and unexpired -> user row -> live grant lookup (per request, not cached in the session) -> AdminActor(user, role). Revoking a grant kills access on the next request even for live sessions. Permission enforcement is a second dependency: require_permission("users.delete") etc., resolved against the in-code map.
  • The user-surface get_current_user never accepts the admin cookie and vice versa - two cookie names, two surfaces, checked explicitly.
  • Test-login hazard (F16): POST /api/auth/test-login upserts arbitrary users in non-prod environments. It must never mint surface='admin' sessions. For E2E, a separate BLOOM_ADMIN_TEST_GRANT=true (validator-forbidden in production, same pattern as config.py:517-531) lets the admin E2E suite grant a test user and exercise the surface.

The nav link (requirement 3): GET /api/auth/me (user surface) adds isAdmin: bool (a grants lookup) and the auth config gains adminUrl (from BLOOM_ADMIN_DASHBOARD_URL). The sidebar footer renders an external-anchor variant of FooterLink (F15) only when both are present, following the existing capability-boolean pattern (F14). The link is a convenience pointer only; possessing it grants nothing (the admin origin re-authenticates).

3.5 Framework: search before build

Candidates evaluated for Django-admin parity on a strict-TS React/Tailwind/shadcn stack against a FastAPI + raw-asyncpg backend:

OptionLicenseMaintenance (as of 2026-08)Stack fitDjango-parity mechanicsVerdict
shadcn-admin-kit (Marmelab)MITActive; maintained by the react-admin teamExact: shadcn/ui + Tailwind + Radix, vendored via shadcn registry into our own treeFull ra-core engine: dataProvider/authProvider contracts, list+filter+sort+pagination, references (FK nav), optimistic CRUD with undo, bulk actions, canAccess RBAC hooksChosen
react-admin properMIT core (enterprise modules paid)Very active, 10+ years, ~25k companiesPoor: hard dependency on MUI/emotion, a second design system beside Tailwind/shadcnBest-in-classRejected on stack fit; we still get its engine via ra-core
Refine CoreMITv5 (Feb 2026) ships React 19 + TanStack Query v5 support, but the company pivoted to an AI app-builder in 2025 and the OSS framework moved to second billingGood (headless; shadcn integration exists)Comparable hooks-level mechanics, more boilerplate per resourceViable runner-up; rejected on maintenance-risk + more per-resource glue
AdminJSMITActiveWrong shape: requires its own Node backend with ORM adapters (Prisma/Sequelize/TypeORM/Mongoose)Auto-CRUD from ORM modelsRejected: there is no Node backend and no ORM here
sqladmin / starlette-admin / fastapi-adminBSD/MITActiveServer-rendered, mount-on-FastAPIAuto-CRUD from SQLAlchemy (or Tortoise) modelsStructurally impossible: the backend is raw asyncpg with no ORM models to introspect (F7), and the interesting "models" are JSONB projections (F9). Adopting one would mean writing a parallel SQLAlchemy schema for 11 tables just to drive it, then still hand-building the JSONB surfaces - the worst of both worlds. Also fails the "more modern than Django admin" bar (they are Django admin's UX)
Hand-rolled (TanStack Table + Query + shadcn)n/an/aExactEverything hand-built: filter/sort/paginate state, reference resolution, optimistic mutations, bulk selection, per-resource formsRejected: rebuilding ra-core poorly; violates search-before-build

Justification detail for the choice:

  • Licensing: shadcn-admin-kit and ra-core are MIT. The paid react-admin enterprise modules are neither needed nor pulled in.
  • Vendoring model fits the repo: shadcn registry components are copied into apps/admin (the same model as the existing 8 ui/ primitives), so the UI layer is ours to restyle and the only npm runtime dependency is ra-core (+ the radix pieces already in use). No lock-in to a hosted service or a foreign design language; a worst-case ra-core abandonment leaves a working vendored UI over a plain REST protocol.
  • The backend cost is small and explicit. ra-core talks to a dataProvider; we implement one against a small admin REST convention (3.5.1) with hand-written SQL per resource - which is how this backend does everything already (F7). No ORM adoption, no codegen.

3.5.1 The admin resource protocol

One convention, every resource:

GET /api/admin/{resource}?filter={json}&sort=field&order=asc|desc&offset=0&limit=25
-> {"items": [...], "total": n}
GET /api/admin/{resource}/{id} -> item
POST /api/admin/{resource}/{id}/actions/{verb} -> action result (guarded mutations)
PUT /api/admin/{resource}/{id} -> item (only resources with editable fields)
DELETE /api/admin/{resource}/{id} -> 204 (only where whitelisted)
  • filter is a flat JSON object of whitelisted fields per resource; free-text q maps to the resource's search columns (ILIKE over e.g. email, name, title-in-state). Every filterable column is backed by an index or an explicitly-accepted seq-scan (11 tables, small cardinality today; bloom_project_events and bloom_jobs already carry the needed indexes).
  • Responses carry total in-body (not Content-Range); the dataProvider maps it. All list endpoints are paginated server-side with a hard limit <= 100.
  • Mutations are actions (verbs), not generic PUTs, except for the few truly editable scalar fields (e.g. users.name). Every action handler runs inside the audit wrapper (section 4.3) and permission dependency.
  • Types for the admin SPA are hand-mirrored from the Pydantic response models, matching the repo's existing (deliberate) practice (no OpenAPI codegen exists; adopting one is out of scope for this milestone and noted as a possible later improvement for both apps).

3.6 RLS vs app-layer authorization

The brief asks where Supabase RLS vs app-layer authz should own the boundary. In this repo the question is settled by F8: the API connects directly to Postgres as a single privileged role and the code documents that RLS does not apply. Nothing talks to PostgREST/supabase-js; there is no per-user database identity to which a policy could bind. Therefore:

  • App-layer authz owns the boundary: get_current_admin + the permission map + per-resource whitelists are the enforcement, and they get the test rigor that implies (every admin route tested for 401/403/404 behavior, including surface-confusion cases: user cookie on admin routes, admin cookie on user routes).
  • RLS is not adopted for the admin surface. Retrofitting RLS under a raw-asyncpg store would require per-request SET ROLE/JWT-claim plumbing across every store and buys nothing while the app connects as one role.
  • A cheaper database-side hardening is noted for the only-mode deployable later: a distinct Postgres role for the admin instance with table-level grants (e.g. no DDL, no DELETE on bloom_audit_log). Recorded as a hardening option in M27-14, not a v1 requirement.

4. Security model and threat model

4.1 Authentication and authorization chain

Browser (admin.<domain> origin)
-> Pages Function proxy (first-party /api/*)
-> FastAPI admin router (mode != disabled)
-> get_current_admin:
bloom_admin_session cookie (host-only, HttpOnly, Secure, SameSite=Lax, 12h)
-> session row, surface='admin', unexpired
-> user row
-> live bloom_admin_grants lookup (every request)
-> require_permission("resource.verb") (in-code role->permission map)
-> audited handler (append bloom_audit_log)

Design invariants:

  1. Two surfaces, two cookies, zero bleed. The admin cookie is host-only on the admin origin; the user cookie never authorizes /api/admin/*; the admin cookie never authorizes user routes. BLOOM_SESSION_COOKIE_DOMAIN is never applied to the admin cookie (F17 is the documented temptation; the admin surface explicitly opts out).
  2. Sign-in does not imply access (F4 makes this mandatory: anyone with a Google account can sign in). Access = sign-in AND grant, re-verified per request.
  3. Deny looks like absence. Non-admin requests to /api/admin/* receive 404 in disabled mode and 401/403 behind valid-cookie-but-no-grant paths; list endpoints never leak existence of resources across permissions. This extends the repo's resource-hiding convention (F6).
  4. Least privilege by default: new grants default to viewer; operator and superadmin are explicit escalations, audited.

4.2 Session, CSRF, and origin isolation across the subdomain split

  • CSRF: the admin surface is same-origin end-to-end (SPA and API share the admin origin via the proxy), so SameSite=Lax on the admin cookie blocks cross-site sends of state-changing requests, and - unlike the user surface, which must tolerate a cross-origin dev topology (config.py:573-580 downgrades to SameSite=None there) - the admin cookie is always Lax; there is no cross-origin admin topology by design. Defense in depth on top: an origin-check middleware on all /api/admin/* mutations rejects requests whose Origin/Sec-Fetch-Site indicate a cross-site initiator, and mutations are JSON-only (Content-Type: application/json enforced), which forces a CORS preflight for any cross-origin attempt - and the admin origin is deliberately not in the CORS allowlist (F11: the allowlist stays single-origin, GET/POST-only; the admin surface never needs a CORS entry because it is proxied same-origin).
  • XSS containment: the admin Pages project ships a strict CSP (default-src 'self', no inline script, no third-party origins - the admin SPA has no analytics and no CDN assets), which the user dashboard currently does not have; the admin surface is where it matters most. React's default escaping + no dangerouslySetInnerHTML policy (lint-enforced) covers the DOM side; JSON state blobs are rendered through a read-only tree viewer, never interpolated as HTML.
  • Session lifecycle: 12h TTL, logout deletes the server-side row (as today, routes/auth.py:212-227); "revoke all admin sessions for user X" is a guarded action; grant revocation invalidates effectively immediately (per-request grant check).
  • Transport: HTTPS everywhere (Pages/exe.dev edge today, cert-manager in the k8s target state); Secure on the admin cookie unconditionally (no dev-http carve-out; local dev uses the Vite proxy on localhost, where Secure cookies over http://localhost are accepted by browsers).

4.3 Audit logging of admin actions

New append-only table (boot-created like all others):

CREATE TABLE IF NOT EXISTS bloom_audit_log (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
actor_id text NOT NULL, -- admin user id
actor_email text NOT NULL, -- snapshot; survives user deletion
actor_role text NOT NULL,
action text NOT NULL, -- "resource.verb", e.g. "sessions.revoke"
resource text NOT NULL,
resource_id text,
before jsonb, -- redacted snapshot where meaningful
after jsonb,
outcome text NOT NULL, -- ok | error | denied
request_id text, -- joins the structured log stream (X-Request-Id)
ip text,
user_agent text
);
CREATE INDEX IF NOT EXISTS bloom_audit_log_at_idx ON bloom_audit_log (at DESC);
CREATE INDEX IF NOT EXISTS bloom_audit_log_actor_idx ON bloom_audit_log (actor_id, at DESC);
  • Every admin mutation goes through one audited_admin_action(...) wrapper (modeled on the existing audited_credential_op contextmanager, credential_audit.py:50-61): success and failure both land a row; denied permission checks land outcome='denied'. Admin reads are not row-logged (volume/noise), but are visible in the structured HTTP log stream with request_id, route template, and admin actor bound via the existing telemetry middleware.
  • Tamper resistance: the table has no update/delete code path anywhere in the app; the admin API exposes it read-only (even to superadmin). DB-level REVOKE UPDATE, DELETE for the admin-mode role is the M27-14 hardening option. Retention: none in v1 (append forever; volumes are tiny); revisit with a retention setting when it matters.
  • before/after snapshots pass the existing redaction discipline: credential names only, never values (the write-only Infisical contract is preserved - the admin surface has no endpoint that can read a secret value); session ids never logged (matches routes/auth.py:221-222).
  • Grant changes, session revocations, user deletions, job retries/cancels, lifecycle actions, and raw-state edits (with full before/after state diff) are all captured by construction.

4.4 Threat model (STRIDE over the admin surface)

Verification (M27-14): every row below is proven by a test or a documented manual check - the per-row pointers live in docs/security/admin-threat-model.md, which mirrors this table and must be updated alongside it.

Assets: all user PII (emails/names), sessions, Telegram links, project state (including repo targets and conversation history), credential metadata, the job queue (adjacent to code execution on the worker VM, F18), outbound messaging (ability to speak to users' Telegram), the audit log itself.

ThreatVectorMitigations
SpoofingAttacker signs in with any Google account (open registration, F4) and reaches adminGrant table required beyond sign-in; grant checked per request; bootstrap only via env; access-denied page mints nothing
SpoofingTest-login mints an admin session in a deployed envTest surface already 404s in prod (F16); admin surface additionally never accepts test-provider sessions unless BLOOM_ADMIN_TEST_GRANT (validator-forbidden in production)
TamperingAdmin edits bloom_runs.state JSONB and corrupts the orchestratorNo generic write path; guarded verbs only; raw editor is superadmin-only, schema-validated (Pydantic RunState + known-key checks), optimistic-concurrency-checked against updated_at, and fully diffed into the audit log
TamperingCSRF from a malicious site drives admin mutationsHost-only SameSite=Lax cookie; origin-check middleware; JSON-only mutations; admin origin absent from CORS allowlist
TamperingAudit-trail erasure to cover tracksAppend-only by construction; read-only API; DB-grant hardening option; request_id cross-links to the external log stream
Repudiation"Who deleted that user?" unanswerablebloom_audit_log rows for every mutation incl. actor snapshot, before/after, outcome
Information disclosureAdmin surface leaks credential valuesNo value-read endpoint exists anywhere (write-only store contract); admin sees names/timestamps only
Information disclosureXSS on admin origin exfiltrates data or rides the sessionStrict CSP; no third-party script origins; HttpOnly cookie (no token in JS at all, F3); JSON rendered via tree viewer
Information disclosureCompromised user dashboard origin pivots to adminDifferent registrable origin; host-only cookies; nothing in the user origin's storage or cookie jar authorizes admin
Denial of serviceBulk actions or unbounded list queries hammer the DBHard limit <= 100; bulk actions capped per request and audited; rate limit on admin auth endpoints (M27-14)
Elevation of privilegeviewer invokes an operator verb; operator grants themselves superadminPer-verb permission dependency (server-side, UI canAccess is cosmetic only); grant management is superadmin-only; last-superadmin lockout guard; all denials audited
Elevation of privilegeJob-queue mutation escalates to code execution with attacker payload (F18)Job payloads are never editable; only retry (re-enqueue as-is) and cancel verbs exist, operator+; both audited

Residual risks, stated plainly: (a) a compromised superadmin Google account is game over for Bloom-held data short of step-up auth - Google-side 2FA is the real control; an optional Cloudflare Access layer in front of the admin Pages project (free tier covers this team size) is recommended in M27-14 as a cheap second factor at the edge; (b) the shared Postgres role means a server-side RCE in either surface reaches all data regardless of this design (pre-existing, unchanged); (c) admin reads are not row-audited in v1.


5. Django-admin parity, mapped to a modern UX

5.1 Feature matrix

Django admin capabilityThis planWhere
Model list views (ModelAdmin.list_display)<List> + shadcn DataTable per resource; column config in codekit + protocol GET /{resource}
Change/detail forms<Show>/<Edit> with typed field components; most resources are read-mostly with action buttonskit
Add/deleteWhitelisted per resource (5.2); delete flows show a cascade preview (Django's "are you sure?" collateral list, rebuilt from FK knowledge: user -> identities, sessions, links, workspaces, credentials meta, projects)protocol actions
Filtering (list_filter)Per-resource whitelisted filter fields, JSON filter param; UI filter chips/selectsprotocol + kit
Search (search_fields)q free-text per resource (email/name/title ILIKE)protocol
Sortingsort/order on whitelisted columnsprotocol
PaginationServer-side offset/limit, total in-body, hard cap 100protocol
Relation navigation (FK links, raw_id_fields)ra-core reference fields: session -> user, identity -> user, project -> owner (via bloom_workspaces chat-id join), event/job -> project; each rendered as a link chipkit references
Bulk actions (actions=)Row-select + bulk verbs where safe (revoke sessions, cancel jobs); capped batch size; one audit row per affected itemkit bulk + protocol actions
Inline related objects (InlineModelAdmin)Detail pages embed related lists (user detail shows sessions, identities, links, grants, projects; project detail shows events, jobs, child flows)kit <ReferenceManyField> equivalents
History (LogEntry, history_view)bloom_audit_log as a first-class resource + per-record "History" tab filtered to that resource id; project domain history additionally from bloom_project_events4.3, M27-13
Permissions (add/change/delete/view per model)Role -> permission map enforced server-side per verb; canAccess mirrors it in the UI (hide, do not rely)3.4
Admin site indexOverview dashboard: user/project/job/session counters, recent failures, recent audit entriesM27-13
Autocomplete fieldsAsync select over the q search for reference pickerskit
Beyond Django admin (the "more modern UX")Dark mode (theme system already shared), fully responsive incl. mobile (F19 gate), command palette (cmdk) for resource/record jumps, saved list views (URL-encoded filter state), optimistic mutations with undo (ra-core), read-only JSON tree inspector for run.state/event/job payloads, live-updating queue/overview via the existing SSE pattern (stretch), skeleton loading states matching bloom-webkit + apps/admin

5.2 Resource capability matrix (the whitelist, v1)

Resource (table)List/ShowSearch/filterMutations (guarded verbs; minimum role)
usersyesq: email/name; filter: created range, has-grantupdate_name (op), revoke_sessions (op), delete w/ cascade preview (superadmin), grant_admin/revoke_admin/change_role (superadmin)
admin_grantsyesfilter: rolevia users verbs above (no direct create/delete endpoints)
sessionsyesfilter: user, surface, active/expiredrevoke (op), bulk revoke (op)
oauth_identitiesyesfilter: provider; q: emaildelete (superadmin)
telegram_linksyesfilter: verifiedunlink (op)
workspacesyesq: user emailread-only
global_credentials (metadata)yesq: name; filter: userdelete metadata (op; value deletion via existing store path, audited)
projects (bloom_runs projection)yesq: title/thread id; filter: phase, lifecycle, ownerarchive/unarchive lifecycle (op), trigger_tick (op), edit_state raw JSONB (superadmin, validated + diffed)
project_eventsyesfilter: thread, type, time rangeread-only
jobsyesfilter: kind, status; sort: run_after/attemptsretry (op), cancel (op), bulk cancel (op); payloads immutable
outbound_messagesyes (new route over existing list_recent, F10)filter: channel, kind, status, threadread-only
runtime_correlationsyesfilter: runtime, status; q: external idread-only
audit_logyesfilter: actor, action, resource, outcome, timeread-only, forever

Everything not listed is not reachable from the admin surface. Growing the whitelist is a reviewed code change, never a config flip.


6. Milestone M27 - "Admin Dashboard": dependency-ordered issues

Numbering follows repo convention (M27-<n>: ...); "M27" to be confirmed as the next free milestone number when created on GitHub. Sizes: S (<= half day), M (~1 day), L (1-2 days) for the production engine. Per AGENTS.md the milestone integrates on branch m27, one PR per issue.

M27-1: Admin grants model + require_admin authz seam (api) - size M, depends: none

  • bloom_admin_grants table (schema in 3.4) created idempotently in the account store; store methods get_grant, list_grants, upsert_grant, revoke_grant with last-superadmin lockout guard, on both Supabase and in-memory backends.
  • AdminRole enum + ADMIN_PERMISSIONS map; get_current_admin and require_permission(perm) FastAPI dependencies (session surface check lands in M27-2; here the dependency validates cookie -> user -> grant against the user surface temporarily and is wired final in M27-2).
  • BLOOM_ADMIN_EMAILS bootstrap seeding at startup, idempotent, granted_by='bootstrap'.
  • GET /api/admin/me returns {user, role, permissions}.
  • AC: no-grant user gets 403 with no resource detail; grants CRUD covered by unit tests incl. lockout guard; bootstrap seeds exactly once per user; mypy strict + ruff clean; zero change to any user-surface route behavior.

M27-2: Admin session surface + admin sign-in flow (api) - size L, depends: M27-1

  • bloom_sessions.surface column (idempotent ALTER); create_session(surface=...); bloom_admin_session cookie (host-only, Secure, SameSite=Lax, BLOOM_ADMIN_SESSION_TTL_SECONDS default 12h).
  • /api/admin/auth/google/login|callback|logout wrapping the existing OIDC helpers; callback requires a grant before minting; denial redirects to /access-denied on the admin origin with nothing set.
  • get_current_admin finalized: admin cookie only, surface='admin' only, live grant re-check per request; user cookie explicitly rejected on admin routes and vice versa.
  • BLOOM_ADMIN_API_MODE (disabled|mounted|only) with router-mounting factory; only skips scheduler/job-runner startup; disabled yields 404s.
  • Test-login can never mint admin sessions; BLOOM_ADMIN_TEST_GRANT (non-prod validator, mirrors config.py:517-531) enables E2E grants.
  • AC: surface-confusion matrix tested (4 combinations of cookie x surface); revoked grant denies next request on a live session; mode switch covered by app-factory tests; production validator rejects BLOOM_ADMIN_TEST_GRANT=true.

M27-3: Append-only admin audit log (api) - size M, depends: M27-1 (parallel with M27-2)

  • bloom_audit_log table + store (Supabase + memory); audited_admin_action wrapper (contextmanager, modeled on credential_audit.py:50-61) capturing actor snapshot, action, resource, before/after, outcome, request_id from the telemetry contextvars, ip/user-agent.
  • Read-only list/get with filters (actor, action, resource, outcome, time range), paginated.
  • AC: success, error, and permission-denied paths each produce a row; no update/delete code path exists (grep-guard test); redaction verified (no credential values, no session ids in snapshots); wrapper adopted by M27-1's grant mutations retroactively.

M27-4: Root pnpm workspace + packages/{config,ui,api-client} extraction (web) - size L, depends: none (parallel with M27-1..3)

  • Root package.json + pnpm-workspace.yaml (apps/*, packages/*), single root lockfile; bloom-web becomes a workspace member with unchanged behavior.
  • packages/config: shared tsconfig base (strict flags from F2's current config), eslint flat base, prettier, Tailwind preset (tokens from index.css/tailwind.config.js).
  • packages/ui: the 8 ui/ primitives, bloom-mark, theme-provider (storage key as prop) + theme-toggle; bloom-web imports switched.
  • packages/api-client: apiFetch/ApiError/apiUrl with injectable base URL, use-auth hooks + types, shared test utilities.
  • CI (ci-test.yml, ci-build.yml, ci-security.yml pnpm audit) and the web deploy workflows updated to install at the workspace root; Pages build output paths unchanged.
  • AC: pnpm run check green for bloom-web; built bundle byte-comparable modulo hashes; no duplicated ui/api-core source remains in apps/web; CI lockfile caching keys updated; e2e suite still passes against a preview.

M27-5: apps/admin scaffold + Pages deploy + admin sign-in UI (web/infra) - size L, depends: M27-2, M27-4

  • New workspace app bloom-admin (Vite + strict TS from packages/config; packages/ui + packages/api-client), with login page (Google button -> /api/admin/auth/google/login), access-denied page, RequireAdmin route guard on GET /api/admin/me, and an empty shell (sidebar + theme toggle + user menu).
  • functions/api/[[path]].ts proxy (clone of the web one) + preview-api-origin.ts seam; strict CSP headers (_headers).
  • Cloudflare Pages project bloom-admin; deploy-admin-dev.yml mirroring deploy-web-dev.yml; admin app wired into ci-test.yml/ci-build.yml.
  • AC: granted user completes Google sign-in on the admin origin and sees the shell; non-granted user lands on access-denied with no session cookie set; CSP present on all responses; vitest
    • lint + typecheck in CI; dev deploy green.

M27-6: Admin nav link in the user dashboard (api+web) - size S, depends: M27-1, M27-5

  • GET /api/auth/me gains isAdmin; auth config gains adminUrl from BLOOM_ADMIN_DASHBOARD_URL.
  • External-anchor FooterLink variant in the sidebar footer, rendered only when isAdmin && adminUrl, opening in a new tab; follows the capability-boolean pattern (F14).
  • AC: link hidden for non-admins and when URL unset; visible for granted users; unit tests for both; no layout shift for non-admins; mobile drawer parity (F19 note: covered by M27-8 pattern frames).

M27-7: Admin resource protocol + account-domain resources, read-only (api) - size L, depends: M27-1, M27-2, M27-3

  • The 3.5.1 protocol helpers (filter/sort/pagination parsing, whitelist enforcement, {items, total} envelope, hard limit cap).
  • Read-only resources: users, admin_grants, sessions, oauth_identities, telegram_links, workspaces, global_credentials metadata - each with its 5.2 search/filter set, all behind require_permission("<resource>.view").
  • AC: pagination/sort/filter/search behavior integration-tested per resource against the memory store + one Supabase-live test (existing opt-in pattern); permission matrix tested for viewer; every list capped at 100; responses carry no redacted fields (session ids exposed only as prefixes for identification).

M27-8: Penpot design studies - admin shell + list/detail patterns - size M, depends: none (start immediately; gates M27-9+ UI)

  • Studies for: admin shell (sidebar/nav), resource list pattern (table, filters, bulk select), record detail pattern (fields + related inlines + history tab), overview dashboard, JSON inspector - as reusable patterns, not one frame per resource; desktop + ~390px mobile frames per AGENTS.md (F19), mobile favoring icon buttons per the M20 convention.
  • AC: boards linked on the ticket; owner approval recorded before any M27-9+ UI PR merges; frames only include controls the backend whitelist actually supports (no fake affordances).

M27-9: shadcn-admin-kit foundation + read-only CRUD screens (admin app) - size L, depends: M27-5, M27-7, M27-8

  • Vendor shadcn-admin-kit; implement dataProvider against the 3.5.1 protocol and authProvider/canAccess against /api/admin/me permissions.
  • Register the M27-7 resources with list (search/filter/sort/pagination), show (with related inlines: user detail embeds sessions/identities/links/grants), and reference navigation.
  • Command palette (cmdk) for resource jumps; saved views via URL filter state.
  • AC: every M27-7 resource browsable end-to-end against dev; list state round-trips through the URL; viewer sees no mutation affordances; matches approved M27-8 frames (desktop + mobile); vitest coverage on dataProvider param mapping.

M27-10: Guarded mutations wave 1 + bulk actions (api + admin app) - size L, depends: M27-7, M27-9

  • Verbs per 5.2: users.update_name, users.revoke_sessions, users.delete (with cascade preview endpoint + confirm flow), grant management verbs, sessions.revoke (+ bulk), oauth_identities.delete, telegram_links.unlink, global_credentials.delete.
  • All via POST .../actions/{verb}, permission-gated, wrapped in audited_admin_action, optimistic UI with undo where reversible, destructive confirms where not.
  • AC: every verb produces an audit row (incl. denied attempts); cascade preview matches actual FK cascades; bulk revoke capped and per-item audited; last-superadmin guard surfaced as a clear UI error; permission matrix integration-tested for all three roles.

M27-11: Projects resource - run read model + state inspector + lifecycle verbs (api + admin app) - size L, depends: M27-9

  • projects projection over bloom_runs (+ owner join via bloom_workspaces): list with phase/lifecycle/owner filters and title search; detail with overview fields, read-only JSON tree of run.state, embedded project_events timeline and child-flow list, links to the owner user record.
  • Verbs: archive/unarchive, trigger_tick; superadmin edit_state (Pydantic-validated, updated_at optimistic-concurrency check, full before/after diff in audit).
  • AC: a concurrent edit_state against a stale updated_at is rejected; invalid state shapes rejected with field-level errors; timeline matches the user-facing timeline route's data; lifecycle verbs reflected in the user dashboard after invalidation.

M27-12: Operational resources - jobs, outbound ledger, correlations (api + admin app) - size M, depends: M27-9

  • jobs resource (kind/status filters, attempts/run_after sort) with retry/cancel (+ bulk cancel) verbs, payloads rendered read-only; outbound_messages (first HTTP exposure of the existing list_recent store read, F10) and runtime_correlations, both read-only.
  • AC: retry re-enqueues the identical payload (byte-equal assertion in tests); cancel is idempotent; payload immutability enforced server-side (no verb accepts a payload); all three resources filter/paginate per protocol; audit rows for every job mutation.

M27-13: Audit-log UI + admin overview dashboard (admin app) - size M, depends: M27-9 (+ M27-3)

  • audit_log resource screens (filters per 5.2; per-record "History" tab on user/project detail filtered by resource id); overview home: counters (users, active projects, queue depth, failed jobs, active sessions) + recent audit entries + recent outbound failures. SSE live-refresh of the overview is a stretch goal, not an AC.
  • AC: history tab on a user shows that user's mutations end-to-end after performing them; counters match direct API queries; dashboard renders under 1s on dev data volumes.

M27-14: Hardening pass + threat-model verification (api + infra) - size M, depends: M27-2, M27-5, M27-10 (final gate before milestone PR)

  • Origin-check middleware on admin mutations; rate limiting on /api/admin/auth/*; CSP finalized + _headers audit; admin E2E suite (grant -> sign-in -> browse -> mutate -> audit row) using BLOOM_ADMIN_TEST_GRANT; verify every 4.4 mitigation with a test or a documented manual check; document the production posture (disabled on the user API + only admin instance) in docs/deployment.md; decide/record the optional Cloudflare Access layer and the admin-role DB-grant hardening as follow-ups or in-scope per Al's call.
  • AC: the 4.4 table has a verification pointer per row; e2e green in CI against a deployed dev admin; security stage (bandit, audits) green; docs/index.md links the milestone; CHANGELOG + version bump per AGENTS.md.

Dependency graph (issues on the same row can run in parallel):

M27-1 ─┬─ M27-2 ─┬─────────────── M27-5 ── M27-6
├─ M27-3 ─┤
│ └─ M27-7 ─┐
M27-4 ─┴─────────── (M27-5)│
M27-8 ─────────────────────┼─ M27-9 ─┬─ M27-10 ─┐
│ ├─ M27-11 ├─ M27-14
│ ├─ M27-12 │
│ └─ M27-13 ─┘

7. Where this plan diverges from the obvious approach, and why

  1. Pages-first, ingress-later (3.2). The obvious reading of "k8s + ingress subdomains" would scaffold helm/tofu first. The repo says SPAs ship on Cloudflare Pages and the k8s web path is not yet real (F12, F13); this plan ships on the real path and lands the cluster artifacts flag-gated, so the admin app neither blocks on nor forks the M24 cutover.
  2. No separate admin backend (3.3). "Separate deployable = separate service" is the reflexive security answer; here the DB credential is the whole blast radius and it is shared by construction, so the plan buys exposure control with a mode switch on one codebase instead of paying the permanent tax of a second service.
  3. Grants table, not is_admin (3.4). The column is the obvious move; the table keeps privilege out of reach of the admin UI's own user-update verb, carries its own audit metadata, and dodges this codebase's overloaded "role" vocabulary.
  4. A vendored kit on ra-core, not a framework app and not hand-rolling (3.5). Notably, the popular FastAPI admin panels (sqladmin/starlette-admin) are eliminated by a structural fact (no ORM, F7) rather than taste - worth stating because it is the first thing a reviewer would suggest.
  5. Guarded verbs, not generic CRUD writes (5.2). Django admin's "edit any row" translated naively to bloom_runs.state would hand admins a tool whose main use case is corrupting the orchestrator. Parity is delivered on capability (you can fix bad state) while shaping the default path to safe, audited verbs.
  6. App-layer authz, explicitly not RLS (3.6) - settled by how the backend actually connects to Postgres, and stated so the "should this be RLS?" review conversation is pre-empted with evidence.

8. Open questions for Al

  1. Production domain: admin.<domain> needs <domain> to exist; any preference (affects only the custom-domain step, nothing structural)?
  2. Cloudflare Access in front of the admin origin as an edge-level second factor: in scope for M27-14 or a follow-up?
  3. Who gets grants at launch besides you, and should viewer exist at all in v1 (it is cheap to keep, but if the only operators are superadmins the map shrinks)?
  4. Admin on staging/preview: should preview stacks deploy the admin SPA too (currently out of scope)?