813bd586d6
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---
475 lines
21 KiB
Markdown
475 lines
21 KiB
Markdown
# 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). |