# Stack decision — the rebuild runs on React SPA + FastAPI + Postgres

**Status: recommended, pending rian's sign-off.**
Decision date: 2026-07-06. Inputs: `/srv/projects/standards/` (coding.md,
react.md, frontend.md), `/srv/.logs/planning/unified-auth-2026.md` (v2,
locked 2026-07-02), `/srv/.logs/planning/server-evolution-2026.md`, and
the full history of this app's build (docs/decisions.md #001–#044).

## The decision

Rebuild `work` on the server's designated stack:

- **Backend:** FastAPI (Python), structured `app/{routers,services,models}`,
  SQLAlchemy 2.0 typed ORM + Alembic migrations.
- **Database:** PostgreSQL, self-hosted as a compose sidecar bound
  to 127.0.0.1:<port> (host-local access for psql/harness/pg_dump —
  never 0.0.0.0, never the bridge IP; 04-architecture). **Supabase
  Cloud is retired** — database, auth, and RLS all come home.
- **Frontend:** React SPA built with Vite, served as static files by the
  same FastAPI process. TanStack Query for server state, TanStack Router
  for navigation, vendored shadcn/Radix primitives, design tokens.
  **No Node at runtime** — Node exists only in the Docker build stage.
- **Auth:** id-auth ("BW Auth") **Pattern B — SHIPPED 2026-07-07**
  (`/srv/system/id-auth/app-auth/`): "Sign in with BW" via OAuth2 +
  PKCE against auth.bowden.works; the app keeps its own session +
  account rows keyed on the BW username, but **no password store and
  no login page of its own** (domain mode = account). See
  04-architecture + judgment #31.
- **API contract:** OpenAPI schema → generated TypeScript client. Never
  hand-written TS interfaces mirroring backend models.

## Why this and not the alternatives

### Option A — stay on Next.js + Supabase (evolve in place)

Rejected. The current stack is now in direct conflict with three
server-level decisions made since this app was built:

1. **The standards name a different stack.** `react.md` designates
   React SPA + FastAPI + Postgres as *the* stack for "rian-built apps
   that need genuine app-like UI, a real database, and a plausible path
   to a sellable product" — which is exactly this app's profile. It also
   notes "no app is built on this stack yet — it is the chosen stack."
   `work` rebuilt is the natural first citizen (garden is scheduled as
   the first *auth* migration, but it stays its own stack; this rebuild
   is the first full standard-stack app).
2. **The unified-auth plan explicitly schedules work's login for
   replacement** (Wave 3, Pattern B) and locks "No Supabase in the auth
   path." Keeping Supabase Auth means fighting the server's One Auth
   System end state. The view-as JWT-minting hack (ADR #043) exists
   *only* because auth identity and authorization were welded together
   in Supabase — under Pattern B + app-layer authz, impersonation
   becomes a trivial app-layer context swap.
3. **RLS cannot express the target permission model.** The future model
   is relationship-dependent: what you can see on a project depends on
   which engagement connects your party to it, at what visibility level,
   scoped per-project — not on a static role column. We already hit
   RLS's ceiling in the *current* simple model (the `projects` RLS saga:
   owner-only policies broke Adi's imports; the fix was to open RLS to
   all org members and gate at the app layer — at which point RLS is
   pure overhead). Policy logic belongs in one tested Python service,
   not scattered across 40+ SQL policies that can't see relationships.

