API & Services
The tenant backend (api) — how requests are shaped, scoped, and served, with one opinionated server per tenant.
The tenant backend (
api): how requests are shaped, scoped, and served. One opinionated server per tenant.
Shape
- TypeScript + Fastify (decided). REST + OpenAPI with a generated client SDK (
packages/sdk) keeps the frontend in sync. - Stateless Cloud Run service; all state in PG/Mongo/Redis. Horizontal scale by instances.
- Endpoints are versioned (
/api/v1/...).
Middleware chain (every request)
1. TLS/ingress (LB + Cloud Armor)
2. requestId + structured logging
3. JWT verify (**dual-issuer**) → principal (40-authentication-and-identity.md)
4. authorize(subject, action, resource) → allow/deny (41-roles-rights-and-authorization.md)
5. rate limit (Redis, per user/tenant)
6. handler → domain service (opens a SHORT, RLS-scoped DB tx only around DB access)
7. audit write (mutations) + NOTIFY (world-state changes)
8. serialize responseDual-issuer JWT verification (step 3). The tenant app accepts two token issuers and maps each to a principal type (41):
- the tenant's own key → a normal tenant user (role + clearance + cell);
- the control-plane JWKS with
purpose:"break_glass"andaud:"tenant:<slug>"→ the operator-in-tenant principal (read-only, no tenant role).
A control-plane-signed token must never satisfy the normal-user path: it has no tenant role, so it can only ever land in the break-glass principal (which the engine restricts to reads). Verify aud matches this exact tenant and jti isn't revoked. The RLS context for a break-glass principal sets app.clearance to the operator's authorized level (often OPEN) and never sets a writable role.
Critical: never hold a Postgres transaction/connection across a Claude stream. A chat turn may take seconds; if the request opened a tx in step 3 and held it for the whole model call, the connection pool exhausts under load. Instead, the RLS context (SET LOCAL app.tenant_id/user_id/role/cell) is set on a short-lived tx scoped to each DB access (retrieval, then the post-stream commit of any proposal), and the connection is released around the model/tool calls. Streaming responses (32) run with no DB connection checked out.
Domain services (modules)
canonical (Source/Observation/Entity/Event CRUD + queries), cases, reports (draft + the privileged publish gate), agents (chat, ontology, adjudicator, after-action — ai/30), rag (retrieval/assembly — ai/31), watches/proposals (autonomy — product §13), sources (onboarding/ingest control), admin (members, roles, settings).
Conventions
- Validation at the boundary (zod/schema); reject unknown fields.
- Parameterized queries only; no string-built SQL.
- Idempotency keys (Redis) on mutating POSTs.
- Pagination cursor-based; AsOf is a query param that caps
ingested_at/observed_at. - Errors: typed, no internal leakage; every authz deny carries a reason code.
Gaps to close
- Pooling decided (D18): PgBouncer, transaction-mode in front of each tenant's Cloud SQL (the per-tenant connection budget is small and the realtime
LISTEN+ ingest compete for it). Transaction-mode requiresSET LOCAL(notSET) for the RLS GUCs — already the canonical form (82) — and forbids session-level features across pooled calls. Verify no connection is held across a model/SSE stream. - Fill out the full endpoint inventory in
81-openapi-endpoint-inventory.mdas domain services land.
Deployment Targets & Portability
Generalising the per-tenant stack from one GCP project to any suitable target, including split and customer-hosted topologies.
Realtime (Postgres LISTEN/NOTIFY)
Multiplayer/live updates without a message broker — one source of truth (Postgres) with minimal moving parts.