KongAI Complete Documentation

All 34 documentation files

Getting Started

README.md

/root/kongai-staging/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

\1
Worker 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

\1
502 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

\1
SSL_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 \1

AGENTS.md

/root/kongai-staging/AGENTS.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

  • \1 is 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 \1 or 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 \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 App code composes \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.

\1 Audit findings (review, Playwright/visual, UX, design

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

/root/kongai-staging/CLAUDE.md

@AGENTS.md

dev-setup.md

/root/kongai-staging/docs/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

/root/kongai-staging/docs/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 the

one 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
  • \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.
  • \1 in \1 holds
\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 \1 in 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

/root/kongai-staging/docs/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 Every other \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

/root/kongai-staging/docs/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.
  • **\1 and 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

  • **\1 lives 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

/root/kongai-staging/docs/deployment.md

auth-flow.md

/root/kongai-staging/docs/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

\1 client β€” cookie-based, CSRF-protected). Django is the sole

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.

  • \1 responses 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 own

OAuth 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

/root/kongai-staging/docs/auth-client.md

boundary-guardrails.md

/root/kongai-staging/docs/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.

\1
  • \1 adds a \1 rule

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).
  • \1 greps \1 for
\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 config

exemption list, so the justification travels with the code:

  • TS/TSX: standard \1. \1 now 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.

\1 not written. There is no path in this repo that

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.

\1

1. \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.

\1
  • 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 \1 row 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.

  • \1 sets \1 and reports to Sentry

on final failure (\1) β€” already correct.

  • \1 + \1's \1 action

(\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 \1 overrides 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/\1 are 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.
  • \1 makes 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.

  • \1 correctly 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

/root/kongai-staging/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

/root/kongai-staging/docs/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 / \1 are only read under
\1. As top-level service keys they are silently dropped β€” the

plan showed \1.

  • \1 defaults the branch to \1 and 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

\1. Without an override the

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

\1 Against the Postgres Railway provisions today:
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.

\1 a deliberately-broken migration, and therefore the

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 described

here 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

/root/kongai-staging/docs/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) takes

priority 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

/root/kongai-staging/docs/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

  • \1 and \1 β€” \1 needs \1 plus

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 \1 job's import-linter check) β€” same

reasoning.

  • \1 β€” needs the \1 run 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

/root/kongai-staging/docs/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: \1 retries 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 \1 writes a \1 row

(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

/root/kongai-staging/docs/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:

  • \1 read \1 β€” \1

now prefetches \1 (ordered by \1), so the list endpoint's

query count no longer grows with the number of jobs.

  • \1 read \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

/root/kongai-staging/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), \1 as 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 β€” \1 generator (Tier 1 and Tier 2), \1 ship-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 β€” \1 frontend 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
  • \1 finalised and tagged \1
  • \1 and \1 degrade

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 \1 against 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 β€” \1 wired into CI with a hard error

on a default \1; secrets and logging hardening

  • \1 β€” rendering, validation, and async task-boundary guardrails
  • \1 β€” local \1 hook (\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 \1 and \1 call 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

  • \1 renamed to \1 to 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 β€” \1 and the \1 slash 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
  • \1 accessibility checks on app routes; k6 smoke test on the

widget list

Phase 7 β€” Marketing site

  • \1 route 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 \1 from the marketing/auth apex
  • \1 and the shared component kit
  • Widget vertical slice (full CRUD, end to end) as the reference resource
  • Command palette with resource search; \1 sort/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
  • \1 management 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 + \1 for auth and account routes

Phase 1 β€” Core domain primitives

  • \1 and \1 apps, ID scheme, base models, error envelope
  • \1 β€” the authorization registry and \1 type
  • Audit decorators (\1 / \1), cursor pagination,

the Tier 1 task shim

  • Encryption seam for \1 tokens
  • \1 model, 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), \1 Next.js 15

skeleton, \1 theme contract and \1 stub

  • \1 Django 6 skeleton with \1, \1
  • ruff, mypy, \1, and pre-commit configuration
  • Per-directory coverage gate (\1)
  • \1 stub
  • CI workflow (\1)
  • pgvector verification and \1

CONTRIBUTING.md

/root/kongai-staging/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 \1 and \1
  • \1 β€” \1, pytest (including an

MFA-enabled run), then \1's per-directory

coverage floors

  • \1 β€” vitest for \1 and \1
  • \1 β€” regenerates the TypeScript client from the

OpenAPI spec and fails if \1 is stale

  • \1 β€” Playwright + \1 over auth and account

routes

  • \1 β€” \1 (the \1 and \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 \1 and
\1 locally before pushing; both are slow enough in CI

that a broken run wastes a cycle.

marketing-removal.md

/root/kongai-staging/docs/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 (\1 and its test).
  • \1 and \1 β€” only used by the

routes above.

  • \1
  • \1 and 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:

  • \1 wrapper in \1 β€”

revert to a plain \1 export.

  • The \1 entry in \1's \1

and in \1's \1.

  • The \1 script in \1, and its
\1 prefix on \1.

Environment

  • \1 in \1 β€” only \1 reads 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

/root/kongai-staging/docs/adr/0001-django-ninja-over-drf.md

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 API

layer, 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 and

jobs 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 the

best parts of the codebase. Rejected β€” see above.

\1 Resolves the question with

evidence 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. Two

API idioms in one template defeats the purpose of a template.

ADR 0002

/root/kongai-staging/docs/adr/0002-auth-bff-shape.md

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 hardening

work 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 route

handler per endpoint:

  • \1 forwards 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.

  • \1 rewrites \1 to
\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 \1 becomes 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 \1 default 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 it

keeps 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 1

materially 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 (\1 is 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

/root/kongai-staging/docs/adr/0003-api-lifecycle-guarantee.md

ο»Ώ# ADR 0003 Ò€” \1 is an Experimental Preview, not a versioning promise

\1 Accepted Ò€” 2026-08-28 \1 api-patterns finding 20 Ò€” \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.
  • \1 is 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 / \1 header 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 \1 now 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 wrong

for 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 Ò€” \1 is 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

/root/kongai-staging/docs/adr/0004-generators-as-the-agent-capability-surface.md

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 \1 or 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.

  • \1 stays 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 three

failures β€” nondeterminism, drift, and cost β€” and forfeits the point below

about legibility.

\1 A generator that also writes the

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

\1

Rejected: the same information expressed twice, and the copy that is read

less often goes stale. \1 is machine-readable enough.

\1 Rejected

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

/root/kongai-staging/findings/a11y/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 the new \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

/root/kongai-staging/findings/code/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 |

\1 either add these to CI with the setup they need (seed

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

/root/kongai-staging/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

/root/kongai-staging/.github/ISSUE_TEMPLATE/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

/root/kongai-staging/.github/ISSUE_TEMPLATE/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

/root/kongai-staging/.github/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: \1 was

regenerated and the diff is committed (\1)

  • [ ] \1 and \1 pass locally
  • [ ] CI is green

Anything reviewers should know

k6-README.md

/root/kongai-staging/infra/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

/root/kongai-staging/packages/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

/root/kongai-staging/docs/diagrams/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

/root/kongai-staging/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 |

\1 1,285 lines of deployment automation

Documentation (3 files)

| Document | Lines | Size | Purpose |

|----------|-------|------|---------|

| \1 | 617 | 21KB | Complete platform guide |

| \1 | 298 | 6KB | Quick problem resolution |

| \1 | - | - | This file |

\1 915+ lines of documentation

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