agilentics / boiler

Read this rendered instead

# Conventions

This file is the point of the skeleton. The code exists mostly to prove these
rules are real; if you follow one thing here, follow this file.

## The layering rule

```
app/endpoints/   façade  - authenticate, deserialise, serialise. Nothing else.
app/core/        logic   - every decision about what may be read or written.
db/              models  - SQLAlchemy declarations and nothing else.
```

Two invariants hold this together. Both are easy to break by accident, and both
are what make the core testable without a running web server:

1. **Nothing in `app/core` imports `db_session`.** Every core function takes its
   session as the first argument. That is what lets `tests/test_auth.py` call
   core functions directly against an in-memory SQLite with no Flask anywhere.

2. **Nothing in `app/core` raises an HTTP error.** The core raises `Invalid`,
   `Forbidden`, `NotFound`, `Conflict` (see `app/core/context.py`). The façade
   maps them to status codes through the `STATUS` table in
   `app/endpoints/crud.py`. Add a new error class there and add its status to
   that table in the same edit.

A core function's signature is `(session, principal, ...)`, it calls
`principal.require(Scope.X)` before it does anything, and it returns model
objects rather than JSON. `app/core/orgs.py` is the worked example — read it
before writing your first core module.

## Multi-tenancy

Anything a customer owns carries `org_id`, and queries for it go through
`scope_to_org(query, Model, principal)`. That function is the single place the
tenancy rule is written down.

`Principal.__post_init__` refuses to build a customer-facing principal with no
organisation, so the leak fails at construction rather than being relied on to be
filtered downstream. Only `system` callers span organisations, and that is
declared in `SPANNING_SURFACES` rather than implied.

When a caller asks for another tenant's row, raise `NotFound`, never `Forbidden`
— "that exists but is not yours" confirms a competitor is a customer.

## Adding a route

1. Write the logic in `app/core/<thing>.py`, taking `(session, principal, ...)`.
2. Write the façade in `app/endpoints/<thing>.py`, decorated with `@endpoint`.
3. Register it in `app/routes.py`.

It is protected automatically — `require_session` is a `before_request` guard
built as an **allowlist**. To make a route reachable signed-out, add its path to
`PUBLIC_PATHS` in `app/endpoints/crud.py` **and write a comment saying what
defends it instead** (a single-use token, a signature header, enumeration
safety). Every existing entry has one; that comment is the review.

Machine-to-machine surfaces (API tokens, provider webhooks) authenticate
themselves inside the endpoint and return early from the guard before the CSRF
check — they hold no cookie, so there is no ambient credential for a forged
request to ride.

## Sign-in paths

Every one of them ends at the same two functions: `auth.principal_for_user`
resolves `(user, org, role) → Principal`, and `session._begin_session` starts the
cookie. `_begin_session` clears the session first — a pre-existing session id
must not survive a sign-in, or a fixed cookie becomes an authenticated one.

`app/endpoints/oauth.py` (Google) is the worked example for adding another. Two
rules it follows and a new one must too:

- The redirect leg cannot use our CSRF token — no session exists yet. Its
  forgery defence is the OAuth `state` parameter, single-use, popped rather than
  read.
- Only ever link a provider identity to an existing email when the provider says
  it verified that address (`email_verified`). Without that check, linking is a
  way to seize somebody's account.

## Configuration

```
app.yaml            a host, an id, a public address, a flag   -> committed
env_secrets.yaml    anything that authenticates               -> never committed
```

`env_secrets.yaml` is gitignored and rendered by CI from repository secrets.
`app.yaml` pulls it in with `includes:`.

**An unset config variable means the feature is off, never a crash.** The app
must run end to end — locally and in CI — with no account registered at any third
party. `SMTP_HOST` in `settings.py` is the worked example: unset, mail is logged
instead of sent. Copy that stance for every integration you add, and `.strip()`
every secret so a trailing newline from a piped value is not baked into a
comparison.

Never interpolate a secret into a shell string. The CI render step writes them
through a **quoted** heredoc with `json.dumps` for a reason — an unquoted one let
the shell expand a password containing `$`, which arrived three characters short
and silently broke outbound mail for three days.

## Database

- Add a model to `db/db_declarations.py`, then
  `alembic revision --autogenerate -m "..."`. Review the generated file; it is a
  draft, not an answer.
- `alembic check` runs in CI between the migration and the deploy. It is the only
  thing comparing models to schema — tests need no database, and `upgrade head`
  only applies what already exists.
- Connection budget: `(pool_size + max_overflow) × WEB_CONCURRENCY ×
  max_instances` must fit inside the Cloud SQL instance's `max_connections`,
  minus whatever else shares it. Those numbers live in `utils/db_wrapper.py`,
  `gunicorn.py` and `app.yaml` and are **one decision** — changing one alone is
  what exhausts the database for every app on the instance.

## Tests

The suite runs on in-memory SQLite and needs no Postgres. Two fixtures:
`session` for core-unit tests, `app_client` for façade tests. Mark anything that
genuinely needs a live database `@pytest.mark.integration`.

Prefer a core-unit test: it is faster, and it only passes if the layering rule
above is still intact.

## Environments

`main` deploys to **int** automatically on merge; a `v*` tag promotes an
int-tested commit to **production**. The tag must be an ancestor of `main` — CI
enforces it. Int is a separate App Engine service in the same project, with its
own database and its own secret file.

## Comments

Comments here say **why**, not what — what the surprising constraint was, what
broke last time, what the alternative was and why it lost. A comment restating
the code is noise; a comment recording a decision is why this file is short.