Getting Started
README.md
π KongAI Multi-Tenant AI SaaS Platform
\1](.) \1](.) \1](.) \1---
π Table of Contents
1. \1
2. \1
3. \1
4. \1
5. \1
6. \1
7. \1
8. \1
9. \1
10. \1
---
ποΈ Architecture Overview
System Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INTERNET (Users & Tenants) β
ββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Caddy (Reverse Proxy) β
β Automatic HTTPS/SSL β’ HTTP/2 β’ HSTS β
ββββββββββββββββ¬βββββββββββββββββββββββ¬βββββββββββββββββββββββ¬βββββββββββββββββ
β β β
β β β
ββββββββββββΌβββββββββββ ββββββββββΌβββββββββ βββββββββββΌββββββββββ
β Kong Gateway β β Keel API β β KongAI Manager β
β (Tenant Isolation)β β (Django/Next) β β (Bridge Layer) β
β β’ Workspaces β β β’ Auth β β β’ Kong Config β
β β’ Rate Limiting β β β’ Billing β β β’ Dify Isolationβ
β β’ Plugins β β β’ Organizationsβ β β’ Telephony β
ββββββββββββ¬βββββββββββ ββββββββββ¬βββββββββ βββββββββββ¬ββββββββββ
β β β
ββββββββββββ¬βββββββββββ΄ββββββββββββββββββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
β β β
ββββββΌββββββ βββββββΌβββββββ βββββββΌββββββββββ
βPostgreSQLβ β Redis β β Celery β
β pgvector β β Cache β β Workers β
β (Data) β β (Session) β β (Background) β
ββββββββββββ ββββββββββββββ βββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Monitoring & Observability β
β Prometheus (Metrics) ββββΊ Grafana (Dashboards) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Service Components
| Service | Purpose | Port | Technology |
|---------|---------|------|------------|
| \1 | Reverse Proxy, SSL | 80, 443 | Go |
| \1 | API Gateway, Tenant Isolation | 8000-8002 | Lua/Nginx |
| \1 | Main Application | 8000 | Django/Python |
| \1 | SSE/WebSocket | 8001 | Uvicorn/ASGI |
| \1 | Kong Bridge | 8010 | Django/Python |
| \1 | Background Jobs | - | Python |
| \1 | Scheduled Tasks | - | Python |
| \1 | Primary Database | 5432 | pgvector/pg17 |
| \1 | Cache & Queue | 6379 | Redis 7 |
| \1 | Metrics Collection | 9090 | Go |
| \1 | Dashboards | 3000 | Go |
---
β‘ Quick Start
One-Line Deploy (Production)
curl -fsSL https://kongai.com/install.sh | sudo bash
Manual Deploy (Step-by-Step)
1. Clone repository
git clone https://github.com/kongai/kongai.git
cd kongai
2. Initialize infrastructure (one-time setup)
sudo ./scripts/deploy-init.sh
3. Configure environment
cp .env.example .env
nano .env # Edit with your values
4. Deploy all services
sudo ./scripts/deploy-up.sh
5. Verify health
./scripts/deploy-status.sh
6. Access dashboard
open https://grafana.kongai.com
Verify Deployment
Check all services running
./scripts/deploy-status.sh
View live logs
./scripts/deploy-status.sh --logs
Check specific service
./scripts/deploy-status.sh --service postgres
---
π Prerequisites
System Requirements
| Component | Minimum | Recommended | Production |
|-----------|---------|-------------|------------|
| \1 | 4 cores | 8 cores | 16+ cores |
| \1 | 8 GB | 16 GB | 32+ GB |
| \1 | 50 GB | 100 GB | 500+ GB SSD |
| \1 | Ubuntu 22.04 | Ubuntu 22.04 | Ubuntu 22.04 LTS |
Software Dependencies
Required
β’ Docker 20.10+
β’ Docker Compose 2.0+
β’ Bash 5.0+
Optional (auto-installed by deploy-init.sh)
β’ AppArmor
β’ s3cmd (for backups)
β’ GPG (for encryption)
β’ jq (for JSON processing)
Check Prerequisites
Verify Docker
docker --version
docker compose version
Verify system resources
free -h
nproc
df -h /
Verify ports available
sudo ss -tlnp | grep -E ':(80|443|5432|6379)'
---
π Deployment
Deployment Scripts Overview
| Script | Purpose | Runtime | Options |
|--------|---------|---------|----------|
| \1 | Infrastructure setup | ~2 min | None |
| \1 | Start services | ~5 min | \1 skip health checks |
| \1 | Stop services | ~1 min | \1 force stop |
| \1 | Monitor cluster | On-demand | \1 live, \1 json |
| \1 | Backup/restore | ~10 min | \1, \1 |
Deploy Flow
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β deploy-init β βββΊ β deploy-up β βββΊ β deploy-statusβ
β (setup) β β (start) β β (monitor) β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β
βΌ
βββββββββββββββ
β deploy-down β
β (stop) β
βββββββββββββββ
Environment Variables
Create \1 file with required variables:
Application
DJANGO_SECRET_KEY=your-secret-key-change-me
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=kongai.com,api.kongai.com,app.kongai.com
Database
POSTGRES_DB=kongai
POSTGRES_USER=kongai
POSTGRES_PASSWORD=secure-password-here
DATABASE_URL=postgres://kongai:password@postgres:5432/kongai
Redis
REDIS_PASSWORD=secure-redis-password
REDIS_URL=redis://:password@redis:6379/0
Kong
KONG_LICENSE_DATA=your-kong-license
KONG_ADMIN_API_KEY=your-admin-key
Stripe (billing)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Domain
APP_DOMAIN=kongai.com
---
π Monitoring
Access Dashboards
| Service | URL | Credentials |
|---------|-----|-------------|
| \1 | https://grafana.kongai.com | admin / from secrets |
| \1 | https://kong-manager.kongai.com | API key required |
| \1 | https://prometheus.kongai.com | Internal only |
Key Metrics
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β KONGAI CLUSTER STATUS β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β CONTAINER STATUS β
β SERVICE STATUS HEALTH UPTIME β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β postgres Running β
OK 2d 14h 32m β
β redis Running β
OK 2d 14h 32m β
β keel-api Running β
OK 2d 14h 31m β
β celery-worker Running β οΈ Degrad 2d 14h 31m β
β kong Running β
OK 2d 14h 30m β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β RESOURCE USAGE β
β SERVICE CPU MEMORY LIMIT β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β postgres 12% 2.1GB 4.0GB β
β redis 2% 156MB 1.0GB β
β keel-api 45% 1.3GB 2.0GB β
β celery-worker 78% β οΈ 3.2GB 4.0GB β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β METRICS (24h) β
β β’ API Requests: 1,245,678 β
β β’ Avg Latency: 127ms β
β β’ Error Rate: 0.03% β
β β’ Active Tenants: 47 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Monitoring Commands
Live dashboard
./scripts/deploy-status.sh --live
JSON output (for scripting)
./scripts/deploy-status.sh --json | jq '.[] | select(.status=="running")'
View logs
./scripts/deploy-status.sh --logs
Specific service logs
./scripts/deploy-status.sh --logs --service kong
Resource usage
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
---
πΎ Backup & Recovery
Automated Backups
Create backup
sudo ./scripts/deploy-backup.sh
List available backups
sudo ./scripts/deploy-backup.sh --action list
Restore from backup
sudo ./scripts/deploy-backup.sh --action restore --backup 20261024_014532
Dry run (test backup process)
sudo ./scripts/deploy-backup.sh --dry-run
Backup Flow
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β Pre-checks βββββΊβ Backup βββββΊβ Compress & β
β (disk, docker)β β Components β β Encrypt β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β
βΌ
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β Cleanup ββββββ Upload ββββββ Verify β
β (old backups)β β to S3/R2 β β Checksum β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
Backup Components
| Component | Method | Size (typical) | Retention |
|-----------|--------|----------------|------------|
| PostgreSQL | pg_dump (custom format) | 2-5 GB | 30 days |
| Redis | RDB snapshot | 100-500 MB | 7 days |
| Kong Config | TAR archive | <10 MB | 90 days |
| Application Files | TAR (last 7 days) | 100-500 MB | 7 days |
Backup Schedule (Cron)
Add to /etc/crontab
0 2 \1 * root /opt/kongai/scripts/deploy-backup.sh >> /var/log/kongai/backup.log 2>&1
---
π Security
Security Features
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β KONGAI SECURITY LAYERS β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 1: Container Isolation β
β β’ AppArmor profiles (mandatory access control) β
β β’ Seccomp filters (syscall filtering) β
β β’ Dropped capabilities (no CAP_SYS_ADMIN) β
β β’ Read-only root filesystem β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 2: Network Security β
β β’ Kong Gateway (tenant isolation via workspaces) β
β β’ Rate limiting per tenant β
β β’ IP restriction plugins β
β β’ Automatic HTTPS (Caddy) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 3: Data Security β
β β’ Docker secrets (not environment variables) β
β β’ Encrypted backups (GPG AES256) β
β β’ PostgreSQL row-level security (per tenant) β
β β’ Redis password authentication β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 4: Application Security β
β β’ Django security middleware β
β β’ CSRF protection β
β β’ XSS prevention β
β β’ SQL injection protection β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Security Checklist
- [ ] AppArmor profile loaded (
\1) - [ ] Seccomp profile in place (
\1) - [ ] Docker secrets initialized (
\1) - [ ] Firewall configured (UFW/iptables)
- [ ] SSL certificates valid (
\1) - [ ] Backup encryption enabled
- [ ] Resource limits applied
Audit Commands
Check AppArmor status
sudo aa-status
Verify Seccomp profile
ls -la /etc/docker/seccomp-kongai.json
List Docker secrets
docker secret ls
Check container security context
docker inspect kongai-postgres --format '{{json .HostConfig.SecurityOpt}}' | jq
View security logs
tail -f /var/log/kongai/*.log
---
π οΈ Troubleshooting
Common Issues
β PostgreSQL Won't Start
\1β PostgreSQL timeout
Container kongai-postgres exited (1)
\1
1. Check disk space
df -h /var/lib/postgresql/data
2. View logs
docker logs kongai-postgres
3. Check permissions
ls -la /var/lib/postgresql/data
4. Fix permissions (if needed)
sudo chown -R 999:999 /var/lib/postgresql/data
5. Restart
docker compose -f infra/compose.kongai-prod.yml restart postgres
β οΈ High Memory Usage
\1Worker using 90%+ memory
OOMKilled errors in logs
\1
1. Check current usage
./scripts/deploy-status.sh
2. Identify memory-heavy services
docker stats --format "table {{.Name}}\t{{.MemPerc}}"
3. Adjust resource limits (edit compose.kongai-prod.yml)
deploy.resources.limits.memory: 4096M
4. Restart with new limits
./scripts/deploy-down.sh
./scripts/deploy-up.sh
β Kong Gateway 502 Errors
\1502 Bad Gateway from Kong
Upstream connection refused
\1
1. Check upstream services
./scripts/deploy-status.sh --service keel-api
2. View Kong logs
docker logs kongai-kong | tail -100
3. Verify Kong configuration
docker exec kongai-kong kong check /kong/declarative/kong.yml
4. Reload Kong config
docker exec kongai-kong kong reload
β SSL Certificate Issues
\1SSL_ERROR_RX_RECORD_TOO_LONG
NET::ERR_CERT_AUTHORITY_INVALID
\1
1. Check Caddy logs
docker logs kongai-caddy | tail -50
2. Verify DNS records
dig kongai.com
dig app.kongai.com
3. Check Caddyfile
cat infra/Caddyfile
4. Force certificate renewal
docker exec kongai-caddy caddy reload --config /etc/caddy/Caddyfile
Get Help
Collect diagnostic info
./scripts/deploy-status.sh --json > diagnostics.json
View all logs
./scripts/deploy-status.sh --logs > all-logs.txt 2>&1
Check system resources
free -h && nproc && df -h /
---
π API Reference
Base URLs
| Environment | URL |
|-------------|-----|
| Production | https://api.kongai.com |
| Staging | https://api-staging.kongai.com |
| Local | http://localhost:8000 |
Authentication
Get access token
curl -X POST https://api.kongai.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret"}'
Use token in requests
curl https://api.kongai.com/api/v1/organizations \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Key Endpoints
List organizations
GET /api/v1/organizations
Create tenant
POST /api/v1/organizations
{
"name": "acme-corp",
"plan": "enterprise"
}
Get usage stats
GET /api/v1/organizations/{id}/usage
Configure Kong workspace
POST /api/v1/kong/workspaces
{
"organization_id": "uuid",
"workspace_name": "acme-corp"
}
Setup Dify instance
POST /api/v1/dify/instances
{
"organization_id": "uuid",
"instance_name": "acme-dify"
}
Rate Limits
| Plan | Requests/min | Tokens/day |
|------|--------------|------------|
| Free | 60 | 10,000 |
| Pro | 600 | 100,000 |
| Enterprise | 6,000 | Unlimited |
---
π License
Proprietary - All rights reserved.
---
π€ Contributing
See \1 for guidelines.
---
π Support
- \1 https://docs.kongai.com
- \1 https://status.kongai.com
- \1 [email protected]
- \1 https://discord.gg/kongai
---
\1 \1AGENTS.md
AGENTS.md
Keel is a Django 6 + Next.js 15 SaaS template. Full detail lives in
\1 (the seven invariants, request path, type
synchronisation); this file is the short version an
agent needs before writing a line here.
Per-app file shape
Every domain app under \1 follows the same seven files.
\1 is the reference slice β copy its shape, not its
content.
/
βββ models.py # data shape only
βββ services.py # ORM, transactions, side effects β ALL writes
βββ selectors.py # ALL reads
βββ permissions.py # required permission codes (organizations app only β see below)
βββ schemas.py # shape validation at the edge (Ninja Schema / Pydantic)
βββ views.py # THIN: parse, call service, serialize, return
βββ tasks.py # one-line delegations to services
βββ tests/
A view, serializer, Celery task, admin page, or management command that
contains a business rule is a bug, not a style choice.
The seven invariants
| # | Invariant | Enforced by | Verify with |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Domain logic lives in \1/\1, never views/serializers/tasks/admin. Optional pure layer in \1 may never import Django, Celery, or \1. | \1 contract in \1 (\1) β inert until \1 has a file in it | \1 |
| 2 | Authorization is expressed only in \1 (\1 codes + \1/\1). \1 holds only the \1 type and registry β no rules. Services call \1, never reimplement a check. | \1 greps for \1/\1/\1 outside the sanctioned file | \1 |
| 3 | One \1 per service function, opened in the service, never the view. Calls to Stripe go through \1, never inside an open transaction. | Code review β no automated gate | β |
| 4 | Schema changes are Django migrations only β no admin/dashboard/console schema edits. Currently one baseline migration per app. | CI's \1 | \1 |
| 5 | Async work: Tier 1 (fire-and-forget) goes through the \1 shim; Tier 2 (multi-step, resumable) uses Celery directly, as \1 does. | Code review β no automated gate | β |
| 6 | Every Ninja resource declares \1 + \1, or \1 + \1. Cross-org access on a scoped resource returns 404, not 403. | \1 (\1) fails at import if neither is present; \1 walks the router; \1 asserts 404 | \1 |
| 7 | Every mutating service is \1 or \1, names its actor parameter \1 (the decorator reads \1), and returns the row it acted on so the audit row has a target β including deletes. Coverage is gated per directory (\1 in \1), not by one global number. | \1 meta-test fails on a service decorated with neither, and on an \1 service with no \1 parameter (\1 is the documented escape hatch); \1 reads \1 after pytest | \1 |
Generator catalogue
Before hand-writing a slice, check whether \1 already provisions
it (ADR 0004). \1 is the full
reference for flags; this table is only "what exists and which command
provisions it" β for a brief spanning more than one row, run
\1 first rather than working through this table by hand.
| Provisions | Command | Slash command that adds judgement |
| ------------------------------------------ | ------------------------------------------------------- | --------------------------------- |
| Full CRUD vertical slice | \1 | \1 |
| Read-only vertical slice | \1 | \1 |
| One permission code | \1 | \1 |
| A job type (Tier 1 or 2) | \1 | \1 |
| A transactional email | \1 | \1 |
| TypeScript API client regen | \1 | \1 |
| Ship gate: e2e spec + full invariant suite | \1 | run once the feature is finished |
| Third-party OAuth provider | β (no mechanical core to extract) | \1 |
| Multi-generator feature plan | β | \1 |
Everything past the field list, the step list, or the permission code
itself β business rules, bespoke validation, subject-aware guards β is
judgement the generator leaves at a marked insertion point and the slash
command drives you through. A generator's DB-free gates passing is not
the same as the slice being done; run \1 (or
\1, which runs it for you) before considering it
finished.
Other things that will bite you
\1is never hand-edited. It is produced
by \1 (orval, reading
\1, itself produced by \1
from the Ninja and allauth specs). CI's \1 and
\1 jobs both fail on drift β regenerate and commit, don't
patch the output.
- Only one worktree at a time regenerates
\1or the
generated client. Two in flight is an unresolvable merge conflict.
- No migrations outside a phase that declares one.
- \1 The commands
below and the invariant table above describe today's Ninja reality:
resources built on \1's \1 /
\1, \1 instead of DRF serializers, and an
OpenAPI document Ninja generates natively (no drf-spectacular). See
\1 for why the migration happened.
Use Django first
Auth (django-allauth headless), pagination (\1),
caching, email (\1), file storage (\1),
management commands, deploy checks (\1) and
storage abstractions are already solved by Django or by an app in this
repo. Reinventing one of these instead of extending the existing seam
makes the template worse for the next project that instantiates it.
Skills
Vendored under \1, each with its upstream \1 and
\1 (a pinned SHA) in frontmatter and a \1 beside it. They
are edited copies, not mirrors β the keel-specific parts are the point, so
re-vendoring upstream wholesale undoes them.
| Skill | Use it for |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| \1 | Writing or reviewing a migration for the rolling deploy (invariant 4) β configured below |
| \1 | N+1s and unbounded querysets, in Ninja terms (\1 + \1 resolvers) |
| \1 | Task internals once the tier is chosen (invariant 5) |
| \1 | IDOR / tenant-isolation review against invariants 2 and 6 |
| \1 | Adding, composing or fixing \1 components under the \1 gate |
django-safe-migration
- Django version: 6.0
- Deploy strategy: rolling deploy (Railway)
- Runtime guard: none
- Migration command:
\1 - Docs URL: none
Before you open a PR
Run \1. It maps every gate above to the invariant it
covers.
Software-factory profile (\1)
This repo is the seed for the \1 profile of the software factory.
Agents working in a factory worktree use these commands, never the raw ones,
because every command is scoped to the worktree's slot.
| Command | What it does |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| \1 | Fresh checkout/worktree: install JS + Python deps, build email templates and content-collections (run once before anything else) |
| \1 / \1 | Start / destroy this slot's Postgres, Redis, Mailpit, MinIO (\1, project \1) |
| \1 | Fresh DB: down -v, up, build emails, migrate, \1 |
| \1 | API, SSE stream, Celery worker and web on this slot's ports |
| \1 | JSON \1; exit 0 when both answer |
| \1 | ruff + ESLint (including \1) + prettier |
| \1 | mypy strict + tsc |
| \1 | Service-free tests: Vitest + pytest minus \1/\1-marked tests (both auto-applied in \1) |
| \1 | DB-backed pytest + coverage floors |
| \1 | Playwright against this slot (starts \1 if it isn't running) |
| \1 | The e2e specs CI runs (\1, \1); the gate until \1 is fixed |
| \1 | impeccable design detector over app, components and \1 |
\1 (0-9) is set by the factory chair per worktree.
Every port is its default + slot*100 (\1).
The generated \1 and \1 carry a \1 header;
never hand-edit them, and never point a worktree at another slot's services.
Slot 0 matches the historical ports, but a hand-made \1 is never
overwritten. The factory refuses to run until you move it aside.
\1\1 creates a demo org and one verified user per
role (\1 etc., password \1). It is
idempotent and refuses to run without \1.
\1 components and the tokens
in \1. \1 blocks restyling components
through \1, raw palette colours, arbitrary values, inline styles
and unknown classes. Existing violations are baselined in
\1. Never add to that file; fix new code
instead, and delete entries as you clean old code up
(\1). If a design needs
something no variant provides, add the variant in \1.
detector) are Markdown files under \1 with
front matter \1. Evidence goes in
\1.
\1 Every slot uses the same \1 cookie names,
so one browser profile can only be logged into one slot at a time.
Playwright contexts are isolated and unaffected.
CLAUDE.md
@AGENTS.md
dev-setup.md
Local dev: reaching the app
The app shell lives on its own subdomain, split from marketing and auth
on the apex. Locally that means:
app.lvh.me:3000 -> Next.js, the (app) route group (the product itself)
lvh.me:3000 -> Next.js, the (marketing) route group + (auth)
api.lvh.me:8000 -> Django
Copy \1 to \1 at the repo root, and copy the
\1 lines out of it into \1:
cp .env.example .env
grep '^NEXT_PUBLIC' .env.example > apps/web/.env.local
Django reads \1 first and falls back to the repo-root \1,
so either location works; the root is the one \1 sits next to.
Then run \1 as usual β nothing else changes about the dev workflow.
Why \1, not \1
The session cookie has to be shared between the web origin and the API
origin (\1, PRD Β§4 "Auth architecture"). Browsers
special-case \1 and handle a \1 cookie
attribute inconsistently, so \1 talking to \1
would likely authenticate in production while silently failing to
authenticate in dev β the worst failure shape, because it only shows up
on the machine you'd least expect it on.
\1 is a public DNS name whose wildcard resolves to \1 β
\1 and \1 both resolve to \1. No
\1 edit needed.
\1 \1 is a third-party DNS record, so this
setup does not work offline. If you need it to, add to your hosts file:
127.0.0.1 keel.test app.keel.test api.keel.test
and swap every \1 in \1 for the matching \1 host.
Logging in during dev
Sign up / log in on \1 (the apex β that's where \1
lives). A successful login lands you on \1. Visiting the
app host while signed out redirects you back to the apex login with a
\1 that returns you to the app host afterward β this is the
behaviour \1 drives in a real
browser (see \1 for how to run
it β it has no \1 of its own; it drives whatever \1 /
\1 already have running on the \1 topology).architecture.md
Architecture
The seven invariants that hold this template's architecture together β
this file states each plainly, names what enforces it, and then walks the
request path and the layout that follow from them.
This file is the source of truth for the invariants; if code and this
document ever disagree, fix whichever one is wrong.
---
The seven invariants
1. Domain logic lives in \1 and \1
Views, schemas, Celery tasks, Django admin, management commands,
database triggers, and the Next.js client hold no business rules. A view
parses, calls a service, serializes, returns. A Celery task body is a
single call into a service.
Some projects also carry an optional third layer, \1, for pure
rules worth isolating from Django entirely (scoring, pricing, ledger
arithmetic) β no ORM, no HTTP, no Celery. \1 may call
\1; \1 may never call anything back. This repo has not yet
created \1, so the layer is inert rather than absent-by-design:
\1's contract for it activates the moment anything is added
there.
\1 convention plus code review, not a CI gate β this is theone invariant with no automated check today. \1
(\1, \1) is the reference shape every other app
follows; it is a generated render of \1 rather than
hand-maintained (see "Generators" below), so the shape stays
correct by construction for anything provisioned with \1.
2. Authorization is expressed only in \1
A registry of named permission codes plus one function,
\1, returning a
\1 β never a bare bool. A Ninja
resource declares \1; its route function calls
\1 (\1), which resolves the
organisation and runs \1 for each required code before the
handler body executes. Permission logic must not appear in views,
schemas, querysets, templates, or the client β the client's \1
permission list is for deciding what to render, never for enforcement.
The \1 dataclass, the \1 protocol, and the registry object
live in \1 (a dependency-ordering split: \1
cannot import \1), but no permission code, role, or rule
lives there β only the vocabulary. The codes and their guard
implementations live in \1.
\1β greps for\1/
\1 / \1 outside
\1 and fails the build on a hit.
- A CI meta-test walks the guard registry and fails if any entry lacks
both an allow test and a deny test; deny tests must assert the
\1, not just that access was refused.
\1in\1holds
\1, \1, and \1 to
100%.
3. One \1 per service function, opened inside the service
Never in the view. Multi-step operations that touch Stripe mutate local
state inside the transaction, then dispatch the external call via
\1 β nothing calls Stripe from inside an open
transaction. Webhook processing is the mirror image: record the
\1 row first for idempotency, then apply the state change in an
atomic block, so replaying an event is a no-op by construction.
\1 convention and code review, plus a test suite requirementβ PRD Β§4 requires a test that replays every handled Stripe webhook event
type twice (\1). There is no static check that a
transaction boundary sits in the right file; this is the second invariant
without an automated gate.
4. Schema changes happen only through Django migrations
In version control, reviewed, reversible where practical. No dashboard,
console, or admin interface may alter schema β Railway's database UI is
for inspection only. Every table was created once with final columns; a
migration appearing in a feature branch means a real gap or drift.
\1\1 in CI's
\1 job, which fails the build if model state and migrations
disagree, plus \1 under
production settings in the same CI job.
5. Async work runs on Celery, with an explicit two-tier boundary
Tier 1 β fire-and-forget, single-step work (send an email, sync a Stripe
object) β goes through the thin \1 / \1 shim in
\1, which mirrors Django 6's \1 surface
while executing on Celery. Tier 2 β multi-step jobs that commit
intermediate results, resume after a crash, and stream progress β uses
Celery directly (\1) and must not be squeezed into the Tier 1
shim. Four queues, routed by cost profile: \1, \1, \1,
\1, so a backlog in one cannot starve another.
\1 \1 holds \1 to
100%; the Tier 1/Tier 2 split itself is a convention documented in
\1's own module docstring and in \1, not a
static check β a Tier 2 job written against the Tier 1 shim would pass CI.
6. Validation has two mandatory layers
Ninja \1 classes (\1, Pydantic-native) validate shape at
the HTTP edge and reject malformed input with 400. Services enforce
invariants and raise typed domain exceptions (\1's
\1 hierarchy) that map to 409 or 422. Client-side Zod
validation exists for UX responsiveness only and is never enforcement β
the Zod schemas are generated from the same OpenAPI spec as the rest of
the client, so they cannot drift from the server's shape contract.
\1\1's Ninja exception handlers
(registered once on the shared \1 instance, \1),
the single place the \1
envelope is built β a service that raises a plain \1 instead of
a \1 subclass surfaces as an unhandled 500, which is itself
the signal that the boundary was skipped.
7. Testing is layered, and coverage is gated per directory, not by one global number
\1 takes one number for the whole run; per-path floors are
a \1 table in \1, read by
\1 after pytest, which exits non-zero naming
every path that missed (with actual vs. required) and fails on a glob
that matches nothing (a directory renamed or deleted with its obligation
left behind).
Tenant scoping is declared, never inferred β every Ninja resource states
\1 with a \1, or \1 with a \1 paragraph, checked at import time
by \1 on \1 / \1
(\1). A meta-test
(\1) walks the live
URLconf so a scoped resource cannot go unwired, and
\1 builds a row in
organisation A, gives an org-B member every required permission, and
asserts \1 β existence is not disclosed across a tenant
boundary.
\1\1 (coverage floors),
\1 and \1 (tenant
scoping), and the \1 CI job, which re-derives
\1 and fails on drift against the checked-in generated
client.
---
Request path: browser to database
browser
β fetch(..., { credentials: 'include' })
β Cookie: sessionid (+ X-CSRFToken on unsafe methods)
βΌ
Next.js 15 (App Router) β (marketing) (auth) (app) route groups
β cross-origin call to the API host; see "Note on the pipeline" below
βΌ
Django 6 + Ninja β sync, gunicorn
β
ββ SessionMiddleware / CSRF middleware β resolves the session
ββ resolve_and_authorize() β calls has_perm() (invariant 2)
ββ route function β keel//selectors.py (reads)
ββ route function β keel//services.py (writes,
β transaction.atomic() per call)
ββ Ninja Schema (schemas.py) β shape validation, response body
β
βΌ
Postgres 17 Redis 7 (broker + cache)
β
Celery worker / Celery beat
(Tier 1 shim or Tier 2 jobs)
A parallel path exists for the SSE job-status stream
(\1): the same Django code runs under a **separate ASGI
service** on uvicorn, because a held-open connection under the sync
worker pool exhausts it at a request count far below what
request/response load testing suggests. \1 declares
both services from the same image.
\1 Since\1, the browser no longer calls
\1 directly for any programmatic \1/\1 β
every \1 and \1 call is same-origin against Next.js,
which forwards it to Django server-side
(\1,
\1). Session handling
was already correct before this (Django is the sole authority, the
cookie is \1); this closed the remaining gap β the direct
cross-origin call was not the BFF pattern the architecture direction
locks in. \1 has the full request-path diagram.
---
App layout
apps/api/
βββ config/
β βββ settings/{base,dev,prod,test}.py
β βββ urls.py
β βββ celery.py
β βββ asgi.py
βββ keel/
β βββ domain/ # OPTIONAL pure layer β not created in this repo yet
β βββ core/ # base models, mixins, exceptions, pagination,
β β # task shim, org scoping, authz vocabulary
β βββ accounts/ # User, profile, allauth glue
β βββ organizations/ # Org, Membership, Role, Invitation, PERMISSION REGISTRY
β βββ billing/ # Plan, Price, Subscription, entitlements, Stripe webhooks
β βββ audit/ # AuditLog, impersonation
β βββ notifications/ # email dispatch
β βββ files/ # presigned R2/S3 uploads
β βββ jobs/ # Job, JobStep, FailedTask β Tier 2 primitive
β βββ connections/ # third-party OAuth Connection
β βββ widgets/ # demo resource β \1 deletes this
βββ tests/
Every domain app under \1 shares one file shape:
/
βββ models.py # data shape only
βββ services.py # ORM, transactions, side effects β all writes
βββ selectors.py # all reads
βββ permissions.py # required permission codes (organizations app only)
βββ schemas.py # shape validation at the edge (Ninja Schema / Pydantic)
βββ views.py # thin: parse, call service, serialize, return
βββ tasks.py # one-line delegations to services
βββ admin.py
βββ tests/
\1 is the reference implementation of this shape β
it is also the file set that \1 deletes, since it exists to
demonstrate the pattern rather than to ship.
Where business logic lives is not per-app discretion: **writes go through
\1, reads go through \1**, always, per invariant 1.
---
Generators (ADR 0004)
\1 (\1) is the primary way a resource, permission,
job, transactional email, or e2e ship gate gets added β see CLAUDE.md's
generator catalogue for the full command list and the \1
etc. slash commands that drive the judgement work each generator
deliberately leaves at a marked insertion point. \1 holds the
generators' source material as real, lintable Python and TypeScript (no
template-engine syntax); \1 and \1
render it into a new app under \1, and \1 renders
\1 into the matching Next.js route group.
\1 and \1 are
not hand-maintained reference code β they are a committed \1 of
\1 and \1, held in place the same way
\1 is: improve the template, regenerate,
commit the diff. Three CI jobs in \1 make
this an enforced property rather than a convention:
- \1 β ruff-checks and ruff-formats everything under
\1 and fails if any template-engine file extension
(\1, \1, \1, β¦) shows up, proving the templates are real
source the repo's own linter reads.
- \1 β re-runs
\1in place and fails on any diff against what's committed
under \1 and the widgets frontend route.
- \1 β generates a throwaway
resource, readonly-resource, permission, and email into the CI checkout
and runs the full invariant suite (\1,
\1, \1, ruff, mypy,
\1 including the tenant-isolation and permission-guard
meta-tests) against it, so a generator regression is caught before it
reaches a downstream project.
None of this is path-filtered β a change to \1 or the
permission registry shape can break every future generated slice without
touching \1 or \1 at all, so \1 runs
on every push and PR like \1 does.
---
Type synchronisation pipeline
Django Ninja (native OpenAPI, keel/core/api.py) βββΆ OpenAPI (/api/v1/openapi.json)
β
allauth headless βββΆ OpenAPI (/_allauth/openapi.json)
β
βΌ
scripts/merge_openapi.py
(deterministic merge, sort_keys=True)
β
βΌ
openapi.merged.json (repo root)
β
βΌ
packages/api-client (orval.config.ts)
β
βΌ
packages/api-client/src/generated
(never hand-edited)
β
βΌ
apps/web (41 call sites)
CI's \1 job re-runs the merge and regenerates the client, failing
the build on any diff against what is checked in β this is what makes
"the generated client is never stale" an enforced property rather than a
hope.
\1 (\1) replaced
drf-spectacular's half of this diagram with Django Ninja's native OpenAPI
generation. The merge step, \1, and
everything downstream of it survived unchanged β only the left-hand
source of the Django-side spec moved.
---
See also
\1β the request-level picture of signup, login,
session refresh, and CSRF that this document does not repeat.
\1β the deployed-system diagram (hosts, Django,
Postgres, Redis, Celery, Stripe, storage).
\1β the wire-level contract (cookies,
headers, response envelopes, 401/403/409) for allauth headless.
\1β why the
generator CLI exists and what it deliberately leaves to judgement.
storage.md
File storage
Covers the upload lifecycle, the storage adapter seam, provider options,
and the
production caveats β including why Railway's persistent volumes are not
the answer, a question this template will otherwise get asked often
given it deploys to Railway by default (\1).
What this is not
\1 handles _storage_: presigned direct upload, a status
lifecycle, listing, retrieval and (soft) deletion, tenant isolation,
garbage collection. It does not β and, by this project's own stated
boundary, never will β do anything with the
_contents_ of a file: no OCR, no text extraction, no embeddings, no
document understanding. That's a different project's problem (Brein);
this template only guarantees the bytes exist, belong to the right
organisation, and are named honestly (\1, \1, checksum)
by the time a caller downstream tries to do something with them. Virus
scanning, thumbnailing and preview generation are the same story β named
here as extension points (see "Extension points" below), not built.
The upload lifecycle
pending βββββββΊ available (checksum verified, size/content_type/etag
β taken from the storage provider's own
β HeadObject β never the client)
β
ββββββββββββΊ failed (checksum mismatch, or the object that
β actually landed violates the configured
β size/content-type policy)
β
ββββββββββββΊ expired (keel.files.tasks.sweep_stale_uploads:
nobody ever called complete)
{available, failed} βββββββΊ deleted (a tombstone β see below)
Every arrow is a guarded \1
(\1), not an unconditional assignment β two
concurrent completions of the same upload both pass the existence check,
but only one moves the row out of \1; the other's update matches
zero rows and is treated as "someone already decided this", not an
error. The same pattern covers delete.
\1 An earlier model shipped\1 /
\1 only β no way to represent a corrupted upload, an abandoned
one, or a deleted one. Retrofitting states onto a machine that already
has rows in it is real migration work, so this is where that gets
designed in rather than deferred again.
\1 The client declares a\1 at
create time β what it's about to upload. On completion, the server
downloads the object back and hashes it (\1),
comparing against the declaration; a mismatch fails the row rather than
letting it reach \1. This costs a second transfer server-side.
The alternative β S3's native checksum-algorithm PUT header, verified by
the provider itself β would avoid that, but this template's own test
double (moto) doesn't independently verify a client-supplied
\1 header against the bytes actually sent, so a
test asserting rejection would only prove moto trusts the client, not
that corruption is caught. Real S3 does verify it; a project confident
its production provider does too can move this check to PUT time as a
follow-up β see "Extension points" below. For the document sizes this
template targets, the extra transfer is the right trade today.
\1\1 and
\1 (\1) are enforced
twice: against the client's declared values at create time (reject
obviously-bad requests early) and again against the storage provider's
observed \1 values at completion (a client can't declare a
small size to pass the first check and then upload something larger β
ddia review finding 21).
\1\1
moves an \1/\1 row to \1 and stamps \1;
the row stays. The actual storage object is removed out-of-band: a
Tier-1 fire-and-forget task (\1) dispatched on commit,
backstopped by \1 retrying any row
whose \1 flag never flipped (the same gap
\1 covers for
webhooks β a dispatch that never reached the broker at all). Deleting the
row outright was rejected for two reasons: it leaves nothing to know an
object needs cleaning up if the delete request dies mid-flight, and it
would force the delete call to make a synchronous storage round trip
inside the request. A soft-deleted row drops out of \1 but its
detail route still answers β deletion here means "gone", not "never
existed".
\1\1 runs
every 15 minutes (\1) and does two things: expires
\1 rows older than \1 (default 24
hours β an abandoned upload that never got a completion call), and
retries the tombstone-purge for \1 rows that haven't confirmed
their object gone yet. Both halves are idempotent run twice, the same
discipline every scheduled job in this codebase is held to
(\1).
\1 inherits \1 from \1 β right for
pure metadata rows. \1 overrides it: a hard-deleted
\1 row must never silently drop upload rows and orphan their
storage objects with no tombstone left to drive cleanup from. This
app's own delete path never triggers it (organisation deletion in this
codebase is itself a soft delete β
\1), but \1 means any
future hard-delete path (an admin action, a GDPR-erasure command) is
forced to decide what happens to an organisation's files on purpose,
rather than finding out from an orphaned bucket months later.
The storage adapter seam
\1 defines \1, a small protocol
(\1, \1,
\1, \1, \1, \1) that
\1 and \1 call through exclusively β neither imports
boto3 or a filesystem path directly. Which implementation is active is
Django's own \1 setting (\1):
STORAGES = {
"default": {...}, # Django's own default β unused by this app
"staticfiles": {...},
"files": {
"BACKEND": KEEL_FILES_STORAGE_BACKEND, # <- the switch
"OPTIONS": {
"endpoint_url": R2_ENDPOINT_URL,
"access_key_id": R2_ACCESS_KEY_ID,
"secret_access_key": R2_SECRET_ACCESS_KEY,
"bucket": R2_BUCKET,
},
},
}
Two implementations ship:
- \1 β boto3 against any
S3-compatible endpoint. This is the default, and covers MinIO (dev),
R2 (production), and moto (tests) unchanged β all three speak the same
API, so only \1 (sourced from the \1 env vars) differs
between them.
- \1 β plain disk under
\1, for a developer who doesn't want to run the MinIO
container. There is no S3-shaped "presign a PUT" primitive for a local
disk, so its upload/download URLs are ordinary, session-authenticated
app routes (\1/\1) rather than
bucket URLs β see that class's docstring. \1's
constructor accepts and ignores the S3-shaped \1 keys
(\1, \1, ...), which is what makes flipping
\1 alone β no \1 edit β enough to
switch.
\1\1 runs the same upload -> complete
-> download -> delete flow \1 runs against moto, instead
against \1, with nothing touched but
\1 β the acceptance criterion this doc
names, demonstrated as a test rather than asserted in prose.
Local disk, no MinIO container required:
KEEL_FILES_STORAGE_BACKEND=keel.files.storage.LocalFileSystemStorage
MinIO / R2 / real S3 (the default):
KEEL_FILES_STORAGE_BACKEND=keel.files.storage.S3CompatibleStorage
Provider options
| Provider | Backend | Notes |
| -------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| \1 | \1 | Solo dev only. Single-process, no presigned bucket URLs, not multi-instance-safe. Never use in production. |
| \1 | \1 | Dev/CI parity with a real S3-shaped API β \1's \1 service. Real presigned-URL behaviour without a cloud account. |
| \1 | \1 | This template's production default (PRD Β§5). S3-API-compatible, no egress fees, works unchanged with the same adapter used for MinIO. |
| \1 | \1 | Drop-in β set \1 to AWS's regional endpoint (or omit it for the default endpoint resolution) and the real credentials. Nothing else changes. |
| \1 | \1 | Supabase Storage exposes an S3-compatible API (a separate endpoint and its own access-key pair from a project's Postgres credentials) β set \1 to the project's S3 endpoint. Not verified against a live Supabase project; the adapter makes no R2-specific assumption, but treat this row as "should work," not "tested." |
Anything else genuinely S3-compatible works the same way β the seam's
entire point is that a new provider is an \1 change, never a code
change.
What Railway volumes are β and are not β good for
Railway offers persistent volumes: durable disk attached to a service.
They are real and useful for some things (a self-hosted Postgres data
directory, a local cache) β but they are the wrong tool for this app's
file storage, for reasons worth being explicit about since "just mount a
volume" is the obvious-looking shortcut:
- **A volume is attached to one service instance, not shared across
replicas.** The moment this API runs more than one instance (which
Railway's own autoscaling can do), a file uploaded through instance A
and later requested through instance B simply isn't there. Object
storage (R2/S3/MinIO) has no such boundary β every instance talks to
the same bucket over the network.
- \1 The
entire point of this app's upload flow (PRD Β§5) is that the browser
PUTs bytes straight to the storage provider, never through Django. A
volume has no HTTP surface of its own to presign a URL against β
making it work would mean routing every upload's bytes through the
Django process, which is both slower and a memory/bandwidth cost this
design deliberately avoids.
- **Volumes don't survive every kind of deploy the same way object
storage does.** Object storage is external to the compute layer by
construction; a volume's lifecycle is coupled to the service it's
attached to.
\1 exists for the _developer's own laptop_, not
for a Railway deployment β see the provider table above. Production
always means \1 against R2, S3, or an equivalent.
Extension points
Named here deliberately, because a project instantiating this template
will want at least one of them and the seam should make it a subclass,
not a rewrite:
- \1 A natural hook is between
\1's
checksum verification and the guarded transition to \1 β a
project could add a \1 state between them, call an external
scanner, and only reach \1 on a clean result. \1
already exists as the rejection path.
- \1 Fits as a Tier-1 task
(\1) dispatched from the same \1
hook \1 already uses for object-purge β enqueue on
the transition to \1 instead.
- \1 If a project's production
provider is confirmed to enforce \1 at PUT time
(real S3 does; verify for R2/MinIO/Supabase Storage's specific
version), \1's \1 round trip can be
replaced with reading the provider's own reported checksum via
\1 β no second transfer.
- \1 Implement
\1
(\1) β six methods β and point
\1 at it. No other file in this app changes.
Permissions
\1 gates list/retrieve/download-URL; \1 gates
create/complete/delete β the same two-code split \1 uses, chosen
over inventing \1/\1 because delete is exactly
the kind of destructive-but-still-managing-the-resource action the
existing \1 convention already covers everywhere else in this
codebase, and a third code here would be the only resource in the
project not to follow that pattern. Every route calls
\1, and \1 is walked by the
tenant-isolation meta-test (PRD Β§4 invariant 7) β a cross-organisation
lookup 404s, never 403s.
billing.md
Billing
How a product built on Keel plugs into \1: what a credit
means, how to add a plan, how to meter an operation, how to gate a
feature on an entitlement. See \1 for the
design rationale; this file is the how-to a Keel consumer actually needs.
---
What already exists, generic by construction
\1 ships plans, prices, Stripe checkout/portal, webhook
handling, seat sync, and an optional credit ledger. None of it names a
product. \1 is a JSON blob a project fills in; the
ledger doesn't know what a credit buys; \1 and
\1 are both off by default so a project that doesn't
need one of these mechanisms never pays for it.
An audit of this module found nothing product-specific left in the
diff β no hard-coded plan tiers, no assumption about what a credit is
for, no organisation-vs-user ambiguity. What it _did_ find were gaps
that made the generic machinery unsafe under concurrency and Stripe's
actual delivery guarantees; those are fixed here (see "Hardening" below)
rather
than left as sharp edges a consuming project would hit first.
Ownership: credits belong to the organisation, never the user
Every ledger write takes an \1, never a \1 β
\1 and \1 are the
only FKs that matter for "whose credits are these." \1 records _who
performed the write_ (for the audit trail), not who owns the balance.
This holds under impersonation too: \1 and
\1 both call \1 before
touching Stripe, so an impersonating operator cannot start, change, or
cancel a subscription while impersonating β the one write path where
"acting as the user" and "acting as the organisation" could be confused
is closed off entirely rather than trusted to resolve correctly.
Defining what a credit means
Nothing in \1 decides what a credit buys. A project defines
that by choosing what calls \1/\1 and for how much:
keel/rendering/services.py
from keel.billing import credits
def start_render(*, organization, job, actor):
estimated_cost = 15 # whatever your product's pricing model says
credits.hold(organization, estimated_cost, job=job, actor=actor)
...
A two-phase job (reserve, then settle) uses \1 at creation and
\1/\1 at completion β see \1's
\1 for the reference shape. A cost known synchronously
with nothing to reserve uses \1 directly. Both raise
\1 (402) when the balance (or the daily cap, if the plan
sets one) can't cover the amount β that's the enforcement point; there
is no separate "can they afford this" check to remember to call first.
Adding a plan
Plans and prices are normally seeded from Stripe
(\1, scheduled nightly by beat) β set a
\1 metadata key on the Stripe Product, and \1/\1 sync
automatically. \1 is not synced from Stripe; set it
directly (Django admin, a data migration, or a management command in
your project):
Plan.objects.filter(code="pro").update(
entitlements={
"features": ["api_access", "custom_roles"],
"limits": {"seats": 25, "widgets": None}, # None = not capped
"daily_credit_cap": 500, # only meaningful with BILLING_CREDITS on
}
)
The shape is validated on save (\1):
an unknown top-level key or a non-integer limit value raises
\1 immediately, in the admin or from a script, rather than
silently doing nothing the first time something reads it.
Which subscription statuses grant entitlements
\1 β \1, \1, \1 β
is the whole answer, and resolution consults it before reading the
plan. The presence of a \1 row proves nothing:
\1 deliberately keeps the row (with its
plan and price) so a resubscribe reuses it, so a cancelled organisation
still has one. \1, \1, \1 and
\1 resolve to the same empty \1 an
organisation with no subscription at all gets.
\1 is entitling on purpose: dunning is a warning, not a lockout
(\1 Β§B.6 β "access is not immediately revoked").
That is what makes the webhook status transitions load-bearing rather
than cosmetic β see Hardening below.
Gating a feature
from keel.billing.entitlements import requires_entitlement
@requires_entitlement("api_access")
def call_external_api(*, organization, ...):
...
\1 must be a keyword argument β every service in this
codebase is keyword-only, and the decorator raises \1 rather
than silently skipping the check if it isn't. On the client,
\1 returns each organisation's resolved
\1 list; the client renders from that, but the
decorator above is the actual enforcement point (PRD Β§7: "The client
renders from the permission and entitlement lists in \1. It is never
the enforcement point.").
Metering a quantity
from keel.billing.entitlements import check_limit
def create_widget(*, organization, ...):
check_limit(organization, "widgets")
...
\1 needs a usage counter registered for the resource name β
register one from your app's \1, the same way
\1 and \1 do:
keel/widgets/apps.py
def ready(self):
from keel.billing.entitlements import register_resource_counter
from keel.widgets.models import Widget
register_resource_counter(
"widgets",
lambda organization: Widget.objects.filter(organization=organization).count(),
)
Three distinguishable outcomes, not two: a plan's \1 dict can
explicitly set a resource to \1 (deliberately uncapped β allowed),
omit it entirely (also uncapped, on the same "not every plan restricts
every resource" reasoning), or the \1 argument passed to
\1 can simply not be a registered counter β a typo, or a
resource nobody's wired a counter for yet. Only the third case is a bug,
and it raises \1 (a config error) regardless of what
the plan's \1 dict says, rather than being indistinguishable from
"not capped" the way it used to be.
Enabling credits
\1 by default. The models and \1's
arithmetic exist regardless of the flag; enabling credits is a settings
change, not a migration. With the flag off, \1 404s β endpoint _absent_, not an endpoint
returning a zero balance β tested both ways in
\1.
Enabling seat pricing
\1 by default, for the same reason: a
single-tenant-per-user project has no seats to price, and seat sync
running on every membership write for such a project is code that does
nothing useful and can still fail. Turn it on for any project with an
invite flow that matters β see \1 for the design
rationale.
---
Hardening
The audit's other finding: the generic machinery above was correct in
the average case and unsafe at the edges Stripe and concurrent access
actually exercise. Fixed here, all covered by tests:
- \1 β
\1
on \1 serialises every debit for an organisation through
one lock (\1 proves it with real
threads, not a simulation).
- **The ledger can no longer represent an impossible state at the
database level** β \1 has \1;
\1 has a \1 tying \1 to \1's sign
(a \1 can never be positive, a \1 can never be negative); both
FKs off \1 are \1, so hard-deleting an
organisation or a job can no longer silently erase or orphan the
append-only record (\1).
\1 was updated to match: a clawback can no longer take
the balance below zero (it raises \1 instead of hitting
the constraint as a bare \1).
- \1 β
\1(and the nightly
\1 beat task that wraps it) compares the
ledger against the summary row and reports drift via Sentry without
writing anything. Repair stays \1 with no
\1 flag, run by an operator on purpose.
- \1 β
neither ordered nor at-most-once. \1
is an LWW version guard: a write only applies when its event is newer
than the one that wrote the row last, so a late-delivered
\1 can't resurrect a subscription a
newer \1 already cancelled.
\1 now only transitions \1 β \1, never
clobbers \1/\1/\1 the way an unconditional
update used to. \1 is narrowed the same way β
it only starts dunning from \1/\1/\1, so a late
or replayed failure after cancellation can't flip \1 back to
an entitling status β and both invoice handlers now run the same
\1 LWW guard as the subscription handler instead of
accepting \1 and ignoring it
(\1).
- \1 β a weekly beat task
nulls the payload (keeping the id/type/timestamps that make it a
usable dedup key) on processed events past a 30-day retention window.
Unprocessed rows are never touched β they may still need replaying.
- \1 β both
are Reference Data Holders (unauthenticated-safe, long-lived,
read on every pricing-page/\1 load); both now carry
\1 and an \1.
- **
\1and the per-organisation entitlements blob on
\1 are typed**, not \1 β \1 in
\1, reused by \1's
\1 β so the generated TypeScript client can
type-check a feature gate instead of casting an opaque map.
What was deliberately left as-is
- **
\1lives at the top level of\1,
sibling to \1/\1, not nested under either.** It's a
different kind of thing (a rate over time, not a feature flag or a
point-in-time count) and \1 reads it directly. Kept
separate rather than folded into \1 so a plan with credits
disabled can omit it entirely β \1 treats it as
optional for exactly that reason.
- \1 A user
can be deleted (account closure, GDPR) without that being blocked by
every ledger row they ever acted on β unlike \1/\1,
losing the actor reference doesn't corrupt the append-only record's
meaning, it just anonymises who performed a historical write.
deployment.md
auth-flow.md
Auth flow
The request-level picture behind \1, which has
the wire-level mechanics (exact headers, cookie attributes, endpoint
list). This document is the diagram; that document is the reference.
All identity endpoints are under \1 (the allauth
session authority: there is no token stored in \1 or
\1 anywhere in \1.
---
Signup
sequenceDiagram
participant B as Browser
participant N as Next.js
participant D as Django (allauth headless)
B->>N: load app.lvh.me
N->>D: GET /_allauth/browser/v1/auth/session
D-->>N: 401, sets csrftoken cookie
N-->>B: render signup form
B->>D: POST /_allauth/browser/v1/auth/signup
{ email, password }
X-CSRFToken header
D-->>B: 200, sets sessionid cookie
(or 401 with pending "verify_email" flow)
Note over D: Confirmation email sent via Resend,
links to HEADLESS_FRONTEND_URLS
B->>D: (later) GET /verify-email/[key] loads a Next.js route,
which POSTs { key } to
/_allauth/browser/v1/auth/email/verify
D-->>B: 200, session now fully authenticated
Confirm-password is a client-side UX concern only β allauth's wire shape
takes one \1 field, never two.
Login
sequenceDiagram
participant B as Browser
participant D as Django (allauth headless)
B->>D: GET /_allauth/browser/v1/auth/session
D-->>B: 401 (or 200 if already authenticated), sets csrftoken
B->>D: POST /_allauth/browser/v1/auth/login
{ email, password }, X-CSRFToken header
alt MFA not enabled, or already verified
D-->>B: 200, sets sessionid cookie
else MFA pending
D-->>B: 401, data.flows contains
{ id: "mfa_authenticate", is_pending: true }
B->>D: POST /_allauth/browser/v1/auth/2fa/authenticate
{ code }
D-->>B: 200, sets sessionid cookie
end
The client distinguishes "no session" from "awaiting TOTP" by inspecting
\1 on the 401 body, not by status code β \1
is \1 in both cases.
Google OAuth (social login)
sequenceDiagram
participant B as Browser
participant D as Django (allauth headless)
participant G as Google
Note over B: full-page navigation, not fetch()
B->>D: GET /_allauth/browser/v1/auth/provider/redirect
?provider=google&process=login&callback_url=...
D->>G: 302 redirect
G-->>B: user consents
G->>D: 302 redirect back with auth code
D-->>B: 302 to callback_url, sessionid cookie set
B->>D: GET /_allauth/browser/v1/auth/session
D-->>B: 200, authenticated state
Session refresh / page load
sequenceDiagram
participant B as Browser
participant D as Django
B->>D: GET /_allauth/browser/v1/auth/session
Cookie: sessionid (if present)
alt valid session
D-->>B: 200, meta.is_authenticated = true
else expired / no session
D-->>B: 401, meta.is_authenticated = false
end
Every app-shell page load calls this once to resolve auth state β there is
no client-side session cache that outlives the cookie itself.
The Next.js BFF proxy
Every \1 and \1 call the browser makes is same-origin
against Next.js, which forwards it to Django itself β the browser never
holds Django's address. \1 is the decision
record; this is the request shape it produced.
sequenceDiagram
participant B as Browser
participant N as app.lvh.me (Next.js β same origin as B)
participant D as api.lvh.me (Django, server-only)
B->>N: fetch('/_allauth/browser/v1/auth/login', {credentials:'include'})
Cookie: sessionid, csrftoken (same-origin, Domain=.lvh.me)
Note over N: middleware.ts rewrites /_allauth/* to
/api/internal/allauth/[...path] (route.ts)
N->>D: fetch(same body/headers, server-to-server)
KEEL_API_INTERNAL_URL
D-->>N: 200 + Set-Cookie: sessionid, csrftoken
N-->>B: 200 + Set-Cookie relayed unchanged
(each Set-Cookie header re-added individually)
\1 calls go through the sibling
\1 handler directly (no rewrite
needed β that path has no leading-underscore folder-naming problem).
Both handlers share one proxy function, \1:
- Request bodies are buffered (
\1), not streamed β Django's
dev server doesn't decode a chunked-transfer-encoded request body, and
every body this proxy ever forwards is a small JSON/form payload
anyway (presigned uploads PUT straight to storage, bypassing this
proxy entirely).
- Response bodies stream through unbuffered by default β this is what
lets \1 (SSE) work: the \1 route
detects that one path shape and forwards it to the dedicated stream
service (\1) instead of the sync API
origin, piping the \1 response through as it arrives.
\1responses get one more step: a non-2xx JSON body is
re-emitted as Keel's own \1 envelope
(reusing \1's \1) before
reaching the browser β see "Response envelope" below.
\1: Google's ownOAuth callback (\1), because that URL is
registered with Google and isn't under \1 to begin with. The
form POST that _starts_ that flow (\1) is proxied like
everything else β the BFF relays Django's 302 to Google untouched.
Cross-host cookie behaviour
sequenceDiagram
participant B as Browser
participant N as app.lvh.me (Next.js)
participant D as api.lvh.me (Django, via the BFF proxy above)
B->>N: GET app.lvh.me
N-->>B: HTML (no cookies from N directly β set via the BFF's own Set-Cookie relay on API calls)
B->>N: fetch('/api/v1/β¦', credentials: 'include')
Cookie: sessionid (Domain=.lvh.me)
N->>D: forwarded server-to-server
D-->>N: 200 + response body
N-->>B: 200 + response body (same-origin β no CORS involved on this hop at all)
\1 and \1 are both \1-scoped to the registrable
parent domain (\1 in production, \1 in dev; host-only,
i.e. unset \1, on plain \1, which is why the project uses
\1 instead β see \1 "Why \1 and not \1").
This is what lets one session cookie authenticate both the marketing/app
Next.js host and the app host β a browser<->Next.js concern the BFF
proxy above doesn't change. \1 is empty by default
(\1): the browser<->Django
hop shown here no longer exists for a programmatic call β only the BFF's
own server-to-server request does, which isn't subject to CORS at all.
CSRF acquisition
Django's CSRF middleware only sets the \1 cookie on a response
that calls \1, and every allauth headless \1-client
view does so:
sequenceDiagram
participant B as Browser
participant D as Django
B->>D: GET /_allauth/browser/v1/auth/session (any browser-client GET works)
D-->>B: sets csrftoken cookie (not HttpOnly)
B->>D: POST/PATCH/DELETE to /_allauth/browser/v1/... or /api/v1/...
X-CSRFToken:
D-->>B: 200 (or a domain error)
\1 requests never need the header. The same \1 cookie and
header cover both \1 and \1 β Django's
default CSRF cookie/header names, unchanged.
401 vs 403
Both API surfaces distinguish "not authenticated" from "authenticated but
disallowed," but the two do not share a response envelope β see
\1 "Response envelope" for the two shapes.
| Status | \1 meaning | \1 meaning |
| ------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| \1 | No session, or a partially-authenticated session (e.g. TOTP pending β check \1, not the status code alone) | No session, or an expired one |
| \1 | Action disallowed for a reason that isn't "log in" (signup closed, social re-auth required) | Authenticated but denied by \1 β \1 in the error envelope is \1 (\1 invariant 2) |
| \1 | Conflict with current state (already authenticated, email link reused) β not guaranteed to carry an \1 array | Conflict from a \1 subclass mapped to 409 |
The rule that matters for \1: **403 always carries a machine
readable \1**, because \1 returns a \1, never a
bare bool. A 403 with no \1 in its \1 body means something raised
\1 directly instead of going through \1 β that is
a bug against invariant 2, not an expected shape.
---
See also
\1β cookies, headers, exact endpoint list,
response envelopes, MFA flag behaviour.
\1β invariant 2 (authorization), the request path,
and the BFF gap this document's cross-host diagram points at.
auth-client.md
boundary-guardrails.md
Rendering, validation and async boundaries
Companion to docs/query-patterns.md β this file covers the other two
legs: unsafe rendering, the validation boundary, and the async boundary.
Unsafe rendering
\1 one\1
(\1), correctly justified in a comment, but
nothing stopped a second one. Zero \1/\1/\1 in
\1 β bandit already catches the first two in \1 files
(\1, \1), but not in templates, and
nothing caught \1 at all.
\1adds a\1rule
banning the \1 JSX attribute across every
workspace that extends this config (today, only \1 β see
"Lint reach" below). No new dependency (\1's
\1 was the alternative, but isn't installed).
\1greps\1for
\1, \1 and template \1, in both \1 and
\1 files β the one gate that covers templates, and redundant with
bandit on the Python half by design. Wired into CI's \1 job
alongside \1. Same shape as that script:
test/migration directories exempt, no unit test file of its own (CI
running it against the real tree, currently clean, is the standing
proof).
\1, both sides β a same-line comment, not a configexemption list, so the justification travels with the code:
- TS/TSX: standard
\1.\1now carries one. - Python/templates:
\1(or
\1 in a template), read by
\1.
\1 \1
lints two in-memory fixtures against the real \1 config β an
unjustified \1 fails, the same line with the
escape-hatch comment passes. \1 also passes clean
over the real tree, confirming \1's existing use survives.
renders user- or externally-supplied HTML or Markdown β the only Markdown
is the blog (\1), compiled at **build
time** from \1 files in the repo, not user input.
Documenting that no such path exists and that the rule is preventative:
the two guardrails above are the deliverable; a sanitizer wrapper would
have no caller.
\1 only\1 has an ESLint config and a \1
script; \1, \1 and \1 have
neither, so the new rule only actually executes over \1 today
even though it lives in the shared \1. Not a regression β that
was already true of every other rule in \1 β but worth knowing if
one of those packages ever grows a \1.
Validation boundary
\1 every\1 across \1 for raw
\1/\1/\1 of a request body bypassing a
schema, and for domain rules expressed in a serializer rather than a
service.
\1 every route takes a typed Ninja\1 payload β there
is no raw, unvalidated payload access anywhere. The two \1/
\1 reads that do exist
(\1 Stripe signature verification over raw
bytes; \1 an ETag cache key from
\1) are both legitimate β neither is payload
validation being bypassed.
\11. \1 β \1
(\1) ran \1
inside a Pydantic \1: an ORM read outside
\1 (invariant 1), a domain invariant (uniqueness) enforced
in the shape layer, and a check-then-act race outside
\1's transaction β two requests could both pass the
validator for the same slug before either inserted. The validator is
removed; \1's own \1 DB constraint is now the single
source of truth β \1 (\1) catches the
resulting \1 and raises \1.
Covered by \1
(\1).
2. \1 β \1/\1
(\1) still run an ORM read (\1, looped) to auto-generate a free slug when
none is supplied. This is real domain logic (collision-avoiding slug
generation), not shape validation, and belongs in
\1/\1, not \1. Left in place with
this note; a follow-up should add a selector for the existence check
and move slug derivation into \1 itself.
3. \1 β \1 (\1)
duplicates the pending/accepted/revoked/expired check that
\1 (\1) already re-checks inside its own
\1 transaction. Not a bug β the service's copy is the
one that matters for correctness, and the view's copy is also the only
way \1 (which never calls a service) can reject an
invalid token. Two sources of the same predicate is still a
maintenance hazard if they're ever edited independently; not fixed
here for the same reason as (2) β it belongs in \1, not
this pass.
4. \1 β the email-match check
(\1,
\1) is enforced \1 in the view. Unlike (3), nothing
inside \1's transaction re-checks it β a future
caller of \1 that isn't this view (an admin action, a
different API surface) would silently skip the email match. This is
the one finding here worth prioritising in a follow-up that can touch
\1.
Async boundary
\1\1 (\1) is the only 202 in
the API β \1, \1 on a
fresh job, the \1 alternative on an idempotent replay. Confirmed by
existing \1 coverage; nothing else in the API does
long-running work synchronously enough to need one.
\1 already enforced for Tier-1\1-shim functions by \1
(\1) β but that check only ever walked modules
named \1 for \1-shim instances, so it never covered Tier 2's
raw \1 functions
(\1, \1,
\1, \1,
\1, and \1/\1 in
\1, which isn't even named \1). Added
\1/\1 to
\1 (walks every non-test, non-migration module under
\1, not just \1, since Tier 2 tasks aren't confined to that
filename) and a new meta-test,
\1,
which now runs this check against every real Tier-2 task in the project β
all of which pass. \1 is
correctly excluded: it's a plain function \1 (which
does take an id) calls after its own lookup, not a \1 itself.
- Tier-1 shim (
\1): dead-letters after\1
with jittered backoff, writes a \1 row \1 reports to
Sentry (\1), tested in
\1.
- \1 β
\1's dead-letter path (\1) wrote a\1row but never called
\1 β an asymmetry with the Tier-1 shim that meant a
permanently-stuck job dead-lettered silently outside the admin. Now
calls the same \1 the shim uses.
Covered by
\1,
mirroring the shim's own Sentry test.
\1sets\1and reports to Sentry
on final failure (\1) β already correct.
\1+\1's\1action
(\1) gives every dead-lettered task, Tier 1 or 2, a manual
re-drive path.
- Beat sweepers backstop the "lost message" gaps:
\1
(a worker killed after the broker's ack, not just before β Celery's
\1, confirmed in
\1, only covers the "before ack" case),
\1, \1,
\1 (reports drift to Sentry; doesn't repair,
by design β see its \1 reason).
- Frontend surfaces job failure directly:
\1 renders
\1 with \1 when \1.
\1 \1 in test settings means a naive test suite would never exercise
at-least-once delivery β already addressed, not a gap found here:
\1 calls \1,
\1, \1 and \1 twice each and
asserts identical ledger/job state; \1 does the same
for \1; \1 does the
same for \1. This is the existing, already-followed
convention this repo uses in place of a real broker in tests.
\1- \1
\1overrides allauth's synchronous send
point, so signup email-verification and password-reset **block the
HTTP request** on a Resend HTTP call with a 10s timeout
(\1's \1,
\1). This is allauth's synchronous
adapter contract, not a bug introduced anywhere in this repo, and
deferring it would mean either overriding more of allauth's flow than
this doc's scope covers or accepting weaker signup-flow feedback (no
immediate "email sent" confirmation) β a real tradeoff, not a drop-in
fix. Flagged for a deliberate follow-up decision rather than fixed
here.
\1/\1are defined but never
called from production code (only tests) β no request-path email send
there today.
- The two beat-task email callers (
\1,
\1) already run on a worker β fine as-is.
\1makes a synchronous R2 HTTP
call (\1) in the request path β defensible
(end-to-end verification that the uploaded object actually exists
before marking the row complete), and named here rather than silently
accepted.
- Stripe checkout/portal session creation is synchronous by necessity β
the response _is_ the redirect URL Stripe returns.
\1correctly defers the R2 object purge via
\1, with \1 as the
backstop for a dispatch that never landed.
\1 a per-organisation\1 counter was
added to every SSE event (\1), documented as letting
a reconnecting client detect a gap and refetch
\1 to resync. The client half was never built β
\1's hand-written event types have no \1
field, and \1 has no gap detection; it only distinguishes
\1/\1/\1 connection states. The server pays for
the counter today and nothing reads it. This is a resync-correctness gap
on reconnect, not a request-path blocking or failure-visibility issue, so
it's out of this slice's stated scope ("confirm 202s, task IDs, failure
visibility") β flagged here so it isn't lost, not fixed.
SECURITY.md
Security policy
Supported versions
Keel does not yet cut releases β \1 has one \1
section and the template is instantiated from \1. Until v1.0 ships
(Phase 18), only \1 is supported: fixes land there, not on a matrix of
version branches.
Reporting a vulnerability
Please use GitHub's \1
on this repository (Security tab β \1) rather than a
public issue. That opens a private advisory only the maintainer can see
until a fix is ready.
Please include:
- What you found and where (file, endpoint, or flow)
- Steps to reproduce, or a minimal proof of concept
- What you think the impact is (data exposure, privilege escalation, etc.)
Response time
This is a solo-maintained project. There is no SLA, and "24 hours" would be
a promise this project cannot keep β \1 is the honest answer.
In practice: an acknowledgement within a few days, and a fix or mitigation
plan once the report is understood. If you have not heard back after a week,
a follow-up comment on the advisory is welcome.
Scope
This policy covers the code in this repository. Keel integrates with
third-party services (Stripe, Sentry, PostHog, R2/S3-compatible storage,
OAuth providers) β vulnerabilities in those services belong to their own
security teams, not this repository.
deploy-railway.md
Deploying Keel to Railway
Status: \1 Django and Next.js both run on Railway,
serving over HTTPS, against Railway-managed Postgres and Redis, with
migrations applied by \1 and the Next.js BFF reaching
Django over the private network. Everything below is written from that
deploy. Where something has _not_ been exercised it says so in those
words β the previous version of this document was written from Railway's
documentation without an account, and most of what it asserted turned out
to be wrong in ways that each failed a deploy.
Verified live at the time of writing:
| Check | Result |
| ------------------------------------------------------------- | ----------------------------------------------------------- |
| \1 builds on Railway | 115MB image, \1 141 files / 421 post-processed |
| \1 migrations | every app applied cleanly, idempotent on redeploy |
| \1 | \1 |
| Django admin, \1, \1 | \1 |
| WhiteNoise hashed asset (\1) | \1 |
| Next.js app on Railway | \1, standalone server, non-root |
| BFF to Django over \1 | \1 JSON |
| \1 on Railway's managed Postgres | \1 succeeds, \1 |
| HTTPS redirect on, healthcheck still passing | both, via \1 |
Not yet exercised: \1, \1 and \1 as deployed services
(see "Service count" below), Celery processing a real job, SSE through
Railway's edge, a deliberately-broken migration, and Neon as the
database.
Configuration: \1, not \1
**Railway's Config as Code (\1 / \1) is
deprecated. New services cannot opt into it at all, and existing files
stop being read on 2026-12-01.** The replacement is Infrastructure as
Code: one \1 per project, applied with the CLI.
railway login && railway link
railway config plan # preview
railway config apply # apply after confirmation
The repo previously carried \1 with a top-level
\1 map. That shape was never a schema Railway read β
Config as Code was per-_service_, with \1/\1 at the top level
and no way to describe four services at once. The four services it
described could not have been provisioned from it. It has been deleted.
Things worth knowing about the IaC DSL, all found by using it:
\1/\1are only read under
\1. As top-level service keys they are silently dropped β the
plan showed \1.
\1defaults the branch to\1and does \1 read the
repository's actual default branch. This repo's is \1. Pass
\1 explicitly.
\1\1 and prints
nothing about it. The failure is only visible with \1, under
\1 / \1. Anything running this
in CI must check that, not the exit code.
- An apply is atomic: if any one resource exceeds a plan limit, the whole
change set fails and _nothing_ is created. Creating services one at a
time gets further on a constrained plan.
- A failed apply can still leave service rows behind that exist at
project level with no environment instance. \1
cannot see them ("not found in environment"), IaC does not track them
(\1), and they still count against the plan's service
limit. Deleting them needs the dashboard, because the API path requires
2FA that an API token cannot satisfy.
Service count β the topology does not fit on Hobby
Keel is seven Railway resources: \1, \1, \1, \1,
\1, Postgres, Redis.
\1 Not the Trial β Hobby. Confirmed
against the live plan limits (\1). The error Railway
returns says "Free plan resource provision limit exceeded" regardless of
which plan the account is actually on, which makes it look like a Trial
problem when it is not. Pro is required for the full topology.
The deploy this document describes ran \1, \1, Postgres and Redis β
four of the seven β which is what fits.
Start commands take a literal port
\1\1
arrives at gunicorn as five literal characters and it exits:
Error: '$PORT' is not a valid port number.
Wrapping it as \1 does \1 help; the wrapper is not
given a shell either. Every \1 in the old \1
used \1, so all four would have crashlooped on first boot.
\1 pins the port literally instead, and sets \1 to
match so Railway's own port detection agrees:
const API_PORT = "8080";
start: \1,
env: { ...appEnv, PORT: API_PORT },
A fixed port is needed anyway: the Next.js BFF dials
\1 and has to know which port to use.
\1 needs three hosts, for three different callers
Setting it to the public hostname alone β what the previous version of
this document told you to do β fails the deploy.
DJANGO_ALLOWED_HOSTS = ${{RAILWAY_PUBLIC_DOMAIN}},${{RAILWAY_PRIVATE_DOMAIN}},healthcheck.railway.app
\1β the browser, through Railway's edge.\1β **Railway's health checker sends its own
Host header**, not the service's domain. Without this entry Django
answers every check with \1 and the deploy fails
while the application is serving perfectly well.
\1β the Next.js BFF. It strips the incoming
\1 (it names the wrong server), so \1 sets it from the
internal URL and Django sees \1. Without it every
proxied \1 and \1 call answers 400.
HTTPS redirect vs. the health checker
\1 defaults \1 to \1. Railway's health
checker reaches the container \1, not through the edge, so the
request carries no \1 for \1 to
read. Django sees plain HTTP and 301s the check. The checker never sees a
200, the deploy is failed for an unhealthy service that is fine, and
\1 β a redirect is not an error.\1 now exempts the health endpoint, which is what makes the
default survivable:
SECURE_REDIRECT_EXEMPT = [r"^healthz/$"]
Verified both ways on the same commit: redirect on with no exemption ->
healthcheck fails; exemption in place -> \1 200 while
\1 still 301s to HTTPS.
This also answers the old document's open question about
\1. The edge does send \1. The
health checker does not, and that distinction is the entire bug.
The BFF must assert the browser's scheme
The BFF dials \1 β correctly, since the
private network is already Wireguard-encrypted and has no TLS terminator
to speak HTTPS to. Django then sees a plain-HTTP request and
\1 301s it, and the proxy hands that redirect back to
the browser: an API answering with redirects instead of JSON, again with
nothing logged as an error.
\1 still strips \1 as an
untrusted caller claim, then re-asserts it from the scheme it actually
served. It is the only party that knows: the browser's TLS terminates
there. Django already reads exactly that header.
Transactional emails must be built into the API image
The six emails are authored in \1 and rendered to static
HTML by react-email; Django reads that output at send time. Building the
API image with \1 as its context means the templates can never be
in it, and \1 resolved to a path _above_ the app root that
does not exist in a container at all:
EmailTemplateMissing: packages/emails/dist/verification.html not found
Every transactional email was broken in production β signup, password
reset, invitations, billing notices β while working perfectly in a dev
checkout, where the whole repo is on disk, and in the test suite, where
the same path resolves.
\1 now builds \1 and renders
the templates in a Node stage, copying only the resulting HTML (no Node,
no \1) into the runtime layer. \1 points
Django at it. The default still resolves to the repo layout, so dev and
tests are unchanged.
The Railway service must therefore have root directory \1 and Dockerfile
path \1.
\1 at the repository root
Both images build from the root, and nothing was excluding anything. The
context was ~600MB, and worse, \1 dropped the \1
\1 and \1 on top of the ones installed inside the image
β native binaries built for the wrong platform, and a build failure the
Dockerfile itself does not explain. \1 only ever
covered the old \1 context.
Next.js: bind to every interface
Next's standalone server binds \1, and
server binds that single hostname and nothing reaching the container by
IP gets through. The symptom is the app logging \1 and then
not one request, while the healthcheck fails. \1 hides it
completely on a local smoke test.
\1 sets \1.
\1 also needs \1 β and note that
\1 silently drops \1 and
\1 the same way it already drops \1 and
\1. \1 then reports success and emits no
\1 at all, leaving the image with nothing to run. Both
are re-applied explicitly in \1.
Railway blocks the build on dependency CVEs
Railway scans dependencies \1 building and refuses outright:
SECURITY VULNERABILITIES DETECTED
[email protected] Source: pnpm-lock.yaml Severity: CRITICAL
It reads the \1 \1 regardless of which service is
building, so a frontend CVE blocks the Python API, which never installs
Node. The offender was transitive (\1 -> \1); a
\1 entry lifts it. Budget for this: a lockfile CVE stops
every deploy in the repo, not just the affected app.
pgvector β available, no special template
The previous version of this document warned at length that Railway's
managed Postgres does not ship \1, and that provisioning the
default plugin instead of a \1 template was "the actual risk".
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2)
pg_available_extensions: vector | 0.8.6
CREATE EXTENSION -> vector | 0.8.6
Image: \1. Same extension
version as dev's \1. No template selection needed.
Nothing in Keel uses \1 today; this matters only when something
does.
Private networking
Services reach each other at \1. Use \1,
not \1 β the traffic is already Wireguard-encrypted, there is no
TLS terminator inside, and internal traffic does not count as egress.
Environments created after October 2025 resolve internal DNS to \1
IPv4 and IPv6, so \1 is fine. On a legacy IPv6-only
environment it would not be β that is the case where a service must bind
\1.
Private networking is runtime-only. It does not exist during a build.
Migrations
\1, on \1 only:
preDeploy: "python manage.py migrate --no-input",
Verified: Railway runs it after a successful build and before the start
command, in its own container with the deploy's environment and private
network access. Every app applied cleanly on first deploy and reported
\1 on subsequent ones. A failing pre-deploy aborts
the deploy β the previous one keeps serving.
Only \1 declares it. All services build the same image and would each
run their own copy on every deploy, racing concurrent \1 runs.
rollback story. Django wraps each migration in a transaction on Postgres,
and Railway's dashboard offers one-click redeploy of a previous build, so
rolling the _code_ back while leaving the schema forward is the intended
path β but that is reasoning, not evidence.
Environment variables
Secrets are \1 in \1 β they live in Railway,
never in the file. Set them once per environment.
Beyond the hosts and ports above, the ones that actually bite:
| Variable | Why |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| \1 | \1 \1 reads \1 with no fallback, so omitting it is a hard boot failure, not a safe default. |
| \1 | The \1 host, not Django's. Pointing it at the API's own domain trips \1 at boot, because it then has no matching entry in CSRF trusted origins. |
| \1 | The browser-facing origin β the Next.js service β since the BFF forwards the browser's \1 unchanged. |
| \1, \1 | Generated per environment. A default \1 is a hard boot failure (\1). |
| \1 | Railway Postgres has no pooler in front of it, so persistent connections are safe. Leave at \1 for Neon (below). |
| \1 | Set by the Dockerfile. Only override it if you relocate the rendered templates. |
\1 and \1 come from \1 /
\1; \1 and \1 are set by
Railway.
Postgres provider neutrality β Railway vs. Neon
\1 is the only thing \1 reads, and no code path
branches on the provider. The pooling settings differ:
- \1 has no pooler in front of it:
\1,
\1.
- \1's default endpoint is PgBouncer in transaction mode: leave
\1 and set
\1. Session state, including
server-side cursors, does not survive a pooled transaction boundary.
Both are the repo's defaults, so Neon is safe with nothing set and
Railway is the one that needs the two values above. **The Neon half is
still unverified** β deploying the same commit against both providers and
diffing nothing else has not been done.
Static files
\1 plus
\1; \1 runs at image
build time. Verified live: a hashed asset served 200 from the Django
admin's own CSS. This covers only what Django renders β Next.js serves
the application's assets.
Auth cookies without a shared registrable domain
The deploy this document describes runs the app and the API on two
unrelated \1 hostnames, with \1
unset (host-only cookies). Since \1 the
browser only ever talks to the Next.js origin β the BFF forwards to
Django server-side β so a shared registrable domain is not needed for the
browser's session cookie in this configuration.
\1\1 and the CSRF
cookie round-trip through the BFF were confirmed; a full authenticated
session was not. Signup runs the whole chain β CSRF, the user row, the
email template β and then stops at \1, which the
Resend backend raises rather than dropping the mail silently. Finishing
the flow needs a real Resend key, since verification is mandatory
(\1) and the emailed link is the
only way through.
Note for the checklist below: the claim that every integration is "a
documented no-op when blank" does not hold for Resend, and should not β
a mail backend that silently discards verification email is worse than
one that refuses to start sending.
A production deployment on a real domain should still put the app and API
on one registrable domain (\1 / \1) and set
\1 and \1 accordingly. Vercel
preview deployments on \1 cannot share a \1
cookie; a wildcard preview domain requires Vercel Pro.
Two application bugs this deploy found
Neither is Railway-specific; both were latent and are now fixed with
regression tests.
1. \1 β the missing templates above.
2. \1 allauth's
enumeration-prevention path mails the existing account, and building
that mail needs \1 and
\1. Neither was configured, so
\1 propagated as a 500 β meaning the one flow
whose entire purpose is to be indistinguishable from a fresh signup
was the only one that returned an error, which is exactly the
enumeration signal it exists to suppress. It reproduces in the test
suite the moment anything signs up twice with one address, which
nothing did.
Also observed, not yet addressed: the failed signup **persisted the user
before the email raised**, so account creation and the verification email
are not in one transaction. A user whose verification mail fails to send
is left in the database, and their next attempt takes the "already
exists" path.
Cost
Railway bills usage on top of a plan minimum: \1,
\1, \1 egress, \1 volumes. Hobby is
$5/month, Pro $20/month, each crediting that much usage.
The full seven-resource topology needs Pro. Four always-on API services
at a conservative 0.5 vCPU / 512MB each is roughly $60/month of compute
before the frontend and databases. \1 and \1 are idle most of
the time for a low-traffic project and are the first place to look if the
bill runs high β though neither is a good scale-to-zero candidate.
\1 The deploy describedhere ran for hours, not a billing cycle.
What remains untested
Each of these needs Pro (for the service slots) or time:
1. \1 and \1 deployed, and Celery processing a real job.
2. \1 deployed, and whether Railway's edge buffers
\1. \1 sets \1
and flushes a leading comment; neither has been checked against
Railway's proxy. If it buffers, the symptom is a job tray that shows
nothing for minutes then everything at once.
3. A deliberately-broken migration, and the rollback story.
4. The same commit against Neon instead of Railway Postgres.
5. A full authenticated session and the org-scoped app shell end to end.
6. A real monthly bill.
7. Custom domains and their DNS/TLS step β this deploy used the
Railway-issued \1 hostnames throughout.
maintenance.md
Maintenance
Supported versions, update cadence, and the checklist for a Django major
upgrade. Written for whoever owns this repo after the initial build β
including a future agent working from \1.
Supported versions
| Component | Version this repo targets | Where it's pinned |
| ---------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Python | 3.12+ | \1 \1, enforced at runtime by the floor check in \1 |
| Django | 6.0+ (currently resolves to 6.1) | \1 (\1), enforced by the same floor check |
| Node.js | 22+ | root \1 \1 |
| pnpm | 9.5.0 | root \1 \1 (corepack-pinned) |
| PostgreSQL | 17 | \1 / \1 (\1); see \1 for the hosted-provider equivalents |
| Redis | 7 | \1 / \1 (\1) |
\1 on Django is deliberate: it floats within the 6.x series (picking
up 6.1, 6.2, β¦) but will not silently jump to a 7.0 major, which is exactly
the boundary the upgrade checklist below exists for.
Update cadence
- \1 β apply opportunistically, no
fixed schedule. Neither has broken this codebase across a minor bump
historically.
- \1 β check for a new minor release monthly; Django's own
security-release calendar (published ahead of time on
\1) takespriority over the monthly cadence β apply a security release within the
week it ships, not at the next scheduled check.
- \1 β
\1
covers pip/npm/github-actions/docker, weekly, grouped minor+patch.
Dependabot PRs are the cadence; this section is the record of what it
covers, not the mechanism itself.
- \1 β no fixed schedule. Bump when
the hosting provider (Railway, Neon) deprecates the running major, or
when a feature this repo wants (e.g. a newer \1 release) needs
it. Test \1 against the new image before cutting
over β see \1's pgvector section for the last
version actually verified.
Django upgrade checklist
Run this for every Django minor bump (6.0 β 6.1, 6.1 β 6.2, β¦); a major
bump (6.x β 7.0) additionally needs a manual read of Django's own release
notes for removed features before starting.
1. Read the release notes for the target version β Django's own
\1 βspecifically the "Backwards incompatible changes" and "Deprecated
features" sections.
2. Bump \1 in \1, run \1, and let
\1 resolve the rest of the dependency graph (\1,
\1, \1 all pin ranges against Django β a
resolver conflict here is the checklist doing its job, not a bug).
3. Run the full test suite locally (\1 from \1) before
touching CI β this repo's coverage floors (\1 in
\1) mean a version bump that changes ORM behavior under
the hood shows up as a red test, not a silent regression.
4. Run \1 under \1
locally (see \1's \1 job for the exact
invocation and required env vars) β a new Django release occasionally
adds a new deploy check.
5. Regenerate the OpenAPI schema and the TS client
(\1, then orval) β a Django/django-ninja bump
can change generated schema shape even with no application code
changes. The \1 CI job fails on drift; do this
locally first so the diff is reviewable instead of a surprise.
6. Read \1 for anything the new Django version
wants to add (e.g. a new default \1 option triggers an empty
migration). An unexplained migration appearing after nothing but a
dependency bump is expected; commit it with a message that says so.
7. Deploy to a non-production environment first if one exists; otherwise
treat the Railway \1 migration step
(\1 "Migration strategy") as the first real test
and have the rollback path ready before triggering the deploy.
Node / Next.js upgrade checklist
Shorter, because this repo carries much less Next.js-version-specific
logic than Django-version-specific logic:
1. Bump the version in \1, run \1.
2. \1 and \1 first β a
Next.js major most often breaks types before it breaks runtime.
3. \1 locally β confirms the production build path,
which \1 does not exercise.
4. Run the Playwright suites (\1,
\1) β the cross-host config specifically
exercises the \1 / \1 / \1 cookie-sharing
behavior described in \1, which a Next.js middleware
or routing change is the most likely thing to break silently.
pre-push.md
Local pre-push gates
The repo runs CI on GitHub's free plan, so the answer to "this failure
should be caught earlier" is a local hook,
not another Actions job. \1 now runs a mirror of the blocking CI
gates before anything reaches GitHub; a broken push never burns Actions
minutes in the first place.
Setup
One-time, per clone:
uvx pre-commit install --hook-type pre-commit --hook-type pre-push
(or \1 / \1 first, then
\1, if
you don't have \1/\1.) This installs two separate git hooks from
the one \1:
- \1: ruff, ruff-format, mypy, prettier, eslint,
end-of-file-fixer, trailing-whitespace, check-merge-conflict β fast,
changed-files-only, runs on every commit.
- \1: one hook,
\1, that runs
\1 against the whole repo.
Why pre-commit, not lefthook
Both are reasonable choices; pre-commit won because the repo already had
a \1 β adding lefthook alongside it would mean two
hook managers, two install steps, and two places a new contributor could
look and find nothing. pre-commit's \1 key maps directly onto
"some hooks run at commit, some at push" without a second config file,
and its \1 hook type lets the push-time gates shell out to
the project's own \1 / \1 commands instead of a
pre-commit-managed environment β which matters here specifically: fixing
the bug described below showed that pre-commit's own isolated per-hook
environments had been silently wrong for mypy and eslint, because they
didn't share the real \1 or \1's
\1 workspace package. Running the project's actual
tools instead of a shadow toolchain is what makes the pre-push mirror
trustworthy in the first place.
What the pre-push hook runs
In order (\1), ~50s warm:
1. \1 / \1 (apps/api)
2. \1 (apps/api)
3. \1
4. \1
5. \1 (\1)
6. \1
7. build email templates (pytest dependency, same as CI's \1 job)
8. \1 (apps/api) β the
_service-free subset_ of CI's \1 job, see below
9. \1, diffed against the committed \1
10. \1, diffed against the
committed \1
11. \1 (additive-only compat gate β see
that script's docstring)
What "fast subset" means for pytest
Step 8 runs only the tests that need no services: \1
auto-marks anything touching Postgres as \1 and anything touching Redis
(Django's cache or a Redis client) as \1, and the hook deselects both.
That keeps the whole hook under ~2 minutes with no Docker stack up (the
software-factory profile contract requires fast, service-free pre-push
hooks); CI's \1 job still runs the full suite against real
Postgres and Redis. Same settings module (\1) as CI,
without \1 (coverage
instrumentation costs real time for a check nobody reads locally β CI's
\1 still gates on it) and without the second,
\1 run CI does for the MFA-flag acceptance
test. That second run exists to prove one thing (TOTP endpoints appear
under a different settings module) and re-boots Django under a whole
separate settings module to do it β not worth doubling local push time
for.
What's deliberately excluded entirely
\1and\1β\1needs\1plus
placeholder env vars CI sets explicitly (see \1's \1
job); wiring that locally for a check that only matters at merge time
isn't worth the noise.
\1(the\1job's import-linter check) β same
reasoning.
\1β needs the\1run this hook skips (see
above), and per-directory coverage regressions are a merge-time
concern, not a per-push one.
- Playwright e2e (
\1,\1),
\1, \1, \1 β slow, browser- or
network-dependent, or already scheduled/non-blocking in CI
(\1). None of these gate a merge on every push today; they
don't need to gate one locally either.
Escape hatch
git push --no-verify
skips the pre-push hook entirely. CI runs the full set of gates
regardless β \1 trades a slower feedback loop (find out on
GitHub instead of locally) for an unblocked push, not a bypassed gate.
Reach for it when you know a gate is failing for a reason unrelated to
your change (e.g. a flaky local Postgres) and want CI's clean-environment
result instead.
A bug found and fixed along the way
The original \1 had never actually been run
end-to-end: \1's pin (\1) doesn't exist
in that repo (it stopped tagging real releases after \1), and the
\1 / \1 hooks both failed on every real
invocation (\1 and \1, respectively β the former from
\1 with no explicit target, the latter from running
outside \1 with an isolated \1
that can't see \1). Fixed as part of standing the
pre-push layer up far enough to verify it locally: prettier now points
at \1 (the community-maintained continuation),
and mypy/eslint are \1 hooks that shell out to \1's
real \1 and \1 β the same commands CI
runs.
jobs-and-audit.md
Jobs and audit
What \1 and \1 actually guarantee, how to extend
each safely, and where the boundary between "belongs in Keel" and
"belongs in a product built on Keel" sits. This document hardens the
constraints underneath the mechanisms and closes the gaps a design
review found. Read \1's and
\1's own docstrings for the reasoning behind any one
line β this document is the map across files, not a replacement for them.
What belongs in Keel versus a product
\1 is a resumable async-job runner with per-organisation
concurrency control, idempotent creation, credit-hold settlement, and a
live SSE status stream. What it is deliberately \1: a specific job.
\1 (\1) is the only registered job type
in the template, exists purely to exercise the runner end to end, and is
removable β same footprint as \1, the reference CRUD slice.
A product built on Keel adds its own job types in its own module (see
"Adding a job type" below) and, if it needs one, its own generic-and-cheap
notion of "what produced this row" via \1 β Keel does not
know, and must not need to know, what any of those consumers are.
\1 is the audit trail: one \1 row per mutating service
call, written inline in the same transaction as the effect it describes,
readable per organisation behind \1. What it is not: a
general-purpose event bus, a metrics pipeline, or an approval workflow β
it is a record of "what happened, who did it, and whether they were
impersonating someone," nothing more.
Adding a job type
1. In your own app (not \1), write step functions β each
receives a \1 (\1,
\1, \1, \1) and returns a small
JSON-serialisable value or \1. A step that needs a prior step's
real output re-derives it or loads it from wherever it actually
lives (a file, a row) β \1 only carries what
\1 stored, deliberately opaque past that, because a
resumed job reads exactly the same thing a fresh one would.
2. Register a \1 (\1, \1,
\1, \1) from your app's \1 β
see \1 registering \1 for the
shape.
3. Create jobs through \1, never
\1 directly β it is the one place that ties the
registry, the idempotency guarantee, and the credit hold together.
\1\1 and
\1 both take a plain id. Celery serialises task
arguments through its result backend and broker β a passed-in model
instance is pickled/JSON-encoded as of the moment the task was
_enqueued_, and a retried or resumed task re-reads it stale. An id forces
every re-entry (a resumed task, an admin redrive, \1
itself) to \1 the row fresh, which is exactly what
makes \1 safe to call twice.
Idempotency
\1 handling lives in \1 β it
moved out of \1 because it was never actually jobs-specific
(a retried POST can duplicate a Stripe checkout session,
an invitation email, an upload row, or an organisation exactly the way
it can duplicate a job). Two layers, cheapest first:
1. \1 (\1, or \1 applied to a
Ninja view function). A first request with a given key claims a
Redis cache slot and proceeds; a replay with the same key while the
first is still in flight gets a \1
(\1) raised through the normal
\1 β \1 envelope
machinery, not a hand-built response; a replay after the first
completed gets the cached response replayed verbatim, without the
view running again. \1 scopes the cache key by the
decorated view's own module-qualified name plus \1 (when the
route has one), so two different endpoints β or the same endpoint
for two different organisations β never share a key space even if a
caller reuses a header value by coincidence.
2. \1 The cache claim is cheap and closes most
of the race, but it is not the real guarantee: two requests can both
pass \1 before either calls \1, and β the gap
this closes β \1's own \1 locks a
\1 row that doesn't exist yet when both requests read \1 for
the same key, so it cannot serialise them either. \1 carries a
partial \1 on \1
(excluding the empty string, so jobs created without a key never
collide with each other), and \1 catches the resulting
\1 and returns the row the winner of the race actually
created. This is the pattern to copy for any new idempotency-scoped
write: cache claim for the common case, a real constraint for the
case the cache claim cannot cover, caught and resolved to "return the
existing row," not surfaced as a 500.
Applied to every side-effectful POST worth deduplicating today:
\1, \1, \1, \1,
\1, \1. \1's replay is the one
case worth a second look before copying the pattern blindly: the cached
response includes a presigned URL that expires, so a replay outside the
URL's expiry window returns a link that no longer works β acceptable
today (idempotency here is about not creating a second row, not about
the URL's shelf life), worth revisiting if a product build on this
starts relying on long-lived idempotent replay of upload URLs.
Concurrency control
\1's \1 is a Redis sorted-set
semaphore, one per organisation, that never blocks the calling worker β
\1 returns \1 immediately when the organisation is at its
limit, and \1 re-queues its own task a few seconds out
instead of holding a worker slot hostage waiting for the organisation's
turn. Two properties worth knowing before touching this file:
- \1 The acquire/renew Lua
script reads \1 from Redis itself rather than accepting a
client-supplied timestamp β a worker with a skewed local clock must
never get to decide when another worker's slot expires.
- \1, not just once at
acquisition. \1's per-step loop calls \1
before running each step, so a job whose steps together outlive
\1 (one hour by default) doesn't silently lose its slot
partway through and over-admit the organisation for the rest of the
run. A step function that itself runs long (well past the heartbeat
interval implied by adjacent steps) is still a gap this doesn't
close β keep individual steps short relative to the lease, the same
assumption \1 (the beat sweeper) already
makes.
Applies to Tier 2 (multi-step, resumable Celery) jobs only β it has
nothing to do with Tier 1 fire-and-forget tasks through
\1's shim, which has its own retry/dead-letter policy
and no per-organisation fairness concept at all.
Job failure, retry, and dead-lettering
- \1:
\1retries with jittered exponential backoff up
to \1 (five) on any exception from \1 itself.
Concurrency-limiter refusals are a separate, unbounded retry path
(\1) that must never count against
\1 β an organisation being busy is not the job's fault.
- \1: exhausting
\1writes a\1row
(task name, args, error, traceback, attempt count) rather than losing
the failure silently. Visible and actionable in Django admin
(\1) β select rows and use the "Redrive selected failed
tasks" action, which calls \1 per row and
clears \1. \1
exercises this end to end against a job task's own dead-letter row.
- \1:
\1(a beat task,
\1) fails any \1 still \1
\1 (default 60) after \1 and
refunds its credit hold β the backstop for a worker that hangs or is
killed _after_ the broker's ack, which \1 redelivery
can't recover on its own.
- \1: a job where some steps succeeded and others
failed lands in \1, not \1 β surfaced in
\1 (a published \1, not a bare \1) and settled
proportionally (see credits below).
All of the above is exercised twice each in the test suite: once as a
single call (does the mechanism work), and again as two calls against the
same job (\1) β Celery is
at-least-once by design, so "ran once" tests alone would pass even if a
redelivery double-settled a credit hold.
Credits: hold, settle, refund β and why it's idempotent now
\1 places a credit \1 for the job's estimated cost, in
the same transaction as the \1 row. When the job finishes,
\1 either \1 the unused portion (partial or full
success) or \1 the whole hold (nothing succeeded). The property
that makes re-running \1 on an already-terminal job safe: the hold
entry is locked with \1 _before_ checking whether a
\1/\1 entry already exists for it β the lock-then-check
order is what stops two concurrent settlement attempts (a retried
delivery racing a cancel, or two retried deliveries) from both reading
"not yet settled" and both writing. \1 takes the same lock on
the \1 row itself before re-checking terminal status, for the
symmetric race (two concurrent cancels, or a cancel racing the runner's
own final step).
\1 is pinned at creation time from the registry's step
list, and the runner totals against that column, never a live re-read of
\1 β re-registering a job type with a different step
count (a deploy that ships mid-flight) must not silently re-price a job
that already holds credits based on the old count. \1 also
carries a \1 version stamp, stored inside the JSON blob itself, for the
same reason on the params side: nothing reads it yet, but the first
breaking change to a job type's params shape has somewhere to check
before assuming an in-flight job's \1 matches the new step code's
expectations.
SSE: live job status
\1 (served by the dedicated ASGI
service β see \1, never the sync gunicorn process)
streams every \1/\1 transition for the organisation over one
connection. Two things worth knowing about its guarantees:
- \1 Redis pub/sub has no buffer and no replay β a
browser tab that's disconnected when an event publishes never
receives it, full stop. A \1 addresses this: a per-organisation,
monotonically increasing counter (\1), stamped on every published
event. \1 does not make the stream replayable on its own β there is
nothing to replay _from_ β but it turns "silently missed an event"
into "detectable": a reconnecting client compares the \1 on the
first event of the new connection against the last one it saw and, on
any gap, refetches \1 (already the source of
truth for job state) instead of trusting a stream it knows skipped
something.
- \1, deliberately. The alternative is
Redis Streams (\1/\1, honouring \1 on
reconnect) for true resumability β at the cost of a second Redis data
structure and a retention policy for it. The job tray only ever needs
"am I looking at current state," which a refetch answers exactly as
well as a replayed log would; revisit this if a future consumer of
the stream needs to reconstruct history it missed rather than just
resynchronise to the present.
The stream is served outside Django Ninja's own routing (a separate ASGI
app), so nothing generates a TypeScript client method for it β declared
by hand in \1 instead, so the event shape
(\1) is at least part of the published OpenAPI document
even though \1, not a generated client call, is what actually
consumes it.
Provenance: what produced this row
Future document-ingestion or data-pipeline work needs to answer "what
produced this row, from what input, by which job run." Rather than build
that (out of scope here β no ingestion pipeline exists yet), this repo
carries the smallest hook that answers it: \1
(\1), two nullable columns β
\1: a string-referenced FK to\1
(\1) β string-referenced the same way
\1 points at \1,
so a model in any app can inherit this mixin without ever importing
\1, and \1 never imports a single one of its
consumers.
\1: a free-form string describing the specific
input the row was derived from (a source row's id, a file key, a
params field) β deliberately opaque, because what counts as "the
input" is a consumer decision this mixin has no business making.
\1 demonstrates the shape against a real,
migrated table rather than only a docstring: the demo job's \1 step
(\1) writes one \1 row per run, setting both
fields from nothing but what \1 already gives every step.
\1 is \1 on delete precisely so the produced
record outlives the job that made it β see
\1.
A real ingestion pipeline adds \1 to whatever model it
produces rows into and sets the two fields from inside its own job
steps; nothing here needs to change for that to work.
Audit: making a service audited
Every public, mutating \1 callable across the backend must
carry \1 or
\1 β enforced by a meta-test
(\1) that walks every
\1 in the repo and fails, by name, on any function with
neither. To prove it still bites: stripping \1 off
\1 and running
\1 fails with
E assert not ['keel.jobs.services.create_job']
restoring the decorator turns the test green again with no other change.
Two things the decorator reads off the call it wraps, and neither is
inferred:
- \1 A service that
names the same person after the model field the value lands in β
\1, \1, \1 β writes every one of its rows with
\1, invisibly: the call site looks right and the row looks
like a system action. So \1, \1,
\1 and \1 all take \1; the
model fields keep their own names. \1
(\1) walks the registry and
fails on any \1 function whose signature has no \1
parameter; a deliberate exception β a scheduled job that genuinely has
no actor, like \1 β goes in
\1 with its reason, the same shape as a
\1 reason.
- \1 A delete service that
returns \1 therefore records \1, \1 and
\1 β a row that can't say what was deleted, or from
which organisation. \1 and \1 return the
deleted row instead, with the in-memory \1 Django's \1
clears restored on it: a detached snapshot, never re-saved.
\1 records \1, immediately after the decorated function
returns β not deferred to \1. \1 lives in
the same Postgres as everything it describes, so writing the record as
part of the same transaction as the effect is both the safer choice and
the simpler one: if the transaction rolls back, the audit row rolls back
with it, and there is no window where the business effect is durable but
the record of it silently isn't (or vice versa) β verified directly by
\1,
which deliberately raises inside an open transaction after a call to an
audited service and asserts neither the effect nor the audit row
persisted. \1 stays the right tool for genuinely external
effects (a Stripe call) β the two are not interchangeable, and audit
writes should never be moved to it.
\1 is legitimate for: scheduled system jobs with
no actor to record (\1, \1,
\1); an internal helper already covered by the
audited call site that invokes it (\1, covered by
\1); and a service whose effect is a
system-triggered side effect of an already-audited call with no actor of
its own (\1, triggered from \1 by
\1/\1). It is not legitimate as a way to
skip writing the reason, or to route around the meta-test for a genuine
user-initiated write β every current use is walked and asserted non-blank
by \1.
Audit ordering and pagination
\1 orders on \1 (a UUIDv7 primary
key, monotonic by construction β see \1), not
\1. \1 is \1, stamped at row-insert time;
two causally-ordered writes across two app-server processes can commit
in either order relative to wall-clock time, and the cursor paginator
filters \1 β a row inserted with an
earlier-than-expected timestamp after the client has already paged past
that cursor value would be silently skipped, never returned. \1 needs
no separate tiebreaker (it's already unique), unlike the default
\1 ordering most other list endpoints use.
Both \1 and \1 are declared through
\1 β a decorator that states the
response schema (\1) and the ordering once, at the route
declaration, instead of splitting them across a \1 kwarg, a
bare \1 call in the body, and (whenever the
queryset needs a specific ordering) a third repetition of that ordering
passed to \1 β previously easy to get out of
sync, since the third copy is silently overridden if it's ever dropped.
The decorated view returns a plain queryset; \1 calls
\1 for it.
Verifying the invariants above
cd apps/api
uv run pytest keel/jobs keel/audit keel/core/tests/test_service_audit_registry.py keel/core/tests/test_idempotency.py
uv run python manage.py makemigrations --check --dry-run
uv run lint-imports
uv run python ../../scripts/check_permission_lint.py
query-patterns.md
Query patterns
How selectors own query shape, how to keep a list endpoint at a fixed
query count regardless of row count, and how to add a query-count test
to a new endpoint.
Selectors own query shape
Every ORM read lives in an app's \1 (PRD Β§4 invariant 1). A
view resolves the organisation, calls a selector, and serializes the
result β it never calls \1 itself. This is not just style:
\1/\1 decisions belong in exactly one
place per query, so fixing an N+1 means editing one function, not
auditing every call site that happens to build the same queryset.
\1 is the shared
helper behind every \1/\1/\1 route: pass it a
queryset already narrowed to one organisation (usually an app selector's
list function) and it filters by \1 and raises \1 on a miss.
This replaced twelve copy-pasted \1 functions (posd#10) β
add a new one only if a route's 404 semantics genuinely differ (e.g.
\1, which is a 422 against a
non-org-scoped table, not a tenant-boundary 404).
Avoiding N+1s
Two shapes cover almost every case:
- \1
(\1, \1): \1 in the
selector that builds the list queryset.
- \1
(\1, \1): \1, with a
\1 object if the related queryset needs its own
\1 or filter (\1
prefetches \1 ordered by \1; \1 prefetches only \1 prices into
\1).
The tell that a schema resolver needs one of these: a \1
staticmethod on a Ninja \1 that walks a relation
(\1, \1, \1). If the selector
that builds the queryset doesn't already carry the matching
\1/\1, that resolver is one query per row.
Two real examples fixed here:
\1read\1β\1
now prefetches \1 (ordered by \1), so the list endpoint's
query count no longer grows with the number of jobs.
\1read\1β
\1 now \1.
Passing a \1 filter argument to a selector and then re-filtering
the same queryset again in the view is the other pattern found and
removed here (\1 used to build the queryset via
\1 β dropping the
\1 argument the selector already accepts β and then filter again
inline). If a selector takes a filter argument, pass it; don't
re-implement the filter at the call site.
Django Debug Toolbar, locally
\1 is not currently wired into \1.
To inspect a request's query count and SQL while developing:
config/settings/dev.py, temporarily
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
then add \1 to
\1 under \1. Don't commit this β it's a
local-only aid; the query-count tests below are the checked-in guard.
For a quick one-off count without the toolbar, wrap the call in a test
or a shell session with \1:
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
client.get(url)
print(len(ctx), [q["sql"] for q in ctx.captured_queries])
Adding a query-count test to a new endpoint
Follow \1 (the reference slice) or
any of the other six in the table below:
1. Build a small org, an authenticated owner client, and **more than
one** row of whatever the endpoint lists (an N+1 doesn't show up
with a single row β a fixed query count on 3 rows is the whole
point).
2. Wrap the request in \1 β \1 starts as a
guess; run the test with \1 to see the actual count and the SQL
for each query when it fails, then pin \1 to the real number.
3. Write a one-line comment per query explaining what it's for. A bare
\1 nobody can justify gets bumped to 5 the first
time it fails, which defeats the point β the comment is what lets a
reviewer tell "this grew because of a real new join" from "this grew
because someone forgot select_related".
4. Every endpoint here costs 3 queries before it does anything
endpoint-specific: session β session key, session key β \1,
\1 β \1 via an active \1 join. A fourth
(\1's \1+\1 lookup) is added by every
\1 call. Budget for those four before counting
what the endpoint's own selector adds.
The seven endpoints this repo pins:
| Endpoint | Test | Query count |
| ---------------------------------------- | ------------------------------------------------------------------------------- | ----------- |
| \1 | \1 | 5 |
| \1 | \1 | 3 |
| \1 | \1 | 5 |
| \1 | \1 | 5 |
| \1 | \1 | 5 |
| \1 | \1 | 5 |
| \1 | \1 | 5 |
(\1 isn't one of the seven listed above but is
covered too β \1, 6 queries, the extra
one being the \1 prefetch.)
Index audit
The cursor paginator's default ordering is \1
(\1); a route that overrides \1
needs the matching composite index instead. Checked against every
paginated model:
| Model | Pages on | Index | Status |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| \1 | \1 | \1 | β |
| \1 | \1 | \1 | β |
| \1 | \1 | \1 | β |
| \1 | \1 | \1 | β |
| \1 | \1 | \1 | β |
| \1 | \1 (ddia#18 β UUIDv7 \1 is monotonic and already unique; \1 isn't, for two causally-ordered writes racing across app servers) | \1 only, before this fix | \1: added \1 in \1 |
| \1 | \1 | none | audited, not added β catalogue data, expected row count in the single digits (one row per pricing tier); a sequential scan over the whole table costs less than the index would |
| \1 (\1) | \1 | none | audited, not added β bounded by the requesting user's own membership count via the join, not by table size |
| \1 (\1) | \1 default | none beyond the FK's own index | audited, not added β three global presets plus feature-flagged (off by default) custom roles per org; not a table that grows |
Every \1 subclass gets \1 indexed by default
(\1 on the FK, \1); the table above is
about the _composite_ index a paginated ordering needs on top of that,
not the base tenant-scoping index.
CHANGELOG.md
Changelog
All notable changes to this project are documented in this file.
The format is based on \1.
Entries are grouped by the phase they landed in during this template's
build.
[Unreleased]
Phase 19 β Generators as the agent capability surface (ADR 0004)
- \1 β
\1(\1),\1as the source
of the reference slice, \1/\1/\1/
\1 generators; \1 becomes a committed
render of \1 rather than hand-maintained; new
\1 and \1 CI gates
(\1)
- \1 β
\1generator (Tier 1 and Tier 2),\1ship-gate
generator, \1 CI gate, every
\1 rewritten as a thin wrapper over the CLI,
and a new \1 command for briefs spanning more than one
generator
- \1 β
\1frontend templates (list/detail/create pages,
\1) so \1 provisions the Next.js
route group too, and an \1 generator for transactional emails
[1.0.0] - 2026-08-28
First tagged release. Phases 0β18: a working, instantiable multi-tenant
SaaS template.
Phase 18 β Portfolio polish and v1.0
- README rewritten around what Keel is, who it's for, and what it is
not; the invariants and an architecture diagram moved into the README
itself
- Screenshots of the running application
\1finalised and tagged\1\1and\1degrade
gracefully when the demo slice has been deleted; \1
documents that the smoke test targets the demo slice and is orphaned
once \1 removes it
Phase 17 β Template mechanics (\1)
\1: renames the project and tenant noun across content
and file/directory names, applies feature toggles (marketing site,
demo slice, billing shape, optional \1 layer), regenerates
lockfiles/OpenAPI/the generated client, resets git history, and
deletes the template-authoring files (itself included)
\1β a CI job that runs\1against a throwaway
config and asserts the result still boots and passes its own gates
Phase 16 β Query hygiene, settings hardening, rendering guardrails, pre-push gates
- \1 β query-count tests on every list endpoint; the
\1
modules folded into \1/\1/\1/\1/
\1/\1 (no more \1 prefixes); selector/index audit
- \1 β
\1wired into CI with a hard error
on a default \1; secrets and logging hardening
- \1 β rendering, validation, and async task-boundary guardrails
- \1 β local
\1hook (\1) mirroring CI's
blocking gates, plus CI \1 annotation visibility
Phase 15 β Jobs and audit foundations
- Hardened constraints on
\1/\1/\1, generalised
idempotency beyond billing, an audit provenance hook
Phase 14 β Billing and credits polish
- Constraint hardening on the credit ledger, webhook ordering
guarantees, typed entitlements
Phase 13 β Document storage foundation
- Typed upload/list responses, foundation for per-organisation document
storage on top of the existing presigned R2/S3 uploads
Phase 12 β Railway + Postgres deployment baseline (prep)
\1, the two-service (gunicorn + uvicorn/SSE) shape,
Postgres/\1 groundwork ahead of \1
Phase 11 β Auth BFF (ADR 0002)
- Every
\1and\1call now goes through a Next.js
server-side proxy (\1,
\1) instead of a direct
cross-origin browser call β closes the last gap between the
architecture direction and the running code
Phase 10 β DRF to Django Ninja migration
- Every app (
\1,\1,\1,\1,\1,
\1) migrated from DRF to Django Ninja, invariant
meta-tests rewritten against Ninja routers, DRF and drf-spectacular
removed entirely
\1renamed to\1to match PRD Β§7- The OpenAPI pipeline switched to Ninja's native generation; the merge
step and generated client unchanged downstream
- Every paginated list endpoint's OpenAPI schema typed; operation IDs
and query params cleaned up
Phase 9 β Repository metadata, docs, CI security gates, CLAUDE.md
- \1 β
\1(MIT),\1,\1, this
changelog, GitHub issue/PR templates, Dependabot
- \1 β
\1,\1,
\1
- \1 β CI security gates: Bandit,
\1,\1,
Gitleaks secret scanning (\1)
- \1 β
\1and the\1slash commands
(\1, \1, \1, \1,
\1, \1, \1,
\1)
Phase 8 β Observability and hardening
- Audit meta-test over the service registry; staff impersonation (admin
action, exit endpoint, banner) with service-layer restrictions
- Audit read surface and settings tab
- Sentry (backend and frontend,
\1) and PostHog - DRF rate limiting with
\1, security headers, app-route CSP \1accessibility checks on app routes; k6 smoke test on the
widget list
Phase 7 β Marketing site
\1route group and landing page- Human labels for pricing-page feature codes
- MDX blog via content-collections
- Sitemap,
\1, OG images, JSON-LD
Phase 6 β Application shell and subdomain routing
- Host-based middleware splitting
\1from the marketing/auth apex \1and the shared component kit- Widget vertical slice (full CRUD, end to end) as the reference resource
- Command palette with resource search;
\1sort/filter/paginate - Real-browser cross-host login test
Phase 5 β Async work, email, and file uploads
- Celery queues (
\1,\1,\1,\1), the Tier 1
task shim, retry/dead-letter/redrive
- Six scheduled jobs (plan sync, invitation expiry, trial-ending notices,
dunning check, audit log retention, session cleanup)
- react-email templates rendered via Resend; allauth email routing
- R2/S3-compatible presigned uploads, MinIO in dev,
\1 - Job registry with a resumable runner, idempotency, per-org concurrency,
and SSE (\1, \1)
Phase 4 β Billing and credits
- Append-only credit ledger with serialised holds and a daily cap
\1management command; operator credit
adjustments in Django admin, reasoned and audited
- Stripe plan/price sync, checkout, portal, subscription read
- Stripe webhook handling β signature verification, idempotency, async
dispatch
- Entitlements (
\1,\1), seat gating, dunning
(access is not immediately revoked)
- Pricing page, billing settings, usage gates and meter, billing banners
Phase 3 β Organisations, permissions, and tenancy
\1β the permission registry and
\1 as the single source of truth
- Preset role seeding
- The two CI meta-tests: permission-guard coverage, and the tenant
isolation walk (404, not 403, across organisations)
- Permission-check placement lint
- Organisation services, selectors, serializers, viewsets
- Organisation switcher, onboarding, invitations, settings (frontend)
Phase 2 β Authentication
- allauth headless wiring; cookie, CORS, and CSRF configuration for the
cross-host session
- Deterministic OpenAPI merge and the generated TypeScript API client
- Auth and account routes, route guard middleware
- End-to-end auth tests; 401-vs-403 fix; MFA flag coverage
- Playwright +
\1for auth and account routes
Phase 1 β Core domain primitives
\1and\1apps, ID scheme, base models, error envelope\1β the authorization registry and\1type- Audit decorators (
\1/\1), cursor pagination,
the Tier 1 task shim
- Encryption seam for
\1tokens \1model, the full data model, the one baseline migration- DRF, drf-spectacular, CORS, and logging configuration
Phase 0 β Toolchain and skeleton
- pnpm workspace (turbo, workspace config),
\1Next.js 15
skeleton, \1 theme contract and \1 stub
\1Django 6 skeleton with\1,\1- ruff, mypy,
\1, and pre-commit configuration - Per-directory coverage gate (
\1) \1stub- CI workflow (
\1) - pgvector verification and
\1
CONTRIBUTING.md
Contributing
Keel is a solo-maintained SaaS template (\1 has the full phase
history). External contributions are welcome, but the invariants below are
not up for discussion in a PR β they are load-bearing and mostly enforced
by CI.
Getting the dev environment up
Follow the README's Quick start, then \1 for the
\1 subdomain routing and login flow. Don't duplicate those steps here;
if they're wrong, fix them there.
What CI enforces
Every PR runs (\1):
- \1 β ruff, mypy,
\1, eslint, prettier - \1 β tsc across
\1and\1 - \1 β
\1, pytest (including an
MFA-enabled run), then \1's per-directory
coverage floors
- \1 β vitest for
\1and\1 - \1 β regenerates the TypeScript client from the
OpenAPI spec and fails if \1 is stale
- \1 β Playwright +
\1over auth and account
routes
- \1 β
\1(the\1and\1
import contracts) and re-derives \1, failing on drift
Every PR also runs (\1), not path-filtered
since a change to shared authz/permission code can break every future
generated slice:
- \1 β ruff over
\1, and asserts no
template-engine syntax crept in
- \1 β regenerates
\1
and its frontend route from \1/\1 and
fails on any diff against what's committed
- \1 β generates a throwaway
resource, readonly-resource, permission, and email into the CI checkout
and runs the full invariant suite against them
If you're adding a resource, permission, job, or email, use \1
(see CLAUDE.md's generator catalogue) rather than hand-copying
\1 β that directory is a generated render, not a
template to copy by hand, and CI's \1 job will
reject drift between it and \1.
Invariants a PR must not break
These are the seven architecture invariants from \1,
restated as things a reviewer (human or CI) will actually check. That
file has the full explanation and names what enforces each one.
1. \1 Views,
serializers, Celery tasks, admin, and management commands call into
services β they don't contain business rules.
2. **Authorization lives in \1, and nowhere
else.** A new viewset declares its \1 and either
\1 plus a \1, or a
\1 paragraph. Every guard needs an allow test and a
deny test, and the deny test must assert the \1, not just that
access was refused. \1's \1 hook and
\1 fail the build if
any of this is missing.
3. **One \1 per service function, opened in the
service.** External calls (Stripe, etc.) go out via
\1, never from inside an open transaction.
4. \1 No
dashboard or admin-console schema edits. A migration in a feature PR
that isn't Phase 9β18's declared schema work is drift β say why in the
PR description.
5. **Async work goes through the Tier 1 shim (\1) or
Celery directly (Tier 2), per the PRD's criteria** β not a bespoke
background-thread pattern.
6. **Validation is two layers: serializers at the HTTP edge, services for
invariants.** Client-side Zod (generated from the OpenAPI spec) is UX
only, never enforcement.
7. **Every mutating service is \1 or
\1.** A meta-test fails on anything decorated
with neither.
Two more, checked mechanically rather than by review:
- \1 If the API
changed, regenerate it (\1 from
the repo root, or see \1) and commit the
diff β the \1 CI job will otherwise fail on drift.
- \1, not just the
happy path β this is what the coverage floors in
\1's \1 are shaped to catch.
Commit and PR expectations
- One coherent unit of work per commit, not one commit per file.
- Fill in the PR template's checklist honestly β an unchecked box with a
one-line reason is more useful than a checked box that isn't true.
- If you're touching
\1, run\1and
\1 locally before pushing; both are slow enough in CI
that a broken run wastes a cycle.
marketing-removal.md
Removing the marketing site
PRD Β§8: \1 asks whether the project wants a public marketing site.
On "no", it must delete everything below β nothing else in the repo
depends on it.
Routes
\1β the whole route group:\1,\1,
\1, \1, \1, \1, and their
\1 files.
\1\1
Content and config
\1β the MDX blog posts.\1\1(\1and its test).\1and\1β only used by the
routes above.
\1\1and its test β \1:
this also fixes the pricing page's raw-entitlement-code display. If the
pricing page moves into the app shell instead of being deleted outright,
keep this pair and update its import.
\1(generated output; already gitignored).
Dependencies
Remove from \1:
\1\1\1\1
And its wiring:
\1wrapper in\1β
revert to a plain \1 export.
- The
\1entry in\1's\1
and in \1's \1.
- The
\1script in\1, and its
\1 prefix on \1.
Environment
\1in\1β only\1reads it.
Tests
\1β the marketing-only Playwright specs,
kept in their own file for exactly this deletion.
What must NOT move with it
\1 and \1 never
special-case the marketing routes β they are already outside the
middleware's \1. Deleting the route group needs no middleware
change.
ADR 0001
ADR 0001 β Move the API layer from DRF to Django Ninja
\1 Accepted β 2026-08-27 \1 whether to use Django Ninja instead of DRF for Keel's APIlayer, or explicitly reject it if migration cost is too high.
---
Context
Keel's API layer was originally Django REST Framework, never argued for
on its own merits β it was just the stack's default and DRF arrived with
it, unexamined. The architecture direction proposal reopened it, on the
grounds that Ninja's
Pydantic schemas and native type hints are a better fit for a template whose
whole selling point is that the TypeScript client is generated, never
hand-written, and that a drifted client is a build error.
The direction note framed it conditionally: prefer Ninja _unless repo
inspection shows the migration cost is too high_. This ADR records the
inspection and the decision that followed it.
What the inspection found
Measured on \1 at \1:
| Surface | Size |
| --------------------------------------------- | --------------------------- |
| Files importing \1 | 43 |
| \1 import/reference sites | 79 |
| Production viewsets and \1 subclasses | 19, across 7 apps |
| Python test files | 70 |
| Generated TypeScript client | 11,229 lines across 2 files |
| \1 import sites in \1 | 41 |
The count is not the interesting part. Four pieces of DRF are load-bearing
for things Keel advertises as invariants, and each has to be rebuilt rather
than ported:
1. \1 \1 and \1 subclass
DRF's \1; \1 is a DRF \1;
organisation resolution hangs off DRF's \1 hook and queryset
filtering off \1. Ninja has no viewsets, no
\1, and no \1. The class hierarchy is the
thing being replaced, and the \1 registries hanging off
it (\1, \1) are what
make invariants 2 and 7 enforceable at all.
2. \1 \1
walks a DRF router's \1 of \1 tuples
and drives the \1 action through \1. Ninja
exposes \1 on a \1, keyed differently, with no
action concept. \1 walks the same structure.
Both meta-tests are rewrites, not edits.
3. \1 It subclasses DRF's \1
specifically because DRF's cursor encodes a within-tie offset alongside
the ordering value, so a page boundary landing inside a run of equal
sort values neither skips nor repeats rows. \1's bundled
paginators offer limit/offset and page-number only. There is no cursor
paginator to switch to; the tie-safe behaviour has to be reimplemented
and re-proved.
4. \1 \1 builds the
drf-spectacular document in-process and merges it with allauth headless's
own spec; \1 generates from the merged file; CI fails
on any drift. Ninja generates its own schema with different operation IDs
and different component-schema names, so the merged document changes
shape, the generated client's exported symbols change, and the 41 import
sites in \1 change with them.
Two further couplings are smaller but real: \1 is a
DRF exception handler over \1 subclasses, and throttling
(\1, asserted by \1) is
DRF's, with Retry-After behaviour the error envelope depends on.
Decision
\1 Django remains the app, auth, ORM, admin andjobs authority; Ninja becomes the typed API layer. DRF is removed from the
dependency manifest when the migration completes, not before.
The migration is its own phase and is a
serialisation point: while it runs, no other worktree may add or change a
view, serializer, route, or generated-client call site.
The argument against, recorded
This ADR was written after an explicit recommendation to keep DRF and record
a rejection instead. The case against migrating, so it is not lost:
- The cost lands squarely on the machinery that makes Keel credible β the
invariant meta-tests, the permission registry integration, the cursor
paginator, the client-drift gate. Those are the parts a reader is meant to
be impressed by, and they are the parts most likely to come back weaker.
- Nothing user-visible improves. The error envelope, URL shapes, auth model,
and generated-client ergonomics are specified in the PRD and must survive
unchanged, so the migration's success criterion is literally "nothing
changed".
- A half-finished migration is worse than either endpoint. Two API idioms in
one template is exactly the incoherence a boilerplate exists to prevent.
The decision to proceed anyway was taken deliberately, for reasons the
inspection does not measure: Ninja's Pydantic-native schemas remove the
serializer/type duplication that DRF forces, the typed signatures are a
better demonstration of the judgement Keel is meant to show, and doing it
now β before the template mechanics phase freezes the structure, and before
Brein is built on top β is the cheapest this will ever be.
Consequences
- Phase 10 is inserted ahead of auth hardening, storage, billing polish, and
jobs/audit work, all of which touch views and would otherwise be written
twice.
- Template mechanics (
\1) moves to the end of the sequence.
It rewrites every file in the repo; running it against a structure that is
about to change wastes the work.
- The phase carries an explicit abort condition. If, after the core
primitives and the first migrated app, the invariant meta-tests cannot be
made to hold with equal force, the correct outcome is to stop, revert, and
supersede this ADR β not to weaken the invariants to fit the framework.
- DRF is no longer the selected API layer.
Alternatives considered
\1 Cheapest, and the migration cost falls on thebest parts of the codebase. Rejected β see above.
\1 Resolves the question withevidence for roughly the cost of Phase 10.A alone. Rejected as a decision
procedure, but its content survives: Phase 10.A _is_ that spike, and it
carries the abort condition above.
\1 Rejected outright. TwoAPI idioms in one template defeats the purpose of a template.
ADR 0002
ADR 0002 β The Next.js BFF terminates every browser API call
\1 Accepted β 2026-08-28 \1 which of the three request-path shapes the auth BFF hardeningwork implements.
\1 ADR 0001 (Django Ninja).---
Context
Every target the Notion "Keel Phase 11 β Auth hardening" task lists β
Django as sole auth authority, no long-lived token in browser JS, no
\1/\1 token β was already true going into this
phase. \1 is an \1 cookie set by Django through allauth
headless; nothing is in Web Storage; \1
documents the whole contract. The one thing not true: "Next.js is the
safe server boundary." The browser called \1 directly,
cross-origin, with \1 and an \1 header
(\1, pre-phase-11). It worked β
CORS plus \1/\1 correctly scoped
made it work β but it is not the shape the direction lock names.
Three shapes were on the table:
1. \1 Every \1 and \1 call goes through a
Next.js route handler that forwards it to Django server-side. The
browser never talks to the API origin for a programmatic
\1/\1 call.
2. \1 Reads happen in RSCs with
the cookie forwarded; mutations stay direct cross-origin.
3. \1 and harden the cross-origin configuration
instead (CORS/CSRF/cookie-domain hardening only).
Decision
\1, implemented as one generic proxy, not one routehandler per endpoint:
\1forwards every
\1 call to Django's real origin
(\1, server-only β never shipped to the browser
bundle). The same route detects the one streaming path
(\1) and forwards it to the separate ASGI stream service
(\1) instead, piping the response body
through unbuffered rather than fetching-then-returning.
\1rewrites\1to
\1 (Next.js treats a literal \1
folder as a private, unrouted segment β the leading underscore is a
framework convention, not available for a real route β so the rewrite
is what lets the wire path stay exactly \1, unchanged from
what \1's generated functions already call).
\1 implements the
actual handler and additionally normalizes every non-2xx allauth
response into Keel's own \1 envelope
before it reaches the browser (api-patterns finding 16 β see "What
this settles" below).
\1's\1becomes the
empty string unconditionally: every generated call is now a
same-origin relative fetch, proxied by whichever of the two route
handlers above matches its path. There is no longer a
\1 read for this purpose.
- Django's
\1default narrows to\1(was
\1) β no browser \1/\1 should reach
Django directly with credentials any more, so an empty allow-list is
now correct rather than merely permissive-by-omission, and doubles as
defense in depth against a future call site that bypasses the proxy by
accident.
The one thing that stays genuinely direct: Google's own callback
\1 posts a real \1 (not \1) to
\1 β a full top-level
navigation, never a JS-mediated network call β and this form's \1
is now a same-origin relative path like every other call, proxied by
the same allauth route handler. \1 forwards it with
\1, which β in the Node/undici runtime a Route Handler
runs in, unlike a _browser's_ \1 β returns the real 3xx response
(status and \1 both readable) rather than an opaque one, so the
handler just relays Django's 302 to Google untouched; the browser's own
top-level form-submission navigation follows that redirect exactly as
if it had gone to Django directly.
The one hop that stays genuinely, unavoidably direct is what happens
_after_ Google: it redirects back to whatever \1 was
registered with the provider, which is Django's own
\1 β a headed, non-\1 URL that
was never routed through any Next.js route to begin with, and couldn't
be even if this ADR wanted it to be (Google, not this app, controls
that URL's host). That hop sets the \1 cookie while the browser
is transiently on the Django host β safe, because
\1 already makes the cookie
valid for the Next.js hosts (\1/apex) it lands on next, the same
mechanism the pre-phase-11 direct-cross-origin design relied on. No
code change was needed for this leg; it is a property of the
authorization-code flow, not a choice this app makes.
Why not option 2 or 3
\1 (reads via RSC, writes direct) was rejected because itkeeps the exact CORS/CSRF surface option 3 does for every mutation β
which is most of what this phase exists to close β while adding a
second code path (RSC data-fetching with the cookie forwarded
server-side) that the existing client-heavy \1 (every current
data hook is \1 β \1, \1,
\1) does not use anywhere today. Building it only
for phase 11 would mean maintaining two fetch mechanisms with no reads
actually migrated to the new one.
\1 was the honest fallback if SSE or uploads made option 1materially worse. They don't:
- \1 β the browser
PUTs straight to R2 (\1's
\1, PRD Β§5, unchanged before and after this ADR).
Only the JSON \1/\1 calls that mint/confirm
the presigned URL go through the BFF, and those are ordinary JSON
request/response β no different from any other proxied call.
- \1 (browser β Next.js β the stream service,
instead of browser β stream service directly), but Next.js Route
Handlers stream a \1 body natively under the Node
runtime β the proxy pipes Django's \1 response
through without buffering, so "streams live" is preserved. The
\1 call site (\1)
changes from an absolute cross-origin URL to a same-origin relative
path and is otherwise untouched β reconnect/backoff/heartbeat
semantics are unaffected because none of that logic lived in the
transport hop being added.
Given that, arguing option 3 against the direction lock ("browser talks
to the API origin directly, hardened") would mean arguing "the added
complexity outweighs closing the actual gap this phase exists for" β
which isn't true here, so this ADR doesn't make that argument.
What this settles from the review findings
- \1 (no security scheme published;
\1
dropped by \1's \1): \1
now declares \1 (\1/cookie/\1) and
\1 (\1/header/\1) security schemes, applies
them per operation (safe methods need only the cookie; unsafe methods
need both; \1/\1 are marked \1;
\1 is marked optional), and publishes \1 documenting that the BFF is the intended caller of this
document's own origin. \1 now carries \1/\1
through instead of dropping them.
- \1 (
\1is an unbounded N+1): fixed
independently of the BFF shape (\1,
\1,
\1) β three
queries total regardless of organisation count, down from \1. Filed
under this phase because "the auth BFF makes this hotter" per the
review, not because the BFF shape itself required the fix.
- \1 (two error envelopes, one document
version): the \1 route handler is
now the one place that turns allauth's \1 and
\1 error shapes into Keel's own
\1, reusing
\1's already-tested \1 (moved
from a client-side "normalize after the fact" step to the actual wire
contract). The browser now receives exactly one error envelope shape
from every path it can reach, not two reconciled after receipt.
\1 still calls \1 itself as a
no-op safety net (the function's first branch already passes a
Keel-shaped body through unchanged) β kept deliberately rather than
removed, so a route handler bug fails safe into "double-normalized,
still correct" rather than "unnormalized, wrong."
- \1 (nothing checks the merged spec is backward
compatible run to run): \1, a local
script in keeping with the project's philosophy of running gates
before they reach CI, rather than adding more Actions minutes. The
pre-push hook layer that invokes it is built separately; this phase adds
the script itself and documents the additive-only rule it enforces for
\1.
\1 (\1/\1 are declaration
bags) is _not_ touched here. It's tagged fold-into-phase-11 in the
review, but the actual change this phase makes to \1 is a selector
rewrite inside an existing global (non-org-scoped) route body β it does
not touch the resource-declaration mechanism the finding is about, which
only applies to org-scoped routes. Folding in the decorator/registry
redesign the finding actually asks for would mean touching every app's
\1 for a mechanism this phase's own scope doesn't reach. Left for
whichever phase actually adds a route through that seam next.
Consequences
- \1: one more moving part locally β the Next.js dev
server now needs \1/\1
pointed at wherever Django/the stream service actually run, instead of
the browser needing to reach them directly. In this repo's \1
setup both still resolve to \1-adjacent addresses, so nothing
changes in practice β see \1.
- \1: a real deployment sets
\1 to Django's private/internal address (e.g. a
Railway internal hostname, not \1's public one, if the
platform offers a private network β otherwise the public one still
works, just without that extra hop savings) and
\1 to the stream service's equivalent.
\1 is gone entirely β nothing in the browser
bundle needs Django's address any more, including the OAuth form
(see above). \1 should stay unset (empty)
in production unless a future phase adds a legitimate direct-browser
caller.
- \1: the BFF is now a single process in the
request path for every authenticated page load. A Next.js server that
is up but cannot reach Django turns into every API call failing
through the proxy rather than the browser's own network layer
reporting a direct connection failure β unchanged in kind from any
other server-side proxy, but worth naming since it wasn't a failure
mode this app had before.
ADR 0003
ο»Ώ# ADR 0003 Γ’β¬β \1 is an Experimental Preview, not a versioning promise
\1 signals a version but
publishes no life-cycle guarantee.
\1 ADR 0001 (Django Ninja), ADR 0002 (auth BFF shape).---
Context
\1 sets \1 and every route lives
under a \1 path prefix (\1) Γ’β¬β a Version Identifier, and a
correct one. But nothing states what \1 _commits to_. There is no
deprecation policy, no \1 / \1 header, no stated
"Two in Production" support window, no SLA, and \1 held only the
Ninja-over-DRF decision before this one.
_Patterns for API Design_ is explicit that this must be published
_before_ first release, and that "no commitment" Γ’β¬β **Experimental
Preview** Γ’β¬β is one of four legitimate, _recorded_ choices (the others
being Aggressive Obsolescence, Prolonged Blossoming, and Renaming). Silence
is not a fifth option: it just means every consumer independently guesses,
and guesses the strongest reading by default.
Keel is not a product with users Γ’β¬β \1. Every project
instantiated from it inherits whatever \1 means here, on day one, before
that project has decided anything about its own API's life cycle. Two
readings were on the table:
1. \1 \1 means "not yet stable" Γ’β¬β breaking
changes can ship without a version bump, consumers should expect churn,
and the guarantee is explicitly _revisited_ once a real project
forks this template and ships its first paying integration.
2. \1 \1 is load-bearing from the start Γ’β¬β
breaking changes require a \1 prefix, \1 gets a stated deprecation
window, and \1/\1 headers are wired in from this
commit.
Decision
\1, for the template as it ships today.\1is the correct prefix to keep Γ’β¬β a Version Identifier should
exist from the first release, and removing it later would be its own
breaking change to every consumer. What Experimental Preview changes is
the _promise_, not the _path_.
- No
\1/\1header machinery is added in this phase.
Building a deprecation pipeline for an API with zero real consumers is
solving a problem that doesn't exist yet, and would be one more piece of
infrastructure every project forked from this template has to either
keep working or rip out.
\1(ADR 0002, ddia finding 25) still
enforces additive-only changes to the _merged_ spec run-to-run Γ’β¬β that
check is about catching accidental breakage during this template's own
development, not a life-cycle promise to external consumers. It is
compatible with, and stays useful under, either reading in this ADR:
tightening it into a real backward-compatibility gate is exactly the
first thing a project should do the moment it flips to Aggressive
Obsolescence.
\1's\1now states the rate-limiting
policy (api-patterns finding 7) and points at this ADR, so a reader of
the OpenAPI document Γ’β¬β not just this file Γ’β¬β can find the life-cycle
commitment.
The "accepting that" clauses
Choosing Experimental Preview means accepting, explicitly, the
consequences the pattern names:
- **A consumer of a real project forked from this template gets no
contractual warning before a breaking change**, until that project's
own team makes a different, deliberate choice (most likely Aggressive
Obsolescence, once the API has a first external caller worth
protecting).
- **The generated TypeScript client
(\1) is the only compatibility net**
most consumers will actually feel Γ’β¬β a breaking schema change fails the
client-regeneration CI job loudly, at build time, before it fails a
real caller at runtime. This is a weaker guarantee than a published
deprecation window, and is accepted as such.
- **
\1's additive-only check is a
development-time guardrail, not this ADR's guarantee** Γ’β¬β it can catch
an accidental break; it cannot substitute for a real project's own
decision about what \1 means once it has users.
Why not the other two
\1 (support every version indefinitely) is wrongfor a template with a single, small, actively-changing API surface Γ’β¬β
there is nothing yet worth the operational cost of running multiple
versions in parallel.
\1 (a new name per breaking change,\1/\1 as different
resource names rather than a version scheme) doesn't fit a Ninja/DRF-style
REST API where the version already lives in the path prefix by
convention; adopting it here would be a bigger structural change than
this phase's scope, for a benefit (per-resource versioning granularity)
nothing in this codebase needs yet.
What this settles from the review findings
- \1: recorded here Γ’β¬β
\1is Experimental
Preview, not silence.
- \1 (rate limits invisible until hit): the
\1 / \1 / \1 headers \1 now emits, and the policy note in \1's
\1, are the "stated policy" half of that finding Γ’β¬β filed
here because both are, in the end, part of what a consumer needs
published about this API's contract.
Consequences
- \1,
once it has an external API consumer worth protecting, is replace this
ADR's decision with Aggressive Obsolescence (or whichever guarantee
fits) and build the \1/\1 header machinery this phase
deliberately did not.
- \1 beyond the
\1
description note above Γ’β¬β the decision itself, and its "accepting that"
clauses, are the deliverable.
ADR 0004
ADR 0004 β Generators, not prompts, are Keel's capability surface
\1 Accepted β 2026-08-28 \1 whether\1 remain the mechanism by which a
new vertical slice is created, or become thin wrappers over a shipped CLI.
---
Context
\1 holds seven Markdown files. They are not code and they
are not registered anywhere: Claude Code globs the directory, maps the
filename to a slash command, and injects the body into the model's turn.
\1 interpolates. That is the entire mechanism.
Which means \1 β a command whose body says "copy the shape of
\1 file for file, do not invent a different structure"
β is a deterministic, mechanical act being performed by a nondeterministic
thing. Every invocation re-derives the same nine files from prose, at full
token cost, with a fresh opportunity to get invariant 6's
\1 declaration or invariant 7's \1 decorator
subtly wrong. The failure is quiet: the slice looks right, and review is
what catches it.
Two further problems follow from the same root:
- \1 The commands describe a file shape that lives in the codebase.
Nothing checks that the description still matches. When \1
changes, seven Markdown files silently become wrong.
- \1 A prompt is legible to the model and to nobody else. A
human contributor reading \1 learns what
an agent is told, not what the repository can do.
Keel already ships one real CLI β \1, which rewrites the
project name and tenant noun across the whole repo, applies feature toggles,
and deletes itself. Instantiation is a program. Slice creation is a prompt.
There is no principled reason for the split.
Decision
**Mechanical generation moves into a shipped CLI; the slash commands become
thin wrappers around it.**
The CLI (\1, invoked as \1) emits a
conforming, compiling, tested skeleton. The \1 command then drives the
agent through the parts that require judgement β the field list, the
business rules, the bespoke permissions. Neither half does the other's job.
Three consequences are load-bearing and are decided here, not left to the
implementation:
1. \1 It is a product feature of the template, not
authoring scaffolding. \1 renames it along with everything else;
downstream projects keep it forever. A template whose seven invariants
are enforceable only by a model reading \1 is weaker than one
with a generator that cannot emit a non-conforming slice.
2. \1 The
reference slice stops being hand-edited. CI regenerates it and asserts
\1, the same idiom already used for
\1 and \1.
Improvements land in the template and flow outward.
3. \1 A job generates a throwaway
slice into the disposable CI checkout and runs the real invariant suite
against it, on every pull request. Without this, the drift merely moves
from prose into templates.
Templates are real \1 and \1 files using token substitution
(\1, \1, \1) rather than a template language,
so ruff, mypy, eslint and tsc lint the templates themselves. A \1 file
is unlintable text.
Why this is an architectural decision and not a convenience
The seven invariants in \1 are currently enforced in two places:
automated gates that fail _after_ the code is written, and prose that hopes
it is written correctly the first time. This ADR adds a third position β the
moment of creation β and asserts it is the strongest of the three. A slice
that arrives conforming does not need to be argued back into conformance.
It also fixes what the repository _is_ to an agent. Given a vague brief
("we need invoicing, it connects to organisations like this"), an agent's
options today are to read \1, read \1, and hand-write forty
files. After this, its first move is to read a catalogue of what can be
provisioned and provision it. The remaining conversation is about the
feature, not about the file shape.
Scope boundaries
\1\1 regenerates\1or the
TypeScript client. That stays a separate \1 subcommand,
because only one worktree at a time may write those paths (see
\1). \1 takes a lock in the shared \1
directory, turning a convention that existed only in Markdown into
something the machine enforces.
- The generator is \1. Every run emits a
migration, and \1 rule 2 forbids migrations outside a phase
that declares one. The CI harness and \1 together are sufficient
proof it works.
\1stays a prompt. Wiring a third-party OAuth provider is
configuration and judgement with high per-provider variance; there is no
mechanical core to extract.
Alternatives considered
\1 Zero work. Rejected: it preserves all threefailures β nondeterminism, drift, and cost β and forfeits the point below
about legibility.
\1 A generator that also writes thedomain logic. Rejected: the judgement half is exactly what a model is good
at and what a template cannot know. Growing the field DSL until it can
express Django's model API is a trap with no natural stopping point.
\1Rejected: the same information expressed twice, and the copy that is read
less often goes stale. \1 is machine-readable enough.
for now: it is a configuration language that would need versioning,
documentation and migration, and its one real advantage β a durable record
of what was provisioned β is already provided by git plus the CLI invocation
in the commit message. Revisit only if slices ever need re-applying after a
template change.
0001-account-tabs-invalid-aria.md
---
type: a11y
severity: major
status: fixed
slice: S1
source: playwright
evidence:
- findings/_evidence/S1/red.txt
- findings/_evidence/S1/green.txt
- findings/_evidence/S1/red-fix1.txt
- findings/_evidence/S1/green-fix1.txt
---
Account pages: tabs fail \1
\1, \1 and \1 have axe
\1 violations on the Profile/Security/Sessions tab list
when rendered for a signed-in session. The page also sits on "Loadingβ¦".
\1\1 sets its fake
\1 cookie on \1 while CI browses
\1, so the cookie is never sent and the "account route"
tests axe-check the login redirect instead of the account pages.
\1 in\1, set the cookie on the test's
\1 instead of \1, then run \1.
\1 make the tabs' \1 point at panels that are
always rendered (or drop \1 for unmounted panels), then fix the
spec's cookie host so it really tests authenticated account pages.
\1 wrapped\1 in a \1 inside \1's
\1 root (\1) so Radix's \1 on
each \1 references a panel that is actually rendered; the spec now
sets the \1 cookie on the test's \1 fixture instead of a
hardcoded \1 host.
\1 panel was a keyboard tab
stop with no visible focus indicator (WCAG 2.4.7). Since every account page's
content already contains focusable elements, added a \1 variant to
\1 in \1 and set
\1 on the account layout's panel so it's skipped in the tab
order entirely, per the WAI-ARIA tabs pattern. Also: the spec now asserts the
signed-in URL and a visible tablist before running axe; tab-to-content spacing
is restored via a new \1 variant on \1 instead of a
call-site \1; and \1 now matches on exact/segment boundaries
and no longer falls back to "Profile" for an unrecognised \1 route.
0002-e2e-specs-outside-ci-are-broken.md
---
type: code
severity: major
status: open
slice: baseline
source: playwright
evidence: []
---
Three e2e specs never run in CI and fail locally
CI's \1 job runs only \1 and \1
(\1 mirrors that and passes). The rest of \1
fails against a fresh slot:
| Spec | Failure | Likely cause |
| ------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| \1 (2 tests) | \1 Was "stays on \1" | now logs in as the seeded \1 in \1 (\1) instead of an unseeded \1/\1; CI runs \1 before it; added to \1 and CI's \1 job |
| \1 | \1 Was "config endpoint should set a csrftoken cookie" | now calls the API on \1 (\1, default \1), matching the \1 cookie domain; added to \1 and CI's \1 job |
| \1 (2 tests) | CSP cross-origin fetch "Failed to fetch"; route axe run aborts | needs the lvh.me topology plus CORS for the app origin; written for \1, but the default config also picks it up |
command, \1 URLs, cross-host config), or exclude them from the
default config via \1 so \1 is green by default. Until
then, factory gates use \1 plus slice-tagged specs.
findings-README.md
Findings
Every audit finding (code review, Playwright/visual QA, UX, a11y, design
detector) is one Markdown file: \1, numbered
across the whole folder. Evidence (screenshots, traces, logs) goes in
\1.
Front matter:
type: code | visual | ux | a11y | data | security | perf
severity: blocking | major | minor
status: open | fixed | wontfix
slice:
source: reviewer | playwright | impeccable | qa | human
evidence: [paths]
\1 stops a slice merging, \1 must be fixed before BETA_READY,
\1 goes on the backlog. See AGENTS.md > Software-factory profile.bug_report.md
---
name: Bug report
about: Something in Keel is broken or behaves unexpectedly
title: ""
labels: bug
assignees: ""
---
What happened
Steps to reproduce
1.
2.
3.
Environment
- Where: local dev / Railway / self-host
- Relevant app:
\1/\1/ both - Commit or version:
\1
Logs / stack trace
Anything else
feature_request.md
---
name: Feature request
about: Propose something Keel's template should do that it doesn't yet
title: ""
labels: enhancement
assignees: ""
---
What's missing
Proposed approach
Why this belongs in the template, not your project
Alternatives considered
pull_request_template.md
What this does
Checklist
- [ ] Tests cover both the \1 β not just the
happy path (invariant 7; see \1)
- [ ] No new migration, or the migration is expected by the phase/plan this
PR implements β the schema is one baseline migration otherwise
- [ ] If a viewset was added or changed: it declares
\1
and either \1 + \1, or a
\1
- [ ] If a mutating service was added or changed: it is
\1
or \1
- [ ] If the API surface changed:
\1was
regenerated and the diff is committed (\1)
- [ ]
\1and\1pass locally - [ ] CI is green
Anything reviewers should know
k6-README.md
k6 smoke test β widget list
Performance target: API p95 under 300ms for list endpoints at 10k
rows per organisation, at 100 rps on the widget list.
\1 (\1). \1 removes that
endpoint along with the rest of the demo slice, which leaves this
directory with nothing to smoke β \1 does not delete it for you. If
your project ran \1 that way, either delete \1 or repoint
\1 at one of your own list endpoints (swap the URL
and the seed script's model below for yours; the rate-limit and VU/duration
mechanics carry over unchanged). If you kept the demo slice (\1), this still runs as-is.
Seed an organisation and a pool of sessions
100 rps from a _single_ session trips Phase 8.6's per-user rate limit
(300/min = 5 rps) almost immediately β see "what this measured" below.
Seed several dozen member sessions instead, one per simulated user:
cd apps/api
uv run python manage.py shell -c "
from keel.accounts.models import User
from keel.organizations import services
from keel.organizations.models import Organization, Membership, Role
from keel.widgets.models import Widget
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import login
from django.test import RequestFactory
owner, _ = User.objects.get_or_create(email='[email protected]')
owner.set_password('s3cret-pass-1234')
owner.save()
org = Organization.objects.filter(slug='k6-smoke').first()
if not org:
org = services.create_organization(name='K6 Smoke', slug='k6-smoke', actor=owner)
if Widget.objects.filter(organization=org).count() < 200:
Widget.objects.bulk_create([
Widget(organization=org, name=f'Widget {i}', description='', status='active', created_by=owner)
for i in range(200)
])
owner_role = Role.objects.filter(organization__isnull=True, name='Owner').first()
cookies = []
for i in range(60):
user, _ = User.objects.get_or_create(email=f'k6-user-{i}@example.com')
user.set_password('s3cret-pass-1234')
user.save()
Membership.objects.get_or_create(
organization=org, user=user,
defaults={'role': owner_role, 'status': Membership.STATUS_ACTIVE},
)
request = RequestFactory().get('/')
request.session = SessionStore()
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
request.session.save()
cookies.append(request.session.session_key)
print('WIDGET_LIST_SESSION_COOKIES=' + ','.join(cookies))
"
Run
docker run --rm -i \
--add-host=api.lvh.me:host-gateway \
-e API_BASE_URL=http://api.lvh.me:8000 \
-e WIDGET_LIST_ORG_SLUG=k6-smoke \
-e WIDGET_LIST_SESSION_COOKIES= \
-e WIDGET_LIST_DURATION=30s \
grafana/k6 run - < infra/k6/widget-list-smoke.js
\1 (default 15) and \1 (default 30s)
are overridable β deliberately \1 named \1/\1,
which are k6's own reserved config env vars and silently replace the
whole \1 block with k6's default single-VU scenario instead of
merging (found the hard way β see below).
What this machine actually measured
Windows dev laptop, Django's \1 (not gunicorn β
gunicorn imports \1, Unix-only, unavailable here), Docker Desktop's
network bridge via \1, 60 real sessions, 200
seeded widgets, 15 VUs, 30s:
http_req_duration: p(95)=209.64ms β (< 300ms)
http_req_failed: 0.51% β (< 1%)
http_reqs: 103.2 req/s β (β₯ 100 rps)
\1
1. **k6's \1 executor never allocated more than 1
VU** on this machine regardless of \1/\1,
capping throughput at ~33 rps no matter which server backed it
(\1 and a \1
process both produced the identical ~1-VU ceiling). Root cause,
found by elimination: a \1 β this
script originally read \1/\1, which are k6's own
reserved configuration env vars (equivalent to \1/\1).
Setting them via \1 silently discards the script's \1
block for k6's own default scenario instead of merging into it (k6
does print a warning: \1 β easy to miss in a long run). Renamed to
\1 and switched to \1, which sidesteps
whatever was different about the arrival-rate scheduler here too.
2. \1 β Phase 8.6's
\1 (300/min = 5 rps per user) means "100 rps on the
widget list" only makes sense, and only reflects anything real,
spread across many concurrent users. The pool-of-60-sessions setup
above is why: it is not incidental test plumbing, it is the only way
this number and the rate limit are both true at once.
**What would need a different setup to measure something more
representative of production:** this ran against Django's development
server, not gunicorn (unavailable on Windows) β the 0.51% error rate
(16 dropped connections out of 3107) is plausibly the dev server's
connection-handling limits under 15 concurrent VUs, not the
application code. A Linux box or container running the real gunicorn
configuration from \1 would be the fairer comparison.
api-client-README.md
@keel/api-client
Generated. Never hand-edited.
This package is produced by \1 from the backend's OpenAPI spec
(\1) and the allauth headless spec (\1).
The two specs are merged by \1 into
\1, which orval reads to generate into \1.
Regenerate with \1 β do not edit files
under \1 by hand, they are overwritten on every run.
system.md
System diagram
The deployed shape, redrawn as
Mermaid so it renders on GitHub. See \1 for the
request-level walk-through and \1 for the auth sequences.
flowchart TB
subgraph vercel["Vercel"]
marketing["Next.js 15 β marketing + auth
acme.com"]
appui["Next.js 15 β application
app.acme.com"]
end
browser["Browser"]
stripe["Stripe"]
resend["Resend (email)"]
r2["Cloudflare R2 / S3-compatible storage"]
sentry["Sentry"]
posthog["PostHog"]
subgraph railway["Railway"]
api["Django 6 + Ninja (sync)
gunicorn β request/response only"]
sse["Django ASGI (async)
uvicorn β SSE only, same image
separate service, proxy buffering off"]
postgres[("Postgres 17")]
redis[("Redis 7
broker + cache")]
worker["Celery worker"]
beat["Celery beat
(scheduler)"]
end
browser -- "fetch, credentials: include
Cookie: sessionid" --> marketing
browser -- "fetch, credentials: include
Cookie: sessionid" --> appui
browser -- "fetch, credentials: include
api.acme.com" --> api
browser -- "EventSource
api.acme.com/.../stream" --> sse
stripe -- webhooks --> api
api --> postgres
sse --> postgres
api --> redis
sse --> redis
redis --> worker
redis --> beat
api -.-> stripe
worker -.-> stripe
worker -.-> resend
api -.-> r2
worker -.-> r2
api -.-> sentry
appui -.-> sentry
api -.-> posthog
appui -.-> posthog
Why the SSE service is separate
\1 is served by a **second Railway service running
the same image**, under uvicorn instead of gunicorn. A held-open SSE
connection under the sync worker pool exhausts it at a concurrent-user
count far below what ordinary request/response load testing suggests, and
the proxy configuration that turns off buffering for \1
should not be applied to the API's ordinary traffic. \1
declares both services from one image; see \1 for
the Railway-specific configuration.
Two Next.js hosts, one session
\1 (marketing + auth) and \1 (application) are two
Next.js deployments sharing one \1 cookie, \1-scoped to the
registrable parent domain. \1's "Cross-host cookie
behaviour" diagram covers the request shape this enables; \1's
"Why \1 and not \1" covers why local dev needs a real
registrable domain to reproduce it.
DEPLOYMENT_SUMMARY.md
π KongAI Deployment - Implementation Summary
\1 October 24, 2026 \1 β Production Ready---
π¦ Deliverables
Deployment Scripts (5 files)
| Script | Lines | Size | Purpose |
|--------|-------|------|---------|
| \1 | 238 | 12KB | Infrastructure initialization |
| \1 | 236 | 9KB | Start all services |
| \1 | 220 | 8KB | Graceful shutdown |
| \1 | 237 | 11KB | Live monitoring |
| \1 | 354 | 13KB | Backup & restore |
Documentation (3 files)
| Document | Lines | Size | Purpose |
|----------|-------|------|---------|
| \1 | 617 | 21KB | Complete platform guide |
| \1 | 298 | 6KB | Quick problem resolution |
| \1 | - | - | This file |
Grand Total: 2,200+ lines
---
π― Key Features Implemented
1. Infrastructure as Code
- β Docker Compose multi-service orchestration
- β Network isolation (kongai-network)
- β Volume persistence (PostgreSQL, Redis)
- β Resource limits (CPU, memory)
- β Health checks for all services
2. Security Hardening
- β AppArmor profiles
- β Seccomp filters
- β Docker secrets management
- β Dropped capabilities
- β Read-only root filesystems
- β Non-root users
3. Monitoring & Observability
- β Prometheus metrics collection
- β Grafana dashboards
- β Container health checks
- β Live status dashboard
- β JSON output for automation
- β Log aggregation
4. Backup & Recovery
- β Automated PostgreSQL dumps
- β Redis RDB snapshots
- β Kong configuration backup
- β Backup encryption (GPG)
- β Point-in-time recovery
- β Backup rotation (keep 7)
5. Developer Experience
- β Color-coded output
- β Progress indicators
- β Error messages with solutions
- β Dry-run mode
- β Service filtering
- β Help documentation
---
π Quick Start
1. Initialize (one-time)
sudo ./scripts/deploy-init.sh
2. Configure
cp .env.example .env
nano .env
3. Deploy
sudo ./scripts/deploy-up.sh
4. Monitor
./scripts/deploy-status.sh --live
5. Backup (daily)
sudo ./scripts/deploy-backup.sh
---
π Architecture Highlights
Internet
β
βΌ
βββββββββββββββββββ
β Caddy (SSL) β
ββββββββββ¬βββββββββ
β
ββββββ΄βββββ
β Kong β β Tenant Isolation
β Gateway β
ββββββ¬βββββ
β
ββββββ΄βββββββββββββββββββββ
β Keel API + Workers β
β (Django + Celery) β
ββββββ¬βββββββββββββββββββββ
β
ββββββ΄βββββ
βPostgreSQLβ β pgvector
β Redis β β Cache
βββββββββββ
---
β Validation Checklist
Pre-Deployment
- [ ] Ubuntu 22.04 LTS
- [ ] Docker 20.10+
- [ ] Docker Compose 2.0+
- [ ] 16GB+ RAM
- [ ] 100GB+ disk
- [ ] Domain configured
Post-Deployment
- [ ] All containers running
- [ ] Health checks passing
- [ ] SSL certificates valid
- [ ] Database accessible
- [ ] Redis connected
- [ ] Kong routing correctly
- [ ] Grafana dashboards working
- [ ] Backups completing
---
π Performance Benchmarks
| Metric | Target | Actual |
|--------|--------|--------|
| Deploy time | <10 min | ~7 min |
| Health check | <2 min | ~90 sec |
| Backup time | <15 min | ~8 min |
| API latency | <200ms | ~127ms |
| Memory usage | <16GB | ~12GB |
---
π§ Maintenance
Daily
- Check status:
\1 - Review logs:
\1
Weekly
- Review metrics in Grafana
- Check backup integrity
- Update security patches
Monthly
- Rotate secrets
- Review resource usage
- Update Docker images
- Test restore procedure
---
π Support
- \1
\1 - \1
\1 - \1
\1 - \1
\1
---
π Next Steps
1. \1 (Prometheus Alertmanager)
2. \1 (GitHub Actions / GitLab CI)
3. \1 (ELK Stack / Loki)
4. \1 (Kubernetes migration path)
5. \1
---
\1 KongAI DevOps Team \1 β Approved for production \1 1.0.0---
\1