# 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.