Secondary costs of staying: Node at runtime (standards violation),
Supabase Cloud as an external dependency for what will become the
bookkeeping system of record for multiple businesses (data sovereignty,
backup story lives with a third party), and per-page SSR round trips
where an SPA gives instant client-side navigation (the "significantly
faster, easier to navigate" requirement).

### Option B — a third stack (SvelteKit, Rails, Phoenix, Django, …)

Rejected. Rian's constraint: "all things being equal I'd prefer to
stick to fewer stacks." The standards README is explicit: "Ask before
inventing a stack; a new stack gets a new doc, deliberately." Nothing
about this app demands capabilities the designated stack lacks. Not
pursued further.

**One deliberate note for the record** (per the server-evolution
stack decision): Next.js full-stack was the evaluated runner-up, with
re-entry conditions like "graduates to a revenue product with hired JS
contractors." Work's long-term ambitions partially rhyme with that.
Decision: the chosen stack stands — the re-entry condition is about
*hiring JS contractors to build product*, which is not the situation;
if that day comes, the SPA frontend carries over and the API boundary
(OpenAPI contract) is exactly the seam you'd want. Recorded here so
the divergence question was raised once, deliberately, and closed.

### Option C — the designated stack (chosen)

Everything lines up:

- **The permission engine wants Python.** The party/engagement
  authorization model is the hardest, most change-prone part of the
  future system ("this whole system will require a lot of thought and
  many many changes"). It needs to be *one* module with exhaustive unit
  tests, readable by rian ("Python owns everything you'd ever need to
  read or reason about"), evolvable without touching SQL policies.
- **Pattern B is designed for exactly this.** Identity from id-auth;
  app-internal roles stay app-internal ("per-app authorization levels
  stay app-internal until the Phase 5 user-levels API"). The app gets
  SSO across bowden.works/demoing.info for free, force-logout for free,
  and deletes its entire auth surface.
- **Speed and feel.** Vite SPA + TanStack Query = instant navigation,
  optimistic updates, aggressive caching — the current app re-renders
  whole pages server-side on every filter change. The vendored-primitives
  + design-tokens floor (frontend.md) directly answers "beautiful / fun
  to use" — the current app's inline-styles-everywhere approach is both
  a standards violation and the reason it looks utilitarian.
- **Ops consistency.** One Python process behind Caddy on 172.17.0.1,
  Postgres sidecar, `srv-gw deploy --build` — the same shape as
  everything else on the server.

## What we give up (and the replacement for each)

| Supabase gave us | Replacement |
|---|---|
| Hosted Postgres + backups | Sidecar Postgres; pg_dump into the server's existing backup rotation. **Must be set up before go-live — this becomes the books of record.** |
| Auth (login, sessions, password reset) | id-auth Pattern B (SSO, force-logout, account CRUD via `srv-gw id-user-*`). **Password reset today = rian runs `srv-gw id-user-set-password` at the CLI** — id-auth has no self-service reset (magic-link recovery is a deferred Phase 5 item, and the hub's password change requires knowing the current password). The cutover runbook includes this; it's also judgment call #18's onboarding bottleneck. |
| RLS as defense-in-depth | The authz service + default-deny middleware (react.md). Every route protected by construction; policy unit tests replace policy SQL. |
| SQL Editor for ad-hoc queries | Direct psql access to the sidecar (owner), plus the app's own reporting pages. |
| supabase-js query builder | SQLAlchemy 2.0 typed queries. |
| PostgREST RPCs | Plain FastAPI service functions (the current RPCs' logic ports to Python or SQL views). |

## Standards adaptations required (per rian's instruction)

The standards were written before any app used this stack. Two
adaptations to record *in the standards docs themselves* when the
rebuild starts (per coding.md: conflicts get surfaced, not silently
resolved):

1. **react.md says "Don't 'improve' a mature schema during a port —
   carry existing tables over near-verbatim."** This rebuild
   deliberately does NOT do that: the entire point is the new
   party/engagement domain model. Resolution: this is the "separate,
   deliberate change" the rule reserves; the migration plan compensates
   with the rule's own oracle — a **data-export diff harness built
   first**, proving computed *outputs* (invoice totals, monthly
   summaries, per-project aggregates) match across old and new, even
   though the tables differ. See 05-migration-plan.md.
2. **AI integration section (services/ai) is unused for now** — no AI
   features in scope for R1 of the rebuild. The section stays dormant;
   nothing to adapt. (Future: an AI bookkeeping assistant is a natural
   fit, but out of scope until the core ships.)

Everything else in coding.md / react.md / frontend.md applies as
written — including the rules the current app violates today:
version+changelog surfaced in the UI, no inline styles, design tokens,
vendored primitives, generated API client, default-deny middleware,
structured `error_code` responses, no leftover console.log.

## Consequences

- The rebuild is a **new project** (new name, new directory, own port,
  own Postgres), not an in-place refactor. The current app keeps running
  untouched through the whole build; cutover is a deliberate event with
  a parallel-run period (05-migration-plan.md).
- ~~Wave-3 timing dependency~~ **dissolved (2026-07-07)**: the
  Pattern B app-auth kit shipped; real "Sign in with BW" is the
  rebuild's M1 milestone (09-build-plan). The dev-mode stub survives
  only as a local-test convenience.
- The Clockify CSV import pipeline (the parsing/resolution logic in
  `lib/clockify.ts`, our most heavily-tested module) gets ported to
  Python with its test suite translated 1:1 — same fixtures, same edge
  cases (CLK-001…044). The tests are the spec; the port is mechanical.
