This repository has been archived on 2026-09-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
praxis/.ciagent/REVIEW.md
T
Praxis CI f2a12f9fed docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
2026-08-04 11:58:44 +00:00

25 KiB

Praxis — v0.4 Milestone Review (Final Phase P3)

Reviewer: ci-code-reviewer (multi-persona: correctness, testing, security, performance, maintainability, adversarial) Scope: full v0.4 milestone diff — git diff main..HEAD (74 files, +12,361/-819 LOC) — covers P1 (operator foundation) + P2 (cohort dashboard) Branch: phase/03-final-review-ship (from milestone/v0.4-operator-tier) Date: 2026-08-04 Method: code inspection (all v0.4 source + tests), test execution, security grep, grill MUST verification, adversarial analysis

Summary

  • Verdict: APPROVE_WITH_NOTES
  • Personas: correctness PASS, testing PASS, security PASS, performance PASS, maintainability PASS, adversarial PASS
  • P0 fixes applied: 0 (none needed — no P0 issues found across all 6 personas)
  • P1+ flagged: 8 (4 from P1 VERIFY + 4 from P2 VERIFY — all non-blocking, all carry-forward)
  • Total v0.4 REQ coverage: 8/8 (REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02, REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02)
  • Grill MUSTs honored: 6/6 (G-008 backup drill, G-011 two-store fallback, G-027 first-boot path, G-031 R-AUTH-01 reframe, G-038 differencing-attack test, G-041 SPA fallback subclass)

Test Results

