# Architecture — the rebuilt application

How the new app is put together. Stack rationale: 03-stack-decision.md.
Data model: 02-domain-model.md. This doc is the how.

## Shape

One Docker deployment, standard server pattern:

```
Caddy (import id-auth, account_required:true)
  └─ 172.17.0.1:<port> → FastAPI (uvicorn, single process)
       ├─ /api/*        JSON API (OpenAPI schema → generated TS client)
       ├─ /healthz      public allowlist (id-auth verify + container health)
       └─ /*            StaticFiles → Vite dist/ (the SPA)
  └─ postgres sidecar — bound 127.0.0.1:<port> (host-local: psql, the
     diff harness, and nightly pg_dump all use this; NEVER 0.0.0.0,
     never the Docker bridge IP). Version pinned in compose.
```

- Node exists only in the Docker build stage (`vite build`).
- Deploys: `srv-gw deploy --project <name> --build`.
- App version + newest-first changelog in the repo; version string
  baked into both the API (`/api/meta`) and the SPA footer — the
  deploy-confirmation rule from coding.md.

## Backend layout

```
app/
  main.py                 # FastAPI wiring, middleware stack, StaticFiles
  routers/                # thin HTTP: validation in, DTOs out
    entries.py imports_.py invoices.py projects.py parties.py
    engagements.py expenses.py comments.py summary.py admin.py meta.py
  services/               # ALL business logic
    authz/                # the permission engine (02-domain-model §4)
      context.py          # actor resolution: app session → user → party → engagements
      policy.py           # can(actor, action, resource) -> Decision (with reason chain)
      scope.py            # scope(actor, action, stmt) -> Select transformer (owns the join graph)
      capabilities.py     # named capability truth tables (R1 = current app parity)
    imports/              # CSV parse (clockify.py port), staging, resolver, commit
    money/                # stamping, re-stamp, adjustments, invoice math
    entities/             # resolve-or-create party/project chains (one implementation)
    reporting/            # summary aggregates (ports of the SQL RPC semantics)
    audit.py              # every state-changing action → audit row
  models/                 # SQLAlchemy 2.0 typed models + Pydantic schemas
  alembic/                # migrations from day one
tests/                    # pytest; mirrors services/ 1:1
frontend/
  src/
    api/                  # GENERATED client (openapi-typescript) — never hand-edited
    components/ui/        # vendored shadcn/Radix primitives
    components/           # app components (one thing, one file)
    features/<area>/      # routes + feature components per area
    lib/                  # query client, formatting, tokens helpers
    styles/tokens.css     # THE design-token file
```

### Auth: "Sign in with BW" (Pattern B — SHIPPED 2026-07-07)

The app-side contract lives at `/srv/system/id-auth/app-auth/`
(README, PATTERN-B.md, CHECKLIST.md, `bw_auth.py`, example-flask) —
**that kit is canonical; this section defers to it.** Judgment call
#31 retired the earlier speculative `X-Auth-Grant` header design.

- Flow: OAuth2 authorization-code + **PKCE** against
  `auth.bowden.works`; the app receives a signed, **audience-bound
  id_token** (username, email, name), then keeps its **own app
  session** and its own account rows keyed on the BW username (our
  `users.idauth_username`). Domain mode = **account**
  (`srv-gw id-gate --site <host> --mode account`) — the domain login
  IS the app login.
- Registration (owner, once per host):
  `srv-gw app-client-register --name <slug> --redirect-uri
  https://<host>/auth/callback --secret-out <envfile>` — the client
  secret goes straight to a mode-600 env file, never printed.
- Middleware: default-deny (public allowlist = `/healthz`, static
  assets, `/auth/callback`); everything else requires the app
  session; the session cookie is HttpOnly + SameSite, short-refresh
  per the PATTERN-B guidance. Reserved route: `/auth/callback`.
- **No password store, ever.** Auto-provision on first login stays:
  a new BW username gets a `users` row with no party link and zero
  capabilities until an owner attaches it.
- Sign-out: end the app session AND link to the id-auth logout.
  Because the app holds a session (per the shipped contract),
  id-auth force-logout does not instantly kill app sessions — the
  app keeps sessions SHORT and re-validates against id-auth on
  refresh, per PATTERN-B; verify the exact revocation guidance in
  the kit at M1.
