v0.3 milestone merged to main. Mastery scoring + competency rubrics + verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered. Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres). ---ci--- project: praxis phase: 2 milestone: v0.3 status: complete milestone_complete: true milestone_merged_to_main: true ---/ci---
21 KiB
RESEARCH: Operator Tier — Postgres-in-LXC + Auth for v0.3
Scope: Research only. No code changes. Grounded in the current Praxis repo
(docker-compose.yml single praxis service; db/store.py aiosqlite
PraxisStore; db/migrate.py ordered .sql migrations; SQLite schema at
db/schema.sql).
Decisions honored: D-007 (SQLite learner, preserved), D-031 (hybrid: SQLite for learner, Postgres for operator), D-040 (Postgres = second docker-compose service in the existing LXC CT), D-041 (session-cookie auth, argon2id, single operator role, rate-limited).
Confidence scores are 0–1 (1 = well-established practice / low risk).
1. Docker-Compose Shape (confidence: 0.90)
Add a postgres service alongside the existing praxis service. Key
best-practices for a second service in an already-running LXC CT:
- Image:
postgres:16-slim(Debian-slim base, glibc — matches the praxis Dockerfile rationale; avoids Alpine musl locale issues withpg_*clients). - Persistence: named volume
pgdata(driver: local). Never bind-mount/var/lib/postgresql/datato the CT filesystem — Postgres requireschown 999and a specific directory layout; named volumes handle this. - Network isolation: declare an explicit internal compose network and
attach only
praxisandpostgresto it. Do not publish5432viaports:. Thepraxisservice keeps its published8789.internal: trueon the network blocks egress to the host bridge, but note: withinternal: truethe postgres container cannot reach the internet (fine — it doesn't need to). If you later want outbound backups via network, dropinternal: trueand instead rely on not publishing the port. The simpler, robust choice for a pilot is: explicit named network, noports:on postgres, nointernal: true.
- Healthcheck:
pg_isready -U praxis -d praxisevery 10s, 5 retries, 5s timeout.depends_on: { postgres: { condition: service_healthy } }on thepraxisservice so the app waits for accept-connections, not just container start. - Init scripts: mount
./db/pg/init/*.sql(or.sh) at/docker-entrypoint-initdb.d/. These run only on first boot (emptypgdata). Use them for: role/db creation, schema bootstrap, and idempotent seed. For versioned schema changes use a migration runner (see §6) — init scripts are one-shot. - Env:
POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DBfrom the existing/etc/praxis/server.env(do not commit secrets to the compose file). AddPGDATA=/var/lib/postgresql/data/pgdatato pin the subdirectory (survives image upgrades). - Restart:
restart: unless-stopped(matches praxis). - Resources: for a pilot on a small LXC CT, set a mem limit
(
deploy.resources.limits.memory: 512m) and rely on Postgres defaultshared_buffers. Tune later.
Sketch (shape only, not for commit):
services:
praxis:
# ... existing v0.2 fields unchanged ...
depends_on:
postgres:
condition: service_healthy
networks: [praxis-net]
postgres:
image: postgres:16-slim
restart: unless-stopped
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DB:-praxis_operator}
PGDATA: /var/lib/postgresql/data/pgdata
env_file:
- path: /etc/praxis/server.env
required: false
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/pg/init:/docker-entrypoint-initdb.d:ro
- pgbackups:/backups
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER:-praxis} -d ${PG_DB:-praxis_operator}"]
interval: 10s
timeout: 5s
retries: 5
networks: [praxis-net]
# NOTE: no `ports:` — not exposed to the LXC host bridge.
volumes:
praxis-data:
driver: local
pgdata:
driver: local
pgbackups:
driver: local
networks:
praxis-net:
driver: bridge
Risk callouts:
- If
praxiscurrently has no explicit network, compose assigns the default bridge; adding an explicit network means the existingpraxisservice gets recreated onup. Plan a brief downtime window (see §6). pg_isreadyreturns healthy before the DB is fully ready for migration load;depends_on: service_healthyis necessary but not sufficient — the app must still retry the first migration attempt.
2. Connection Management (confidence: 0.85)
Two async DB drivers in one process: aiosqlite (already a dep) for the learner store, asyncpg for the operator store.
- Pools are independent and must not be shared. asyncpg uses a
asyncpg.create_pool(...)(sized pool, real connections). aiosqlite opens a fresh connection perasync with aiosqlite.connect(...)(the currentPraxisStore._connectpattern). They have nothing in common — different backends, different lifecycles. Do not wrap them in a single sharedAsyncSessionobject; SQLAlchemy's async session is an option only if you adopt SQLAlchemy for both — that's a larger refactor and not warranted for v0.3. - Pool sizing (avoid exhaustion):
- asyncpg pool:
min_size=2, max_size=10for a pilot single-instance. Operator endpoints are low-frequency (cohort dashboard, VC issuance). - aiosqlite: no pool; the current pattern opens/closes per call. SQLite
is single-writer; keep
WALmode and short transactions. This is already fine for one learner. - Total concurrent DB connections ≈ asyncpg(10) + aiosqlite(1-2). On a small CT this is trivial. Exhaustion risk is essentially zero at pilot scale; revisit if operator endpoints are hit by N concurrent cohort users.
- asyncpg pool:
- Lifecycle: create the asyncpg pool once at FastAPI startup
(
lifespancontext manager), close on shutdown. Store onapp.state.pg_pool. ThePraxisStorekeeps its current per-call connect pattern (no change to D-007 code path). - Transaction boundaries: asyncpg use
pool.acquire()+conn.transaction()for multi-statement writes; aiosqlite unchanged. - Config:
PG_DSNenv var, e.g.postgresql://praxis:***@postgres:5432/praxis_operator(host = service name onpraxis-net). - Statement timeout: set
command_timeout=10on the asyncpg pool to prevent a slow operator query from blocking the event loop.
Pip: asyncpg>=0.29 (new dep). aiosqlite>=0.20 already present.
3. Auth Stack (confidence: 0.90 for the stack; 0.70 for rate-limit choice)
D-041 spec: session-cookie, argon2id, single operator role, rate-limited.
3a. Session cookie
starletteSessionMiddleware(FastAPI bundles Starlette). Usesitsdangerousto sign the cookie — no server-side session store needed (stateless, fits single-instance LXC). Data lives in the cookie itself, signed withSECRET_KEY.- Settings:
secret_key: from env, ≥32 bytes random. Rotate by changing the key (invalidates all sessions — acceptable for a pilot).session_cookie:"praxis_op"(distinct from any future learner cookie name).max_age:28800(8h, per D-041).path:/(or scope to/opif operator routes live under a prefix — cleaner).https_only:True(Secure flag). Requires TLS — the LXC deployment must terminate TLS (reverse proxy / Caddy / Proxmox level). If running plain HTTP on the LAN for the pilot, set toFalsetemporarily and document the risk; never ship False.httponly:True(the middleware sets this by default; verify).samesite:"strict"(D-041). CSRF defense-in-depth; with Strict, no credential is sent on cross-site navigations.
- Cookie contents: store
{operator_id: str, issued_at: epoch}. Never store the password hash or any PII. Roles aren't needed in the cookie yet (single role — see §4).
3b. Password hashing — argon2id
argon2-cffi(PasswordHasherdefault is argon2id, RFC 9106). Pip:argon2-cffi>=23.1.- On login:
ph.verify(stored_hash, password)→ on success,ph.check_needs_rehash(stored_hash)→ rehash if params bumped. - Params: keep
PasswordHasher()defaults for v0.3 (time_cost=3, memory_cost=64MiB, parallelism=4— reasonable on a small CT; benchmark and tune if login latency > 1s). - Store the hash as
TEXTinoperators.password_hash.
3c. Rate limiting
Two options:
slowapi(pipslowapi>=0.1) — the idiomatic FastAPI choice. Decorator/IP-based limiter. Default in-memory backend is fine for single-instance. Confidence 0.70 — it works, but it's a young lib and the in-memory backend is per-process (breaks if you ever scale to1 praxis process; not a v0.3 concern).
- In-memory counter (a simple
dict[remote_ip, (count, window_start)]in a small dependency) — zero deps, trivially auditable. For a single operator login endpoint this is enough. Confidence 0.80 for the pilot specifically.
Recommendation: start with slowapi on the login route only
(@limiter.limit("5/minute")), in-memory backend. Migrate to a Redis
backend only if/when you go multi-instance. Threshold: 5 failed
attempts/minute/IP → 429 + exponential backoff marker.
Pip additions: argon2-cffi>=23.1, slowapi>=0.1. (starlette and
itsdangerous come with FastAPI.)
4. Auth Dependency Pattern (confidence: 0.90)
Single-role v0.3 → no RBAC framework needed. A single FastAPI
Depends that resolves the operator from the signed session is the
minimal secure shape.
Concept (not committed code):
# pseudo — shape only
async def current_operator(request: Request) -> Operator:
sess = request.session # populated by SessionMiddleware
op_id = sess.get("operator_id")
if not op_id:
raise HTTPException(401, "not authenticated")
op = await pg_store.get_operator(op_id)
if not op or not op.is_active:
# invalidate the cookie
request.session.clear()
raise HTTPException(401, "operator not found / disabled")
return op
- Apply via
Depends(current_operator)on every operator-tier router. Group operator routes under anAPIRouter(prefix="/op")and attach the dependency at the router level (dependencies=[Depends(current_operator)]) — one declaration, not per-endpoint. - Login/logout are outside the protected router (login is rate- limited, not auth-gated).
- CSRF: with
SameSite=Strict+httponlycookies, CSRF surface is minimal for state-changing requests. If any operator endpoint acceptsContent-Type: application/x-www-form-urlencoded/multipart(form posts), add a double-submit token or requireContent-Type: application/jsononly (the latter is the cheaper defense — JSON bodies are not auto-sent by browsers across origins).
When to migrate to RBAC
Migrate when any of these become true:
- A second role appears (admin, auditor, reviewer) — i.e. v0.4+ if the pilot expands.
- Permissions diverge within a role (e.g. some operators can issue VCs, others can only view cohorts).
- You need row-level visibility rules (operator A sees only their cohort).
At that point the cheapest upgrade is: add a role column to
operators, split current_operator into current_operator (any
authenticated) + require_role("admin") (a parametrized dependency
checking op.role). Reach for a full RBAC lib (casbin,
fastapi-permissions) only when the role matrix exceeds ~3 roles × ~5
permissions. Don't pre-build it.
5. Postgres Schema (confidence: 0.80)
Operator-tier tables. Types chosen for Postgres 16 specifically
(TIMESTAMPTZ, BIGSERIAL, GENERIC via JSONB).
operators
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
username TEXT NOT NULL UNIQUE
password_hash TEXT NOT NULL -- argon2id
display_name TEXT NOT NULL
role TEXT NOT NULL DEFAULT 'operator' -- reserved for §4 migration
is_active BOOLEAN NOT NULL DEFAULT TRUE
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
last_login_at TIMESTAMPTZ
- Index: unique on
username(covered by constraint). No extra index needed at single-operator scale. - Requires
pgcryptoextension or Postgres 13+ (wheregen_random_uuid()is built-in viapgcryptoshipped default — actually:gen_random_uuid()is built into core as of PG 13). So no extension needed on PG16. ✓
issued_credentials
id BIGSERIAL PRIMARY KEY
operator_id UUID NOT NULL REFERENCES operators(id)
learner_ref TEXT, -- opaque ref into SQLite side (no FK cross-DB)
vc_type TEXT NOT NULL -- 'mastery' | 'completion' | ...
payload_jsonb JSONB NOT NULL -- the W3C VC document (signed elsewhere)
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
revoked_at TIMESTAMPTZ
- Indices:
issued_credentials(operator_id, issued_at DESC)— operator's issuance log.issued_credentials(learner_ref)— lookup by learner (k-anon aggregate joins).issued_credentials(vc_type)if filtering by type is a dashboard query.
mastery_gate_events
id BIGSERIAL PRIMARY KEY
learner_ref TEXT NOT NULL
scenario_id TEXT NOT NULL
path_id TEXT NOT NULL -- learning path
gate_outcome TEXT NOT NULL -- 'pass' | 'fail' | 'retry'
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
source TEXT NOT NULL DEFAULT 'sync' -- 'sync' from SQLite learner store
- Indices:
(learner_ref, recorded_at DESC)— per-learner timeline.(path_id, recorded_at)— feeds the cohort aggregate.
cohort_aggregates — k-anonymized
Model as pre-materialized rows partitioned by (path_id, week) with
a minimum bin size enforced at write time (k≥K, e.g. K=5). A 7-day
window is a rolling construct over the weekly partitions.
path_id TEXT NOT NULL
week_start DATE NOT NULL -- ISO week Monday
bin_count INTEGER NOT NULL -- learners in this bin
k_anon_pass INTEGER NOT NULL -- pass count, suppressed if < K
k_anon_fail INTEGER NOT NULL -- fail count, suppressed if < K
median_attempts INTEGER
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
PRIMARY KEY (path_id, week_start)
- k-anon rule: when materializing, if
bin_count < Kemitbin_count = <K-masked>and null-out the count columns (or clamp them to K). Enforce in the aggregation job, not in a SQL view, so the suppression is auditable at write time. - 7-day window: compute on read as a window function over the last
≤2 weekly partitions, or maintain a parallel rolling table. For a
pilot, compute on read:
SUM(k_anon_pass) ... WHERE week_start >= now()::date - interval '7 days'. - Indices: PK covers
(path_id, week_start). Add a secondary(week_start DESC)only if you query "all paths for the latest week" frequently.
General indices summary: 4 indices beyond PKs/constraints for v0.3 — keep it lean; add per slow-query evidence.
6. Migration Strategy (confidence: 0.85)
Goal: add Postgres to the running v0.2 LXC CT without breaking the learner service.
Steps (ordered, low-risk)
- Prepare on a staging CT first (clone the production LXC CT in Proxmox). Never test the migration path on the live CT.
- Add the
postgresservice +praxis-net+ volumes todocker-compose.yml. Thepraxisservice gainsdepends_on: postgres (service_healthy)and joinspraxis-net. - Add init scripts under
db/pg/init/:00_create_schema.sql— the four tables from §5.01_seed_operator.sh— creates the initial operator with an argon2id hash (run from env-supplied temp password; force password change on first login). These run only on first boot of an emptypgdatavolume.
- Add the asyncpg pool + operator store + auth wiring to the praxis
image (new code paths, new deps in
pyproject.toml). Learner paths (db/store.py,db/migrate.py) unchanged — D-007 preserved. - Build the new image (
docker compose build praxis) — does not touch the running container. - Controlled cutover:
docker compose up -d postgres→ wait for healthy.docker compose up -d praxis→ recreate the praxis container with the new image. Expect ~5–15s of downtime (the learner voice loop is not HA anyway). The SQLite volume (praxis-data) is untouched, so learner state is preserved across the recreate.
- Smoke tests:
/health, learner voice loop, operator login, one cohort-dashboard read. - Rollback plan: if operator endpoints misbehave, revert the
praxis image tag and
docker compose up -d praxisagain — Postgres stays up but unused. Learner path is independent, so a bad operator rollout does not regress v0.2 learner behavior. This is the core safety property of the hybrid (D-031) design.
Versioned migrations beyond first boot
The SQLite side already has db/migrate.py (ordered .sql, _migrations
table). For Postgres, two options:
- (a) Reuse the pattern: a
pg_migrate.pymirroring the SQLite runner, against a_pg_migrationstable. Lowest cognitive load — same mental model, same directory convention (db/pg/migrations/). - (b) Adopt
yoyo-migrationsoralembic: more machinery, not warranted at 4 tables.
Recommendation (a): mirror the existing runner. Run on praxis startup (after the pool is up), idempotent. Confidence 0.80 on the pattern; it's exactly what v0.2 already does for SQLite.
7. Backup (confidence: 0.85)
Minimum viable backup for a pilot operator Postgres in LXC:
- Method:
pg_dump -Fc(custom compressed format) → file in thepgbackupsvolume.-Fcgives you selective restore and parallel restore later. - Frequency: daily is enough for a pilot. A cron job inside the
postgres container (or a sidecar) runs:
Using
pg_dump -U praxis -Fc praxis_operator > /backups/pg_$(date +%u).dump%u(day-of-week 1–7) gives a rolling 7-file retention with zero cleanup logic. - Where:
/backupsis thepgbackupsnamed volume. Keep backups inside the compose stack so they move with the CT. For off-CT safety: a Proxmox-level cronpct push/rsyncof thepgbackupsvolume to the Proxmox host or a NAS — out of scope for the app, but the named volume makes it a one-line host-side copy. - Restore (drill it once):
docker compose exec postgres pg_restore -U praxis -d praxis_operator \ --clean --if-exists /backups/pg_3.dump--clean --if-existsdrops+recreates objects; safe against a partially-populated DB. Never restore into the live DB without stopping the praxis service first. - Don't back up the SQLite side here — it's already on the
praxis-datavolume and covered by whatever volume backup the CT already has. Keep the two backup streams separate (matches the hybrid design). - Encryption at rest: out of scope for the MVP; rely on LXC/Proxmox
disk encryption. If the
pgbackupsvolume is ever pulled off-host,gpg -cthe dump in the cron step.
Pip: none new for backup (uses pg_dump/pg_restore shipped with
the postgres image).
Summary table — new pip dependencies
| Dep | Purpose | Confidence |
|---|---|---|
asyncpg>=0.29 |
Postgres async driver / pool | 0.90 |
argon2-cffi>=23.1 |
argon2id password hashing | 0.95 |
slowapi>=0.1 |
login rate limiting (in-memory) | 0.70 |
starlette (already via FastAPI) |
SessionMiddleware signed cookies |
0.95 |
itsdangerous (already via Starlette) |
cookie signing | 0.95 |
Cross-cutting risks (watch list)
- TLS or not: Secure cookie flag requires TLS. Confirm the LXC
fronting layer terminates HTTPS before enabling
https_only=True. - First-boot-only init scripts: if
pgdataalready exists (e.g. after a failed first boot), seed scripts won't re-run — keep a separate re-runnable seed path (the01_seed_operator.shshould be idempotent viaON CONFLICT DO NOTHINGor a shell guard). - Two migration runners (SQLite + Postgres) — keep directory
layouts visually distinct:
db/migrations/(SQLite, existing) vsdb/pg/migrations/(Postgres, new). Don't merge. - Event-loop blocking: argon2id hashing is CPU-bound
(
time_cost=3≈ 30–80ms). For a single operator login this is fine on the main event loop; if you ever batch-hashed, move torun_in_executor. Not a v0.3 concern. - Cross-DB joins are impossible (SQLite ↔ Postgres). Anything that
needs both (e.g. a dashboard joining learner sessions to issued VCs)
must be assembled in application code. The
learner_refopaque key inissued_credentials/mastery_gate_eventsis the join handle — keep it stable and never reuse SQLite rowids directly (use the existingsess-…/learner-1string ids).