# scout — agents.md

Project constitution. Inherits from `/srv/CLAUDE.md` (server scope) and
`/srv/apps/CLAUDE.md` (tenant scope). Only rules meeting the admission rubric
(failure-backed, tool-enforceable, decision-encoding, or triggerable) belong
here — no generic advice a model already applies. Budget: ~200 lines.

Model-independence: this file is the source of truth. `CLAUDE.md` is a symlink
to it so Claude Code loads the same content.

## Read first (in order)
1. **`brief.md`** — what this project is, and the stack decision.
2. **`.logs/planning/`** — the build plan: locked decisions, milestones, gates.
3. **`/srv/projects/standards/README.md`** — server-wide coding standards
   (dispatches by stack: `coding.md` → `react.md` → `frontend.md`).
4. **`.logs/handoff.md`** — newest-first; where the last session left off.

## Project-specific rules

### Authentication is not ours to build
- **Never add a password field, a login form, a users-with-passwords table, or a
  password reset flow.** Scout is a BW Auth Pattern B app: identity comes from
  `auth.bowden.works`, Scout keeps only its own session and its own levels. A
  request for "a login for X" means *create a BW account for X*
  (`srv-gw id-user-create`, then `srv-gw id-user-set-password` — owner-run,
  interactive, never in chat or argv), then add them in Scout by username.
- **This host must never be gated.** `scout.bowden.works` stays
  `auth_enabled:false` with no `import id-auth` in its Caddy conf. The gate
  claims `/login`, `/logout`, and `/auth/*` — the exact paths Scout's OAuth flow
  owns — so gating it returns 404 on sign-in. The gateway refuses to gate a
  registered app-client host; if one is ever found gated (hand-edited conf), fix
  it with `srv-gw id-gate --site scout.bowden.works --mode public`. To hide the
  app before a launch, use `srv-gw id-site-set --host scout.bowden.works
  --lan-only true`, never a password gate.
- **The `app_accounts` login upsert is column-scoped on purpose.** See
  `app/services/accounts.py`. It updates only the BW-derived columns and seeds
  `level` only when empty. Rewriting it as a whole-row upsert silently wipes every
  app-owned column (level, all_instances, active, and any profile field added
  later) on the user's next sign-in — a failure that shows up days later as "why did Darren
  lose access?"

### Levels: two questions, never interchangeable
- **`app_can(user, perm)` is app-wide; `project_can(user, project_id, perm)` is
  per-project.** Creating a project is app-wide; managing or seeing the results
  of *one* is not. A route scoped to a project must resolve its permission
  against that project — there is deliberately no general `require_admin`
  dependency, because that is the shape the mistake takes.
- **The owner has no level row.** `bw_accounts` synthesizes `rian` as a super
  admin whose level is the literal string `"super admin"`, for which
  `level_def()` returns `None`. Every helper that maps level → permissions
  short-circuits on `is_owner` first; delete that short-circuit and the owner
  silently loses every per-project permission while appearing fine everywhere
  else.
- **An automatic path must never write a grant below someone's app-wide level.**
  A per-project grant beats `all_instances`, so giving an admin the default
  `reviewer` grant when adding them to a project strips their ability to manage
  it — this shipped once, in migration 0003, and is why `add_member` clamps and
  migration 0004 exists. Scoping someone down on one project is still allowed,
  but only through the explicit `set_member_level` path.
- **A level a caller may not assign is refused by the kit, not by Scout.** Ask
  `levels.assignable_by(actor)`; do not reimplement the rule. It is what stops
  an admin minting another admin.
- **The UI never switches on a level name.** The API sends decided capabilities
  (`can_manage`, `can_view_results`, `is_staff`, `can_create_projects`). A
  frontend comparison like `level === 'admin'` re-derives policy in the one place
  that cannot see the owner or per-project grants.

### View As
- **Data and permissions follow the EFFECTIVE user; identity and audit follow
  the REAL one.** `authz.session_username` runs the fail-closed `verify()` and
  returns the impersonated user — everything below it answers as them, which is
  the point. Anything that logs, reports presence, or renders "who you are"
  uses `authz.real_username`. Mixing these up either leaks the impersonator's
  reach to the target's view or attributes the target's actions to the wrong
  person.
- **The read-only write block lives in the middleware, not in routes.** While
  impersonating read-only, every POST/PATCH/DELETE except `/api/view-as/stop`
  and `/logout` is refused structurally (`VIEW_AS_READ_ONLY`). Do not add
  per-route can_write checks — one enforcement point, like the default-deny.
- **`mode='act'` is owner-only** (`services/view_as.py`). Acting writes real
  rows under the target's name; it exists for testing flows as a client, not
  for delegation.