- Roles: app-internal, derived by the authz engine; the app may
  report roles back to BW for the hub display (per the kit) — a
  nice-to-have, not R1-gating.
- **Machine/API tokens: deferred out of R1** (no non-browser consumer
  exists — the diff harness reads Postgres directly, DB-to-DB). When
  a real consumer appears, the token path is added as a deliberate,
  host-only (127.0.0.1) endpoint — never a Caddy gate exclusion.
- Dev-mode stub (`AUTH_DEV_USER`): local tests only; refuses
  production-flagged envs. Real login works from M1, so it is a
  convenience, not a phase.

### Per-tenant configuration (no hard-coded business literals)

The current app hard-codes `'Bowden Works Team'`, `'Bowden Works'`,
`'Labour'` in export formats and `bowden-works` as THE org. In the
rebuild every such literal is tenant settings (paste-block column
values, invoice numbering, default categories, the 140h-style targets
live on engagements). This is what makes "add Heather's business"
configuration instead of code.

### The authz engine is the load-bearing wall

Everything else is conventional CRUD. The rules:

- **One choke point.** Every router dependency-injects
  `Actor = Depends(resolve_actor)`; every query goes through
  `scope()`; every mutation calls `can()` first. No inline permission
  logic in routers — grep `services/authz` to see the whole model.
- **Decisions carry reasons.** `Decision(allowed, reason_chain=[...])`
  — e.g. `worker_party(Adi) --subcontract#42--> project(Brentwood):
  visibility=subcontractor_deep => entries:read:own`. Surfaced in a
  super-admin debug panel. When the permission model gets its "many
  many changes," this is what keeps it debuggable.
- **Exhaustively unit-tested.** The capability truth tables are data;
  tests enumerate (actor-fixture × action × resource-fixture) grids.
  R1's grids must reproduce the current app's permission functions
  exactly (ported truth-table tests before any new semantics).
- **View-as = context swap.** Super-admin picks a user; `resolve_actor`
  returns that user's context with `impersonated_by` set; audit log
  records both identities; guard rails (no password/credential
  actions while impersonating) carry over from the current app.

### Money paths

- `services/money/stamping.py` — the ONLY place cost/billout stamps
  are computed (from `compensation_terms` in force at entry time).
  Re-stamp is an explicit action: scoped, skips invoiced rows,
  reports counts (current behavior preserved).
