Backend Standards: otari gateway (src/gateway/)
The gateway is an async FastAPI service: request handlers in api/routes/, business logic in
services/, ORM in models/ (entities.py plus tenancy.py), migrations in
alembic/versions/. This guide is the backend counterpart to the frontend skill and to the
path-scoped review instructions in
.github/instructions/ (performance and security). AGENTS.md is the source of truth for
build/test/lint commands and runtime modes; read it first. This file captures the
conventions that keep new backend code correct and consistent.
Async SQLAlchemy 2.0: the house style
Everything is async. Match the shapes already in services/:
from sqlalchemy import select, func
# scalar list
rows = (await db.execute(select(ModelAlias))).scalars().all()
# single row (or None)
existing = (await db.execute(select(APIKey.id).limit(1))).scalar_one_or_none()
# count without loading rows
count = (await db.execute(select(func.count()).select_from(ModelPricing))).scalar_one()
- Use
await db.execute(select(...)) + .scalars() / .scalar_one_or_none() /
.scalar_one(). Don't fetch rows to count them (len(all())), use func.count().
- ORM columns are typed with
Mapped[...] + mapped_column(...). Follow the existing style:
modern generics (str | None, list[str]), timezone-aware DateTime(timezone=True).
- Sessions come from the
get_db dependency in routes; non-request code uses
create_session() (core/database.py). Don't open ad-hoc engines.
The SQLModel half: the reconciled control plane's tables
models/tenancy.py (organizations, workspaces, identities, memberships) is SQLModel rather
than entities.py's declarative style, because its Create/Update/Public schemas are the
endpoint contracts the generated dashboard client is built from. Same session, same chain, three
extra rules:
- Wrap every column reference in
sqlmodel.col(). On a SQLModel class the attribute's
static type is the annotation, so col(Organization.slug) == slug typechecks where
Organization.slug == slug reads as bool and mypy rejects it. Applies to where,
order_by, join conditions, and .in_(...).
- Inherit
BaseRepository (repositories/base_repository.py) for get/get_all/create/
update/delete/count, and put tenancy repositories in repositories/tenancy/. Every
repository write flushes and never commits: the service owns the commit boundary, because
it is the layer that knows when a unit of work is complete.
- Declare no
relationship(). Lazy loading raises MissingGreenlet on an AsyncSession at
attribute access rather than at the query; join explicitly and return
(model, related) tuples instead.
A column that needs a SQLAlchemy construct Field() cannot express (use_alter, a custom type
instance) takes an explicit sa_column=Column(...), but never on a mixin: one Column instance
cannot attach to more than one table, so a shared mixin passes sa_type plus
sa_column_kwargs and lets SQLModel build a fresh column per model.
Layering
- Routes (
api/routes/) stay thin: parse the request, resolve identity, call a service,
shape the response. Keep request/response Pydantic models near the handler; return typed
models, not raw dicts; use fastapi.status constants.
- Services (
services/, one concern per *_service.py) hold the business logic and own
the DB work.
- Service-specific exceptions live beside their service (e.g.
UnsafeURLError,
GuardrailsNotReachableError). Raise HTTPException with a clear detail in the API layer;
prefer specific exceptions (ValueError, SQLAlchemyError) over broad except Exception.
- The tenancy slice is the one exception, deliberately.
services/tenancy/errors.py
declares a TenancyError family that each carry their own status_code, and one handler
registered in gateway.main renders them as FastAPI's {"detail": ...} shape. A tenancy
route therefore raises nothing and needs no try/except; a 5xx member has its message
logged and a generic detail returned. Follow that convention inside services/tenancy/ and
api/routes/organizations.py / workspaces.py, and the rule above everywhere else.
The budget / reservation lifecycle is load-bearing
Billable routes hold money-adjacent state. The invariant (detailed in
../../instructions/security-review.instructions.md)
is: reserve before the provider call, then reconcile on success or refund on every error
path, including provider errors, tool-iteration caps, unreachable sandbox/web-search,
generic except, and except HTTPException, plus streaming completion and client disconnect.
A reservation that never settles leaks and permanently shrinks the user's budget.
- Bind spend to the authenticated principal via
resolve_user_id, never to the
client-supplied user field.
- Enforce budgets atomically (the reservation is a single conditional
UPDATE), not
check-then-act. Use is None for "absent" vs a legitimate 0 (falsy-zero traps).
- New billable logic must be correct in every applicable runtime mode. Verify whether it
belongs in the local control-plane branch (
db is not None), the data plane, or both.
Migrations (Alembic)
- A change to anything under
models/ ships with a matching migration in alembic/versions/,
chained to the current head, in the same PR.
- New non-nullable columns need a
server_default for existing rows (e.g. users.reserved
defaults to "0").
- Every foreign key needs an explicit
ondelete policy; index it (index=True), see the
performance instructions. Account deletion must leave no orphaned billable rows.
- Provide a real, reversible
downgrade().
- The chain runs on SQLite and PostgreSQL, so keep it dialect-neutral:
sa.func.now() rather
than a literal now()/CURRENT_TIMESTAMP, and no ALTER TABLE ... ADD CONSTRAINT, which
SQLite does not have. Adding a constraint to an existing table goes through
op.batch_alter_table(..., copy_from=<the sa.Table>); copy_from is what keeps SQLite's
table rebuild from dropping what reflection could not see. Verify both engines locally
(upgrade, downgrade, upgrade): the integration suite migrates PostgreSQL only, so SQLite is
covered only where a test asks for it (tests/unit/test_tenancy_schema_chain.py is the pattern).
Config & env
GatewayConfig (core/config.py) loads config.yml then layers env vars, under the
user-facing OTARI_ prefix. New security-relevant
flags fail closed by default and are validated at load (reject unknown values), like the
stream_missing_usage_policy validator. Don't read os.getenv directly on a hot path; route
through the config / otari_env().
Logging
- Use the module logger from
gateway.log_config with %s placeholders.
- Never log secrets or user payloads: no API keys, no
messages/input/completion text,
no full request bodies. Log opaque ids, token counts, model/provider names, status. (The
one sanctioned exception is the intentional one-time bootstrap key print.)
Before you finish
- Add happy-path and error-path tests next to the changed behavior (unit for pure logic,
integration for route/DB behavior; integration spins up Postgres via testcontainers).
- If you touched request/response models, run
uv run python scripts/generate_openapi.py --check.
- Run
make lint and make typecheck (ruff + mypy strict over src, tests, scripts).
Related instructions
1---2name: backend-standards3description: Backend conventions for the otari gateway (`src/gateway/`), async SQLAlchemy 2.0, FastAPI, budget/reservation lifecycle, Alembic migrations, config layering. Use when writing or reviewing gateway request handling, services, models, or migrations.4---56# Backend Standards: otari gateway (`src/gateway/`)78The gateway is an async FastAPI service: request handlers in `api/routes/`, business logic in9`services/`, ORM in `models/` (`entities.py` plus `tenancy.py`), migrations in10`alembic/versions/`. This guide is the backend counterpart to the frontend skill and to the11path-scoped review instructions in12`.github/instructions/` (performance and security). `AGENTS.md` is the source of truth for13build/test/lint commands and runtime modes; read it first. This file captures the14conventions that keep new backend code correct and consistent.1516## Async SQLAlchemy 2.0: the house style1718Everything is async. Match the shapes already in `services/`:1920```python21from sqlalchemy import select, func2223# scalar list24rows = (await db.execute(select(ModelAlias))).scalars().all()2526# single row (or None)27existing = (await db.execute(select(APIKey.id).limit(1))).scalar_one_or_none()2829# count without loading rows30count = (await db.execute(select(func.count()).select_from(ModelPricing))).scalar_one()31```3233- Use `await db.execute(select(...))` + `.scalars()` / `.scalar_one_or_none()` /34 `.scalar_one()`. Don't fetch rows to count them (`len(all())`), use `func.count()`.35- ORM columns are typed with `Mapped[...]` + `mapped_column(...)`. Follow the existing style:36 modern generics (`str | None`, `list[str]`), timezone-aware `DateTime(timezone=True)`.37- Sessions come from the `get_db` dependency in routes; non-request code uses38 `create_session()` (`core/database.py`). Don't open ad-hoc engines.3940## The SQLModel half: the reconciled control plane's tables4142`models/tenancy.py` (organizations, workspaces, identities, memberships) is SQLModel rather43than `entities.py`'s declarative style, because its `Create`/`Update`/`Public` schemas are the44endpoint contracts the generated dashboard client is built from. Same session, same chain, three45extra rules:4647- **Wrap every column reference in `sqlmodel.col()`.** On a SQLModel class the attribute's48 static type is the annotation, so `col(Organization.slug) == slug` typechecks where49 `Organization.slug == slug` reads as `bool` and mypy rejects it. Applies to `where`,50 `order_by`, `join` conditions, and `.in_(...)`.51- **Inherit `BaseRepository`** (`repositories/base_repository.py`) for `get`/`get_all`/`create`/52 `update`/`delete`/`count`, and put tenancy repositories in `repositories/tenancy/`. Every53 repository write **flushes and never commits**: the service owns the commit boundary, because54 it is the layer that knows when a unit of work is complete.55- **Declare no `relationship()`.** Lazy loading raises `MissingGreenlet` on an `AsyncSession` at56 attribute access rather than at the query; join explicitly and return57 `(model, related)` tuples instead.5859A column that needs a SQLAlchemy construct `Field()` cannot express (`use_alter`, a custom type60instance) takes an explicit `sa_column=Column(...)`, but never on a mixin: one `Column` instance61cannot attach to more than one table, so a shared mixin passes `sa_type` plus62`sa_column_kwargs` and lets SQLModel build a fresh column per model.6364## Layering6566- **Routes** (`api/routes/`) stay thin: parse the request, resolve identity, call a service,67 shape the response. Keep request/response Pydantic models near the handler; return typed68 models, not raw dicts; use `fastapi.status` constants.69- **Services** (`services/`, one concern per `*_service.py`) hold the business logic and own70 the DB work.71- Service-specific exceptions live beside their service (e.g. `UnsafeURLError`,72 `GuardrailsNotReachableError`). Raise `HTTPException` with a clear `detail` in the API layer;73 prefer specific exceptions (`ValueError`, `SQLAlchemyError`) over broad `except Exception`.74- **The tenancy slice is the one exception, deliberately.** `services/tenancy/errors.py`75 declares a `TenancyError` family that each carry their own `status_code`, and one handler76 registered in `gateway.main` renders them as FastAPI's `{"detail": ...}` shape. A tenancy77 route therefore raises nothing and needs no `try`/`except`; a 5xx member has its message78 logged and a generic detail returned. Follow that convention inside `services/tenancy/` and79 `api/routes/organizations.py` / `workspaces.py`, and the rule above everywhere else.8081## The budget / reservation lifecycle is load-bearing8283Billable routes hold money-adjacent state. The invariant (detailed in84[../../instructions/security-review.instructions.md](../../instructions/security-review.instructions.md#budget-billing-and-tenant-isolation))85is: **reserve before the provider call, then reconcile on success or refund on every error86path**, including provider errors, tool-iteration caps, unreachable sandbox/web-search,87generic `except`, and `except HTTPException`, plus streaming completion and client disconnect.88A reservation that never settles leaks and permanently shrinks the user's budget.8990- Bind spend to the **authenticated principal** via `resolve_user_id`, never to the91 client-supplied `user` field.92- Enforce budgets atomically (the reservation is a single conditional `UPDATE`), not93 check-then-act. Use `is None` for "absent" vs a legitimate `0` (falsy-zero traps).94- New billable logic must be correct in every applicable runtime mode. Verify whether it95 belongs in the local control-plane branch (`db is not None`), the data plane, or both.9697## Migrations (Alembic)9899- A change to anything under `models/` ships with a matching migration in `alembic/versions/`,100 chained to the current head, in the same PR.101- New non-nullable columns need a `server_default` for existing rows (e.g. `users.reserved`102 defaults to `"0"`).103- Every foreign key needs an explicit `ondelete` policy; index it (`index=True`), see the104 performance instructions. Account deletion must leave no orphaned billable rows.105- Provide a real, reversible `downgrade()`.106- The chain runs on SQLite *and* PostgreSQL, so keep it dialect-neutral: `sa.func.now()` rather107 than a literal `now()`/`CURRENT_TIMESTAMP`, and no `ALTER TABLE ... ADD CONSTRAINT`, which108 SQLite does not have. Adding a constraint to an existing table goes through109 `op.batch_alter_table(..., copy_from=<the sa.Table>)`; `copy_from` is what keeps SQLite's110 table rebuild from dropping what reflection could not see. Verify both engines locally111 (upgrade, downgrade, upgrade): the integration suite migrates PostgreSQL only, so SQLite is112 covered only where a test asks for it (`tests/unit/test_tenancy_schema_chain.py` is the pattern).113114## Config & env115116`GatewayConfig` (`core/config.py`) loads `config.yml` then layers env vars, under the117user-facing `OTARI_` prefix. New security-relevant118flags **fail closed by default** and are validated at load (reject unknown values), like the119`stream_missing_usage_policy` validator. Don't read `os.getenv` directly on a hot path; route120through the config / `otari_env()`.121122## Logging123124- Use the module logger from `gateway.log_config` with `%s` placeholders.125- **Never log secrets or user payloads**: no API keys, no `messages`/`input`/completion text,126 no full request bodies. Log opaque ids, token counts, model/provider names, status. (The127 one sanctioned exception is the intentional one-time bootstrap key print.)128129## Before you finish130131- Add happy-path **and** error-path tests next to the changed behavior (unit for pure logic,132 integration for route/DB behavior; integration spins up Postgres via testcontainers).133- If you touched request/response models, run `uv run python scripts/generate_openapi.py134 --check`.135- Run `make lint` and `make typecheck` (ruff + mypy strict over `src`, `tests`, `scripts`).136137## Related instructions138139- [performance-review.instructions.md](../../instructions/performance-review.instructions.md): N+1, indexes, pagination limits, transaction atomicity, async efficiency.140- [security-review.instructions.md](../../instructions/security-review.instructions.md): budget/tenant isolation, auth, SSRF, prompt injection, migration safety.