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:
@@ -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 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 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 ~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.
|
||||
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 1–7) 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` ≈ 30–80ms). 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).
|
||||
Reference in New Issue
Block a user