Suite Result Notes
python3 -m pytest tests/ 317 passed, 36 skipped, 0 failed (90.28s) Postgres-requiring tests skip gracefully (PRAXIS_PG_DSN unset); voice-service-key skips pre-existing
cd client && npx vitest run 17/17 passed Dashboard auth gate, login (200/401/429), sparkline (4 cases), suppressedLabel, formatFreshness, no-PII-in-DOM
cd client && npm run build PASS 168 modules, 414ms, 662KB / 186KB gzip
cd client && npm run typecheck PASS tsc -b --noEmit clean
python3 -c "import server.__main__" PASS All v0.4 modules load, logs "SPA fallback enabled"
docker compose config PASS Validates; postgres has no ports: (D-040 honored)
Security grep (f-string SQL, hardcoded secrets, missing auth deps) PASS No injection vectors; no secrets in code; all /api/operator/* auth-gated

Persona 1 — Correctness

Findings (all PASS — no P0)

  1. k-anon threshold (exactly 10): K_ANON_THRESHOLD = 10 is a module constant in server/cohort/aggregator.py:32. Suppression logic suppressed = active_count < K_ANON_THRESHOLD (line 87). Boundary tests pass: 9 → suppressed (test_9_learners_suppressed), 10 → not suppressed (test_10_learners_not_suppressed), 11 → not suppressed (test_11_learners_not_suppressed). The threshold is NOT env-configurable (correct for a privacy control — adversarial persona confirms).

  2. VC key migration (archive-before-active, G-027 first-boot): server/vc/migrate_keys.py implements the R-VC-MIG-01 ordering correctly:

    • Step 2 (_archive_v03_public_key, line 86) runs BEFORE step 3 (_generate_fresh_v04_key, line 90).
    • G-027 first-boot path (line 80-87): if v03_row is Nonearchived_key_id=None, skips archive, generates fresh key only. Test: test_migration_g027_first_boot_no_v03_key.
    • Idempotent (line 74-76): if get_active_signing_key_row() returns non-None → returns {None, None} (no-op). Test: test_migration_idempotent_when_active_key_exists.
    • init_issuer_key uses ON CONFLICT (id) DO NOTHING → cannot replay to overwrite.
  3. Auth flow (login/logout/me, cookie lifecycle, rate limit):

    • Login (routes.py:58): rate-limited, verify_password, sets request.session["operator_id"], updates last_login_at, rehashes if needs_rehash.
    • Logout (routes.py:104): Depends(current_operator), clears session.
    • Me (routes.py:112): Depends(current_operator), returns operator info.
    • Inactive operator (dependencies.py:40): 401 + session.clear() (invalidates cookie).
  4. SPA fallback (SpaStaticFiles subclass, G-041): server/__main__.py:279-289 defines class SpaStaticFiles(StaticFiles) with get_response override that returns FileResponse("index.html") ONLY on 404 (non-file paths). This is the custom subclass mandated by G-041, NOT a @app.get("/{path:path}") catch-all (which would shadow asset serving). Test: test_assets_served_by_staticfiles_not_spa_fallback confirms /assets/index.js returns javascript content, not index.html.

  5. Nightly scheduler timing (03:00 CT): seconds_until_next_03_ct (nightly.py:32) computes seconds until 03:00 CT correctly. Tests: test_seconds_until_next_03_ct_future_today + test_seconds_until_next_03_ct_past_today_wraps_tomorrow. Fixed UTC-5 offset is a documented DST approximation (P1+-02 from VERIFY-P2).

  6. Race conditions (aggregation hook fire-and-forget, pool access):

    • Hook: session_recorder.py:161 uses asyncio.create_task(self._run_cohort_aggregation(session_outcome)) — fire-and-forget, off the voice path.
    • Hook failure: hook.py:37 except Exception: log.exception(...) — no propagation; nightly reconciles.
    • Pool access: all PgStore methods use async with self.pool.acquire() as conn — no leaked connections.

Correctness verdict: PASS — no logic errors, off-by-ones, or missing edge cases found.


Persona 2 — Testing

Findings (all PASS — no P0)

  1. Postgres-requiring tests skip gracefully: 36 skips total — all test_pg_store.py (12), test_p1_auth_integration.py, test_p1_vc_migration_e2e.py, test_backup_restore.py, test_p2_aggregation_integration.py (3) skip with clear messages when PRAXIS_PG_DSN is unset. No hard CI dependency on Postgres.

  2. G-038 differencing-attack test: tests/test_cohort_aggregation.py:175 test_g038_differencing_attack_cannot_isolate_dropped_learner — seeds 10 learners in window A, 9 in window B (learner-9 dropped), asserts:

    • Window A has non-suppressed cells (10 ≥ threshold).
    • Window B has ALL cells suppressed (9 < threshold), NO non-suppressed cells.
    • Suppressed cells have value=None (differencing-attack defense — subtraction impossible).
    • No learner-9 ref leaks in any aggregate cell arg. API e2e layer: test_p2_aggregation_integration.py::test_g038_differencing_attack_api_layer (skips without Postgres, logic verified at unit layer).
  3. R-VC-MIG-01 e2e test: tests/test_p1_vc_migration_e2e.py (skips without Postgres) — seeds v0.3 VC, runs migration, verifies v0.3 VC against archived superseded key, issues v0.4 VC, verifies, tampers, confirms idempotency. Mock-based equivalent: test_vc_migration.py::test_migration_archives_before_activating_r_vc_mig_01 (instrumented ordering test).

  4. Graceful degradation (server starts without Postgres): lifespan in __main__.py:78-90 — if PRAXIS_PG_DSN unset, logs WARNING, sets pg_pool=None, pg_store=None, yields. /health returns 200, auth routes return 503, learner voice loop (SQLite) unaffected.

  5. Voice UI at / unchanged (R-DASH-03, R-DASH-05): test_p2_spa_fallback.py::test_root_serves_voice_ui (200, text/html, <div id="root">). client/src/App.tsx route /<VoiceSession />, *<VoiceSession />. All v0.1-v0.3 tests still pass (317 passed, 0 failed).

  6. Mock-based equivalents exist for all Postgres-requiring paths: test_auth.py (mocked PgStore, 310 LOC), test_vc_migration.py (mocked stores, 354 LOC), test_create_operator.py (mocked PgStore, 217 LOC), test_cohort_aggregation.py (mocked PgStore, 246 LOC).

  7. Rate limit 429 path: Tested at decorator level in mock suite (test_rate_limit_login_decorator); full 6th-attempt→429 path is in PG-requiring test_p1_auth_integration.py. P1+ carry-forward (P1 VERIFY P1+-02): add a mock-based 429 test for CI coverage without Postgres. Non-blocking.

Testing verdict: PASS — comprehensive coverage, graceful skips, G-038 + R-VC-MIG-01 explicitly tested.


Persona 3 — Security

Findings (all PASS — no P0)

  1. Auth: argon2id params (OWASP): server/auth/passwords.py:14 _ph = PasswordHasher() — defaults (time_cost=3, memory_cost=64MiB=65536 KiB, parallelism=4) exceed all OWASP minimums (46MiB/t=1, 19MiB/t=2, 12MiB/t=3, etc.). verify_password catches VerifyMismatchError → False (no exception, uniform 401 path). needs_rehash delegates to check_needs_rehash.

  2. Signed cookies (HMAC-SHA256, httpOnly+secure+SameSite): server/auth/cookies.py returns SessionMiddleware kwargs: https_only=secure (Starlette's https_only param, not secure — verified correct via fix 0a95102), same_site="strict", max_age=28800 (8h), session_cookie="praxis_op", path="/". itsdangerous HMAC-SHA256 under the hood.

  3. R-AUTH-01 / G-031 reframe: cookies.py docstring (lines 7-12) + WARNING text (lines 51-57) correctly frame the k-anon defense-in-depth as the PRIMARY mitigation ("cohort dashboard reads only k-anonymized aggregates → sniffed cookie leaks no PII") and the config flag as SECONDARY ("operational convenience for when TLS arrives"). G-031 honored.

  4. SQL injection (all PgStore queries parameterized): Verified all PgStore methods use asyncpg $1, $2, ... parameterized bindings. Grep for f"(SELECT|INSERT|UPDATE|DELETE|FROM) found:

    • db/pg_store.py:227 f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2"extra is a hardcoded constant (, revoked_at = now() or empty) derived from status == "revoked" comparison, NOT user input. status and cred_id are bound parameters. SAFE (P1+-04 code smell, non-blocking).
    • tests/test_backup_restore.py f-strings interpolate hardcoded table names (not user input). SAFE.
  5. k-anon (write-time suppression, no per-learner drill-down, no PII): Suppression applied in aggregator.py:87 BEFORE upsert_cohort_aggregate (write-time, auditable). No per-learner drill-down: endpoints return only (path, metric, value, cell_count, cell_suppressed, updated_at). test_no_per_learner_data_in_cohort_response confirms no learner_ref string in cohort/mastery/failure responses. No raw PII in Postgres aggregates (D-031): only opaque learner_ref for distinct counting.

  6. VC key migration (v0.3 private key NOT migrated, v0.4 encrypted at rest): migrate_keys.py:45 init_issuer_key(v03_key_id, v03_public_key, b"") — empty bytes for private_key_enc (only public key archived). Fresh v0.4 key encrypted via _encrypt_private_key(signing_key, root_key) (nacl.SecretBox, line 56). issuer_keys.private_key_enc is BYTEA in Postgres.

  7. Secret handling (.env.secrets gitignored, no secrets in code): .gitignore has .env.secrets, .env.* ignored, !.ciagent/.env.secrets.example whitelisted. Grep for os.environ["PRAXIS_PG_PASSWORD"] / os.environ["PRAXIS_COOKIE_SECRET"] / os.environ["PRAXIS_BOOTSTRAP found only in test (test_p2_spa_fallback.py:47 sets a test secret). No secrets committed.

  8. Cookie PII check: The signed cookie (praxis_op) payload contains ONLY {operator_id: "<uuid>"}. No username, display_name, role, or learner data in the cookie. Verified by inspecting routes.py:85 (sets operator_id) and dependencies.py:33 (reads operator_id).

Security verdict: PASS — no injection vectors, no PII leaks, auth stack solid, secrets handled correctly.


Persona 4 — Performance

Findings (all PASS — no P0)

  1. asyncpg pool (min 1, max 10): __main__.py:94-99 create_pool(dsn, min_size=1, max_size=10, command_timeout=10). D-050 honored. Appropriate for single-instance pilot with low-frequency operator queries. command_timeout=10 prevents slow queries from blocking.

  2. Aggregation hook non-blocking (asyncio.create_task): session_recorder.py:161 asyncio.create_task(self._run_cohort_aggregation(session_outcome)) — fire-and-forget, off the voice path (C-8, D-054). Voice loop latency unaffected.

  3. Nightly job doesn't block the event loop: nightly.py:81-95 _run_loop uses asyncio.sleep(secs) (cooperative). Reconciliation (_reconcile) is a sequence of await pg_store.upsert_cohort_aggregate(...) calls (yields between each). Runs at 03:00 CT (low activity).

  4. SPA fallback doesn't add latency to API routes: API routers (auth_router, cohort_router, mastery_router, failure_router, credentials_router) are mounted (__main__.py:259-268) BEFORE the SPA StaticFiles mount (__main__.py:297). FastAPI matches API routes first — no fallback overhead on API paths.

  5. argon2id hashing is sync (~100-300ms): verify_password + hash_password (rehash) are sync calls in the async login handler (routes.py:79, 88). Blocks the event loop ~100-300ms per login. Acceptable for single-operator pilot (R-AUTH-02 — low frequency, single operator). P1+ carry-forward (P1 VERIFY P1+-01): offload to asyncio.to_thread if login frequency increases or multi-operator. Non-blocking.

  6. Voice loop (WebRTC → Pipecat) does NOT touch Postgres: Uses SQLite (D-007 preserved). No perf impact on the <600ms latency budget (C-8).

Performance verdict: PASS — no blocking calls on the voice path, pool sizing appropriate, async patterns correct.


Persona 5 — Maintainability

Findings (all PASS — no P0)

  1. IssuerKeyStore protocol clean: server/vc/issuer_keys.py:26-44@runtime_checkable class IssuerKeyStore(Protocol) with 4 methods. Both PraxisStore (SQLite, v0.3) and PgStore (Postgres, v0.4) implement it (duck-typed). isinstance(store, IssuerKeyStore) succeeds for both. Clean dependency inversion — verification.py depends on the protocol, not concrete stores.

  2. SpaStaticFiles subclass clean: __main__.py:279-289 — 11-line override, get_response catches 404 → FileResponse("index.html"). Well-commented with G-041 rationale.

  3. 3 dashboard view components consistent: PracticeVolume.tsx, MasteryProgression.tsx, FailurePatterns.tsx all share _viewCommon.ts (Cell type, suppressedLabel, formatFreshness) and follow the same fetch→render pattern. Server-side: cohort.py, mastery.py, failure_patterns.py all use _common.py (require_pg_store, all_recent_aggregates, group_by_path).

  4. Router mounting order (API before SPA fallback before StaticFiles): __main__.py:256-298 — auth_router → cohort_router → mastery_router → failure_router → credentials_router → SpaStaticFiles mount. Documented in comments.

  5. Naming, structure, coupling: server/auth/ package (passwords, cookies, rate_limit, dependencies, routes, models) — clear separation. db/pg_store.py — single class with clear method groups (operator CRUD, cohort, issuer keys, credentials, gate events). No god-class. learner_ref is opaque (not FK) per D-031. Consistent get_*_row / set_* / insert_* / upsert_* conventions.

Maintainability verdict: PASS — clean protocols, consistent structure, good separation of concerns.


Persona 6 — Adversarial

Findings (all PASS — no P0)

  1. What if an attacker calls /api/operator/cohort with a path that doesn't exist? The endpoint takes NO path parameter — it returns all paths' aggregates from the last 30 days. A non-existent path simply returns no rows (no error, no leak). The attacker cannot probe for specific paths.

  2. What if k-anon threshold is lowered via config? K_ANON_THRESHOLD = 10 is a module constant in aggregator.py:32, NOT configurable via env. Changing it requires a code change + redeploy. This is correct for a privacy control — it should not be runtime-configurable (an operator with env access should not be able to weaken k-anon).

  3. What if the aggregation hook runs before Postgres is healthy? The hook (hook.py:27-32) checks pg_store is None → no-op + WARNING. If Postgres is unhealthy mid-session, upsert_cohort_aggregate raises → caught by hook.py:37 except Exception: log.exception(...) → nightly job reconciles. No crash path.

  4. What if PRAXIS_COOKIE_SECRET is weak? cookies.py:41-48 checks if not secret (empty) → generates ephemeral random + WARNING. However, it does NOT validate len(secret) >= 32 — a short non-empty secret (e.g., "x") would be accepted, weakening the HMAC signature. P1+ carry-forward (P1 VERIFY P1+-03): add len(secret) >= 32 check with WARNING. Non-blocking — .env.secrets.example documents openssl rand -base64 48 generation.

  5. What if Postgres is exposed despite the internal Docker network? docker-compose.yml:59-82 — postgres service has NO ports: mapping (D-040 honored). An attacker would need to compromise the LXC CT or the praxis-net bridge. Mitigated by network isolation.

  6. What if an attacker forges a cookie? SessionMiddleware validates the itsdangerous HMAC-SHA256 signature on every request. A forged cookie without the correct PRAXIS_COOKIE_SECRET fails signature validation → request.session is empty → current_operator returns 401.

  7. Migration replay attack? init_issuer_key uses ON CONFLICT (id) DO NOTHING → re-running migration cannot overwrite an existing key. An attacker with DB access could insert a key directly, but DB access is already game-over. Not a v0.4 concern.


P0 Fixes Applied

None. No P0 issues (broken tests, missing REQ coverage, security holes, logic errors causing incorrect behavior) were found across any of the 6 personas. The v0.4 implementation is correct, secure, complete, and well-tested. All 6 grill MUSTs are honored. All 8 REQs are covered. No auto-fixes were necessary.


P1+ Flagged for Post-Hoc Review

The following 8 non-blocking issues are flagged for the next milestone's backlog. All have mitigations present in the v0.4 code. None block ship.

From P1 VERIFY (4 P1+):

  1. Argon2id blocking event loop (server/auth/routes.py:79,88): verify_password + hash_password (rehash) are sync calls in the async login handler, blocking ~100-300ms. Acceptable for single-operator pilot (R-AUTH-02). If login frequency increases, offload to asyncio.to_thread. Non-blocking.

  2. Rate limit 429 not tested in mock path (tests/test_auth.py:303): only the decorator factory is tested in the mock-based suite; the full 6th-attempt→429 path is in the PG-requiring integration test. Add a mock-based 429 test for CI coverage without Postgres. Non-blocking.

  3. No PRAXIS_COOKIE_SECRET length validation (server/auth/cookies.py:41): only checks non-empty, not >=32 bytes. A short secret weakens the HMAC signature. Add len(secret) >= 32 check with WARNING. Non-blocking.

  4. set_credential_status status field not validated (db/pg_store.py:223): accepts any string for status (no enum check). Currently only called with "revoked" from operator code, but a future caller could pass arbitrary strings. Consider a CHECK constraint on the issued_credentials.status column or a Python enum. Non-blocking.

From P2 VERIFY (4 P1+):

  1. Credential revocation lacks application-level audit log (server/operator/credentials.py): the revoke_credential endpoint sets status='revoked' + revoked_at=now() but does NOT log the revocation event at the application level, and the revoking operator_id is not recorded. Mitigation: revoked_at timestamp + signed session cookie. Recommended: add log.info("credential revoked: operator=%s cred_id=%s", op.id, cred_id) + consider an audit_log table. Non-blocking.

  2. Nightly scheduler uses fixed UTC-5 offset (not true America/Winnipeg DST) (server/cohort/nightly.py:27): CT approximated as fixed UTC-5. America/Winnipeg observes CST (UTC-6) in winter + CDT (UTC-5) in summer. Scheduler drifts ≤1h across DST boundaries — acceptable for a nightly reconciliation job. Documented in comments. Recommended: replace with zoneinfo.ZoneInfo("America/Winnipeg"). Non-blocking.

  3. Aggregation in-memory cache is per-PgStore-instance (lost on restart) (server/cohort/aggregator.py:162-170): the _agg_cache on PgStore tracks running counters + distinct learner sets. On restart, the cache is lost — the next hook starts fresh, active_learners_count may reset to 1 (under-counting until nightly reconcile). Risk is low — nightly reconciliation recomputes from mastery_gate_events (source of truth), and under-counting → over-suppression (privacy-safe but value-destroying). Non-blocking.

  4. set_credential_status uses f-string interpolation in SQL (code smell) (db/pg_store.py:227): the extra variable (, revoked_at = now() or empty) is interpolated via f-string. While extra is a hardcoded constant (not user input) and status/cred_id are parameterized, f-strings in SQL are a code smell. Recommended: refactor to two explicit queries. (Same as P1+ #4 — listed in both VERIFY reports.) Non-blocking.


Carry-forward from P1/P2 VERIFY (P1+ items)

P1 VERIFY P1+ (4):

  1. Argon2id blocking event loop (server/auth/routes.py:79,88) — offload to asyncio.to_thread if login frequency increases.
  2. Rate limit 429 not tested in mock path (tests/test_auth.py:303) — add mock-based 429 test.
  3. No PRAXIS_COOKIE_SECRET length validation (server/auth/cookies.py:41) — add len(secret) >= 32 check.
  4. set_credential_status status field not validated (db/pg_store.py:223) — add CHECK constraint or Python enum.

P2 VERIFY P1+ (4):

  1. Credential revocation lacks application-level audit log (server/operator/credentials.py) — add log.info + consider audit_log table.
  2. Nightly scheduler fixed UTC-5 offset (server/cohort/nightly.py:27) — use zoneinfo.ZoneInfo("America/Winnipeg").
  3. Aggregation in-memory cache lost on restart (server/cohort/aggregator.py:162-170) — document or persist distinct-learner set.
  4. set_credential_status f-string SQL code smell (db/pg_store.py:227) — refactor to two explicit queries. (Overlaps with P1+ #4.)

REQ Coverage (8/8)

REQ-ID Phase Covered by Status
REQ-MT-01 P1 docker-compose postgres + asyncpg pool + PgStore + IssuerKeyStore protocol + verification swap COVERED
REQ-AUTH-01 P1 argon2id + signed cookies + rate limit + current_operator dep + bootstrap CLI COVERED
REQ-NFR-AUTH-01 P1 argon2id (PasswordHasher defaults), httpOnly+secure+SameSite=Strict, 5/min rate limit, 8h expiry COVERED
REQ-NFR-MT-01 P1 postgres internal network only (no ports), 6GB CT, graceful degradation, voice loop unaffected COVERED
REQ-MT-02 P1+P2 schema (P1 SLICE-01) + pipeline (P2 SLICE-07 aggregator + hook + nightly) COVERED
REQ-DASH-01 P2 4 endpoints + React UI + SPA fallback COVERED
REQ-NFR-DASH-01 P2 write-time suppression + query value=null + display "— (<10 learners)" + G-038 COVERED
REQ-NFR-DASH-02 P2 nightly job + on-session-end hook + last_updated freshness COVERED

Grill MUSTs Honored (6/6)

MUST Honored Evidence
G-008 (backup drill) YES tests/test_backup_restore.py seeds 5 tables, pg_dump, drop, pg_restore --clean --if-exists, verify counts. scripts/backup-pg.sh has restore drill comments.
G-011 (two-store fallback) YES server/vc/verification.py _lookup_credential + _lookup_public_key implement (a)/(b)/(c). Tests: G-011b + G-011c.
G-027 (first-boot no v0.3 key) YES migrate_keys.py:80-87 if v03_row is None → archived_key_id=None, skip archive. Tests: test_migration_g027_first_boot_no_v03_key + e2e.
G-031 (R-AUTH-01 reframe) YES cookies.py docstring + WARNING: "primary R-AUTH-01 mitigation is k-anon defense-in-depth... this flag is the secondary mitigation."
G-038 (differencing-attack test) YES test_g038_differencing_attack_cannot_isolate_dropped_learner — 10 in A, 9 in B → B fully suppressed, dropped learner not isolatable.
G-041 (SPA fallback subclass) YES __main__.py:279-289 class SpaStaticFiles(StaticFiles) with get_response 404→index.html. NOT a catch-all route. test_assets_served_by_staticfiles_not_spa_fallback.

Bottom Line

The v0.4 milestone (Operator Tier — Cohort Dashboard + Auth + Postgres) is APPROVE_WITH_NOTES. All 6 personas pass. All 8 REQs are covered. All 6 grill MUSTs are honored. Zero P0 issues. Eight P1+ items flagged for post-hoc review (all non-blocking, all with mitigations present, all carry-forward to the next milestone's backlog).

The implementation is correct (k-anon threshold exactly 10, archive-before-active, G-027 first-boot), secure (argon2id exceeding OWASP, parameterized SQL, k-anon defense-in-depth, no PII in Postgres), performant (async fire-and-forget hook, pool sizing appropriate, voice loop untouched), maintainable (clean protocols, consistent structure, good separation), and adversarially sound (non-configurable privacy controls, no exploitable attack paths).

The milestone is ready for ship (v0.1.9 = v0.4). The orchestrator delegates to ship after this review.