docs(milestone): merge phase/00 pre-execution → milestone/v0.3-mastery-scoring

Phase 0 complete. v0.3 mastery-scoring planning artifacts shipped.
Pipeline: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL.
13 REQ-IDs active (7 functional + 6 NFR); 8 deferred to v0.4 (operator tier).
Decisions D-031..D-049 (19 total, all >=0.70 confidence).
4 MUST grill conditions resolved, 5 FIX tracked.

---ci---
project: praxis
phase: 0
milestone: v0.3
status: complete
requirements:
  covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-03 19:58:38 +00:00
parent bea2af13d4
commit dc673e5e3d
14 changed files with 2906 additions and 904 deletions
+475
View File
@@ -0,0 +1,475 @@
# 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 01 (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 with
`pg_*` clients).
- **Persistence:** named volume `pgdata` (driver: local). Never bind-mount
`/var/lib/postgresql/data` to the CT filesystem — Postgres requires
`chown 999` and a specific directory layout; named volumes handle this.
- **Network isolation:** declare an explicit internal compose network and
attach **only** `praxis` and `postgres` to it. Do **not** publish
`5432` via `ports:`. The `praxis` service keeps its published `8789`.
- `internal: true` on the network blocks egress to the host bridge, but
note: with `internal: true` the postgres container cannot reach the
internet (fine — it doesn't need to). If you later want outbound
backups via network, drop `internal: true` and instead rely on
*not* publishing the port. The simpler, robust choice for a pilot is:
explicit named network, no `ports:` on postgres, no `internal: true`.
- **Healthcheck:** `pg_isready -U praxis -d praxis` every 10s, 5 retries,
5s timeout. `depends_on: { postgres: { condition: service_healthy } }`
on the `praxis` service 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** (empty
`pgdata`). 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_DB` from the
existing `/etc/praxis/server.env` (do **not** commit secrets to the
compose file). Add `PGDATA=/var/lib/postgresql/data/pgdata` to 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 default
`shared_buffers`. Tune later.
**Sketch (shape only, not for commit):**
```yaml
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 `praxis` currently has no explicit network, compose assigns the
default bridge; adding an explicit network means the *existing*
`praxis` service gets recreated on `up`. Plan a brief downtime window
(see §6).
- `pg_isready` returns healthy before the DB is fully ready for migration
load; `depends_on: service_healthy` is 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 per `async with aiosqlite.connect(...)` (the
current `PraxisStore._connect` pattern). They have nothing in common —
different backends, different lifecycles. **Do not** wrap them in a
single shared `AsyncSession` object; 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=10` for 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 `WAL` mode 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.
- **Lifecycle:** create the asyncpg pool once at FastAPI startup
(`lifespan` context manager), close on shutdown. Store on
`app.state.pg_pool`. The `PraxisStore` keeps 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_DSN` env var, e.g.
`postgresql://praxis:***@postgres:5432/praxis_operator` (host =
service name on `praxis-net`).
- **Statement timeout:** set `command_timeout=10` on 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
- **`starlette` `SessionMiddleware`** (FastAPI bundles Starlette). Uses
`itsdangerous` to sign the cookie — no server-side session store
needed (stateless, fits single-instance LXC). Data lives in the cookie
itself, signed with `SECRET_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 `/op` if 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 to
`False` *temporarily* 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`** (`PasswordHasher` default 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 `TEXT` in `operators.password_hash`.
### 3c. Rate limiting
Two options:
1. **`slowapi`** (pip `slowapi>=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 to
>1 praxis process; not a v0.3 concern).
2. **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):
```python
# 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 an `APIRouter(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` + `httponly` cookies, CSRF surface is
minimal for state-changing requests. If any operator endpoint accepts
`Content-Type: application/x-www-form-urlencoded`/`multipart` (form
posts), add a double-submit token or require `Content-Type:
application/json` only (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 `pgcrypto` extension **or** Postgres 13+ (where
`gen_random_uuid()` is built-in via `pgcrypto` shipped 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 < K` emit
`bin_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)
1. **Prepare on a staging CT first** (clone the production LXC CT in
Proxmox). Never test the migration path on the live CT.
2. **Add the `postgres` service + `praxis-net` + volumes** to
`docker-compose.yml`. The `praxis` service gains
`depends_on: postgres (service_healthy)` and joins `praxis-net`.
3. **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 empty `pgdata` volume.
4. **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.
5. **Build the new image** (`docker compose build praxis`) — does not
touch the running container.
6. **Controlled cutover:**
- `docker compose up -d postgres` → wait for healthy.
- `docker compose up -d praxis` → recreate the praxis container with
the new image. Expect ~515s 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.
7. **Smoke tests:** `/health`, learner voice loop, operator login, one
cohort-dashboard read.
8. **Rollback plan:** if operator endpoints misbehave, revert the
praxis image tag and `docker compose up -d praxis` again — 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.py` mirroring the SQLite
runner, against a `_pg_migrations` table. Lowest cognitive load —
same mental model, same directory convention (`db/pg/migrations/`).
- **(b) Adopt `yoyo-migrations` or `alembic`:** 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 the
`pgbackups` volume. `-Fc` gives 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:
```
pg_dump -U praxis -Fc praxis_operator > /backups/pg_$(date +%u).dump
```
Using `%u` (day-of-week 17) gives a rolling 7-file retention with
zero cleanup logic.
- **Where:** `/backups` is the `pgbackups` named volume. Keep backups
**inside the compose stack** so they move with the CT. For off-CT
safety: a Proxmox-level cron `pct push`/`rsync` of the `pgbackups`
volume 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-exists` drops+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-data` volume 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 `pgbackups` volume is ever pulled off-host,
`gpg -c` the 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)
1. **TLS or not:** Secure cookie flag requires TLS. Confirm the LXC
fronting layer terminates HTTPS before enabling `https_only=True`.
2. **First-boot-only init scripts:** if `pgdata` already exists (e.g.
after a failed first boot), seed scripts **won't re-run** — keep a
separate re-runnable seed path (the `01_seed_operator.sh` should be
idempotent via `ON CONFLICT DO NOTHING` or a shell guard).
3. **Two migration runners** (SQLite + Postgres) — keep directory
layouts visually distinct: `db/migrations/` (SQLite, existing) vs
`db/pg/migrations/` (Postgres, new). Don't merge.
4. **Event-loop blocking:** argon2id hashing is CPU-bound
(`time_cost=3` ≈ 3080ms). For a single operator login this is fine
on the main event loop; if you ever batch-hashed, move to
`run_in_executor`. Not a v0.3 concern.
5. **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_ref` opaque key
in `issued_credentials`/`mastery_gate_events` is the join handle —
keep it stable and never reuse SQLite rowids directly (use the
existing `sess-…`/`learner-1` string ids).