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---
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(frommilestone/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)
-
k-anon threshold (exactly 10):
K_ANON_THRESHOLD = 10is a module constant inserver/cohort/aggregator.py:32. Suppression logicsuppressed = 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). ✅ -
VC key migration (archive-before-active, G-027 first-boot):
server/vc/migrate_keys.pyimplements 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 None→archived_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_keyusesON CONFLICT (id) DO NOTHING→ cannot replay to overwrite. ✅
- Step 2 (
-
Auth flow (login/logout/me, cookie lifecycle, rate limit):
- Login (
routes.py:58): rate-limited,verify_password, setsrequest.session["operator_id"], updateslast_login_at, rehashes ifneeds_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). ✅
- Login (
-
SPA fallback (SpaStaticFiles subclass, G-041):
server/__main__.py:279-289definesclass SpaStaticFiles(StaticFiles)withget_responseoverride that returnsFileResponse("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_fallbackconfirms/assets/index.jsreturns javascript content, not index.html. ✅ -
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). ✅ -
Race conditions (aggregation hook fire-and-forget, pool access):
- Hook:
session_recorder.py:161usesasyncio.create_task(self._run_cohort_aggregation(session_outcome))— fire-and-forget, off the voice path. - Hook failure:
hook.py:37except Exception: log.exception(...)— no propagation; nightly reconciles. - Pool access: all PgStore methods use
async with self.pool.acquire() as conn— no leaked connections. ✅
- Hook:
Correctness verdict: PASS — no logic errors, off-by-ones, or missing edge cases found.
Persona 2 — Testing
Findings (all PASS — no P0)
-
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 whenPRAXIS_PG_DSNis unset. No hard CI dependency on Postgres. ✅ -
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-9ref 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). ✅
-
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). ✅ -
Graceful degradation (server starts without Postgres):
lifespanin__main__.py:78-90— ifPRAXIS_PG_DSNunset, logs WARNING, setspg_pool=None,pg_store=None, yields./healthreturns 200, auth routes return 503, learner voice loop (SQLite) unaffected. ✅ -
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.tsxroute/→<VoiceSession />,*→<VoiceSession />. All v0.1-v0.3 tests still pass (317 passed, 0 failed). ✅ -
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). ✅ -
Rate limit 429 path: Tested at decorator level in mock suite (
test_rate_limit_login_decorator); full 6th-attempt→429 path is in PG-requiringtest_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)
-
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_passwordcatchesVerifyMismatchError→ False (no exception, uniform 401 path).needs_rehashdelegates tocheck_needs_rehash. ✅ -
Signed cookies (HMAC-SHA256, httpOnly+secure+SameSite):
server/auth/cookies.pyreturns SessionMiddleware kwargs:https_only=secure(Starlette'shttps_onlyparam, notsecure— verified correct via fix0a95102),same_site="strict",max_age=28800(8h),session_cookie="praxis_op",path="/". itsdangerous HMAC-SHA256 under the hood. ✅ -
R-AUTH-01 / G-031 reframe:
cookies.pydocstring (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. ✅ -
SQL injection (all PgStore queries parameterized): Verified all PgStore methods use asyncpg
$1, $2, ...parameterized bindings. Grep forf"(SELECT|INSERT|UPDATE|DELETE|FROM)found:db/pg_store.py:227f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2"—extrais a hardcoded constant (, revoked_at = now()or empty) derived fromstatus == "revoked"comparison, NOT user input.statusandcred_idare bound parameters. SAFE (P1+-04 code smell, non-blocking).tests/test_backup_restore.pyf-strings interpolate hardcoded table names (not user input). SAFE. ✅
-
k-anon (write-time suppression, no per-learner drill-down, no PII): Suppression applied in
aggregator.py:87BEFOREupsert_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_responseconfirms nolearner_refstring in cohort/mastery/failure responses. No raw PII in Postgres aggregates (D-031): only opaquelearner_reffor distinct counting. ✅ -
VC key migration (v0.3 private key NOT migrated, v0.4 encrypted at rest):
migrate_keys.py:45init_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_encis BYTEA in Postgres. ✅ -
Secret handling (.env.secrets gitignored, no secrets in code):
.gitignorehas.env.secrets,.env.*ignored,!.ciagent/.env.secrets.examplewhitelisted. Grep foros.environ["PRAXIS_PG_PASSWORD"]/os.environ["PRAXIS_COOKIE_SECRET"]/os.environ["PRAXIS_BOOTSTRAPfound only in test (test_p2_spa_fallback.py:47sets a test secret). No secrets committed. ✅ -
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 inspectingroutes.py:85(setsoperator_id) anddependencies.py:33(readsoperator_id). ✅
Security verdict: PASS — no injection vectors, no PII leaks, auth stack solid, secrets handled correctly.
Persona 4 — Performance
Findings (all PASS — no P0)
-
asyncpg pool (min 1, max 10):
__main__.py:94-99create_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=10prevents slow queries from blocking. ✅ -
Aggregation hook non-blocking (asyncio.create_task):
session_recorder.py:161asyncio.create_task(self._run_cohort_aggregation(session_outcome))— fire-and-forget, off the voice path (C-8, D-054). Voice loop latency unaffected. ✅ -
Nightly job doesn't block the event loop:
nightly.py:81-95_run_loopusesasyncio.sleep(secs)(cooperative). Reconciliation (_reconcile) is a sequence ofawait pg_store.upsert_cohort_aggregate(...)calls (yields between each). Runs at 03:00 CT (low activity). ✅ -
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. ✅ -
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 toasyncio.to_threadif login frequency increases or multi-operator. Non-blocking. ✅ -
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)
-
IssuerKeyStore protocol clean:
server/vc/issuer_keys.py:26-44—@runtime_checkable class IssuerKeyStore(Protocol)with 4 methods. BothPraxisStore(SQLite, v0.3) andPgStore(Postgres, v0.4) implement it (duck-typed).isinstance(store, IssuerKeyStore)succeeds for both. Clean dependency inversion —verification.pydepends on the protocol, not concrete stores. ✅ -
SpaStaticFiles subclass clean:
__main__.py:279-289— 11-line override,get_responsecatches 404 →FileResponse("index.html"). Well-commented with G-041 rationale. ✅ -
3 dashboard view components consistent:
PracticeVolume.tsx,MasteryProgression.tsx,FailurePatterns.tsxall share_viewCommon.ts(Cell type, suppressedLabel, formatFreshness) and follow the same fetch→render pattern. Server-side:cohort.py,mastery.py,failure_patterns.pyall use_common.py(require_pg_store, all_recent_aggregates, group_by_path). ✅ -
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. ✅ -
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_refis opaque (not FK) per D-031. Consistentget_*_row/set_*/insert_*/upsert_*conventions. ✅
Maintainability verdict: PASS — clean protocols, consistent structure, good separation of concerns.
Persona 6 — Adversarial
Findings (all PASS — no P0)
-
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. ✅
-
What if k-anon threshold is lowered via config?
K_ANON_THRESHOLD = 10is a module constant inaggregator.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). ✅ -
What if the aggregation hook runs before Postgres is healthy? The hook (
hook.py:27-32) checkspg_store is None→ no-op + WARNING. If Postgres is unhealthy mid-session,upsert_cohort_aggregateraises → caught byhook.py:37except Exception: log.exception(...)→ nightly job reconciles. No crash path. ✅ -
What if PRAXIS_COOKIE_SECRET is weak?
cookies.py:41-48checksif not secret(empty) → generates ephemeral random + WARNING. However, it does NOT validatelen(secret) >= 32— a short non-empty secret (e.g., "x") would be accepted, weakening the HMAC signature. P1+ carry-forward (P1 VERIFY P1+-03): addlen(secret) >= 32check with WARNING. Non-blocking —.env.secrets.exampledocumentsopenssl rand -base64 48generation. ✅ -
What if Postgres is exposed despite the internal Docker network?
docker-compose.yml:59-82— postgres service has NOports:mapping (D-040 honored). An attacker would need to compromise the LXC CT or thepraxis-netbridge. Mitigated by network isolation. ✅ -
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_SECRETfails signature validation →request.sessionis empty →current_operatorreturns 401. ✅ -
Migration replay attack?
init_issuer_keyusesON 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. ✅
Adversarial verdict: PASS — no exploitable attack paths found. Privacy controls are non-configurable (correct). Weak cookie secret is a P1+ carry-forward.
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+):
-
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 toasyncio.to_thread. Non-blocking. -
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. -
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. Addlen(secret) >= 32check with WARNING. Non-blocking. -
set_credential_statusstatus field not validated (db/pg_store.py:223): accepts any string forstatus(no enum check). Currently only called with "revoked" from operator code, but a future caller could pass arbitrary strings. Consider a CHECK constraint on theissued_credentials.statuscolumn or a Python enum. Non-blocking.
From P2 VERIFY (4 P1+):
-
Credential revocation lacks application-level audit log (
server/operator/credentials.py): therevoke_credentialendpoint setsstatus='revoked'+revoked_at=now()but does NOT log the revocation event at the application level, and the revokingoperator_idis not recorded. Mitigation:revoked_attimestamp + signed session cookie. Recommended: addlog.info("credential revoked: operator=%s cred_id=%s", op.id, cred_id)+ consider anaudit_logtable. Non-blocking. -
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 withzoneinfo.ZoneInfo("America/Winnipeg"). Non-blocking. -
Aggregation in-memory cache is per-PgStore-instance (lost on restart) (
server/cohort/aggregator.py:162-170): the_agg_cacheon PgStore tracks running counters + distinct learner sets. On restart, the cache is lost — the next hook starts fresh,active_learners_countmay reset to 1 (under-counting until nightly reconcile). Risk is low — nightly reconciliation recomputes frommastery_gate_events(source of truth), and under-counting → over-suppression (privacy-safe but value-destroying). Non-blocking. -
set_credential_statususes f-string interpolation in SQL (code smell) (db/pg_store.py:227): theextravariable (, revoked_at = now()or empty) is interpolated via f-string. Whileextrais a hardcoded constant (not user input) andstatus/cred_idare 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):
- Argon2id blocking event loop (
server/auth/routes.py:79,88) — offload toasyncio.to_threadif login frequency increases. - Rate limit 429 not tested in mock path (
tests/test_auth.py:303) — add mock-based 429 test. - No PRAXIS_COOKIE_SECRET length validation (
server/auth/cookies.py:41) — addlen(secret) >= 32check. set_credential_statusstatus field not validated (db/pg_store.py:223) — add CHECK constraint or Python enum.
P2 VERIFY P1+ (4):
- Credential revocation lacks application-level audit log (
server/operator/credentials.py) — addlog.info+ consideraudit_logtable. - Nightly scheduler fixed UTC-5 offset (
server/cohort/nightly.py:27) — usezoneinfo.ZoneInfo("America/Winnipeg"). - Aggregation in-memory cache lost on restart (
server/cohort/aggregator.py:162-170) — document or persist distinct-learner set. set_credential_statusf-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.