### Default-deny is structural, not per-route
- **`app/middleware.py` denies every path that is not explicitly public.** A new
  `/api/...` route is protected because the middleware protects everything, not
  because someone remembered a dependency. **Never add a prefix exemption inside
  `/api/`** — an inverted default is how API routes get left open to the
  internet, a repeated failure on this server.
- **A project the caller cannot see returns 404, never 403.** `readable_project`
  in `app/services/authz.py`. A 403 confirms the id exists, which leaks the
  client list to any signed-in user willing to count upward. A project they *can*
  see but lack a permission on is a 403 — they already know it exists, so hiding
  it only confuses. `project_with_permission` encodes both halves.

### No service worker
- **Do not add `vite-plugin-pwa`, Workbox, or any service worker** without also
  adding `navigateFallbackDenylist: [/^\/login/, /^\/logout/, /^\/auth\//,
  /^\/api\//]` to its config. A navigation fallback answers `/auth/callback` and
  `/auth/probe` from the cached shell, so those requests never reach the backend
  and sign-in fails silently with **zero requests in the logs** — and a stale
  service worker then makes the correct fix look broken. The baseline ships no
  service worker specifically to keep this trap out of the project.

### The API client is generated, never written
- **`frontend/src/api/schema.d.ts` is generated during the image build** (the
  `openapi` Dockerfile stage dumps the live schema; the `frontend` stage runs
  `openapi-typescript` over it). It is not committed. After changing a Pydantic
  model or a route, do not hand-edit a TypeScript type — rebuild and fix what
  `tsc` flags.
- **`app/main.py` must stay importable with no database and no BW client.** The
  schema dump imports it during the build, where neither exists. A setting that
  raises on a missing value, or an eager connection at import time, breaks the
  build rather than the runtime.

### Secrets
- **`.app.env` (660, `scout-dev`)** holds the DB password, the DB URL, and the
  session secret. **`.bw-auth.env` (600, `srv-gateway`)** holds the BW client
  secret and is written by `srv-gw app-client-register --secret-out` — the
  gateway owns it; do not chmod or rewrite it by hand.
- **Never reference a secret as `${...}` in `docker-compose.yml`.** Compose
  interpolates from the shell and the gateway-managed `.env` — *not* from
  `env_file` — so a `${SECRET}` supplied via `env_file` resolves to an empty
  string with only a warning, and the container starts misconfigured.

### The vendored drop-ins
- **`app/bw_auth.py` and `app/bw_accounts.py` are verbatim copies** of the kit in
  `/srv/system/id-auth/app-auth/`. Do not edit or reformat either (ruff is
  configured to ignore them) — re-copy when they are updated upstream, and check
  the copy is byte-identical to the source before trusting it. Project-specific
  wrapping belongs in `app/services/bw.py` and `app/services/levels.py`; Scout's
  storage for the kit is `app/services/accounts_store.py`.

## Operational
- **Manage:** `srv-gw {deploy,restart,logs,status} --project scout`.
- **Deploy is a build:** `srv-gw deploy --project scout --build`. Without
  `--build` the image is not rebuilt and the SPA serves stale code.
- **Deploy confirmation:** `app/version.py` is the single source; it surfaces at
  `/api/meta` and in the UI footer. Every change bumps it and adds a newest-first
  `CHANGELOG.md` line — the visible version is how you confirm a deploy landed.
- **Reach the database** with `docker exec scout-db psql -U scout scout`. It
  publishes no port on purpose; nothing outside the compose project can reach it.
- **Schema changes are Alembic migrations only** (`main/alembic/versions/`),
  never a hand-written `ALTER`.
- **Write access:** `scout-dev` Unix group (setgid + default ACL). New files need
  `chmod 664` / dirs `775`, or `srv-gw fix-permissions --project scout`,
  so the service user can read them.
- **Create new subdirectories under `sg scout-dev`.** If the session's groups
  don't include `scout-dev`, the kernel silently strips setgid on `chmod 2775`
  and files underneath get the wrong group.

## Layout
```
/srv/apps/scout/          # workspace — docs and runtime state
├── brief.md  agents.md  CLAUDE.md -> agents.md
├── docker-compose.yml  .env  .app.env  .bw-auth.env
├── .logs/{planning/, handoff.md, diary.md, v8-feedback.md}
├── .memory/  .archive/  notes/
└── main/                  # the instance — code only
    ├── Dockerfile  requirements.txt  pyproject.toml  alembic.ini
    ├── alembic/           # migrations
    ├── app/               # FastAPI: routers/ services/ models/
    └── frontend/          # Vite + React SPA
```
Runtime state (uploads, dumps) belongs at the workspace root, not in
`main/`. The compose file and `.env` sit at the workspace root because
`srv-gw deploy` requires them there — a documented deviation until T3.1.