- Invoice generation (R1 = manual grouping parity; R4 = engagement-
  driven) always goes through `services/money/invoices.py`; attaching
  a line locks the underlying rows (DB constraint + service check —
  belt and suspenders, since the constraint can't see "void" nuance).
- Everything money-touching is decimal (`Numeric(12,2)`), never float.
  (The TS side renders from strings; the generated client keeps
  decimals as strings — a real bug class in the current app's
  `number | string` unions.)
- **Money is (amount, currency) everywhere** — one shared Pydantic
  `Money` type in DTOs, one `MoneyCell` component rendering it (code
  badge whenever a view can mix currencies). The segregation rule
  (02 §3: no cross-currency arithmetic in R1) is enforced in
  `services/money/` — aggregate helpers take and return per-currency
  maps, so a naive `sum()` across currencies is unrepresentable, not
  just forbidden.

### Reporting

The current SQL RPCs (project_summary, entries_filter_totals,
entries_filter_options, summary_by_project, …) encode carefully-tuned
semantics (status eligibility, pivot filtering, adjustment
application). Port strategy: each becomes a `services/reporting/`
function, with semantics pinned by tests derived from the RPC
extraction doc (01-current-system/rpcs-and-authz.md).
**The sanctioned pattern is scope()-transformed SQL aggregation
executed IN Postgres** (SQLAlchemy Core group_by/func.sum) —
fetching row sets and aggregating in Python is what the old RPCs
existed to avoid at 100x data, and requires written justification
per case. Perf guard: latency budgets on the seeded 100x dataset +
a no-seq-scan check on the hot endpoints + pg_stat_statements
enabled in the sidecar (brittle full-EXPLAIN CI assertions rejected
by the plan critique).

## Frontend architecture

- **SPA with TanStack Router**: instant navigation, URL-addressable
  state. Every current page's filter-in-URL behavior carries over
  (bookmarkable filters are a real workflow rian uses — quick links,
  "Last month's PlusROI" chips).
- **TanStack Query** owns server state: normalized query keys per
  resource; invalidation on mutation success. **Mutation-feedback
  policy (stakes-split, per the plan critique):** optimistic updates
  ONLY for cosmetic/reversible edits (descriptions, notes);
  money-affecting and lock-affecting mutations (apply/detach,
  recalculate, bulk reassign) are PESSIMISTIC with a progress state
  and a post-hoc verified count ("Applied 353 — verified");
  single-row destructive ops use toast-with-undo; bulk ops keep
  count-confirmation + undo (judgment #11). For this user,
  counts-matching-reality is the top trust rule — an optimistic
  partial failure would be worse than a spinner.
- **Design system**: `styles/tokens.css` (colors incl. the semantic
  row-state trio — saved/auto-matched/highlight — spacing, type scale,
  radii); vendored shadcn/Radix for Dialog, Combobox, DropdownMenu,
  Toast, Command; a small set of app primitives (DataTable with
  sticky header + sortable columns, FilterBar, EntityPicker with
  create-in-place, MoneyCell, DurationCell, StatusBadge). The
  SearchableCombobox/datalist patterns from the current app become
  ONE EntityPicker component.
- **Zero inline styles** (frontend.md). The current app's inline-style
  debt does not port.
- **Fun/fast extras** (cheap once the foundation is right): command
  palette (Cmd+K: jump to project/client/page, run quick filters),
  keyboard-first table editing, skeleton loaders, subtle motion on
  state changes (Radix + CSS transitions — no animation library).
- **Phone floor**: every page usable at 390px (frontend.md rule);
  tables collapse to card lists at narrow widths.

## Cross-cutting

- **Errors**: structured `{error_code, summary, detail}` from every
  route (the srv-gw db-query convention, which rian already likes);
  the SPA maps codes to toasts/inline messages; unexpected errors get
  a copy-debug-info panel. **The stale-deploy recovery pattern carries
  over from the current app's error boundary** (a tab loaded before a
  deploy calls endpoints that no longer exist): the SPA detects
  version skew (every API response carries the app version; the
  client compares against its build) and shows the current app's
  "Page is out of date → Refresh" treatment rather than a generic
  error. This was a real recurring incident class; spec in
  01-current-system/features/auth-and-login.md (app shell section).
- **Performance baseline**: R0 captures the current production row
  counts (time_entries, invoices, cc lines) as the harness baseline
  and sizes indexes accordingly — the current schema's index set
  (org/date, project_id, invoice_id partials) is the starting point,
  re-keyed to the new FKs. The "100x data" requirement means list
  endpoints are paginated + indexed from day one and the summary
  aggregates get an EXPLAIN check in CI against a seeded 100x
  dataset (test plan §reporting).
- **Audit**: `services/audit.py` writes (actor, impersonated_by,
  action, entity, before/after image) for every mutation — and for
  bulk operations the before-image is COMPLETE, because judgment
  #11's undo is implemented as replay-from-audit-row (one mechanism,
  not an audit log plus a separate undo log). The current app has no
  audit trail — for a bookkeeping system of record it's non-optional.
  Surfaced in an owner-only activity view (R2).
- **Testing** (per react.md): pytest+httpx for API/domain (the
  authz grids, stamping, import pipeline ports with the CLK-*
  fixtures); Vitest for frontend logic (filter serialization, table
  interaction hooks); Playwright smoke for the critical flows
  (import→resolve→commit, entry edit, invoice create+lock) — behavior
  assertions, not render assertions. Full catalog: 06-test-plan.md.
- **Seed + fixtures**: `scripts/seed.py` builds the party/engagement
  graph from docs/domain rosters (dev database only —
  the production databases are populated exclusively by the migration
  scripts, which have their own kill-switch checks). Test fixtures
  are small slices of the same graph, so tests speak the business's
  language (Adi, Brentwood, PlusROI) instead of user1/project1.
