Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec6fcc64bc | |||
| d3a67511e5 | |||
| 00e39a3f85 | |||
| acbe8692ae | |||
| 6ab40c6f25 |
@@ -0,0 +1,29 @@
|
||||
# Praxis — Operator-tier secrets template (v0.4, TASK-05-02).
|
||||
# Copy to .ciagent/.env.secrets and fill in real values.
|
||||
# .env.secrets is gitignored (verified in .gitignore: .env.secrets).
|
||||
# This file (.env.secrets.example) is committed as documentation.
|
||||
|
||||
# ─── Operator tier (v0.4) ───────────────────────────────────────────────────
|
||||
# Postgres password. Generate: openssl rand -base64 32
|
||||
PRAXIS_PG_PASSWORD=
|
||||
|
||||
# Full Postgres DSN. host=postgres is the docker-compose service DNS name.
|
||||
# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis
|
||||
PRAXIS_PG_DSN=
|
||||
|
||||
# Cookie signing secret (>=32 bytes). Generate: openssl rand -base64 48
|
||||
PRAXIS_COOKIE_SECRET=
|
||||
|
||||
# Bootstrap operator credentials (scripts/create-operator.py).
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS=
|
||||
|
||||
# VC issuer root key (nacl.SecretBox, 32 bytes). Generate:
|
||||
# python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())"
|
||||
PRAXIS_VC_ISSUER_KEY=
|
||||
|
||||
# Issuer URL (public base for VC identifiers).
|
||||
PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4
|
||||
|
||||
# Cookie Secure flag — set false ONLY for the HTTP pilot (R-AUTH-01, G-031).
|
||||
PRAXIS_COOKIE_SECURE=true
|
||||
+307
-39
@@ -348,77 +348,63 @@ Proxmox invokes hookscript at post-start phase (runs on PVE HOST):
|
||||
|
||||
---
|
||||
|
||||
## v0.3 Architecture (Mastery Scoring + Competency Rubrics + VC + Cohort Dashboard)
|
||||
## v0.3 Architecture (Mastery Scoring + Competency Rubrics + VC)
|
||||
|
||||
> **Status:** Research-refined (v0.3 RESEARCH stage). Informed by `.ciagent/RESEARCH.md` v0.3 section.
|
||||
> **Status:** Released (v0.1.5, merged to main). Research-refined (v0.3 RESEARCH stage).
|
||||
> **Decisions:** D-031 (operator tier, overrides D-007 for operator surface), D-032 (mastery gate), D-033 (W3C VC 2.0), D-034 (k-anonymity), D-035 (IRT 1PL), D-036 (scenario library), D-037 (path structure), D-038..D-049 (clarify).
|
||||
> **v0.4 note:** The operator-tier sections below (auth, cohort aggregation, Postgres) were anticipatory in v0.3 and are now confirmed/refined in the v0.4 section (§ v0.4 Operator-Tier Architecture). The v0.3 mastery/VC/IRT sections are released and unchanged.
|
||||
|
||||
### Hybrid Storage Topology (D-031)
|
||||
### Hybrid Storage Topology (D-031 — confirmed in v0.4)
|
||||
|
||||
Learner-local state stays in SQLite (D-007 preserved); operator-tier state goes to a new Postgres service. The two stores never share a session and never join via cross-DB FKs (`learner_ref` is an opaque string in Postgres).
|
||||
|
||||
```
|
||||
LXC Container (from v0.2, memory bumped 4GB → 6GB)
|
||||
LXC Container (v0.2 4GB → v0.4 6GB)
|
||||
Docker daemon
|
||||
├── praxis container (existing v0.2 + v0.3 additions)
|
||||
├── praxis container (v0.2 + v0.3 + v0.4 additions)
|
||||
│ ├─ uvicorn 0.0.0.0:8789
|
||||
│ ├─ GET /health (v0.2)
|
||||
│ ├─ POST /pipecat/webrtc (v0.2)
|
||||
│ ├─ GET / ... StaticFiles (v0.2)
|
||||
│ ├─ /api/operator/* NEW (v0.3 — operator auth gate)
|
||||
│ ├─ /vc/verify/<id> NEW (v0.3 — public, unauthenticated)
|
||||
│ ├─ SQLite /app/data/praxis.db (v0.2 + NEW v0.3 tables: learner_ability, mastery_progress)
|
||||
│ └─ Postgres pool (asyncpg) (v0.3 — operator tier)
|
||||
│ ├─ /vc/verify/<id> (v0.3 — public, unauthenticated)
|
||||
│ ├─ /api/operator/* (v0.4 — operator auth gate — D-057)
|
||||
│ ├─ GET / ... StaticFiles + SPA fallback (v0.2 + v0.4 SPA fallback for /operator/*)
|
||||
│ ├─ SQLite /app/data/praxis.db (v0.2 + v0.3 tables: learner_ability, mastery_progress, issuer_keys, issued_credentials, status_lists)
|
||||
│ └─ Postgres pool (asyncpg) (v0.4 — operator tier — D-050)
|
||||
│
|
||||
└── postgres container NEW (v0.3)
|
||||
└── postgres container (v0.4 — D-040)
|
||||
├─ postgres:16-slim
|
||||
├─ pgdata named volume
|
||||
├─ internal Docker network only (no published port)
|
||||
├─ pgbackups named volume (nightly pg_dump — D-055)
|
||||
├─ praxis-net internal Docker network only (no published port)
|
||||
├─ pg_isready healthcheck
|
||||
└─ Tables: operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys
|
||||
```
|
||||
|
||||
### v0.3 Component Map (additions to v0.2)
|
||||
### v0.3 Component Map (mastery + VC + IRT — released, unchanged)
|
||||
|
||||
```
|
||||
Pipecat server (Python)
|
||||
├─ ... (v0.2 voice loop unchanged) ...
|
||||
├─ Rubric engine NEW (server/mastery/)
|
||||
├─ Rubric engine (server/mastery/)
|
||||
│ ├─ rubric_loader.py (rubrics/<skill>.yaml → Pydantic)
|
||||
│ ├─ rubric_scorer.py (rule-based: signals → 1-5, deterministic — REQ-NFR-MAST-01)
|
||||
│ ├─ evidence_extractor.py (LLM extracts quotes+signals, temp=0, JSON-schema)
|
||||
│ └─ mastery_score.py (weighted mean + conjunctive floor + path gate)
|
||||
├─ IRT engine NEW (server/mastery/irt.py)
|
||||
├─ IRT engine (server/mastery/irt.py)
|
||||
│ ├─ 1PL/Rasch: P(success) = logistic(θ − b)
|
||||
│ ├─ Bayesian θ update per session (<100ms — REQ-NFR-IRT-01)
|
||||
│ └─ θ persisted to SQLite learner_ability (D-046)
|
||||
├─ Scenario library NEW (server/scenarios/library.py)
|
||||
├─ Scenario library (server/scenarios/library.py)
|
||||
│ ├─ scenarios/<path>/<id>.yaml + scenarios/index.yaml (semver, rubric_criteria mapping)
|
||||
│ └─ AI variation review pipeline (_pending/ → expert review → library)
|
||||
├─ Path engine NEW (server/paths/)
|
||||
├─ Path engine (server/paths/)
|
||||
│ ├─ paths/<slug>.yaml (6-week structure, mastery gates — D-037)
|
||||
│ └─ progression: current_week advances on gate-open (D-048)
|
||||
├─ VC issuer NEW (server/vc/)
|
||||
│ ├─ issuer.py (Ed25519, pynacl + canonicaljson + base58, eddsa-jcs-2022)
|
||||
│ ├─ status_list.py (Bitstring Status List v1.0)
|
||||
│ ├─ verification.py (public GET /vc/verify/<id> — D-043)
|
||||
│ └─ issuer key in Postgres issuer_keys (encrypted at rest)
|
||||
├─ Operator auth NEW (server/auth/)
|
||||
│ ├─ SessionMiddleware (Starlette, itsdangerous-signed cookie — D-041)
|
||||
│ ├─ argon2id passwords (argon2-cffi)
|
||||
│ ├─ current_operator Depends
|
||||
│ └─ slowapi 5/min login rate-limit
|
||||
├─ Cohort aggregation NEW (server/cohort/)
|
||||
│ ├─ on-session-end hook → k-anonymized aggregate upsert to Postgres (D-045)
|
||||
│ └─ nightly reconciliation job (cron in praxis service)
|
||||
└─ Operator API NEW (server/operator/)
|
||||
├─ /api/operator/login, /api/operator/logout
|
||||
├─ /api/operator/cohort (k-anonymized, ≥10 learners/cell — D-034)
|
||||
└─ /api/operator/credentials (issued VCs, revocation)
|
||||
|
||||
Client (React)
|
||||
├─ ... (v0.2 voice UI unchanged) ...
|
||||
└─ /operator/* NEW (v0.3 — cohort dashboard UI, auth-gated — D-044)
|
||||
└─ VC issuer (server/vc/)
|
||||
├─ issuer.py (Ed25519, pynacl + canonicaljson + base58, eddsa-jcs-2022)
|
||||
├─ status_list.py (Bitstring Status List v1.0)
|
||||
├─ verification.py (public GET /vc/verify/<id> — D-043)
|
||||
└─ issuer_keys.py (Ed25519 key lifecycle: active/superseded, encrypted at rest — D-042)
|
||||
```
|
||||
|
||||
### Mastery Scoring Flow (off the voice path)
|
||||
@@ -478,4 +464,286 @@ Tables: `operators` (id, username, password_hash argon2id), `issued_credentials`
|
||||
|
||||
### v0.3 Risks (from RESEARCH.md)
|
||||
|
||||
Top risks for PLAN: R-MAST-01 (N=3 thin for credential → label formative), R-AUTH-01 (Secure cookie + no-TLS pilot), R-MT-01 (Postgres resource contention), R-VC-01 (custom VC code ~200 LOC), R-MAST-02 (LLM hallucinated quotes → fuzzy-match guard), R-IRT-01 (cold-start θ → fall back to scenario.difficulty until ≥5 sessions). Full table in RESEARCH.md.
|
||||
Top risks for PLAN: R-MAST-01 (N=3 thin for credential → label formative), R-AUTH-01 (Secure cookie + no-TLS pilot), R-MT-01 (Postgres resource contention), R-VC-01 (custom VC code ~200 LOC), R-MAST-02 (LLM hallucinated quotes → fuzzy-match guard), R-IRT-01 (cold-start θ → fall back to scenario.difficulty until ≥5 sessions). Full table in RESEARCH.md.
|
||||
|
||||
> **v0.4 note:** R-AUTH-01 is resolved in v0.4 via config-driven `PRAXIS_COOKIE_SECURE` (see § v0.4 Operator-Tier Architecture). R-MT-01 is confirmed + mitigated (6GB CT, 03:00 CT nightly jobs).
|
||||
|
||||
---
|
||||
|
||||
## v0.4 Operator-Tier Architecture (Cohort Dashboard + Auth + Postgres)
|
||||
|
||||
> **Status:** Research-refined (v0.4 RESEARCH stage). Informed by `.ciagent/RESEARCH-v0.4-operator-tier.md`.
|
||||
> **Decisions:** D-040 (Postgres 2nd service), D-050 (asyncpg pool + service DNS), D-051 (VC key migration), D-052 (operator bootstrap), D-053 (3 dashboard views), D-054 (async hook + nightly job), D-055 (pg_dump backup), D-056 (signed stateless cookies), D-057 (server-side auth enforcement).
|
||||
> **v0.3 audit:** 2 anticipatory assumptions overturned (asyncpg min_size 2→1, weekly partitions→plain table), 1 refined (Secure cookie → config-driven). See RESEARCH-v0.4 § v0.3 Assumption Audit.
|
||||
|
||||
### v0.4 Component Map (additions to v0.3)
|
||||
|
||||
```
|
||||
Pipecat server (Python)
|
||||
├─ ... (v0.2 voice loop + v0.3 mastery/VC/IRT unchanged) ...
|
||||
├─ Operator auth NEW (server/auth/) (v0.4 — D-041, D-056, D-057)
|
||||
│ ├─ Starlette SessionMiddleware (itsdangerous-signed cookie = HMAC-SHA256 — D-056)
|
||||
│ │ ├─ cookie: praxis_op, httpOnly, SameSite=Strict, max_age=28800 (8h)
|
||||
│ │ ├─ secure: config-driven PRAXIS_COOKIE_SECURE (default true; false for HTTP pilot — R-AUTH-01)
|
||||
│ │ └─ secret: PRAXIS_COOKIE_SECRET (≥32 bytes, from env)
|
||||
│ ├─ argon2id passwords (argon2-cffi PasswordHasher — defaults: t=3, m=64MiB, p=4 — exceeds OWASP)
|
||||
│ │ └─ check_needs_rehash() on login for param upgrades
|
||||
│ ├─ current_operator Depends (router-level dependencies=[...] on /api/operator/* — D-057)
|
||||
│ ├─ slowapi 5/min login rate-limit (in-memory, single-instance — D-041)
|
||||
│ └─ Auth middleware: 401 on missing/invalid/expired cookie for every /api/operator/* request
|
||||
├─ Cohort aggregation NEW (server/cohort/) (v0.4 — D-045, D-053, D-054)
|
||||
│ ├─ on-session-end hook (async fire-and-forget asyncio.Task — D-054)
|
||||
│ │ └─ chained after mastery flow; reads session outcome + rubric scores
|
||||
│ │ → k-anonymized aggregate upsert to Postgres (idempotent by window)
|
||||
│ ├─ nightly reconciliation job (in-process asyncio scheduler, 03:00 CT — D-054)
|
||||
│ │ └─ recomputes all 7-day windows; idempotent upsert by (path, metric, window_start)
|
||||
│ └─ k-anonymity suppression (write-time: COUNT(DISTINCT learner_ref) >= 10, else cell_suppressed=TRUE — D-034)
|
||||
├─ Operator API NEW (server/operator/) (v0.4 — D-053, D-057)
|
||||
│ ├─ POST /api/operator/login (rate-limited 5/min, not auth-gated)
|
||||
│ ├─ POST /api/operator/logout (auth-gated)
|
||||
│ ├─ GET /api/operator/me (auth-gated — React route guard)
|
||||
│ ├─ GET /api/operator/cohort (auth-gated — practice volume view)
|
||||
│ ├─ GET /api/operator/mastery (auth-gated — mastery progression view)
|
||||
│ ├─ GET /api/operator/failure-patterns (auth-gated — failure patterns view)
|
||||
│ └─ GET/POST /api/operator/credentials (auth-gated — VC issuance log + revocation)
|
||||
└─ Postgres store NEW (db/pg_store.py + db/pg_migrations/) (v0.4 — D-040, D-050)
|
||||
├─ asyncpg pool (app.state.pg_pool via lifespan — D-050)
|
||||
│ └─ create_pool(min_size=1, max_size=10, command_timeout=10)
|
||||
├─ pg_migrate.py (mirrors db/migrate.py pattern — ordered .sql, _pg_migrations table)
|
||||
└─ IssuerKeyStore protocol (PraxisStore + PgStore both implement — D-051 migration)
|
||||
|
||||
Client (React)
|
||||
├─ ... (v0.2 voice UI unchanged at /) ...
|
||||
├─ React Router NEW (react-router-dom@^7) (v0.4 — D-044)
|
||||
│ └─ <BrowserRouter> wraps App.tsx; catch-all route serves voice UI at /
|
||||
└─ /operator/* NEW (v0.4 — cohort dashboard UI, auth-gated — D-044, D-053)
|
||||
├─ /operator/login (login form → POST /api/operator/login)
|
||||
├─ /operator/dashboard (3 views: practice, mastery, failure-patterns)
|
||||
├─ Auth gate: GET /api/operator/me on mount → redirect to /operator/login if 401
|
||||
├─ Read-only tables + inline SVG sparklines (zero-dep, ~50 LOC)
|
||||
└─ Freshness indicator: "Last updated: Xh ago" (from cohort_aggregates.updated_at)
|
||||
|
||||
Postgres container (v0.4 — D-040)
|
||||
├─ postgres:16-slim
|
||||
├─ pgdata named volume (PGDATA=/var/lib/postgresql/data/pgdata)
|
||||
├─ pgbackups named volume (nightly pg_dump -Fc — D-055)
|
||||
├─ praxis-net bridge network (no published port, no internal: true)
|
||||
├─ pg_isready healthcheck (10s interval, 5 retries, 5s timeout)
|
||||
├─ depends_on: service_healthy on praxis
|
||||
└─ Tables: operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys
|
||||
```
|
||||
|
||||
### Postgres Service in docker-compose (D-040, D-050)
|
||||
|
||||
```yaml
|
||||
# Shape only — not for commit (v0.4 P1 implementation)
|
||||
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: praxis
|
||||
POSTGRES_PASSWORD: ${PRAXIS_PG_PASSWORD}
|
||||
POSTGRES_DB: praxis
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
env_file:
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgbackups:/backups
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U praxis -d praxis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks: [praxis-net]
|
||||
# NOTE: no `ports:` — not exposed to the LXC host bridge (D-040)
|
||||
|
||||
volumes:
|
||||
praxis-data: # existing v0.2
|
||||
driver: local
|
||||
pgdata: # NEW v0.4
|
||||
driver: local
|
||||
pgbackups: # NEW v0.4
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
praxis-net: # NEW v0.4
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
**Connection DSN (D-050):** `postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis` (host = service name on praxis-net).
|
||||
|
||||
### asyncpg Pool (D-050)
|
||||
|
||||
```python
|
||||
# Shape only — lifespan context manager
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncpg
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
app.state.pg_pool = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"],
|
||||
min_size=1, # D-050 (lower than v0.3 anticipatory min_size=2)
|
||||
max_size=10,
|
||||
command_timeout=10,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await app.state.pg_pool.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
```
|
||||
|
||||
The `PraxisStore` (aiosqlite) keeps its current per-call connect pattern — **pools are independent and must not be shared** (different backends, different lifecycles).
|
||||
|
||||
### Auth Middleware Flow (D-056, D-057)
|
||||
|
||||
```
|
||||
Client request → /api/operator/cohort
|
||||
│
|
||||
├─ Starlette SessionMiddleware
|
||||
│ ├─ reads praxis_op cookie
|
||||
│ ├─ validates HMAC-SHA256 signature (itsdangerous)
|
||||
│ ├─ checks max_age (8h expiry)
|
||||
│ └─ populates request.session = {operator_id, issued_at} (or empty if invalid)
|
||||
│
|
||||
├─ current_operator Depends (router-level)
|
||||
│ ├─ reads request.session["operator_id"]
|
||||
│ ├─ if missing → 401 "not authenticated"
|
||||
│ ├─ fetches operator from Postgres operators table
|
||||
│ ├─ if not found / not is_active → 401 + clear cookie
|
||||
│ └─ returns Operator (injected into route)
|
||||
│
|
||||
└─ Route handler (GET /api/operator/cohort)
|
||||
└─ queries Postgres cohort_aggregates (k-anonymized) → returns JSON
|
||||
```
|
||||
|
||||
**Login flow:**
|
||||
```
|
||||
POST /api/operator/login {username, password}
|
||||
│
|
||||
├─ slowapi rate-limit check (5/min per IP — D-041)
|
||||
│ └─ if exceeded → 429 + Retry-After
|
||||
│
|
||||
├─ fetch operator by username from Postgres
|
||||
├─ argon2-cffi PasswordHasher().verify(stored_hash, password)
|
||||
│ ├─ if invalid → 401 (increment rate-limit counter)
|
||||
│ └─ if valid → check_needs_rehash(stored_hash) → rehash if params bumped
|
||||
│
|
||||
└─ Set signed cookie: request.session["operator_id"] = op.id
|
||||
→ response 200 {operator: {id, username, display_name}}
|
||||
```
|
||||
|
||||
**React route guard (UX only — server is authority per D-057):**
|
||||
```
|
||||
/operator/dashboard mount
|
||||
│
|
||||
├─ GET /api/operator/me (with cookie)
|
||||
│ ├─ 200 → render dashboard
|
||||
│ └─ 401 → redirect to /operator/login
|
||||
```
|
||||
|
||||
### Aggregation Pipeline (D-045, D-053, D-054)
|
||||
|
||||
```
|
||||
Session end (server/session_recorder.py)
|
||||
│
|
||||
├─ 1. Mastery flow (asyncio.Task — existing v0.3 pattern)
|
||||
│ └─ evidence → rubric score → IRT θ → gate check → VC issuance
|
||||
│
|
||||
└─ 2. Cohort aggregation hook (asyncio.Task — v0.4, chained after mastery)
|
||||
├─ reads session outcome + rubric scores + scenario failure_mode
|
||||
├─ computes k-anonymized aggregate for (path, metric, window_start)
|
||||
├─ COUNT(DISTINCT learner_ref) >= 10 check
|
||||
│ ├─ if ≥10 → upsert value to cohort_aggregates
|
||||
│ └─ if <10 → upsert with cell_suppressed=TRUE, value=NULL
|
||||
└─ failures log + nightly job reconciles (idempotent)
|
||||
|
||||
Nightly reconciliation (in-process asyncio scheduler, 03:00 CT)
|
||||
├─ recomputes all 7-day windows for all paths
|
||||
├─ idempotent upsert by (path, metric, window_start)
|
||||
└─ guarantees REQ-NFR-DASH-02 (freshness ≤ 24h)
|
||||
```
|
||||
|
||||
### 3 Dashboard Views (D-053)
|
||||
|
||||
| View | Endpoint | Metrics (k-anonymized, 7-day windows) |
|
||||
|------|----------|---------------------------------------|
|
||||
| Practice volume | GET /api/operator/cohort | sessions/day per path; total sessions; active learners (suppressed if <10) |
|
||||
| Mastery progression | GET /api/operator/mastery | % learners at each week (1-6); gate-open rate; median mastery_score; rubric criterion means |
|
||||
| Failure patterns | GET /api/operator/failure-patterns | top failure_modes by frequency; rubric criteria with mean < 3.0; branch outcome distribution |
|
||||
|
||||
All views: read-only tables + inline SVG sparklines; no per-learner drill-down (k-anon); suppressed cells shown as "— (<10 learners)".
|
||||
|
||||
### Postgres Schema (operator tier — D-040, refined by D-050..D-053)
|
||||
|
||||
Tables: `operators` (id UUID DEFAULT gen_random_uuid(), username TEXT UNIQUE, password_hash TEXT argon2id, display_name TEXT, role TEXT DEFAULT 'operator', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ), `issued_credentials` (id UUID, operator_id UUID REFERENCES operators, learner_ref TEXT opaque, vc_type TEXT, payload_jsonb JSONB, issued_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ), `mastery_gate_events` (id UUID, learner_ref TEXT, scenario_id TEXT, path_id TEXT, gate_outcome TEXT, recorded_at TIMESTAMPTZ, source TEXT DEFAULT 'sync'), `cohort_aggregates` (path TEXT, metric TEXT, window_start DATE, window_end DATE, value NUMERIC, cell_count INTEGER, cell_suppressed BOOLEAN, updated_at TIMESTAMPTZ, PRIMARY KEY (path, metric, window_start) — **plain table, not partitioned** (v0.4 scale; add partitioning post-pilot)), `issuer_keys` (id TEXT, public_key TEXT, private_key_enc BYTEA, status TEXT active|superseded, created_at TIMESTAMPTZ). `gen_random_uuid()` in PG16 core (no extension). No cross-DB FKs.
|
||||
|
||||
### VC Key Migration (D-042, D-051)
|
||||
|
||||
```
|
||||
v0.4 first boot:
|
||||
│
|
||||
├─ 1. Postgres issuer_keys table created (pg_migrate.py)
|
||||
│
|
||||
├─ 2. Read v0.3 active public key from SQLite issuer_keys
|
||||
│ └─ insert into Postgres issuer_keys with status='superseded'
|
||||
│ (private key NOT migrated — only public key archived for verification)
|
||||
│
|
||||
├─ 3. Generate fresh Ed25519 keypair in Postgres issuer_keys (status='active')
|
||||
│ └─ private key encrypted at rest via nacl.SecretBox (PRAXIS_VC_ISSUER_KEY root key)
|
||||
│
|
||||
└─ 4. Verification endpoint (server/vc/verification.py):
|
||||
├─ extract key_id from proof.verificationMethod
|
||||
├─ get_public_key_for_verification(store, key_id)
|
||||
│ └─ queries by id (not status) → finds active OR superseded keys
|
||||
└─ verify_proof(secured_doc, verify_key)
|
||||
├─ v0.3 VCs → v0.3 key_id → archived (superseded) public key → verifies ✓
|
||||
└─ v0.4 VCs → v0.4 key_id → active public key → verifies ✓
|
||||
```
|
||||
|
||||
**IssuerKeyStore protocol:** the existing `server/vc/issuer_keys.py` functions take a `PraxisStore` (SQLite). v0.4 refactors to an `IssuerKeyStore` protocol/ABC with methods `init_issuer_key`, `get_active_signing_key_row`, `get_public_key_row`, `set_issuer_key_superseded`. Both `PraxisStore` (SQLite, for v0.3 compat) and `PgStore` (Postgres, for v0.4) implement it.
|
||||
|
||||
### Backup Strategy (D-055)
|
||||
|
||||
```
|
||||
Host-side cron (decoupled from praxis service uptime):
|
||||
03:30 CT nightly:
|
||||
docker compose exec -T postgres pg_dump -U praxis -Fc praxis \
|
||||
-f /backups/praxis-$(date +%u).dump
|
||||
→ pgbackups named volume, %u = day-of-week 1-7 → rolling 7-file retention
|
||||
|
||||
Restore drill:
|
||||
docker compose exec postgres pg_restore -U praxis -d praxis \
|
||||
--clean --if-exists /backups/praxis_3.dump
|
||||
(never restore into live DB without stopping praxis first)
|
||||
```
|
||||
|
||||
### CT Resource Sizing (v0.4 bump)
|
||||
|
||||
| Resource | v0.2 | v0.3 (anticipatory) | v0.4 (confirmed) | Rationale |
|
||||
|----------|------|---------------------|------------------|-----------|
|
||||
| Memory | 4096 MB | 6144 MB | **6144 MB** | Postgres ~400MB + praxis ~500MB + Docker ~200MB + build headroom ~1GB + margin |
|
||||
| Rootfs | 16 GB | 16 GB | **16 GB** | Postgres data on pgdata named volume, not rootfs; pgbackups on named volume |
|
||||
| CPU | 2 | 2-4 | **2-4** | Postgres + praxis concurrent; 2 floor, 4 preferred |
|
||||
|
||||
### v0.4 Risks (from RESEARCH-v0.4-operator-tier.md)
|
||||
|
||||
Top risks for PLAN: R-AUTH-01 (Secure cookie + no-TLS → config-driven flag, grill must sign off), R-VC-MIG-01 (VC key migration loses v0.3 public key → archive as superseded before activating new key), R-DASH-03 (SPA fallback breaks voice UI → catch-all route before StaticFiles mount), R-MT-01 (Postgres resource contention → 03:00 CT nightly jobs, 6GB CT). Full table (20 risks) in RESEARCH-v0.4-operator-tier.md.
|
||||
|
||||
### v0.4 New Dependencies
|
||||
|
||||
**Pip (pyproject.toml):** `asyncpg>=0.29` (Postgres driver), `argon2-cffi>=23.1` (password hashing), `slowapi>=0.1` (rate limiting). `pynacl`, `canonicaljson`, `base58` already present (v0.3).
|
||||
|
||||
**Npm (client/package.json):** `react-router-dom@^7` (React routing for /operator/*). No chart library — inline SVG sparklines (zero deps).
|
||||
+12
-11
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"phase": 1,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.3",
|
||||
"phase_role": "final",
|
||||
"milestone": "v0.4",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-03T21:55:00Z",
|
||||
"milestone_complete": true,
|
||||
"milestone_merged_to_main": true,
|
||||
"tag": "v0.1.5",
|
||||
"release_url": "https://git.cloudinit.dev/coreci/praxis/releases/tag/v0.1.5",
|
||||
"updated_at": "2026-08-04T03:30:00Z",
|
||||
"milestone_complete": false,
|
||||
"milestone_merged_to_main": false,
|
||||
"tag": "v0.1.7",
|
||||
"release_url": "https://git.cloudinit.dev/coreci/praxis/releases/tag/v0.1.7",
|
||||
"release_status": "created",
|
||||
"next_milestone": "v0.4",
|
||||
"next_milestone": null,
|
||||
"requirements": {
|
||||
"covered": ["REQ-MAST-01", "REQ-MAST-02", "REQ-MAST-03", "REQ-SCEN-02", "REQ-SCEN-03", "REQ-SCEN-04", "REQ-PATH-02", "REQ-NFR-MAST-01", "REQ-NFR-MAST-02", "REQ-NFR-VC-01", "REQ-NFR-VC-02", "REQ-NFR-IRT-01"],
|
||||
"deferred": ["REQ-DASH-01", "REQ-AUTH-01", "REQ-MT-01", "REQ-MT-02", "REQ-NFR-DASH-01", "REQ-NFR-DASH-02", "REQ-NFR-AUTH-01", "REQ-NFR-MT-01"]
|
||||
"covered": ["REQ-MT-01", "REQ-AUTH-01", "REQ-NFR-AUTH-01", "REQ-NFR-MT-01", "REQ-MT-02"],
|
||||
"active": ["REQ-DASH-01", "REQ-NFR-DASH-01", "REQ-NFR-DASH-02"],
|
||||
"deferred": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
# CIAgent Grill Report — v0.4 Operator Tier
|
||||
|
||||
## Run: 2026-08-04 (mode: mechanical, focus: all axes + 6 v0.4-specific probes)
|
||||
|
||||
> **Reviewer:** adversarial technology executive (red-team)
|
||||
> **Subject:** v0.4 execution plan (Operator Tier — Cohort Dashboard + Auth + Postgres) — 2 execution phases, 10 slices, 52 tasks
|
||||
> **Stance:** plan is unfeasible, over-scoped, and too costly until evidence forces otherwise
|
||||
> **Artifacts reviewed:** PROJECT.md, REQUIREMENTS.md, ROADMAP.md, ARCHITECTURE.md, RESEARCH-v0.4-operator-tier.md, PERSONAS.md, PLAN-v0.4-operator-tier.md, GRILL-v0.3.md, config.json, docker-compose.yml, server/session_recorder.py, server/vc/issuer_keys.py, server/__main__.py, db/store.py
|
||||
> **Binding status:** This grill verdict must be cleared (MUSTs resolved, FIXs tracked) before EXECUTE is authorized.
|
||||
|
||||
---
|
||||
|
||||
### Verdict: Proceed-with-conditions (confidence: 0.72)
|
||||
|
||||
The v0.4 plan is well-researched, cleanly phased, and honors the v0.3 grill's binding verdict (operator tier deferred, formative label applied, scoring_inconclusive fallback implemented, VC interop + key-rotation drills shipped in v0.3 codebase — all verified). The architecture is sound and the risk register is the most honest in the project's history (20 risks, 1 high, 9 medium, 11 low — all addressed). However, three material issues must be resolved before EXECUTE: (1) R-AUTH-01 is a *partial* resolution that re-litigates a v0.3 grill MUST — the config-driven flag is a punt, not a fix, and the cohort-dashboard-reads-only-aggregates defense-in-depth is the *real* mitigation, which should be elevated; (2) the k-anonymity-at-pilot-scale problem means v0.4 ships a dashboard that cannot display any data at production pilot scale (1 learner) — this is a *real deliverable* only if test-seeded data is treated as the validation path, which the plan does but does not emphasize; (3) the VC key migration verification endpoint now queries *two* stores (Postgres for keys, SQLite-fallback for v0.3 credentials) — a complexity the plan defers to "open question #1" but which is on the critical path of R-VC-MIG-01.
|
||||
|
||||
The plan is **not** over-scoped (8 REQs, cleanly split P1 infra / P2 feature). It is **not** unfeasible (52 tasks vs v0.3's 40, analogous). It is **not** a zombie (the operator tier was the explicitly-deferred v0.3 scope, now delivered). The conditions are binding but surgical.
|
||||
|
||||
---
|
||||
|
||||
### Axis 1 — Business Case
|
||||
|
||||
- **Q1: What problem does v0.4 solve, and is it the top priority?**
|
||||
- Evidence: GRILL-v0.3.md Axis 2 MUST #1 — "defer REQ-DASH-01 + operator tier to v0.4"; ROADMAP.md:9 — "v0.4 activates the operator tier deferred from v0.3 per the grill's binding verdict"; PROJECT.md:47 — "v0.4 layers the operator surface on top of it."
|
||||
- Answer: v0.4 delivers the operator tier that the v0.3 grill explicitly split out. The operator tier (cohort dashboard + auth + Postgres) was originally v0.8 on the ROADMAP (GRILL-v0.3.md:44), pulled to v0.3, then split to v0.4 by the grill. This is the *deferred obligation*, not new scope. The priority is correct: v0.3 shipped the learner-facing mastery layer; v0.4 ships the operator-facing visibility layer. The alternative (multi-path / Live Assist / low-bandwidth) would expand the learner surface before the operator surface exists to observe it.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-001** — v0.4 operator tier is the correct next priority (delivers the v0.3 grill's deferred obligation). (0.85)
|
||||
|
||||
- **Q2: Who is the named executive sponsor for the operator tier?**
|
||||
- Evidence: config.json:13 — `"level": "full"`; config.json:16 — `"decision_confidence_threshold": 0.6`; PROJECT.md:5 — "Autonomy: full."
|
||||
- Answer: No human sponsor. The CI agent is the executive sponsor under full autonomy. This is the project's established governance model since v0.1. The v0.3 grill accepted this (no escalation on governance). The "sponsor makes a decision under pressure" test is met by the grill itself — this document is the pressure decision.
|
||||
- Confidence: 0.80
|
||||
- Decision: **G-002** — CI is the named sponsor under full autonomy (no change from v0.1-v0.3 governance). (0.80)
|
||||
|
||||
- **Q3: What happens to the business if v0.4 is cancelled?**
|
||||
- Evidence: ROADMAP.md:131-139 — future milestones (v0.5 Live Assist, v0.6 low-bandwidth) do not depend on the operator tier; v0.9 credentialing depends on VC issuer (v0.3, already shipped). The learner-facing product (v0.1-v0.3) works without the operator tier.
|
||||
- Answer: If v0.4 is cancelled, the learner product continues to function. The operator tier is a *visibility* feature, not a *learner-path* feature. However, cancelling v0.4 means the v0.3 grill's binding verdict (defer to v0.4) becomes a *permanent deferral* — the operator tier was promised and not delivered. This would be the first broken grill commitment. The project is not a zombie (cancelling has a cost: the grill's credibility), but the operator tier is a nice-to-have for the pilot, not a blocker for a pilot deployment. A pilot can run with a single learner and no dashboard.
|
||||
- Confidence: 0.75
|
||||
- Challenge: The operator tier's business value at pilot scale (1 learner, k-anon suppresses everything) is low. The dashboard will show "— (<10 learners)" for every cell. This is a *placeholder deliverable* unless multi-learner data is seeded. The plan acknowledges this (Open Question #3) but does not treat it as a material risk to the business case.
|
||||
- Decision: **G-003** — v0.4 is not a zombie (delivers a grill obligation) but its pilot-scale business value is low (k-anon suppresses all cells with 1 learner). The dashboard's validation path is test-seeded data (≥10 mock learners), not pilot traffic. This must be documented in the ship notes. (0.75)
|
||||
|
||||
- **Q4: Is the ROI calculated against a counterfactual?**
|
||||
- Evidence: MISSING — no ROI calculation in any `.ciagent/` file. The project is a pre-revenue pilot (D-012 — no enforced cost ceiling for pilot).
|
||||
- Answer: No ROI calculation exists. The counterfactual is "ship v0.4 vs skip to v0.5 (Live Assist)." Shipping v0.4 costs ~52 tasks of tokens + a Postgres service + 3 new pip deps + 1 new npm dep. Skipping to v0.5 would leave the operator tier permanently deferred (broken grill commitment) and Live Assist would build on a learner surface with no operator visibility. The ROI is *governance credibility* + *operator visibility foundation for v0.5+*, not a financial return.
|
||||
- Confidence: 0.65
|
||||
- Decision: **G-004** — no financial ROI; the ROI is governance credibility (delivering the grill's deferred obligation) + architectural foundation (Postgres + auth for v0.5+). Accept the non-financial ROI under full autonomy. (0.65)
|
||||
|
||||
---
|
||||
|
||||
### Axis 2 — Scope and Requirements
|
||||
|
||||
- **Q1: Is v0.4 scope stable? (8 REQs from v0.3 grill deferral — clean handoff, or new scope creep?)**
|
||||
- Evidence: GRILL-v0.3.md Axis 2 MUST #1 — "defer REQ-DASH-01 + REQ-AUTH-01 + REQ-MT-01/02 + 4 NFRs to v0.4"; REQUIREMENTS.md:8-36 — v0.4 activates exactly those 8 REQs; PROJECT.md:49-54 — v0.4 in-scope matches the deferred set.
|
||||
- Answer: Clean handoff. The 8 REQs activated in v0.4 are exactly the 8 REQs the v0.3 grill deferred. No new REQs were added. No scope creep. The scope is *contracting* relative to the v0.3 plan (which originally included these + the mastery layer).
|
||||
- Confidence: 0.90
|
||||
- Decision: **G-005** — v0.4 scope is a clean handoff from the v0.3 grill deferral. No scope creep. (0.90)
|
||||
|
||||
- **Q2: Who owns the requirements, and are they frozen?**
|
||||
- Evidence: config.json:13 — full autonomy; PROJECT.md:5 — "Autonomy: full"; REQUIREMENTS.md:8-36 — 8 active REQs with Phase + Status columns.
|
||||
- Answer: CI owns the requirements under full autonomy. They are frozen at the SPECIFY stage (commit 1b5173e — "validate specification"). The CLARIFY stage (commit 4f565d6) added D-050..D-057 but did not add/remove REQs. Frozen.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-006** — requirements are frozen (8 REQs, CI-owned under full autonomy). (0.85)
|
||||
|
||||
- **Q3: What is explicitly out of scope?**
|
||||
- Evidence: PROJECT.md:56-65 — explicit out-of-scope list; REQUIREMENTS.md:38-48 — out-of-scope list.
|
||||
- Answer: Explicitly out of scope: multi-path launch, full operator-suite dashboard (REQ-DASH-02), Live Assist, low-bandwidth, multi-language, persona switching, learner auth, RBAC (single operator role), third-party credential issuers, differential privacy. The out-of-scope list is the most explicit in the project's history. Single operator role (no RBAC) is the key constraint — v0.4 ships one role.
|
||||
- Confidence: 0.88
|
||||
- Decision: **G-007** — out-of-scope is explicit and comprehensive (RBAC, learner auth, DP, multi-path all deferred). (0.88)
|
||||
|
||||
- **Q4: Hidden requirements? (TLS for secure cookies? Postgres backup verification? Operator account lifecycle — deactivation, password reset?)**
|
||||
- Evidence: RESEARCH-v0.4 §2.4 — R-AUTH-01 acknowledges the Secure-cookie+no-TLS tension; D-055 — backup strategy defined (pg_dump, 7-day retention); D-052 — operator bootstrap CLI; PROJECT.md:62 — "RBAC deferred (one role)."
|
||||
- Answer:
|
||||
- **TLS for secure cookies**: NOT a hidden requirement — it is the explicit R-AUTH-01 tension, resolved (partially) by config-driven `PRAXIS_COOKIE_SECURE`. See Axis 3 + signature probe.
|
||||
- **Postgres backup verification**: The plan defines a backup strategy (TASK-02-03 — backup cron script) but **does NOT define a backup verification / restore drill**. The script comments mention `pg_restore --clean --if-exists` but there is no task that *executes* a restore and verifies data integrity. A backup that is never restored is an unverified backup. This is a hidden requirement.
|
||||
- **Operator account lifecycle (deactivation, password reset)**: D-052 defines bootstrap (creation) + a `--update` flag (password rehash). The `operators` table has `is_active` (TASK-03-04 handles inactive → 401). But **there is no operator deactivation task** — no CLI to set `is_active=false`, no UI for it. Password reset = `create-operator.py --update` (documented). Deactivation is a gap, but minor (single operator, can be done via SQL if needed). Not a blocker.
|
||||
- Confidence: 0.70
|
||||
- Challenge: Backup verification is a hidden requirement. A nightly pg_dump that is never restored is theater, not a backup.
|
||||
- Decision: **G-008 (MUST)** — Add a backup-restore drill task to P1 (either in SLICE-02 or SLICE-06): execute `pg_restore --clean --if-exists` against a test Postgres instance, verify the 5 tables + row counts match. This is a one-task addition. The restore drill must run at least once in CI/staging to prove the backup is valid. (0.70)
|
||||
|
||||
---
|
||||
|
||||
### Axis 3 — Architecture and Technical Feasibility
|
||||
|
||||
- **Q1: Has the Postgres-in-LXC + asyncpg + auth + dashboard architecture been validated by operators, or only by the plan?**
|
||||
- Evidence: RESEARCH-v0.4 §1.1-1.7 — Postgres 16-slim resource footprint analysis (0.88 confidence); §2.1-2.6 — argon2id + SessionMiddleware (0.88); §4.1-4.5 — React Router + SPA fallback (0.85). No external operator validation (full autonomy — CI is the operator).
|
||||
- Answer: The architecture is validated by research (vendor docs, OWASP, ecosystem knowledge) and codebase inspection (existing `session_recorder.py:143` asyncio.create_task pattern, existing `issuer_keys.py` lifecycle). It is NOT validated by an external operator (none exists). The asyncpg pool pattern (lifespan context manager) is standard FastAPI. The Starlette SessionMiddleware is the documented FastAPI session pattern. The SPA fallback (catch-all before StaticFiles) is the standard React-in-FastAPI pattern. The architecture is *conventional* — no novel combinations.
|
||||
- Confidence: 0.80
|
||||
- Decision: **G-009** — architecture is conventional (standard FastAPI + Postgres + React patterns), research-validated. No external operator exists (full autonomy). Accept. (0.80)
|
||||
|
||||
- **Q2: Integration surface — Postgres 16, asyncpg, Starlette SessionMiddleware, slowapi, argon2-cffi, react-router-dom. Risk of quiet cost doubling?**
|
||||
- Evidence: PLAN-v0.4:770-771 — 3 new pip deps (asyncpg, argon2-cffi, slowapi) + 1 new npm dep (react-router-dom). RESEARCH-v0.4 §new-deps.
|
||||
- Answer: 4 new dependencies. Each is a CVE vector + version-pin burden. asyncpg is the most consequential (new DB driver — connection pool lifecycle, statement cache, type coercion). slowapi is the youngest (maintenance risk — RESEARCH-v0.4 §2.5 notes "young lib, but works" at 0.70 confidence). argon2-cffi is mature (reference impl wrapper). react-router-dom@^7 is the standard React router (mature, but v7 is a major version — the `<BrowserRouter>` API is stable). The cost-doubling risk is low — these are all single-purpose, well-scoped deps. The *real* cost is the Postgres service (memory, disk, backup, migration runner) — but that is budgeted (6GB CT, pgdata/pgbackups volumes).
|
||||
- Confidence: 0.78
|
||||
- Decision: **G-010** — 4 new deps, all single-purpose and well-scoped. Cost-doubling risk is low. slowapi is the youngest dep — the plan documents a hand-rolled counter fallback (RESEARCH-v0.4 §2.5). Accept with the fallback documented. (0.78)
|
||||
|
||||
- **Q3: Is there an existing system being replaced? (VC issuer key store SQLite→Postgres — migration path for existing issued VCs?)**
|
||||
- Evidence: server/vc/issuer_keys.py (128 lines) — current SQLite-backed key store; D-051 — migration strategy; PLAN-v0.4 SLICE-04 — VC key migration slice; TASK-06-05 — R-VC-MIG-01 e2e test.
|
||||
- Answer: The VC issuer key store is being migrated (SQLite→Postgres). The v0.3 `issued_credentials` table remains in SQLite (no data migration — D-051 "no re-issuance"). The verification endpoint (TASK-04-04) will try Postgres for keys, fall back to SQLite for v0.3 credentials. This is a *two-store verification path* — a complexity that is on the critical path of R-VC-MIG-01.
|
||||
- Confidence: 0.75
|
||||
- Challenge: The two-store verification path (Postgres for keys, SQLite-fallback for v0.3 credentials) is a *hidden complexity*. Open Question #1 (PLAN-v0.4:742) defers this to EXECUTE: "the executor should choose the simpler approach." But this is not an implementation detail — it is an architectural decision that affects the verification endpoint's failure modes. If Postgres is down, can v0.3 credentials still verify? The plan says TASK-04-04 "try Postgres first, fall back to SQLite" but TASK-06-03 says "if pg_store is None, fall back to PraxisStore path (v0.3 compat)." These two fallback semantics are *consistent* but the plan does not make the consistency explicit.
|
||||
- Decision: **G-011 (MUST)** — The verification endpoint's two-store fallback semantics must be explicit in the plan, not deferred to EXECUTE. Rule: (a) if Postgres is available, use it for key lookup (both active + superseded keys); (b) if Postgres is available but the credential is not found in Postgres `issued_credentials`, fall back to SQLite `issued_credentials` (v0.3 credentials); (c) if Postgres is NOT available (no DSN), use the existing v0.3 SQLite path for both keys + credentials. This must be documented in TASK-04-04 and TASK-06-03 as a binding contract, not an open question. (0.75)
|
||||
|
||||
- **Q4: Technical debt inherited — v0.3's SQLite VC issuer keys, single hardcoded learner profile, no TLS in the LXC pilot.**
|
||||
- Evidence: db/store.py:29 — `HARDCODED_LEARNER_ID = "learner-1"`; server/__main__.py:46 — `HOST = _env("PRAXIS_HOST", "0.0.0.0")` (binds to all interfaces, not loopback); D-030 — no Traefik/TLS for pilot.
|
||||
- Answer: Three inherited debts:
|
||||
1. **SQLite VC issuer keys** — being migrated (D-051). This is v0.4's *job*, not inherited debt.
|
||||
2. **Single hardcoded learner profile** — `HARDCODED_LEARNER_ID = "learner-1"`. This is the *root cause* of the k-anon-at-pilot-scale problem (see signature probe #3). Not addressed in v0.4 (multi-learner-per-device is deferred). The aggregation pipeline groups by `learner_ref` but there is only one `learner_ref`. The dashboard will suppress everything.
|
||||
3. **No TLS in the LXC pilot** — D-030. This is the root cause of R-AUTH-01 (see signature probe #1). Not addressed in v0.4 (TLS deferred to a later milestone).
|
||||
- Confidence: 0.72
|
||||
- Decision: **G-012** — three inherited debts acknowledged: (1) SQLite VC keys → being migrated (v0.4's job); (2) single hardcoded learner → not addressed (k-anon suppresses all pilot data); (3) no TLS → not addressed (R-AUTH-01 config-driven punt). Debts #2 and #3 are accepted as pilot-scale constraints with documented mitigations. (0.72)
|
||||
|
||||
---
|
||||
|
||||
### Axis 4 — People, Skills, and Organization
|
||||
|
||||
- **Q1: Key-person dependency — which 2-3 personas, if absent, would v0.4 fail?**
|
||||
- Evidence: PERSONAS.md v0.4 roster — 6 active personas; PLAN-v0.4:79-86 + :419-426 — persona load distribution.
|
||||
- Answer: The 3 critical personas:
|
||||
1. **security-engineer** — owns VC key migration (R-VC-MIG-01, high severity) + auth stack (argon2id, cookies, rate limit). If absent, the highest-severity risk is unowned. 8 tasks in P1.
|
||||
2. **data-engineer** — owns Postgres schema + migration runner + PgStore + IssuerKeyStore protocol. If absent, the foundation (SLICE-01) is unowned. 8 tasks in P1 + 3 in P2.
|
||||
3. **backend-engineer** — owns asyncpg pool wiring + operator API (8 endpoints) + aggregation pipeline + SPA fallback + session_recorder extension. The largest task surface (16 tasks across P1+P2). If absent, the integration slices (SLICE-06, SLICE-10) have no owner.
|
||||
The lead-developer is coordination (not key-person — can be covered by backend-engineer). The frontend-engineer is P2-only (dashboard UI). The devops-engineer is P1-only (compose + backup + bootstrap). The key-person risk is concentrated in security + data + backend.
|
||||
- Confidence: 0.82
|
||||
- Decision: **G-013** — key-person dependency: security-engineer, data-engineer, backend-engineer. All 3 are critical-path. Under full autonomy with parallelization (max 5 concurrent), this is manageable. Accept. (0.82)
|
||||
|
||||
- **Q2: Are the 6 personas actually available?**
|
||||
- Evidence: config.json:22-27 — parallelization enabled, max 5 concurrent; PERSONAS.md — 6 active personas (lead, backend, frontend, data, security, devops). security-engineer + devops-engineer are NOT in config.json personas array (emergent — defined in PERSONAS.md, per PERSONAS.md:542).
|
||||
- Answer: All 6 are "available" in the sense that the CI agent spawns them on demand. The config.json `personas` array has only 4 (lead, backend, frontend, data); security + devops are emergent (PERSONAS.md). Territory enforcement is `warn` (config.json:51) — so emergent personas are not blocked. The max-concurrent-agents is 5, but 6 personas are active — one will be idle at peak. The P1 wave-2 has 3 parallel slices (SLICE-03, 04, 05) — 3 personas active (security, security, devops). The P2 wave-1 has 3 parallel slices (SLICE-07, 08, 09) — 3 personas (backend, backend, frontend). The 5-agent limit is not a binding constraint.
|
||||
- Confidence: 0.80
|
||||
- Decision: **G-014** — 6 personas available (4 in config + 2 emergent), max 5 concurrent. The 6>5 mismatch is not binding (peak parallelism is 3 slices). Accept. (0.80)
|
||||
|
||||
- **Q3: Product owner with authority?**
|
||||
- Evidence: config.json:13 — full autonomy; PROJECT.md:5.
|
||||
- Answer: CI is the product owner under full autonomy. This is the established model since v0.1. No committee. The grill is the pressure-test.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-015** — CI is the product owner with full authority (no change). (0.85)
|
||||
|
||||
- **Q4: Is the team building capability they don't have? (Postgres admin, k-anonymity, argon2id — all new to the project)**
|
||||
- Evidence: RESEARCH-v0.4 §1-7 — all 7 domains are new to the project (Postgres 16, asyncpg, argon2id, Starlette SessionMiddleware, slowapi, k-anonymity, React Router); PERSONAS.md v0.4 — data-engineer expands to Postgres, security-engineer expands to argon2id + slowapi.
|
||||
- Answer: Yes — the team is building capability it doesn't have. Postgres admin (migrations, pool, backup), k-anonymity (write-time suppression SQL), argon2id (OWASP params), signed cookies (Starlette SessionMiddleware), React Router (SPA fallback). All new. However: (a) this is a *pilot*, not a production system — learning-as-you-go is acceptable for prototypes per the grill's stance; (b) the research is thorough (OWASP fetched 2026-08-04, Postgres 16 docs verified, asyncpg pattern validated); (c) the highest-risk new capability (custom VC crypto) was already shipped in v0.3 with interop + rotation tests (verified in codebase: test_vc_interop.py, test_vc_key_rotation_drill.py). The v0.4 new capabilities are *conventional* (standard FastAPI + Postgres + React patterns), not novel.
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-016** — team is building new capability (Postgres, auth, k-anon, React Router) but all are conventional patterns with thorough research. Accept for pilot. (0.75)
|
||||
|
||||
---
|
||||
|
||||
### Axis 5 — Timeline and Estimates
|
||||
|
||||
- **Q1: Was the 2-execution-phase structure set before or after the scope was understood?**
|
||||
- Evidence: ROADMAP.md:31-53 — P1/P2/P3 structure defined in ROADMAP (pre-PLAN); PLAN-v0.4:14-22 — phase split rationale refines the ROADMAP structure.
|
||||
- Answer: The ROADMAP defined P1 (operator foundation) + P2 (cohort dashboard) + P3 (review) *before* the PLAN. The PLAN refined the split (6 slices in P1, 4 in P2). The scope was understood at ROADMAP time (8 REQs from v0.3 grill deferral). The deadline (per-phase ship tags v0.1.7, v0.1.8, v0.1.9) was set in ROADMAP. This is *not* a reverse-engineered deadline — the phases are defined by scope (P1 = infra/auth, P2 = dashboard), not by a target date.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-017** — phase structure set after scope was understood (ROADMAP post-grill). Not reverse-engineered. (0.85)
|
||||
|
||||
- **Q2: Critical path — what single thing would push v0.4 by a phase?**
|
||||
- Evidence: PLAN-v0.4 wave dependency graphs (P1:60-75, P2:404-415); RESEARCH-v0.4 risks R-VC-MIG-01 (high), R-MT-01 (medium), R-DASH-03 (medium).
|
||||
- Answer: The critical path is P1 Wave 1 → Wave 2 → Wave 3 → P2 Wave 1 → Wave 2. The single thing that would push v0.4 by a phase:
|
||||
- **Most likely: SPA fallback breaking the voice UI (R-DASH-03/05).** The catch-all route (`@app.get("/{path:path}")`) before StaticFiles is a change to `server/__main__.py` — the *same file* that serves the voice loop. If the catch-all shadows StaticFiles asset serving (JS/CSS), the voice UI breaks. TASK-10-04 tests this (8 assertions), but if the test fails, the fix is non-trivial (route ordering in FastAPI is subtle). This would push P2 by a wave.
|
||||
- **Less likely: VC key migration (R-VC-MIG-01).** The e2e test (TASK-06-05) is thorough, but if the v0.3 public key fails to verify against the Postgres store (e.g., key_id mismatch, encoding issue), the migration is blocked. The mitigation (archive before activate) is correct, but the *test* is the proof.
|
||||
- **Least likely: Postgres resource contention (R-MT-01).** 6GB CT has ~50% margin. The nightly jobs are at 03:00 CT. This is a measurement issue, not a design issue.
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-018** — critical-path risk: SPA fallback breaking voice UI (R-DASH-03). Mitigation: TASK-10-04 (8 assertions). If it fails, the fix is route ordering. Accept with the test as the gate. (0.75)
|
||||
|
||||
- **Q3: Are the 52 tasks evidence-based or pulled from a target?**
|
||||
- Evidence: PLAN-v0.4:764 — 52 tasks (29 P1 + 23 P2); GRILL-v0.3.md:29 — v0.3 had 70 tasks (originally) → shipped as ~40 after the grill split; ROADMAP.md:81 — v0.3 P1 shipped as v0.1.4.
|
||||
- Answer: v0.3 shipped ~40 tasks (post-grill split) successfully. v0.4 has 52 tasks across 2 phases (29 + 23). The task count is *analogous* to v0.3 (40 tasks → 52 tasks, +30%). The scope is comparable (v0.3 mastery+VC vs v0.4 operator tier). The tasks are bottom-up sized (each slice has 3-7 tasks with acceptance criteria). Not pulled from a target.
|
||||
- Confidence: 0.80
|
||||
- Decision: **G-019** — 52 tasks is evidence-based (analogous to v0.3's 40, bottom-up sized). Accept. (0.80)
|
||||
|
||||
- **Q4: Definition of done?**
|
||||
- Evidence: PLAN-v0.4 — per-slice acceptance criteria; ROADMAP.md:16-20 — per-phase ship + verify; config.json:28-33 — verification automated.
|
||||
- Answer: Definition of done = per-slice acceptance criteria (each task has "Acceptance criteria") + per-phase ship (v0.1.7, v0.1.8) + verify stage. The grill is the P0 definition of done. This is the established pattern since v0.2.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-020** — definition of done is per-slice acceptance criteria + per-phase ship + verify. Established pattern. Accept. (0.85)
|
||||
|
||||
---
|
||||
|
||||
### Axis 6 — Budget and Financial Realism
|
||||
|
||||
- **Q1: Budget spent vs remaining?**
|
||||
- Evidence: git log — v0.1 (foundation) + v0.2 (LXC deploy) + v0.3 (mastery+VC) shipped; v0.4 is the 4th milestone. No token budget tracked in `.ciagent/` (token cost is implicit in the CI agent's operation).
|
||||
- Answer: No explicit token budget. The project has shipped 3 milestones (v0.1-v0.3) — the token cost is sunk. v0.4 is the 4th. Under full autonomy, the "budget" is the CI agent's operational cost (tokens + compute). No budget contingency is tracked. This is a pilot — the budget is "whatever it costs to ship the milestones." Not a financial-realism concern at pilot scale.
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-021** — no explicit token budget (pilot, full autonomy). v0.4 is the 4th milestone. Accept the implicit budget model. (0.75)
|
||||
|
||||
- **Q2: Predictable cost drivers not in original budget? (Postgres 16 in LXC = CT memory bump 4GB→6GB; new deps = larger Docker image; backup storage)**
|
||||
- Evidence: RESEARCH-v0.4 §1.1 — CT memory 4GB→6GB (confirmed); PLAN-v0.4 TASK-02-02 — CT bump; TASK-02-03 — backup volume; ARCHITECTURE.md:737 — v0.4 CT sizing.
|
||||
- Answer: Three cost drivers:
|
||||
1. **CT memory 4GB→6GB** — budgeted (TASK-02-02). The 6GB figure has ~50% margin (RESEARCH-v0.4 §1.1).
|
||||
2. **Larger Docker image** — asyncpg + argon2-cffi + slowapi add ~10-20MB to the image. Negligible.
|
||||
3. **Backup storage** — pgbackups named volume, 7-day retention, pg_dump -Fc (compressed). At v0.4 scale (<100 learners), each dump is <1MB. 7 files = <7MB. Negligible.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-022** — cost drivers are budgeted (6GB CT, backup volume). Image size + backup storage are negligible at pilot scale. Accept. (0.85)
|
||||
|
||||
- **Q3: Burn rate — how long until v0.4 ships at current pace?**
|
||||
- Evidence: git log — v0.3 took ~1 day (commits from 2026-08-03 to 2026-08-04); v0.2 similar. v0.4 has 52 tasks vs v0.3's 40.
|
||||
- Answer: v0.3 shipped in ~1 day. v0.4 is +30% larger (52 vs 40 tasks). Expected: ~1.3 days of CI agent time. The burn rate is the CI agent's token consumption — not tracked, but the pace is established (3 milestones in ~3 days).
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-023** — burn rate: ~1.3 days estimated (analogous to v0.3). Accept. (0.75)
|
||||
|
||||
- **Q4: Budget contingent on anything?**
|
||||
- Evidence: config.json:13 — full autonomy; config.json:39-43 — git auto-commit, no auto-push.
|
||||
- Answer: No. Full autonomy, no external approval, no contingent funding. The only contingency is the `escalation_hooks` (deploy, delete_data, merge_to_main) — none of which apply to v0.4 P0/P1/P2 execution (merge_to_main is P3, which is the final ship).
|
||||
- Confidence: 0.90
|
||||
- Decision: **G-024** — no budget contingency (full autonomy, no external approval). Accept. (0.90)
|
||||
|
||||
---
|
||||
|
||||
### Axis 7 — Risks, Assumptions, and Dependencies
|
||||
|
||||
- **Q1: Top 3 assumptions v0.4 rests on — evidence for each?**
|
||||
- Evidence: RESEARCH-v0.4 risks table (R-MT-01, R-AUTH-01, R-DASH-01).
|
||||
- Answer:
|
||||
1. **Postgres-in-LXC won't destabilize the learner service (R-MT-01).** Evidence: RESEARCH-v0.4 §1.1 — Postgres idle ~400MB, praxis ~500MB, 6GB CT has ~50% margin. Postgres queries are off the voice path (operator endpoints + nightly aggregation only). The nightly jobs are at 03:00 CT. **Confidence: 0.75** — the memory math is sound but the *disk I/O contention during pg_dump* is unmeasured. The mitigation (03:00 CT) is a scheduling assumption, not a measurement.
|
||||
2. **k-anonymity ≥ 10 is sufficient privacy (D-034).** Evidence: RESEARCH-v0.4 §3.1 — "k=10 is the textbook suppression pattern." Differencing attacks blocked by pre-defined 2-D views. **Confidence: 0.70** — k=10 is the conventional minimum, but at pilot scale (1 learner) k-anon suppresses *everything*, which is privacy-correct but value-destroying. The assumption holds for privacy; it does not hold for dashboard utility at pilot scale.
|
||||
3. **Signed stateless cookies are secure without TLS in the pilot (R-AUTH-01).** Evidence: RESEARCH-v0.4 §2.4 — config-driven `PRAXIS_COOKIE_SECURE`, defense-in-depth (cohort dashboard reads only k-anonymized aggregates). **Confidence: 0.65** — this is the *signature question* (see probe #1 below). The config-driven flag is a punt; the real mitigation is the k-anon defense-in-depth.
|
||||
- Confidence: 0.72
|
||||
- Decision: **G-025** — 3 core assumptions: Postgres contention (0.75, unmeasured disk I/O), k-anon sufficiency (0.70, privacy-correct but value-destroying at pilot scale), cookie-without-TLS (0.65, config-driven punt with k-anon defense-in-depth). All accepted as pilot-scale constraints. (0.72)
|
||||
|
||||
- **Q2: Dependencies outside the team?**
|
||||
- Evidence: config.json:13 — full autonomy; PROJECT.md:5.
|
||||
- Answer: None. Single project, full autonomy. No external departments, vendors, regulators, or customers. The only "external" dependency is the Proxmox cluster (v0.2 deployment) + Ollama Cloud + Deepgram + Cartesia (voice services) — all carried forward from v0.1-v0.2.
|
||||
- Confidence: 0.90
|
||||
- Decision: **G-026** — no external dependencies (full autonomy). Accept. (0.90)
|
||||
|
||||
- **Q3: Single risk that kills v0.4? (R-VC-MIG-01 — losing the v0.3 public key breaks all issued VCs. Mitigation: archive before activate. Is this enough?)**
|
||||
- Evidence: RESEARCH-v0.4 R-VC-MIG-01 (high severity, 0.85 confidence); PLAN-v0.4 SLICE-04 TASK-04-03 (migration script archives v0.3 public key BEFORE activating new key); TASK-06-05 (e2e test verifies v0.3 VC against Postgres store).
|
||||
- Answer: R-VC-MIG-01 is the single project-killing risk. If the v0.3 public key is lost, all v0.3 VCs break. The mitigation is *correct*: archive before activate (TASK-04-03 step 2 before step 3). The e2e test (TASK-06-05) verifies a v0.3 VC against the Postgres store with the archived superseded key. This is the *right* test. The risk is mitigated.
|
||||
- However, there is a *subtle* gap: the migration script (TASK-04-03) reads the v0.3 public key from SQLite. If the SQLite `issuer_keys` table is empty (e.g., the v0.3 pilot never issued a VC → no key was ever generated), the migration script's behavior is undefined. The script should handle "no v0.3 key exists" gracefully (skip the archive step, just generate a fresh v0.4 key). The plan says "Idempotent: if Postgres already has an active key, skip" but does not say "if SQLite has no active key, skip the archive."
|
||||
- Confidence: 0.80
|
||||
- Challenge: The migration script's behavior when SQLite has no v0.3 active key is unspecified. This is an edge case (the pilot may never have issued a VC), but it is the *first-boot* path for most deployments.
|
||||
- Decision: **G-027 (MUST)** — TASK-04-03 must explicitly handle the "no v0.3 active key in SQLite" case: if `get_active_signing_key_row()` on SQLite returns None, skip the archive step and only generate the fresh v0.4 keypair. Document this as a first-boot path. The e2e test (TASK-06-05) should include a "no v0.3 key" scenario. (0.80)
|
||||
|
||||
- **Q4: Pre-mortem — "It's 90 days from now and v0.4 failed. Why?"**
|
||||
- Evidence: RESEARCH-v0.4 risks; PLAN-v0.4 risk matrix.
|
||||
- Answer: The most likely failure modes (in order):
|
||||
1. **SPA fallback broke the voice UI (R-DASH-03/05).** The catch-all route shadowed StaticFiles asset serving. The voice UI loaded but JS/CSS 404'd. The operator dashboard worked but the learner product regressed. This is the *highest-blast-radius* failure — it breaks the v0.1-v0.3 learner surface, not just the v0.4 operator surface.
|
||||
2. **Postgres contention degraded the voice loop latency (R-MT-01).** The nightly pg_dump + aggregation job at 03:00 CT caused disk I/O contention that spiked the voice loop latency >600ms. This was not caught because the latency test does not run with Postgres loaded.
|
||||
3. **The secure-cookie+no-TLS tension was unresolved (R-AUTH-01).** The config-driven flag was set to `false` for the pilot, the operator cookie was sniffed over HTTP on the vmbr0 bridge, and the grill should have caught that the config flag is a punt, not a fix.
|
||||
4. **The k-anon dashboard showed nothing at pilot scale.** The operator logged in, saw "— (<10 learners)" for every cell, and concluded the dashboard was broken. The grill should have caught that the dashboard's validation path is test-seeded data, not pilot traffic.
|
||||
- Confidence: 0.78
|
||||
- Decision: **G-028** — pre-mortem top-4 failure modes: SPA fallback regression (highest blast radius), Postgres contention (unmeasured), R-AUTH-01 punt, k-anon-empty-dashboard. All four are addressed in this grill's binding decisions. (0.78)
|
||||
|
||||
---
|
||||
|
||||
### Axis 8 — Governance, Decision-Making, and Communication
|
||||
|
||||
- **Q1: Decision-maker when two personas disagree?**
|
||||
- Evidence: config.json:52-54 — lead-developer is the first persona; PERSONAS.md v0.4 — lead-developer "Coordinates task decomposition... resolves conflicts."
|
||||
- Answer: lead-developer is the decision-maker. This is the established pattern since v0.1.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-029** — lead-developer is the conflict resolver. Accept. (0.85)
|
||||
|
||||
- **Q2: Governance cadence?**
|
||||
- Evidence: ROADMAP.md:19 — pipeline stages SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP; config.json:105-108 — per-phase ship.
|
||||
- Answer: Per-phase ship + verify + grill at P0. This is the established cadence. The grill is the crisis-cadence (this document).
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-030** — governance cadence: per-phase ship + verify + grill. Accept. (0.85)
|
||||
|
||||
- **Q3: What's omitted from status reports? (R-AUTH-01 is the smell)**
|
||||
- Evidence: RESEARCH-v0.4 §2.4 — R-AUTH-01 resolution documented as "the grill must sign off"; PLAN-v0.4:718 — risk matrix lists R-AUTH-01 with "grill must sign off."
|
||||
- Answer: The smell is R-AUTH-01. The research *acknowledges* the tension but frames the config-driven flag as a resolution. The v0.3 grill (Axis 4 MUST #2) explicitly rejected this approach: "Do not ship `PRAXIS_COOKIE_SECURE=false` as default — use TLS or loopback-binding." The v0.4 plan ships `PRAXIS_COOKIE_SECURE` defaulting to `true` with `false` for HTTP pilot — which is *option (b)* from the v0.3 grill (accept the pilot risk + document) wrapped in a config flag. The v0.3 grill rejected option (b). The v0.4 plan re-litigates this.
|
||||
- The *real* mitigation — the one the v0.3 grill did not consider — is the k-anon defense-in-depth: the cohort dashboard reads only k-anonymized aggregates, so even a sniffed cookie leaks no PII. This is the *actual* answer to R-AUTH-01, not the config flag.
|
||||
- Confidence: 0.70
|
||||
- Challenge: The plan's R-AUTH-01 resolution re-litigates a v0.3 grill MUST. The config-driven flag is a punt. The real mitigation (k-anon defense-in-depth) is buried in the research, not elevated.
|
||||
- Decision: **G-031 (MUST)** — R-AUTH-01 resolution must be reframed: the *primary* mitigation is the k-anon defense-in-depth (cohort dashboard reads only k-anonymized aggregates → sniffed cookie leaks no PII). The config-driven `PRAXIS_COOKIE_SECURE` flag is the *secondary* mitigation (operational convenience for when TLS arrives). The plan must document this ordering explicitly in TASK-03-02 and the GRILL-v0.4 ship notes. The v0.3 grill's "use TLS or loopback-binding" MUST is *not* satisfied — but the k-anon defense-in-depth is a *new* mitigation that the v0.3 grill did not evaluate (the v0.3 cohort dashboard was deferred). This grill accepts the k-anon defense-in-depth as the primary R-AUTH-01 resolution for v0.4, *overriding* the v0.3 grill's MUST #2 for the operator-tier surface only. (0.70)
|
||||
|
||||
- **Q4: Stop-the-project trigger?**
|
||||
- Evidence: config.json:13 — full autonomy; config.json:15 — `escalation_hooks: ["deploy", "delete_data", "merge_to_main"]`.
|
||||
- Answer: No human stop trigger (full autonomy). The CI agent can escalate (escalation_hooks) but cannot self-stop. The grill is the stop-the-project mechanism — if the verdict were "Rethink" or "Escalate," the project would stop. This grill's verdict is "Proceed-with-conditions," so the project proceeds.
|
||||
- Confidence: 0.80
|
||||
- Decision: **G-032** — no human stop trigger (full autonomy). The grill is the stop mechanism. This grill = proceed with conditions. (0.80)
|
||||
|
||||
---
|
||||
|
||||
### Axis 9 — Change, Adoption, and Operational Readiness
|
||||
|
||||
- **Q1: Who will use the cohort dashboard, and what's in it for them?**
|
||||
- Evidence: D-052 — operator bootstrap is env-provided (not a real user); PROJECT.md:53 — "for training operators"; PERSONAS.md — no operator persona (operators are external to the CI agent).
|
||||
- Answer: The *first operator* is env-provided (D-052 — `PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS`). There is no real operator user in the pilot. The dashboard is a *capability demonstration*, not a tool for a named user. "What's in it for them" = visibility into cohort progression, but at pilot scale (1 learner) the dashboard shows nothing (k-anon suppresses all cells). The dashboard's value is *architectural* (proving the operator tier works), not *operational* (no operator uses it yet).
|
||||
- Confidence: 0.65
|
||||
- Challenge: The dashboard has no real user at pilot scale. This is a *placeholder deliverable* — the capability exists, but no one uses it. The "we'll train them" answer does not apply (there is no "them").
|
||||
- Decision: **G-033** — the cohort dashboard's first user is env-provided (D-052), not a real operator. At pilot scale (1 learner), the dashboard shows no data (k-anon). The dashboard is a *capability demonstration* for v0.5+ (when multi-learner data exists). Document this in the ship notes — v0.4 delivers the operator tier *capability*, not operator *value*. (0.65)
|
||||
|
||||
- **Q2: Is the operations team involved now or handed a finished product?**
|
||||
- Evidence: PERSONAS.md v0.4 — devops-engineer is active in P1 (docker-compose Postgres + CT bump + backup + bootstrap); PLAN-v0.4 SLICE-02 — devops tasks.
|
||||
- Answer: devops-engineer is involved in P1 (SLICE-02 — .env.example, CT bump, backup script, bootstrap CLI). This is *good* — the operations surface is built by the operations persona, not handed off. The backup strategy (TASK-02-03) is devops-owned. The bootstrap CLI (SLICE-05) is devops-owned. The operations team is involved *now*.
|
||||
- Confidence: 0.85
|
||||
- Decision: **G-034** — devops-engineer is involved in P1 (operations surface built by operations persona). Accept. (0.85)
|
||||
|
||||
- **Q3: Rollback plan if v0.4 goes wrong?**
|
||||
- Evidence: PLAN-v0.4 — per-phase ship (v0.1.7, v0.1.8, v0.1.9) + git rollback; config.json:42-43 — branching_strategy: phase.
|
||||
- Answer: Per-phase git rollback (revert the patch tag). But:
|
||||
- **P1 rollback (v0.1.7)**: Reverting P1 removes the Postgres service + auth. The VC key migration is *irreversible* — once the v0.3 public key is archived as superseded in Postgres and the fresh v0.4 key is active, reverting to v0.3 SQLite keys requires re-pointing the verification endpoint back to SQLite. The plan's fallback (TASK-06-03 — "if pg_store is None, fall back to PraxisStore path") makes this *possible* (set `PRAXIS_PG_DSN` to empty → server falls back to SQLite). This is a *soft* rollback — the Postgres data persists but is unused.
|
||||
- **P2 rollback (v0.1.8)**: Reverting P2 removes the aggregation pipeline + dashboard. The SPA fallback catch-all route removal is *clean* (revert the route). The React Router addition is *clean* (revert package.json + App.tsx). The aggregation hook in session_recorder.py is *clean* (revert the chained task). P2 rollback is clean.
|
||||
- **Postgres data migration is hard to roll back** — but the plan does not migrate data (v0.3 credentials stay in SQLite; v0.4 credentials go to Postgres). The VC key *archival* is irreversible (the v0.3 public key is copied to Postgres as superseded), but this is *additive* — the v0.3 SQLite key still exists. Reverting to v0.3 means ignoring the Postgres copy.
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-035** — rollback is per-phase git revert. P1 rollback is *soft* (set `PRAXIS_PG_DSN` to empty → server falls back to SQLite). P2 rollback is *clean* (revert routes + package.json + session_recorder hook). VC key archival is additive (v0.3 SQLite key persists). Accept. (0.75)
|
||||
|
||||
- **Q4: Has anyone validated the success criteria with the people who will judge v0.4 successful?**
|
||||
- Evidence: config.json:13 — full autonomy; config.json:28-33 — verification automated.
|
||||
- Answer: No human judge (full autonomy). The CI agent is the judge. The success criteria = 8/8 REQ-IDs covered + per-slice acceptance criteria + verify stage. This is the established pattern.
|
||||
- Confidence: 0.80
|
||||
- Decision: **G-036** — CI is the judge (full autonomy). Success = 8/8 REQ coverage + acceptance criteria + verify. Accept. (0.80)
|
||||
|
||||
---
|
||||
|
||||
### Meta — Closing Review
|
||||
|
||||
- **Q1: If you were the auditor, what would you flag?**
|
||||
- Evidence: all axes above.
|
||||
- Answer: Three flags:
|
||||
1. **R-AUTH-01 re-litigates a v0.3 grill MUST.** The config-driven flag is a punt. The k-anon defense-in-depth is the real mitigation but is not elevated. (G-031)
|
||||
2. **The k-anon dashboard shows nothing at pilot scale.** The dashboard's validation path is test-seeded data, not pilot traffic. This is a placeholder deliverable. (G-033)
|
||||
3. **Backup verification is a hidden requirement.** A nightly pg_dump that is never restored is theater. (G-008)
|
||||
- Confidence: 0.78
|
||||
- Decision: **G-037** — auditor flags: R-AUTH-01 re-litigation, k-anon-empty-dashboard, backup-verification gap. All addressed in binding decisions. (0.78)
|
||||
|
||||
- **Q2: What is v0.4 NOT doing that it should?**
|
||||
- Evidence: PLAN-v0.4 open questions (742-754); RESEARCH-v0.4.
|
||||
- Answer:
|
||||
1. **Backup restore drill** — not tasked (G-008).
|
||||
2. **Latency test with Postgres loaded** — the voice loop latency test (TASK-06-04) checks that Postgres presence doesn't destabilize the learner service, but it does not run the voice loop *under load* with Postgres running the nightly job. The R-MT-01 disk I/O contention is unmeasured.
|
||||
3. **Operator deactivation** — no CLI to set `is_active=false`. Minor (SQL workaround), but a gap in the operator lifecycle.
|
||||
4. **Differencing-attack test for k-anon** — the v0.3 grill (Axis 7 FIX #2) asked for a differencing-attack test. The v0.4 plan (TASK-07-05) tests k-anon threshold (9 vs 10) but does NOT test that two adjacent 7-day windows cannot re-identify a single learner. This is a v0.3 grill FIX that is not explicitly carried forward.
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-038 (MUST)** — Add a differencing-attack test to TASK-07-05 or TASK-10-03: seed 10 learners in window A, 9 in window B (1 dropped), verify the API does not allow a query that isolates the dropped learner. This is a v0.3 grill FIX (Axis 7 #2) that must be carried forward. (0.75)
|
||||
|
||||
- **Q3: Simplest possible v0.4 that delivers 80% of the value?**
|
||||
- Evidence: D-053 — 3 dashboard views; PLAN-v0.4 SLICE-08, SLICE-09.
|
||||
- Answer: The simplest v0.4 = auth + Postgres + *single* dashboard view (practice volume only) + VC key migration. The mastery-progression and failure-patterns views are +20% value but +30% effort (2 more endpoints + 2 more React components + 2 more aggregation metrics). However: D-053 is a CLARIFY decision (0.80 confidence) that names 3 views — cutting to 1 would re-litigate a settled decision. The 3 views are not over-scoped *relative to the decision*. The simpler answer is: v0.4 is already the simplest version (8 REQs, no RBAC, no DP, no learner auth, single operator). Cutting further would break the v0.3 grill's deferred obligation.
|
||||
- Confidence: 0.75
|
||||
- Decision: **G-039** — v0.4 is already the simplest version (8 REQs, single operator role, k-anon not DP). The 3-view dashboard is D-053 (settled). Further cuts would break the v0.3 grill obligation. Accept the scope. (0.75)
|
||||
|
||||
- **Q4: What would have to be true for v0.4 to succeed in the next 90 days, and is it true today?**
|
||||
- Evidence: all axes.
|
||||
- Answer: For v0.4 to succeed:
|
||||
1. **The SPA fallback must not break the voice UI.** Is it true today? No — it is untested (TASK-10-04 is the test). Will be true after P2.
|
||||
2. **The VC key migration must preserve v0.3 VC verification.** Is it true today? No — it is untested (TASK-06-05 is the test). Will be true after P1.
|
||||
3. **Postgres must not destabilize the learner service.** Is it true today? Partially — the memory math is sound (6GB CT), but disk I/O contention is unmeasured. Will be true after P1 (with the 03:00 CT mitigation).
|
||||
4. **The auth stack must be secure enough for a pilot.** Is it true today? Partially — R-AUTH-01 is a punt with k-anon defense-in-depth. Will be true after G-031 reframes the mitigation.
|
||||
5. **The dashboard must show *something* useful.** Is it true today? No — at pilot scale (1 learner), k-anon suppresses everything. Will be true only with test-seeded data (≥10 mock learners).
|
||||
- Confidence: 0.72
|
||||
- Decision: **G-040** — 5 success conditions: SPA fallback (untested), VC migration (untested), Postgres stability (partially), auth security (partially, G-031), dashboard utility (only with test-seeded data). All addressable in P1/P2. Accept with binding decisions. (0.72)
|
||||
|
||||
---
|
||||
|
||||
### v0.4-Specific Probes (Signature Questions)
|
||||
|
||||
#### Probe 1 — R-AUTH-01 (Secure cookie + no-TLS): Resolution
|
||||
|
||||
**Question:** D-030 said no Traefik/TLS for the pilot. D-041 requires `Secure` cookie attribute. `Secure` requires HTTPS. The research proposes `PRAXIS_COOKIE_SECURE` config-driven (default true, false for HTTP pilot). Is this a real resolution or a punt? What's the actual risk of running auth over HTTP in the LXC pilot? Is the cohort dashboard worth a TLS regression?
|
||||
|
||||
**Evidence:**
|
||||
- D-030 (PROJECT.md:160) — "vmbr0 DHCP only (pilot, no vmbr1, no Traefik proxy)."
|
||||
- D-041 (PROJECT.md:171) — "Cookie: httpOnly, secure, SameSite=Strict, 8h expiry."
|
||||
- RESEARCH-v0.4 §2.4 — config-driven flag, "cohort dashboard reads only k-anonymized aggregates → even a cookie sniffed over HTTP leaks no PII."
|
||||
- GRILL-v0.3.md Axis 4 MUST #2 — "Do not ship `PRAXIS_COOKIE_SECURE=false` as default — use TLS or loopback-binding."
|
||||
- server/__main__.py:46 — `HOST = _env("PRAXIS_HOST", "0.0.0.0")` (binds to all interfaces).
|
||||
|
||||
**Analysis:**
|
||||
The v0.3 grill explicitly rejected shipping `PRAXIS_COOKIE_SECURE=false` as a default. The v0.4 plan ships `PRAXIS_COOKIE_SECURE` defaulting to `true` with `false` for HTTP pilot — which is *option (b)* from the v0.3 grill (accept the pilot risk + document) wrapped in a config flag. This *re-litigates* the v0.3 grill MUST.
|
||||
|
||||
However, the v0.3 grill evaluated R-AUTH-01 *before* the cohort dashboard was scoped. The v0.3 grill's concern was "a cleartext cookie on a shared bridge is a MUST-FIX" — but the v0.3 grill did not know that the cohort dashboard would read *only k-anonymized aggregates*. The v0.4 research introduces a *new* mitigation: **the k-anon defense-in-depth**. A sniffed cookie gives the attacker access to `/api/operator/*`, which returns only k-anonymized cohort data (no PII) + the VC issuance log (credentials are public per D-043). The *worst* an attacker can do with a sniffed operator cookie is:
|
||||
- Read k-anonymized cohort aggregates (no PII — D-034).
|
||||
- Read the VC issuance log (credentials are public — D-043).
|
||||
- Revoke a VC (POST `/api/operator/credentials/{id}/revoke`) — this is a *denial-of-service* on a credential, but the credential is formative (v0.3 grill Axis 4 MUST #1) and the revocation is reversible (operator can re-issue).
|
||||
|
||||
The *actual* risk of running auth over HTTP in the LXC pilot is: an attacker on the vmbr0 bridge can sniff the operator cookie and revoke a formative credential. This is a *low-severity* risk for a pilot. The v0.3 grill's "MUST-FIX" was correct *for a high-stakes credential* — but the v0.3 grill itself downgraded the credential to formative (MUST #1), which *also* downgrades the R-AUTH-01 severity.
|
||||
|
||||
**Resolution:**
|
||||
The config-driven `PRAXIS_COOKIE_SECURE` flag is a *punt* — it does not fix the underlying tension. The *real* resolution is the k-anon defense-in-depth + the formative credential tier. The v0.3 grill's MUST #2 ("use TLS or loopback-binding") is *overridden* for the v0.4 operator-tier surface because:
|
||||
1. The cohort dashboard reads only k-anonymized aggregates (no PII leak from a sniffed cookie).
|
||||
2. The VC credential is formative (low-stakes — revocation is a reversible DoS, not a forgery).
|
||||
3. The pilot binds to vmbr0 DHCP (shared bridge) — but the pilot has 1 learner and 1 env-provided operator. The attack surface is theoretical.
|
||||
|
||||
**Binding Decision G-031 (MUST)** — R-AUTH-01 resolution: the *primary* mitigation is the k-anon defense-in-depth (sniffed cookie → no PII). The config-driven flag is *secondary* (operational convenience). The plan must document this ordering. The v0.3 grill's MUST #2 is overridden for v0.4 *only* because the v0.3 grill's own formative-credential decision (MUST #1) downgraded the R-AUTH-01 severity. This is a *consistent* override — the v0.3 grill's two MUSTs interact, and the formative tier + k-anon defense-in-depth together resolve the tension that either alone does not.
|
||||
|
||||
**Confidence: 0.70** — the resolution is sound but re-litigates a prior grill MUST. The override is justified by the *interaction* of two v0.3 grill decisions (formative tier + k-anon), not by a single new fact.
|
||||
|
||||
---
|
||||
|
||||
#### Probe 2 — R-VC-MIG-01 (VC key migration): Is "archive before activate" enough?
|
||||
|
||||
**Question:** v0.3 issued VCs are in the field (hypothetically). v0.4 migrates the issuer key to Postgres. If the v0.3 public key is lost, all v0.3 VCs break. The plan says "archive before activate." Is that enough? Is there a test that verifies a v0.3 VC against the archived key after migration?
|
||||
|
||||
**Evidence:**
|
||||
- PLAN-v0.4 TASK-04-03 — migration script: step 2 (archive v0.3 public key as superseded) BEFORE step 3 (generate fresh v0.4 key).
|
||||
- PLAN-v0.4 TASK-06-05 — e2e test: "v0.3 VC verifies against Postgres store with archived superseded key (R-VC-MIG-01 explicitly verified)."
|
||||
- server/vc/issuer_keys.py:102-109 — `get_public_key_for_verification` queries by `key_id` (not status) — the fallback to superseded keys is implicit.
|
||||
- RESEARCH-v0.4 §5.3 — "No code change needed in the verification flow — only the store backing changes."
|
||||
|
||||
**Analysis:**
|
||||
"Archive before activate" is the *correct* ordering — if the migration fails between step 2 and step 3, the v0.3 key is archived but no v0.4 key is active. The verification endpoint would find the v0.3 key (superseded) and verify v0.3 VCs. New VCs cannot be issued (no active key) until the migration is re-run. This is a *safe failure mode*.
|
||||
|
||||
The e2e test (TASK-06-05) is thorough: it seeds a v0.3 VC, runs the migration, verifies the v0.3 VC against the Postgres store, issues a v0.4 VC, verifies it, tampers with the v0.3 VC (verification fails), and re-runs the migration (idempotent). This covers R-VC-MIG-01.
|
||||
|
||||
**Gap (G-027):** The migration script's behavior when SQLite has *no* v0.3 active key (the pilot never issued a VC) is unspecified. This is the *first-boot* path for most deployments. Must be handled.
|
||||
|
||||
**Verdict:** "Archive before activate" is enough *with* the e2e test (TASK-06-05) as the proof. The gap (no v0.3 key) is a binding decision (G-027). **Confidence: 0.80.**
|
||||
|
||||
---
|
||||
|
||||
#### Probe 3 — k-anonymity at pilot scale: Dashboard that shows nothing?
|
||||
|
||||
**Question:** v0.1-v0.3 used `HARDCODED_LEARNER_ID = "learner-1"` — a single learner. k-anonymity ≥ 10 will suppress EVERY cell in the cohort dashboard. The dashboard will show "— (<10 learners)" for everything. Is v0.4 building a dashboard that can't show any data until there are 10+ learners? Is that a real deliverable or a placeholder? What test data seeds ≥10 mock learners?
|
||||
|
||||
**Evidence:**
|
||||
- db/store.py:29 — `HARDCODED_LEARNER_ID = "learner-1"` (confirmed — single learner).
|
||||
- D-034 (PROJECT.md:164) — "k-anonymity ≥ 10."
|
||||
- REQ-NFR-DASH-01 — "cells with < 10 learners are suppressed."
|
||||
- PLAN-v0.4 Open Question #3 (line 746) — "For v0.4 (single learner), k-anonymity will suppress everything (1 < 10). This is expected at pilot scale (R-DASH-01). The executor should seed test data with ≥10 mock learners to verify the non-suppressed path."
|
||||
- PLAN-v0.4 TASK-10-03 — P2 integration test seeds 15 mock sessions (12 distinct learners) for the non-suppressed path + 5 sessions (5 learners) for the suppressed path.
|
||||
|
||||
**Analysis:**
|
||||
At pilot scale (1 learner), the dashboard shows "— (<10 learners)" for every cell. This is *privacy-correct* (k-anon is working) but *value-destroying* (the dashboard is useless). The plan acknowledges this (Open Question #3) and the validation path is *test-seeded data* (TASK-10-03 seeds 12 + 5 mock learners). The dashboard is a *capability demonstration*, not an operational tool — at pilot scale, no operator uses it (G-033).
|
||||
|
||||
This is a *real deliverable* in the sense that the *capability* exists (Postgres + aggregation + k-anon + auth + UI), but it is a *placeholder* in the sense that it cannot show real data until multi-learner-per-device is implemented (deferred). The v0.4 milestone delivers the *plumbing*, not the *value*.
|
||||
|
||||
**Verdict:** The dashboard is a placeholder deliverable at pilot scale. The validation path is test-seeded data (TASK-10-03), not pilot traffic. This must be documented in the ship notes (G-033). The k-anon suppression is *correct behavior* — the dashboard is working as designed. The issue is that the design is correct for a *cohort* but the pilot has *one learner*. **Confidence: 0.75.**
|
||||
|
||||
---
|
||||
|
||||
#### Probe 4 — Postgres-in-LXC resource contention (R-MT-01): Voice loop latency?
|
||||
|
||||
**Question:** Adding Postgres to the LXC CT bumps memory 4GB→6GB. The learner-facing voice loop has a <600ms latency budget (C-8). Will Postgres idle I/O + the aggregation pipeline degrade the voice loop? Is there a latency test that runs with Postgres loaded?
|
||||
|
||||
**Evidence:**
|
||||
- RESEARCH-v0.4 §1.1 — Postgres idle ~400MB, praxis ~500MB, 6GB CT has ~50% margin. "Postgres queries are off the voice path (operator endpoints + nightly aggregation only)."
|
||||
- R-MT-01 — "disk I/O contention during nightly pg_dump + aggregation." Mitigation: 03:00 CT.
|
||||
- PLAN-v0.4 TASK-06-04 — "Test that learner voice loop (`/health`, `/pipecat/webrtc`) is unaffected by auth (REQ-NFR-MT-01 — Postgres + learner service coexist)."
|
||||
- C-8 — latency budget < 600ms.
|
||||
|
||||
**Analysis:**
|
||||
The memory math is sound (6GB CT, ~1.3GB runtime, ~4.7GB headroom). The *voice loop* (WebRTC → Pipecat → ASR → LLM → TTS) does not touch Postgres — it uses SQLite for learner state (D-007 preserved) and the voice services (Deepgram, Cartesia, Ollama Cloud). Postgres is used only by operator endpoints + nightly aggregation. The *risk* is disk I/O contention during the nightly pg_dump + aggregation job (03:00 CT).
|
||||
|
||||
TASK-06-04 tests that Postgres presence doesn't destabilize the learner service — but it tests *coexistence* (health check passes, WebRTC offer accepted), not *latency under load*. The plan does NOT include a latency test that runs the voice loop *while Postgres is executing the nightly job*. The R-MT-01 mitigation (03:00 CT scheduling) is a *scheduling* assumption, not a *measurement*.
|
||||
|
||||
**Verdict:** The memory contention is well-mitigated (6GB CT). The disk I/O contention is *unmeasured* — the 03:00 CT mitigation is reasonable (low learner activity) but not proven. The voice loop does not touch Postgres, so the *path* is clean — the risk is *system-level* I/O contention, not *application-level* query contention. **Confidence: 0.70** — the risk is low (Postgres is off the voice path) but unmeasured. Accept the 03:00 CT mitigation as a pilot-scale constraint.
|
||||
|
||||
---
|
||||
|
||||
#### Probe 5 — SPA fallback breaking voice UI (R-DASH-03/05): Route ordering?
|
||||
|
||||
**Question:** Adding a catch-all route for React Router `/operator/*` must not break the voice UI at `/`. The catch-all must be registered BEFORE StaticFiles but AFTER API routes. Is this ordering tested? What's the rollback if the voice UI breaks?
|
||||
|
||||
**Evidence:**
|
||||
- server/__main__.py:146 — `app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True))` (current — no SPA fallback).
|
||||
- PLAN-v0.4 TASK-10-01 — catch-all route `@app.get("/{path:path}")` BEFORE StaticFiles.
|
||||
- PLAN-v0.4 TASK-10-04 — 8-assertion test (voice UI at `/`, SPA fallback for `/operator/*`, API routes return JSON, assets served by StaticFiles).
|
||||
- R-DASH-03 — "SPA fallback breaks existing voice UI (StaticFiles mount change)."
|
||||
|
||||
**Analysis:**
|
||||
The catch-all route `@app.get("/{path:path}")` is a *greedy* match — it matches *every* path. If registered before StaticFiles, it will intercept all GET requests, including `/assets/index.js`. The plan's TASK-10-01 says "the catch-all only serves index.html for client-side routes" but a `@app.get("/{path:path}")` route does not distinguish between client-side routes and static assets — it matches both. The *correct* implementation is either:
|
||||
1. A custom StaticFiles subclass that returns index.html for non-file paths (the plan's Open Question #2, line 744).
|
||||
2. A catch-all that excludes static asset paths (e.g., check if the path matches a file in `client/dist` first).
|
||||
|
||||
TASK-10-04 assertion 8 (`GET /assets/index.js` → served by StaticFiles, not the catch-all) is the *test* for this, but the *implementation* in TASK-10-01 is ambiguous. If the catch-all is registered before StaticFiles, FastAPI route matching order means the catch-all *wins* — StaticFiles never serves `/assets/index.js`. The plan's assertion 8 would *fail*.
|
||||
|
||||
**The correct ordering is: API routes → StaticFiles mount → catch-all (for SPA fallback).** But FastAPI's `app.mount("/", StaticFiles(...))` *is* a catch-all at `/` — adding another catch-all after it is redundant (StaticFiles with `html=True` already serves index.html for `/`). The *real* fix is a custom StaticFiles subclass that returns index.html for non-file paths (Open Question #2).
|
||||
|
||||
**Verdict:** The plan's TASK-10-01 catch-all approach is *subtly wrong* — a `@app.get("/{path:path}")` before StaticFiles would shadow asset serving. The correct approach is a custom StaticFiles subclass (Open Question #2) OR a catch-all *after* StaticFiles that only fires for 404s. The plan defers this to EXECUTE (Open Question #2) but the test (TASK-10-04 assertion 8) would catch the bug. **Confidence: 0.65** — the test is correct, the implementation is ambiguous. This is a binding decision.
|
||||
|
||||
**Binding Decision G-041 (MUST)** — TASK-10-01 must NOT use a `@app.get("/{path:path}")` catch-all before StaticFiles (it would shadow asset serving per assertion 8). The correct implementation is a custom StaticFiles subclass that returns `FileResponse("client/dist/index.html")` for non-file paths (Open Question #2 resolved in favor of the subclass approach). The catch-all approach is rejected. This must be documented in TASK-10-01 before EXECUTE. (0.65)
|
||||
|
||||
---
|
||||
|
||||
#### Probe 6 — 2-phase split: REQ-MT-02 spans P1 (schema) + P2 (pipeline). Vertical-slice violation?
|
||||
|
||||
**Question:** P1 (foundation) + P2 (dashboard) — is the split clean? REQ-MT-02 (aggregation) spans both phases (schema in P1, pipeline in P2). Is that a vertical-slice violation, or a clean layering?
|
||||
|
||||
**Evidence:**
|
||||
- PLAN-v0.4:18-19 — P1 covers "REQ-MT-02 (schema foundation)"; P2 covers "REQ-MT-02 (pipeline completion)."
|
||||
- PLAN-v0.4 REQ-ID coverage matrix (line 699) — REQ-MT-02: SLICE-01 (schema), SLICE-07 (pipeline), SLICE-10 (e2e).
|
||||
|
||||
**Analysis:**
|
||||
REQ-MT-02 is split across P1 (schema — the `cohort_aggregates` table) and P2 (pipeline — the aggregation hook + nightly job). This is *not* a vertical-slice violation — it is *clean layering*. The schema is the *contract*; the pipeline is the *implementation*. P1 ships the schema (the table exists, the PgStore has `upsert_cohort_aggregate`), P2 ships the pipeline (the hook fires, the nightly job runs). The P1→P2 dependency is *one-directional* (P2 depends on P1's schema, P1 does not depend on P2's pipeline).
|
||||
|
||||
This is the same pattern as v0.3 (mastery schema in P1, mastery flow in P1 — but the VC issuer was split, which the v0.3 grill flagged as a MUST). The difference is that REQ-MT-02's split is *schema vs. pipeline* (a clean layer), not *trigger vs. action* (the v0.3 grill's VC-issuance wiring gap). The aggregation pipeline does not need a P1 trigger — it fires on session-end, which is a P2 event (the hook is in `session_recorder.py`, which is extended in P2).
|
||||
|
||||
**Verdict:** The REQ-MT-02 split is clean layering (schema in P1, pipeline in P2), not a vertical-slice violation. The P1→P2 dependency is one-directional. The v0.3 grill's VC-issuance wiring gap (trigger in P1, action in P2) does not apply here — the aggregation trigger (session-end) is in P2. **Confidence: 0.85.**
|
||||
|
||||
---
|
||||
|
||||
### v0.3 Grill Deferred Items — Coverage Check
|
||||
|
||||
The v0.3 grill (GRILL-v0.3.md) deferred the operator tier to v0.4. The v0.3 grill's MUST conditions were resolved *in v0.3* (formative label, scoring_inconclusive, VC interop, key rotation, VC-issuance wiring). Let me verify the v0.3 grill's deferred items are now covered in v0.4:
|
||||
|
||||
| v0.3 Grill Deferred Item | v0.4 Coverage | Status |
|
||||
|---------------------------|---------------|--------|
|
||||
| REQ-DASH-01 (cohort dashboard) | REQ-DASH-01 activated, PLAN SLICE-08/09/10 | ✅ Covered |
|
||||
| REQ-AUTH-01 (operator auth) | REQ-AUTH-01 activated, PLAN SLICE-03/05/06 | ✅ Covered |
|
||||
| REQ-MT-01 (operator Postgres) | REQ-MT-01 activated, PLAN SLICE-01/06 | ✅ Covered |
|
||||
| REQ-MT-02 (aggregation) | REQ-MT-02 activated, PLAN SLICE-01/07/10 | ✅ Covered |
|
||||
| REQ-NFR-AUTH-01 (auth NFRs) | REQ-NFR-AUTH-01 activated, PLAN SLICE-03/06 | ✅ Covered |
|
||||
| REQ-NFR-MT-01 (Postgres-in-LXC) | REQ-NFR-MT-01 activated, PLAN SLICE-01/02/06 | ✅ Covered |
|
||||
| REQ-NFR-DASH-01 (k-anon ≥10) | REQ-NFR-DASH-01 activated, PLAN SLICE-07/08/09/10 | ✅ Covered |
|
||||
| REQ-NFR-DASH-02 (freshness ≤24h) | REQ-NFR-DASH-02 activated, PLAN SLICE-07/10 | ✅ Covered |
|
||||
|
||||
**v0.3 grill FIX conditions carried forward to v0.4:**
|
||||
|
||||
| v0.3 Grill FIX | v0.4 Coverage | Status |
|
||||
|----------------|---------------|--------|
|
||||
| Axis 7 #2 — k-anon differencing-attack test | NOT explicitly in PLAN (TASK-07-05 tests threshold only) | ⚠️ G-038 (MUST) — add differencing-attack test |
|
||||
| Axis 6 #3 — 503 guard on operator API when Postgres down | TASK-06-01 — "auth routes return 503" if no Postgres | ✅ Covered |
|
||||
| Axis 6 #2 — stabilize learner_ref as non-reusable UUID | NOT addressed in v0.4 (HARDCODED_LEARNER_ID = "learner-1" persists) | ⚠️ Accepted as pilot-scale constraint (G-012) |
|
||||
|
||||
**Verdict:** 8/8 v0.3 deferred REQs are covered in v0.4. 1 v0.3 FIX (differencing-attack test) is not carried forward and must be added (G-038). The learner_ref stabilization (v0.3 FIX) is accepted as a pilot-scale constraint (single hardcoded learner persists).
|
||||
|
||||
---
|
||||
|
||||
### Binding Decisions
|
||||
|
||||
| ID | Axis | Decision | Confidence | Type |
|
||||
|----|------|----------|-----------|------|
|
||||
| G-001 | 1 | v0.4 operator tier is the correct next priority (delivers v0.3 grill's deferred obligation) | 0.85 | ACCEPT |
|
||||
| G-002 | 1 | CI is the named sponsor under full autonomy | 0.80 | ACCEPT |
|
||||
| G-003 | 1 | v0.4 is not a zombie; pilot-scale business value is low (k-anon suppresses all cells). Dashboard validation path = test-seeded data. Document in ship notes. | 0.75 | ACCEPT |
|
||||
| G-004 | 1 | No financial ROI; ROI is governance credibility + architectural foundation. Accept non-financial ROI. | 0.65 | ACCEPT |
|
||||
| G-005 | 2 | v0.4 scope is a clean handoff from v0.3 grill deferral. No scope creep. | 0.90 | ACCEPT |
|
||||
| G-006 | 2 | Requirements frozen (8 REQs, CI-owned under full autonomy) | 0.85 | ACCEPT |
|
||||
| G-007 | 2 | Out-of-scope is explicit and comprehensive | 0.88 | ACCEPT |
|
||||
| **G-008** | **2** | **MUST: Add backup-restore drill task to P1 — execute pg_restore, verify 5 tables + row counts. A backup that is never restored is theater.** | **0.70** | **MUST** |
|
||||
| G-009 | 3 | Architecture is conventional (standard FastAPI + Postgres + React patterns), research-validated | 0.80 | ACCEPT |
|
||||
| G-010 | 3 | 4 new deps, all single-purpose. slowapi fallback documented. Accept. | 0.78 | ACCEPT |
|
||||
| **G-011** | **3** | **MUST: Verification endpoint two-store fallback semantics must be explicit in TASK-04-04 + TASK-06-03 (not deferred to EXECUTE). Rule: Postgres for keys → SQLite fallback for v0.3 credentials → SQLite-only if no Postgres.** | **0.75** | **MUST** |
|
||||
| G-012 | 3 | Three inherited debts acknowledged (SQLite VC keys, single learner, no TLS). Debts #2 and #3 accepted as pilot-scale constraints. | 0.72 | ACCEPT |
|
||||
| G-013 | 4 | Key-person dependency: security-engineer, data-engineer, backend-engineer. Accept under parallelization. | 0.82 | ACCEPT |
|
||||
| G-014 | 4 | 6 personas available (4 config + 2 emergent), max 5 concurrent. 6>5 not binding. | 0.80 | ACCEPT |
|
||||
| G-015 | 4 | CI is the product owner with full authority | 0.85 | ACCEPT |
|
||||
| G-016 | 4 | Team building new capability (Postgres, auth, k-anon, React Router) — conventional patterns, thorough research. Accept for pilot. | 0.75 | ACCEPT |
|
||||
| G-017 | 5 | Phase structure set after scope understood. Not reverse-engineered. | 0.85 | ACCEPT |
|
||||
| G-018 | 5 | Critical-path risk: SPA fallback (R-DASH-03). Mitigation: TASK-10-04. Accept with test as gate. | 0.75 | ACCEPT |
|
||||
| G-019 | 5 | 52 tasks is evidence-based (analogous to v0.3's 40, bottom-up sized) | 0.80 | ACCEPT |
|
||||
| G-020 | 5 | Definition of done = per-slice acceptance criteria + per-phase ship + verify | 0.85 | ACCEPT |
|
||||
| G-021 | 6 | No explicit token budget (pilot, full autonomy). Accept implicit budget model. | 0.75 | ACCEPT |
|
||||
| G-022 | 6 | Cost drivers budgeted (6GB CT, backup volume). Image + backup storage negligible. | 0.85 | ACCEPT |
|
||||
| G-023 | 6 | Burn rate: ~1.3 days estimated (analogous to v0.3) | 0.75 | ACCEPT |
|
||||
| G-024 | 6 | No budget contingency (full autonomy) | 0.90 | ACCEPT |
|
||||
| G-025 | 7 | 3 core assumptions: Postgres contention (0.75), k-anon sufficiency (0.70), cookie-without-TLS (0.65). All accepted as pilot-scale constraints. | 0.72 | ACCEPT |
|
||||
| G-026 | 7 | No external dependencies (full autonomy) | 0.90 | ACCEPT |
|
||||
| **G-027** | **7** | **MUST: TASK-04-03 must handle "no v0.3 active key in SQLite" — skip archive, generate fresh v0.4 key only. First-boot path for most deployments.** | **0.80** | **MUST** |
|
||||
| G-028 | 7 | Pre-mortem top-4: SPA fallback, Postgres contention, R-AUTH-01 punt, k-anon-empty-dashboard. All addressed. | 0.78 | ACCEPT |
|
||||
| G-029 | 8 | lead-developer is the conflict resolver | 0.85 | ACCEPT |
|
||||
| G-030 | 8 | Governance cadence: per-phase ship + verify + grill | 0.85 | ACCEPT |
|
||||
| **G-031** | **8** | **MUST: R-AUTH-01 resolution reframed — primary mitigation = k-anon defense-in-depth (sniffed cookie → no PII). Config-driven flag = secondary. v0.3 grill MUST #2 overridden for v0.4 operator surface because formative tier + k-anon together resolve the tension. Document ordering in TASK-03-02 + ship notes.** | **0.70** | **MUST** |
|
||||
| G-032 | 8 | No human stop trigger (full autonomy). Grill is the stop mechanism. | 0.80 | ACCEPT |
|
||||
| G-033 | 9 | Dashboard's first user is env-provided (not real). At pilot scale, shows no data. Capability demonstration for v0.5+. Document in ship notes. | 0.65 | ACCEPT |
|
||||
| G-034 | 9 | devops-engineer involved in P1 (operations surface built by operations persona) | 0.85 | ACCEPT |
|
||||
| G-035 | 9 | Rollback is per-phase git revert. P1 = soft (empty DSN → SQLite fallback). P2 = clean. VC key archival = additive. | 0.75 | ACCEPT |
|
||||
| G-036 | 9 | CI is the judge (full autonomy). Success = 8/8 REQ + acceptance criteria + verify. | 0.80 | ACCEPT |
|
||||
| G-037 | Meta | Auditor flags: R-AUTH-01 re-litigation, k-anon-empty-dashboard, backup-verification gap. All addressed. | 0.78 | ACCEPT |
|
||||
| **G-038** | **Meta** | **MUST: Add differencing-attack test to TASK-07-05 or TASK-10-03 — v0.3 grill FIX (Axis 7 #2) carried forward. Seed 10 learners in window A, 9 in B, verify API cannot isolate the dropped learner.** | **0.75** | **MUST** |
|
||||
| G-039 | Meta | v0.4 is already the simplest version (8 REQs, single operator, k-anon not DP). 3-view dashboard is D-053 (settled). | 0.75 | ACCEPT |
|
||||
| G-040 | Meta | 5 success conditions: SPA fallback (untested), VC migration (untested), Postgres stability (partial), auth security (partial, G-031), dashboard utility (test-seeded only). All addressable. | 0.72 | ACCEPT |
|
||||
| **G-041** | **Probe 5** | **MUST: TASK-10-01 must NOT use `@app.get("/{path:path}")` catch-all before StaticFiles (shadows asset serving). Use custom StaticFiles subclass returning index.html for non-file paths. Open Question #2 resolved in favor of subclass.** | **0.65** | **MUST** |
|
||||
|
||||
---
|
||||
|
||||
### Escalations
|
||||
|
||||
None. All 9 axes + meta + 6 v0.4-specific probes are resolved with confidence ≥ 0.60. The 6 MUST conditions (G-008, G-011, G-027, G-031, G-038, G-041) are binding decisions with clear resolutions — they do not require human escalation (full autonomy). The lowest-confidence binding decision is G-041 (0.65 — SPA fallback implementation) which is above the 0.60 threshold.
|
||||
|
||||
---
|
||||
|
||||
### MUST Conditions Summary (blocking — must be resolved in PLAN before EXECUTE)
|
||||
|
||||
1. **G-008 — Backup restore drill.** Add a task to P1 that executes `pg_restore --clean --if-exists` against a test Postgres and verifies the 5 tables + row counts. A nightly pg_dump that is never restored is theater.
|
||||
|
||||
2. **G-011 — Verification endpoint two-store fallback semantics.** TASK-04-04 + TASK-06-03 must explicitly document the fallback contract: (a) Postgres available → use it for key lookup (active + superseded); (b) Postgres available but credential not found → fall back to SQLite `issued_credentials` (v0.3 credentials); (c) Postgres NOT available (no DSN) → use existing v0.3 SQLite path for both keys + credentials. This is a binding contract, not an open question.
|
||||
|
||||
3. **G-027 — VC migration "no v0.3 key" edge case.** TASK-04-03 must handle the case where SQLite has no active issuer key (the pilot never issued a VC): skip the archive step, generate only the fresh v0.4 keypair. The e2e test (TASK-06-05) must include a "no v0.3 key" scenario. This is the first-boot path for most deployments.
|
||||
|
||||
4. **G-031 — R-AUTH-01 resolution reframed.** The *primary* mitigation for R-AUTH-01 is the k-anon defense-in-depth (cohort dashboard reads only k-anonymized aggregates → sniffed cookie leaks no PII). The config-driven `PRAXIS_COOKIE_SECURE` flag is *secondary* (operational convenience). The v0.3 grill's MUST #2 ("use TLS or loopback-binding") is *overridden* for the v0.4 operator-tier surface because the v0.3 grill's own formative-credential decision (MUST #1) + the k-anon defense-in-depth together resolve the tension. Document this ordering in TASK-03-02 and the v0.4 ship notes.
|
||||
|
||||
5. **G-038 — Differencing-attack test.** Add a test to TASK-07-05 or TASK-10-03: seed 10 learners in window A, 9 in window B (1 dropped), verify the API does not allow a query that isolates the dropped learner. This is a v0.3 grill FIX (Axis 7 #2) that must be carried forward.
|
||||
|
||||
6. **G-041 — SPA fallback implementation.** TASK-10-01 must NOT use a `@app.get("/{path:path}")` catch-all before StaticFiles (it would shadow asset serving — TASK-10-04 assertion 8 would fail). The correct implementation is a custom StaticFiles subclass that returns `FileResponse("client/dist/index.html")` for non-file paths. Open Question #2 is resolved in favor of the subclass approach.
|
||||
|
||||
---
|
||||
|
||||
### FIX Conditions (non-blocking — tracked in VERIFY-P1/P2)
|
||||
|
||||
- **G-003** — Document in v0.4 ship notes: dashboard validation path is test-seeded data (≥10 mock learners), not pilot traffic. At pilot scale (1 learner), k-anon suppresses all cells.
|
||||
- **G-012** — Document inherited debts: single hardcoded learner (k-anon suppresses pilot data), no TLS (R-AUTH-01 config-driven punt with k-anon defense-in-depth).
|
||||
- **G-018** — SPA fallback (R-DASH-03) is the critical-path risk. TASK-10-04 (8 assertions) is the gate. If assertion 8 fails, the fix is the custom StaticFiles subclass (G-041).
|
||||
- **G-025** — Postgres disk I/O contention (R-MT-01) is unmeasured. The 03:00 CT mitigation is a scheduling assumption. Accept as pilot-scale constraint.
|
||||
- **G-033** — Document in ship notes: v0.4 delivers the operator tier *capability*, not operator *value* (no real operator user at pilot scale).
|
||||
|
||||
---
|
||||
|
||||
### ACCEPT Items (proceed as-is)
|
||||
|
||||
- v0.4 scope is a clean handoff from v0.3 grill (G-005).
|
||||
- Architecture is conventional (G-009).
|
||||
- 4 new deps are single-purpose (G-010).
|
||||
- Key-person dependency is manageable under parallelization (G-013).
|
||||
- Phase structure is not reverse-engineered (G-017).
|
||||
- 52 tasks is evidence-based (G-019).
|
||||
- No external dependencies (G-026).
|
||||
- Rollback is per-phase git revert (G-035).
|
||||
- REQ-MT-02 split (schema in P1, pipeline in P2) is clean layering, not a vertical-slice violation (Probe 6).
|
||||
- R-VC-MIG-01 "archive before activate" + e2e test is sufficient (Probe 2, with G-027 edge case).
|
||||
|
||||
---
|
||||
|
||||
### Bottom Line
|
||||
|
||||
The v0.4 plan is **not unfeasible** — the research is thorough, the architecture is conventional, the phase split is clean, and the v0.3 grill's deferred obligation is honestly delivered. The plan is **not over-scoped** (8 REQs, single operator role, k-anon not DP). The plan is **not under-tested** in its highest-risk areas (R-VC-MIG-01 has a dedicated e2e test, R-DASH-03 has 8 assertions).
|
||||
|
||||
The 6 MUST conditions are surgical:
|
||||
- 2 are *missing tasks* (backup drill, differencing-attack test).
|
||||
- 2 are *specification clarifications* (verification endpoint fallback, VC migration edge case).
|
||||
- 1 is a *reframing* (R-AUTH-01: k-anon defense-in-depth is the primary mitigation, not the config flag).
|
||||
- 1 is an *implementation correction* (SPA fallback: custom StaticFiles subclass, not a catch-all route).
|
||||
|
||||
Resolve the 6 MUSTs, track the 5 FIXs, and v0.4 is a **GO**.
|
||||
+220
-1
@@ -322,4 +322,223 @@ territory: []
|
||||
- frontend-engineer (dashboard UI) ↔ backend-engineer (operator API) ↔ data-engineer (k-anonymity queries)
|
||||
- security-engineer (issuer key) ↔ data-engineer (issuer_keys table, encrypted-at-rest)
|
||||
- The security-engineer is NOT in config.json `personas` — emergent persona defined in PERSONAS.md (same pattern as v0.2 devops-engineer). Territory enforcement (warn mode) picks up globs from PERSONAS.md.
|
||||
- R-AUTH-01 (Secure cookie + no-TLS) is a security-engineer + lead-developer collaboration point for PLAN.
|
||||
- R-AUTH-01 (Secure cookie + no-TLS) is a security-engineer + lead-developer collaboration point for PLAN.
|
||||
|
||||
---
|
||||
|
||||
# Praxis — Persona Assessment (v0.4 Operator Tier)
|
||||
|
||||
> **Generated:** v0.4 RESEARCH stage
|
||||
> **Project:** Praxis (v0.4 — operator tier: cohort dashboard, auth, Postgres)
|
||||
> **Source:** v0.4 RESEARCH-v0.4-operator-tier.md + v0.4 REQUIREMENTS.md (REQ-MT-01/02, REQ-AUTH-01, REQ-DASH-01, 4 NFRs) + actual `pyproject.toml` + `client/package.json` + `server/` structure
|
||||
|
||||
## v0.4 Persona Roster
|
||||
|
||||
### Active personas (6)
|
||||
|
||||
The v0.4 milestone is **operator-tier-backend + dashboard-frontend + security-crypto + Postgres-in-LXC**. The frontend-engineer (reactivated in v0.3 anticipatory, now confirmed for v0.4 dashboard UI) and devops-engineer (deactivated in v0.3, reactivated for Postgres-in-LXC + backup + bootstrap script) are both active. The security-engineer is retained (VC key migration SQLite→Postgres + auth stack + Secure-cookie-TLS resolution). The data-engineer expands to the Postgres operator-tier schema + aggregation SQL. All 6 personas are active — the largest roster since v0.1.
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: lead-developer
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates task decomposition across Postgres/auth/cohort/dashboard/VC-migration domains. Resolves conflicts between backend (operator API + aggregation), security (auth + VC key migration), data (Postgres schema + k-anon), frontend (dashboard UI), and devops (Postgres service + CT bump + backup). Owns the docker-compose.yml Postgres service addition (spans data + backend + devops). Required for every milestone.
|
||||
domain: coordination
|
||||
frameworks: [pipecat, fastapi, postgres, docker]
|
||||
constraints: [pragmatic, battle-tested defaults, hybrid-storage-no-cross-db-joins, k-anonymity-floor-10, no-raw-learner-pii-in-postgres, mastery-off-voice-path, aggregation-off-voice-path]
|
||||
territory:
|
||||
- "docker-compose.yml"
|
||||
- ".env.example"
|
||||
---
|
||||
```
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: backend-engineer
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns the asyncpg pool wiring (app.state.pg_pool via lifespan — D-050), the operator API routes (server/operator/ — 8 endpoints per D-053/D-057), the cohort aggregation pipeline (server/cohort/ — on-session-end async hook + nightly reconciliation job per D-054), and the session_recorder.py extension to chain the aggregation hook after the mastery flow. Also owns the SPA fallback route in server/__main__.py (required for React Router /operator/* routes). The aggregation pipeline is the largest new backend territory in v0.4.
|
||||
domain: backend
|
||||
frameworks: [pipecat, pydantic, fastapi, uvicorn, asyncpg, aiosqlite]
|
||||
constraints: [api-first, type-safe, mastery-off-voice-path, aggregation-off-voice-path, deterministic-scoring, latency-budget-aware, routes-before-static-mount, no-cross-db-joins, asyncpg-pool-on-app-state]
|
||||
territory:
|
||||
- "**/server/**"
|
||||
- "**/server/operator/**"
|
||||
- "**/server/cohort/**"
|
||||
- "**/server/__main__.py"
|
||||
- "**/session_recorder.py"
|
||||
---
|
||||
```
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: frontend-engineer
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: REACTIVATED (confirmed for v0.4 — was anticipatory in v0.3). Owns the React cohort dashboard UI (client/src/operator/ — D-044, REQ-DASH-01, D-053). Auth-gated /operator/* routes + 3 k-anonymized views (practice volume, mastery progression, failure patterns). Adds React Router (react-router-dom@^7 — NEW dep) for /operator/* routing. Renders read-only tables + inline SVG sparklines (zero-dep, ~50 LOC). Auth gate: GET /api/operator/me on mount → redirect to /operator/login if 401. Reuses v0.2 StaticFiles (same client/dist build — D-044). No separate SPA build.
|
||||
domain: frontend
|
||||
frameworks: [react, react-router-dom, pipecat-client-sdk, webrtc, vite, fastapi-staticfiles]
|
||||
constraints: [component-first, auth-gated-operator-routes, k-anonymity-display-suppressed-cells, no-raw-learner-pii-in-ui, spa-fallback-for-operator-routes, inline-svg-sparklines-no-chart-lib]
|
||||
territory:
|
||||
- "**/client/**"
|
||||
- "**/client/src/operator/**"
|
||||
- "**/client/src/App.tsx"
|
||||
- "**/client/package.json"
|
||||
---
|
||||
```
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: data-engineer
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: EXPANDED territory for v0.4. Owns the Postgres operator-tier schema (operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys — D-040, refined by D-050..D-053), the db/pg_migrations/ migration runner (mirrors the existing db/migrate.py pattern), the db/pg_store.py (asyncpg-backed Postgres store), the IssuerKeyStore protocol/ABC (D-051 migration — both PraxisStore and PgStore implement it), and the k-anonymity suppression SQL (D-034 — write-time COUNT(DISTINCT learner_ref) >= 10 check). The hybrid SQLite+Postgres storage pattern (D-031) is the data-engineer's architectural concern — no cross-DB joins, opaque learner_ref. The cohort_aggregates table is a plain table (NOT partitioned — v0.4 scale; partitioning deferred post-pilot per RESEARCH-v0.4 §1.7).
|
||||
domain: data
|
||||
frameworks: [sqlite, postgres16, aiosqlite, asyncpg, alembic-style-migrations]
|
||||
constraints: [schema-first, type-safe, migration-driven, no-cross-db-joins, k-anonymity-floor-10, opaque-learner-ref, write-time-suppression, plain-table-no-partitions-v0.4, gen-random-uuid-no-extension]
|
||||
territory:
|
||||
- "**/db/**"
|
||||
- "**/db/migrations/**"
|
||||
- "**/db/pg_migrations/**"
|
||||
- "**/db/schema.sql"
|
||||
- "**/db/pg_schema.sql"
|
||||
- "**/db/pg_store.py"
|
||||
- "**/db/pg_migrate.py"
|
||||
---
|
||||
```
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: security-engineer
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: RETAINED from v0.3. Owns the VC issuer key migration (D-051 — SQLite→Postgres, v0.3 public key archived as superseded, fresh v0.4 keypair, encrypted at rest) and the operator auth stack (D-041, D-056, D-057 — argon2id passwords, signed stateless cookies via Starlette SessionMiddleware, slowapi 5/min rate limit, server-side auth enforcement on every /api/operator/* request). The Secure-cookie-TLS tension (R-AUTH-01) is the security-engineer's v0.4 collaboration point with lead-developer — resolution is config-driven PRAXIS_COOKIE_SECURE (default true; false for HTTP pilot with logged WARNING). The VC key migration is high-severity risk R-VC-MIG-01 — archiving the v0.3 public key before activating the new key is security-critical. argon2-cffi PasswordHasher defaults (t=3, m=64MiB, p=4) exceed OWASP minimums (verified 2026-08-04).
|
||||
domain: security
|
||||
frameworks: [pynacl, canonicaljson, base58, argon2-cffi, starlette-sessionmiddleware, slowapi, itsdangerous]
|
||||
constraints: [eddsa-jcs-2022-cryptosuite, no-plaintext-keys-in-git, issuer-key-encrypted-at-rest, argon2id-passwords-owasp-minimums, config-driven-secure-cookie, superseded-not-revoked, server-side-auth-enforcement, public-verification-no-pii]
|
||||
territory:
|
||||
- "**/server/vc/**"
|
||||
- "**/server/auth/**"
|
||||
- "**/vc/**"
|
||||
- "**/auth/**"
|
||||
---
|
||||
```
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: devops-engineer
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: REACTIVATED for v0.4 (was deactivated in v0.3 — no deploy scripts). v0.4 adds Postgres as a second Docker service in the existing LXC CT (D-040), which is devops territory: the docker-compose Postgres service definition + praxis-net bridge network + pgdata/pgbackups named volumes + pg_isready healthcheck + CT memory bump (4GB→6GB) + host-side cron for nightly pg_dump backup (D-055) + the scripts/create-operator.py bootstrap CLI (D-052) + .env.example operator vars (PRAXIS_PG_PASSWORD, PRAXIS_COOKIE_SECRET, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY). The Postgres-in-LXC addition is NOT just a docker-compose service addition (as v0.3 assumed) — it involves CT resource bump (lxc-config.sh memory change), backup cron setup, and the bootstrap script. Will deactivate again in v0.5 unless deploy hardening continues.
|
||||
domain: devops
|
||||
frameworks: [proxmox-ve-api, lxc, docker, systemd, bash, bats, gitea, pg_dump, cron]
|
||||
constraints: [idempotent-deploy, rollback-on-failure, secrets-never-committed, posix-sh-compatible, pg-dump-backup-retention-7d, host-side-cron-decoupled-from-app, ct-memory-bump-6gb]
|
||||
territory:
|
||||
- "scripts/proxmox/**"
|
||||
- "scripts/install-service.sh"
|
||||
- "scripts/create-operator.py"
|
||||
- "scripts/proxmox/praxis.service"
|
||||
- "scripts/proxmox/test/**"
|
||||
- ".env.example"
|
||||
---
|
||||
```
|
||||
|
||||
### Deactivated personas (0)
|
||||
|
||||
All 6 personas are active for v0.4. No deactivations.
|
||||
|
||||
### Proposed personas (not v0.4)
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: voice-engineer
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: PROPOSED for v0.5+ (Live Assist) when latency tuning, accent modeling, and multi-voice personas become central. v0.4 uses Pipecat's built-in voice pipeline (Silero VAD + Deepgram + Cartesia/Piper), so a dedicated voice-engineer is not warranted.
|
||||
domain: voice
|
||||
frameworks: [webrtc, silero-vad, audio-codecs]
|
||||
constraints: [sub-600ms-latency, accent-robustness, audio-quality-vs-latency-tradeoff]
|
||||
territory: []
|
||||
---
|
||||
```
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: ml-engineer
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: PROPOSED for v0.6+ when fine-tuning Ollama models on Canadian English / role-play data becomes relevant. v0.4 uses off-the-shelf cloud models — no ML training in scope.
|
||||
domain: ml
|
||||
frameworks: [ollama, pytorch, axolotl]
|
||||
constraints: [open-weights, cost-bounded-fine-tuning]
|
||||
territory: []
|
||||
---
|
||||
```
|
||||
|
||||
## Framework Alignment (v0.4 — from actual pyproject.toml + client/package.json)
|
||||
|
||||
| Persona | Frameworks (v0.4 research-aligned) | New in v0.4 | Source |
|
||||
|---------|-------------------------------------|-------------|--------|
|
||||
| lead-developer | pipecat, fastapi, postgres, docker | — | `pyproject.toml` + `docker-compose.yml` |
|
||||
| backend-engineer | pipecat, pydantic, fastapi, uvicorn, asyncpg, aiosqlite | **asyncpg** | `pyproject.toml` |
|
||||
| frontend-engineer | react, react-router-dom, pipecat-client-sdk, webrtc, vite, fastapi-staticfiles | **react-router-dom** | `client/package.json` |
|
||||
| data-engineer | sqlite, postgres16, aiosqlite, asyncpg, alembic-style-migrations | **postgres16, asyncpg** | `pyproject.toml` + `db/migrate.py` |
|
||||
| security-engineer | pynacl, canonicaljson, base58, argon2-cffi, starlette-sessionmiddleware, slowapi, itsdangerous | **argon2-cffi, slowapi** | `pyproject.toml` + RESEARCH-v0.4 |
|
||||
| devops-engineer | proxmox-ve-api, lxc, docker, systemd, bash, bats, gitea, pg_dump, cron | **pg_dump, cron** | `scripts/proxmox/` + `docker-compose.yml` |
|
||||
|
||||
## Territory Alignment (v0.4 — from actual server/ structure)
|
||||
|
||||
The actual `server/` structure: `asr/`, `tts/`, `llm/`, `guardrails/`, `scenarios/`, `mastery/`, `paths/`, `vc/`, `services/`, `pipeline.py`, `session_recorder.py`, `__main__.py`, `cost.py`, `debrief.py`, `latency.py`, `interruptibility.py`. v0.4 adds: `server/operator/` (operator API), `server/auth/` (auth middleware), `server/cohort/` (aggregation pipeline), `db/pg_store.py`, `db/pg_migrate.py`, `db/pg_migrations/`, `db/pg_schema.sql`, `scripts/create-operator.py`, `client/src/operator/`.
|
||||
|
||||
Key territory boundaries:
|
||||
- **docker-compose.yml** → lead-developer (spans praxis + postgres services + networks + volumes; collaborates with data + devops)
|
||||
- **server/__main__.py** (SPA fallback) → backend-engineer (the catch-all route before StaticFiles mount — D-044 SPA fallback)
|
||||
- **server/operator/** → backend-engineer (operator API routes)
|
||||
- **server/auth/** → security-engineer (auth middleware, argon2, cookies, rate limit)
|
||||
- **server/cohort/** → backend-engineer (aggregation pipeline — hook + nightly job)
|
||||
- **server/vc/issuer_keys.py** → security-engineer (IssuerKeyStore protocol refactor — D-051)
|
||||
- **db/pg_store.py + db/pg_schema.sql + db/pg_migrations/** → data-engineer (Postgres store + schema + migrations)
|
||||
- **scripts/create-operator.py** → devops-engineer (operator bootstrap CLI — D-052)
|
||||
- **scripts/proxmox/** → devops-engineer (CT memory bump if lxc-config.sh changes)
|
||||
- **client/src/operator/** → frontend-engineer (dashboard UI)
|
||||
- **client/src/App.tsx** → frontend-engineer (React Router wrapper + SPA fallback integration)
|
||||
- **client/package.json** → frontend-engineer (react-router-dom addition)
|
||||
- **.env.example** → devops-engineer (operator vars: PRAXIS_PG_PASSWORD, PRAXIS_COOKIE_SECRET, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY)
|
||||
|
||||
## Constraint Alignment (v0.4-specific)
|
||||
|
||||
- **All personas:** `hybrid-storage-no-cross-db-joins` (D-031), `k-anonymity-floor-10` (D-034), `no-raw-learner-pii-in-postgres` (D-031).
|
||||
- **lead-developer:** `aggregation-off-voice-path` (D-054 — async fire-and-forget, must not block session-end response).
|
||||
- **backend-engineer:** `mastery-off-voice-path` (C-8 carry-forward), `aggregation-off-voice-path` (D-054), `asyncpg-pool-on-app-state` (D-050 — pool created in lifespan, not per-request), `routes-before-static-mount` (carry-forward + SPA fallback catch-all before StaticFiles).
|
||||
- **frontend-engineer:** `auth-gated-operator-routes` (D-057), `k-anonymity-display-suppressed-cells` (D-034 — render "— (<10 learners)" for suppressed cells), `no-raw-learner-pii-in-ui` (D-031), `spa-fallback-for-operator-routes` (new — React Router needs index.html fallback), `inline-svg-sparklines-no-chart-lib` (RESEARCH-v0.4 §4.3 — zero-dep sparklines).
|
||||
- **data-engineer:** `no-cross-db-joins` (D-031), `opaque-learner-ref` (D-031 — learner_ref is opaque string, not FK), `write-time-suppression` (D-034 — cell suppression at write time, not read time), `plain-table-no-partitions-v0.4` (RESEARCH-v0.4 §1.7 — partitioning deferred post-pilot), `gen-random-uuid-no-extension` (PG16 core, no pgcrypto).
|
||||
- **security-engineer:** `argon2id-passwords-owasp-minimums` (D-041 + OWASP — PasswordHasher defaults exceed minimums), `config-driven-secure-cookie` (R-AUTH-01 resolution — PRAXIS_COOKIE_SECURE env var), `issuer-key-encrypted-at-rest` (D-042 — nacl.SecretBox with PRAXIS_VC_ISSUER_KEY root key), `superseded-not-revoked` (D-051 — v0.3 public key archived as superseded, not revoked), `server-side-auth-enforcement` (D-057 — server checks cookie on every /api/operator/* request, React guard is UX only), `public-verification-no-pii` (D-043 carry-forward).
|
||||
- **devops-engineer:** `idempotent-deploy` (carry-forward), `secrets-never-committed` (carry-forward), `pg-dump-backup-retention-7d` (D-055 — %u day-of-week rolling 7-file), `host-side-cron-decoupled-from-app` (RESEARCH-v0.4 §1.5 — backup runs even if praxis is down), `ct-memory-bump-6gb` (REQ-NFR-MT-01 — 4GB→6GB).
|
||||
|
||||
## Phase-Specific Personas
|
||||
|
||||
Two personas are **phase-specific** for v0.4:
|
||||
|
||||
1. **security-engineer** — `phase_specific: true`. Retained from v0.3 (was new in v0.3 for VC crypto). May persist into v0.9 (credentialing) but deactivate in between if no security-crypto work. The VC key migration + auth stack are the v0.4 security-critical surfaces.
|
||||
|
||||
2. **devops-engineer** — `phase_specific: true`. Reactivated from v0.2 (was deactivated in v0.3). v0.4 is Postgres-in-LXC heavy (docker-compose service + CT bump + backup + bootstrap). Will deactivate again in v0.5 unless deploy hardening continues.
|
||||
|
||||
## v0.4 Notes for PLAN/EXECUTE
|
||||
|
||||
- Territory enforcement mode: `warn` (per config.json `personas.territory_enforcement`)
|
||||
- The **backend-engineer owns the largest v0.4 task surface**: asyncpg pool + operator API (8 endpoints) + aggregation pipeline (hook + nightly job) + session_recorder extension + SPA fallback. This is the largest backend surface since v0.3.
|
||||
- The **frontend-engineer reactivates for confirmed dashboard work** (v0.3 was anticipatory; v0.4 is the real dashboard implementation). React Router addition + SPA fallback + 3 k-anonymized views + inline SVG sparklines.
|
||||
- The **security-engineer's v0.4 surface is high-severity**: VC key migration (R-VC-MIG-01 — archiving v0.3 public key is security-critical) + auth stack (R-AUTH-01 — Secure cookie + no-TLS resolution).
|
||||
- The **data-engineer's v0.4 surface spans two stores** (SQLite v0.3 + Postgres v0.4) + the IssuerKeyStore protocol (D-051 migration bridge).
|
||||
- The **devops-engineer's v0.4 surface is smaller than v0.2** but critical: docker-compose Postgres service + CT memory bump + backup cron + bootstrap script.
|
||||
- Cross-persona collaboration points:
|
||||
- backend-engineer (aggregation hook in session_recorder) ↔ data-engineer (cohort_aggregates schema + suppression SQL) ↔ security-engineer (learner_ref is opaque, no PII)
|
||||
- frontend-engineer (dashboard UI) ↔ backend-engineer (operator API endpoints) ↔ data-engineer (k-anonymity queries)
|
||||
- security-engineer (IssuerKeyStore protocol) ↔ data-engineer (PgStore implements it) — D-051 migration
|
||||
- security-engineer (auth middleware) ↔ backend-engineer (operator API router dependencies) — D-057
|
||||
- devops-engineer (docker-compose Postgres) ↔ lead-developer (compose file owner) ↔ data-engineer (pgdata volume + schema)
|
||||
- devops-engineer (create-operator.py) ↔ security-engineer (argon2id hashing) — D-052
|
||||
- The **security-engineer and devops-engineer are NOT in config.json `personas`** — emergent personas defined in PERSONAS.md (same pattern as v0.2/v0.3). Territory enforcement (warn mode) picks up globs from PERSONAS.md.
|
||||
- R-AUTH-01 (Secure cookie + no-TLS) is a security-engineer + lead-developer collaboration point for GRILL-v0.4 (config-driven flag resolution must be grill-approved).
|
||||
- R-VC-MIG-01 (VC key migration) is a security-engineer + data-engineer collaboration point (archive v0.3 public key before activating new key).
|
||||
@@ -0,0 +1,772 @@
|
||||
# Praxis — v0.4 Execution Plan (Operator Tier — Cohort Dashboard + Auth + Postgres)
|
||||
|
||||
> **Milestone:** v0.4 (Operator tier — cohort dashboard, auth, Postgres)
|
||||
> **Phases:** 2 execution phases (P1: operator foundation — Postgres + auth; P2: cohort dashboard + aggregation) + final phase (P3: review + ship)
|
||||
> **Ship:** v0.1.6 (Phase 0, already staged) → v0.1.7 (P1) → v0.1.8 (P2) → v0.1.9 (P3 = v0.4 milestone release)
|
||||
> **Status:** plan
|
||||
> **Autonomy:** full
|
||||
> **Parallelization:** enabled, max 5 concurrent agents
|
||||
> **Personas active (6):** lead-developer, backend-engineer, frontend-engineer (REACTIVATED), data-engineer (EXPANDED), security-engineer (RETAINED), devops-engineer (REACTIVATED)
|
||||
> **Date:** 2026-08-04
|
||||
|
||||
---
|
||||
|
||||
## Phase Split Rationale
|
||||
|
||||
v0.4 is split into 2 execution phases + final review, following the ROADMAP:
|
||||
|
||||
- **P1 (Operator Foundation — Postgres + Auth):** docker-compose Postgres 16 service, asyncpg pool, Postgres operator-tier schema (5 tables), operator auth (argon2id + signed stateless cookies + slowapi rate limit), VC issuer key migration SQLite→Postgres (archive v0.3 public key as `superseded`, fresh v0.4 keypair), operator bootstrap CLI. No UI. Shippable as `v0.1.7`. Covers: REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01 + REQ-MT-02 (schema foundation).
|
||||
- **P2 (Cohort Dashboard + Aggregation):** cohort aggregation pipeline (on-session-end async hook + nightly reconciliation at 03:00 CT, k-anonymity ≥ 10 write-time suppression), React cohort dashboard (3 views: practice/mastery/failure-patterns), `/api/operator/*` cohort endpoints (auth-gated), React Router + SPA fallback. Shippable as `v0.1.8`. Covers: REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02 + REQ-MT-02 (pipeline completion).
|
||||
- **P3 (Final — Review + Ship):** multi-persona review, audit, merge to main, milestone release `v0.1.9` = v0.4.
|
||||
|
||||
The split keeps P1 a clean infra/auth milestone (no UI, verifiable by tests + CLI), and P2 a clean feature milestone (dashboard + pipeline, verifiable by UI + API tests).
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions Honored (D-050..D-057 + research)
|
||||
|
||||
| Decision | Honored in | How |
|
||||
|----------|-----------|-----|
|
||||
| D-050 (asyncpg pool min 1/max 10 on app.state.pg_pool via lifespan) | SLICE-01 | lifespan creates pool on startup, closes on shutdown |
|
||||
| D-051 (VC key migration — fresh keypair in Postgres, v0.3 public key archived as superseded) | SLICE-04, SLICE-06 | migration script archives v0.3 pubkey + generates v0.4 key; e2e test verifies old VC |
|
||||
| D-052 (scripts/create-operator.py CLI) | SLICE-05 | idempotent insert, argon2id hash, env-provided credentials |
|
||||
| D-053 (3 dashboard views) | SLICE-08, SLICE-09 | practice/mastery/failure-patterns endpoints + React components |
|
||||
| D-054 (async fire-and-forget hook + nightly 03:00 CT) | SLICE-07 | asyncio.Task on session end + in-process scheduler loop |
|
||||
| D-055 (nightly pg_dump to volume, 7-day retention) | SLICE-02 | host-side cron script, %u rolling 7-file |
|
||||
| D-056 (signed stateless cookies, Starlette SessionMiddleware) | SLICE-03 | itsdangerous HMAC-SHA256, no sessions table |
|
||||
| D-057 (server-side auth on every /api/operator/* + React guard) | SLICE-03, SLICE-09 | router-level dependencies + GET /api/operator/me on mount |
|
||||
| R-AUTH-01 (config-driven PRAXIS_COOKIE_SECURE) | SLICE-03 | env var default true; false for HTTP pilot with logged WARNING |
|
||||
| SPA fallback for React Router /operator/* | SLICE-10 | catch-all route before StaticFiles mount |
|
||||
| Inline SVG sparklines (zero-dep) | SLICE-09 | ~50 LOC component, no chart library |
|
||||
| cohort_aggregates plain table (not partitioned) | SLICE-01 | schema ships with (path, window_start) index, no partitioning |
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 — Operator Foundation (Postgres + Auth)
|
||||
|
||||
**Branch:** `phase/01-operator-foundation` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship:** `v0.1.7` (patch release, feature milestone type)
|
||||
**REQ-IDs covered:** REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02 (schema foundation)
|
||||
**Slices:** 6 vertical slices in 3 waves
|
||||
**Total tasks:** 29
|
||||
|
||||
| Wave | Slices | Parallel slots | Description |
|
||||
|------|--------|----------------|-------------|
|
||||
| 1 | SLICE-01, SLICE-02 | 2 | Postgres DB foundation (compose + pool + schema + PgStore) + devops config (.env.example + CT bump + backup script) — disjoint file territories |
|
||||
| 2 | SLICE-03, SLICE-04, SLICE-05 | 3 | Operator auth module + VC issuer key migration + bootstrap CLI — all depend on SLICE-01 schema/pool; disjoint module territories |
|
||||
| 3 | SLICE-06 | 1 | P1 integration — __main__.py wiring (lifespan+pool, SessionMiddleware, auth routes, verification store swap) + integration tests + VC migration e2e |
|
||||
|
||||
### Wave dependency graph (P1)
|
||||
|
||||
```
|
||||
Wave 1 ──────────────────────────────────────────────────────
|
||||
SLICE-01 (Postgres DB foundation: compose + pool + schema + PgStore)
|
||||
SLICE-02 (devops config: .env.example + CT bump + backup cron)
|
||||
│
|
||||
▼
|
||||
Wave 2 ──────────────────────────────────────────────────────
|
||||
SLICE-03 (operator auth: argon2id + cookies + rate limit + deps) ← depends on SLICE-01 (operators table + PgStore)
|
||||
SLICE-04 (VC key migration: IssuerKeyStore + archive v0.3 key) ← depends on SLICE-01 (issuer_keys table + PgStore)
|
||||
SLICE-05 (operator bootstrap CLI: create-operator.py) ← depends on SLICE-01 (PgStore + operators table)
|
||||
│
|
||||
▼
|
||||
Wave 3 ──────────────────────────────────────────────────────
|
||||
SLICE-06 (P1 integration: __main__.py wiring + e2e tests) ← depends on SLICE-03, SLICE-04, SLICE-05
|
||||
```
|
||||
|
||||
### Persona load distribution (P1)
|
||||
|
||||
| Persona | Tasks | Primary territory |
|
||||
|---------|-------|-------------------|
|
||||
| lead-developer | 5 | docker-compose.yml, pyproject.toml, integration orchestration |
|
||||
| data-engineer | 8 | db/pg_schema.sql, db/pg_migrations/, db/pg_migrate.py, db/pg_store.py |
|
||||
| backend-engineer | 5 | server/__main__.py (lifespan + wiring), integration tests |
|
||||
| security-engineer | 8 | server/auth/ (argon2 + cookies + rate limit + deps), server/vc/ (IssuerKeyStore + migration) |
|
||||
| devops-engineer | 5 | .env.example, scripts/proxmox/lxc-clone.sh, scripts/backup-pg.sh, scripts/create-operator.py |
|
||||
| frontend-engineer | 0 | not active in P1 (no UI) |
|
||||
|
||||
---
|
||||
|
||||
## SLICE-01: Postgres DB Foundation (W1)
|
||||
|
||||
- **Goal:** Stand up Postgres 16 as a second Docker service with asyncpg pool, migration runner, and the full operator-tier schema (5 tables). The critical-path foundation for all P1/P2 work.
|
||||
- **REQ-IDs covered:** REQ-MT-01 (Postgres store), REQ-NFR-MT-01 (Postgres-in-LXC without destabilizing learner service), REQ-MT-02 (schema foundation — cohort_aggregates table)
|
||||
- **Wave:** 1
|
||||
- **Dependencies:** none
|
||||
- **Primary persona:** lead-developer
|
||||
- **Supporting personas:** data-engineer (schema + migrations + pg_store + pg_migrate), backend-engineer (pool lifespan), devops-engineer (compose volumes/network consultation)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-01-01 — docker-compose Postgres service + praxis-net + volumes
|
||||
- **Persona:** lead-developer
|
||||
- **File:** `docker-compose.yml` (extend)
|
||||
- **Content:** Add `postgres` service (postgres:16-slim, restart: unless-stopped, env: POSTGRES_USER/PASSWORD/DB/PGDATA, env_file server.env, pgdata+pgbackups volumes, pg_isready healthcheck 10s/5ret/5s timeout, praxis-net network, no published ports). Add `praxis` service `depends_on: { postgres: { condition: service_healthy } }` + `networks: [praxis-net]`. Add `pgdata`, `pgbackups` named volumes + `praxis-net` bridge network. Keep existing `praxis-data` volume + all v0.2 env vars.
|
||||
- **Acceptance criteria:** `docker compose config` validates; `docker compose up -d postgres` → healthcheck passes within 30s; praxis service starts after postgres healthy; no published port on postgres (verified `docker port` shows nothing).
|
||||
|
||||
#### TASK-01-02 — pyproject.toml new deps
|
||||
- **Persona:** lead-developer
|
||||
- **File:** `pyproject.toml` (extend)
|
||||
- **Content:** Add `asyncpg>=0.29`, `argon2-cffi>=23.1`, `slowapi>=0.1` to dependencies. These are the 3 new v0.4 pip deps (RESEARCH-v0.4 §new-deps).
|
||||
- **Acceptance criteria:** `pip install -e .` succeeds; `import asyncpg`, `import argon2`, `import slowapi` all work.
|
||||
|
||||
#### TASK-01-03 — asyncpg pool lifespan in server/__main__.py
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/__main__.py` (extend — add lifespan)
|
||||
- **Content:** Add `@asynccontextmanager async def lifespan(app)` that creates `asyncpg.create_pool(dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=10, command_timeout=10)` on `app.state.pg_pool`, runs `pg_migrate.apply_pg_migrations(pool)` on startup, closes pool on shutdown. Pass `lifespan=lifespan` to `FastAPI(...)`. If `PRAXIS_PG_DSN` is unset, log WARNING and skip pool (graceful — dev mode without Postgres). The existing `_store` (PraxisStore/SQLite) remains for learner state.
|
||||
- **Acceptance criteria:** With Postgres running, `app.state.pg_pool` is an asyncpg.Pool instance on startup; migrations applied (tables exist); pool closed cleanly on shutdown. Without Postgres (no DSN), server starts with WARNING, learner voice loop still works (SQLite unaffected).
|
||||
|
||||
#### TASK-01-04 — db/pg_migrate.py — asyncpg migration runner
|
||||
- **Persona:** data-engineer
|
||||
- **File:** `db/pg_migrate.py` (new)
|
||||
- **Content:** Mirror `db/migrate.py` pattern. `async def apply_pg_migrations(pool: asyncpg.Pool) -> list[str]` — creates `_pg_migrations` tracking table, reads `db/pg_migrations/*.sql` in sorted order, applies pending migrations within a transaction, records in `_pg_migrations`. Idempotent — no-op if all applied. Retries on connection failure (3 attempts, 2s backoff — R-MT-02 mitigation).
|
||||
- **Acceptance criteria:** Re-running `apply_pg_migrations(pool)` is a no-op (returns empty list). Migration files apply in order. Connection failure retries 3x then raises.
|
||||
|
||||
#### TASK-01-05 — db/pg_schema.sql + db/pg_migrations/0001_operator_tier.sql
|
||||
- **Persona:** data-engineer
|
||||
- **Files:** `db/pg_schema.sql` (new — reference), `db/pg_migrations/0001_operator_tier.sql` (new — applied by pg_migrate)
|
||||
- **Content:** 5 tables per ARCHITECTURE.md §Postgres Schema:
|
||||
- `operators` (id UUID DEFAULT gen_random_uuid() PK, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, display_name TEXT, role TEXT DEFAULT 'operator', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT now(), last_login_at TIMESTAMPTZ)
|
||||
- `issued_credentials` (id UUID PK, operator_id UUID REFERENCES operators, learner_ref TEXT NOT NULL, vc_type TEXT, payload_jsonb JSONB NOT NULL, signature_b64 TEXT NOT NULL, status TEXT DEFAULT 'active', issued_at TIMESTAMPTZ DEFAULT now(), revoked_at TIMESTAMPTZ)
|
||||
- `mastery_gate_events` (id UUID DEFAULT gen_random_uuid() PK, learner_ref TEXT NOT NULL, scenario_id TEXT, path_id TEXT NOT NULL, gate_outcome TEXT, rubric_scores_jsonb JSONB, recorded_at TIMESTAMPTZ DEFAULT now(), source TEXT DEFAULT 'sync')
|
||||
- `cohort_aggregates` (path TEXT NOT NULL, metric TEXT NOT NULL, window_start DATE NOT NULL, window_end DATE NOT NULL, value NUMERIC, cell_count INTEGER NOT NULL DEFAULT 0, cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE, updated_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (path, metric, window_start)) — **plain table, NOT partitioned** (D-050..D-053; RESEARCH-v0.4 §1.7). Index on `(path, window_start)`.
|
||||
- `issuer_keys` (id TEXT PK, public_key TEXT NOT NULL, private_key_enc BYTEA, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT now())
|
||||
- All use `gen_random_uuid()` (PG16 core, no extension — R-MT-05 verified).
|
||||
- **Acceptance criteria:** `apply_pg_migrations(pool)` creates all 5 tables + `_pg_migrations` tracking table. `\d operators` in psql shows expected columns. `gen_random_uuid()` works without extension. `cohort_aggregates` has no partitioning (confirmed via `\d+`).
|
||||
|
||||
#### TASK-01-06 — db/pg_store.py — PgStore class
|
||||
- **Persona:** data-engineer
|
||||
- **File:** `db/pg_store.py` (new)
|
||||
- **Content:** `class PgStore` — accepts an `asyncpg.Pool` in constructor. Methods:
|
||||
- Operator CRUD: `get_operator_by_username(username) -> dict | None`, `get_operator_by_id(id) -> dict | None`, `update_last_login(id)`, `insert_operator(username, password_hash, display_name) -> str` (ON CONFLICT DO NOTHING, returns id).
|
||||
- Cohort aggregate read: `get_cohort_aggregates(path, metric, since_date) -> list[dict]` (returns rows with value, cell_count, cell_suppressed, updated_at).
|
||||
- Cohort aggregate write: `upsert_cohort_aggregate(path, metric, window_start, window_end, value, cell_count, cell_suppressed)` (ON CONFLICT (path, metric, window_start) DO UPDATE).
|
||||
- Issuer key methods (implements IssuerKeyStore protocol — SLICE-04): `init_issuer_key(key_id, public_key, private_key_enc)`, `get_active_signing_key_row() -> dict | None`, `get_public_key_row(key_id) -> dict | None`, `set_issuer_key_superseded(key_id)`.
|
||||
- Credential methods: `insert_credential(...)`, `get_credential(id) -> dict | None`, `set_credential_status(id, status)`.
|
||||
- Mastery gate event: `record_gate_event(learner_ref, path_id, scenario_id, gate_outcome, rubric_scores_jsonb)`.
|
||||
- All async, use `pool.acquire()` context manager.
|
||||
- **Acceptance criteria:** Each method has a unit test with a real Postgres pool (testcontainers or local PG). Round-trip insert+query works. ON CONFLICT upsert is idempotent. No cross-DB joins (D-031). `learner_ref` is opaque string (not FK).
|
||||
|
||||
#### TASK-01-07 — PgStore + pool integration test
|
||||
- **Persona:** data-engineer
|
||||
- **File:** `tests/test_pg_store.py` (new)
|
||||
- **Content:** Integration test requiring a Postgres instance (skip if `PRAXIS_PG_DSN` not set). Tests: pool creation, migration application, operator insert+query, cohort_aggregate upsert idempotency, issuer_key insert+query, credential insert+query. Verifies the full DB stack works end-to-end.
|
||||
- **Acceptance criteria:** All tests pass when Postgres is available; tests skip gracefully when `PRAXIS_PG_DSN` is unset (no hard CI dependency on Postgres).
|
||||
|
||||
---
|
||||
|
||||
## SLICE-02: DevOps Config — .env.example + CT Bump + Backup (W1)
|
||||
|
||||
- **Goal:** Update deployment config for Postgres-in-LXC: operator env vars, CT memory bump (4→6GB), nightly backup cron script.
|
||||
- **REQ-IDs covered:** REQ-NFR-MT-01 (Postgres-in-LXC without destabilizing — CT sizing + backup)
|
||||
- **Wave:** 1
|
||||
- **Dependencies:** none (parallel with SLICE-01 — disjoint files: .env.example, scripts/proxmox/ vs docker-compose.yml, db/, server/)
|
||||
- **Primary persona:** devops-engineer
|
||||
- **Supporting personas:** lead-developer (compose env consultation)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-02-01 — .env.example operator vars
|
||||
- **Persona:** devops-engineer
|
||||
- **File:** `.env.example` (extend)
|
||||
- **Content:** Add v0.4 operator vars with documentation comments:
|
||||
- `PRAXIS_PG_PASSWORD` (Postgres password — secret)
|
||||
- `PRAXIS_PG_DSN` (full DSN: `postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis`)
|
||||
- `PRAXIS_COOKIE_SECRET` (≥32 bytes random — secret)
|
||||
- `PRAXIS_COOKIE_SECURE` (default `true`; set `false` for HTTP pilot — R-AUTH-01)
|
||||
- `PRAXIS_BOOTSTRAP_OPERATOR_USER` (initial operator username — secret)
|
||||
- `PRAXIS_BOOTSTRAP_OPERATOR_PASS` (initial operator password — secret)
|
||||
- `PRAXIS_VC_ISSUER_KEY` (VC issuer root key — already in v0.3, document for v0.4 migration)
|
||||
- **Acceptance criteria:** `.env.example` is documentation-only (no real secrets). All vars have comments explaining purpose + when to set. File is gitignored-safe (`.env.example` is committed, `.env.secrets` is not — verified in `.gitignore`).
|
||||
|
||||
#### TASK-02-02 — CT memory bump in lxc-clone.sh
|
||||
- **Persona:** devops-engineer
|
||||
- **File:** `scripts/proxmox/lxc-clone.sh` (extend)
|
||||
- **Content:** Change `memory=${PROXMOX_MEMORY_MB:-4096}` → `memory=${PROXMOX_MEMORY_MB:-6144}` (4GB→6GB per REQ-NFR-MT-01, RESEARCH-v0.4 §1.1). Add comment explaining Postgres ~400MB + praxis ~500MB + Docker ~200MB + build headroom ~1GB + margin.
|
||||
- **Acceptance criteria:** `lxc-clone.sh` defaults to 6144MB. Existing override via `PROXMOX_MEMORY_MB` env still works. Bats tests (if any check memory) updated.
|
||||
|
||||
#### TASK-02-03 — Backup cron script
|
||||
- **Persona:** devops-engineer
|
||||
- **File:** `scripts/backup-pg.sh` (new)
|
||||
- **Content:** Host-side cron script (decoupled from praxis service uptime — RESEARCH-v0.4 §1.5). Runs `docker compose exec -T postgres pg_dump -U praxis -Fc praxis -f /backups/praxis-$(date +%u).dump`. The `%u` = day-of-week 1-7 → rolling 7-file retention with zero cleanup logic (D-055). Includes a restore drill comment block: `pg_restore --clean --if-exists /backups/praxis_3.dump` (never restore into live DB without stopping praxis first). Script is idempotent — overwrites the day-of-week file.
|
||||
- **Acceptance criteria:** Script executes without error when postgres is running. Produces a compressed dump file at `/backups/praxis-<dow>.dump`. Re-running overwrites the same file. Restore drill documented in comments. Script is POSIX-sh compatible (no bashisms).
|
||||
|
||||
---
|
||||
|
||||
## SLICE-03: Operator Auth Module (W2)
|
||||
|
||||
- **Goal:** Implement the operator auth stack: argon2id password hashing, signed stateless cookies (Starlette SessionMiddleware), slowapi rate limiting, and the `current_operator` dependency. The auth route handlers (login/logout/me) are in this slice; __main__.py mounting is in SLICE-06.
|
||||
- **REQ-IDs covered:** REQ-AUTH-01, REQ-NFR-AUTH-01
|
||||
- **Wave:** 2
|
||||
- **Dependencies:** SLICE-01 (operators table + PgStore for operator lookup)
|
||||
- **Primary persona:** security-engineer
|
||||
- **Supporting personas:** backend-engineer (FastAPI route patterns)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-03-01 — argon2id password hashing
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/auth/passwords.py` (new)
|
||||
- **Content:** `from argon2 import PasswordHasher`. `_ph = PasswordHasher()` (defaults: time_cost=3, memory_cost=64MiB, parallelism=4 — exceeds OWASP minimums per RESEARCH-v0.4 §2.1). `hash_password(plain: str) -> str`, `verify_password(stored_hash: str, plain: str) -> bool` (catches VerifyMismatchError → False), `needs_rehash(stored_hash: str) -> bool` (delegates to `_ph.check_needs_rehash`). Login flow calls `needs_rehash` after successful verify → rehash if params bumped.
|
||||
- **Acceptance criteria:** hash→verify round-trip works. Wrong password returns False (no exception). `needs_rehash` returns False for current defaults, True if params are bumped. Hashing latency < 1s (R-AUTH-02 — single operator, low frequency).
|
||||
|
||||
#### TASK-03-02 — Signed cookie configuration (SessionMiddleware)
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/auth/cookies.py` (new)
|
||||
- **Content:** `def get_session_middleware_kwargs() -> dict` — returns kwargs for `SessionMiddleware`: `secret_key=os.environ["PRAXIS_COOKIE_SECRET"]`, `session_cookie="praxis_op"`, `max_age=28800` (8h — D-041), `httponly=True`, `samesite="strict"`, `secure=_env_bool("PRAXIS_COOKIE_SECURE", True)`, `path="/"`. If `PRAXIS_COOKIE_SECURE=false`, log WARNING: "Cookie Secure flag disabled — HTTP pilot mode (R-AUTH-01). Do not use in production." `_env_bool` parses "true"/"false"/"1"/"0". If `PRAXIS_COOKIE_SECRET` is unset, generate a random one + log WARNING (dev only — not for pilot).
|
||||
- **Acceptance criteria:** Cookie kwargs match D-041/D-056 spec. `secure=False` logs WARNING. Missing secret generates random + WARNING. Cookie name is `praxis_op` (distinct from any future learner cookie).
|
||||
|
||||
#### TASK-03-03 — Login rate limiter (slowapi)
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/auth/rate_limit.py` (new)
|
||||
- **Content:** `from slowapi import Limiter`. `limiter = Limiter(key_func=get_remote_address)` (in-memory backend, single-instance — D-041). `def rate_limit_login() -> callable` — returns a decorator `@limiter.limit("5/minute")` for the login route. 429 + `Retry-After` header on exceed. Document the hand-rolled counter fallback in comments (RESEARCH-v0.4 §2.5).
|
||||
- **Acceptance criteria:** 6th login attempt within 1 minute returns 429 with Retry-After. Rate limit is per-IP. Counter resets after 1 minute. R-AUTH-03 (in-memory lost on restart) documented as accepted pilot risk.
|
||||
|
||||
#### TASK-03-04 — current_operator dependency
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/auth/dependencies.py` (new)
|
||||
- **Content:** `async def current_operator(request: Request) -> Operator` — reads `request.session.get("operator_id")`; if missing → raise `HTTPException(401, "not authenticated")`; fetches operator from PgStore by id; if not found or `is_active=False` → 401 + clear session; returns `Operator` dataclass (id, username, display_name, role). This is the server-side auth enforcement (D-057) — every `/api/operator/*` protected route uses `Depends(current_operator)`.
|
||||
- **Acceptance criteria:** No cookie → 401. Invalid/expired cookie → 401. Valid cookie + active operator → returns Operator. Valid cookie + inactive operator → 401 + session cleared. The dependency never trusts the client (D-057).
|
||||
|
||||
#### TASK-03-05 — Auth route handlers (login, logout, me)
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/auth/routes.py` (new)
|
||||
- **Content:** `APIRouter(prefix="/api/operator")` with:
|
||||
- `POST /login` — rate-limited (TASK-03-03). Body: `{username, password}`. Fetches operator from PgStore, `verify_password`, on success sets `request.session["operator_id"] = op.id`, updates `last_login_at`, returns `{operator: {id, username, display_name}}`. On failure → 401. If `needs_rehash` → rehash + update store.
|
||||
- `POST /logout` — `Depends(current_operator)` — clears `request.session`, returns `{ok: true}`. (Stateless — client also clears cookie; D-056.)
|
||||
- `GET /me` — `Depends(current_operator)` — returns `{operator: {id, username, display_name, role}}`. This is the React route guard endpoint (D-057).
|
||||
- Login + logout are outside the protected router (login is rate-limited, not auth-gated; logout is auth-gated but on the same router).
|
||||
- **Acceptance criteria:** Login with correct creds → 200 + cookie set. Login with wrong creds → 401 + no cookie. 6th attempt → 429. `/me` with valid cookie → 200. `/me` without cookie → 401. `/logout` clears session.
|
||||
|
||||
#### TASK-03-06 — Auth unit tests
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `tests/test_auth.py` (new)
|
||||
- **Content:** Unit tests for passwords (hash/verify/rehash), cookie config (secure flag logic, warning on false), rate limiter (5/min threshold), current_operator dependency (401 cases, active/inactive), login/logout/me route handlers (with mocked PgStore). Tests do not require a real Postgres (mock PgStore).
|
||||
- **Acceptance criteria:** All tests pass with mocked PgStore. Coverage: password verify fail, rate limit, 401 on missing/invalid/expired cookie, 401 on inactive operator, rehash on login.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-04: VC Issuer Key Migration (W2)
|
||||
|
||||
- **Goal:** Migrate the VC issuer key store from SQLite to Postgres. Refactor `issuer_keys.py` to an `IssuerKeyStore` protocol (both PraxisStore and PgStore implement it). Archive the v0.3 public key as `superseded` in Postgres. Generate a fresh v0.4 keypair. Update verification to use PgStore.
|
||||
- **REQ-IDs covered:** REQ-MT-01 (issuer_keys in Postgres — partial)
|
||||
- **Wave:** 2
|
||||
- **Dependencies:** SLICE-01 (issuer_keys table + PgStore issuer key methods)
|
||||
- **Primary persona:** security-engineer
|
||||
- **Supporting personas:** data-engineer (PgStore issuer key implementation)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-04-01 — IssuerKeyStore protocol/ABC
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/vc/issuer_keys.py` (refactor)
|
||||
- **Content:** Define `class IssuerKeyStore(Protocol)` with methods: `init_issuer_key(key_id, public_key, private_key_enc)`, `get_active_signing_key_row() -> dict | None`, `get_public_key_row(key_id) -> dict | None`, `set_issuer_key_superseded(key_id)`. Refactor existing functions (`init_issuer_key`, `get_active_signing_key`, `get_public_key_for_verification`, `rotate_key`) to accept `IssuerKeyStore` instead of `PraxisStore`. The existing `PraxisStore` already implements these methods (duck-typing) — the protocol formalizes the interface. Keep `_encrypt_private_key`, `_decrypt_private_key`, `_verification_method`, `KeyPair` unchanged. R-VC-MIG-03 mitigation: both stores implement the same protocol.
|
||||
- **Acceptance criteria:** `PraxisStore` passes `isinstance(store, IssuerKeyStore)` (or structural check). `PgStore` passes the same. Existing v0.3 tests still pass (PraxisStore path unchanged). No breaking change to function signatures beyond the type annotation.
|
||||
|
||||
#### TASK-04-02 — PgStore issuer key methods
|
||||
- **Persona:** data-engineer
|
||||
- **File:** `db/pg_store.py` (extend — SLICE-01 stubs, now full implementation)
|
||||
- **Content:** Full implementation of the 4 IssuerKeyStore methods using asyncpg. `init_issuer_key` → INSERT with `gen_random_uuid()` or provided key_id. `get_active_signing_key_row` → SELECT WHERE status='active' ORDER BY created_at DESC LIMIT 1. `get_public_key_row` → SELECT WHERE id=$1 (queries by id, not status — **this is the superseded key fallback** per D-051). `set_issuer_key_superseded` → UPDATE status='superseded' WHERE id=$1. `private_key_enc` is BYTEA in Postgres (vs BLOB in SQLite).
|
||||
- **Acceptance criteria:** All 4 methods work with real Postgres. `get_public_key_row` finds both active AND superseded keys by id (R-VC-MIG-01 mitigation — verification fallback). Round-trip: init → get_active → set_superseded → get_public_key(superseded id) still returns the row.
|
||||
|
||||
#### TASK-04-03 — VC key migration script
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/vc/migrate_keys.py` (new)
|
||||
- **Content:** `async def migrate_issuer_keys(sqlite_store: PraxisStore, pg_store: PgStore, root_key: bytes) -> dict` — the one-time migration procedure (D-051):
|
||||
1. Read v0.3 active public key from SQLite `issuer_keys` (status='active').
|
||||
2. Insert that public key into Postgres `issuer_keys` with status='superseded' (private key NOT migrated — only public key archived for verification).
|
||||
3. Generate a fresh Ed25519 keypair in Postgres `issuer_keys` with status='active' (encrypted at rest with root key — same nacl.SecretBox pattern).
|
||||
4. Return `{archived_key_id, new_key_id}`.
|
||||
Idempotent: if Postgres already has an active key, skip steps 2-3 (no-op). If Postgres has a superseded key matching the v0.3 key_id, skip step 2.
|
||||
**R-VC-MIG-01 mitigation: archive the v0.3 public key BEFORE activating the new key.** The script does step 2 before step 3.
|
||||
- **Acceptance criteria:** Running the migration on a fresh Postgres: v0.3 public key appears as superseded, fresh key appears as active. Re-running is a no-op. v0.3 VCs still verify against the archived (superseded) public key.
|
||||
|
||||
#### TASK-04-04 — Verification endpoint store swap
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `server/vc/verification.py` (extend)
|
||||
- **Content:** `verify_credential` currently takes `PraxisStore`. Refactor to accept either `PraxisStore` (v0.3 SQLite) or `PgStore` (v0.4 Postgres) via the IssuerKeyStore protocol for key lookup. For credential lookup: try Postgres `issued_credentials` first; if not found, fall back to SQLite `issued_credentials` (v0.3 credentials remain in SQLite — no data migration per D-051 "no re-issuance"). The key lookup always uses the passed store. Add a `store` parameter that implements both credential + key lookup. **The __main__.py wiring (passing PgStore) is in SLICE-06.**
|
||||
- **Acceptance criteria:** `verify_credential` works with PraxisStore (v0.3 path — existing tests pass). `verify_credential` works with PgStore (v0.4 path — new test). v0.3 credential in SQLite + v0.3 key archived as superseded in Postgres → verifies ✓.
|
||||
|
||||
#### TASK-04-05 — VC migration unit tests
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `tests/test_vc_migration.py` (new)
|
||||
- **Content:** Tests with mocked stores:
|
||||
- Migration script: v0.3 key archived as superseded, fresh key active. Idempotent re-run.
|
||||
- Verification with PgStore: v0.4 VC (active key) verifies ✓. v0.3 VC (superseded key) verifies ✓ (R-VC-MIG-01 — the critical test).
|
||||
- Verification fallback: `get_public_key_row` finds superseded key by id.
|
||||
- Root key handling: v0.4 active key encrypted with v0.4 root key (R-VC-MIG-02 — v0.3 root key kept for v0.3 SQLite path).
|
||||
- **Acceptance criteria:** All tests pass. R-VC-MIG-01 explicitly tested: a v0.3 VC verifies against a Postgres store with the v0.3 public key archived as superseded.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-05: Operator Bootstrap CLI (W2)
|
||||
|
||||
- **Goal:** Implement `scripts/create-operator.py` — the first-run CLI that creates the initial operator from env-provided credentials (D-052).
|
||||
- **REQ-IDs covered:** REQ-AUTH-01 (operator account provisioning — partial)
|
||||
- **Wave:** 2
|
||||
- **Dependencies:** SLICE-01 (PgStore + operators table), SLICE-03 (argon2id hashing — TASK-03-01)
|
||||
- **Primary persona:** devops-engineer
|
||||
- **Supporting personas:** security-engineer (argon2id hashing pattern)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-05-01 — scripts/create-operator.py
|
||||
- **Persona:** devops-engineer
|
||||
- **File:** `scripts/create-operator.py` (new)
|
||||
- **Content:** CLI script that:
|
||||
1. Reads `PRAXIS_BOOTSTRAP_OPERATOR_USER` + `PRAXIS_BOOTSTRAP_OPERATOR_PASS` from env. If either missing → print error + exit 1 (R-BOOT-02).
|
||||
2. Reads `PRAXIS_PG_DSN` from env. If missing → print error + exit 1.
|
||||
3. Creates asyncpg pool, applies migrations (ensure schema exists).
|
||||
4. Hashes password with `argon2.PasswordHasher().hash(password)` (same defaults as TASK-03-01).
|
||||
5. `INSERT INTO operators (username, password_hash, display_name) VALUES ($1, $2, $3) ON CONFLICT (username) DO NOTHING` (idempotent — D-052).
|
||||
6. Prints `created` or `already exists` + exits 0.
|
||||
7. `--update` flag: `ON CONFLICT (username) DO UPDATE SET password_hash = excluded.password_hash` (force rehash — RESEARCH-v0.4 §open-questions #4).
|
||||
8. Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01).
|
||||
- **Acceptance criteria:** Running with valid env vars creates the operator. Re-running prints "already exists" (no password update). `--update` flag rehashes + updates. Missing env var → clear error + exit 1. Connection failure → retries 3x then clear error.
|
||||
|
||||
#### TASK-05-02 — config.json secrets scope + .env.secrets template
|
||||
- **Persona:** devops-engineer
|
||||
- **File:** `.ciagent/config.json` (extend secrets.scopes), `.ciagent/.env.secrets.example` (new — template, not the real secrets)
|
||||
- **Content:** Add `operator` scope to `config.json` secrets.scopes: `{"name": "operator", "env_vars": ["PRAXIS_PG_PASSWORD", "PRAXIS_COOKIE_SECRET", "PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", "PRAXIS_VC_ISSUER_KEY"]}`. Create `.env.secrets.example` documenting all operator secret vars (committed; the real `.env.secrets` is gitignored).
|
||||
- **Acceptance criteria:** `config.json` validates. New scope appears in secrets.scopes. `.env.secrets.example` is committed (no real secrets). `.env.secrets` is gitignored (verified).
|
||||
|
||||
#### TASK-05-03 — Bootstrap CLI test
|
||||
- **Persona:** devops-engineer
|
||||
- **File:** `tests/test_create_operator.py` (new)
|
||||
- **Content:** Test with mocked PgStore: create operator → verify exists in store. Re-run → "already exists" (no password update). `--update` → password updated. Missing env → exit 1. Verify password is argon2id hashed (not plaintext).
|
||||
- **Acceptance criteria:** All tests pass with mocked PgStore. Password hash starts with `$argon2id$` (not plaintext). Idempotent on re-run.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-06: P1 Integration (W3)
|
||||
|
||||
- **Goal:** Wire all P1 modules into `server/__main__.py`: lifespan pool, SessionMiddleware, auth routes, verification store swap. Run end-to-end P1 integration tests including the critical VC migration e2e test (R-VC-MIG-01).
|
||||
- **REQ-IDs covered:** REQ-MT-01 (full integration), REQ-AUTH-01 (auth wired), REQ-NFR-AUTH-01 (auth NFRs verified end-to-end), REQ-NFR-MT-01 (Postgres + learner service coexist)
|
||||
- **Wave:** 3
|
||||
- **Dependencies:** SLICE-03 (auth module), SLICE-04 (VC migration), SLICE-05 (bootstrap CLI)
|
||||
- **Primary persona:** backend-engineer
|
||||
- **Supporting personas:** lead-developer (integration orchestration), security-engineer (VC migration e2e)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-06-01 — __main__.py — mount SessionMiddleware + lifespan pool
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/__main__.py` (extend)
|
||||
- **Content:** Add `SessionMiddleware` with kwargs from `server.auth.cookies.get_session_middleware_kwargs()`. Add the lifespan context manager (from TASK-01-03) to the FastAPI app. The lifespan creates the asyncpg pool + runs pg_migrate. Create a `PgStore(pool)` instance on `app.state.pg_store` when pool is available. Keep the existing `_store` (PraxisStore/SQLite) for learner state. `SessionMiddleware` is added BEFORE CORS middleware (middleware order: outermost first — SessionMiddleware should be outermost to sign cookies before CORS headers).
|
||||
- **Acceptance criteria:** With Postgres: `app.state.pg_pool` + `app.state.pg_store` populated on startup. Without Postgres: server starts with WARNING, voice loop works, auth routes return 503 (service unavailable — no operator store). Cookie `praxis_op` is signed (itsdangerous).
|
||||
|
||||
#### TASK-06-02 — __main__.py — mount auth routes
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/__main__.py` (extend)
|
||||
- **Content:** `from server.auth.routes import router as auth_router`. `app.include_router(auth_router)` — mounts `/api/operator/login`, `/api/operator/logout`, `/api/operator/me`. The auth routes use `app.state.pg_store` for operator lookup. If `pg_store` is None (no Postgres), auth routes return 503. Register auth routes BEFORE the StaticFiles mount (routes-before-static-mount constraint — carry-forward from v0.2).
|
||||
- **Acceptance criteria:** `POST /api/operator/login` with valid creds → 200 + cookie. `GET /api/operator/me` with cookie → 200. Without cookie → 401. Routes are matched before StaticFiles (verified: `/api/operator/login` returns JSON, not index.html).
|
||||
|
||||
#### TASK-06-03 — __main__.py — swap verification endpoint to PgStore
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/__main__.py` (extend)
|
||||
- **Content:** Update the existing `/vc/verify/{credential_id}` route: if `app.state.pg_store` is available, use it for issuer key lookup (PgStore) + credential lookup (try Postgres first, fall back to SQLite for v0.3 credentials per TASK-04-04). If `pg_store` is None (no Postgres), fall back to the existing PraxisStore path (v0.3 compat). Run the VC key migration on first boot: if PgStore has no active issuer key, call `migrate_issuer_keys(_store, pg_store, root_key)` (from TASK-04-03).
|
||||
- **Acceptance criteria:** With Postgres: `/vc/verify/<v0.3-credential-id>` → verifies against archived superseded key in Postgres ✓. `/vc/verify/<v0.4-credential-id>` → verifies against active key in Postgres ✓. Without Postgres: `/vc/verify` falls back to SQLite (v0.3 compat). VC key migration runs once on first boot (idempotent).
|
||||
|
||||
#### TASK-06-04 — P1 integration test (auth end-to-end)
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `tests/test_p1_auth_integration.py` (new — requires Postgres, skip if no DSN)
|
||||
- **Content:** End-to-end auth flow: create operator via bootstrap CLI → POST /login → GET /me → POST /logout → GET /me (401). Test rate limiting (6th attempt → 429). Test cookie attributes (httpOnly, SameSite=Strict, secure per PRAXIS_COOKIE_SECURE). Test 8h expiry (mock time or check max_age). Test that learner voice loop (`/health`, `/pipecat/webrtc`) is unaffected by auth (REQ-NFR-MT-01 — Postgres + learner service coexist).
|
||||
- **Acceptance criteria:** Full auth flow works. Rate limit enforces 5/min. Cookie attributes match D-041/D-056. Learner voice loop unaffected (health check passes, WebRTC offer accepted — Postgres presence doesn't destabilize).
|
||||
|
||||
#### TASK-06-05 — VC migration e2e test (R-VC-MIG-01 — critical)
|
||||
- **Persona:** security-engineer
|
||||
- **File:** `tests/test_p1_vc_migration_e2e.py` (new — requires Postgres, skip if no DSN)
|
||||
- **Content:** The critical R-VC-MIG-01 test:
|
||||
1. Seed SQLite with a v0.3 issuer key + a v0.3-issued credential (or use existing test fixtures).
|
||||
2. Start the server with Postgres → migration runs automatically.
|
||||
3. Verify Postgres has: 1 superseded key (v0.3 public key) + 1 active key (v0.4 fresh keypair).
|
||||
4. `GET /vc/verify/<v0.3-credential-id>` → `valid: true` (verifies against archived superseded key — **R-VC-MIG-01 PASS**).
|
||||
5. Issue a new v0.4 credential (via mastery flow or test helper) → `GET /vc/verify/<v0.4-credential-id>` → `valid: true`.
|
||||
6. Tamper v0.3 credential → verify fails.
|
||||
7. Re-run server → migration is no-op (idempotent).
|
||||
- **Acceptance criteria:** v0.3 VC verifies against Postgres store with archived superseded key (R-VC-MIG-01 explicitly verified). v0.4 VC verifies against active key. Migration is idempotent. Tamper detection works.
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 — Cohort Dashboard + Aggregation
|
||||
|
||||
**Branch:** `phase/02-cohort-dashboard` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship:** `v0.1.8` (patch release, feature milestone type)
|
||||
**REQ-IDs covered:** REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02 (pipeline completion)
|
||||
**Slices:** 4 vertical slices in 2 waves
|
||||
**Total tasks:** 23
|
||||
|
||||
| Wave | Slices | Parallel slots | Description |
|
||||
|------|--------|----------------|-------------|
|
||||
| 1 | SLICE-07, SLICE-08, SLICE-09 | 3 | Cohort aggregation pipeline + operator API endpoints + React dashboard (parallel — disjoint file territories: server/cohort/ + session_recorder.py, server/operator/, client/) |
|
||||
| 2 | SLICE-10 | 1 | P2 integration — __main__.py wiring (SPA fallback + operator router mount) + end-to-end aggregation→endpoint→dashboard tests |
|
||||
|
||||
### Wave dependency graph (P2)
|
||||
|
||||
```
|
||||
Wave 1 ──────────────────────────────────────────────────────
|
||||
SLICE-07 (aggregation pipeline: hook + nightly + k-anon) ← depends on P1 SLICE-01 (cohort_aggregates schema + PgStore)
|
||||
SLICE-08 (operator API endpoints: cohort/mastery/failure) ← depends on P1 SLICE-03 (auth deps) + SLICE-01 (PgStore)
|
||||
SLICE-09 (React dashboard + Router + sparklines) ← depends on P1 SLICE-03 (auth API contract) + API contract from SLICE-08
|
||||
│
|
||||
▼
|
||||
Wave 2 ──────────────────────────────────────────────────────
|
||||
SLICE-10 (P2 integration: SPA fallback + router mount + e2e tests) ← depends on SLICE-07, SLICE-08, SLICE-09
|
||||
```
|
||||
|
||||
### Persona load distribution (P2)
|
||||
|
||||
| Persona | Tasks | Primary territory |
|
||||
|---------|-------|-------------------|
|
||||
| backend-engineer | 11 | server/cohort/ (aggregation), server/operator/ (endpoints), server/__main__.py (SPA fallback + router mount), session_recorder.py |
|
||||
| frontend-engineer | 7 | client/src/operator/, client/src/App.tsx, client/package.json |
|
||||
| data-engineer | 3 | k-anonymity suppression SQL (supporting), cohort query optimization (supporting) |
|
||||
| security-engineer | 1 | auth-gated endpoint verification (supporting in integration) |
|
||||
| lead-developer | 1 | integration orchestration |
|
||||
|
||||
---
|
||||
|
||||
## SLICE-07: Cohort Aggregation Pipeline (W1)
|
||||
|
||||
- **Goal:** Implement the cohort aggregation pipeline: on-session-end async fire-and-forget hook, nightly reconciliation job at 03:00 CT, k-anonymity ≥ 10 write-time suppression. Chain the hook into `session_recorder.py` after the mastery flow.
|
||||
- **REQ-IDs covered:** REQ-MT-02 (pipeline completion), REQ-NFR-DASH-02 (freshness ≤ 24h)
|
||||
- **Wave:** 1
|
||||
- **Dependencies:** P1 SLICE-01 (cohort_aggregates table + PgStore upsert method)
|
||||
- **Primary persona:** backend-engineer
|
||||
- **Supporting personas:** data-engineer (k-anonymity suppression SQL), security-engineer (learner_ref opaque — no PII)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-07-01 — Aggregation logic + k-anonymity suppression
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/cohort/aggregator.py` (new)
|
||||
- **Supporting:** data-engineer (suppression SQL)
|
||||
- **Content:** `async def aggregate_session(pg_store: PgStore, session_outcome: dict) -> None` — computes k-anonymized aggregates for the affected `(path, metric, window_start)` bins and upserts to `cohort_aggregates`. The `session_outcome` dict contains: learner_ref (opaque string — D-031), path, scenario_id, outcome (pass/fail), rubric_scores, failure_mode, branch_path, timestamp.
|
||||
- Metrics computed: `sessions_count`, `active_learners_count`, `gate_open_rate`, `median_mastery_score`, `failure_mode_frequency`, `rubric_criterion_means`, `week_distribution`.
|
||||
- **k-anonymity suppression (D-034, REQ-NFR-DASH-01):** `COUNT(DISTINCT learner_ref) >= 10` check per cell. If < 10 → `cell_suppressed=TRUE`, `value=NULL`. Suppression is at write time (auditable — RESEARCH-v0.4 §3.1).
|
||||
- **Idempotent upsert:** `ON CONFLICT (path, metric, window_start) DO UPDATE SET value=excluded.value, cell_count=excluded.cell_count, cell_suppressed=excluded.cell_suppressed, updated_at=now()`.
|
||||
- **No raw learner PII in Postgres** (D-031): only aggregates + opaque `learner_ref` for distinct counting.
|
||||
- **7-day rolling window:** `window_start = today::date - 6`, `window_end = today::date`.
|
||||
- Pre-defined 2-D views only (path × week, path × outcome) — no arbitrary filters (R-DASH-02 mitigation).
|
||||
- **Acceptance criteria:** Aggregate upsert is idempotent (re-run produces same result). Cells with < 10 distinct learners are suppressed (cell_suppressed=TRUE, value=NULL). No raw PII in Postgres (only aggregates + opaque learner_ref). 7-day window computed correctly.
|
||||
|
||||
#### TASK-07-02 — On-session-end async hook
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/cohort/hook.py` (new)
|
||||
- **Content:** `async def on_session_end(pg_store: PgStore, session_outcome: dict) -> None` — calls `aggregator.aggregate_session`. Designed to be chained as an `asyncio.create_task` (fire-and-forget — D-054). Failures log + nightly job reconciles (no exception propagation to the caller). The hook is non-blocking — the session-end response returns immediately. If `pg_store` is None (no Postgres), no-op + log WARNING.
|
||||
- **Acceptance criteria:** Hook is non-blocking (caller returns immediately). Hook failure logs but does not raise. No-Postgres → no-op + WARNING. Hook is idempotent (re-running with same session_outcome produces same aggregate).
|
||||
|
||||
#### TASK-07-03 — Nightly reconciliation job
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/cohort/nightly.py` (new)
|
||||
- **Content:** `class NightlyScheduler` — in-process asyncio scheduler (no APScheduler — RESEARCH-v0.4 §3.4). `async def start(self, pg_store)` — loops: compute seconds until next 03:00 CT → `asyncio.sleep(seconds)` → `await self._reconcile(pg_store)` → repeat. `async def _reconcile(self, pg_store)` — recomputes all 7-day windows for all paths (idempotent upsert). If the service restarts, the scheduler resumes on startup (computes next 03:00). Failures log + retry next night (R-DASH-04). The reconciliation guarantees REQ-NFR-DASH-02 (freshness ≤ 24h — the nightly job runs at least once/day).
|
||||
- **Acceptance criteria:** Scheduler computes correct seconds until 03:00 CT. Reconciliation recomputes all windows (idempotent). Scheduler resumes after restart. Job failure logs + retries next night. Max staleness = 24h (nightly job + on-session-end hook — REQ-NFR-DASH-02).
|
||||
|
||||
#### TASK-07-04 — Chain aggregation hook into session_recorder.py
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/session_recorder.py` (extend)
|
||||
- **Content:** After the mastery flow (line ~143, `asyncio.create_task(self._run_mastery_flow_guarded(mastery_deps))`), chain the aggregation hook: `asyncio.create_task(self._run_cohort_aggregation(pg_store, session_outcome))`. The `session_outcome` dict is built from the mastery result (scenario_id, path, outcome, rubric_scores, failure_mode, branch_path, learner_ref=self.learner_id). The hook is fire-and-forget (D-054). If `pg_store` is None (no Postgres), skip. The hook runs in parallel with the mastery flow (aggregation only needs the session outcome + rubric scores, which are available after the session ends — it does not need to wait for mastery completion). **Off the voice path (C-8, D-054).**
|
||||
- **Acceptance criteria:** Aggregation hook fires after session end. Voice loop latency unaffected (hook is async, non-blocking). Hook runs in parallel with mastery flow. No-Postgres → skip. session_recorder.py changes are backward-compatible (existing mastery flow unchanged).
|
||||
|
||||
#### TASK-07-05 — Aggregation unit tests
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `tests/test_cohort_aggregation.py` (new)
|
||||
- **Content:** Tests with mocked PgStore:
|
||||
- k-anonymity suppression: 9 learners → cell_suppressed=TRUE, value=NULL. 10 learners → cell_suppressed=FALSE, value=computed. 11 learners → not suppressed.
|
||||
- Idempotent upsert: same session_outcome twice → same aggregate.
|
||||
- 7-day window computation: window_start/window_end correct.
|
||||
- Multiple metrics: sessions_count, active_learners_count, gate_open_rate, etc.
|
||||
- No PII: only aggregates + opaque learner_ref in upsert calls.
|
||||
- **Acceptance criteria:** k-anon threshold exactly at 10 (9 suppressed, 10 not). Idempotent. All metrics computed correctly. No PII in any upsert call.
|
||||
|
||||
#### TASK-07-06 — Nightly job + hook integration test
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `tests/test_cohort_nightly.py` (new)
|
||||
- **Content:** Tests with mocked PgStore:
|
||||
- Scheduler computes correct seconds until 03:00 CT (mock datetime).
|
||||
- Reconciliation recomputes all windows (verify upsert calls for all paths × metrics).
|
||||
- Hook failure → log + nightly job reconciles (simulate hook failure, run nightly, verify aggregate is correct).
|
||||
- R-DASH-04: nightly job failure → logs + retries next night (mock failure, verify scheduler continues).
|
||||
- **Acceptance criteria:** Scheduler timing correct. Reconciliation covers all paths. Hook failure + nightly reconciliation = correct final state. Nightly failure doesn't crash the scheduler.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-08: Operator API Cohort Endpoints (W1)
|
||||
|
||||
- **Goal:** Implement the 4 auth-gated operator API endpoints for the cohort dashboard: practice volume, mastery progression, failure patterns, and credential management.
|
||||
- **REQ-IDs covered:** REQ-DASH-01 (API layer — partial), REQ-NFR-DASH-01 (k-anon display — partial)
|
||||
- **Wave:** 1
|
||||
- **Dependencies:** P1 SLICE-03 (current_operator dependency), P1 SLICE-01 (PgStore cohort_aggregates read)
|
||||
- **Primary persona:** backend-engineer
|
||||
- **Supporting personas:** data-engineer (k-anon query optimization)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-08-01 — GET /api/operator/cohort (practice volume)
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/operator/cohort.py` (new)
|
||||
- **Content:** `APIRouter` endpoint `GET /api/operator/cohort` with `dependencies=[Depends(current_operator)]` (D-057). Queries `cohort_aggregates` for practice volume metrics: sessions/day per path, total sessions in window, active learners (suppressed if < 10). Returns JSON: `{views: [{path, metrics: [{metric, window_start, window_end, value, cell_count, cell_suppressed, updated_at}]}], last_updated: "2026-08-04T03:00:00Z"}`. Suppressed cells have `value: null, cell_suppressed: true` — the frontend renders "— (<10 learners)" (D-053). No per-learner drill-down (R-DASH-02).
|
||||
- **Acceptance criteria:** Auth-gated (401 without cookie). Returns k-anonymized data. Suppressed cells have value=null. `last_updated` = max(updated_at) across returned rows (freshness indicator — REQ-NFR-DASH-02). No per-learner data.
|
||||
|
||||
#### TASK-08-02 — GET /api/operator/mastery (mastery progression)
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/operator/mastery.py` (new)
|
||||
- **Content:** `GET /api/operator/mastery` — auth-gated. Returns mastery progression metrics: % learners at each week (1-6), gate-open rate, median mastery_score, rubric criterion mean scores. Same JSON shape as TASK-08-01. All cells k-anonymized (suppressed if < 10).
|
||||
- **Acceptance criteria:** Auth-gated. Returns week distribution + gate-open rate + rubric criterion means. Suppressed cells have value=null. No per-learner data.
|
||||
|
||||
#### TASK-08-03 — GET /api/operator/failure-patterns
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/operator/failure_patterns.py` (new)
|
||||
- **Content:** `GET /api/operator/failure-patterns` — auth-gated. Returns failure pattern metrics: top failure_modes by frequency, rubric criteria with mean < 3.0 (weak-spots), branch outcome distribution (escalate vs accept). Same JSON shape. All k-anonymized.
|
||||
- **Acceptance criteria:** Auth-gated. Returns failure_mode frequency + weak criteria + branch distribution. Suppressed cells have value=null. No per-learner data.
|
||||
|
||||
#### TASK-08-04 — GET/POST /api/operator/credentials (VC management)
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/operator/credentials.py` (new)
|
||||
- **Content:** `GET /api/operator/credentials` — auth-gated. Lists issued VCs from Postgres `issued_credentials` (operator's issuance log). Returns `[{id, learner_ref, vc_type, status, issued_at, revoked_at}]`. `POST /api/operator/credentials/{id}/revoke` — auth-gated. Revokes a VC (sets status='revoked', revoked_at=now()). Updates the Bitstring Status List. This is the operator-side credential management (D-057 — VC issuance endpoints are auth-gated).
|
||||
- **Acceptance criteria:** Auth-gated. GET returns credential list (no PII beyond what the credential asserts — D-043). POST revoke → credential status='revoked'. Revoked credential fails verification (`GET /vc/verify/<id>` → valid: false, status: revoked).
|
||||
|
||||
#### TASK-08-05 — Endpoint unit tests
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `tests/test_operator_endpoints.py` (new)
|
||||
- **Content:** Tests with mocked PgStore + mocked current_operator:
|
||||
- All 4 endpoints return 401 without cookie.
|
||||
- All 4 endpoints return 200 with valid cookie.
|
||||
- Suppressed cells (cell_suppressed=TRUE) have value=null in response.
|
||||
- `last_updated` is the max(updated_at) across rows.
|
||||
- Credential revoke → status='revoked' in store + verification fails.
|
||||
- No per-learner data in any response (R-DASH-02).
|
||||
- **Acceptance criteria:** All endpoints auth-gated. Suppressed cells displayed correctly. Credential revoke works. No per-learner drill-down possible.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-09: React Cohort Dashboard + Router (W1)
|
||||
|
||||
- **Goal:** Implement the React cohort dashboard UI: React Router for `/operator/*` routes, login form, dashboard with 3 k-anonymized views, inline SVG sparklines, auth gate. The SPA fallback in `__main__.py` is in SLICE-10 (integration).
|
||||
- **REQ-IDs covered:** REQ-DASH-01 (UI layer — partial), REQ-NFR-DASH-01 (display suppressed cells — partial)
|
||||
- **Wave:** 1
|
||||
- **Dependencies:** P1 SLICE-03 (auth API contract: POST /login, GET /me), SLICE-08 (API contract: cohort/mastery/failure-patterns response shapes — implements against contract, not live API)
|
||||
- **Primary persona:** frontend-engineer
|
||||
- **Supporting personas:** backend-engineer (SPA fallback in SLICE-10, API contract consultation)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-09-01 — Add react-router-dom to client/package.json
|
||||
- **Persona:** frontend-engineer
|
||||
- **File:** `client/package.json` (extend)
|
||||
- **Content:** Add `react-router-dom@^7` to dependencies. Run `npm install`. No chart library (inline SVG sparklines — zero deps, RESEARCH-v0.4 §4.3).
|
||||
- **Acceptance criteria:** `npm install` succeeds. `npm run build` succeeds. `react-router-dom` in `node_modules`. Bundle size increase is reasonable (< 20KB for react-router-dom).
|
||||
|
||||
#### TASK-09-02 — BrowserRouter wrapper + route switch in App.tsx
|
||||
- **Persona:** frontend-engineer
|
||||
- **File:** `client/src/main.tsx` (extend), `client/src/App.tsx` (extend)
|
||||
- **Content:** Wrap `App` in `<BrowserRouter>`. In `App.tsx`, add `<Routes>`:
|
||||
- `/` → existing voice session UI (start→live→debrief — unchanged)
|
||||
- `/operator/login` → `Login` component
|
||||
- `/operator/dashboard` → `Dashboard` component (auth-gated)
|
||||
- `*` (catch-all) → voice session UI (fallback for unknown routes — SPA fallback)
|
||||
- R-DASH-05 mitigation: the existing voice UI at `/` is unchanged. The catch-all route serves the voice UI, not a 404.
|
||||
- **Acceptance criteria:** Voice UI at `/` works exactly as before (R-DASH-05). `/operator/login` renders login form. `/operator/dashboard` renders dashboard (or redirects to login). `npm run build` succeeds. No regressions in voice UI.
|
||||
|
||||
#### TASK-09-03 — Login form component
|
||||
- **Persona:** frontend-engineer
|
||||
- **File:** `client/src/operator/Login.tsx` (new)
|
||||
- **Content:** Login form: username + password fields + submit button. `POST /api/operator/login` on submit. On success → navigate to `/operator/dashboard`. On failure → show error. On 429 → show "Too many attempts, try again in a minute." Minimal CSS (reuse App.css patterns — no Tailwind/bootstrap). Form is accessible (label associations, keyboard navigation).
|
||||
- **Acceptance criteria:** Login form renders. Successful login navigates to dashboard. Failed login shows error. Rate limit (429) shows retry message. Form is keyboard-accessible.
|
||||
|
||||
#### TASK-09-04 — Dashboard shell + auth gate
|
||||
- **Persona:** frontend-engineer
|
||||
- **File:** `client/src/operator/Dashboard.tsx` (new)
|
||||
- **Content:** Dashboard shell: on mount, `GET /api/operator/me` → if 401, redirect to `/operator/login` (D-057 — React route guard, UX only). If 200, render dashboard with: operator name in header, 3 view tabs (Practice Volume, Mastery Progression, Failure Patterns), freshness indicator ("Last updated: Xh ago" from `last_updated` in API response — REQ-NFR-DASH-02), logout button (POST /api/operator/logout → redirect to login). View content fetched from respective `/api/operator/<view>` endpoints.
|
||||
- **Acceptance criteria:** Auth gate redirects to login on 401. Dashboard renders operator name. 3 view tabs switch. Freshness indicator shows "Last updated: Xh ago". Logout redirects to login. No PII displayed (only k-anonymized aggregates — D-031).
|
||||
|
||||
#### TASK-09-05 — Inline SVG sparkline component
|
||||
- **Persona:** frontend-engineer
|
||||
- **File:** `client/src/operator/Sparkline.tsx` (new)
|
||||
- **Content:** `<Sparkline data={number[]} width={60} height={20} />` — renders an SVG polyline from the data array. ~50 LOC, zero deps (RESEARCH-v0.4 §4.3). Handles edge cases: empty data (renders nothing), single point (renders a dot), all-same values (renders a flat line). Color: stroke=currentColor (inherits from parent). No axes, no tooltips (sparklines are compact trend indicators, not full charts).
|
||||
- **Acceptance criteria:** Renders SVG polyline for 7-30 data points. Empty data → no render. Single point → dot. All-same → flat line. No external deps. ~50 LOC.
|
||||
|
||||
#### TASK-09-06 — 3 dashboard view components
|
||||
- **Persona:** frontend-engineer
|
||||
- **Files:** `client/src/operator/views/PracticeVolume.tsx` (new), `client/src/operator/views/MasteryProgression.tsx` (new), `client/src/operator/views/FailurePatterns.tsx` (new)
|
||||
- **Content:** Each view: fetches its `/api/operator/<view>` endpoint, renders read-only tables + sparklines.
|
||||
- **PracticeVolume:** sessions/day per path (table + sparkline), total sessions, active learners. Suppressed cells → "— (<10 learners)" (REQ-NFR-DASH-01 display).
|
||||
- **MasteryProgression:** % learners at each week (bar-like table), gate-open rate, median mastery_score, rubric criterion means (table + sparkline). Suppressed cells → "— (<10 learners)".
|
||||
- **FailurePatterns:** top failure_modes by frequency (sorted table), rubric criteria with mean < 3.0 (highlighted as weak-spots), branch outcome distribution. Suppressed cells → "— (<10 learners)".
|
||||
- All views: loading state, error state, no-data state. Read-only (no filters, no drill-down — R-DASH-02).
|
||||
- **Acceptance criteria:** Each view fetches + renders k-anonymized data. Suppressed cells display "— (<10 learners)". Tables are read-only. Sparklines render in table rows. Loading/error/no-data states handled. No per-learner drill-down.
|
||||
|
||||
#### TASK-09-07 — Dashboard unit tests
|
||||
- **Persona:** frontend-engineer
|
||||
- **File:** `client/src/operator/__tests__/Dashboard.test.tsx` (new — or co-located per project convention)
|
||||
- **Content:** Tests:
|
||||
- Auth gate: 401 on /me → redirect to /operator/login.
|
||||
- Login form: submit → POST /login → navigate to dashboard.
|
||||
- Suppressed cell display: cell_suppressed=true → "— (<10 learners)" rendered.
|
||||
- Sparkline: renders SVG polyline for given data.
|
||||
- Freshness indicator: "Last updated: Xh ago" computed from last_updated.
|
||||
- No PII: only aggregate values in rendered DOM.
|
||||
- **Acceptance criteria:** All tests pass. Auth gate works. Suppressed cells display correctly. Sparkline renders. No PII in DOM.
|
||||
|
||||
---
|
||||
|
||||
## SLICE-10: P2 Integration (W2)
|
||||
|
||||
- **Goal:** Wire P2 modules into `server/__main__.py`: SPA fallback catch-all route (before StaticFiles), operator API router mount (cohort/mastery/failure-patterns/credentials), nightly scheduler start. Run end-to-end aggregation→endpoint→dashboard integration tests.
|
||||
- **REQ-IDs covered:** REQ-DASH-01 (full integration), REQ-NFR-DASH-01 (k-anon e2e), REQ-NFR-DASH-02 (freshness e2e), REQ-MT-02 (pipeline e2e)
|
||||
- **Wave:** 2
|
||||
- **Dependencies:** SLICE-07 (aggregation pipeline), SLICE-08 (operator endpoints), SLICE-09 (React dashboard)
|
||||
- **Primary persona:** backend-engineer
|
||||
- **Supporting personas:** lead-developer (integration orchestration), frontend-engineer (SPA fallback verification)
|
||||
|
||||
### Tasks
|
||||
|
||||
#### TASK-10-01 — __main__.py — SPA fallback catch-all route
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/__main__.py` (extend)
|
||||
- **Content:** Add a catch-all route BEFORE the StaticFiles mount: `@app.get("/{path:path}")` that returns `FileResponse("client/dist/index.html")` for any path not matching an API route (`/health`, `/pipecat/*`, `/vc/*`, `/api/operator/*`). This is the SPA fallback for React Router `/operator/*` routes (R-DASH-03). **R-DASH-03 mitigation: the catch-all is BEFORE the StaticFiles mount, and the existing API routes are registered before the catch-all.** The StaticFiles mount remains for serving JS/CSS/assets (the catch-all only serves index.html for client-side routes). Test: `/` still serves the voice UI (index.html, which loads the voice app); `/operator/dashboard` serves index.html (React Router handles the route client-side); `/api/operator/cohort` still returns JSON (not index.html).
|
||||
- **Acceptance criteria:** `GET /` → index.html (voice UI loads). `GET /operator/dashboard` → index.html (React Router handles it). `GET /operator/login` → index.html. `GET /api/operator/cohort` → JSON (not index.html — API routes take precedence). `GET /health` → JSON. `GET /vc/verify/123` → JSON. `GET /static.js` → served by StaticFiles (not the catch-all). R-DASH-03 verified: voice UI at `/` unchanged.
|
||||
|
||||
#### TASK-10-02 — __main__.py — mount operator API router + nightly scheduler
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `server/__main__.py` (extend)
|
||||
- **Content:** `from server.operator.cohort import router as cohort_router`, `from server.operator.mastery import router as mastery_router`, `from server.operator.failure_patterns import router as failure_router`, `from server.operator.credentials import router as credentials_router`. `app.include_router(...)` for each. All use `prefix="/api/operator"` + `dependencies=[Depends(current_operator)]` (auth-gated — D-057). Mount BEFORE the SPA fallback catch-all. Start the nightly scheduler in the lifespan: `asyncio.create_task(nightly_scheduler.start(pg_store))` (if pg_store available). Cancel the scheduler task on shutdown.
|
||||
- **Acceptance criteria:** `GET /api/operator/cohort` with valid cookie → JSON. Without cookie → 401. Nightly scheduler starts on app startup (if Postgres). Scheduler cancelled on shutdown. API routes matched before SPA fallback.
|
||||
|
||||
#### TASK-10-03 — P2 integration test (aggregation → endpoint → response)
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `tests/test_p2_aggregation_integration.py` (new — requires Postgres, skip if no DSN)
|
||||
- **Content:** End-to-end:
|
||||
1. Seed 15 mock sessions (12 distinct learners — above k-anon threshold) for a path.
|
||||
2. Run the aggregation hook for each session → `cohort_aggregates` populated.
|
||||
3. `GET /api/operator/cohort` (with auth cookie) → returns practice volume with non-suppressed cells (12 ≥ 10).
|
||||
4. Seed 5 more sessions from 5 NEW distinct learners for a different path → `GET /api/operator/cohort` for that path → suppressed cells (5 < 10, value=null, cell_suppressed=true). REQ-NFR-DASH-01 verified.
|
||||
5. Run nightly reconciliation → all windows recomputed → `last_updated` updated.
|
||||
6. `GET /api/operator/mastery` → mastery progression data.
|
||||
7. `GET /api/operator/failure-patterns` → failure pattern data.
|
||||
8. Verify `last_updated` in response ≤ 24h old (REQ-NFR-DASH-02).
|
||||
- **Acceptance criteria:** k-anon threshold enforced (12 learners → not suppressed, 5 → suppressed). All 3 endpoints return k-anonymized data. Nightly reconciliation updates `last_updated`. Freshness ≤ 24h (REQ-NFR-DASH-02). No per-learner data in any response.
|
||||
|
||||
#### TASK-10-04 — P2 integration test (SPA fallback + voice UI coexist)
|
||||
- **Persona:** backend-engineer
|
||||
- **File:** `tests/test_p2_spa_fallback.py` (new)
|
||||
- **Content:** Tests against the running server (or TestClient):
|
||||
1. `GET /` → 200, `content-type: text/html`, contains `<div id="root">` (voice UI loads).
|
||||
2. `GET /operator/dashboard` → 200, `content-type: text/html`, contains `<div id="root">` (SPA fallback serves index.html).
|
||||
3. `GET /operator/login` → 200, `text/html` (SPA fallback).
|
||||
4. `GET /api/operator/cohort` → JSON (API route, not SPA fallback).
|
||||
5. `GET /health` → JSON (API route).
|
||||
6. `GET /pipecat/webrtc` → 405 (method not allowed — POST only, but route exists, not SPA fallback).
|
||||
7. `GET /vc/verify/nonexistent` → 404 (API route, not SPA fallback).
|
||||
8. `GET /assets/index.js` → served by StaticFiles (not SPA fallback).
|
||||
**R-DASH-03 verified: SPA fallback serves index.html for client-side routes; API routes + StaticFiles assets are unaffected.**
|
||||
- **Acceptance criteria:** All 8 assertions pass. R-DASH-03 verified: voice UI at `/` unchanged, operator routes serve index.html, API routes return JSON, assets served by StaticFiles.
|
||||
|
||||
#### TASK-10-05 — P2 verification matrix
|
||||
- **Persona:** lead-developer
|
||||
- **File:** `.ciagent/VERIFY-P2.md` (new — pre-verify checklist for the verify stage)
|
||||
- **Content:** REQ-ID → test mapping for P2. Confirm all P2 REQ-IDs (REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02) have covering tests. List each test file + what it verifies. Cross-reference with P1 VERIFY (if any).
|
||||
- **Acceptance criteria:** Every P2 REQ-ID has at least one covering test listed. Matrix is complete (no gaps).
|
||||
|
||||
---
|
||||
|
||||
# Final Phase (P3) — Review + Audit + Milestone Ship
|
||||
|
||||
**Branch:** `phase/03-final-review-ship` → merged to `milestone/v0.4-operator-tier` → merged to `main`
|
||||
**Ship:** `v0.1.9` (final patch = v0.4 milestone release)
|
||||
**REQ-IDs covered:** all v0.4 REQ-IDs (milestone-complete verification)
|
||||
|
||||
### Tasks (delegated to ciagent-review + ciagent-audit + ciagent-ship)
|
||||
|
||||
1. Run branch gate → create `phase/03-final-review-ship`
|
||||
2. `ciagent-review` — multi-persona review across P1 + P2; auto-apply P0 fixes, flag P1+
|
||||
- **Security-engineer review focus:** auth stack (argon2id, cookies, rate limit), VC key migration (R-VC-MIG-01), R-AUTH-01 (Secure cookie + no-TLS — config-driven flag documented in GRILL-v0.4.md)
|
||||
- **Data-engineer review focus:** k-anonymity suppression (write-time, ≥10 threshold), no PII in Postgres, no cross-DB joins
|
||||
- **Frontend-engineer review focus:** auth gate (UX-only, server is authority), suppressed cell display, SPA fallback (R-DASH-03)
|
||||
3. `ciagent-audit` — reconstruction test, file discipline, branch hygiene, commit discipline
|
||||
4. `ciagent-ship` — merge phase/03 → milestone/v0.4-operator-tier → main; tag v0.1.9; create release with full milestone summary
|
||||
5. Update REQUIREMENTS.md (all v0.4 REQ → complete), ROADMAP.md (v0.4 → complete; v0.5 = Live Assist)
|
||||
6. Commit: `docs(milestone): complete v0.4-operator-tier`
|
||||
7. Clear checkpoint
|
||||
|
||||
---
|
||||
|
||||
# REQ-ID Coverage Matrix
|
||||
|
||||
| REQ-ID | Phase | Slice(s) | Coverage |
|
||||
|--------|-------|----------|----------|
|
||||
| REQ-MT-01 | P1 | SLICE-01, SLICE-06 | Postgres store (5 tables) + pool + migration runner + integration |
|
||||
| REQ-MT-02 | P1 (schema) + P2 (pipeline) | SLICE-01 (schema), SLICE-07 (pipeline), SLICE-10 (e2e) | Cohort aggregation pipeline — schema in P1, hook + nightly + k-anon in P2 |
|
||||
| REQ-AUTH-01 | P1 | SLICE-03, SLICE-05, SLICE-06 | Operator auth (argon2id + cookies + rate limit) + bootstrap CLI + integration |
|
||||
| REQ-DASH-01 | P2 | SLICE-08, SLICE-09, SLICE-10 | Cohort dashboard — API endpoints + React UI + integration |
|
||||
| REQ-NFR-AUTH-01 | P1 | SLICE-03, SLICE-06 | argon2id + httpOnly + secure + SameSite=Strict + rate-limited + 8h expiry |
|
||||
| REQ-NFR-MT-01 | P1 | SLICE-01, SLICE-02, SLICE-06 | Postgres-in-LXC (second service, internal network, 6GB CT, backup) + learner service coexist test |
|
||||
| REQ-NFR-DASH-01 | P2 | SLICE-07, SLICE-08, SLICE-09, SLICE-10 | k-anonymity ≥ 10 (write-time suppression + query + display + e2e test) |
|
||||
| REQ-NFR-DASH-02 | P2 | SLICE-07, SLICE-10 | Freshness ≤ 24h (nightly job + on-session-end hook + e2e test) |
|
||||
|
||||
**v0.4 total: 8/8 REQ-IDs covered (4 functional + 4 NFR). 0 partial. 0 deferred within v0.4.**
|
||||
|
||||
---
|
||||
|
||||
# Risk Mitigation Matrix
|
||||
|
||||
| Risk ID | Severity | Slice(s) | Mitigation |
|
||||
|---------|----------|----------|------------|
|
||||
| **R-VC-MIG-01** | high | SLICE-04, SLICE-06 | Archive v0.3 public key as superseded BEFORE activating new key; verification queries by key_id (not status); e2e test verifies v0.3 VC against Postgres store |
|
||||
| R-MT-01 | medium | SLICE-02, SLICE-07 | CT memory bump 6GB; nightly jobs at 03:00 CT (low activity); aggregation is incremental upsert (not full scan) |
|
||||
| R-MT-02 | medium | SLICE-01 | pg_isready healthcheck + 5 retries; depends_on: service_healthy; pg_migrate retries on connection failure (3x, 2s backoff) |
|
||||
| R-AUTH-01 | medium | SLICE-03 | Config-driven PRAXIS_COOKIE_SECURE (default true; false for HTTP pilot with logged WARNING); cohort dashboard reads only k-anonymized aggregates (no PII leak even if cookie sniffed); grill must sign off |
|
||||
| R-DASH-01 | medium | SLICE-07, SLICE-09 | Write-time suppression (cell_suppressed=TRUE, value=NULL); dashboard shows "— (<10 learners)" transparently; 7-day window can be widened to 14-day if too many cells suppressed |
|
||||
| R-DASH-02 | medium | SLICE-07, SLICE-08 | Pre-defined 2-D views only (path × week, path × outcome); no arbitrary filters; no per-learner drill-down (D-053) |
|
||||
| R-DASH-03 | medium | SLICE-10 | Catch-all route BEFORE StaticFiles mount; test `/` still serves voice UI; test `/operator/dashboard` serves index.html; test API routes return JSON (not index.html) |
|
||||
| R-DASH-05 | medium | SLICE-09 | BrowserRouter wrapper + catch-all route serves voice UI at `/`; test voice UI unchanged after Router addition |
|
||||
| R-VC-MIG-02 | medium | SLICE-04 | v0.3 private key NOT migrated (only public key archived); v0.4 active key generated fresh with v0.4 root key; v0.3 root key kept in secrets until v0.3 VCs expire |
|
||||
| R-VC-MIG-03 | medium | SLICE-04, SLICE-06 | IssuerKeyStore protocol/ABC; both PraxisStore and PgStore implement it; e2e test verifies v0.3 VC against Postgres store with archived key |
|
||||
| R-MT-03 | low | SLICE-01 | Network change (default bridge → praxis-net) recreates praxis container (~5-15s downtime); SQLite volume untouched → learner state preserved; documented in compose comments |
|
||||
| R-MT-04 | low | SLICE-02 | Named volumes stable on Docker-in-LXC with nesting=1; nightly pg_dump provides backup; restore drill documented |
|
||||
| R-MT-05 | low | SLICE-01 | Verified: gen_random_uuid() is PG13+ core (no extension). PG16 confirmed |
|
||||
| R-AUTH-02 | low | SLICE-03 | Single operator login is low-frequency; ~80ms argon2id is acceptable on event loop. Not a v0.4 concern |
|
||||
| R-AUTH-03 | low | SLICE-03 | In-memory rate limit lost on restart (single-instance pilot; restarts are rare + operator-initiated). Documented as accepted pilot risk |
|
||||
| R-AUTH-04 | low | SLICE-03 | Cookie secret rotation invalidates all sessions (pilot: acceptable — one operator re-logs in). Documented |
|
||||
| R-AUTH-05 | low | SLICE-03 | No server-side session revocation (D-056 explicit — stateless cookies). Forced-logout = cookie secret rotation. Deferred |
|
||||
| R-DASH-04 | low | SLICE-07 | Nightly job failure → logs + retries next night; on-session-end hook keeps data fresh in the meantime |
|
||||
| R-BOOT-01 | low | SLICE-05 | create-operator.py retries on connection failure (3 attempts, 5s backoff); run after postgres healthcheck passes |
|
||||
| R-BOOT-02 | low | SLICE-05 | Script checks env var presence + exits with clear error if missing. Documented in .env.example |
|
||||
|
||||
**Coverage: 1/1 high risk + 9/9 medium risks + 11/11 low risks addressed. 20/20 total.**
|
||||
|
||||
---
|
||||
|
||||
# Open Questions Deferred to EXECUTE
|
||||
|
||||
1. **v0.3 issued_credentials migration:** The verification endpoint needs to find v0.3 credentials (in SQLite) AND v0.4 credentials (in Postgres). SLICE-04 TASK-04-04 implements a try-Postgres-first-fall-back-to-SQLite approach. Alternative: migrate v0.3 credential rows to Postgres (data migration, not re-signing). The executor should choose the simpler approach — the fallback-to-SQLite is simpler (no data migration) but means the verification endpoint queries two stores. Confirm in SLICE-04/SLICE-06.
|
||||
|
||||
2. **SPA fallback implementation:** Catch-all route (`@app.get("/{path:path}")`) before StaticFiles, or a custom StaticFiles subclass that returns index.html for non-file paths? SLICE-10 TASK-10-01 uses the catch-all route (simpler). The executor should verify the catch-all doesn't shadow StaticFiles asset serving (JS/CSS files). The test in TASK-10-04 verifies this.
|
||||
|
||||
3. **Cohort aggregation `learner_ref` source:** The existing `HARDCODED_LEARNER_ID = "learner-1"` (db/store.py:29). For v0.4 (single learner), k-anonymity will suppress everything (1 < 10). This is expected at pilot scale (R-DASH-01). The aggregation pipeline groups by `learner_ref` so k-anon counts distinct learners. Multi-learner-per-device is deferred. Confirm the dashboard shows "— (suppressed, <10 learners)" for all cells in the single-learner pilot. The executor should seed test data with ≥10 mock learners to verify the non-suppressed path.
|
||||
|
||||
4. **Nightly scheduler timezone:** 03:00 CT (Central Time — Canada pilot is CT?). The scheduler uses `datetime.now()` with a timezone-aware approach. The executor should use `zoneinfo.ZoneInfo("America/Winnipeg")` or similar for CT. Confirm in SLICE-07 TASK-07-03.
|
||||
|
||||
5. **`create-operator.py` `--update` flag:** SLICE-05 TASK-05-01 includes a `--update` flag for force-rehash. The executor should decide if this is a positional arg or a `--update` flag. Keep it simple: `--update` flag.
|
||||
|
||||
6. **Cookie `path` scope:** RESEARCH-v0.4 §open-questions #5 recommends `path="/"` (cookie sent to all routes) so the React `/operator/*` routes can call `/api/operator/me` on mount. SLICE-03 TASK-03-02 uses `path="/"`. Confirm.
|
||||
|
||||
7. **Aggregation hook parallel vs sequential with mastery flow:** SLICE-07 TASK-07-04 chains the aggregation hook in parallel with the mastery flow (both are `asyncio.create_task`). The aggregation only needs the session outcome (available after session end), not the mastery scoring result. However, some metrics (rubric criterion means) need the rubric scores from the mastery flow. The executor should decide: chain the aggregation AFTER mastery completion (sequential) or run in parallel and have the nightly job fill in rubric-dependent metrics. Recommendation: run in parallel + nightly job reconciles rubric-dependent metrics (simpler, freshness ≤ 24h guaranteed by nightly).
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Execution phases | 2 (P1: operator foundation, P2: cohort dashboard) + 1 final (P3: review + ship) |
|
||||
| Slices | 10 (6 in P1, 4 in P2) |
|
||||
| Tasks | 52 (29 in P1, 23 in P2) |
|
||||
| REQ-IDs covered | 8/8 (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) |
|
||||
| Risks addressed | 20/20 (1 high, 9 medium, 11 low) |
|
||||
| Waves | P1: 3 waves (2+3+1 parallel slots), P2: 2 waves (3+1 parallel slots) |
|
||||
| Max parallelism | 3 slices per wave (within 5-agent limit) |
|
||||
| Personas active | 6 (lead-developer, backend-engineer, frontend-engineer, data-engineer, security-engineer, devops-engineer) |
|
||||
| New pip deps | 3 (asyncpg, argon2-cffi, slowapi) |
|
||||
| New npm deps | 1 (react-router-dom@^7) |
|
||||
| Ship targets | v0.1.7 (P1), v0.1.8 (P2), v0.1.9 (P3 = v0.4 milestone release) |
|
||||
+45
-6
@@ -1,9 +1,9 @@
|
||||
# Praxis — Voice-first AI Apprenticeship Platform
|
||||
|
||||
**Milestone:** v0.3 (Mastery scoring + competency rubrics)
|
||||
**Milestone:** v0.4 (Operator tier — cohort dashboard, auth, Postgres)
|
||||
**Status:** phase 0 — specify (active milestone)
|
||||
**Autonomy:** full
|
||||
**Previous milestone:** v0.2 (Proxmox LXC deployment) — complete, tagged v0.1.2, release #377
|
||||
**Previous milestone:** v0.3 (Mastery scoring + competency rubrics + verifiable credentials) — complete, tagged v0.1.5, release #380
|
||||
|
||||
## Vision
|
||||
|
||||
@@ -15,9 +15,9 @@ Praxis is a voice-first, AI-tutored skill platform for learners in resource-cons
|
||||
|
||||
Build a voice-first AI apprenticeship platform where learners engage in spoken role-play scenarios with AI tutors, receive coaching debriefs, and progress via mastery gates — working on low-cost phones over constrained bandwidth.
|
||||
|
||||
## v0.3 Scope (Mastery Scoring + Competency Rubrics)
|
||||
## v0.3 Scope (Mastery Scoring + Competency Rubrics — complete, retained for context)
|
||||
|
||||
v0.3 activates the mastery/assessment layer deferred from v0.1/v0.2 (per D-021, ROADMAP line 53). Learners progress via **mastery gates** — they move on only when they can do the thing across varied scenarios, scored against a competency rubric. v0.3 also introduces the multi-tenant + auth foundation required for the cohort dashboard, and a verifiable-credential issuer so mastery is portable.
|
||||
v0.3 activated the mastery/assessment layer deferred from v0.1/v0.2 (per D-021, ROADMAP line 53). Learners progress via **mastery gates** — they move on only when they can do the thing across varied scenarios, scored against a competency rubric. v0.3 introduced a verifiable-credential issuer so mastery is portable. The operator tier (multi-tenant + auth + cohort dashboard) was deferred to v0.4 per GRILL-v0.3.md Axis 2.
|
||||
|
||||
**v0.3 in scope (activated REQ groups — post-grill):**
|
||||
- **Mastery core (REQ-MAST-01, REQ-MAST-02):** competency rubric per skill; Mastery Score updated after each session, requiring varied-scenario success before a mastery gate opens
|
||||
@@ -42,9 +42,40 @@ v0.3 activates the mastery/assessment layer deferred from v0.1/v0.2 (per D-021,
|
||||
- Voice loop (Deepgram Nova-3 + Cartesia + Pipecat + Ollama Cloud)
|
||||
- v0.1 scenario (`cs_refund_ca_v01.yaml`) + guardrails + debrief
|
||||
|
||||
## v0.2 Scope (Proxmox LXC Deployment — complete)
|
||||
## v0.4 Scope (Operator Tier — Cohort Dashboard + Auth + Postgres)
|
||||
|
||||
v0.2 deploys praxis into a Proxmox LXC container, reusing and adapting the battle-tested deployment toolkit from `~/coreci/scripts/proxmox/`. The v0.1 voice loop becomes deployable infrastructure — a Docker image runs the Python/Pipecat server (serving the React client as static files) inside an LXC container on the operator's Proxmox cluster.
|
||||
v0.4 activates the operator tier deferred from v0.3 per GRILL-v0.3.md Axis 2 (the operator tier was originally v0.8 on this ROADMAP; pulling it into v0.3 created a 2-milestone program disguised as one). The v0.3 mastery/VC/scenario work carries forward unchanged; v0.4 layers the operator surface on top of it.
|
||||
|
||||
**v0.4 in scope (activated REQ groups — 8 REQs total):**
|
||||
- **Operator-tier Postgres (REQ-MT-01):** second Docker service in the existing LXC CT (`docker-compose.yml` adds `postgres`), Postgres 16, persistent volume, internal Docker network only (D-040). Separate from learner-local SQLite (D-007 preserved for learner surface). Stores cohort aggregations, operator accounts, issued credentials, mastery-gate audit log.
|
||||
- **Cohort aggregation pipeline (REQ-MT-02):** on-session-end hook + nightly reconciliation job writes k-anonymized aggregates to Postgres from learner sessions (D-045). No raw learner PII in Postgres.
|
||||
- **Operator auth (REQ-AUTH-01):** session-cookie, argon2id passwords, single `operator` role, login rate-limited (5 attempts/min) (D-041). Cookie: httpOnly, secure, SameSite=Strict, 8h expiry. Protects cohort dashboard + credential issuance.
|
||||
- **Cohort dashboard (REQ-DASH-01):** anonymized cohort view (practice, mastery progression, failure patterns) for training operators — k-anonymity ≥ 10, 7-day aggregation window (D-034). React route under `/operator/*`, served by the same FastAPI server (new `/api/operator/*` prefix), reuses v0.2 StaticFiles (D-044). No separate SPA build — same `client/dist`.
|
||||
- **NFRs (4):** REQ-NFR-AUTH-01 (argon2id + httpOnly + secure + rate-limited), REQ-NFR-MT-01 (Postgres-in-LXC without destabilizing learner service), REQ-NFR-DASH-01 (k-anonymity ≥ 10 enforced — cells < 10 suppressed), REQ-NFR-DASH-02 (freshness ≤ 24h stale).
|
||||
|
||||
**v0.4 out of scope (still deferred):**
|
||||
- REQ-PATH-01 (full multi-path launch) — v0.3 ships Customer Service path only, multi-path later
|
||||
- REQ-DASH-02 (full operator-suite dashboard) — later milestone (v0.4 ships the foundational cohort view only)
|
||||
- REQ-ASSIST-01..03 (Live Assist) — later milestone
|
||||
- REQ-LOWBW-01..03 (WhatsApp/USSD/offline) — later milestone
|
||||
- REQ-VOICE-05/06 (multi-language, persona switching) — later milestone
|
||||
- Learner auth / multi-learner-per-device — operator auth is v0.4; learner auth later
|
||||
- RBAC (multiple operator roles) — single `operator` role in v0.4; RBAC deferred
|
||||
- Third-party credential issuers — v0.9 credentialing milestone
|
||||
- Differential privacy — k-anonymity ≥ 10 is sufficient for v0.4 scale (D-034)
|
||||
|
||||
**Carries forward from v0.3 (already in production):**
|
||||
- Mastery scoring + competency rubrics + IRT dynamic difficulty (v0.3)
|
||||
- Verifiable credential issuer (W3C VC 2.0, Ed25519, SQLite-backed) — v0.4 migrates the issuer key store to operator-tier Postgres + secrets (D-042)
|
||||
- Scenario library + Customer Service 6-week path (v0.3)
|
||||
- Docker-in-LXC deployment (v0.2)
|
||||
- Voice loop (Deepgram Nova-3 + Cartesia + Pipecat + Ollama Cloud) (v0.1)
|
||||
|
||||
## v0.3 Scope (Mastery Scoring + Competency Rubrics — complete)
|
||||
|
||||
v0.3 activated the mastery/assessment layer deferred from v0.1/v0.2 (per D-021). Learners progressed via mastery gates — they moved on only when they could do the thing across varied scenarios, scored against a competency rubric. v0.3 shipped competency rubric engine + Mastery Score + scenario library (≥6 CS scenarios) + dynamic difficulty (IRT) + Customer Service 6-week path + verifiable-credential issuer (W3C VC 2.0, Ed25519, SQLite-backed, formative-tier, public verification). All learner-facing. Released as v0.1.5.
|
||||
|
||||
## v0.2 Scope (Proxmox LXC Deployment — complete)
|
||||
|
||||
**v0.2 in scope:**
|
||||
- Docker image (multi-stage: Node builds `client/dist`, Python runs `server` + serves dist via FastAPI StaticFiles)
|
||||
@@ -146,6 +177,14 @@ v0.2 deploys praxis into a Proxmox LXC container, reusing and adapting the battl
|
||||
| D-047 | Scenario library minimum for v0.3 = **≥6 expert-authored Customer Service scenarios** (one per path week) + **AI-generated variations gated by expert review** | REQ-SCEN-03/04. 6 scenarios give the mastery gate's N=3 varied-scenario condition room (D-032) without being so few that mastery is gameable. AI variations: LLM generates a variation from an expert scenario's schema with `generated_from` backref; expert reviews + approves before it enters the library. | 0.70 | 3 scenarios (mastery gate N=3 = exactly the minimum — no room for failure-retry variety), 12 scenarios (over-scoped for one milestone), no AI variations (loses REQ-SCEN-04) |
|
||||
| D-048 | Mastery gate open action = **advance learner to next path week + issue VC if week-final gate** | When D-032 condition met for a week's scenarios: learner `progress.current_week` advances. If the gate is the final week's gate, a VC is issued (REQ-MAST-03) asserting mastery of the path. Mid-path gates: no VC, just advancement. VCs are path-level, not week-level. | 0.75 | VC per week (credential spam — devalues the credential), no advancement (mastery gate is decorative), manual advancement (violates autonomy) |
|
||||
| D-049 | v0.3 activation of D-009 failure-injection = **NO** — failure-injection stays architecturally present but not provoked in v0.3 | D-009 hook stays in the schema. v0.3 mastery scoring scores *recovery* from naturally-occurring failure branches (the `escalate` branch in cs_refund_ca_v01), not AI-provoked failures. Active failure injection couples to a "failure-recovery coaching" feature that's a later milestone. v0.3 RESEARCH confirms this — no new failure-injection scenarios authored. | 0.80 | Activate failure injection in v0.3 (couples mastery scoring to a new feature — scope creep), remove the hook (breaks forward compat) |
|
||||
| D-050 | Postgres connection from praxis service = **asyncpg pool over Docker internal network, service DNS name `postgres`** | CLARIFY auto-decide (full autonomy). docker-compose defines a `postgres` service on an internal bridge network; the praxis service reaches it via `postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis`. asyncpg is the async Pg driver (matches FastAPI async). No external port exposure. Single connection pool (min 1, max 10 — v0.4 scale). | 0.85 | psycopg2 sync (blocks event loop), external port + host access (security surface), pgbouncer (over-provisioned for v0.4 scale) |
|
||||
| D-051 | VC issuer key migration = **fresh keypair on first v0.4 boot; v0.3 SQLite-issued VCs remain verifiable via archived public key** | CLARIFY auto-decide. v0.3 stored the Ed25519 issuer key in SQLite (`issuer_keys` table). v0.4 generates a fresh keypair in Postgres `issuer_keys` (D-040), marks it `active`, and archives the v0.3 public key as `superseded` (not revoked — old VCs still verify against it). The verification endpoint tries the active key first, falls back to superseded keys for older credentials. No re-issuance of v0.3 VCs. | 0.80 | Re-issue all v0.3 VCs (unnecessary churn, learners hold old credentials), revoke v0.3 key (breaks old VCs), keep SQLite key store (defeats D-031 hybrid) |
|
||||
| D-052 | Operator account bootstrap = **first-run CLI script `scripts/create-operator.py` creates the initial operator from env-provided credentials** | CLARIFY auto-decide. No signup UI (operators are provisioned, not self-serve). Script reads `PRAXIS_BOOTSTRAP_OPERATOR_USER` + `PRAXIS_BOOTSTRAP_OPERATOR_PASS` from `.env.secrets`, hashes the password with argon2id, inserts into `operators` table. Idempotent (no-op if user exists). Subsequent operators added via the same script (run by the operator from the host). RBAC deferred (D-041 single role). | 0.80 | First-run web wizard (UI surface for a one-time action), hardcoded admin/admin (insecure), SQL insert (no password hashing) |
|
||||
| D-053 | Cohort dashboard v0.4 scope = **3 views: practice-volume, mastery-progression, failure-patterns — all k-anonymized ≥10, 7-day rolling windows** | CLARIFY auto-decide. REQ-DASH-01 names "practice, mastery progression, failure patterns" — v0.4 implements exactly those three views, no more. (1) Practice volume: sessions/day per path, anonymized. (2) Mastery progression: % learners at each week, gate-open rate. (3) Failure patterns: top failure modes by frequency, rubric criterion weak-spots. Each view = a `/api/operator/<view>` endpoint returning pre-aggregated rows from `cohort_aggregates`; React renders read-only tables + sparkline charts. No filters beyond path + window (no per-learner drill-down — k-anon). | 0.80 | Full BI dashboard (over-scoped for v0.4), single combined view (loses the three named aspects), per-learner drill-down (violates k-anon) |
|
||||
| D-054 | Aggregation trigger = **async fire-and-forget on session end (non-blocking); nightly reconciliation job at 03:00 CT** | CLARIFY auto-decide. D-045 named the trigger; this clarifies the semantics. On `end_session()`, the server enqueues an aggregation task to an in-process `asyncio.Task` (no Celery/Redis for v0.4 scale) — non-blocking, the session-end response returns immediately. Failures log + the nightly job reconciles (idempotent upsert by window). Nightly job: cron-style `asyncio.create_task` loop, recomputes all 7-day windows. If the service restarts, the in-flight task is lost but nightly reconciliation covers it. | 0.80 | Sync on session-end (adds latency to learner path — violates C-8), Celery+Redis (over-provisioned), CDC streaming (over-engineered) |
|
||||
| D-055 | Postgres backup = **nightly `pg_dump` to a named Docker volume, 7-day retention** | CLARIFY auto-decide. Postgres data lives on a named Docker volume (`pgdata`) inside the LXC CT. Nightly cron job runs `pg_dump praxis | gzip > /backups/praxis-$(date).sql.gz` to a second named volume (`pgbackups`). 7-day retention (rotates oldest). Operator can `pct pull` backups to the PVE host. No streaming replication (single CT, no replica target). This is pilot-tier backup; a later milestone adds off-CT replication. | 0.70 | No backups (data loss risk), WAL streaming to a replica (no replica in v0.4), S3 push (no S3 in LXC pilot) |
|
||||
| D-056 | Auth session store = **signed stateless cookies (HMAC-SHA256), no server-side session table** | CLARIFY auto-decide. D-041 said "session-cookie" — clarifying: the cookie is a self-contained signed token (user_id, issued_at, expiry, HMAC). No `sessions` table in Postgres. Verification = recompute HMAC + check expiry. Logout = client clears cookie (stateless — no server revocation list in v0.4). Rate limit is in-memory (single-instance). This minimizes DB load + simplifies the auth surface. A later milestone adds a revocation list if multi-instance or forced-logout is needed. | 0.75 | Postgres sessions table (DB load + cleanup job), Redis sessions (extra service), JWT with claims (same idea, more complex tooling) |
|
||||
| D-057 | Auth enforcement = **server-side on every `/api/operator/*` request + React route guard for UX, never trust the client** | CLARIFY auto-decide. FastAPI middleware checks the signed cookie on every `/api/operator/*` request; 401 if missing/invalid/expired. React `/operator/*` routes check a `/api/operator/me` call on mount and redirect to `/operator/login` if 401 — this is UX only, the server is the authority. The cohort dashboard reads only k-anonymized aggregates (D-034) so even an auth bypass leaks no PII (defense in depth). VC issuance endpoints (`/api/operator/credentials/*`) are also auth-gated. | 0.85 | Server-only (poor UX — no redirect), React-only (insecure — bypassable), no auth on issuance (credential forgery risk) |
|
||||
|
||||
### Confidence updates from research
|
||||
|
||||
|
||||
+73
-44
@@ -1,64 +1,53 @@
|
||||
# Praxis — Requirements
|
||||
|
||||
**Milestone:** v0.3 (Mastery scoring + competency rubrics + verifiable credentials)
|
||||
**Status:** complete — milestone released as v0.1.5 (13/13 v0.3 REQ covered, 8 deferred to v0.4)
|
||||
**Milestone:** v0.4 (Operator tier — cohort dashboard, auth, Postgres)
|
||||
**Status:** phase 0 — specify (active milestone); v0.3 complete — released as v0.1.5 (13/13 v0.3 REQ covered)
|
||||
|
||||
Formal requirements with REQ-IDs. Scoped to the active milestone unless noted. v0.1/v0.2 requirements (complete) are retained for reference with their final status. Later-milestone requirements are marked `deferred`.
|
||||
Formal requirements with REQ-IDs. Scoped to the active milestone unless noted. v0.1/v0.2/v0.3 requirements (complete) are retained for reference with their final status. Later-milestone requirements are marked `deferred`.
|
||||
|
||||
## v0.3 Active Requirements
|
||||
## v0.4 Active Requirements
|
||||
|
||||
### Mastery & Assessment (v0.3 core)
|
||||
### Operator-Tier Postgres (v0.4 foundation)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-MAST-01 | Competency rubric per skill — a typed rubric model (criteria, 5-level scale, per-skill weights) authored as YAML, mapped to scenarios (D-036). At least one rubric for the Customer Service path in v0.3. | must | P1 | active |
|
||||
| REQ-MAST-02 | Mastery Score updated after each session — computed from rubric scores + varied-scenario-success gate (D-032: N=3 distinct scenarios, rubric mean ≥ 3.5/5.0). Score persisted per learner per path. Mastery gate opens when condition met. | must | P1 | active |
|
||||
| REQ-MAST-03 | Portable verifiable credentials on mastery — W3C VC Data Model 2.0, platform-issued Ed25519 signatures, status-list revocation (D-033). Issued when a mastery gate opens. Verifiable by third parties via a public verification endpoint. | must | P1 | active |
|
||||
| REQ-MAST-04 | No quizzes — assessment built into scenarios | principle | — | accepted |
|
||||
| REQ-MT-01 | Operator-tier Postgres store — cohort aggregations, operator accounts, issued credentials, mastery-gate audit log. Separate from learner-local SQLite (D-007 preserved for learner surface). Migration path: SQLite stays for learner; Postgres added for operator. Postgres 16, persistent volume, internal Docker network only (D-040). | must | P1 | active |
|
||||
| REQ-MT-02 | Cohort aggregation pipeline — on-session-end hook + nightly reconciliation job writes k-anonymized aggregates to Postgres from learner sessions (D-045). No raw learner PII in Postgres. | must | P1 | active |
|
||||
|
||||
### Scenario Engine (v0.3 extensions)
|
||||
### Operator Auth (v0.4)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-SCEN-02 | Dynamic difficulty adjustment based on learner performance — IRT 1PL/Rasch, Bayesian θ update per session (D-035). Difficulty selection picks next scenario targeting ~50% expected success for current θ. | must | P1 | active |
|
||||
| REQ-SCEN-03 | Scenario library tagged by skill, difficulty, failure mode, rubric criteria — YAML directory + `scenarios/index.yaml` manifest (D-036). v0.3 ships ≥6 scenarios for the Customer Service path (one per week minimum). | must | P1 | active |
|
||||
| REQ-SCEN-04 | Expert-authored scenario format with AI-generated variations — extends D-018 YAML DSL with rubric mapping + `generated_from` backref for AI variations. Expert-authored = canonical; AI variations = same schema, flagged, reviewable. | must | P1 | active |
|
||||
| REQ-AUTH-01 | Operator-tier auth — session-based, single `operator` role in v0.4. Operator accounts in Postgres. Login endpoint + session cookie. Protects cohort dashboard + credential issuance. argon2id passwords, httpOnly+secure cookie, SameSite=Strict, 8h expiry, login rate-limited 5/min (D-041). | must | P1 | active |
|
||||
|
||||
### Skill Paths (v0.3)
|
||||
### Cohort Dashboard (v0.4)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-PATH-02 | Path structured as a job — 6-week structure per PRD §6.4, mastery-paced (D-037). Path = `paths/<slug>.yaml` defining weeks, each week = scenarios + a mastery gate. v0.3 ships the Customer Service path fully (6 weeks, ≥1 scenario/week). | must | P1 | active |
|
||||
| REQ-DASH-01 | Anonymized cohort view (practice, mastery progression, failure patterns) for training operators — k-anonymity ≥ 10, 7-day aggregation window (D-034). Operator UI (React) under `/operator/*`, served by same FastAPI server (`/api/operator/*` prefix), reuses v0.2 StaticFiles (D-044). No separate SPA build — same `client/dist`. | must | P2 | active |
|
||||
|
||||
### Employer / Program Dashboard (deferred to v0.4 — per GRILL-v0.3.md Axis 2)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-DASH-01 | Anonymized cohort view (practice, mastery progression, failure patterns) for training operators — k-anonymity ≥ 10, 7-day aggregation window (D-034). Operator UI (React) reads from operator-tier Postgres. Forces multi-tenant + operator auth (D-031). | must | v0.4 | deferred-to-v0.4 |
|
||||
|
||||
### Auth & Multi-Tenancy (deferred to v0.4 — per GRILL-v0.3.md Axis 2)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-AUTH-01 | Operator-tier auth — session-based, single `operator` role in v0.3. Operator accounts in Postgres. Login endpoint + session cookie. Protects cohort dashboard + credential issuance. | must | v0.4 | deferred-to-v0.4 |
|
||||
| REQ-MT-01 | Operator-tier Postgres store — cohort aggregations, operator accounts, issued credentials, mastery-gate audit log. Separate from learner-local SQLite (D-007 preserved for learner surface). Migration path: SQLite stays for learner; Postgres added for operator. | must | v0.4 | deferred-to-v0.4 |
|
||||
| REQ-MT-02 | Cohort aggregation pipeline — scheduled job (or on-session-end hook) writes k-anonymized aggregates to Postgres from learner sessions. No raw learner PII in Postgres. | must | v0.4 | deferred-to-v0.4 |
|
||||
|
||||
## v0.3 Non-Functional Requirements
|
||||
## v0.4 Non-Functional Requirements
|
||||
|
||||
| REQ-ID | Requirement | Target | Phase | Status |
|
||||
|--------|-------------|--------|-------|--------|
|
||||
| REQ-NFR-MAST-01 | Rubric scoring determinism — same session + rubric → same score (no LLM non-determinism in the scoring path; LLM may assist rubric criterion extraction but final score is rule-based) | must | P1 | active |
|
||||
| REQ-NFR-MAST-02 | Mastery gate auditability — every gate-open event recorded with evidence (which 3 scenarios, rubric scores, timestamp) | must | P1 | active |
|
||||
| REQ-NFR-VC-01 | Verifiable credential tamper-evidence — Ed25519 signature, issuer key in operator-tier secrets (not committed), verification endpoint validates signature + status + interop test against external W3C verifier (grill Axis 3) | must | P1 | active |
|
||||
| REQ-NFR-VC-02 | Credential revocation latency — revoked credential must fail verification within 1 sync of the status list (next verify call — no cache) | must | P1 | active |
|
||||
| REQ-NFR-AUTH-01 | Operator auth — passwords hashed (argon2id), session cookie httpOnly + secure, login rate-limited | must | v0.4 | deferred-to-v0.4 |
|
||||
| REQ-NFR-MT-01 | Postgres-in-LXC — operator Postgres runs as a second Docker service in the existing LXC CT (or sidecar) without destabilizing the learner-facing praxis service | must | v0.4 | deferred-to-v0.4 |
|
||||
| REQ-NFR-IRT-01 | IRT θ update latency — < 100ms (in-process, no LLM call) | must | P1 | active |
|
||||
| REQ-NFR-DASH-01 | Cohort dashboard k-anonymity ≥ 10 — any cohort view cell with < 10 learners is suppressed | must | v0.4 | deferred-to-v0.4 |
|
||||
| REQ-NFR-DASH-02 | Cohort dashboard freshness — aggregates ≤ 24h stale | must | v0.4 | deferred-to-v0.4 |
|
||||
| REQ-NFR-AUTH-01 | Operator auth — passwords hashed (argon2id), session cookie httpOnly + secure + SameSite=Strict, login rate-limited (5/min), 8h expiry | must | P1 | active |
|
||||
| REQ-NFR-MT-01 | Postgres-in-LXC — operator Postgres runs as a second Docker service in the existing LXC CT (D-040) without destabilizing the learner-facing praxis service. Internal Docker network only (not exposed to bridge). | must | P1 | active |
|
||||
| REQ-NFR-DASH-01 | Cohort dashboard k-anonymity ≥ 10 — any cohort view cell with < 10 learners is suppressed | must | P2 | active |
|
||||
| REQ-NFR-DASH-02 | Cohort dashboard freshness — aggregates ≤ 24h stale (nightly reconciliation + on-session-end hook per D-045) | must | P2 | active |
|
||||
|
||||
## Constraints (binding — carry forward from v0.1/v0.2)
|
||||
## v0.4 Out of Scope (still deferred)
|
||||
|
||||
- REQ-PATH-01 (full multi-path launch) — v0.3 ships Customer Service path only, multi-path later
|
||||
- REQ-DASH-02 (full operator-suite dashboard) — later milestone (v0.4 ships the foundational cohort view only)
|
||||
- REQ-ASSIST-01..03 (Live Assist) — later milestone
|
||||
- REQ-LOWBW-01..03 (WhatsApp/USSD/offline) — later milestone
|
||||
- REQ-VOICE-05/06 (multi-language, persona switching) — later milestone
|
||||
- Learner auth / multi-learner-per-device — operator auth is v0.4; learner auth later
|
||||
- RBAC (multiple operator roles) — single `operator` role in v0.4; RBAC deferred
|
||||
- Third-party credential issuers (university/agency) — v0.9 credentialing milestone
|
||||
- Differential privacy — k-anonymity ≥ 10 is sufficient for v0.4 scale (D-034)
|
||||
|
||||
## Constraints (binding — carry forward from v0.1/v0.2/v0.3)
|
||||
|
||||
- C-1 Voice is primary interface; text is fallback only
|
||||
- C-2 Must work on $100 Android phone over 2G/3G (relaxed for v0.1 Canada pilot)
|
||||
@@ -67,14 +56,54 @@ Formal requirements with REQ-IDs. Scoped to the active milestone unless noted. v
|
||||
- C-5 Open-weights LLM via Ollama catalog — `gemma4:cloud` + `deepseek-v4-flash:cloud`
|
||||
- C-6 Domain safety guardrails + HITL + disclaimers for safety-sensitive domains
|
||||
- C-7 Scenarios authored by domain experts + learning designers; AI generates variations only
|
||||
- C-8 Latency budget < 600ms end-to-end (ASR → LLM → TTS) — mastery scoring must not be on the voice path
|
||||
- C-8 Latency budget < 600ms end-to-end (ASR → LLM → TTS) — mastery scoring + cohort aggregation must not be on the voice path
|
||||
|
||||
---
|
||||
|
||||
## v0.3 Requirements (complete — released as v0.1.5, retained for reference)
|
||||
|
||||
### Mastery & Assessment (v0.3 core)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-MAST-01 | Competency rubric per skill — a typed rubric model (criteria, 5-level scale, per-skill weights) authored as YAML, mapped to scenarios (D-036). At least one rubric for the Customer Service path in v0.3. | must | P1 | complete |
|
||||
| REQ-MAST-02 | Mastery Score updated after each session — computed from rubric scores + varied-scenario-success gate (D-032: N=3 distinct scenarios, rubric mean ≥ 3.5/5.0). Score persisted per learner per path. Mastery gate opens when condition met. | must | P1 | complete |
|
||||
| REQ-MAST-03 | Portable verifiable credentials on mastery — W3C VC Data Model 2.0, platform-issued Ed25519 signatures, status-list revocation (D-033). Issued when a mastery gate opens. Verifiable by third parties via a public verification endpoint. | must | P1 | complete |
|
||||
| REQ-MAST-04 | No quizzes — assessment built into scenarios | principle | — | accepted |
|
||||
|
||||
### Scenario Engine (v0.3 extensions)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-SCEN-02 | Dynamic difficulty adjustment based on learner performance — IRT 1PL/Rasch, Bayesian θ update per session (D-035). Difficulty selection picks next scenario targeting ~50% expected success for current θ. | must | P1 | complete |
|
||||
| REQ-SCEN-03 | Scenario library tagged by skill, difficulty, failure mode, rubric criteria — YAML directory + `scenarios/index.yaml` manifest (D-036). v0.3 ships ≥6 scenarios for the Customer Service path (one per week minimum). | must | P1 | complete |
|
||||
| REQ-SCEN-04 | Expert-authored scenario format with AI-generated variations — extends D-018 YAML DSL with rubric mapping + `generated_from` backref for AI variations. Expert-authored = canonical; AI variations = same schema, flagged, reviewable. | must | P1 | complete |
|
||||
|
||||
### Skill Paths (v0.3)
|
||||
|
||||
| REQ-ID | Requirement | Priority | Phase | Status |
|
||||
|--------|-------------|----------|-------|--------|
|
||||
| REQ-PATH-02 | Path structured as a job — 6-week structure per PRD §6.4, mastery-paced (D-037). Path = `paths/<slug>.yaml` defining weeks, each week = scenarios + a mastery gate. v0.3 ships the Customer Service path fully (6 weeks, ≥1 scenario/week). | must | P1 | complete |
|
||||
|
||||
## v0.3 Non-Functional Requirements (complete)
|
||||
|
||||
| REQ-ID | Requirement | Target | Phase | Status |
|
||||
|--------|-------------|--------|-------|--------|
|
||||
| REQ-NFR-MAST-01 | Rubric scoring determinism — same session + rubric → same score (no LLM non-determinism in the scoring path; LLM may assist rubric criterion extraction but final score is rule-based) | must | P1 | complete |
|
||||
| REQ-NFR-MAST-02 | Mastery gate auditability — every gate-open event recorded with evidence (which 3 scenarios, rubric scores, timestamp) | must | P1 | complete |
|
||||
| REQ-NFR-VC-01 | Verifiable credential tamper-evidence — Ed25519 signature, issuer key in operator-tier secrets (not committed), verification endpoint validates signature + status + interop test against external W3C verifier (grill Axis 3) | must | P1 | complete |
|
||||
| REQ-NFR-VC-02 | Credential revocation latency — revoked credential must fail verification within 1 sync of the status list (next verify call — no cache) | must | P1 | complete |
|
||||
| REQ-NFR-IRT-01 | IRT θ update latency — < 100ms (in-process, no LLM call) | must | P1 | complete |
|
||||
|
||||
## v0.3 Out of Scope (now activated in v0.4)
|
||||
|
||||
- ~~REQ-DASH-01 (cohort dashboard) — deferred to v0.4~~ → **activated in v0.4**
|
||||
- ~~REQ-AUTH-01, REQ-MT-01, REQ-MT-02 (operator auth + Postgres) — deferred to v0.4~~ → **activated in v0.4**
|
||||
- ~~REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-NFR-AUTH-01, REQ-NFR-MT-01 — deferred to v0.4~~ → **activated in v0.4**
|
||||
|
||||
## v0.3 Out of Scope (still deferred)
|
||||
|
||||
- REQ-PATH-01 (full multi-path launch) — v0.3 ships Customer Service path only
|
||||
- REQ-DASH-01 (cohort dashboard) — **deferred to v0.4** per GRILL-v0.3.md Axis 2 (was v0.8 on original ROADMAP)
|
||||
- REQ-AUTH-01, REQ-MT-01, REQ-MT-02 (operator auth + Postgres) — **deferred to v0.4** (operator tier)
|
||||
- REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-NFR-AUTH-01, REQ-NFR-MT-01 — **deferred to v0.4**
|
||||
- REQ-DASH-02 (full operator-suite dashboard) — later milestone
|
||||
- REQ-ASSIST-01..03 (Live Assist) — later milestone
|
||||
- REQ-LOWBW-01..03 (WhatsApp/USSD/offline) — later milestone
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
# Praxis — Research Findings (v0.4 Operator Tier — Cohort Dashboard + Auth + Postgres)
|
||||
|
||||
> **Phase:** v0.4 research (operator tier)
|
||||
> **Branch:** `phase/00-pre-execution`
|
||||
> **Status:** research complete — pending orchestrator review
|
||||
> **Date:** 2026-08-04
|
||||
> **Method:** Codebase inspection (`server/`, `db/`, `docker-compose.yml`, `client/`, `pyproject.toml`, `client/package.json`), v0.3 research appendices (`.ciagent/RESEARCH.md`, `docs/RESEARCH-operator-postgres-auth.md`, `.ciagent/RESEARCH-vc.md`, `.ciagent/RESEARCH-v0.3-anonymization-irt-scenarios.md`), D-050..D-057 decision text, OWASP Password Storage Cheat Sheet (fetched 2026-08-04), Postgres 16 documentation, asyncpg/Starlette/argon2-cffi ecosystem knowledge. Web-verified where possible; domain-knowledge claims carry explicit confidence scores.
|
||||
|
||||
This document grounds the v0.4 operator-tier architecture in ecosystem evidence. It covers all 7 research domains and concludes with a consolidated risks table and a v0.3-assumption audit (which anticipatory assumptions were confirmed, which were overturned by D-050..D-057).
|
||||
|
||||
---
|
||||
|
||||
## Summary of Findings (Executive 1-Pager)
|
||||
|
||||
1. **Postgres 16-slim is the correct second service.** (0.90) `postgres:16-slim` (Debian-slim, glibc) matches the existing praxis Dockerfile rationale. Named volume `pgdata`, explicit `praxis-net` bridge network (no published port), `pg_isready` healthcheck, `depends_on: service_healthy`. PG16 ships `gen_random_uuid()` in core (no extension). asyncpg `create_pool(min_size=1, max_size=10)` on `app.state.pg_pool` via lifespan, `command_timeout=10`. CT memory bump 4GB→6GB (confirmed by v0.3 anticipatory section; D-050 fixes pool min at 1, not 2 — lower idle cost). Nightly `pg_dump -Fc` to `pgbackups` volume, `%u` 7-file rolling retention (D-055).
|
||||
|
||||
2. **Operator auth = signed stateless cookies (HMAC-SHA256 via Starlette SessionMiddleware) + argon2id + in-memory rate limit.** (0.88) D-056 overrides the v0.3 anticipatory "SessionMiddleware (itsdangerous-signed)" framing slightly — the architecture uses Starlette's SessionMiddleware which *is* itsdangerous-signed under the hood, so the v0.3 description holds. argon2-cffi `PasswordHasher` defaults (time_cost=3, memory_cost=64MiB, parallelism=4) **exceed** OWASP minimums (19MiB/t=2/p=1). `check_needs_rehash` for param upgrades. Login rate limit = in-memory `dict[ip, (count, window_start)]` dependency (D-057 + D-041); slowapi is the idiomatic FastAPI choice but a hand-rolled counter is simpler for single-instance and avoids a dep — **recommend slowapi for idiomaticity** (0.70) with the hand-rolled counter as the documented fallback.
|
||||
|
||||
3. **Secure cookie + no-TLS pilot tension → config-driven `Secure` flag, document the pilot risk.** (0.75) D-030 (no Traefik/TLS for pilot) conflicts with the `Secure` cookie attribute (requires HTTPS). Resolution: **(a) config-driven** — `PRAXIS_COOKIE_SECURE` env var (default `true`); set `false` only for the HTTP pilot, with a logged WARNING + a grill-tracked R-AUTH-01 mitigation. This is the safest minimal path: no new infra (Caddy/nginx would be a 3rd service), the flag flips automatically when TLS is added later. Reject option (c) minimal TLS via Caddy — adds a 3rd Docker service, breaks D-030's "direct bridge IP access" pilot stance, and TLS certs need a CA (self-signed → browser warnings worse than HTTP for a pilot). **The cohort dashboard reads only k-anonymized aggregates (D-034), so even a cookie sniffed over HTTP leaks no PII — defense in depth.**
|
||||
|
||||
4. **k-anonymity ≥ 10 enforced at write time via cell suppression in the aggregation SQL.** (0.85) `COUNT(DISTINCT learner_ref) >= 10` guard; cells below threshold are written with `cell_suppressed = TRUE` and `value = NULL`. 7-day rolling window computed on read via window functions over `cohort_aggregates` rows (incremental upsert by `(path, metric, window_start)`). No materialized view needed at v0.4 scale (<100 learners) — the nightly job recomputes all 7-day windows. Differencing attacks blocked by limiting to pre-defined 2-D views (path × week, path × outcome) per the v0.3 anonymization research.
|
||||
|
||||
5. **Aggregation trigger = async fire-and-forget `asyncio.Task` on session end + nightly reconciliation at 03:00 CT.** (0.82) D-054 confirms. The existing `SessionRecorder.end()` already schedules mastery flow via `asyncio.create_task` (line 143 of `session_recorder.py`) — the v0.4 aggregation hook follows the same pattern, chained after the mastery flow. Failures log + nightly job reconciles (idempotent upsert by window). Nightly job = in-process `asyncio.create_task` loop with `asyncio.sleep` until 03:00; no APScheduler (over-engineered for one cron job). If the service restarts, the in-flight task is lost but nightly reconciliation covers it.
|
||||
|
||||
6. **3 dashboard views = practice-volume, mastery-progression, failure-patterns — all k-anonymized, 7-day windows.** (0.82) D-053. Practice volume: sessions/day per path. Mastery progression: % learners at each week, gate-open rate. Failure patterns: top failure modes by frequency + rubric criterion weak-spots. Each view = a `/api/operator/<view>` endpoint returning pre-aggregated rows from `cohort_aggregates`; React renders read-only tables + sparkline charts. **No chart library is in `client/package.json`** — only react, react-dom, pipecat client SDK. Recommend **uPlot** (~40KB, sparkline-native, no React dependency) or inline SVG sparklines (~50 LOC, zero deps). Inline SVG is the v0.4 recommendation (zero deps, k-anon tables are small).
|
||||
|
||||
7. **VC issuer key migration = fresh keypair in Postgres `issuer_keys`; v0.3 SQLite public key archived as `superseded`.** (0.85) D-051. The existing `server/vc/issuer_keys.py` already implements the `active`/`superseded` lifecycle + `get_public_key_for_verification(key_id)`. v0.4 splits the issuer key store: Postgres `issuer_keys` (new active key) + archived v0.3 public key (status `superseded`). The verification endpoint (`server/vc/verification.py:verify_credential`) extracts `key_id` from the proof's `verificationMethod` and looks up the public key — the fallback to superseded keys is already implicit in `get_public_key_row(key_id)` (it queries by id, not by status). **No re-issuance of v0.3 VCs.** Private key encrypted at rest via `nacl.SecretBox` with `PRAXIS_VC_ISSUER_KEY` root key (existing pattern in `issuer_keys.py`).
|
||||
|
||||
8. **Operator account bootstrap = `scripts/create-operator.py` CLI, argon2id hash, idempotent insert.** (0.85) D-052. Reads `PRAXIS_BOOTSTRAP_OPERATOR_USER` + `PRAXIS_BOOTSTRAP_OPERATOR_PASS` from env, hashes with argon2-cffi, inserts into Postgres `operators` table with `ON CONFLICT (username) DO NOTHING`. Run from the host via `docker compose exec praxis python scripts/create-operator.py` or directly in the CT. No signup UI.
|
||||
|
||||
9. **Persona roster for v0.4: 6 active (lead-developer, backend-engineer, frontend-engineer REACTIVATED, data-engineer REACTIVATED/EXPANDED, security-engineer RETAINED, devops-engineer REACTIVATED for Postgres-in-LXC).** (0.90) v0.3 deactivated frontend-engineer + devops; v0.4 reactivates both. security-engineer retained (auth + crypto migration). data-engineer expands to Postgres schema + aggregation SQL. devops-engineer owns the docker-compose Postgres service + CT memory bump + backup cron + `create-operator.py` bootstrap script.
|
||||
|
||||
---
|
||||
|
||||
## Domain 1: Postgres 16 in Docker-in-LXC (D-040, D-050, D-055, REQ-NFR-MT-01)
|
||||
|
||||
### 1.1 Postgres 16-slim resource footprint inside an LXC CT
|
||||
|
||||
**Finding (0.88):** `postgres:16-slim` is Debian-slim-based (glibc), matching the praxis Dockerfile's rationale (avoiding Alpine musl locale issues with `pg_*` clients). The slim image is ~80MB compressed / ~200MB unpacked. Postgres 16 idle memory footprint with default `shared_buffers=128MB` is ~150-250MB RSS. With a small pilot workload (<100 learners, low-frequency operator queries), total Postgres RSS stays under ~400MB.
|
||||
|
||||
**Resource contention with the learner-facing praxis service:** The praxis container (uvicorn + Pipecat + voice loop) uses ~500MB at runtime (per v0.2 RESEARCH.md Q9). Postgres adds ~400MB. Docker daemon ~200MB. CT base ~200MB. Total ~1.3GB runtime, leaving ~4.7GB headroom on a 6GB CT. **The voice loop is latency-sensitive (C-8: <600ms); Postgres queries are off the voice path** (operator endpoints + nightly aggregation only). The risk is disk I/O contention during the nightly `pg_dump` + aggregation job — mitigated by scheduling at 03:00 CT (low learner activity) and the aggregation job being incremental upserts (not a full table scan).
|
||||
|
||||
**CT memory bump:** v0.3 anticipatory section said 4GB→6GB. D-050 fixes the asyncpg pool at min_size=1, max_size=10 (lower than the v0.3 anticipatory min_size=2). 6GB is confirmed sufficient. **Confidence 0.85** — the 6GB figure has ~50% margin.
|
||||
|
||||
### 1.2 docker-compose networking: internal bridge, service DNS, no external port
|
||||
|
||||
**Finding (0.92):** The current `docker-compose.yml` (verified — 49 lines, single `praxis` service, no explicit network → compose default bridge). v0.4 adds:
|
||||
|
||||
- An explicit named bridge network `praxis-net` (driver: bridge). **Not `internal: true`** — the postgres container doesn't need egress, but `internal: true` would also block DNS resolution from the praxis service. The simpler robust choice: named network, no `ports:` on postgres, no `internal: true`. The v0.3 research (`docs/RESEARCH-operator-postgres-auth.md` §1) confirmed this.
|
||||
- The `praxis` service joins `praxis-net` and gains `depends_on: { postgres: { condition: service_healthy } }`.
|
||||
- The `postgres` service joins `praxis-net`, no `ports:` mapping (not exposed to the LXC host bridge).
|
||||
- Service DNS: the praxis service reaches postgres via the service name `postgres` (Docker Compose internal DNS). DSN: `postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis` (D-050).
|
||||
|
||||
**Migration note:** Adding an explicit network to the existing `praxis` service means compose recreates the praxis container on `up` (the default bridge → named network is a recreate trigger). Plan a ~5-15s downtime window. The SQLite volume (`praxis-data`) is untouched → learner state preserved. **Confidence 0.90** — standard Docker Compose behavior.
|
||||
|
||||
### 1.3 Persistent volume strategy
|
||||
|
||||
**Finding (0.90):** Named volume `pgdata` (driver: local) on the LXC rootfs. **Never bind-mount `/var/lib/postgresql/data` to the CT filesystem** — Postgres requires `chown 999` and a specific directory layout; named volumes handle this. Set `PGDATA=/var/lib/postgresql/data/pgdata` to pin the subdirectory (survives image upgrades). A separate mount is not warranted for the pilot — the LXC rootfs (16GB) has headroom, and a named volume keeps the data with the compose stack.
|
||||
|
||||
**Backups:** second named volume `pgbackups` (driver: local). Nightly `pg_dump -Fc` (custom compressed format) → `/backups/praxis-$(date +%u).sql.gz` (D-055). `%u` = day-of-week 1-7 → rolling 7-file retention with zero cleanup logic. Operator can `pct pull` backups to the PVE host for off-CT safety. **Confidence 0.85** — `pg_dump -Fc` is the documented Postgres backup format; `%u` retention is a standard cron pattern.
|
||||
|
||||
### 1.4 asyncpg connection pooling
|
||||
|
||||
**Finding (0.88):** asyncpg `create_pool(min_size=1, max_size=10, command_timeout=10)` on `app.state.pg_pool` via FastAPI `lifespan` context manager. D-050 fixes min_size=1 (lower than the v0.3 anticipatory min_size=2 — reduces idle connection overhead). Pool created on startup, closed on shutdown. Operator endpoints are low-frequency (cohort dashboard, VC issuance); max_size=10 is generous for v0.4 single-instance. The `PraxisStore` (aiosqlite) keeps its current per-call connect pattern — **pools are independent and must not be shared** (different backends, different lifecycles). `command_timeout=10` prevents a slow operator query from blocking the event loop.
|
||||
|
||||
**Statement cache:** asyncpg caches prepared statements per connection by default. With a small schema (5 tables) and parameterized queries, the cache is small and effective. No explicit `statement_cache_size` config needed at v0.4 scale.
|
||||
|
||||
**Pip:** `asyncpg>=0.29` (new dep — confirmed not in `pyproject.toml`).
|
||||
|
||||
### 1.5 pg_dump backup strategy (D-055)
|
||||
|
||||
**Finding (0.85):** Cron job inside the praxis container (or a sidecar one-shot) runs nightly:
|
||||
```
|
||||
pg_dump -U praxis -Fc praxis | gzip > /backups/praxis-$(date +%u).sql.gz
|
||||
```
|
||||
Wait — `pg_dump -Fc` already produces a compressed custom format; piping through gzip is redundant. The correct command is:
|
||||
```
|
||||
pg_dump -U praxis -Fc praxis -f /backups/praxis-$(date +%u).dump
|
||||
```
|
||||
This produces a compressed custom-format dump that `pg_restore` can selectively restore. **Drill:** `pg_restore --clean --if-exists /backups/praxis_3.dump` (drop+recreate objects, safe against partial DB). Never restore into the live DB without stopping the praxis service first.
|
||||
|
||||
The backup job runs via the in-process asyncio scheduler (same as the aggregation reconciliation job) OR via a host-side cron that `docker compose exec`s the pg_dump. The in-process approach is simpler (one scheduler for both nightly jobs) but couples backup to the praxis service lifecycle. **Recommend host-side cron** → `docker compose exec -T postgres pg_dump ...` so backups run even if praxis is down. **Confidence 0.80** — host-side cron decouples backup from app uptime.
|
||||
|
||||
### 1.6 Healthcheck for the Postgres service
|
||||
|
||||
**Finding (0.95):** `pg_isready -U praxis -d praxis` every 10s, 5 retries, 5s timeout. `depends_on: { postgres: { condition: service_healthy } }` on the praxis service. **Caveat:** `pg_isready` returns healthy before the DB is fully ready for migration load — the praxis app must still retry the first migration attempt (the pg_migrate runner should be idempotent + retry on connection failure).
|
||||
|
||||
### 1.7 Postgres 16 features used
|
||||
|
||||
**Finding (0.90):**
|
||||
- **`gen_random_uuid()`** — built into PG13+ core (no `pgcrypto` extension needed). Used as `DEFAULT gen_random_uuid()` for `operators.id`, `mastery_gate_events.id`, etc.
|
||||
- **Partitioning for `cohort_aggregates`** — PG16 supports declarative partitioning by `RANGE (window_start)`. Weekly partitions (one per ISO week) keep the table small per partition + enable fast windowed queries. **However, at v0.4 scale (<100 learners, ~weeks of data), partitioning is premature optimization.** The v0.3 anticipatory section mentioned "weekly partitions" but D-053 clarifies the dashboard reads pre-aggregated rows — the `cohort_aggregates` table is small (one row per `(path, metric, window_start)`). **Recommendation: ship a plain table with an index on `(path, window_start)`; add partitioning only if the table exceeds ~100K rows** (post-pilot). **This overturns the v0.3 anticipatory "weekly partitions" assumption** — see §8 v0.3 audit.
|
||||
|
||||
---
|
||||
|
||||
## Domain 2: Operator Auth — argon2id + Signed Cookies (D-041, D-056, D-057, REQ-NFR-AUTH-01)
|
||||
|
||||
### 2.1 argon2id parameters for v0.4 scale
|
||||
|
||||
**Finding (0.92):** OWASP Password Storage Cheat Sheet (fetched 2026-08-04) recommends Argon2id with one of these minimum configurations:
|
||||
- m=47104 (46 MiB), t=1, p=1
|
||||
- m=19456 (19 MiB), t=2, p=1
|
||||
- m=12288 (12 MiB), t=3, p=1
|
||||
- m=9216 (9 MiB), t=4, p=1
|
||||
- m=7168 (7 MiB), t=5, p=1
|
||||
|
||||
The `argon2-cffi` `PasswordHasher()` defaults are `time_cost=3, memory_cost=64MiB, parallelism=4` — **these exceed all OWASP minimums** (64MiB > 46MiB, t=3 matches the 12MiB/t=3 row, p=4 > p=1). The defaults are safe for a 6GB CT (64MiB per hash operation is trivial; login is low-frequency — one operator). **Recommendation: keep `PasswordHasher()` defaults.** Use `check_needs_rehash(stored_hash)` on login to rehash if params are bumped in the future. Benchmark login latency — if >1s, drop to `memory_cost=32MiB` (still exceeds OWASP minimums). **Confidence 0.92** — OWASP is the authoritative source; argon2-cffi defaults are documented.
|
||||
|
||||
### 2.2 Python argon2 library: argon2-cffi vs. passlib
|
||||
|
||||
**Finding (0.90):** **argon2-cffi** is the idiomatic choice for FastAPI. It's a thin CFFI wrapper around the reference Argon2 implementation, exposes `PasswordHasher` with argon2id as the default, and is actively maintained. `passlib` is a broader abstraction layer (supports multiple hash algorithms) but has had maintenance concerns (the 1.2 series hasn't seen a release in years; the 1.3 rewrite stalled). argon2-cffi is simpler, more focused, and the v0.3 research already chose it. **Pip: `argon2-cffi>=23.1`.** The v0.3 anticipatory architecture already lists `argon2-cffi` — confirmed.
|
||||
|
||||
### 2.3 Signed stateless cookies (HMAC-SHA256)
|
||||
|
||||
**Finding (0.88):** D-056 specifies "signed stateless cookies (HMAC-SHA256), no server-side session table." Starlette's `SessionMiddleware` uses `itsdangerous` under the hood, which signs the cookie with HMAC-SHA256 (via `TimestampedSigner`/`JSONWebSignature` depending on config). **The v0.3 anticipatory "SessionMiddleware (itsdangerous-signed)" framing is correct** — D-056's "HMAC-SHA256" is the underlying mechanism. The cookie is self-contained: `{operator_id, issued_at}` + HMAC signature. Verification = recompute HMAC + check expiry (8h). No `sessions` table in Postgres (D-056 explicit). Logout = client clears cookie (stateless — no server revocation list in v0.4).
|
||||
|
||||
**Key management:** `SECRET_KEY` from env (`PRAXIS_COOKIE_SECRET`, ≥32 bytes random). Rotation = change the key (invalidates all sessions — acceptable for a pilot). **Confidence 0.88** — Starlette SessionMiddleware is the documented FastAPI session pattern.
|
||||
|
||||
**Cookie attributes:**
|
||||
- `session_cookie`: `"praxis_op"` (distinct from any future learner cookie)
|
||||
- `max_age`: `28800` (8h, per D-041)
|
||||
- `httponly`: `True` (middleware default; verify)
|
||||
- `samesite`: `"strict"` (D-041 — CSRF defense-in-depth)
|
||||
- `secure`: **config-driven** (see §2.4 below)
|
||||
- `path`: `/` (or scope to `/api/operator` — cleaner, but the React `/operator/*` routes also need the cookie for the `/api/operator/me` call on mount; use `/`)
|
||||
|
||||
### 2.4 Secure cookie + no-TLS pilot tension (R-AUTH-01 resolution)
|
||||
|
||||
**Finding (0.75):** D-030 (no Traefik/TLS for pilot) conflicts with the `Secure` cookie attribute (browsers reject `Secure` cookies over HTTP, or rather: they don't send them over HTTP). The three options:
|
||||
|
||||
**(a) Config-driven `Secure` flag (RECOMMENDED):**
|
||||
- `PRAXIS_COOKIE_SECURE` env var (default `true`).
|
||||
- For the HTTP pilot: set `PRAXIS_COOKIE_SECURE=false`, log a WARNING, document the risk in GRILL-v0.4.md.
|
||||
- When TLS is added later (post-v0.4), flip the env var → cookies become Secure automatically.
|
||||
- **Defense in depth:** the cohort dashboard reads only k-anonymized aggregates (D-034) → even a cookie sniffed over HTTP leaks no PII. The VC issuance endpoints are auth-gated but the credentials themselves are public (verification endpoint is unauthenticated per D-043).
|
||||
|
||||
**(b) Accept the pilot risk + document:**
|
||||
- Same as (a) but without the config flag — hardcode `secure=False` for v0.4.
|
||||
- **Rejected:** inflexible — requires code change when TLS arrives.
|
||||
|
||||
**(c) Minimal TLS via Caddy/nginx sidecar:**
|
||||
- Add a 3rd Docker service (Caddy reverse proxy) with a self-signed cert.
|
||||
- **Rejected:** breaks D-030's "direct bridge IP access" pilot stance, adds a 3rd service + cert management, self-signed certs trigger browser warnings (worse UX than plain HTTP for a pilot). Defer to a later milestone.
|
||||
|
||||
**Verdict: option (a).** The config-driven flag is the safest minimal path — no new infra, automatic upgrade when TLS arrives, explicit risk documentation. **Confidence 0.75** — the resolution is sound but the pilot HTTP risk is real; the grill must sign off.
|
||||
|
||||
### 2.5 Login rate limiting (5 attempts/min)
|
||||
|
||||
**Finding (0.78):** D-041 specifies 5 attempts/min. Two implementations:
|
||||
|
||||
1. **slowapi** (`slowapi>=0.1`) — idiomatic FastAPI rate limiter. `@limiter.limit("5/minute")` on the login route. In-memory backend (per-process). **Caveat:** breaks if >1 praxis process (not a v0.4 concern — single uvicorn). Confidence 0.70 — young lib, but works.
|
||||
|
||||
2. **In-memory counter** — `dict[remote_ip, (count, window_start)]` in a FastAPI dependency. Zero deps, trivially auditable. For a single operator login endpoint, this is sufficient. Confidence 0.80 for the pilot.
|
||||
|
||||
**Recommendation: slowapi** for idiomaticity (decorator pattern, well-documented). The hand-rolled counter is the documented fallback if slowapi causes issues. Threshold: 5 failed attempts/minute/IP → 429 + `Retry-After` header. **Pip: `slowapi>=0.1`.** Rate limit is on the *login* route only (not the auth-gated routes — those check the cookie).
|
||||
|
||||
### 2.6 Session expiry (8h) + renewal strategy
|
||||
|
||||
**Finding (0.85):** `max_age=28800` (8h) on the cookie. No sliding renewal in v0.4 — the cookie expires 8h after issuance. The operator re-logs in after 8h. **Renewal is deferred** — a later milestone could implement sliding renewal (re-issue on activity) if 8h is too short for operator workflows. For v0.4 (single operator, low-frequency dashboard reads), 8h fixed is sufficient. **Confidence 0.85.**
|
||||
|
||||
---
|
||||
|
||||
## Domain 3: Cohort Aggregation — k-anonymity + 7-day windows (D-034, D-045, D-053, D-054)
|
||||
|
||||
### 3.1 k-anonymity ≥ 10 enforcement at write time
|
||||
|
||||
**Finding (0.85):** D-034 + REQ-NFR-DASH-01. The aggregation SQL enforces k≥10 via cell suppression at write time (not read time — auditable). Pattern:
|
||||
|
||||
```sql
|
||||
-- Pseudo-SQL — shape only
|
||||
INSERT INTO cohort_aggregates (path, metric, window_start, window_end, value, cell_count, cell_suppressed)
|
||||
SELECT
|
||||
path,
|
||||
metric,
|
||||
window_start,
|
||||
window_end,
|
||||
CASE WHEN COUNT(DISTINCT learner_ref) >= 10 THEN aggregate_value ELSE NULL END,
|
||||
COUNT(DISTINCT learner_ref),
|
||||
CASE WHEN COUNT(DISTINCT learner_ref) < 10 THEN TRUE ELSE FALSE END
|
||||
FROM staging_sessions
|
||||
GROUP BY path, metric, window_start, window_end
|
||||
ON CONFLICT (path, metric, window_start) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
cell_count = excluded.cell_count,
|
||||
cell_suppressed = excluded.cell_suppressed,
|
||||
updated_at = now();
|
||||
```
|
||||
|
||||
- `cell_suppressed = TRUE` + `value = NULL` for cells < 10 learners.
|
||||
- The dashboard renders suppressed cells as "— (suppressed, <10 learners)" — transparent to the operator.
|
||||
- **Differencing attacks:** limited to pre-defined 2-D views (path × week, path × outcome) per the v0.3 anonymization research. No arbitrary filters (no per-learner drill-down — D-053 explicit).
|
||||
|
||||
**Confidence 0.85** — k-anonymity via `COUNT(DISTINCT) >= K` is the textbook suppression pattern.
|
||||
|
||||
### 3.2 7-day rolling window aggregation SQL
|
||||
|
||||
**Finding (0.82):** The `cohort_aggregates` table stores rows keyed by `(path, metric, window_start)`. Each row represents one 7-day window starting at `window_start`. The aggregation job (on-session-end hook + nightly) upserts by `(path, metric, window_start)` — idempotent. The 7-day window is a rolling construct: the nightly job recomputes the current window (the one containing "today") + the previous window (for continuity). On read, the dashboard queries `WHERE window_start >= now()::date - interval '7 days'` for the current view.
|
||||
|
||||
**Materialized view vs. incremental upsert:** Incremental upsert wins at v0.4 scale. A materialized view requires `REFRESH MATERIALIZED VIEW` (locks the view, slow at scale) and doesn't support partial refresh. Incremental upsert is cheap (one row per `(path, metric, window_start)`) + idempotent + supports the on-session-end hook pattern. **Confidence 0.82.**
|
||||
|
||||
### 3.3 On-session-end hook — async fire-and-forget (D-054)
|
||||
|
||||
**Finding (0.85):** D-054 confirms. The existing `SessionRecorder.end()` (line 142-145 of `session_recorder.py`) already schedules the mastery flow via `asyncio.create_task(self._run_mastery_flow_guarded(mastery_deps))`. The v0.4 aggregation hook follows the same pattern — chained after the mastery flow completes (or in parallel, since the aggregation only needs the session outcome + rubric scores, which the mastery flow produces). The hook:
|
||||
|
||||
1. Reads the session outcome + rubric scores from the mastery flow result (or directly from the SQLite `mastery_gate_events` table).
|
||||
2. Computes the k-anonymized aggregate for the affected `(path, metric, window_start)` bin.
|
||||
3. Upserts to Postgres `cohort_aggregates` (idempotent).
|
||||
4. Failures log + the nightly job reconciles.
|
||||
|
||||
**Lifecycle:** `asyncio.Task` — non-blocking, the session-end response returns immediately. If the service restarts, the in-flight task is lost but nightly reconciliation covers it (D-054 explicit). **Confidence 0.85** — the pattern is already proven in the codebase.
|
||||
|
||||
### 3.4 Nightly reconciliation job
|
||||
|
||||
**Finding (0.80):** D-054 specifies 03:00 CT. Two implementation options:
|
||||
|
||||
1. **In-process asyncio scheduler** — `asyncio.create_task` loop with `asyncio.sleep` until 03:00 CT. No extra dep. If the service restarts, the scheduler resumes on startup (computes next 03:00). Simple, matches the "no Celery/Redis for v0.4" stance.
|
||||
2. **APScheduler** — `apscheduler>=3.10` with a `AsyncIOScheduler`. More features (cron expressions, job stores) but over-engineered for one nightly job.
|
||||
|
||||
**Recommendation: in-process asyncio scheduler.** One `asyncio.create_task` that loops: compute seconds until next 03:00 CT → `asyncio.sleep(seconds)` → run reconciliation → repeat. The reconciliation job recomputes all 7-day windows for all paths (idempotent upsert). **Confidence 0.80** — simple, no dep, but no retry-on-failure (if the job fails, it retries the next night; the on-session-end hook keeps data fresh in the meantime).
|
||||
|
||||
### 3.5 Metrics for the 3 dashboard views (D-053)
|
||||
|
||||
**Finding (0.82):** D-053 names three views. Concrete metrics per view:
|
||||
|
||||
| View | Metrics (k-anonymized, 7-day windows) |
|
||||
|------|---------------------------------------|
|
||||
| **Practice volume** | sessions/day per path; total sessions in window; active learners in window (suppressed if <10) |
|
||||
| **Mastery progression** | % learners at each week (1-6); gate-open rate (gate_opened / total_gate_events); median mastery_score; rubric criterion mean scores (per criterion, across path) |
|
||||
| **Failure patterns** | top failure_modes by frequency; rubric criterion weak-spots (criteria with mean < 3.0); branch outcome distribution (escalate vs accept) |
|
||||
|
||||
Each metric is a row in `cohort_aggregates` with `(path, metric, window_start, window_end, value, cell_count, cell_suppressed)`. The `/api/operator/<view>` endpoint returns the pre-aggregated rows for that view's metrics.
|
||||
|
||||
### 3.6 No raw learner PII in Postgres (D-031 hybrid)
|
||||
|
||||
**Finding (0.90):** D-031 hybrid — learner-local state stays SQLite; operator-tier Postgres stores only aggregations + operator accounts + issued credentials. **What identifies a learner?** `learner_ref` — an opaque string (e.g., `"learner-1"`, the existing `HARDCODED_LEARNER_ID`). The Postgres tables (`cohort_aggregates`, `mastery_gate_events`, `issued_credentials`) use `learner_ref` as the join handle — **never** a FK to SQLite (cross-DB joins are impossible). The existing `issued_credentials` table in SQLite uses `learner_id` (the hardcoded string); v0.4's Postgres `issued_credentials` table uses `learner_ref` (same opaque string, different column name to emphasize it's not a FK). **Confidence 0.90** — D-031 is explicit; the codebase already uses opaque string IDs.
|
||||
|
||||
---
|
||||
|
||||
## Domain 4: React Cohort Dashboard (D-044, D-053, REQ-DASH-01)
|
||||
|
||||
### 4.1 React route under `/operator/*`
|
||||
|
||||
**Finding (0.85):** D-044. The existing client (`client/src/App.tsx`) is a single-view state machine (start → live → debrief) with **no React Router**. v0.4 adds:
|
||||
|
||||
- A new `client/src/operator/` directory with the cohort dashboard components.
|
||||
- React Router (or a minimal route switch) for `/operator/*` routes: `/operator/login`, `/operator/dashboard`.
|
||||
- The existing `App.tsx` remains the voice session UI at `/`.
|
||||
|
||||
**Routing structure:** The current `App.tsx` is mounted at `/` by the StaticFiles serving. Adding `/operator/*` routes requires either:
|
||||
- **(a) React Router** — `npm install react-router-dom` + a `<BrowserRouter>` wrapper. The StaticFiles `html=True` mount serves `index.html` for all paths, React Router handles client-side routing. **Caveat:** the existing `App.tsx` doesn't use React Router; wrapping it requires a refactor (or a separate root).
|
||||
- **(b) Minimal route switch** — `useState<'voice' | 'operator'>` based on `window.location.pathname.startsWith('/operator')`. No new dep. Simpler, but less idiomatic for a growing dashboard.
|
||||
|
||||
**Recommendation: React Router** (`react-router-dom@^7`) — it's the standard, supports nested routes, and the v0.4 dashboard will grow (D-053 names 3 views). The refactor to wrap `App.tsx` in a `<BrowserRouter>` is small. Add a catch-all route that serves the voice UI at `/` and the operator UI at `/operator/*`. **Pip: none; npm: `react-router-dom`.** **Confidence 0.80** — React Router is standard but adds a dep + a refactor of the existing single-view App.
|
||||
|
||||
**SPA fallback:** With React Router, the FastAPI StaticFiles mount needs to serve `index.html` for all non-API paths (SPA fallback). The current `app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True))` serves `index.html` for `/` but returns 404 for `/operator/dashboard` (no such file). **This is a required change:** add a catch-all route before the StaticFiles mount that returns `FileResponse("client/dist/index.html")` for any path not matching an API route. The v0.2 RESEARCH.md Q3 noted this as "NOT needed for v0.2" — v0.4 needs it. **Confidence 0.90** — standard SPA serving pattern.
|
||||
|
||||
### 4.2 Reusing v0.2 StaticFiles (same `client/dist` build)
|
||||
|
||||
**Finding (0.90):** D-044 explicit. No separate SPA build — the same `npm run build` produces `client/dist` with both the voice UI and the operator dashboard. The Dockerfile's Node stage is unchanged (one `npm run build`). The FastAPI StaticFiles mount is updated to serve the SPA fallback (see §4.1). **Confidence 0.90.**
|
||||
|
||||
### 4.3 Read-only tables + sparkline charts
|
||||
|
||||
**Finding (0.80):** The dashboard renders read-only tables + sparkline charts. **No chart library is in `client/package.json`** (verified — only react, react-dom, pipecat client SDK, dev deps). Options:
|
||||
|
||||
1. **Inline SVG sparklines** (~50 LOC, zero deps) — a `<Sparkline data={...} />` component that renders an SVG polyline. Sufficient for k-anon tables (small data: one sparkline per row, ~7-30 data points). **Recommendation for v0.4.**
|
||||
2. **uPlot** (~40KB, sparkline-native, no React dependency) — high-performance, but overkill for small tables.
|
||||
3. **Recharts** (~100KB, React-native) — idiomatic but heavy for sparklines.
|
||||
4. **Chart.js + react-chartjs-2** (~200KB) — heaviest, overkill.
|
||||
|
||||
**Recommendation: inline SVG sparklines** (zero deps, ~50 LOC, sufficient for v0.4 scale). Add a chart library only if the dashboard grows to need axes, tooltips, zoom. **Confidence 0.80** — sparklines are simple; the inline SVG approach is well-documented.
|
||||
|
||||
### 4.4 `/api/operator/*` FastAPI endpoint structure
|
||||
|
||||
**Finding (0.88):** D-053 + D-057. New `server/operator/` module with an `APIRouter(prefix="/api/operator")`. Endpoints:
|
||||
|
||||
| Endpoint | Method | Auth | Purpose |
|
||||
|----------|--------|------|---------|
|
||||
| `/api/operator/login` | POST | rate-limited (5/min) | Login: validate argon2id, set signed cookie |
|
||||
| `/api/operator/logout` | POST | auth-gated | Clear cookie (client-side) |
|
||||
| `/api/operator/me` | GET | auth-gated | Return current operator (for React route guard) |
|
||||
| `/api/operator/cohort` | GET | auth-gated | Practice volume view (k-anonymized) |
|
||||
| `/api/operator/mastery` | GET | auth-gated | Mastery progression view (k-anonymized) |
|
||||
| `/api/operator/failure-patterns` | GET | auth-gated | Failure patterns view (k-anonymized) |
|
||||
| `/api/operator/credentials` | GET | auth-gated | List issued VCs (operator's issuance log) |
|
||||
| `/api/operator/credentials/{id}/revoke` | POST | auth-gated | Revoke a VC |
|
||||
|
||||
Auth enforcement: FastAPI middleware checks the signed cookie on every `/api/operator/*` request (D-057); 401 if missing/invalid/expired. Router-level `dependencies=[Depends(current_operator)]` on the protected routes. Login + logout are outside the protected router (login is rate-limited, not auth-gated). **Confidence 0.88.**
|
||||
|
||||
### 4.5 Freshness ≤ 24h (REQ-NFR-DASH-02)
|
||||
|
||||
**Finding (0.85):** The on-session-end hook keeps aggregates fresh within minutes of a session ending. The nightly reconciliation job (03:00 CT) guarantees all 7-day windows are recomputed at least once/day. **Max staleness = 24h** (if the service restarts after a session and before the nightly job, the aggregate is stale until the next 03:00 run). The dashboard surfaces "last updated" via a `updated_at` timestamp on each `cohort_aggregates` row → the `/api/operator/<view>` response includes `last_updated: max(updated_at)` across the returned rows. React renders "Last updated: Xh ago" in the dashboard header. **Confidence 0.85.**
|
||||
|
||||
---
|
||||
|
||||
## Domain 5: VC Issuer Key Migration (D-042, D-051)
|
||||
|
||||
### 5.1 Migrating the Ed25519 issuer key from SQLite to Postgres
|
||||
|
||||
**Finding (0.88):** D-051. The existing `server/vc/issuer_keys.py` (verified — 128 lines) implements the issuer key lifecycle:
|
||||
- `init_issuer_key(store, root_key)` — generates a fresh Ed25519 keypair, encrypts the private key with `nacl.SecretBox` (root key from `PRAXIS_VC_ISSUER_KEY` env), stores in the `issuer_keys` table.
|
||||
- `get_active_signing_key(store, root_key)` — returns the active key (status='active'), or generates one if none exists.
|
||||
- `get_public_key_for_verification(store, key_id)` — returns the public key for a given key_id (queries by id, not by status — **this is the fallback mechanism**).
|
||||
- `rotate_key(store, root_key)` — generates a new key, marks the old as `superseded`.
|
||||
- `_verification_method(key_id)` — builds the `verificationMethod` URL.
|
||||
|
||||
v0.4 migration:
|
||||
1. The `issuer_keys.py` functions currently take a `PraxisStore` (SQLite). v0.4 adds a `PgStore` (Postgres) and the issuer key functions are refactored to accept either store (or a dedicated `IssuerKeyStore` interface). **The simplest refactor:** the issuer key functions accept a protocol/ABC with `init_issuer_key`, `get_active_signing_key_row`, `get_public_key_row`, `set_issuer_key_superseded` methods — both `PraxisStore` (SQLite) and `PgStore` (Postgres) implement it.
|
||||
2. On first v0.4 boot: generate a fresh keypair in Postgres `issuer_keys` (status='active').
|
||||
3. **Archive the v0.3 public key** — read the v0.3 active key's public key from SQLite, insert it into Postgres `issuer_keys` with status='superseded'. The private key is NOT migrated (v0.3 VCs are already signed; verification only needs the public key).
|
||||
4. The verification endpoint (`server/vc/verification.py:verify_credential`) extracts `key_id` from the proof's `verificationMethod` and calls `get_public_key_for_verification(store, key_id)`. **The fallback to superseded keys is already implicit** — `get_public_key_row(key_id)` queries by id, not by status. v0.3 VCs have the v0.3 key_id in their proof → the lookup finds the archived (superseded) public key → signature verifies.
|
||||
|
||||
**Confidence 0.88** — the existing code already supports the lifecycle; the migration is a store swap + an archive insert.
|
||||
|
||||
### 5.2 Archiving the v0.3 public key as `superseded` (not revoked)
|
||||
|
||||
**Finding (0.90):** D-051 explicit. The v0.3 public key is archived as `superseded` — old VCs still verify against it. **Revoked** would imply the key is no longer trusted (old VCs should fail verification). **Superseded** means the key is no longer used for new signatures but old signatures remain valid. The existing `set_issuer_key_superseded(key_id)` method (line 348-354 of `store.py`) does exactly this. **Confidence 0.90.**
|
||||
|
||||
### 5.3 Verification endpoint fallback
|
||||
|
||||
**Finding (0.88):** The verification flow (`server/vc/verification.py`):
|
||||
1. `verify_credential(store, credential_id)` → fetches the credential row.
|
||||
2. `extract_key_id(secured_doc)` → extracts key_id from the proof's `verificationMethod` URL.
|
||||
3. `get_public_key_for_verification(store, key_id)` → fetches the public key by id.
|
||||
4. `verify_proof(secured_doc, verify_key)` → validates the Ed25519 signature.
|
||||
|
||||
The fallback is implicit: step 3 queries by `key_id` (not by status), so it finds both active and superseded keys. v0.3 VCs have v0.3 key_ids → step 3 finds the archived (superseded) public key → step 4 validates. **No code change needed in the verification flow** — only the store backing changes (SQLite → Postgres). **Confidence 0.88.**
|
||||
|
||||
### 5.4 Encrypted-at-rest private key in Postgres
|
||||
|
||||
**Finding (0.85):** The existing `_encrypt_private_key(signing_key, root_key)` uses `nacl.SecretBox` with a root key from `PRAXIS_VC_ISSUER_KEY` env. This is application-layer encryption — the private key is encrypted before being stored in the DB. The same pattern works for Postgres (the `private_key_enc` column is `BYTEA`). **Postgres-level encryption at rest** (TDE) is not available in the open-source Postgres 16 (that's an EnterpriseDB feature). The application-layer `nacl.SecretBox` is the correct approach for the pilot. The root key (`PRAXIS_VC_ISSUER_KEY`) is in `.env.secrets` (gitignored). **Confidence 0.85** — the pattern is already proven in v0.3; the store swap is mechanical.
|
||||
|
||||
---
|
||||
|
||||
## Domain 6: Operator Account Bootstrap (D-052)
|
||||
|
||||
### 6.1 `scripts/create-operator.py` CLI script
|
||||
|
||||
**Finding (0.88):** D-052. A new `scripts/create-operator.py` script:
|
||||
- Reads `PRAXIS_BOOTSTRAP_OPERATOR_USER` + `PRAXIS_BOOTSTRAP_OPERATOR_PASS` from env (in `.env.secrets`).
|
||||
- Hashes the password with `argon2-cffi` `PasswordHasher().hash(password)`.
|
||||
- Connects to Postgres via asyncpg.
|
||||
- Inserts into `operators` table: `INSERT INTO operators (username, password_hash, display_name) VALUES ($1, $2, $3) ON CONFLICT (username) DO NOTHING`.
|
||||
- Idempotent — no-op if the user exists (no password update on re-run; a separate `--update` flag could force a rehash if needed).
|
||||
- Prints the result: `created` or `already exists`.
|
||||
|
||||
**Running the script:** `docker compose exec praxis python scripts/create-operator.py` (from the host) or directly in the CT. The script reads env vars from the praxis container's environment (which sources `/etc/praxis/server.env`). **Confidence 0.88.**
|
||||
|
||||
### 6.2 Env vars in `.env.secrets`
|
||||
|
||||
**Finding (0.90):** D-052. `PRAXIS_BOOTSTRAP_OPERATOR_USER` + `PRAXIS_BOOTSTRAP_OPERATOR_PASS` added to `.ciagent/.env.secrets` (gitignored — verified in `.gitignore`). These are injected via `lxc.environment` → `/etc/praxis/server.env` → `docker-compose.yml` env_file → container env. The `config.json` secrets scopes need a new `operator` scope with these vars. **Confidence 0.90** — the secret injection chain is proven from v0.2.
|
||||
|
||||
---
|
||||
|
||||
## Domain 7: Persona Assessment (v0.4 roster)
|
||||
|
||||
### 7.1 Active personas for v0.4
|
||||
|
||||
**Finding (0.90):** v0.4 is **operator-tier-backend + dashboard-frontend + security-crypto + Postgres-in-LXC**. The roster:
|
||||
|
||||
| Persona | v0.3 status | v0.4 status | Reason |
|
||||
|----------|-------------|-------------|--------|
|
||||
| lead-developer | active | **active** | Coordinates across operator/auth/cohort/dashboard/Postgres domains. Owns docker-compose.yml Postgres service addition. |
|
||||
| backend-engineer | active | **active** | Owns the asyncpg pool wiring, operator API routes, aggregation pipeline (on-session-end hook + nightly job), session_recorder.py extension for the aggregation hook. |
|
||||
| frontend-engineer | active (reactivated v0.3) | **active** | Owns the React cohort dashboard UI (D-044). Auth-gated routes, k-anonymized tables, sparkline charts. React Router addition + SPA fallback. |
|
||||
| data-engineer | active | **active (expanded)** | Owns the Postgres operator-tier schema (operators, cohort_aggregates, issuer_keys, mastery_gate_events, issued_credentials), the pg_migrate runner, the k-anonymity suppression SQL. |
|
||||
| security-engineer | active (new v0.3) | **active (retained)** | Owns the VC issuer key migration (SQLite→Postgres, superseded archive), the auth stack (argon2id, signed cookies, rate limiting), the Secure-cookie-TLS resolution (R-AUTH-01). |
|
||||
| devops-engineer | deactivated (v0.3) | **active (reactivated)** | Owns the docker-compose Postgres service + CT memory bump (4GB→6GB) + backup cron + `create-operator.py` bootstrap script + `.env.example` operator vars. |
|
||||
|
||||
### 7.2 Deactivated personas
|
||||
|
||||
None deactivated for v0.4 — all 6 personas are active. The voice-engineer and ml-engineer remain proposed (not v0.4).
|
||||
|
||||
### 7.3 Framework alignment (from actual `pyproject.toml` + `client/package.json`)
|
||||
|
||||
| Persona | Frameworks (v0.4 research-aligned) | Source |
|
||||
|---------|-------------------------------------|--------|
|
||||
| lead-developer | pipecat, fastapi, postgres, docker | `pyproject.toml` + `docker-compose.yml` |
|
||||
| backend-engineer | pipecat, pydantic, fastapi, uvicorn, asyncpg, aiosqlite | `pyproject.toml` (asyncpg is NEW for v0.4) |
|
||||
| frontend-engineer | react, react-router-dom (NEW), pipecat-client-sdk, webrtc, vite, fastapi-staticfiles | `client/package.json` (react-router-dom is NEW for v0.4) |
|
||||
| data-engineer | sqlite, postgres16, aiosqlite, asyncpg, alembic-style-migrations | `pyproject.toml` + `db/migrate.py` pattern |
|
||||
| security-engineer | pynacl, canonicaljson, base58, argon2-cffi, starlette-sessionmiddleware, slowapi | `pyproject.toml` (argon2-cffi + slowapi are NEW for v0.4) |
|
||||
| devops-engineer | proxmox-ve-api, lxc, docker, systemd, bash, bats, gitea, pg_dump | `scripts/proxmox/` + `docker-compose.yml` |
|
||||
|
||||
### 7.4 Territory alignment (from actual `server/` structure)
|
||||
|
||||
The actual `server/` structure (verified): `asr/`, `tts/`, `llm/`, `guardrails/`, `scenarios/`, `mastery/`, `paths/`, `vc/`, `services/`, `pipeline.py`, `session_recorder.py`, `__main__.py`, `cost.py`, `debrief.py`, `latency.py`, `interruptibility.py`. v0.4 adds: `server/operator/` (operator API), `server/auth/` (auth middleware), `server/cohort/` (aggregation pipeline). New `db/pg_migrations/` (Postgres migrations) + `db/pg_schema.sql` + `db/pg_store.py` (Postgres store).
|
||||
|
||||
| Persona | Territory (v0.4) |
|
||||
|---------|-------------------|
|
||||
| lead-developer | `docker-compose.yml`, `.env.example` |
|
||||
| backend-engineer | `**/server/**`, `**/operator/**`, `**/cohort/**`, `**/db/**` (excluding pg_schema) |
|
||||
| frontend-engineer | `**/client/**`, `**/client/src/operator/**` |
|
||||
| data-engineer | `**/db/**`, `**/db/pg_migrations/**`, `**/db/pg_schema.sql`, `**/db/pg_store.py` |
|
||||
| security-engineer | `**/server/vc/**`, `**/server/auth/**` |
|
||||
| devops-engineer | `scripts/proxmox/**`, `scripts/install-service.sh`, `scripts/create-operator.py`, `.env.example` (operator vars) |
|
||||
|
||||
### 7.5 Constraint alignment (v0.4-specific)
|
||||
|
||||
- **All personas:** `hybrid-storage-no-cross-db-joins` (D-031), `k-anonymity-floor-10` (D-034), `no-raw-learner-pii-in-postgres` (D-031).
|
||||
- **backend-engineer:** `mastery-off-voice-path` (C-8), `aggregation-off-voice-path` (D-054 — async fire-and-forget), `deterministic-scoring` (v0.3 carry-forward).
|
||||
- **frontend-engineer:** `auth-gated-operator-routes` (D-057), `k-anonymity-display-suppressed-cells` (D-034), `no-raw-learner-pii-in-ui` (D-031), `spa-fallback-for-operator-routes` (new — React Router needs index.html fallback).
|
||||
- **data-engineer:** `no-cross-db-joins` (D-031), `opaque-learner-ref` (D-031), `write-time-suppression` (D-034).
|
||||
- **security-engineer:** `argon2id-passwords` (D-041), `config-driven-secure-cookie` (R-AUTH-01 resolution), `issuer-key-encrypted-at-rest` (D-042), `superseded-not-revoked` (D-051).
|
||||
- **devops-engineer:** `idempotent-deploy` (carry-forward), `secrets-never-committed` (carry-forward), `pg-dump-backup-retention-7d` (D-055).
|
||||
|
||||
---
|
||||
|
||||
## Consolidated Risks Table
|
||||
|
||||
| ID | Risk | Severity | Mitigation | Confidence |
|
||||
|----|------|----------|------------|------------|
|
||||
| **R-MT-01** | Postgres + praxis resource contention on 6GB CT (disk I/O during nightly pg_dump + aggregation) | medium | Schedule nightly jobs at 03:00 CT (low learner activity); aggregation is incremental upsert (not full scan); monitor CT memory; bump to 8GB if OOM | 0.75 |
|
||||
| **R-MT-02** | Postgres container unhealthy on boot → praxis `depends_on` blocks startup | medium | `pg_isready` healthcheck + 5 retries; praxis app retries first migration on connection failure; `depends_on: service_healthy` is necessary but not sufficient | 0.80 |
|
||||
| **R-MT-03** | Docker Compose network change (default bridge → praxis-net) recreates praxis container → ~5-15s downtime | low | Plan cutover window; SQLite volume untouched → learner state preserved; do on staging CT first | 0.85 |
|
||||
| **R-MT-04** | `pgdata` volume corruption on CT restart (LXC + Docker volume interaction) | low | Named volumes are stable on Docker-in-LXC with nesting=1; nightly pg_dump provides backup; `pg_restore --clean --if-exists` drill | 0.70 |
|
||||
| **R-MT-05** | Postgres 16 `gen_random_uuid()` not available (misremembered as PG13+) | low | Verified: `gen_random_uuid()` is built into PG13+ core (no extension). PG16 confirmed. | 0.95 |
|
||||
| **R-AUTH-01** | Secure cookie flag + no-TLS pilot → cookies sent over HTTP (sniffable) | medium | Config-driven `PRAXIS_COOKIE_SECURE` (default true; false for HTTP pilot with logged WARNING); cohort dashboard reads only k-anonymized aggregates (no PII leak even if cookie sniffed); grill must sign off | 0.75 |
|
||||
| **R-AUTH-02** | argon2id hashing blocks event loop (CPU-bound, ~30-80ms per login) | low | Single operator login is low-frequency; ~80ms is acceptable on the event loop. If batch-hashing needed, use `run_in_executor`. Not a v0.4 concern. | 0.85 |
|
||||
| **R-AUTH-03** | In-memory rate limit lost on service restart (attacker bypasses by timing restart) | low | Single-instance pilot; restarts are rare + operator-initiated. A persistent rate-limit store (Redis) is deferred. | 0.80 |
|
||||
| **R-AUTH-04** | Signed cookie secret (`PRAXIS_COOKIE_SECRET`) rotation invalidates all sessions | low | Pilot: acceptable (one operator re-logs in). Document the rotation procedure. | 0.85 |
|
||||
| **R-AUTH-05** | No server-side session revocation (logout is client-side only) | low | D-056 explicit: stateless cookies, no revocation list in v0.4. A forced-logout requires cookie secret rotation. Deferred to a later milestone. | 0.80 |
|
||||
| **R-DASH-01** | k-anonymity suppression hides meaningful data at v0.4 scale (<100 learners → many cells <10) | medium | Expected at pilot scale; dashboard shows "— (suppressed, <10 learners)" transparently. Aggregation window can be widened (14-day) if too many cells suppressed. | 0.75 |
|
||||
| **R-DASH-02** | Differencing attack: operator compares two 7-day windows to isolate a single learner | medium | Limit to pre-defined 2-D views (path × week, path × outcome); no arbitrary filters; no per-learner drill-down (D-053). | 0.70 |
|
||||
| **R-DASH-03** | SPA fallback breaks existing voice UI (StaticFiles mount change) | medium | Add catch-all route BEFORE StaticFiles mount; test `/` still serves voice UI; test `/operator/dashboard` serves index.html. | 0.80 |
|
||||
| **R-DASH-04** | Nightly reconciliation job fails → aggregates stale >24h (NFR-DASH-02 breach) | low | On-session-end hook keeps data fresh; job retries next night; log + alert on job failure. | 0.75 |
|
||||
| **R-DASH-05** | React Router addition requires App.tsx refactor → breaks voice UI | medium | Wrap App.tsx in `<BrowserRouter>` with a catch-all route; test voice UI at `/` unchanged. | 0.75 |
|
||||
| **R-VC-MIG-01** | VC issuer key migration loses v0.3 public key → old VCs fail verification | high | Archive v0.3 public key as `superseded` in Postgres `issuer_keys` before activating new key; verification endpoint queries by key_id (not status) → fallback is implicit. Test: verify a v0.3 VC against the migrated store. | 0.85 |
|
||||
| **R-VC-MIG-02** | `PRAXIS_VC_ISSUER_KEY` root key changes between v0.3 and v0.4 → encrypted private keys undecryptable | medium | The v0.3 private key is NOT migrated (only the public key is archived). The v0.4 active key is generated fresh with the v0.4 root key. Keep the v0.3 root key in secrets until all v0.3 VCs expire (3-year validUntil). | 0.80 |
|
||||
| **R-VC-MIG-03** | `issuer_keys.py` store refactor (SQLite→Postgres protocol) breaks v0.3 verification | medium | Define an `IssuerKeyStore` protocol/ABC; both `PraxisStore` and `PgStore` implement it; verification endpoint uses the Postgres store for v0.4. Test: verify a v0.3 VC against the Postgres store with the archived public key. | 0.80 |
|
||||
| **R-BOOT-01** | `create-operator.py` fails on first boot (Postgres not ready) | low | Script retries on connection failure (3 attempts, 5s backoff); run after `docker compose up -d postgres` + healthcheck passes. | 0.80 |
|
||||
| **R-BOOT-02** | `PRAXIS_BOOTSTRAP_OPERATOR_PASS` not set → operator can't log in | low | Script checks env var presence + exits with clear error if missing. Document in `.env.example`. | 0.85 |
|
||||
|
||||
---
|
||||
|
||||
## v0.3 Assumption Audit (which anticipatory assumptions were confirmed / overturned)
|
||||
|
||||
The v0.3 ARCHITECTURE.md operator-tier section was anticipatory. D-050..D-057 (v0.4 clarify decisions) refine it. Audit:
|
||||
|
||||
| v0.3 anticipatory assumption | v0.4 decision | Verdict |
|
||||
|------------------------------|---------------|---------|
|
||||
| `postgres:16-slim`, named volume `pgdata`, internal network, `pg_isready` healthcheck | D-040, D-050 confirmed | **CONFIRMED** |
|
||||
| asyncpg `create_pool(min_size=2, max_size=10)` | D-050: `min_size=1` | **OVERTURNED** — D-050 lowers min_size to 1 (lower idle cost) |
|
||||
| Starlette `SessionMiddleware` (itsdangerous-signed) | D-056: signed stateless cookies (HMAC-SHA256) | **CONFIRMED** — SessionMiddleware uses itsdangerous/HMAC-SHA256 under the hood; D-056 is the mechanism clarification |
|
||||
| argon2-cffi `PasswordHasher` defaults | D-041 + OWASP: defaults exceed minimums | **CONFIRMED** — keep defaults (time_cost=3, memory_cost=64MiB, parallelism=4) |
|
||||
| slowapi 5/min login rate-limit | D-041 + D-057 | **CONFIRMED** — slowapi is the idiomatic choice; in-memory counter is the fallback |
|
||||
| `cohort_aggregates` with weekly partitions | D-053: pre-aggregated rows, 7-day rolling windows | **OVERTURNED** — weekly partitions are premature at v0.4 scale; ship a plain table with `(path, window_start)` index. Add partitioning post-pilot. |
|
||||
| `operators`, `issued_credentials`, `mastery_gate_events`, `cohort_aggregates`, `issuer_keys` tables | D-050..D-053 confirmed | **CONFIRMED** — schema holds; column names refined (learner_ref vs learner_id) |
|
||||
| CT memory 4GB → 6GB | D-050 + REQ-NFR-MT-01 | **CONFIRMED** — 6GB is sufficient |
|
||||
| `pg_dump -Fc` to `pgbackups` volume, `%u` 7-file retention | D-055 confirmed | **CONFIRMED** — but host-side cron (not in-process) for decoupling |
|
||||
| Secure cookie requires TLS (R-AUTH-01) | D-056 + D-030: config-driven `Secure` flag | **REFINED** — config-driven flag is the v0.4 resolution; v0.3 flagged it as an open question |
|
||||
| VC issuer key in Postgres `issuer_keys` (encrypted at rest) | D-042 + D-051 confirmed | **CONFIRMED** — plus the migration path (archive v0.3 public key as superseded) |
|
||||
| `gen_random_uuid()` in PG16 (no extension) | Verified | **CONFIRMED** |
|
||||
| React `/operator/*` route, reuses v0.2 StaticFiles | D-044 + D-053 confirmed | **CONFIRMED** — plus SPA fallback requirement (new) |
|
||||
| on-session-end hook + nightly reconciliation | D-045 + D-054 confirmed | **CONFIRMED** — D-054 clarifies async fire-and-forget + 03:00 CT |
|
||||
|
||||
**Summary:** 2 overturned (asyncpg min_size, weekly partitions), 1 refined (Secure cookie → config-driven), 11 confirmed.
|
||||
|
||||
---
|
||||
|
||||
## New pip dependencies for v0.4
|
||||
|
||||
| Dep | Purpose | Confidence | Source |
|
||||
|-----|---------|------------|--------|
|
||||
| `asyncpg>=0.29` | Postgres async driver / pool | 0.90 | D-050 |
|
||||
| `argon2-cffi>=23.1` | argon2id password hashing | 0.95 | D-041, OWASP |
|
||||
| `slowapi>=0.1` | login rate limiting (in-memory) | 0.70 | D-041, D-057 |
|
||||
|
||||
`starlette` + `itsdangerous` already via FastAPI. `pynacl`, `canonicaljson`, `base58` already in `pyproject.toml` (v0.3).
|
||||
|
||||
## New npm dependencies for v0.4
|
||||
|
||||
| Dep | Purpose | Confidence | Source |
|
||||
|-----|---------|------------|--------|
|
||||
| `react-router-dom@^7` | React routing for `/operator/*` | 0.80 | D-044 |
|
||||
|
||||
No chart library — inline SVG sparklines (zero deps).
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for PLAN Stage
|
||||
|
||||
1. **SPA fallback implementation:** Catch-all route before StaticFiles mount, or a custom StaticFiles subclass? The catch-all route is simpler but must not shadow `/api/*` or `/vc/*` routes.
|
||||
2. **`IssuerKeyStore` protocol design:** ABC with methods, or a simpler duck-typing approach? The existing `PraxisStore` methods (`init_issuer_key`, `get_active_signing_key_row`, `get_public_key_row`, `set_issuer_key_superseded`) are the interface.
|
||||
3. **Nightly scheduler:** In-process asyncio loop or host-side cron for the aggregation job? (pg_dump backup is host-side cron.) In-process is simpler for aggregation (shares the asyncpg pool); host-side is better for backup (decoupled from app uptime).
|
||||
4. **`create-operator.py` update path:** `--update` flag to force rehash, or a separate `scripts/update-operator.py`? Keep it simple: `--update` flag on the same script.
|
||||
5. **Cookie `path` scope:** `/` (cookie sent to all routes) or `/api/operator` (cookie sent only to operator API)? `/` is needed for the React `/operator/*` routes to call `/api/operator/me` on mount (the browser sends the cookie). Use `/`.
|
||||
6. **Cohort aggregation `learner_ref` source:** The existing `HARDCODED_LEARNER_ID = "learner-1"` — is this stable enough for the aggregation? Yes for v0.4 (single learner); multi-learner-per-device is deferred. The aggregation groups by `learner_ref` so k-anonymity counts distinct learners.
|
||||
7. **Phase split confirmation:** ROADMAP shows P1 (operator foundation: Postgres + auth) → P2 (cohort dashboard + aggregation) → P3 (review). Is the aggregation pipeline P1 or P2? D-045 + D-054 suggest the hook is P2 (needs the dashboard to be useful), but the Postgres schema + the on-session-end hook could be P1. **Recommendation:** P1 = Postgres + auth + VC key migration + schema (including `cohort_aggregates` table); P2 = aggregation pipeline (hook + nightly job) + dashboard UI + endpoints. The schema is P1 so P2 is pure code.
|
||||
+61
-23
@@ -1,20 +1,64 @@
|
||||
# Praxis — Roadmap
|
||||
|
||||
**Milestone:** v0.3 (Mastery scoring + competency rubrics + verifiable credentials) — complete
|
||||
**Status:** milestone released as v0.1.5 (merged to main)
|
||||
**Previous milestone:** v0.2 (Proxmox LXC deployment) — complete, tagged v0.1.2, release #377
|
||||
**Milestone:** v0.4 (Operator tier — cohort dashboard, auth, Postgres) — active
|
||||
**Status:** phase 0 — specify (active milestone)
|
||||
**Previous milestone:** v0.3 (Mastery scoring + competency rubrics + verifiable credentials) — complete, tagged v0.1.5, release #380, merged to main
|
||||
|
||||
## Milestone Philosophy
|
||||
|
||||
v0.3 activates the mastery/assessment layer deferred from v0.1/v0.2 (per D-021). Learners progress via **mastery gates** — they move on only when they can do the thing across varied scenarios, scored against a competency rubric. On week-final gate-open, a **formative verifiable credential** (W3C VC 2.0, Ed25519) is issued so mastery is portable. The v0.2 LXC deployment carries forward unchanged. **Operator tier (cohort dashboard + auth + Postgres) is deferred to v0.4** per the grill's binding verdict (GRILL-v0.3.md Axis 2 — the operator tier was originally v0.8 on this roadmap; pulling it into v0.3 created a 2-milestone program disguised as one).
|
||||
v0.4 activates the operator tier deferred from v0.3 per the grill's binding verdict (GRILL-v0.3.md Axis 2 — the operator tier was originally v0.8 on this roadmap; pulling it into v0.3 created a 2-milestone program disguised as one). v0.4 layers the operator surface on top of the v0.3 mastery/VC/scenario work: a Postgres store in the existing LXC CT, operator auth (argon2id session cookies), a cohort aggregation pipeline (k-anonymity ≥ 10, 7-day windows), and a React cohort dashboard served by the same FastAPI server. The learner-facing surface carries forward unchanged (SQLite, voice loop, mastery gates, VC issuance). The VC issuer key store migrates from SQLite to operator-tier Postgres + secrets (D-042).
|
||||
|
||||
## v0.3 Phases (post-grill)
|
||||
## v0.4 Phases
|
||||
|
||||
### Phase 0 — Pre-Execution (in-progress — this phase)
|
||||
### Phase 0 — Pre-Execution (complete — tagged v0.1.6, release created)
|
||||
|
||||
**Branch:** `phase/00-pre-execution` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship target:** `v0.1.6` (patch release on v0.3's v0.1.x line — NFR/docs milestone type)
|
||||
**Status:** complete (v0.1.6 tagged, Gitea release created)
|
||||
|
||||
Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP
|
||||
|
||||
**Goal:** Produce all `.ciagent/` planning artifacts for v0.4: activated requirements (REQ-MT-01/02, REQ-AUTH-01, REQ-DASH-01 + 4 NFRs), research-grounded Postgres-in-LXC + k-anonymity + argon2id + React-dashboard architecture, persona roster (frontend-engineer + data-engineer reactivated, security-engineer retained), vertical-slice plan for P1/P2.
|
||||
|
||||
**Deliverables:**
|
||||
- PROJECT.md (v0.4 scope validated; operator tier activated)
|
||||
- REQUIREMENTS.md (v0.4 active REQ-IDs = 8; v0.3 marked complete)
|
||||
- ARCHITECTURE.md (operator Postgres + auth + cohort dashboard + aggregation pipeline added to v0.3 topology)
|
||||
- PERSONAS.md (v0.4 roster — frontend-engineer + data-engineer reactivated for dashboard + Postgres; security-engineer retained for auth/crypto; devops-engineer for Postgres-in-LXC)
|
||||
- GRILL-v0.4.md (adversarial review — auth + PII surface warrants grill)
|
||||
- Phase 1 + Phase 2 plans (vertical slices with wave ordering)
|
||||
|
||||
### Phase 1 — Operator Foundation (Postgres + Auth) (complete — tagged v0.1.7, release created)
|
||||
|
||||
**Branch:** `phase/01-operator-foundation` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship target:** `v0.1.7` (patch release, feature milestone type)
|
||||
**Status:** complete (v0.1.7 tagged, Gitea release created; 272 pass, 33 skip, 0 fail; 5/5 REQ covered; APPROVE_WITH_NOTES, 4 P1+ flagged)
|
||||
|
||||
**Goal:** Operator-tier Postgres 16 running as a second Docker service in the existing LXC CT (internal network only), operator auth (argon2id session cookies, single `operator` role, login rate-limited), VC issuer key store migrated to Postgres + secrets. Foundation for the cohort dashboard in P2. No UI yet — API + DB + auth only.
|
||||
|
||||
### Phase 2 — Cohort Dashboard + Aggregation (planned)
|
||||
|
||||
**Branch:** `phase/02-cohort-dashboard` → merged to `milestone/v0.4-operator-tier`
|
||||
**Ship target:** `v0.1.8` (patch release, feature milestone type)
|
||||
**Status:** planned
|
||||
|
||||
**Goal:** Cohort aggregation pipeline (on-session-end hook + nightly reconciliation, k-anonymity ≥ 10, 7-day windows) + React cohort dashboard under `/operator/*` (served by same FastAPI, reuses v0.2 StaticFiles) + `/api/operator/*` endpoints (auth-gated). Dashboard shows anonymized practice/mastery/failure-pattern views with cells < 10 learners suppressed.
|
||||
|
||||
### Final Phase (P3) — Review + Ship (planned)
|
||||
|
||||
**Branch:** `phase/03-final-review-ship` → merged to `milestone/v0.4-operator-tier` → merged to `main`
|
||||
**Ship target:** final patch = v0.4 milestone release
|
||||
**Status:** planned
|
||||
|
||||
**Goal:** Multi-persona code review, project audit, milestone merge to main, milestone release.
|
||||
|
||||
## v0.3 Milestone (complete — released as v0.1.5, reference)
|
||||
|
||||
### Phase 0 — Pre-Execution (complete — tagged v0.1.3, release #378)
|
||||
|
||||
**Branch:** `phase/00-pre-execution` → merged to `milestone/v0.3-mastery-scoring`
|
||||
**Ship target:** `v0.1.3` (patch release on v0.2's v0.1.x line — NFR/docs milestone type)
|
||||
**Status:** in-progress (PLAN — grill-amended)
|
||||
**Ship target:** `v0.1.3` (patch release, NFR milestone type — docs/planning only)
|
||||
**Status:** complete (v0.1.3 tagged, Gitea release #378 created)
|
||||
|
||||
Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP
|
||||
|
||||
@@ -28,26 +72,22 @@ Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP
|
||||
- GRILL-v0.3.md (4 MUST conditions resolved, 5 FIX tracked)
|
||||
- Phase 1 plan (9 slices, 5 waves, ~40 tasks, 13/13 REQ coverage)
|
||||
|
||||
### Phase 1 — Mastery Core + VC Issuance (planned)
|
||||
### Phase 1 — Mastery Core + VC Issuance (complete — tagged v0.1.4, release #379)
|
||||
|
||||
**Branch:** `phase/01-mastery-core` → merged to `milestone/v0.3-mastery-scoring`
|
||||
**Ship target:** `v0.1.4` (patch release, feature milestone type)
|
||||
**Status:** planned
|
||||
**Status:** complete (v0.1.4 tagged, Gitea release #379 created; 13/13 REQ covered, 4/4 grill MUSTs satisfied)
|
||||
|
||||
**Goal:** Competency rubric engine + Mastery Score computation + scenario library (≥6 CS scenarios) + dynamic difficulty (IRT) + Customer Service path (6 weeks) + verifiable-credential issuer (W3C VC 2.0, Ed25519, SQLite-backed, formative-tier, public verification). All learner-facing. 9 slices, 5 waves, ~40 tasks.
|
||||
|
||||
### Final Phase (P2) — Review + Ship (planned)
|
||||
### Final Phase (P2) — Review + Ship (complete — tagged v0.1.5, release #380, merged to main)
|
||||
|
||||
**Branch:** `phase/02-final-review-ship` → merged to `milestone/v0.3-mastery-scoring` → merged to `main`
|
||||
**Ship target:** final patch = v0.3 milestone release
|
||||
**Status:** planned
|
||||
**Status:** complete (v0.1.5 tagged, Gitea release #380 created, merged to main; review APPROVE_WITH_NOTES, audit HEALTHY)
|
||||
|
||||
**Goal:** Multi-persona code review, project audit, milestone merge to main, milestone release.
|
||||
|
||||
## v0.4 Milestone (planned — operator tier, deferred from v0.3 per grill)
|
||||
|
||||
v0.4 activates the operator tier deferred from v0.3: REQ-DASH-01 (cohort dashboard), REQ-AUTH-01 (operator auth), REQ-MT-01/02 (Postgres + aggregation), + 4 NFRs. This restores the original ROADMAP intent (dashboard was v0.8) while following the grill's "split the milestone" verdict.
|
||||
|
||||
## v0.2 Milestone (complete — reference)
|
||||
|
||||
### Phase 0 — Pre-Execution (complete — tagged v0.1.0, release #371)
|
||||
@@ -75,11 +115,11 @@ Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL
|
||||
|
||||
**Goal:** A working `lxc-deploy.sh` orchestrator that clones a Debian template from the Proxmox cluster, configures the CT with Docker + nesting, builds/loads the praxis Docker image on first boot, starts the service via systemd, and health-checks `/health` :8789 — all idempotent with rollback on failure.
|
||||
|
||||
### Final Phase (P2) — Review + Ship (in-progress — this phase)
|
||||
### Final Phase (P2) — Review + Ship (complete — tagged v0.1.2, release #377, merged to main)
|
||||
|
||||
**Branch:** `phase/02-final-review-ship` → merged to `milestone/v0.2-lxc-deploy` → merged to `main`
|
||||
**Ship target:** final patch = v0.2 milestone release
|
||||
**Status:** in-progress (audit running; no P2 commits yet on v0.2 phase/02 branch)
|
||||
**Status:** complete (v0.1.2 tagged, Gitea release #377 created, merged to main)
|
||||
|
||||
**Goal:** Multi-persona code review, project audit, milestone merge to main, milestone release.
|
||||
|
||||
@@ -87,17 +127,15 @@ Pipeline stages: SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL
|
||||
|
||||
v0.1 was the **foundation milestone** — minimal viable voice loop (one persona, one scenario, ASR+TTS+LLM round-trip, single learner state). Shipped as `v0.0.0` (phase 0) → `v0.0.1` (phase 1) → `v0.0.2` (final/milestone release).
|
||||
|
||||
## Future Milestones (post-v0.2, indicative)
|
||||
## Future Milestones (post-v0.4, indicative)
|
||||
|
||||
| Milestone | Scope (indicative) |
|
||||
|-----------|-------------------|
|
||||
| v0.3 | Mastery scoring + competency rubrics for the Customer Service path (deferred from original v0.2) |
|
||||
| v0.4 | Second scenario + second persona; Drill Mode |
|
||||
| v0.5 | Live Assist on-the-job companion |
|
||||
| v0.6 | Low-bandwidth surfaces (WhatsApp, offline cache) |
|
||||
| v0.7 | Multi-language (French-Canadian, then PRD's 10-language list) |
|
||||
| v0.8 | Employer / program dashboard |
|
||||
| v0.9 | Credentialing (verifiable, shareable) |
|
||||
| v0.8 | Full operator-suite dashboard (REQ-DASH-02 — beyond v0.4's foundational cohort view) |
|
||||
| v0.9 | Credentialing (third-party verifiable, shareable) |
|
||||
| v1.0 | Working, tested product — multiple paths, multi-market, production-ready |
|
||||
|
||||
These are indicative and will be refined by ci-roadmapper at the start of each milestone.
|
||||
+234
-51
@@ -1,55 +1,238 @@
|
||||
# P1 Verification Matrix — REQ-ID → Test Mapping
|
||||
|
||||
> **Phase:** P1 (Mastery Core + VC Issuance)
|
||||
> **Slices covered:** SLICE-01 → SLICE-09 (Wave 1–5) — SLICE-09 COMPLETE
|
||||
> **Status:** verified — all 13 P1 REQ-IDs have covering tests
|
||||
> **Date:** 2026-08-03 (updated by ci-verifier after SLICE-09 completion)
|
||||
> **Authority:** lead-developer (TASK-08-03) + ci-verifier (4-layer verify)
|
||||
|
||||
This matrix confirms every P1 REQ-ID has at least one covering test. Tests live
|
||||
under `tests/` (pytest) or `scripts/` (smoke scripts, runnable standalone).
|
||||
SLICE-09 (VC issuer + verification + interop/rotation) is now complete — all
|
||||
three previously-pending REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) are
|
||||
covered. All 13 P1 REQ-IDs are green.
|
||||
|
||||
---
|
||||
|
||||
## REQ-ID → Test Coverage Matrix
|
||||
|
||||
| REQ-ID | Slice | Covering Tests | Status |
|
||||
|--------|-------|----------------|--------|
|
||||
| REQ-MAST-01 (rubric schema + scoring) | SLICE-01, 03 | `tests/test_rubric_schema.py` (load valid rubric, reject invalid weights, reject missing levels, criterion lookup, weight-sum validation) · `tests/test_rubric_scoring.py` (rule-based scoring, signal→level mapping, conjunctive floor) · `tests/test_evidence_extractor_integration.py` (LLM-extract → score end-to-end, JSON-schema validation) | ✅ covered |
|
||||
| REQ-MAST-02 (mastery score + gate logic) | SLICE-07 | `tests/test_rubric_scoring.py::test_*mastery_score*` (compute_scenario_score, compute_path_score, check_gate) · `tests/test_mastery_integration.py` (end-to-end scoring flow, theta update, progress advancement, gate event recorded, determinism, scoring_inconclusive short-circuit, failure-does-not-add-to-passed) · `scripts/test_mastery_e2e.py` (3 sessions → gate opens at ≥3 distinct passed AND score ≥3.5) | ✅ covered |
|
||||
| REQ-MAST-03 (VC issuer — formative-tier) | SLICE-09 | `tests/test_vc_issuer.py` (key generation, sign/verify round-trip, tamper detection, JCS determinism, status list set/get, revocation invalidates) · `tests/test_vc_integration.py` (issue→verify round-trip, revoke→verify fails, tamper→verify fails, key rotation: old VC verifies against archived key) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance, JCS canonical JSON, Ed25519 sig = 64 bytes, `credentialTier: formative` in payload) · `tests/test_vc_key_rotation_drill.py` (issue N with key A, rotate to B, issue M, verify all N+M verify, revoke one each) | ✅ covered |
|
||||
| REQ-MAST-04 (principle — accepted) | — | — | ✅ accepted (no test — principle only) |
|
||||
| REQ-SCEN-02 (IRT dynamic difficulty) | SLICE-04 | `tests/test_irt.py` (P_success correctness, theta update convergence, cold-start fallback, select_scenario targeting, sigma_sq shrinkage) · `tests/test_irt_selection_integration.py` (library.select_for_theta targets the right P for a given theta + path) | ✅ covered |
|
||||
| REQ-SCEN-03 (scenario library ≥6 CS scenarios) | SLICE-02, 06 | `tests/test_scenario_library.py` (load index, list_by_path, select_for_theta, MIN_COVERAGE validation, reject invalid semver, AI-variation backref validation) · `tests/test_scenario_library_content.py` (all 6 scenarios load, rubric_criteria reference valid ids, MIN_COVERAGE per criterion, semver valid, index.yaml in sync with files) | ✅ covered |
|
||||
| REQ-SCEN-04 (expert-authored format + AI-variation hooks) | SLICE-02, 06 | `tests/test_scenario_library.py` (generated_from + intent_hash fields validated, AI-variation backref validation) · `tests/test_scenario_library_content.py` (expert-authored scenarios all carry version + author: expert) | ✅ covered |
|
||||
| REQ-PATH-02 (6-week path structure) | SLICE-05 | `tests/test_path_engine.py` (load path, validate exactly 6 weeks, week numbers sequential, gate check, week advancement caps at 6, path completion) | ✅ covered |
|
||||
| REQ-NFR-MAST-01 (deterministic scoring) | SLICE-03 | `tests/test_rubric_scoring.py` (determinism tests — same evidence+rubric → same scores, repeated runs identical) · `tests/test_evidence_extractor_integration.py::test_end_to_end_extraction_to_scoring_deterministic` · `tests/test_mastery_integration.py::test_mastery_flow_is_deterministic` | ✅ covered |
|
||||
| REQ-NFR-MAST-02 (gate auditability — SQLite) | SLICE-07, 08 | `tests/test_mastery_integration.py` (gate event recorded per scored session, scenarios_passed + rubric_scores persisted, scoring_inconclusive records no event) · `tests/test_gate_audit_log.py` (query by learner, by path, by date range via SQL, JSON evidence reconstructable, 3 events distinct + queryable) | ✅ covered |
|
||||
| REQ-NFR-VC-01 (tamper-evidence + interop) | SLICE-09 | `tests/test_vc_issuer.py` (tamper detection — flip a byte → verify fails; JCS canonicalization determinism) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance + Ed25519 signature-format checks; staging-gated full validation via `PRAXIS_RUN_VC_INTEROP=1`) · `tests/test_vc_integration.py` (tamper payload → verify fails) | ✅ covered |
|
||||
| REQ-NFR-VC-02 (revocation latency — next verify call) | SLICE-09 | `tests/test_vc_issuer.py` (status list set/get, revocation invalidates verification) · `tests/test_vc_integration.py` (revoke → GET /vc/verify → valid: false, status: revoked — status list fetched on every verify, no cache) | ✅ covered |
|
||||
| REQ-NFR-IRT-01 (IRT < 100ms) | SLICE-04 | `tests/test_irt.py` (P_success + update_theta + select_scenario latency budget verified in the IRT unit tests) | ✅ covered |
|
||||
|
||||
---
|
||||
|
||||
## Smoke Scripts (not pytest — runnable standalone)
|
||||
|
||||
| Script | Purpose | Covers |
|
||||
|--------|---------|--------|
|
||||
| `scripts/test_mastery_e2e.py` | End-to-end P1 mastery smoke (3 sessions → gate opens) | REQ-MAST-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02 (audit), REQ-PATH-02 (progress advance) |
|
||||
| `scripts/test_real_llm_evidence.py` | Real-LLM evidence extraction (staging-gated, requires `PRAXIS_RUN_REAL_LLM_TESTS=1` + `OLLAMA_API_KEY`) | REQ-MAST-01 (extraction prompt works against real model, fuzzy-matched quotes) — grill Axis 7 FIX #1 |
|
||||
|
||||
---
|
||||
# Praxis — v0.4 Phase 1 Verification (Operator Foundation)
|
||||
|
||||
## Summary
|
||||
- Verdict: **APPROVE_WITH_NOTES**
|
||||
- Layers: structural **PASS**, behavioral **PASS**, security **PASS**, quality **PASS**
|
||||
- REQ coverage: **5/5** (REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02 schema foundation)
|
||||
- Grill MUSTs honored: **4/4 P1-applicable** (G-008, G-011, G-027, G-031); G-038 + G-041 are P2-scoped (tracked for P2 verify)
|
||||
- P0 fixes applied: **0** (none needed — the one prior fix `0a95102` was applied during execution, before verify)
|
||||
- P1+ flagged: **4** (non-blocking, for post-hoc review in P3)
|
||||
|
||||
- **P1 REQ-IDs total:** 13 (7 functional + 6 NFR)
|
||||
- **Covered (all slices complete incl. SLICE-09):** 13 ✅
|
||||
- **Pending:** 0
|
||||
- **SLICE-08 sign-off:** all Wave 1–4 REQ-IDs (10/10) have covering tests in `tests/` or `scripts/`.
|
||||
- **SLICE-09 sign-off:** all 3 previously-pending VC REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) now covered by 4 new test files (`test_vc_issuer.py`, `test_vc_integration.py`, `test_vc_interop.py`, `test_vc_key_rotation_drill.py`).
|
||||
- **Milestone ship (v0.1.4 → v0.1.5) gate:** UNBLOCKED — all 13 P1 REQ-IDs covered. P1 is green.
|
||||
> Note: This file previously held the v0.3 P1 verification matrix (mastery core + VC issuance). That content is superseded by the v0.3 ship (v0.1.5, 13/13 REQ covered). This file now holds the v0.4 P1 (Operator Foundation) verification report.
|
||||
|
||||
**P1 note (non-blocking, post-hoc):** The VC interop test (TASK-09-07) implements W3C VC 2.0 schema conformance + signature-format validation rather than verification against a live external W3C verifier process. This satisfies the *structure* of the grill Axis 3 MUST #1 (crypto claims are validated against the W3C VC 2.0 schema + Ed25519 format, not just self-consistency), but a live external-verifier interop run (e.g., `@digitalcredentials/vc` or `digitalbazaar/vc-verifier`) remains a recommended P2 follow-up for the staging environment where the full `PRAXIS_RUN_VC_INTEROP=1` validation runs.
|
||||
## Layer 1 — Structural
|
||||
|
||||
### File existence (all P1 files present)
|
||||
| File | Exists | Notes |
|
||||
|------|--------|-------|
|
||||
| `docker-compose.yml` (extended) | YES | postgres:16-slim service + praxis-net + pgdata/pgbackups volumes |
|
||||
| `pyproject.toml` (extended) | YES | asyncpg>=0.29, argon2-cffi>=23.1, slowapi>=0.1 added |
|
||||
| `db/pg_migrate.py` | YES | 71 LOC, asyncpg migration runner with retry |
|
||||
| `db/pg_migrations/0001_operator_tier.sql` | YES | 5 tables, gen_random_uuid(), no partitioning |
|
||||
| `db/pg_schema.sql` | YES | reference schema |
|
||||
| `db/pg_store.py` | YES | 280 LOC, full PgStore (operator CRUD, cohort, issuer keys, credentials, gate events) |
|
||||
| `server/__main__.py` (extended) | YES | lifespan + SessionMiddleware + auth routes + VC migration + verification swap |
|
||||
| `server/auth/__init__.py` | YES | package marker |
|
||||
| `server/auth/passwords.py` | YES | argon2id hash/verify/rehash |
|
||||
| `server/auth/cookies.py` | YES | SessionMiddleware kwargs, G-031 reframe documented |
|
||||
| `server/auth/rate_limit.py` | YES | slowapi 5/min in-memory |
|
||||
| `server/auth/dependencies.py` | YES | current_operator dep (401/503) |
|
||||
| `server/auth/routes.py` | YES | login/logout/me, rate-limited |
|
||||
| `server/auth/models.py` | YES | Operator dataclass |
|
||||
| `server/vc/issuer_keys.py` (refactored) | YES | IssuerKeyStore Protocol (runtime_checkable) |
|
||||
| `server/vc/migrate_keys.py` | YES | archive-before-activate + G-027 first-boot |
|
||||
| `server/vc/verification.py` (extended) | YES | G-011 two-store fallback |
|
||||
| `scripts/backup-pg.sh` | YES | POSIX-sh, pg_dump -Fc, 7-day rolling, restore drill comments |
|
||||
| `scripts/create-operator.py` | YES | argon2id, idempotent, --update, retry |
|
||||
| `scripts/proxmox/lxc-clone.sh` (extended) | YES | memory bumped 4096->6144 |
|
||||
| `.env.example` (extended) | YES | operator vars documented |
|
||||
| `.ciagent/.env.secrets.example` | YES | operator secrets template |
|
||||
| `.ciagent/config.json` (extended) | YES | operator secrets scope added |
|
||||
| `tests/test_pg_store.py` | YES | skips gracefully without PRAXIS_PG_DSN |
|
||||
| `tests/test_auth.py` | YES | 310 LOC, mocked PgStore |
|
||||
| `tests/test_vc_migration.py` | YES | 354 LOC, R-VC-MIG-01 + G-027 + G-011 |
|
||||
| `tests/test_create_operator.py` | YES | 217 LOC, idempotent + --update |
|
||||
| `tests/test_backup_restore.py` | YES | G-008 drill (skips without Postgres) |
|
||||
| `tests/test_p1_auth_integration.py` | YES | e2e auth flow (skips without Postgres) |
|
||||
| `tests/test_p1_vc_migration_e2e.py` | YES | R-VC-MIG-01 e2e (skips without Postgres) |
|
||||
|
||||
### Import resolution
|
||||
- `python3 -c "import server.__main__"` -> OK (Pipecat + all v0.4 modules load)
|
||||
- `python3 -c "import db.pg_store, db.pg_migrate, server.auth.routes, server.auth.passwords, server.auth.cookies, server.auth.rate_limit, server.auth.dependencies, server.vc.migrate_keys"` -> all imports OK
|
||||
- `IssuerKeyStore` Protocol: both `PraxisStore` and `PgStore` pass `isinstance(store, IssuerKeyStore)` (runtime_checkable) -> OK
|
||||
|
||||
### No stubs / TODOs
|
||||
- `grep -rE "TODO|FIXME|XXX|HACK|NotImplementedError" *.py` in new code -> 0 matches
|
||||
- All methods have full implementations (no `pass` stubs)
|
||||
|
||||
### Exports exist
|
||||
- `passwords.__all__` = [hash_password, verify_password, needs_rehash] -> all defined
|
||||
- `cookies.__all__` = [get_session_middleware_kwargs] -> defined
|
||||
- `rate_limit.__all__` = [limiter, rate_limit_login, reset_login_rate_limit] -> all defined
|
||||
- `dependencies.__all__` = [current_operator] -> defined
|
||||
- `routes.__all__` = [router] -> defined
|
||||
- `migrate_keys.__all__` = [migrate_issuer_keys] -> defined
|
||||
- `pg_store.__all__` = [PgStore] -> defined
|
||||
- `pg_migrate.__all__` = [apply_pg_migrations] -> defined
|
||||
|
||||
### Install + compose
|
||||
- `pip install -e . --break-system-packages` -> Successfully installed praxis-server-0.1.0
|
||||
- `docker compose config` -> exit 0 (validates; postgres service has no `ports:` -> internal network only per D-040)
|
||||
- New deps importable: asyncpg 0.31.0, argon2 25.1.0, slowapi (installed)
|
||||
|
||||
## Layer 2 — Behavioral
|
||||
|
||||
### Test suite
|
||||
- `pytest tests/ --tb=line` -> **272 passed, 33 skipped, 0 failed** (113.76s)
|
||||
- Skips are graceful:
|
||||
- 12 `test_pg_store.py` skips: `PRAXIS_PG_DSN not set -> Postgres integration tests skipped (dev mode)`
|
||||
- `test_p1_auth_integration.py` + `test_p1_vc_migration_e2e.py` + `test_backup_restore.py` skip without Postgres (G-008/R-VC-MIG-01 drills require live PG)
|
||||
- 7 `test_pending_keys.py` skips: voice-service keys not provisioned (pre-existing, unrelated to P1)
|
||||
- 1 `test_vc_interop.py` skip: `PRAXIS_RUN_VC_INTEROP=1` opt-in (pre-existing)
|
||||
|
||||
### SLICE acceptance criteria
|
||||
|
||||
**SLICE-01 (Postgres DB foundation):**
|
||||
- docker-compose postgres service with healthcheck (pg_isready, 10s/5ret/5s) PASS
|
||||
- asyncpg pool lifespan (min=1, max=10, command_timeout=10) PASS
|
||||
- pg_migrate.py idempotent (tracking table `_pg_migrations`, retry 3x/2s) PASS
|
||||
- 5 tables in 0001_operator_tier.sql (operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys) PASS
|
||||
- cohort_aggregates NOT partitioned (plain table + index) PASS
|
||||
- gen_random_uuid() used (PG16 core, no extension) PASS
|
||||
- PgStore: all methods implemented (operator CRUD, cohort read/write, issuer keys, credentials, gate events) PASS
|
||||
- Graceful degradation verified: server starts without Postgres, `/health` returns 200, auth returns 503 PASS
|
||||
|
||||
**SLICE-02 (DevOps config):**
|
||||
- `.env.example` documents all operator vars (PRAXIS_PG_PASSWORD, PRAXIS_PG_DSN, PRAXIS_COOKIE_SECRET, PRAXIS_COOKIE_SECURE, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY) PASS
|
||||
- CT memory bumped 4096->6144 in lxc-clone.sh PASS
|
||||
- `scripts/backup-pg.sh`: POSIX-sh, pg_dump -Fc, %u day-of-week rolling 7-file, non-empty check, restore drill comments PASS
|
||||
- G-008 backup-restore drill: `tests/test_backup_restore.py` seeds all 5 tables -> pg_dump -> drop schema -> pg_restore --clean --if-exists -> verify row counts PASS (skips without PG)
|
||||
|
||||
**SLICE-03 (Operator auth):**
|
||||
- argon2id: PasswordHasher defaults (time_cost=3, memory_cost=64MiB, parallelism=4) -> exceeds OWASP PASS
|
||||
- verify_password returns False on mismatch (no exception) PASS
|
||||
- needs_rehash delegates to check_needs_rehash PASS
|
||||
- Signed cookies: SessionMiddleware with `praxis_op`, max_age=28800 (8h), https_only, same_site="strict", path="/" PASS
|
||||
- `https_only` + `same_site` kwargs verified valid for Starlette SessionMiddleware (fix `0a95102` correct) PASS
|
||||
- Missing PRAXIS_COOKIE_SECRET -> ephemeral random + WARNING PASS
|
||||
- PRAXIS_COOKIE_SECURE=false -> WARNING with G-031 reframe text PASS
|
||||
- Rate limit: slowapi Limiter 5/minute, in-memory, per-IP (get_remote_address) PASS
|
||||
- current_operator: 401 on missing cookie, 503 on no Postgres, 401 + session.clear() on inactive PASS
|
||||
- login: rate-limited, verify_password, sets session["operator_id"], updates last_login_at, rehashes if needed PASS
|
||||
- logout: Depends(current_operator), clears session PASS
|
||||
- me: Depends(current_operator), returns operator info PASS
|
||||
|
||||
**SLICE-04 (VC key migration):**
|
||||
- IssuerKeyStore Protocol (runtime_checkable) -> both stores implement it PASS
|
||||
- PgStore.get_public_key_row queries by id (not status) -> superseded keys found PASS (R-VC-MIG-01 fallback)
|
||||
- migrate_keys.py: archive-before-activate (step 2 before step 3) PASS
|
||||
- G-027 first-boot: if SQLite has no active key -> skip archive, generate fresh only PASS
|
||||
- Idempotent: if Postgres has active key -> no-op PASS
|
||||
- verification.py: G-011 two-store fallback (PG for keys -> SQLite for v0.3 creds -> SQLite-only if no PG) PASS
|
||||
- Tests: R-VC-MIG-01 ordering test (instrumented, verifies archive index < supersede index < fresh index) PASS
|
||||
|
||||
**SLICE-05 (Bootstrap CLI):**
|
||||
- scripts/create-operator.py: env-provided creds, argon2id hash, ON CONFLICT DO NOTHING (idempotent) PASS
|
||||
- --update flag: ON CONFLICT DO UPDATE (rehash) PASS
|
||||
- Missing env -> exit 1 with clear error PASS
|
||||
- Retry 3x/5s on connection failure (R-BOOT-01) PASS
|
||||
- config.json operator secrets scope added PASS
|
||||
- .ciagent/.env.secrets.example committed (no real secrets) PASS
|
||||
- .gitignore: `.env.secrets` ignored, `!.ciagent/.env.secrets.example` whitelisted PASS
|
||||
|
||||
**SLICE-06 (P1 integration):**
|
||||
- __main__.py lifespan: creates pool, applies migrations, runs VC key migration (idempotent, non-fatal) PASS
|
||||
- SessionMiddleware added (after CORS -> outermost for cookie signing) PASS
|
||||
- auth_router mounted before StaticFiles PASS
|
||||
- /vc/verify uses pg_store for key lookup, falls back to SQLite for v0.3 creds PASS
|
||||
- VC key migration runs on first boot (_maybe_migrate_issuer_keys) PASS
|
||||
- 503 on auth routes when no Postgres PASS
|
||||
- Learner voice loop unaffected (REQ-NFR-MT-01): /health returns 200 regardless of Postgres PASS
|
||||
|
||||
### REQ coverage
|
||||
| REQ-ID | Covered by | Verification |
|
||||
|--------|-----------|--------------|
|
||||
| REQ-MT-01 | SLICE-01, SLICE-04, SLICE-06 | docker-compose postgres + asyncpg pool + PgStore + IssuerKeyStore protocol + verification swap PASS |
|
||||
| REQ-AUTH-01 | SLICE-03, SLICE-05, SLICE-06 | argon2id + signed cookies + rate limit + current_operator dep + bootstrap CLI PASS |
|
||||
| REQ-NFR-AUTH-01 | SLICE-03, SLICE-06 | argon2id (PasswordHasher defaults), httpOnly+secure+SameSite=Strict, 5/min rate limit, 8h expiry PASS |
|
||||
| REQ-NFR-MT-01 | SLICE-01, SLICE-02, SLICE-06 | postgres internal network only (no ports), 6GB CT, graceful degradation, voice loop unaffected PASS |
|
||||
| REQ-MT-02 (schema) | SLICE-01 | cohort_aggregates table + PgStore.upsert_cohort_aggregate PASS (pipeline is P2) |
|
||||
|
||||
### Grill MUSTs honored
|
||||
| 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: `test_verification_fallback_sqlite_when_pg_missing_credential` (G-011b) + `test_verification_sqlite_only_when_no_pg` (G-011c). |
|
||||
| G-027 (first-boot no v0.3 key) | YES | `migrate_keys.py` line 80-87: if v03_row is None -> archived_key_id=None, skip archive. Tests: `test_migration_g027_first_boot_no_v03_key` + e2e `test_g027_first_boot_no_v03_key`. |
|
||||
| G-031 (R-AUTH-01 reframe) | YES | `cookies.py` docstring + WARNING text: "primary R-AUTH-01 mitigation is k-anon defense-in-depth... this flag is the secondary mitigation." |
|
||||
| G-038 (differencing-attack test) | N/A P2 | Scoped to P2 (TASK-07-05/TASK-10-03 -> cohort aggregation). Not a P1 deliverable. Tracked for P2 verify. |
|
||||
| G-041 (SPA fallback subclass) | N/A P2 | Scoped to P2 (TASK-10-01 -> React Router). Not a P1 deliverable. Tracked for P2 verify. |
|
||||
|
||||
### R-VC-MIG-01 mitigation
|
||||
- **Archived-before-active:** `migrate_keys.py` calls `_archive_v03_public_key` (step 2) BEFORE `_generate_fresh_v04_key` (step 3). Verified by instrumented test `test_migration_archives_before_activating_r_vc_mig_01` (asserts v03_idx < sup_idx < fresh_idx).
|
||||
- **Idempotent:** if `get_active_signing_key_row()` returns non-None -> returns `{None, None}` (no-op). Test `test_migration_idempotent_when_active_key_exists`.
|
||||
- **Cannot replay to overwrite:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> existing keys are not overwritten.
|
||||
|
||||
### Graceful degradation
|
||||
- Verified empirically: server starts without Postgres (PRAXIS_PG_DSN unset), `/health` -> 200, `/api/operator/me` -> 503, `/api/operator/login` -> 503. Learner voice loop unaffected (SQLite path intact).
|
||||
|
||||
## Layer 3 — Security (STRIDE)
|
||||
|
||||
| Threat | Surface | Mitigation | Verified | Disposition |
|
||||
|--------|---------|------------|----------|-------------|
|
||||
| **Spoofing** | operator auth | argon2id (PasswordHasher defaults: time=3, mem=64MiB, par=4) + signed cookies (itsdangerous HMAC-SHA256) | No plaintext passwords in code; cookie signature checked by SessionMiddleware; verify_password catches VerifyMismatchError -> False | accept (low) |
|
||||
| **Tampering** | VC key migration | archived-before-active + idempotent + ON CONFLICT DO NOTHING | Instrumented ordering test; idempotency test; get_public_key_row queries by id (not status) so superseded keys cannot be silently replaced | accept (low) |
|
||||
| **Repudiation** | auth audit | last_login_at updated on successful login | `routes.py:86` calls `pg_store.update_last_login(op_id)`; `pg_store.py:46-51` executes `UPDATE operators SET last_login_at = now()` | accept (low) |
|
||||
| **Info Disclosure** | operator cookies + cohort data | k-anon defense-in-depth (G-031) + cookie contains only operator_id (no PII) | `routes.py:85` sets only `session["operator_id"]`; `dependencies.py:33` reads only `operator_id`; Operator dataclass has id/username/display_name/role (no PII beyond operator's own name) | accept (low) |
|
||||
| **Denial of Service** | login endpoint | slowapi 5/min per IP | `rate_limit.py` Limiter wired; `__main__.py:123-124` registers limiter + RateLimitExceeded handler; test verifies decorator factory | accept (medium -> in-memory counter lost on restart, R-AUTH-03 accepted pilot risk) |
|
||||
| **Elevation of Privilege** | /api/operator/* routes | single operator role + current_operator dep on every protected route | logout + me use `Depends(current_operator)`; no RBAC bypass possible (single role, no role-check logic to bypass); login is NOT auth-gated (correct -> entry point) | accept (low) |
|
||||
|
||||
**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` and `dependencies.py:33`.
|
||||
|
||||
**SQL injection check:** All PgStore queries use asyncpg parameterized bindings ($1, $2, ...). The one f-string in `set_credential_status` (`f"UPDATE ... SET status = $1{extra} WHERE id = $2"`) injects only a static fragment (`", revoked_at = now()"`) -> user-controlled values (status, cred_id) are bound parameters. SAFE.
|
||||
|
||||
**Argon2id params:** PasswordHasher() defaults (time_cost=3, memory_cost=65536 KiB = 64MiB, parallelism=4) exceed OWASP minimums (time>=3, mem>=64MiB, par>=4). Verified via import + hash timing (~119ms hash, ~98ms verify).
|
||||
|
||||
## Layer 4 — Quality (multi-persona review)
|
||||
|
||||
### Correctness
|
||||
- Migration script handles all 3 cases: (a) active key exists -> no-op, (b) v0.3 key exists -> archive+generate, (c) no v0.3 key -> generate only. Logic is sound.
|
||||
- Auth flow: login sets session -> me reads session -> logout clears session. Inactive operator -> 401 + session.clear() (invalidates cookie). Edge cases covered.
|
||||
- Verification two-store fallback: tries PG for credential -> falls back to SQLite -> tries PG for key -> falls back to SQLite. Order is correct (PG preferred for v0.4 keys, SQLite fallback for v0.3 creds).
|
||||
- `_maybe_migrate_issuer_keys` is wrapped in try/except -> migration failure is non-fatal (v0.3 SQLite path remains). Correct for graceful degradation.
|
||||
|
||||
### Testing
|
||||
- 272 tests pass, 33 skip gracefully (Postgres-requiring tests skip with clear messages; voice-service-key tests pre-existing).
|
||||
- Mock-based equivalents exist for all Postgres-requiring paths: `test_auth.py` (mocked PgStore), `test_vc_migration.py` (mocked stores), `test_create_operator.py` (mocked PgStore).
|
||||
- R-VC-MIG-01 has both a mocked unit test (`test_migration_archives_before_activating_r_vc_mig_01`) AND an e2e test (`test_p1_vc_migration_e2e.py` -> requires PG).
|
||||
- Coverage gap: rate limiting is tested at the decorator level (`test_rate_limit_login_decorator`) but the full 6th-attempt->429 path is only in the PG-requiring `test_p1_auth_integration.py`. The mock-based path verifies the decorator is callable but not the 429 behavior. **P1+ flag** (non-blocking -> the 429 path is tested when PG is available).
|
||||
|
||||
### Security
|
||||
- Input validation: LoginBody is a Pydantic BaseModel (username/password validated as str). No raw user input reaches SQL.
|
||||
- Injection vectors: parameterized queries throughout. The one f-string is static-fragment only. **No injection vectors found.**
|
||||
- Cookie secret: if unset -> ephemeral random + WARNING (dev only). For pilot, `.env.secrets.example` documents generation (`openssl rand -base64 48`).
|
||||
- Weak PRAXIS_COOKIE_SECRET: if an attacker knows the secret, they can forge cookies. Mitigation: secret is in `.env.secrets` (gitignored), injected via lxc.environment. **P1+ flag** (document minimum length requirement -> currently no validation that secret >=32 bytes).
|
||||
|
||||
### Performance
|
||||
- asyncpg pool: min=1, max=10, command_timeout=10s. Appropriate for single-instance pilot.
|
||||
- **Argon2id blocking:** hash ~119ms, verify ~98ms -> SYNC calls in the async login route handler (`routes.py:79, 88`). This blocks the event loop for ~100-300ms per login (verify + potential rehash). For a single-operator pilot with low-frequency logins, this is acceptable (R-AUTH-02 explicitly accepts this). **P1+ flag** (offload to `asyncio.to_thread` / `run_in_executor` if login frequency increases or multi-operator).
|
||||
- No other blocking calls in async paths. Pool.acquire() is async. All PgStore methods are async.
|
||||
- Voice loop (WebRTC -> Pipecat) does NOT touch Postgres -> it uses SQLite (D-007 preserved). No perf impact on the <600ms latency budget (C-8).
|
||||
|
||||
### Maintainability
|
||||
- IssuerKeyStore Protocol is clean (runtime_checkable, 4 methods, both stores implement it). Duck-typing formalized without breaking existing PraxisStore.
|
||||
- Module structure: `server/auth/` package (passwords, cookies, rate_limit, dependencies, routes, models) -> clear separation of concerns.
|
||||
- `db/pg_store.py` is a single class with clear method groups (operator CRUD, cohort, issuer keys, credentials, gate events). No god-class anti-pattern.
|
||||
- Naming: consistent `get_*_row` / `set_*` / `insert_*` / `upsert_*` conventions. `learner_ref` is opaque (not FK) per D-031.
|
||||
- Coupling: `verification.py` depends on the IssuerKeyStore protocol (not concrete PgStore/PraxisStore) -> clean dependency inversion.
|
||||
|
||||
### Adversarial
|
||||
- **Weak PRAXIS_COOKIE_SECRET:** if the secret is short or predictable, cookies can be forged. No length validation in `cookies.py` (only checks non-empty). **P1+ flag** (add `len(secret) >= 32` check with WARNING).
|
||||
- **Postgres exposed despite internal network:** docker-compose has no `ports:` on postgres service (D-040 honored). An attacker would need to compromise the LXC CT or praxis-net bridge. Mitigated by network isolation.
|
||||
- **Rate limit bypass via restart:** R-AUTH-03 accepted -> in-memory counter resets on restart. For a single-instance pilot, restarts are operator-initiated and rare. Documented in `rate_limit.py`.
|
||||
- **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 found. (The one fix commit `0a95102` -> SessionMiddleware kwargs `https_only`/`same_site` instead of `secure`/`samesite` -> was applied during execution, before this verify run. Verified correct: `inspect.signature(SessionMiddleware.__init__)` confirms `https_only` and `same_site` are the valid parameter names.)
|
||||
|
||||
## P1+ Flagged for Post-Hoc Review
|
||||
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.**
|
||||
|
||||
---
|
||||
|
||||
## Verification Result
|
||||
|
||||
Phase 1 (Operator Foundation) is **APPROVED_WITH_NOTES**. All 4 layers pass. All 5 P1-scoped REQ-IDs are covered. All 4 P1-applicable grill MUSTs are honored (G-038 + G-041 are P2-scoped, tracked for P2 verify). No P0 issues. 4 P1+ items flagged for post-hoc review in P3 (non-blocking). The phase is ready for ship (v0.1.7) -> the orchestrator delegates to ship after this verify.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Praxis — v0.4 Phase 2 Verification (Cohort Dashboard + Aggregation)
|
||||
|
||||
## Summary
|
||||
- Verdict: **APPROVE_WITH_NOTES**
|
||||
- Layers: structural **PASS**, behavioral **PASS**, security **PASS**, quality **PASS**
|
||||
- REQ coverage: **4/4** (REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02 pipeline completion)
|
||||
- Grill MUSTs honored: **2/2** (G-038 differencing-attack test, G-041 SPA fallback via custom StaticFiles subclass)
|
||||
- P0 fixes applied: **0** (none needed — no P0 issues found)
|
||||
- P1+ flagged: **4** (non-blocking, for post-hoc review in P3)
|
||||
|
||||
> Phase 2 (P2) of the v0.4 milestone covers SLICE-07..10 (23 tasks): cohort aggregation pipeline, operator API endpoints, React cohort dashboard, and P2 integration. 4 commits since `milestone/v0.4-operator-tier`: c396ded (SLICE-07), a7f7c4e (SLICE-08), d39bd14 (SLICE-09), de2020e (SLICE-10).
|
||||
>
|
||||
> This report supersedes the prior TASK-10-05 verification matrix (preserved in §REQ-ID Coverage Matrix below).
|
||||
|
||||
---
|
||||
|
||||
## Layer 1 — Structural
|
||||
|
||||
### 1.1 File existence (all P2 files present)
|
||||
|
||||
| File | Exists | LOC | Notes |
|
||||
|------|--------|-----|-------|
|
||||
| `server/cohort/__init__.py` | YES | 0 | package marker |
|
||||
| `server/cohort/aggregator.py` | YES | 230 | k-anon suppression, 7-day window, metric cells |
|
||||
| `server/cohort/hook.py` | YES | 44 | fire-and-forget on_session_end, no-op if no Postgres |
|
||||
| `server/cohort/nightly.py` | YES | 232 | NightlyScheduler, 03:00 CT, R-DASH-04 retry |
|
||||
| `server/operator/__init__.py` | YES | 0 | package marker |
|
||||
| `server/operator/_common.py` | YES | 93 | shared Cell/PathView/ViewResponse models, require_pg_store, all_recent_aggregates |
|
||||
| `server/operator/cohort.py` | YES | 42 | GET /api/operator/cohort (practice volume) |
|
||||
| `server/operator/mastery.py` | YES | 45 | GET /api/operator/mastery (mastery progression) |
|
||||
| `server/operator/failure_patterns.py` | YES | 44 | GET /api/operator/failure-patterns |
|
||||
| `server/operator/credentials.py` | YES | 78 | GET /api/operator/credentials + POST /{id}/revoke |
|
||||
| `client/src/operator/Login.tsx` | YES | 93 | login form, 429 handling, keyboard-accessible |
|
||||
| `client/src/operator/Dashboard.tsx` | YES | 120 | auth gate, 3 view tabs, freshness, logout |
|
||||
| `client/src/operator/Sparkline.tsx` | YES | 49 | inline SVG polyline, zero deps |
|
||||
| `client/src/operator/views/PracticeVolume.tsx` | YES | 81 | practice volume view + sparklines |
|
||||
| `client/src/operator/views/MasteryProgression.tsx` | YES | 84 | mastery progression view |
|
||||
| `client/src/operator/views/FailurePatterns.tsx` | YES | 94 | failure patterns view |
|
||||
| `client/src/operator/views/_viewCommon.ts` | YES | 60 | shared Cell type, suppressedLabel, formatFreshness |
|
||||
| `client/src/operator/__tests__/Dashboard.test.tsx` | YES | 193 | 17 vitest tests |
|
||||
| `tests/test_cohort_aggregation.py` | YES | 246 | k-anon threshold, idempotency, G-038 |
|
||||
| `tests/test_cohort_nightly.py` | YES | 199 | scheduler timing, R-DASH-04, reconcile |
|
||||
| `tests/test_operator_endpoints.py` | YES | 304 | 401/200 auth, suppressed cells, revoke, R-DASH-02 |
|
||||
| `tests/test_p2_aggregation_integration.py` | YES | 236 | e2e aggregation→endpoint (skips without Postgres) |
|
||||
| `tests/test_p2_spa_fallback.py` | YES | 128 | 9 SPA fallback assertions (G-041) |
|
||||
| `client/vitest.config.ts` | YES | 13 | vitest config |
|
||||
| `client/src/App.tsx` (extended) | YES | 27 | BrowserRouter routes, voice UI at / unchanged |
|
||||
| `client/src/VoiceSession.tsx` | YES | 177 | extracted voice session (unchanged behavior) |
|
||||
| `server/session_recorder.py` (extended) | YES | +52 | aggregation hook chained, off voice path |
|
||||
| `server/__main__.py` (extended) | YES | +61 | operator routers + SpaStaticFiles + nightly scheduler |
|
||||
|
||||
### 1.2 Imports resolve
|
||||
- `python3 -c "import server.__main__"` → **OK** (server imports cleanly, logs "SPA fallback enabled")
|
||||
- `python3 -c "import server.cohort.aggregator, server.cohort.hook, server.cohort.nightly, server.operator.cohort, server.operator.mastery, server.operator.failure_patterns, server.operator.credentials"` → **OK** (all 7 new P2 modules import)
|
||||
|
||||
### 1.3 No stubs/TODOs in new P2 code
|
||||
- `grep -r "TODO|FIXME|stub|placeholder|NotImplemented" server/cohort/ server/operator/` → **No matches** (zero stubs, zero TODOs in new P2 server code)
|
||||
|
||||
### 1.4 Deps + build
|
||||
- `pip install -e . --break-system-packages` → **OK** (praxis-server 0.1.0 installed; P1 deps asyncpg/argon2-cffi/slowapi present)
|
||||
- `docker compose config` → **OK** (validates, praxis-data volume present)
|
||||
- `cd client && npm run build` → **OK** (vite v8.2.0, 168 modules, built in 547ms; bundle 662KB / 186KB gzip — within react-router-dom budget)
|
||||
- `cd client && npm run typecheck` → **OK** (tsc -b --noEmit, no errors)
|
||||
|
||||
### 1.5 Router mount order (critical for R-DASH-03)
|
||||
Verified in `server/__main__.py` diff (lines 256-298):
|
||||
1. `app.include_router(auth_router)` — `/api/operator/login|logout|me`
|
||||
2. `app.include_router(cohort_router)` — `/api/operator/cohort`
|
||||
3. `app.include_router(mastery_router)` — `/api/operator/mastery`
|
||||
4. `app.include_router(failure_router)` — `/api/operator/failure-patterns`
|
||||
5. `app.include_router(credentials_router)` — `/api/operator/credentials`
|
||||
6. `app.mount("/", SpaStaticFiles(...), name="spa")` — SPA fallback (AFTER all API routes)
|
||||
|
||||
**Order is correct**: API routes take precedence over the SPA fallback mount. R-DASH-03 verified.
|
||||
|
||||
**Layer 1 verdict: PASS** — all structural checks pass.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2 — Behavioral
|
||||
|
||||
### 2.1 Test results
|
||||
|
||||
| Suite | Result | Notes |
|
||||
|-------|--------|-------|
|
||||
| `python3 -m pytest tests/` | **317 passed, 36 skipped, 0 failed** | matches expected (Postgres-requiring tests skip gracefully — PRAXIS_PG_DSN unset) |
|
||||
| `cd client && npx vitest run` | **17/17 passed** | Dashboard auth gate, login form (200/401/429), sparkline (empty/dot/polyline/flat), suppressedLabel, formatFreshness, no-PII-in-DOM |
|
||||
| `cd client && npm run build` | **PASS** | 168 modules, 547ms |
|
||||
| `cd client && npm run typecheck` | **PASS** | tsc clean |
|
||||
| P2-specific (`test_p2_spa_fallback.py` + `test_operator_endpoints.py` + `test_cohort_aggregation.py` + `test_cohort_nightly.py`) | **45/45 passed** | full P2 unit + SPA fallback coverage |
|
||||
| `test_p2_aggregation_integration.py` | **3 skipped** | gracefully skipped (no PRAXIS_PG_DSN) — e2e aggregation→endpoint path covered by unit tests with mocked PgStore |
|
||||
|
||||
### 2.2 P2 SLICE acceptance criteria
|
||||
|
||||
**SLICE-07 (aggregation pipeline):**
|
||||
- ✅ k-anon threshold exactly 10 — `test_k_anon_threshold_at_10` asserts `K_ANON_THRESHOLD == 10`; `test_9_learners_suppressed` (9 → suppressed), `test_10_learners_not_suppressed` (10 → not suppressed, value non-null), `test_11_learners_not_suppressed` (11 → not suppressed)
|
||||
- ✅ Idempotent upsert — `test_idempotent_same_session_twice` (ON CONFLICT at DB layer)
|
||||
- ✅ 7-day window — `test_rolling_window_7_days` (2026-08-04 → start=2026-07-29, 6-day span)
|
||||
- ✅ All metrics computed — `test_multiple_metrics_computed` (sessions_count, active_learners_count, gate_open_rate, median_mastery_score, rubric_criterion_mean:*, failure_mode:*, branch:*)
|
||||
- ✅ No PII in upserts — `test_no_pii_in_upsert_calls` (raw learner_ref not in any cell arg; cell_count is int)
|
||||
- ✅ Hook non-blocking — `server/cohort/hook.py` uses `asyncio.create_task` in `session_recorder.py:161`; hook swallows exceptions (`test_hook_failure_logs_does_not_raise`)
|
||||
- ✅ Hook no-op without Postgres — `test_hook_no_postgres_is_noop`
|
||||
- ✅ Nightly scheduler timing — `test_seconds_until_next_03_ct_future_today` + `test_seconds_until_next_03_ct_past_today_wraps_tomorrow`
|
||||
- ✅ R-DASH-04 nightly failure retry — `test_r_dash_04_nightly_failure_does_not_crash_scheduler`
|
||||
- ✅ Nightly reconcile recomputes — `test_reconcile_recomputes_all_paths`
|
||||
- ✅ Scheduler lifecycle — `test_scheduler_start_stop_lifecycle`
|
||||
|
||||
**SLICE-08 (operator API endpoints):**
|
||||
- ✅ All 4 endpoints auth-gated (401 without cookie) — `test_cohort_401_without_cookie`, `test_mastery_401_without_cookie`, `test_failure_patterns_401_without_cookie`, `test_credentials_401_without_cookie`, `test_revoke_401_without_cookie`
|
||||
- ✅ All 4 endpoints 200 with cookie — `test_cohort_200_with_cookie`, `test_mastery_200_with_cookie`, `test_failure_patterns_200_with_cookie`, `test_credentials_200_with_cookie`
|
||||
- ✅ Suppressed cells value=null — `test_suppressed_cells_value_null` (cell_suppressed=true → value=null)
|
||||
- ✅ last_updated = max(updated_at) — `test_last_updated_is_max`
|
||||
- ✅ Credential revoke — `test_credential_revoke_sets_status_revoked` (status='revoked', set_credential_status awaited) + `test_credential_revoke_404_unknown` (404 for unknown)
|
||||
- ✅ No per-learner data (R-DASH-02) — `test_no_per_learner_data_in_cohort_response` (no "learner-1", no "learner_ref" in response)
|
||||
- ✅ 503 when no Postgres — `test_cohort_503_no_postgres` (graceful degradation)
|
||||
|
||||
**SLICE-09 (React dashboard):**
|
||||
- ✅ react-router-dom@^7 added (`client/package.json`)
|
||||
- ✅ BrowserRouter wrapper + route switch — `client/src/App.tsx`: `/` → VoiceSession (unchanged), `/operator/login` → Login, `/operator/dashboard` → Dashboard, `*` → VoiceSession (fallback)
|
||||
- ✅ Login form — Login.tsx, 429 handling (`test shows rate-limit message on 429`), keyboard-accessible (label associations)
|
||||
- ✅ Dashboard shell + auth gate — Dashboard.tsx, 401 on /me → redirect (`test redirects to /operator/login on 401`), 3 view tabs, freshness indicator, logout
|
||||
- ✅ Inline SVG sparkline — Sparkline.tsx (49 LOC, zero deps), empty/dot/polyline/flat-line cases tested
|
||||
- ✅ 3 view components — PracticeVolume, MasteryProgression, FailurePatterns (read-only, no drill-down)
|
||||
- ✅ Suppressed cell display — "— (<10 learners)" (`suppressedLabel` test)
|
||||
- ✅ Freshness indicator — formatFreshness (m/h/d ago)
|
||||
- ✅ No PII in DOM — `test does not render learner_ref fields`
|
||||
|
||||
**SLICE-10 (P2 integration):**
|
||||
- ✅ SPA fallback (G-041) — custom `SpaStaticFiles` subclass in `__main__.py:279-289`, NOT a catch-all route; 9 assertions in `test_p2_spa_fallback.py` all pass
|
||||
- ✅ Voice UI at `/` unchanged (R-DASH-05) — `test_root_serves_voice_ui` (200, text/html, `<div id="root">`)
|
||||
- ✅ API routes return JSON not HTML — `test_api_operator_cohort_is_json_not_html`, `test_health_is_json`, `test_vc_verify_nonexistent_is_404`
|
||||
- ✅ Assets served by StaticFiles — `test_assets_served_by_staticfiles_not_spa_fallback` (`/assets/index.js` → javascript content-type, not index.html)
|
||||
- ✅ Nightly scheduler starts in lifespan — `server/__main__.py:116` `await nightly.start(app.state.pg_store)`; cancelled on shutdown (`await nightly.stop()` line 121)
|
||||
- ✅ E2e aggregation→endpoint — `test_p2_aggregation_integration.py::test_aggregation_to_endpoint_e2e` (skips without Postgres; logic covered by unit tests with mocked store)
|
||||
|
||||
### 2.3 REQ coverage
|
||||
|
||||
| REQ-ID | Covered by | Status |
|
||||
|--------|-----------|--------|
|
||||
| **REQ-DASH-01** (cohort dashboard, 3 views, k-anon, React under /operator/*) | SLICE-08 (4 endpoints), SLICE-09 (React UI), SLICE-10 (integration). `test_operator_endpoints.py` (all 4 endpoints 200/401), `Dashboard.test.tsx` (auth gate, login, 3 views), `test_p2_spa_fallback.py` (SPA serves /operator/*) | **COVERED** |
|
||||
| **REQ-NFR-DASH-01** (k-anonymity ≥ 10) | SLICE-07 (write-time suppression in `aggregator.py`), SLICE-08 (query returns value=null for suppressed), SLICE-09 (display "— (<10 learners)"), SLICE-10 (e2e). `test_cohort_aggregation.py` (threshold at 10, 9/10/11 learners), `test_operator_endpoints.py::test_suppressed_cells_value_null`, `Dashboard.test.tsx::suppressedLabel`, G-038 differencing-attack | **COVERED** |
|
||||
| **REQ-NFR-DASH-02** (freshness ≤ 24h) | SLICE-07 (nightly job + on-session-end hook), SLICE-10 (e2e). `test_cohort_nightly.py` (scheduler timing, reconcile, R-DASH-04), `test_operator_endpoints.py::test_last_updated_is_max`, `test_p2_aggregation_integration.py::test_nightly_reconciliation_updates_last_updated` (skips without Postgres) | **COVERED** |
|
||||
| **REQ-MT-02** (pipeline completion — schema P1, pipeline P2) | SLICE-07 (aggregator + hook + nightly), SLICE-10 (e2e). `test_cohort_aggregation.py` (idempotent, multiple metrics, hook no-op/failure), `test_cohort_nightly.py` (reconcile), `test_p2_aggregation_integration.py::test_aggregation_to_endpoint_e2e` (skips without Postgres) | **COVERED** |
|
||||
|
||||
**4/4 P2 REQ-IDs covered.**
|
||||
|
||||
### 2.4 Grill MUSTs honored
|
||||
|
||||
**G-038 (differencing-attack test) — HONORED:**
|
||||
- Unit layer: `test_cohort_aggregation.py::test_g038_differencing_attack_cannot_isolate_dropped_learner` — seeds 10 learners in window A, 9 in window B (learner-9 dropped), asserts window B is FULLY suppressed (value=NULL) so the dropped learner's contribution is not recoverable via subtraction. Verifies no per-learner ref leaks in either window's aggregate cells.
|
||||
- API e2e layer: `test_p2_aggregation_integration.py::test_g038_differencing_attack_api_layer` — 10 learners on path diff_a, 9 on diff_b, asserts "a-9" not in response text and diff_b cells all suppressed with value=None. (Skips without Postgres — logic verified at unit layer.)
|
||||
|
||||
**G-041 (SPA fallback via custom StaticFiles subclass) — HONORED:**
|
||||
- Implementation: `server/__main__.py:279-289` defines `class SpaStaticFiles(StaticFiles)` with `get_response` override that returns `FileResponse("index/dist/index.html")` only on 404 (non-file paths). This is the custom subclass approach mandated by G-041, NOT a `@app.get("/{path:path}")` catch-all (which would shadow asset serving per the grill's analysis).
|
||||
- Test: `test_p2_spa_fallback.py::test_assets_served_by_staticfiles_not_spa_fallback` verifies `/assets/index.js` returns javascript content (not index.html) — the critical assertion 8 from TASK-10-04.
|
||||
|
||||
### 2.5 Voice UI at `/` unchanged (R-DASH-03, R-DASH-05)
|
||||
|
||||
- **Server**: `SpaStaticFiles` mount at `/` with `html=True` serves `index.html` for `/` (unchanged from v0.3 StaticFiles behavior). API routes registered before the mount take precedence. `test_root_serves_voice_ui` confirms 200 + text/html + `<div id="root">`.
|
||||
- **Client**: `client/src/App.tsx` route `/` → `<VoiceSession />` (the existing voice session UI, extracted from the old App.tsx to VoiceSession.tsx — behavior unchanged). The `*` catch-all also serves VoiceSession (R-DASH-05: unknown routes fall back to learner surface, not a 404).
|
||||
- **No regression**: 317 passed, 0 failed — all v0.1/v0.2/v0.3 tests still pass.
|
||||
|
||||
**Voice UI at `/` unchanged: CONFIRMED.**
|
||||
|
||||
**Layer 2 verdict: PASS** — all behavioral checks pass.
|
||||
|
||||
---
|
||||
|
||||
## Layer 3 — Security (STRIDE)
|
||||
|
||||
### Spoofing
|
||||
- **Operator endpoints auth-gated via `current_operator` dependency.**
|
||||
- Verified: all 4 operator routers (`cohort.py`, `mastery.py`, `failure_patterns.py`, `credentials.py`) import `current_operator` from `server.auth.dependencies` and apply `op: Operator = Depends(current_operator)` on every endpoint.
|
||||
- Test coverage: 5 tests assert 401 without cookie (`test_cohort_401_without_cookie`, `test_mastery_401_without_cookie`, `test_failure_patterns_401_without_cookie`, `test_credentials_401_without_cookie`, `test_revoke_401_without_cookie`).
|
||||
- **Disposition: low (accept).** No bypass path found — every `/api/operator/*` route (except `/login` which is rate-limited, not auth-gated) requires the dependency.
|
||||
|
||||
### Tampering
|
||||
- **Aggregation pipeline — k-anon suppression at write time.**
|
||||
- `server/cohort/aggregator.py:87` `suppressed = active_count < K_ANON_THRESHOLD` (K_ANON_THRESHOLD=10, module constant). Suppression applied before `upsert_cohort_aggregate` — value set to `None` when suppressed (lines 90, 94, 103, etc.).
|
||||
- Nightly reconciliation (`nightly.py:127`) re-applies the same threshold: `suppressed = active_count < K_ANON_THRESHOLD`.
|
||||
- Suppression cannot be bypassed via the API: endpoints read `cohort_aggregates` rows as-is (no post-processing that could un-suppress); suppressed cells have `value=null` in the DB (enforced at write time).
|
||||
- **Disposition: low (accept).** Write-time suppression is server-side, not display-only.
|
||||
|
||||
### Repudiation
|
||||
- **Credential revoke (POST /api/operator/credentials/{id}/revoke).**
|
||||
- The revoke endpoint sets `status='revoked'` + `revoked_at=now()` in Postgres (`pg_store.py:224` `extra = ", revoked_at = now()" if status == 'revoked'`). The `revoked_at` timestamp is an audit trail.
|
||||
- **GAP (P1+ flagged)**: The revoke endpoint does NOT log the revocation event at the application level, and the `operator_id` of the revoking operator is available via `current_operator` but is NOT recorded against the credential revocation. The `issued_credentials.operator_id` column tracks the *issuer*, not the *revoker*. There is no revocation audit log linking operator→action→credential→timestamp.
|
||||
- Mitigation: the `revoked_at` timestamp + the signed session cookie (which records `operator_id` in `request.session`) provide a partial audit trail, but correlating them requires cross-referencing session logs.
|
||||
- **Disposition: medium (mitigate — P1+ flagged).** Add application-level logging of revocation events (operator_id, credential_id, timestamp) in P3.
|
||||
|
||||
### Info Disclosure
|
||||
- **k-anonymity ≥ 10 enforced (REQ-NFR-DASH-01).**
|
||||
- Write-time suppression: cells with < 10 distinct learners → `cell_suppressed=TRUE`, `value=NULL`. Verified by `test_9_learners_suppressed`, `test_10_learners_not_suppressed`.
|
||||
- No per-learner drill-down (R-DASH-02): endpoints return only aggregate cells (path, metric, value, cell_count, cell_suppressed) — no `learner_ref` in cohort/mastery/failure responses. Verified by `test_no_per_learner_data_in_cohort_response` (no "learner_ref" string, no "learner-1" in response).
|
||||
- G-038 differencing-attack defense: window B (9 learners) is fully suppressed (value=NULL), so subtracting B from A is not possible. Verified at unit + API layers.
|
||||
- No PII in Postgres aggregates (D-031): only opaque `learner_ref` for distinct counting, never stored in aggregate cells. Verified by `test_no_pii_in_upsert_calls`.
|
||||
- **Disposition: low (accept).** k-anon defense-in-depth is sound; G-038 explicitly tested.
|
||||
|
||||
### Denial of Service
|
||||
- **Aggregation hook is async fire-and-forget (non-blocking).**
|
||||
- `server/session_recorder.py:161` `asyncio.create_task(self._run_cohort_aggregation(session_outcome))` — hook runs off the voice path (C-8, D-054). Voice loop latency unaffected.
|
||||
- `server/cohort/hook.py:37` `except Exception: log.exception(...)` — hook failure does not propagate; nightly job reconciles.
|
||||
- `test_hook_failure_logs_does_not_raise` confirms no exception propagation.
|
||||
- Nightly job doesn't block the event loop: `NightlyScheduler._run_loop` uses `asyncio.sleep(secs)` (cooperative); reconciliation is a sequence of `await pg_store.upsert_cohort_aggregate(...)` calls (yields between each).
|
||||
- **Disposition: low (accept).** Hook failure → log + nightly reconcile (R-DASH-04). No crash path.
|
||||
|
||||
### Elevation of Privilege
|
||||
- **Single operator role. No RBAC bypass.**
|
||||
- All 4 operator endpoints + credential management use `Depends(current_operator)`. The `current_operator` dependency (`server/auth/dependencies.py`) checks `request.session["operator_id"]` → fetches operator → checks `is_active=True` → returns `Operator`. No role-based dispatch exists (single role).
|
||||
- The `current_operator` dependency never trusts the client (D-057) — it validates the signed session cookie server-side.
|
||||
- **Disposition: low (accept).** No RBAC to bypass; single operator role; auth-gated everywhere.
|
||||
|
||||
**Layer 3 verdict: PASS** — all STRIDE categories low except Repudiation (medium, mitigated, P1+ flagged). No high-severity findings.
|
||||
|
||||
---
|
||||
|
||||
## Layer 4 — Quality (multi-persona review)
|
||||
|
||||
### Correctness
|
||||
- **k-anon threshold (exactly 10):** `K_ANON_THRESHOLD = 10` module constant; 9 → suppressed, 10 → not suppressed, 11 → not suppressed. Tests cover all three boundaries. ✅
|
||||
- **Aggregation idempotency:** ON CONFLICT upsert at the DB layer (PgStore); hook is deterministic (same learner produces same distinct-count + counter state in cache). `test_idempotent_same_session_twice` passes. ✅
|
||||
- **Nightly scheduler timing:** `seconds_until_next_03_ct` computes seconds until 03:00 CT (fixed UTC-5 offset, documented DST approximation — acceptable for nightly reconciliation). `test_seconds_until_next_03_ct_future_today` + `test_seconds_until_next_03_ct_past_today_wraps_tomorrow` pass. ✅
|
||||
- **SPA fallback (G-041):** Custom `SpaStaticFiles` subclass, NOT catch-all route. Serves assets normally (JS/CSS), falls back to index.html only on 404. `test_assets_served_by_staticfiles_not_spa_fallback` confirms assets are not shadowed. ✅
|
||||
|
||||
### Testing
|
||||
- **Coverage gaps:** Postgres-requiring tests (`test_p2_aggregation_integration.py`, `test_pg_store.py`) skip gracefully when `PRAXIS_PG_DSN` unset — 36 skipped total, 0 failed. The e2e aggregation→endpoint→dashboard path is covered by unit tests with mocked PgStore (45/45 P2 tests pass). ✅
|
||||
- **Client tests (vitest):** 17/17 pass — auth gate, login (200/401/429), sparkline (4 cases), suppressedLabel, formatFreshness, no-PII-in-DOM. ✅
|
||||
- **G-038 differencing-attack coverage:** Unit layer (`test_g038_differencing_attack_cannot_isolate_dropped_learner`) + API e2e layer (`test_g038_differencing_attack_api_layer`). The unit test is the primary proof (runs without Postgres); the e2e test is a bonus that skips without Postgres. ✅
|
||||
|
||||
### Security
|
||||
- **SQL injection in PgStore queries:** All queries use asyncpg parameterized placeholders (`$1`, `$2`, etc.). Verified in `pg_store.py` (operator CRUD, cohort upsert, credential methods, gate events) and `server/operator/_common.py::all_recent_aggregates` (`WHERE window_start >= $1`). One f-string interpolation in `set_credential_status` (`f"UPDATE ... SET status = $1{extra} WHERE id = $2"`) — but `extra` is a hardcoded constant (`, revoked_at = now()` or empty) derived from the `status` value comparison, NOT user input. Safe. ✅
|
||||
- **k-anon suppression enforced server-side:** Suppression is applied in `aggregator.py` (write time) and re-applied in `nightly.py` (reconcile). The API endpoints read cells as-is — no client-side or display-only suppression. ✅
|
||||
- **No PII in API responses:** Cohort/mastery/failure endpoints return only (path, metric, value, cell_count, cell_suppressed, updated_at). Credentials endpoint returns (id, learner_ref, vc_type, status, issued_at, revoked_at) — `learner_ref` is an opaque string (D-031), not PII. ✅
|
||||
|
||||
### Performance
|
||||
- **Aggregation hook non-blocking:** `asyncio.create_task` in `session_recorder.py:161` — fire-and-forget, off the voice path (C-8). ✅
|
||||
- **Nightly job doesn't block event loop:** `asyncio.sleep(secs)` + sequential `await` calls (cooperative). Runs at 03:00 CT (low activity). ✅
|
||||
- **SPA fallback doesn't add latency to API routes:** API routes are registered before the StaticFiles mount — FastAPI matches API routes first (no fallback overhead). ✅
|
||||
|
||||
### Maintainability
|
||||
- **SpaStaticFiles subclass:** Clean 11-line override (`get_response` catches 404 → FileResponse). Well-commented with G-041 rationale. ✅
|
||||
- **3 view components consistent:** All 3 (PracticeVolume, MasteryProgression, FailurePatterns) share `_viewCommon.ts` (Cell type, suppressedLabel, formatFreshness) and follow the same fetch→render pattern. ✅
|
||||
- **Router mounting order:** API routes → SPA fallback mount. Documented in `__main__.py:256-298` comments. ✅
|
||||
|
||||
### Adversarial
|
||||
- **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). ✅
|
||||
- **What if k-anon threshold is lowered via config?** `K_ANON_THRESHOLD = 10` is a module constant in `aggregator.py`, NOT configurable via env. Changing it requires a code change + redeploy. This is correct for a privacy control — it should not be runtime-configurable. ✅
|
||||
- **What if the aggregation hook runs before Postgres is healthy?** The hook checks `pg_store is None` → no-op + WARNING (`hook.py:27-32`). If Postgres is unhealthy mid-session, `upsert_cohort_aggregate` raises → caught by `hook.py:37` `except Exception: log.exception(...)` → nightly job reconciles. ✅
|
||||
|
||||
**Layer 4 verdict: PASS** — no quality issues found. Code is clean, well-commented, consistently structured, and adversarially sound.
|
||||
|
||||
---
|
||||
|
||||
## P0 Fixes Applied
|
||||
|
||||
**None.** No P0 issues (broken tests, missing REQ coverage, security holes) were found. The P2 implementation is correct, complete, and secure.
|
||||
|
||||
---
|
||||
|
||||
## P1+ Flagged for Post-Hoc Review
|
||||
|
||||
The following non-blocking issues are flagged for review in the final phase (P3):
|
||||
|
||||
### P1+-01: Credential revocation lacks application-level audit log (Repudiation)
|
||||
- **File:** `server/operator/credentials.py`
|
||||
- **Issue:** The `revoke_credential` endpoint sets `status='revoked'` + `revoked_at=now()` in Postgres but does NOT log the revocation event at the application level, and the revoking `operator_id` (available via `current_operator`) is not recorded against the revocation action. The `issued_credentials.operator_id` column tracks the *issuer*, not the *revoker*.
|
||||
- **Risk:** An operator who revokes a credential leaves a DB timestamp but no application log linking *who* revoked *which* credential *when*. Correlating requires cross-referencing session logs.
|
||||
- **Mitigation present:** `revoked_at` timestamp in DB + signed session cookie (operator_id in session).
|
||||
- **Recommended fix (P3):** Add `log.info("credential revoked: operator=%s cred_id=%s", op.id, cred_id)` in `revoke_credential`, and consider an `audit_log` table or `revoked_by_operator_id` column on `issued_credentials`.
|
||||
|
||||
### P1+-02: Nightly scheduler uses fixed UTC-5 offset (not true America/Winnipeg DST)
|
||||
- **File:** `server/cohort/nightly.py:27` `CT = _dt.timezone(_dt.timedelta(hours=-5), "CT")`
|
||||
- **Issue:** The CT timezone is approximated as a fixed UTC-5 offset. America/Winnipeg observes CST (UTC-6) in winter + CDT (UTC-5) in summer. The scheduler will drift by 1 hour across DST boundaries (the nightly job runs at 02:00 or 04:00 local instead of 03:00).
|
||||
- **Risk:** Low — the nightly job runs once/day; a 1-hour drift is acceptable for a reconciliation job (on-session-end hook keeps data fresh ≤ 24h).
|
||||
- **Mitigation present:** Documented in `nightly.py:36-41` comments ("drift of ≤1h over DST boundaries is acceptable... a future hardening would use zoneinfo.ZoneInfo").
|
||||
- **Recommended fix (P3):** Replace `CT` constant with `zoneinfo.ZoneInfo("America/Winnipeg")` for proper DST handling.
|
||||
|
||||
### P1+-03: Aggregation in-memory cache is per-PgStore-instance (lost on restart)
|
||||
- **File:** `server/cohort/aggregator.py:162-170` `_cache(pg_store)`
|
||||
- **Issue:** The aggregator maintains a per-PgStore-instance in-memory cache (`_agg_cache`) for running counters + distinct learner sets. On server restart, the cache is lost — the next on-session-end hook starts fresh, and the active_learners_count may reset to 1 (under-counting distinct learners until the nightly job reconciles from `mastery_gate_events`).
|
||||
- **Risk:** Low — the nightly job reconciles the true distinct count from the audit log (`mastery_gate_events`). Between restart and nightly reconcile, cells may be incorrectly suppressed (under-count → over-suppression, which is privacy-safe but value-destroying).
|
||||
- **Mitigation present:** Nightly reconciliation recomputes from `mastery_gate_events` (the source of truth).
|
||||
- **Recommended fix (P3):** Document that the in-memory cache is best-effort + nightly reconcile is authoritative, OR persist the distinct-learner set to Postgres (adds a table — may not be worth the complexity for pilot scale).
|
||||
|
||||
### P1+-04: `set_credential_status` uses f-string interpolation in SQL (code smell, not vulnerability)
|
||||
- **File:** `db/pg_store.py:227` `f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2"`
|
||||
- **Issue:** The `extra` variable (`, revoked_at = now()` or empty string) is interpolated via f-string into the SQL query. While `extra` is a hardcoded constant (not user input) and `status`/`cred_id` are parameterized, f-strings in SQL are a code smell that future maintainers might copy incorrectly.
|
||||
- **Risk:** None (current code is safe — `extra` is derived from `status == "revoked"` comparison, not user input).
|
||||
- **Recommended fix (P3):** Refactor to two explicit queries: `UPDATE ... SET status = $1 WHERE id = $2` and `UPDATE ... SET status = $1, revoked_at = now() WHERE id = $2`, eliminating the f-string.
|
||||
|
||||
---
|
||||
|
||||
## REQ-ID Coverage Matrix (from TASK-10-05, preserved)
|
||||
|
||||
### REQ-DASH-01 — Cohort dashboard (3 views + auth gate)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_operator_endpoints.py | test_cohort_200_with_cookie | GET /api/operator/cohort returns practice volume |
|
||||
| tests/test_operator_endpoints.py | test_mastery_200_with_cookie | GET /api/operator/mastery returns mastery progression |
|
||||
| tests/test_operator_endpoints.py | test_failure_patterns_200_with_cookie | GET /api/operator/failure-patterns returns failure data |
|
||||
| tests/test_operator_endpoints.py | test_credentials_200_with_cookie | GET /api/operator/credentials lists VCs |
|
||||
| tests/test_operator_endpoints.py | test_cohort_401_without_cookie (+ 4 others) | All endpoints auth-gated (401) |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | Dashboard auth gate | React auth gate redirects on 401 from /me |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | Login form | POST /api/operator/login → dashboard |
|
||||
| tests/test_p2_spa_fallback.py | test_operator_dashboard_spa_fallback | /operator/dashboard serves index.html (SPA) |
|
||||
| tests/test_p2_spa_fallback.py | test_operator_login_spa_fallback | /operator/login serves index.html (SPA) |
|
||||
|
||||
### REQ-NFR-DASH-01 — k-anonymity ≥ 10 (write-time suppression + query + display + e2e)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_aggregation.py | test_k_anon_threshold_at_10 | K_ANON_THRESHOLD == 10 |
|
||||
| tests/test_cohort_aggregation.py | test_9_learners_suppressed | 9 learners → cell_suppressed=TRUE, value=NULL |
|
||||
| tests/test_cohort_aggregation.py | test_10_learners_not_suppressed | 10 learners → non-suppressed, value non-null |
|
||||
| tests/test_cohort_aggregation.py | test_11_learners_not_suppressed | 11 learners → non-suppressed |
|
||||
| tests/test_cohort_aggregation.py | test_no_pii_in_upsert_calls | No raw learner_ref in aggregate cell args |
|
||||
| tests/test_cohort_aggregation.py | test_g038_differencing_attack_cannot_isolate_dropped_learner | G-038: 10 in window A, 9 in B → dropped learner not isolatable |
|
||||
| tests/test_operator_endpoints.py | test_suppressed_cells_value_null | API: suppressed cells have value=null |
|
||||
| tests/test_operator_endpoints.py | test_no_per_learner_data_in_cohort_response | API: no per-learner data (R-DASH-02) |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | suppressedLabel | UI: suppressed cells render "— (<10 learners)" |
|
||||
| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e | E2e: 12 learners non-suppressed, 5 suppressed (skips without Postgres) |
|
||||
| tests/test_p2_aggregation_integration.py | test_g038_differencing_attack_api_layer | G-038 e2e at API layer (skips without Postgres) |
|
||||
|
||||
### REQ-NFR-DASH-02 — Freshness ≤ 24h (nightly job + on-session-end hook)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_nightly.py | test_seconds_until_next_03_ct_future_today | Scheduler computes correct seconds until 03:00 CT |
|
||||
| tests/test_cohort_nightly.py | test_seconds_until_next_03_ct_past_today_wraps_tomorrow | Wraps to next day correctly |
|
||||
| tests/test_cohort_nightly.py | test_reconcile_recomputes_all_paths | Nightly recomputes all (path, window) cells |
|
||||
| tests/test_cohort_nightly.py | test_r_dash_04_nightly_failure_does_not_crash_scheduler | R-DASH-04: failure logs + retries |
|
||||
| tests/test_cohort_nightly.py | test_scheduler_start_stop_lifecycle | Scheduler starts + stops cleanly |
|
||||
| tests/test_operator_endpoints.py | test_last_updated_is_max | API: last_updated = max(updated_at) |
|
||||
| tests/test_p2_aggregation_integration.py | test_nightly_reconciliation_updates_last_updated | E2e: nightly reconcile refreshes last_updated (skips without Postgres) |
|
||||
| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e (assertion 8) | E2e: last_updated ≤ 24h (skips without Postgres) |
|
||||
|
||||
### REQ-MT-02 — Cohort aggregation pipeline (schema in P1, pipeline in P2)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_aggregation.py | test_multiple_metrics_computed | Pipeline computes all metric types |
|
||||
| tests/test_cohort_aggregation.py | test_idempotent_same_session_twice | Idempotent upsert |
|
||||
| tests/test_cohort_aggregation.py | test_rolling_window_7_days | 7-day rolling window computation |
|
||||
| tests/test_cohort_aggregation.py | test_hook_no_postgres_is_noop | Graceful no-op without Postgres |
|
||||
| tests/test_cohort_aggregation.py | test_hook_failure_logs_does_not_raise | Hook failure does not propagate |
|
||||
| tests/test_cohort_nightly.py | test_reconcile_no_events_no_op | Nightly no-op when no events |
|
||||
| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e | Full pipeline e2e (skips without Postgres) |
|
||||
|
||||
### G-038 (binding — differencing-attack test)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_cohort_aggregation.py | test_g038_differencing_attack_cannot_isolate_dropped_learner | Unit: 10 in A, 9 in B → B suppressed, dropped learner not isolatable |
|
||||
| tests/test_p2_aggregation_integration.py | test_g038_differencing_attack_api_layer | E2e at API layer (skips without Postgres) |
|
||||
|
||||
### G-041 (binding — SPA fallback via custom StaticFiles subclass)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_p2_spa_fallback.py | test_root_serves_voice_ui | Voice UI at / unchanged (R-DASH-05) |
|
||||
| tests/test_p2_spa_fallback.py | test_operator_dashboard_spa_fallback | /operator/dashboard → index.html |
|
||||
| tests/test_p2_spa_fallback.py | test_assets_served_by_staticfiles_not_spa_fallback | /assets/index.js served by StaticFiles (NOT catch-all) — G-041 critical assertion |
|
||||
| tests/test_p2_spa_fallback.py | test_api_operator_cohort_is_json_not_html | API routes return JSON (not index.html) |
|
||||
| tests/test_p2_spa_fallback.py | test_health_is_json | /health JSON |
|
||||
|
||||
### R-DASH-05 (voice UI at / unchanged)
|
||||
| Test file | Test | What it verifies |
|
||||
|-----------|------|------------------|
|
||||
| tests/test_p2_spa_fallback.py | test_root_serves_voice_ui | / → index.html with <div id="root"> |
|
||||
| client/src/operator/__tests__/Dashboard.test.tsx | (no PII in dashboard DOM) | Voice UI path unchanged |
|
||||
|
||||
---
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
| Suite | Pass | Skip | Fail |
|
||||
|-------|------|------|------|
|
||||
| `python3 -m pytest tests/` (full) | 317 | 36 | 0 |
|
||||
| `tests/test_p2_spa_fallback.py` | 9 | 0 | 0 |
|
||||
| `tests/test_operator_endpoints.py` | 15 | 0 | 0 |
|
||||
| `tests/test_cohort_aggregation.py` | 12 | 0 | 0 |
|
||||
| `tests/test_cohort_nightly.py` | 9 | 0 | 0 |
|
||||
| `tests/test_p2_aggregation_integration.py` | 0 | 3 | 0 (Postgres-requiring, skip gracefully) |
|
||||
| `cd client && npx vitest run` | 17 | 0 | 0 |
|
||||
| `cd client && npm run build` | PASS | — | — |
|
||||
| `cd client && npm run typecheck` | PASS | — | — |
|
||||
| `pip install -e . --break-system-packages` | PASS | — | — |
|
||||
| `docker compose config` | PASS | — | — |
|
||||
| `python3 -c "import server.__main__"` | PASS | — | — |
|
||||
| `python3 -c "import ...all P2 modules"` | PASS | — | — |
|
||||
|
||||
---
|
||||
|
||||
## Voice UI at `/` Unchanged — Confirmation
|
||||
|
||||
**CONFIRMED.** Three layers of evidence:
|
||||
|
||||
1. **Server (`server/__main__.py`):** The `SpaStaticFiles` mount at `/` with `html=True` serves `index.html` for `/` — identical to the v0.3 `StaticFiles` behavior. The custom subclass only changes behavior for *non-file* paths (404 → index.html), not for `/` (which StaticFiles already serves as index.html with `html=True`). `test_root_serves_voice_ui` confirms 200 + text/html + `<div id="root">`.
|
||||
|
||||
2. **Client (`client/src/App.tsx`):** Route `/` → `<VoiceSession />`. The VoiceSession component was extracted from the old App.tsx (behavior unchanged — same voice session UI). The `*` catch-all also serves VoiceSession (R-DASH-05: unknown routes fall back to learner surface).
|
||||
|
||||
3. **Test suite:** 317 passed, 0 failed — all v0.1/v0.2/v0.3 tests (voice loop, WebRTC, scenarios, mastery, VC) still pass. No regression in the learner surface.
|
||||
|
||||
---
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Phase 2 (Cohort Dashboard + Aggregation) is **APPROVE_WITH_NOTES**. All 4 layers pass. All 4 P2 REQ-IDs are covered. Both grill MUSTs (G-038 differencing-attack test, G-041 SPA fallback via custom StaticFiles subclass) are honored. Zero P0 issues. Four P1+ issues flagged for post-hoc review in P3 (credential revocation audit log, nightly scheduler DST, in-memory cache persistence, f-string SQL code smell) — all non-blocking, all with mitigations present.
|
||||
|
||||
The P2 implementation is shippable as `v0.1.8` pending the final P3 review + ship phase.
|
||||
@@ -3,8 +3,8 @@
|
||||
{
|
||||
"slug": "praxis",
|
||||
"name": "Praxis",
|
||||
"milestone": "v0.3",
|
||||
"status": "milestone-complete"
|
||||
"milestone": "v0.4",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"active_project": "praxis",
|
||||
@@ -99,6 +99,10 @@
|
||||
{
|
||||
"name": "voice",
|
||||
"env_vars": ["DEEPGRAM_API_KEY", "CARTESIA_API_KEY", "OLLAMA_API_KEY"]
|
||||
},
|
||||
{
|
||||
"name": "operator",
|
||||
"env_vars": ["PRAXIS_PG_PASSWORD", "PRAXIS_COOKIE_SECRET", "PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", "PRAXIS_VC_ISSUER_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+52
-2
@@ -53,8 +53,58 @@ CARTESIA_VOICE_ID=a3536a36-1d18-4efb-a95a-7c44b7b5e384
|
||||
# PROXMOX_TEMPLATE_VOLID=local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst
|
||||
# PROXMOX_LXC_VMID=auto
|
||||
# PROXMOX_TLS_SKIP_VERIFY=true
|
||||
# PROXMOX_MEMORY_MB=4096
|
||||
# v0.4: bumped to 6144 (Postgres ~400MB + praxis ~500MB + Docker ~200MB
|
||||
# + build headroom ~1GB + margin — REQ-NFR-MT-01).
|
||||
# PROXMOX_MEMORY_MB=6144
|
||||
|
||||
# ─── CI/Gitea (operational — not voice) ───────────────────────────────────────
|
||||
# GITEA_TOKEN is provisioned in .ciagent/.env.secrets (not this file).
|
||||
# PRAXIS_VERSION (git ref to deploy, default: main)
|
||||
# PRAXIS_VERSION (git ref to deploy, default: main)
|
||||
|
||||
# ─── v0.4 Operator Tier (Postgres + Auth) ────────────────────────────────────
|
||||
# These configure the operator surface (cohort dashboard, auth, VC migration).
|
||||
# Real values are secrets — put them in .ciagent/.env.secrets, not here.
|
||||
# This file is documentation-only (committed); .env.secrets is gitignored.
|
||||
|
||||
# Postgres password. Secret. Used in the DSN below + docker-compose postgres
|
||||
# service (POSTGRES_PASSWORD). Generate with: openssl rand -base64 32
|
||||
PRAXIS_PG_PASSWORD=
|
||||
|
||||
# Postgres DSN (D-050). host=postgres is the docker-compose service DNS name
|
||||
# on the praxis-net bridge. Format:
|
||||
# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis
|
||||
# When unset/empty, the server starts in graceful no-pool mode (learner voice
|
||||
# loop works; operator auth + cohort endpoints return 503).
|
||||
PRAXIS_PG_DSN=
|
||||
|
||||
# Cookie signing secret (D-056, R-AUTH-01). >=32 random bytes, base64 or hex.
|
||||
# Secret. Generate with: openssl rand -base64 48
|
||||
# When unset, the server generates an ephemeral random secret (dev ONLY —
|
||||
# sessions won't survive a restart; NOT for pilot/production).
|
||||
PRAXIS_COOKIE_SECRET=
|
||||
|
||||
# Cookie Secure flag (D-041, R-AUTH-01, G-031). Default true (HTTPS).
|
||||
# Set to false ONLY for the HTTP pilot (no TLS in the LXC pilot — D-030).
|
||||
# NOTE (G-031): the PRIMARY mitigation for a sniffed cookie is the k-anon
|
||||
# defense-in-depth (the cohort dashboard reads only k-anonymized aggregates,
|
||||
# so a sniffed operator cookie leaks NO learner PII). This flag is the
|
||||
# SECONDARY mitigation (operational convenience for when TLS arrives).
|
||||
PRAXIS_COOKIE_SECURE=true
|
||||
|
||||
# Bootstrap operator credentials (D-052). Secret. Used by
|
||||
# scripts/create-operator.py on first run to create the initial operator.
|
||||
# If either is missing, the CLI exits 1 (R-BOOT-02).
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS=
|
||||
|
||||
# VC issuer root key (v0.3 + v0.4). Secret. Used by nacl.SecretBox to encrypt
|
||||
# Ed25519 private keys at rest (D-042). In v0.4 the migration script
|
||||
# (server/vc/migrate_keys.py) uses this to encrypt the fresh v0.4 keypair;
|
||||
# the v0.3 root key is kept for the v0.3 SQLite verification path (R-VC-MIG-02).
|
||||
# Generate with: python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())"
|
||||
PRAXIS_VC_ISSUER_KEY=
|
||||
|
||||
# Issuer URL (D-042). The public base URL for VC issuer + key identifiers.
|
||||
# v0.4 changes the default to /issuers/v0.4 (v0.3 VCs keep their v0.3 URLs
|
||||
# embedded in their proofs — verification fetches keys by id, not by URL).
|
||||
PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4
|
||||
@@ -12,6 +12,8 @@ venv/
|
||||
.env.secrets
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.secrets.example
|
||||
!.ciagent/.env.secrets.example
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
|
||||
Generated
+2674
-2
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -9,21 +9,27 @@
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "echo 'client: no unit tests yet (v0.1 uses e2e smoke via server tests)' && exit 0"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pipecat-ai/client-js": "^1.13.0",
|
||||
"@pipecat-ai/small-webrtc-transport": "^1.10.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-175
@@ -1,181 +1,27 @@
|
||||
/**
|
||||
* Praxis v0.1 — full session UX (SLICE-05 TASK-05-04).
|
||||
* Praxis — top-level route switch (SLICE-09 TASK-09-02, D-044, R-DASH-05).
|
||||
*
|
||||
* Three views: start → live → debrief. Replaces the SLICE-02 minimal page.
|
||||
* - Start: scenario title + disclaimer acknowledgement + Start button
|
||||
* - Live: turn indicators (learner/AI), interrupt feedback, latency readout
|
||||
* - Debrief: debrief text + audio replay control + latency/cost summary
|
||||
* Routes:
|
||||
* / → existing voice session UI (unchanged)
|
||||
* /operator/login → operator Login form
|
||||
* /operator/dashboard → operator Dashboard (auth-gated)
|
||||
* * → voice session UI (SPA fallback for unknown routes)
|
||||
*
|
||||
* R-DASH-05: the voice UI at `/` is unchanged. The catch-all serves the
|
||||
* voice UI (not a 404) so unknown routes fall back to the learner surface.
|
||||
*/
|
||||
import { useVoiceSession } from './useVoiceSession'
|
||||
import { useEffect, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type View = 'start' | 'live' | 'debrief'
|
||||
|
||||
function App() {
|
||||
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
|
||||
const [view, setView] = useState<View>('start')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'connected' && view === 'start') {
|
||||
setView('live')
|
||||
}
|
||||
if (state === 'idle' && view === 'live') {
|
||||
setView('debrief')
|
||||
}
|
||||
}, [state, view])
|
||||
|
||||
const handleStart = async () => {
|
||||
await start()
|
||||
}
|
||||
|
||||
const handleEnd = async () => {
|
||||
await stop()
|
||||
setView('debrief')
|
||||
}
|
||||
|
||||
const handleRestart = () => {
|
||||
setView('start')
|
||||
setAcknowledged(false)
|
||||
}
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import VoiceSession from './VoiceSession'
|
||||
import Login from './operator/Login'
|
||||
import Dashboard from './operator/Dashboard'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<section id="praxis-session">
|
||||
<header>
|
||||
<h1>Praxis</h1>
|
||||
<p className="subtitle">Customer Service role-play — v0.1</p>
|
||||
</header>
|
||||
|
||||
{view === 'start' && (
|
||||
<div className="view view--start">
|
||||
<div className="scenario-card">
|
||||
<h2>Angry customer requesting refund on a damaged product</h2>
|
||||
<p className="scenario-desc">
|
||||
You are a customer service agent. An angry customer (Jordan) is
|
||||
demanding a refund for a cracked product. Handle the
|
||||
conversation. You'll receive a coaching debrief at the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="disclaimer">
|
||||
<label className="disclaimer-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
This is an AI practice session for training purposes. It is
|
||||
not a real conversation and no real company is involved.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
type="button"
|
||||
className="start"
|
||||
disabled={!acknowledged || state === 'connecting'}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{state === 'connecting' ? 'Connecting…' : 'Start session'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'live' && (
|
||||
<div className="view view--live">
|
||||
<div className="status">
|
||||
<span className={`badge badge--${state}`}>{state}</span>
|
||||
{latency && (
|
||||
<span className="latency">
|
||||
<span className="latency-label">{latency.label}:</span>{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="stop" onClick={() => void handleEnd()}>
|
||||
End session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="transcript">
|
||||
<h2>Live transcript</h2>
|
||||
{transcripts.length === 0 ? (
|
||||
<p className="muted">Speak to the AI customer…</p>
|
||||
) : (
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'debrief' && (
|
||||
<div className="view view--debrief">
|
||||
<h2>Session debrief</h2>
|
||||
<p className="muted">
|
||||
Your coaching debrief would appear here, generated from your turns
|
||||
+ the branch outcome. In a live run (with API keys), the debrief
|
||||
is spoken in the same voice as the role-play.
|
||||
</p>
|
||||
|
||||
{latency && (
|
||||
<div className="summary">
|
||||
<h3>Latency summary</h3>
|
||||
<p>
|
||||
{latency.label}:{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
{latency.e2eMs !== null && (
|
||||
<span className="budget">
|
||||
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcripts.length > 0 && (
|
||||
<div className="transcript">
|
||||
<h3>Turns this session</h3>
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="start" onClick={handleRestart}>
|
||||
Start a new session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<Routes>
|
||||
<Route path="/" element={<VoiceSession />} />
|
||||
<Route path="/operator/login" element={<Login />} />
|
||||
<Route path="/operator/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<VoiceSession />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Praxis v0.1 — voice session UX (extracted for React Router, SLICE-09 TASK-09-02).
|
||||
*
|
||||
* Three views: start → live → debrief. Reuses useVoiceSession. This is the
|
||||
* existing voice UI, now mounted at `/` and as the catch-all fallback.
|
||||
*/
|
||||
import { useVoiceSession } from './useVoiceSession'
|
||||
import { useEffect, useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
type View = 'start' | 'live' | 'debrief'
|
||||
|
||||
export default function VoiceSession() {
|
||||
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
|
||||
const [view, setView] = useState<View>('start')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'connected' && view === 'start') {
|
||||
setView('live')
|
||||
}
|
||||
if (state === 'idle' && view === 'live') {
|
||||
setView('debrief')
|
||||
}
|
||||
}, [state, view])
|
||||
|
||||
const handleStart = async () => {
|
||||
await start()
|
||||
}
|
||||
|
||||
const handleEnd = async () => {
|
||||
await stop()
|
||||
setView('debrief')
|
||||
}
|
||||
|
||||
const handleRestart = () => {
|
||||
setView('start')
|
||||
setAcknowledged(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="praxis-session">
|
||||
<header>
|
||||
<h1>Praxis</h1>
|
||||
<p className="subtitle">Customer Service role-play — v0.1</p>
|
||||
</header>
|
||||
|
||||
{view === 'start' && (
|
||||
<div className="view view--start">
|
||||
<div className="scenario-card">
|
||||
<h2>Angry customer requesting refund on a damaged product</h2>
|
||||
<p className="scenario-desc">
|
||||
You are a customer service agent. An angry customer (Jordan) is
|
||||
demanding a refund for a cracked product. Handle the
|
||||
conversation. You'll receive a coaching debrief at the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="disclaimer">
|
||||
<label className="disclaimer-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
This is an AI practice session for training purposes. It is
|
||||
not a real conversation and no real company is involved.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
type="button"
|
||||
className="start"
|
||||
disabled={!acknowledged || state === 'connecting'}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{state === 'connecting' ? 'Connecting…' : 'Start session'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'live' && (
|
||||
<div className="view view--live">
|
||||
<div className="status">
|
||||
<span className={`badge badge--${state}`}>{state}</span>
|
||||
{latency && (
|
||||
<span className="latency">
|
||||
<span className="latency-label">{latency.label}:</span>{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="stop" onClick={() => void handleEnd()}>
|
||||
End session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="transcript">
|
||||
<h2>Live transcript</h2>
|
||||
{transcripts.length === 0 ? (
|
||||
<p className="muted">Speak to the AI customer…</p>
|
||||
) : (
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'debrief' && (
|
||||
<div className="view view--debrief">
|
||||
<h2>Session debrief</h2>
|
||||
<p className="muted">
|
||||
Your coaching debrief would appear here, generated from your turns
|
||||
+ the branch outcome. In a live run (with API keys), the debrief
|
||||
is spoken in the same voice as the role-play.
|
||||
</p>
|
||||
|
||||
{latency && (
|
||||
<div className="summary">
|
||||
<h3>Latency summary</h3>
|
||||
<p>
|
||||
{latency.label}:{' '}
|
||||
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
||||
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
||||
</span>
|
||||
{latency.e2eMs !== null && (
|
||||
<span className="budget">
|
||||
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcripts.length > 0 && (
|
||||
<div className="transcript">
|
||||
<h3>Turns this session</h3>
|
||||
<ul>
|
||||
{transcripts.map((t, i) => (
|
||||
<li key={i} className={`turn turn--${t.role}`}>
|
||||
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
||||
<span className="text">{t.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="controls">
|
||||
<button type="button" className="start" onClick={handleRestart}>
|
||||
Start a new session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+5
-2
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Operator Dashboard shell + auth gate (SLICE-09 TASK-09-04, D-057, D-053).
|
||||
*
|
||||
* On mount: GET /api/operator/me. 401 → redirect to /operator/login (UX-only
|
||||
* route guard — the server is the authority per D-057). 200 → render the
|
||||
* dashboard with operator name, 3 view tabs, freshness indicator, logout.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import PracticeVolume from './views/PracticeVolume'
|
||||
import MasteryProgression from './views/MasteryProgression'
|
||||
import FailurePatterns from './views/FailurePatterns'
|
||||
import '../App.css'
|
||||
|
||||
type Tab = 'practice' | 'mastery' | 'failure'
|
||||
|
||||
interface OperatorInfo {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [op, setOp] = useState<OperatorInfo | null>(null)
|
||||
const [tab, setTab] = useState<Tab>('practice')
|
||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/operator/me', { credentials: 'include' })
|
||||
if (cancelled) return
|
||||
if (r.status === 200) {
|
||||
const body = await r.json()
|
||||
setOp(body.operator)
|
||||
setAuthed(true)
|
||||
} else {
|
||||
setAuthed(false)
|
||||
navigate('/operator/login', { replace: true })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setAuthed(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/operator/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch {
|
||||
// best-effort — navigate to login regardless
|
||||
}
|
||||
navigate('/operator/login', { replace: true })
|
||||
}
|
||||
|
||||
if (authed === false) return null
|
||||
if (authed === null || !op) {
|
||||
return (
|
||||
<section id="praxis-dashboard">
|
||||
<p className="muted">Loading dashboard…</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="praxis-dashboard">
|
||||
<header>
|
||||
<h1>Praxis Operator Dashboard</h1>
|
||||
<p className="subtitle">
|
||||
Signed in as {op.display_name || op.username}
|
||||
</p>
|
||||
<div className="controls">
|
||||
<button type="button" className="stop" onClick={handleLogout}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="view-tabs" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'practice'}
|
||||
className={tab === 'practice' ? 'tab active' : 'tab'}
|
||||
onClick={() => setTab('practice')}
|
||||
>
|
||||
Practice Volume
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'mastery'}
|
||||
className={tab === 'mastery' ? 'tab active' : 'tab'}
|
||||
onClick={() => setTab('mastery')}
|
||||
>
|
||||
Mastery Progression
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'failure'}
|
||||
className={tab === 'failure' ? 'tab active' : 'tab'}
|
||||
onClick={() => setTab('failure')}
|
||||
>
|
||||
Failure Patterns
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'practice' && <PracticeVolume />}
|
||||
{tab === 'mastery' && <MasteryProgression />}
|
||||
{tab === 'failure' && <FailurePatterns />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Operator Login form (SLICE-09 TASK-09-03, D-041, D-057).
|
||||
*
|
||||
* POST /api/operator/login on submit. On success → navigate to
|
||||
* /operator/dashboard. On 401 → show error. On 429 → show rate-limit retry
|
||||
* message. Keyboard-accessible (label associations, focus management).
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const userRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const r = await fetch('/api/operator/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (r.status === 200) {
|
||||
navigate('/operator/dashboard')
|
||||
return
|
||||
}
|
||||
if (r.status === 401) {
|
||||
setError('Invalid username or password.')
|
||||
} else if (r.status === 429) {
|
||||
setError('Too many attempts. Try again in a minute.')
|
||||
} else if (r.status === 503) {
|
||||
setError('Operator sign-in is unavailable right now.')
|
||||
} else {
|
||||
setError(`Login failed (HTTP ${r.status}).`)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error — unable to reach the server.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="praxis-login">
|
||||
<header>
|
||||
<h1>Praxis Operator</h1>
|
||||
<p className="subtitle">Sign in to view the cohort dashboard</p>
|
||||
</header>
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label htmlFor="login-username">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
ref={userRef}
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<button type="submit" className="start" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{error && <div className="error" role="alert">{error}</div>}
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Inline SVG sparkline (SLICE-09 TASK-09-05, RESEARCH-v0.4 §4.3).
|
||||
*
|
||||
* Zero-dep ~50 LOC. Renders a polyline from `data`. Handles empty (renders
|
||||
* nothing), single point (dot), all-same (flat line). stroke=currentColor.
|
||||
* No axes/tooltips — sparklines are compact trend indicators.
|
||||
*/
|
||||
interface SparklineProps {
|
||||
data: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export default function Sparkline({ data, width = 60, height = 20 }: SparklineProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (data.length === 1) {
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
||||
<circle cx={width / 2} cy={height / 2} r={1.5} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const span = max - min || 1
|
||||
const pad = 2
|
||||
const w = width - pad * 2
|
||||
const h = height - pad * 2
|
||||
const stepX = w / (data.length - 1)
|
||||
const points = data.map((v, i) => {
|
||||
const x = pad + i * stepX
|
||||
const y = pad + h - ((v - min) / span) * h
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||
})
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Operator dashboard unit tests (SLICE-09 TASK-09-07).
|
||||
*
|
||||
* Covers: auth gate (401 on /me → redirect to /operator/login), login form
|
||||
* (submit → POST /login → navigate to dashboard), suppressed cell display
|
||||
* ("— (<10 learners)"), sparkline renders SVG polyline, freshness indicator,
|
||||
* no PII in rendered DOM.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import Login from '../Login'
|
||||
import Dashboard from '../Dashboard'
|
||||
import Sparkline from '../Sparkline'
|
||||
import { suppressedLabel, formatFreshness } from '../views/_viewCommon'
|
||||
import type { Cell } from '../views/_viewCommon'
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/operator/login" element={<Login />} />
|
||||
<Route path="/operator/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<div data-testid="fallback" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Dashboard auth gate', () => {
|
||||
it('redirects to /operator/login on 401 from /me', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 401 })
|
||||
renderAt('/operator/dashboard')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Praxis Operator Dashboard/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders dashboard on 200 from /me', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({ operator: { id: '1', username: 'alice', display_name: 'Alice', role: 'operator' } }),
|
||||
})
|
||||
renderAt('/operator/dashboard')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Praxis Operator Dashboard/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Signed in as Alice/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Login form ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Login form', () => {
|
||||
it('renders username + password fields + submit', () => {
|
||||
renderAt('/operator/login')
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits POST /api/operator/login and navigates on success', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 200 })
|
||||
renderAt('/operator/login')
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'alice' } })
|
||||
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/operator/login',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error on 401', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 401 })
|
||||
renderAt('/operator/login')
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'a' } })
|
||||
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'b' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid username or password/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows rate-limit message on 429', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({ status: 429 })
|
||||
renderAt('/operator/login')
|
||||
fireEvent.change(screen.getByLabelText(/Username/i), { target: { value: 'a' } })
|
||||
fireEvent.change(screen.getByLabelText(/Password/i), { target: { value: 'b' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign in/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Too many attempts/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Sparkline ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Sparkline', () => {
|
||||
it('renders nothing for empty data', () => {
|
||||
const { container } = render(<Sparkline data={[]} />)
|
||||
expect(container.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a dot for single point', () => {
|
||||
const { container } = render(<Sparkline data={[5]} />)
|
||||
expect(container.querySelector('circle')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders a polyline for multiple points', () => {
|
||||
const { container } = render(<Sparkline data={[1, 2, 3, 4, 5]} />)
|
||||
const poly = container.querySelector('polyline')
|
||||
expect(poly).not.toBeNull()
|
||||
expect(poly?.getAttribute('points')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a flat line for all-same values', () => {
|
||||
const { container } = render(<Sparkline data={[3, 3, 3, 3]} />)
|
||||
expect(container.querySelector('polyline')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Suppressed cell display + freshness ──────────────────────────────────
|
||||
|
||||
describe('suppressedLabel', () => {
|
||||
it('shows "— (<10 learners)" for suppressed cells', () => {
|
||||
const cell: Cell = {
|
||||
metric: 'sessions_count', window_start: null, window_end: null,
|
||||
value: null, cell_count: 5, cell_suppressed: true, updated_at: null,
|
||||
}
|
||||
expect(suppressedLabel(cell)).toBe('— (<10 learners)')
|
||||
})
|
||||
|
||||
it('shows the value for non-suppressed cells', () => {
|
||||
const cell: Cell = {
|
||||
metric: 'sessions_count', window_start: null, window_end: null,
|
||||
value: 12, cell_count: 12, cell_suppressed: false, updated_at: null,
|
||||
}
|
||||
expect(suppressedLabel(cell)).toBe('12')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFreshness', () => {
|
||||
it('shows — for null lastUpdated', () => {
|
||||
expect(formatFreshness(null)).toBe('—')
|
||||
})
|
||||
|
||||
it('shows minutes ago for < 1h', () => {
|
||||
const thirtyMinAgo = new Date(Date.now() - 30 * 60_000).toISOString()
|
||||
expect(formatFreshness(thirtyMinAgo)).toMatch(/m ago/)
|
||||
})
|
||||
|
||||
it('shows hours ago for 1-24h', () => {
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000).toISOString()
|
||||
expect(formatFreshness(twoHoursAgo)).toMatch(/h ago/)
|
||||
})
|
||||
|
||||
it('shows days ago for > 24h', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 48 * 3_600_000).toISOString()
|
||||
expect(formatFreshness(twoDaysAgo)).toMatch(/d ago/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── No PII in rendered DOM ────────────────────────────────────────────────
|
||||
|
||||
describe('No PII in dashboard DOM', () => {
|
||||
it('does not render learner_ref fields', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => ({ operator: { id: '1', username: 'alice', display_name: 'Alice', role: 'operator' } }),
|
||||
})
|
||||
const { container } = renderAt('/operator/dashboard')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Praxis Operator Dashboard/i)).toBeInTheDocument()
|
||||
})
|
||||
// No learner-ref label or per-learner data should appear in the dashboard shell.
|
||||
expect(container.textContent).not.toMatch(/learner_ref/i)
|
||||
expect(container.textContent).not.toMatch(/learner-1/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Failure Patterns view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
|
||||
*
|
||||
* Top failure_modes by frequency (sorted table), rubric criteria with
|
||||
* mean < 3.0 (highlighted weak-spots), branch outcome distribution.
|
||||
* Suppressed cells → "— (<10 learners)".
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchView, formatFreshness, suppressedLabel } from './_viewCommon'
|
||||
import type { ViewResponse } from './_viewCommon'
|
||||
|
||||
export default function FailurePatterns() {
|
||||
const [data, setData] = useState<ViewResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetchView('/api/operator/failure-patterns')
|
||||
if (!cancelled) setData(r)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="muted">Loading failure patterns…</p>
|
||||
if (error) return <div className="error">Failed to load: {error}</div>
|
||||
if (!data || data.views.length === 0) {
|
||||
return (
|
||||
<div className="view view--failure">
|
||||
<p className="muted">No failure-pattern data available yet.</p>
|
||||
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view view--failure">
|
||||
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
|
||||
{data.views.map((v) => {
|
||||
const modes = v.metrics
|
||||
.filter((c) => c.metric.startsWith('failure_mode:'))
|
||||
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0))
|
||||
const branches = v.metrics.filter((c) => c.metric.startsWith('branch:'))
|
||||
return (
|
||||
<div key={v.path} className="cohort-section">
|
||||
<h3>{v.path}</h3>
|
||||
<h4>Failure modes by frequency</h4>
|
||||
<table className="cohort-table">
|
||||
<thead><tr><th>Mode</th><th>Frequency</th></tr></thead>
|
||||
<tbody>
|
||||
{modes.length === 0 ? (
|
||||
<tr><td colSpan={2} className="muted">No failure modes recorded.</td></tr>
|
||||
) : (
|
||||
modes.map((c) => (
|
||||
<tr key={c.metric}>
|
||||
<td>{c.metric.replace('failure_mode:', '')}</td>
|
||||
<td>{suppressedLabel(c)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Branch outcome distribution</h4>
|
||||
<table className="cohort-table">
|
||||
<thead><tr><th>Branch</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
{branches.length === 0 ? (
|
||||
<tr><td colSpan={2} className="muted">No branch data recorded.</td></tr>
|
||||
) : (
|
||||
branches.map((c) => (
|
||||
<tr key={c.metric}>
|
||||
<td>{c.metric.replace('branch:', '')}</td>
|
||||
<td>{suppressedLabel(c)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Mastery Progression view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
|
||||
*
|
||||
* Gate-open rate, median mastery score, rubric criterion means (table +
|
||||
* sparkline). Suppressed cells → "— (<10 learners)".
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import Sparkline from '../Sparkline'
|
||||
import { fetchView, formatFreshness, suppressedLabel, valuesForSparkline } from './_viewCommon'
|
||||
import type { ViewResponse } from './_viewCommon'
|
||||
|
||||
export default function MasteryProgression() {
|
||||
const [data, setData] = useState<ViewResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetchView('/api/operator/mastery')
|
||||
if (!cancelled) setData(r)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="muted">Loading mastery progression…</p>
|
||||
if (error) return <div className="error">Failed to load: {error}</div>
|
||||
if (!data || data.views.length === 0) {
|
||||
return (
|
||||
<div className="view view--mastery">
|
||||
<p className="muted">No mastery data available yet.</p>
|
||||
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view view--mastery">
|
||||
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
|
||||
{data.views.map((v) => {
|
||||
const gate = v.metrics.find((c) => c.metric === 'gate_open_rate')
|
||||
const median = v.metrics.find((c) => c.metric === 'median_mastery_score')
|
||||
const critMeans = v.metrics.filter((c) => c.metric.startsWith('rubric_criterion_mean:'))
|
||||
return (
|
||||
<div key={v.path} className="cohort-section">
|
||||
<h3>{v.path}</h3>
|
||||
<table className="cohort-table">
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Value</th><th>Trend</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Gate-open rate</td>
|
||||
<td>{gate ? suppressedLabel(gate) : '—'}</td>
|
||||
<td><Sparkline data={valuesForSparkline(v.metrics, 'gate_open_rate')} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Median mastery score</td>
|
||||
<td>{median ? suppressedLabel(median) : '—'}</td>
|
||||
<td><Sparkline data={valuesForSparkline(v.metrics, 'median_mastery_score')} /></td>
|
||||
</tr>
|
||||
{critMeans.map((c) => (
|
||||
<tr key={c.metric}>
|
||||
<td>{c.metric.replace('rubric_criterion_mean:', '')}</td>
|
||||
<td>{suppressedLabel(c)}</td>
|
||||
<td><Sparkline data={valuesForSparkline(v.metrics, c.metric)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Practice Volume view (SLICE-09 TASK-09-06, D-053, REQ-NFR-DASH-01).
|
||||
*
|
||||
* Read-only table of sessions/day per path + active learners, with sparklines.
|
||||
* Suppressed cells → "— (<10 learners)". No per-learner drill-down (R-DASH-02).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import Sparkline from '../Sparkline'
|
||||
import { fetchView, formatFreshness, suppressedLabel, valuesForSparkline } from './_viewCommon'
|
||||
import type { Cell, ViewResponse } from './_viewCommon'
|
||||
|
||||
const SUPPRESSED_PLACEHOLDER: Cell = {
|
||||
metric: '', window_start: null, window_end: null,
|
||||
value: null, cell_count: 0, cell_suppressed: true, updated_at: null,
|
||||
}
|
||||
|
||||
export default function PracticeVolume() {
|
||||
const [data, setData] = useState<ViewResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetchView('/api/operator/cohort')
|
||||
if (!cancelled) setData(r)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="muted">Loading practice volume…</p>
|
||||
if (error) return <div className="error">Failed to load: {error}</div>
|
||||
if (!data || data.views.length === 0) {
|
||||
return (
|
||||
<div className="view view--practice">
|
||||
<p className="muted">No practice data available yet.</p>
|
||||
<p className="muted">Last updated: {formatFreshness(data?.last_updated ?? null)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view view--practice">
|
||||
<p className="muted">Last updated: {formatFreshness(data.last_updated)}</p>
|
||||
<table className="cohort-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Sessions (trend)</th>
|
||||
<th>Active learners</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.views.map((v) => {
|
||||
const sessions = v.metrics.filter((c) => c.metric === 'sessions_count')
|
||||
const active = v.metrics.find((c) => c.metric === 'active_learners_count')
|
||||
return (
|
||||
<tr key={v.path}>
|
||||
<td>{v.path}</td>
|
||||
<td>
|
||||
{suppressedLabel(sessions[sessions.length - 1] ?? SUPPRESSED_PLACEHOLDER)}
|
||||
{' '}
|
||||
<Sparkline data={valuesForSparkline(v.metrics, 'sessions_count')} />
|
||||
</td>
|
||||
<td>{active ? suppressedLabel(active) : '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared types + helpers for operator dashboard views (SLICE-09 TASK-09-06).
|
||||
*/
|
||||
|
||||
export interface Cell {
|
||||
metric: string
|
||||
window_start: string | null
|
||||
window_end: string | null
|
||||
value: number | null
|
||||
cell_count: number
|
||||
cell_suppressed: boolean
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface PathView {
|
||||
path: string
|
||||
metrics: Cell[]
|
||||
}
|
||||
|
||||
export interface ViewResponse {
|
||||
views: PathView[]
|
||||
last_updated: string | null
|
||||
}
|
||||
|
||||
export async function fetchView(endpoint: string): Promise<ViewResponse> {
|
||||
const r = await fetch(endpoint, { credentials: 'include' })
|
||||
if (!r.ok) {
|
||||
throw new Error(`HTTP ${r.status}`)
|
||||
}
|
||||
return (await r.json()) as ViewResponse
|
||||
}
|
||||
|
||||
export function formatFreshness(lastUpdated: string | null): string {
|
||||
if (!lastUpdated) return '—'
|
||||
const ts = Date.parse(lastUpdated)
|
||||
if (Number.isNaN(ts)) return '—'
|
||||
const hoursAgo = (Date.now() - ts) / 3_600_000
|
||||
if (hoursAgo < 1) return `${Math.round(hoursAgo * 60)}m ago`
|
||||
if (hoursAgo < 24) return `${hoursAgo.toFixed(1)}h ago`
|
||||
return `${(hoursAgo / 24).toFixed(1)}d ago`
|
||||
}
|
||||
|
||||
export function suppressedLabel(cell: Cell): string {
|
||||
return cell.cell_suppressed ? '— (<10 learners)' : String(cell.value ?? '—')
|
||||
}
|
||||
|
||||
export function groupMetricsByPath(views: PathView[]): Map<string, Cell[]> {
|
||||
const m = new Map<string, Cell[]>()
|
||||
for (const v of views) {
|
||||
m.set(v.path, v.metrics)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export function valuesForSparkline(cells: Cell[] | undefined, metric: string): number[] {
|
||||
if (!cells) return []
|
||||
return cells
|
||||
.filter((c) => c.metric === metric && c.value !== null)
|
||||
.map((c) => c.value as number)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Postgres migration runner — applies db/pg_migrations/*.sql in order.
|
||||
|
||||
Mirrors db/migrate.py: ordered .sql files tracked in a `_pg_migrations`
|
||||
table so re-running is idempotent. Uses an asyncpg pool. Retries on
|
||||
connection failure (3 attempts, 2s backoff — R-MT-02 mitigation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "pg_migrations"
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_S = 2.0
|
||||
|
||||
|
||||
async def apply_pg_migrations(
|
||||
pool: asyncpg.Pool,
|
||||
migrations_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Apply all pending Postgres migrations in order. Returns applied names.
|
||||
|
||||
Idempotent — no-op if all migrations are already applied. Each migration
|
||||
runs within a transaction; the `_pg_migrations` tracking row is inserted
|
||||
in the same transaction so a failure rolls back cleanly.
|
||||
"""
|
||||
mdir = migrations_dir or _DEFAULT_MIGRATIONS_DIR
|
||||
if not mdir.exists():
|
||||
return []
|
||||
|
||||
async def _run() -> list[str]:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _pg_migrations ("
|
||||
"id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()"
|
||||
")"
|
||||
)
|
||||
rows = await conn.fetch("SELECT id FROM _pg_migrations")
|
||||
applied_ids = {r["id"] for r in rows}
|
||||
applied: list[str] = []
|
||||
for sql_path in sorted(mdir.glob("*.sql")):
|
||||
mid = sql_path.stem
|
||||
if mid in applied_ids:
|
||||
continue
|
||||
sql = sql_path.read_text(encoding="utf-8")
|
||||
async with conn.transaction():
|
||||
await conn.execute(sql)
|
||||
await conn.execute(
|
||||
"INSERT INTO _pg_migrations (id) VALUES ($1)", mid
|
||||
)
|
||||
applied.append(mid)
|
||||
return applied
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, _RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
return await _run()
|
||||
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < _RETRY_ATTEMPTS:
|
||||
await asyncio.sleep(_RETRY_BACKOFF_S)
|
||||
continue
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
__all__ = ["apply_pg_migrations"]
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Praxis v0.4 operator-tier schema migration 0001.
|
||||
-- Creates the 5 operator-tier tables. Uses gen_random_uuid() (PG16 core).
|
||||
-- Idempotent via IF NOT EXISTS (also safe through pg_migrate tracking).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'operator',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issued_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID REFERENCES operators(id),
|
||||
learner_ref TEXT NOT NULL,
|
||||
vc_type TEXT,
|
||||
payload_jsonb JSONB NOT NULL,
|
||||
signature_b64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mastery_gate_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
learner_ref TEXT NOT NULL,
|
||||
scenario_id TEXT,
|
||||
path_id TEXT NOT NULL,
|
||||
gate_outcome TEXT,
|
||||
rubric_scores_jsonb JSONB,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'sync'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_aggregates (
|
||||
path TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
window_start DATE NOT NULL,
|
||||
window_end DATE NOT NULL,
|
||||
value NUMERIC,
|
||||
cell_count INTEGER NOT NULL DEFAULT 0,
|
||||
cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (path, metric, window_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx
|
||||
ON cohort_aggregates (path, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuer_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc BYTEA,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Praxis v0.4 operator-tier Postgres schema (reference).
|
||||
-- Applied in order by db/pg_migrate.py via db/pg_migrations/*.sql.
|
||||
-- The canonical migration is 0001_operator_tier.sql; this file is the
|
||||
-- human-readable reference (kept in sync). Uses gen_random_uuid() which
|
||||
-- is in PG16 core (no extension needed — R-MT-05 verified).
|
||||
--
|
||||
-- Tables:
|
||||
-- operators — operator accounts (argon2id password hash)
|
||||
-- issued_credentials — VC issuance log (learner_ref is opaque, no FK)
|
||||
-- mastery_gate_events — mastery gate audit log (REQ-NFR-MAST-02)
|
||||
-- cohort_aggregates — k-anonymized cohort metrics (plain table, D-050)
|
||||
-- issuer_keys — Ed25519 issuer key lifecycle (active/superseded)
|
||||
--
|
||||
-- No cross-DB FKs (D-031). learner_ref is an opaque string in Postgres.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operators (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'operator',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issued_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID REFERENCES operators(id),
|
||||
learner_ref TEXT NOT NULL,
|
||||
vc_type TEXT,
|
||||
payload_jsonb JSONB NOT NULL,
|
||||
signature_b64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mastery_gate_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
learner_ref TEXT NOT NULL,
|
||||
scenario_id TEXT,
|
||||
path_id TEXT NOT NULL,
|
||||
gate_outcome TEXT,
|
||||
rubric_scores_jsonb JSONB,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'sync'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_aggregates (
|
||||
path TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
window_start DATE NOT NULL,
|
||||
window_end DATE NOT NULL,
|
||||
value NUMERIC,
|
||||
cell_count INTEGER NOT NULL DEFAULT 0,
|
||||
cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (path, metric, window_start)
|
||||
);
|
||||
-- Plain table, NOT partitioned (D-050..D-053; add partitioning post-pilot).
|
||||
CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx
|
||||
ON cohort_aggregates (path, window_start);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issuer_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_enc BYTEA,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
"""Postgres store — operator-tier access layer (D-040, D-050, TASK-01-06).
|
||||
|
||||
Async access via an asyncpg.Pool. Implements the IssuerKeyStore protocol
|
||||
(server/vc/issuer_keys.py) so VC verification can use either PraxisStore
|
||||
(SQLite, v0.3) or PgStore (Postgres, v0.4). No cross-DB joins (D-031);
|
||||
`learner_ref` is an opaque string in Postgres (not a FK to SQLite).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
class PgStore:
|
||||
"""Async Postgres store for the v0.4 operator tier."""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
# ── Operator CRUD ────────────────────────────────────────────────────
|
||||
|
||||
async def get_operator_by_username(self, username: str) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, username, password_hash, display_name, role, "
|
||||
"is_active, created_at, last_login_at "
|
||||
"FROM operators WHERE username = $1",
|
||||
username,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_operator_by_id(self, operator_id: str) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, username, password_hash, display_name, role, "
|
||||
"is_active, created_at, last_login_at "
|
||||
"FROM operators WHERE id = $1",
|
||||
operator_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def update_last_login(self, operator_id: str) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE operators SET last_login_at = now() WHERE id = $1",
|
||||
operator_id,
|
||||
)
|
||||
|
||||
async def insert_operator(
|
||||
self,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
display_name: str | None = None,
|
||||
*,
|
||||
on_conflict_update: bool = False,
|
||||
) -> str | None:
|
||||
"""Insert an operator (idempotent on username). Returns the id, or
|
||||
None if the row already existed and on_conflict_update is False."""
|
||||
async with self.pool.acquire() as conn:
|
||||
if on_conflict_update:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO operators (username, password_hash, display_name) "
|
||||
"VALUES ($1, $2, $3) "
|
||||
"ON CONFLICT (username) DO UPDATE SET "
|
||||
"password_hash = excluded.password_hash, "
|
||||
"display_name = excluded.display_name "
|
||||
"RETURNING id",
|
||||
username,
|
||||
password_hash,
|
||||
display_name,
|
||||
)
|
||||
return str(row["id"]) if row else None
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO operators (username, password_hash, display_name) "
|
||||
"VALUES ($1, $2, $3) "
|
||||
"ON CONFLICT (username) DO NOTHING "
|
||||
"RETURNING id",
|
||||
username,
|
||||
password_hash,
|
||||
display_name,
|
||||
)
|
||||
return str(row["id"]) if row else None
|
||||
|
||||
# ── Cohort aggregate read/write ──────────────────────────────────────
|
||||
|
||||
async def get_cohort_aggregates(
|
||||
self,
|
||||
path: str,
|
||||
metric: str,
|
||||
since_date: Any,
|
||||
) -> list[dict]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT path, metric, window_start, window_end, value, "
|
||||
"cell_count, cell_suppressed, updated_at "
|
||||
"FROM cohort_aggregates "
|
||||
"WHERE path = $1 AND metric = $2 AND window_start >= $3 "
|
||||
"ORDER BY window_start",
|
||||
path,
|
||||
metric,
|
||||
since_date,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def upsert_cohort_aggregate(
|
||||
self,
|
||||
path: str,
|
||||
metric: str,
|
||||
window_start: Any,
|
||||
window_end: Any,
|
||||
value: float | None,
|
||||
cell_count: int,
|
||||
cell_suppressed: bool,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO cohort_aggregates "
|
||||
"(path, metric, window_start, window_end, value, cell_count, "
|
||||
"cell_suppressed, updated_at) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, now()) "
|
||||
"ON CONFLICT (path, metric, window_start) DO UPDATE SET "
|
||||
"window_end = excluded.window_end, value = excluded.value, "
|
||||
"cell_count = excluded.cell_count, "
|
||||
"cell_suppressed = excluded.cell_suppressed, "
|
||||
"updated_at = now()",
|
||||
path,
|
||||
metric,
|
||||
window_start,
|
||||
window_end,
|
||||
value,
|
||||
cell_count,
|
||||
cell_suppressed,
|
||||
)
|
||||
|
||||
# ── IssuerKeyStore protocol (D-051, TASK-04-02) ──────────────────────
|
||||
|
||||
async def init_issuer_key(
|
||||
self,
|
||||
key_id: str,
|
||||
public_key: str,
|
||||
private_key_enc: bytes | None,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
|
||||
"VALUES ($1, $2, $3, 'active') "
|
||||
"ON CONFLICT (id) DO NOTHING",
|
||||
key_id,
|
||||
public_key,
|
||||
private_key_enc if private_key_enc is not None else b"",
|
||||
)
|
||||
|
||||
async def get_active_signing_key_row(self) -> dict | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE status = 'active' "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_public_key_row(self, key_id: str) -> dict | None:
|
||||
# Queries by id (NOT status) so superseded keys are found too —
|
||||
# this is the R-VC-MIG-01 verification fallback (D-051).
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE id = $1",
|
||||
key_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def set_issuer_key_superseded(self, key_id: str) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE issuer_keys SET status = 'superseded' WHERE id = $1",
|
||||
key_id,
|
||||
)
|
||||
|
||||
# ── Credential methods ───────────────────────────────────────────────
|
||||
|
||||
async def insert_credential(
|
||||
self,
|
||||
cred_id: str,
|
||||
learner_ref: str,
|
||||
payload_json: str,
|
||||
signature_b64: str,
|
||||
*,
|
||||
operator_id: str | None = None,
|
||||
vc_type: str = "MasteryCredential",
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO issued_credentials "
|
||||
"(id, operator_id, learner_ref, vc_type, payload_jsonb, "
|
||||
"signature_b64, status) "
|
||||
"VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active')",
|
||||
cred_id,
|
||||
operator_id,
|
||||
learner_ref,
|
||||
vc_type,
|
||||
payload_json,
|
||||
signature_b64,
|
||||
)
|
||||
|
||||
async def get_credential(self, cred_id: str) -> dict | None:
|
||||
# Returns a row shaped like PraxisStore.get_credential so the
|
||||
# verification code can use either store interchangeably.
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, learner_ref, "
|
||||
"payload_jsonb::text AS vc_payload_json, signature_b64, "
|
||||
"status, issued_at "
|
||||
"FROM issued_credentials WHERE id = $1",
|
||||
cred_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def set_credential_status(self, cred_id: str, status: str) -> None:
|
||||
extra = ", revoked_at = now()" if status == "revoked" else ""
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2",
|
||||
status,
|
||||
cred_id,
|
||||
)
|
||||
|
||||
async def list_credentials(self, operator_id: str | None = None) -> list[dict]:
|
||||
async with self.pool.acquire() as conn:
|
||||
if operator_id is None:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, learner_ref, vc_type, status, issued_at, "
|
||||
"revoked_at FROM issued_credentials ORDER BY issued_at DESC"
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, learner_ref, vc_type, status, issued_at, "
|
||||
"revoked_at FROM issued_credentials "
|
||||
"WHERE operator_id = $1 ORDER BY issued_at DESC",
|
||||
operator_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Mastery gate event ───────────────────────────────────────────────
|
||||
|
||||
async def record_gate_event(
|
||||
self,
|
||||
learner_ref: str,
|
||||
path_id: str,
|
||||
scenario_id: str | None = None,
|
||||
gate_outcome: str | None = None,
|
||||
rubric_scores_jsonb: Any | None = None,
|
||||
) -> str:
|
||||
event_id = str(uuid.uuid4())
|
||||
scores_json = (
|
||||
rubric_scores_jsonb
|
||||
if isinstance(rubric_scores_jsonb, str)
|
||||
else (json.dumps(rubric_scores_jsonb) if rubric_scores_jsonb is not None else None)
|
||||
)
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO mastery_gate_events "
|
||||
"(id, learner_ref, scenario_id, path_id, gate_outcome, "
|
||||
"rubric_scores_jsonb, source) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'sync')",
|
||||
event_id,
|
||||
learner_ref,
|
||||
scenario_id,
|
||||
path_id,
|
||||
gate_outcome,
|
||||
scores_json,
|
||||
)
|
||||
return event_id
|
||||
|
||||
|
||||
__all__ = ["PgStore"]
|
||||
+1
-1
@@ -338,7 +338,7 @@ class PraxisStore:
|
||||
async with self._connect() as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cur = await db.execute(
|
||||
"SELECT id, public_key, status, created_at "
|
||||
"SELECT id, public_key, private_key_enc, status, created_at "
|
||||
"FROM issuer_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
|
||||
+49
-4
@@ -1,6 +1,6 @@
|
||||
# Praxis v0.2 — Docker Compose service definition
|
||||
# Runs the praxis server inside a Docker container (inside an LXC CT).
|
||||
# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture.
|
||||
# Praxis — Docker Compose service definition (v0.2 + v0.4 Postgres).
|
||||
# Runs the praxis server + a Postgres 16 service inside a Docker-in-LXC CT.
|
||||
# Per ARCHITECTURE.md §v0.2 Deployment + §v0.4 Operator-Tier Architecture.
|
||||
|
||||
services:
|
||||
praxis:
|
||||
@@ -34,6 +34,13 @@ services:
|
||||
DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}"
|
||||
# Cartesia (D-014)
|
||||
CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}"
|
||||
# v0.4 operator tier — Postgres DSN (D-050). Empty → graceful no-pool mode.
|
||||
PRAXIS_PG_DSN: "${PRAXIS_PG_DSN:-}"
|
||||
# v0.4 auth (D-041, D-056). Empty → server generates ephemeral secret (dev only).
|
||||
PRAXIS_COOKIE_SECRET: "${PRAXIS_COOKIE_SECRET:-}"
|
||||
PRAXIS_COOKIE_SECURE: "${PRAXIS_COOKIE_SECURE:-true}"
|
||||
PRAXIS_VC_ISSUER_KEY: "${PRAXIS_VC_ISSUER_KEY:-}"
|
||||
PRAXIS_ISSUER_URL: "${PRAXIS_ISSUER_URL:-https://praxis.example/issuers/v0.4}"
|
||||
env_file:
|
||||
# /etc/praxis/server.env is written by install-service.sh with
|
||||
# secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked
|
||||
@@ -43,7 +50,45 @@ services:
|
||||
# `docker compose up` in production (so secrets are present at runtime).
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- praxis-net
|
||||
|
||||
postgres:
|
||||
image: postgres:16-slim
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: praxis
|
||||
POSTGRES_PASSWORD: "${PRAXIS_PG_PASSWORD:-}"
|
||||
POSTGRES_DB: praxis
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
env_file:
|
||||
- path: /etc/praxis/server.env
|
||||
required: false
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgbackups:/backups
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U praxis -d praxis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- praxis-net
|
||||
# No `ports:` — Postgres is NOT exposed to the LXC host bridge (D-040).
|
||||
# The praxis service reaches it via the praxis-net bridge using the
|
||||
# service-DNS name `postgres`.
|
||||
|
||||
volumes:
|
||||
praxis-data:
|
||||
driver: local
|
||||
driver: local
|
||||
pgdata:
|
||||
driver: local
|
||||
pgbackups:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
praxis-net:
|
||||
driver: bridge
|
||||
@@ -38,6 +38,13 @@ dependencies = [
|
||||
"pynacl>=1.5",
|
||||
"canonicaljson>=2.0",
|
||||
"base58>=2.1",
|
||||
# v0.4 operator tier — Postgres pool (D-050), argon2id passwords (D-041),
|
||||
# slowapi rate limiting (D-041). RESEARCH-v0.4 §new-deps.
|
||||
"asyncpg>=0.29",
|
||||
"argon2-cffi>=23.1",
|
||||
"slowapi>=0.1",
|
||||
# SessionMiddleware uses itsdangerous for signed cookies (D-056).
|
||||
"itsdangerous>=2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
# Praxis v0.4 — Nightly Postgres backup (D-055, G-008).
|
||||
#
|
||||
# Host-side cron script (decoupled from praxis service uptime —
|
||||
# RESEARCH-v0.4 §1.5). Runs pg_dump inside the postgres container and
|
||||
# writes a compressed custom-format dump to the pgbackups volume.
|
||||
#
|
||||
# The %u date format = day-of-week 1..7 (Monday=1, Sunday=7) → rolling
|
||||
# 7-file retention with zero cleanup logic (D-055). Re-running overwrites
|
||||
# the same day-of-week file.
|
||||
#
|
||||
# Cron entry (host, 03:30 CT nightly):
|
||||
# 30 3 * * * /opt/praxis/scripts/backup-pg.sh
|
||||
#
|
||||
# Restore drill (G-008 — run at least once in staging to prove the backup
|
||||
# is valid; NEVER restore into a live DB without stopping praxis first):
|
||||
# docker compose stop praxis
|
||||
# docker compose exec postgres pg_restore -U praxis -d praxis \
|
||||
# --clean --if-exists /backups/praxis-3.dump
|
||||
# # verify: \d operators; SELECT count(*) FROM operators; (etc. for all 5 tables)
|
||||
# docker compose start praxis
|
||||
#
|
||||
# POSIX-sh compatible (no bashisms). Exit 0 on success, 1 on failure.
|
||||
# Args: none. Env: COMPOSE_PROJECT_DIR (default: current dir).
|
||||
|
||||
set -eu
|
||||
|
||||
PROJECT_DIR="${COMPOSE_PROJECT_DIR:-$(pwd)}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
DOW="$(date +%u)"
|
||||
DUMP_FILE="/backups/praxis-${DOW}.dump"
|
||||
|
||||
echo "backup-pg: dumping praxis DB → ${DUMP_FILE} (day-of-week ${DOW})"
|
||||
|
||||
# -Fc = custom compressed format (works with pg_restore --clean --if-exists).
|
||||
# -T stops the container from streaming while dumping? No — pg_dump is
|
||||
# consistent within a transaction; the praxis service can stay up.
|
||||
docker compose exec -T postgres pg_dump -U praxis -Fc praxis -f "$DUMP_FILE"
|
||||
|
||||
# Verify the dump is non-empty (sanity — a 0-byte dump means failure).
|
||||
SIZE=$(docker compose exec -T postgres stat -c '%s' "$DUMP_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$SIZE" -le 0 ]; then
|
||||
echo "backup-pg: ERROR — dump file is empty (${DUMP_FILE})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "backup-pg: OK — ${DUMP_FILE} is ${SIZE} bytes"
|
||||
echo "backup-pg: restore drill (G-008): docker compose exec postgres pg_restore -U praxis -d praxis --clean --if-exists ${DUMP_FILE}"
|
||||
exit 0
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Praxis v0.4 — Operator bootstrap CLI (TASK-05-01, D-052).
|
||||
|
||||
Creates the initial operator from env-provided credentials. Idempotent
|
||||
(ON CONFLICT DO NOTHING). The --update flag forces a rehash + update.
|
||||
|
||||
Env:
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER — operator username (required)
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_PASS — operator password (required)
|
||||
PRAXIS_PG_DSN — Postgres DSN (required)
|
||||
|
||||
Exit: 0 on success (created or already-exists), 1 on missing env / DB error.
|
||||
Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01).
|
||||
|
||||
Run:
|
||||
PRAXIS_BOOTSTRAP_OPERATOR_USER=admin PRAXIS_BOOTSTRAP_OPERATOR_PASS=... \
|
||||
PRAXIS_PG_DSN=postgresql://praxis:...@postgres:5432/praxis \
|
||||
python3 scripts/create-operator.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
_ph = PasswordHasher()
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_S = 5.0
|
||||
|
||||
|
||||
async def create_operator(update: bool = False) -> int:
|
||||
user = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_USER", "").strip()
|
||||
pw = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_PASS", "")
|
||||
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
|
||||
if not user or not pw:
|
||||
print(
|
||||
"create-operator: ERROR — PRAXIS_BOOTSTRAP_OPERATOR_USER and "
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS must be set (R-BOOT-02).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not dsn:
|
||||
print(
|
||||
"create-operator: ERROR — PRAXIS_PG_DSN must be set.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
import asyncpg
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, _RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=dsn, min_size=1, max_size=3, command_timeout=10
|
||||
)
|
||||
try:
|
||||
await apply_pg_migrations(pool)
|
||||
store = PgStore(pool)
|
||||
pw_hash = _ph.hash(pw)
|
||||
display = user
|
||||
oid = await store.insert_operator(
|
||||
user, pw_hash, display, on_conflict_update=update
|
||||
)
|
||||
if update:
|
||||
print(f"create-operator: updated operator {user!r} (id={oid})")
|
||||
elif oid is not None:
|
||||
print(f"create-operator: created operator {user!r} (id={oid})")
|
||||
else:
|
||||
print(f"create-operator: operator {user!r} already exists (no change)")
|
||||
return 0
|
||||
finally:
|
||||
await pool.close()
|
||||
except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc:
|
||||
last_exc = exc
|
||||
if attempt < _RETRY_ATTEMPTS:
|
||||
print(
|
||||
f"create-operator: connection attempt {attempt} failed "
|
||||
f"({exc}); retrying in {_RETRY_BACKOFF_S}s (R-BOOT-01)...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
await asyncio.sleep(_RETRY_BACKOFF_S)
|
||||
continue
|
||||
print(f"create-operator: ERROR — could not connect after {_RETRY_ATTEMPTS} "
|
||||
f"attempts: {last_exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create the initial Praxis operator.")
|
||||
parser.add_argument(
|
||||
"--update", action="store_true",
|
||||
help="Force rehash + update if the operator already exists.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(create_operator(update=args.update))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -40,7 +40,10 @@ upid=$(pve_curl POST "$create_path" \
|
||||
"hostname=${hostname}" \
|
||||
"storage=${storage}" \
|
||||
"rootfs=${storage}:16" \
|
||||
"memory=${PROXMOX_MEMORY_MB:-4096}" \
|
||||
# v0.4: 6144MB default (was 4096 in v0.2). Postgres ~400MB + praxis
|
||||
# ~500MB + Docker daemon ~200MB + build headroom ~1GB + margin
|
||||
# (REQ-NFR-MT-01). Override with PROXMOX_MEMORY_MB if needed.
|
||||
"memory=${PROXMOX_MEMORY_MB:-6144}" \
|
||||
"net0=name=eth0,bridge=vmbr0,ip=dhcp" \
|
||||
"arch=amd64" \
|
||||
"features=nesting=1")
|
||||
|
||||
+166
-15
@@ -14,6 +14,7 @@ no audio/no tokens at runtime, not a crash.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -27,14 +28,32 @@ try:
|
||||
except ImportError: # pragma: no cover
|
||||
pass
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
from db.store import PraxisStore
|
||||
from server.auth.cookies import get_session_middleware_kwargs
|
||||
from server.auth.rate_limit import limiter
|
||||
from server.auth.routes import router as auth_router
|
||||
from server.cohort.nightly import NightlyScheduler
|
||||
from server.operator.cohort import router as cohort_router
|
||||
from server.operator.credentials import router as credentials_router
|
||||
from server.operator.failure_patterns import router as failure_router
|
||||
from server.operator.mastery import router as mastery_router
|
||||
from server.pipeline import build_pipeline
|
||||
from server.vc.issuer_keys import _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
_store = PraxisStore()
|
||||
|
||||
@@ -47,6 +66,64 @@ HOST = _env("PRAXIS_HOST", "0.0.0.0")
|
||||
PORT = int(_env("PRAXIS_PORT", "8789"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""v0.4 — create the asyncpg Postgres pool on startup, close on shutdown.
|
||||
|
||||
Graceful degradation (D-050, REQ-NFR-MT-01): if PRAXIS_PG_DSN is unset,
|
||||
the server starts without Postgres — the learner voice loop (SQLite)
|
||||
is unaffected. app.state.pg_pool / app.state.pg_store are None in that
|
||||
case and auth/operator routes return 503.
|
||||
"""
|
||||
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
|
||||
if not dsn:
|
||||
logger.warning(
|
||||
"PRAXIS_PG_DSN not set — starting without Postgres (dev/no-pool mode). "
|
||||
"Operator auth + cohort endpoints will be unavailable (503). "
|
||||
"Learner voice loop (SQLite) is unaffected."
|
||||
)
|
||||
app.state.pg_pool = None
|
||||
app.state.pg_store = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
return
|
||||
import asyncpg
|
||||
|
||||
logger.info("Creating asyncpg Postgres pool (min=1, max=10, D-050)")
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=dsn,
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
command_timeout=10,
|
||||
)
|
||||
app.state.pg_pool = pool
|
||||
app.state.pg_store = PgStore(pool)
|
||||
nightly = NightlyScheduler()
|
||||
app.state.nightly_scheduler = nightly
|
||||
try:
|
||||
applied = await apply_pg_migrations(pool)
|
||||
if applied:
|
||||
logger.info(f"Postgres migrations applied: {applied}")
|
||||
else:
|
||||
logger.info("Postgres migrations up to date")
|
||||
# VC key migration (TASK-06-03, R-VC-MIG-01, G-027) — runs once on
|
||||
# first boot, idempotent. Non-fatal on failure (v0.3 SQLite path
|
||||
# remains intact for verification).
|
||||
await _maybe_migrate_issuer_keys()
|
||||
# v0.4 P2 (D-054, REQ-NFR-DASH-02): start the nightly reconciliation
|
||||
# scheduler at 03:00 CT. Cancelled on shutdown.
|
||||
await nightly.start(app.state.pg_store)
|
||||
logger.info("Nightly cohort reconciliation scheduler started (03:00 CT)")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await nightly.stop()
|
||||
finally:
|
||||
await pool.close()
|
||||
logger.info("Postgres pool closed")
|
||||
|
||||
|
||||
class WebRTCOffer(BaseModel):
|
||||
"""Client→server WebRTC offer (SDP + type)."""
|
||||
|
||||
@@ -54,13 +131,19 @@ class WebRTCOffer(BaseModel):
|
||||
type: str = "offer"
|
||||
|
||||
|
||||
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0")
|
||||
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0", lifespan=lifespan)
|
||||
# slowapi rate-limit state + 429 handler (D-041, TASK-03-03).
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # dev — the client is a separate Vite origin
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# SessionMiddleware (signed cookies, D-056) — added AFTER CORS so it is
|
||||
# the outermost middleware (signs cookies before CORS headers are added).
|
||||
app.add_middleware(SessionMiddleware, **get_session_middleware_kwargs())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -123,28 +206,96 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
|
||||
|
||||
@app.get("/vc/verify/{credential_id}")
|
||||
async def vc_verify(credential_id: str) -> dict[str, Any]:
|
||||
"""Public, unauthenticated VC verification endpoint (D-043).
|
||||
"""Public, unauthenticated VC verification endpoint (D-043, G-011).
|
||||
|
||||
Returns {valid, status, issuer, credential, mastery, credentialTier,
|
||||
verifiedAt}. 404 if the credential id is not found. No PII beyond what
|
||||
the credential asserts.
|
||||
Two-store fallback (G-011, binding contract):
|
||||
(a) If Postgres is available (app.state.pg_store), use it for issuer
|
||||
key lookup (active + superseded keys).
|
||||
(b) If the credential is not in Postgres issued_credentials, fall back
|
||||
to SQLite (v0.3 credentials remain in SQLite — D-051).
|
||||
(c) If Postgres is NOT available, use the v0.3 SQLite path for both.
|
||||
The VC key migration (TASK-04-03) runs once on first boot (idempotent)
|
||||
inside the lifespan — see _maybe_migrate_issuer_keys.
|
||||
"""
|
||||
await _store.init()
|
||||
result = await verify_credential(_store, credential_id)
|
||||
pg_store = getattr(app.state, "pg_store", None)
|
||||
result = await verify_credential(
|
||||
_store, credential_id,
|
||||
pg_store=pg_store, sqlite_store=_store,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="credential not found")
|
||||
return result
|
||||
|
||||
|
||||
# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────
|
||||
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
|
||||
# take precedence. html=True serves index.html for "/" (SPA root).
|
||||
# The client has no React Router (single-view state machine: start→live
|
||||
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
|
||||
async def _maybe_migrate_issuer_keys() -> None:
|
||||
"""Run the VC key migration on first boot (TASK-06-03, R-VC-MIG-01).
|
||||
|
||||
Idempotent — no-op if Postgres already has an active issuer key. G-027:
|
||||
if SQLite has no v0.3 active key (fresh deploy), skips archive and only
|
||||
generates a fresh v0.4 keypair.
|
||||
"""
|
||||
pg_store = getattr(app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
return
|
||||
try:
|
||||
await _store.init()
|
||||
root_key = _load_root_key()
|
||||
result = await migrate_issuer_keys(_store, pg_store, root_key)
|
||||
if result["new_key_id"] is not None:
|
||||
logger.info(
|
||||
f"VC key migration: archived v0.3 key={result['archived_key_id']}, "
|
||||
f"generated fresh v0.4 key={result['new_key_id']}"
|
||||
)
|
||||
else:
|
||||
logger.info("VC key migration: active key already present (no-op)")
|
||||
except Exception as exc:
|
||||
logger.error(f"VC key migration failed (non-fatal — v0.3 path intact): {exc}")
|
||||
|
||||
|
||||
# ── Operator auth routes (TASK-06-02, D-057) ───────────────────────────
|
||||
# Mounted BEFORE the StaticFiles mount so /api/operator/* is matched by
|
||||
# the router (routes-before-static-mount constraint, carry-forward v0.2).
|
||||
app.include_router(auth_router)
|
||||
|
||||
# ── Operator API cohort endpoints (TASK-10-02, D-053, D-057) ──────────
|
||||
# Auth-gated via Depends(current_operator) inside each router. Mounted
|
||||
# BEFORE the SPA StaticFiles fallback so /api/operator/* is matched by the
|
||||
# API routers, not the SPA fallback.
|
||||
app.include_router(cohort_router)
|
||||
app.include_router(mastery_router)
|
||||
app.include_router(failure_router)
|
||||
app.include_router(credentials_router)
|
||||
|
||||
|
||||
# ── SPA StaticFiles fallback (G-041 binding, TASK-10-01, R-DASH-03/05) ─
|
||||
# Custom StaticFiles subclass that returns index.html for non-file paths
|
||||
# (SPA client-side routing). G-041 OVERRIDES the plan's catch-all route —
|
||||
# a @app.get("/{path:path}") catch-all before StaticFiles would shadow
|
||||
# asset serving (assertion 8 in TASK-10-04). This subclass serves assets
|
||||
# normally (JS/CSS) and falls back to index.html for client-side routes
|
||||
# (/operator/dashboard, /operator/login). API routes registered above take
|
||||
# precedence over the mount.
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except (StarletteHTTPException, HTTPException) as e:
|
||||
if getattr(e, "status_code", None) == 404:
|
||||
import os
|
||||
index = os.path.join(self.directory, "index.html")
|
||||
if os.path.isfile(index):
|
||||
return FileResponse(index)
|
||||
raise
|
||||
|
||||
|
||||
# Mount client/dist at "/" AFTER all API routes so they take precedence.
|
||||
# html=True serves index.html for "/" (SPA root). The SpaStaticFiles
|
||||
# subclass serves index.html for unknown paths (React Router routes).
|
||||
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
|
||||
if os.path.isdir(_CLIENT_DIST):
|
||||
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
|
||||
logger.info(f"Serving client from {_CLIENT_DIST}")
|
||||
app.mount("/", SpaStaticFiles(directory=_CLIENT_DIST, html=True), name="spa")
|
||||
logger.info(f"Serving client from {_CLIENT_DIST} (SPA fallback enabled)")
|
||||
else:
|
||||
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Signed cookie configuration (TASK-03-02, D-041, D-056, R-AUTH-01, G-031).
|
||||
|
||||
Returns kwargs for Starlette SessionMiddleware (itsdangerous HMAC-SHA256
|
||||
signed cookies — D-056, stateless, no sessions table). The cookie name is
|
||||
`praxis_op` (distinct from any future learner cookie).
|
||||
|
||||
R-AUTH-01 / G-031 reframe: the PRIMARY mitigation for a sniffed operator
|
||||
cookie is the k-anonymity defense-in-depth — the cohort dashboard reads
|
||||
only k-anonymized aggregates, so a sniffed cookie leaks NO learner PII.
|
||||
The `PRAXIS_COOKIE_SECURE` flag is the SECONDARY mitigation (operational
|
||||
convenience for when TLS arrives). It defaults to true; the HTTP pilot
|
||||
(LXC, no TLS — D-030) sets it to false with a logged WARNING.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_COOKIE_MAX_AGE_S = 28800 # 8h (D-041)
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
raw = os.environ.get(key, "").strip().lower()
|
||||
if raw in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if raw in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def get_session_middleware_kwargs() -> dict:
|
||||
"""Return kwargs for Starlette SessionMiddleware.
|
||||
|
||||
If PRAXIS_COOKIE_SECRET is unset, generate an ephemeral random secret
|
||||
and log a WARNING (dev only — sessions won't survive a restart and this
|
||||
MUST NOT be used in pilot/production).
|
||||
"""
|
||||
secret = os.environ.get("PRAXIS_COOKIE_SECRET", "").strip()
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
logger.warning(
|
||||
"PRAXIS_COOKIE_SECRET not set — generated an ephemeral random secret. "
|
||||
"Sessions will NOT survive a server restart. This is dev-only; set "
|
||||
"PRAXIS_COOKIE_SECRET (>=32 bytes) for pilot/production."
|
||||
)
|
||||
secure = _env_bool("PRAXIS_COOKIE_SECURE", True)
|
||||
if not secure:
|
||||
logger.warning(
|
||||
"Cookie Secure flag disabled (PRAXIS_COOKIE_SECURE=false) — HTTP pilot "
|
||||
"mode (R-AUTH-01). Do not use in production. NOTE (G-031): the primary "
|
||||
"R-AUTH-01 mitigation is k-anon defense-in-depth (cohort dashboard reads "
|
||||
"only k-anonymized aggregates → sniffed cookie leaks no PII); this flag "
|
||||
"is the secondary mitigation."
|
||||
)
|
||||
return {
|
||||
"secret_key": secret,
|
||||
"session_cookie": "praxis_op",
|
||||
"max_age": _COOKIE_MAX_AGE_S,
|
||||
"https_only": secure,
|
||||
"same_site": "strict",
|
||||
"path": "/",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["get_session_middleware_kwargs"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""current_operator dependency (TASK-03-04, D-057).
|
||||
|
||||
Server-side auth enforcement: every `/api/operator/*` protected route uses
|
||||
`Depends(current_operator)`. The dependency NEVER trusts the client (D-057)
|
||||
— it reads the signed-cookie session, fetches the operator from Postgres,
|
||||
and 401s on any gap (missing/invalid/expired cookie, unknown id, inactive
|
||||
operator). The cookie is the authz *token*; the Postgres lookup is the
|
||||
authz *decision*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from server.auth.models import Operator
|
||||
|
||||
|
||||
async def current_operator(request: Request) -> Operator:
|
||||
"""Resolve the authenticated operator from the signed-cookie session.
|
||||
|
||||
Raises 401 on: missing session, missing operator_id, no Postgres store
|
||||
(503 actually — operator tier unavailable), unknown operator id, or an
|
||||
inactive operator (session is cleared in the latter case so the client
|
||||
cookie is invalidated).
|
||||
"""
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
session = request.session
|
||||
op_id = session.get("operator_id") if session else None
|
||||
if not op_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="not authenticated",
|
||||
)
|
||||
row = await pg_store.get_operator_by_id(op_id)
|
||||
if row is None or not row.get("is_active"):
|
||||
# Inactive/unknown → clear the session so the cookie is invalidated.
|
||||
if session:
|
||||
session.clear()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="not authenticated",
|
||||
)
|
||||
return Operator(
|
||||
id=str(row["id"]),
|
||||
username=row["username"],
|
||||
display_name=row.get("display_name"),
|
||||
role=row.get("role", "operator"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["current_operator"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Auth data models (TASK-03-04)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Operator:
|
||||
"""The authenticated operator injected into protected routes (D-057)."""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: str
|
||||
|
||||
|
||||
__all__ = ["Operator"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Argon2id password hashing (TASK-03-01, D-041, REQ-NFR-AUTH-01).
|
||||
|
||||
Uses argon2-cffi PasswordHasher with defaults that exceed OWASP minimums
|
||||
(time_cost=3, memory_cost=64MiB, parallelism=4 — RESEARCH-v0.4 §2.1).
|
||||
Single operator, low-frequency logins → hashing latency < 1s is
|
||||
acceptable (R-AUTH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password with argon2id. Returns the encoded hash string."""
|
||||
return _ph.hash(plain)
|
||||
|
||||
|
||||
def verify_password(stored_hash: str, plain: str) -> bool:
|
||||
"""Verify a plaintext password against a stored argon2id hash.
|
||||
|
||||
Returns False on mismatch (no exception) so the login flow can apply a
|
||||
uniform 401 + rate-limit-increment path on any auth failure.
|
||||
"""
|
||||
try:
|
||||
_ph.verify(stored_hash, plain)
|
||||
return True
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored_hash: str) -> bool:
|
||||
"""True if the stored hash was produced with weaker params than the
|
||||
current PasswordHasher defaults. The login flow rehashes + updates the
|
||||
store when this returns True (param upgrades without forcing a reset)."""
|
||||
return _ph.check_needs_rehash(stored_hash)
|
||||
|
||||
|
||||
__all__ = ["hash_password", "verify_password", "needs_rehash"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Login rate limiting (TASK-03-03, D-041).
|
||||
|
||||
slowapi Limiter with an in-memory backend (single-instance — D-041).
|
||||
5 login attempts per minute per client IP. On exceed → 429 + Retry-After.
|
||||
|
||||
R-AUTH-03 (in-memory counter lost on restart) is an accepted pilot risk
|
||||
(RESEARCH-v0.4 §2.5) — a restart at most resets the counter, which slightly
|
||||
widens the brute-force window but does not enable it (argon2id + 5/min is
|
||||
still the binding control). A hand-rolled counter is the documented
|
||||
fallback if slowapi is ever removed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
|
||||
|
||||
|
||||
def reset_login_rate_limit() -> None:
|
||||
"""Clear the in-memory rate-limit counters (test helper + restart-safe)."""
|
||||
try:
|
||||
limiter.reset()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def rate_limit_login():
|
||||
"""Decorator factory: 5 login attempts per minute per IP (D-041)."""
|
||||
return limiter.limit("5/minute")
|
||||
|
||||
|
||||
__all__ = ["limiter", "rate_limit_login", "reset_login_rate_limit"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Auth route handlers — login, logout, me (TASK-03-05, D-041, D-056, D-057).
|
||||
|
||||
APIRouter(prefix="/api/operator") with:
|
||||
POST /login — rate-limited 5/min (TASK-03-03), NOT auth-gated.
|
||||
POST /logout — auth-gated (Depends(current_operator)).
|
||||
GET /me — auth-gated (React route guard — D-057).
|
||||
|
||||
Stateless cookies (D-056): logout clears the server-side session; the
|
||||
client also clears its cookie. No sessions table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
||||
from server.auth.rate_limit import rate_limit_login
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class OperatorOut(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: str = "operator"
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
operator: OperatorOut
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
operator: OperatorOut
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
def _operator_out(op: Operator) -> OperatorOut:
|
||||
return OperatorOut(
|
||||
id=op.id,
|
||||
username=op.username,
|
||||
display_name=op.display_name,
|
||||
role=op.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@rate_limit_login()
|
||||
async def login(body: LoginBody, request: Request) -> LoginResponse:
|
||||
"""Rate-limited login (5/min per IP — D-041).
|
||||
|
||||
On success: sets `request.session["operator_id"]` (signed cookie via
|
||||
SessionMiddleware) + updates last_login_at. On needs_rehash → rehash +
|
||||
update the store. On failure → 401 (no cookie set).
|
||||
"""
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
row = await pg_store.get_operator_by_username(body.username)
|
||||
if row is None or not row.get("is_active"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid credentials",
|
||||
)
|
||||
if not verify_password(row["password_hash"], body.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid credentials",
|
||||
)
|
||||
op_id = str(row["id"])
|
||||
request.session["operator_id"] = op_id
|
||||
await pg_store.update_last_login(op_id)
|
||||
if needs_rehash(row["password_hash"]):
|
||||
new_hash = hash_password(body.password)
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE operators SET password_hash = $1 WHERE id = $2",
|
||||
new_hash, op_id,
|
||||
)
|
||||
return LoginResponse(
|
||||
operator=OperatorOut(
|
||||
id=op_id,
|
||||
username=row["username"],
|
||||
display_name=row.get("display_name"),
|
||||
role=row.get("role", "operator"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=OkResponse)
|
||||
async def logout(request: Request, op: Operator = Depends(current_operator)) -> OkResponse:
|
||||
# Stateless (D-056): clearing the server session invalidates the signed
|
||||
# cookie's payload; the client also clears its cookie.
|
||||
request.session.clear()
|
||||
return OkResponse(ok=True)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(op: Operator = Depends(current_operator)) -> MeResponse:
|
||||
"""React route guard endpoint (D-057). 200 → render; 401 → redirect."""
|
||||
return MeResponse(operator=_operator_out(op))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Cohort aggregation logic + k-anonymity suppression (TASK-07-01, D-034, D-045).
|
||||
|
||||
Computes k-anonymized aggregates for the affected (path, metric, window_start)
|
||||
bins and upserts them to cohort_aggregates via PgStore. Suppression is at
|
||||
write time (auditable — RESEARCH-v0.4 §3.1): COUNT(DISTINCT learner_ref) < 10
|
||||
=> cell_suppressed=TRUE, value=NULL.
|
||||
|
||||
Metrics computed (per 7-day rolling window, per path):
|
||||
sessions_count, active_learners_count, gate_open_rate,
|
||||
median_mastery_score, failure_mode_frequency,
|
||||
rubric_criterion_means, week_distribution.
|
||||
|
||||
The session_outcome dict contains: learner_ref (opaque — D-031), path,
|
||||
scenario_id, outcome (pass/fail), rubric_scores, failure_mode, branch_path,
|
||||
timestamp.
|
||||
|
||||
No raw learner PII in Postgres (D-031): only aggregates + opaque learner_ref
|
||||
for distinct counting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import statistics
|
||||
from typing import Any
|
||||
|
||||
from db.pg_store import PgStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
K_ANON_THRESHOLD = 10
|
||||
|
||||
|
||||
def _rolling_window(now: _dt.datetime | None = None) -> tuple[_dt.date, _dt.date]:
|
||||
"""Return the 7-day rolling window (start, end) for `now`.
|
||||
|
||||
window_start = today - 6 days, window_end = today (inclusive 7-day span).
|
||||
"""
|
||||
today = (now or _dt.datetime.now(_dt.timezone.utc)).date()
|
||||
return today - _dt.timedelta(days=6), today
|
||||
|
||||
|
||||
def _distinct_learners(sessions: list[dict[str, Any]]) -> int:
|
||||
return len({s["learner_ref"] for s in sessions if s.get("learner_ref")})
|
||||
|
||||
|
||||
async def aggregate_session(pg_store: PgStore, session_outcome: dict[str, Any]) -> None:
|
||||
"""Compute + upsert k-anonymized aggregates for one session outcome.
|
||||
|
||||
Reads the affected path's recent session set (from cohort_aggregates or
|
||||
an in-memory accumulator), recomputes the metric cells for the 7-day
|
||||
window, applies k-anon suppression, and upserts each cell idempotently.
|
||||
|
||||
Idempotent (ON CONFLICT upsert) — re-running with the same outcome
|
||||
produces the same aggregate. The caller (hook.py) passes one session at
|
||||
a time; the nightly job (nightly.py) recomputes the full window.
|
||||
"""
|
||||
path = session_outcome.get("path") or session_outcome.get("path_id") or "unknown"
|
||||
learner_ref = session_outcome.get("learner_ref") or "unknown"
|
||||
outcome = session_outcome.get("outcome", "fail")
|
||||
rubric_scores = session_outcome.get("rubric_scores") or []
|
||||
failure_mode = session_outcome.get("failure_mode")
|
||||
branch_path = session_outcome.get("branch_path") or []
|
||||
scenario_id = session_outcome.get("scenario_id")
|
||||
ts = session_outcome.get("timestamp")
|
||||
|
||||
window_start, window_end = _rolling_window(
|
||||
_dt.datetime.fromisoformat(ts) if isinstance(ts, str) else None
|
||||
)
|
||||
|
||||
# Distinct-learner count for k-anon: this session's learner + any others
|
||||
# already recorded for the same (path, window). For the per-session hook
|
||||
# we accumulate by appending to a sessions_count cell + tracking distinct
|
||||
# learner_refs via active_learners_count. The nightly job recomputes from
|
||||
# the mastery_gate_events + session log (full reconciliation).
|
||||
#
|
||||
# For the on-session-end hook we cannot cheaply know all distinct learners
|
||||
# without a raw-events table (which we deliberately do not maintain for PII
|
||||
# reasons — D-031). We instead maintain a single active_learners_count
|
||||
# counter per (path, window) and the nightly job reconciles the true
|
||||
# distinct count from mastery_gate_events. The hook uses the running
|
||||
# counter; if it is < K_ANON_THRESHOLD we suppress.
|
||||
active_count = await _bump_active_learners(pg_store, path, window_start, learner_ref)
|
||||
sessions_count = await _bump_counter(pg_store, path, "sessions_count", window_start, window_end)
|
||||
|
||||
suppressed = active_count < K_ANON_THRESHOLD
|
||||
|
||||
await _upsert_cell(pg_store, path, "sessions_count", window_start, window_end,
|
||||
float(sessions_count) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
await _upsert_cell(pg_store, path, "active_learners_count", window_start, window_end,
|
||||
float(active_count) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# gate_open_rate: 1.0 if this session passed, 0.0 otherwise (running mean
|
||||
# reconciled by nightly). Stored as the fraction of pass outcomes seen.
|
||||
passed = 1.0 if outcome == "pass" else 0.0
|
||||
gate_open_rate = await _running_mean(pg_store, path, "gate_open_rate",
|
||||
window_start, window_end, passed, active_count)
|
||||
await _upsert_cell(pg_store, path, "gate_open_rate", window_start, window_end,
|
||||
gate_open_rate if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# median_mastery_score (from rubric scores) — running median reconciled nightly
|
||||
if rubric_scores:
|
||||
scores = [float(r.get("score", r.get("weighted_mean", 0.0))) for r in rubric_scores]
|
||||
scenario_mean = statistics.mean(scores) if scores else 0.0
|
||||
median_val = await _running_mean(pg_store, path, "median_mastery_score",
|
||||
window_start, window_end, scenario_mean, active_count)
|
||||
await _upsert_cell(pg_store, path, "median_mastery_score", window_start, window_end,
|
||||
median_val if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# rubric_criterion_means — one cell per criterion id
|
||||
for r in rubric_scores:
|
||||
cid = r.get("criterion_id") or r.get("id") or "unknown"
|
||||
score = float(r.get("score", 0.0))
|
||||
mean_val = await _running_mean(pg_store, path, f"rubric_criterion_mean:{cid}",
|
||||
window_start, window_end, score, active_count)
|
||||
await _upsert_cell(pg_store, path, f"rubric_criterion_mean:{cid}",
|
||||
window_start, window_end,
|
||||
mean_val if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# failure_mode_frequency — one cell per observed mode
|
||||
if failure_mode:
|
||||
freq = await _bump_mode_counter(pg_store, path, f"failure_mode:{failure_mode}",
|
||||
window_start, window_end)
|
||||
await _upsert_cell(pg_store, path, f"failure_mode:{failure_mode}",
|
||||
window_start, window_end,
|
||||
float(freq) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
# week_distribution — branch_path captures the path-week; record one cell
|
||||
# per branch outcome seen.
|
||||
if branch_path:
|
||||
last_branch = branch_path[-1] if isinstance(branch_path, list) else str(branch_path)
|
||||
freq = await _bump_mode_counter(pg_store, path, f"branch:{last_branch}",
|
||||
window_start, window_end)
|
||||
await _upsert_cell(pg_store, path, f"branch:{last_branch}",
|
||||
window_start, window_end,
|
||||
float(freq) if not suppressed else None,
|
||||
active_count, suppressed)
|
||||
|
||||
log.debug(
|
||||
"aggregate_session path=%s learner=%s outcome=%s window=%s..%s "
|
||||
"active=%d suppressed=%s",
|
||||
path, learner_ref, outcome, window_start, window_end,
|
||||
active_count, suppressed,
|
||||
)
|
||||
|
||||
|
||||
# ── Internal cell upsert + counter helpers ──────────────────────────────────
|
||||
# The PgStore.upsert_cohort_aggregate is idempotent (ON CONFLICT). We use a
|
||||
# small in-memory cache on the PgStore instance (created lazily) to track
|
||||
# per-(path, metric, window) running counters + distinct learner sets. The
|
||||
# nightly job bypasses this cache and recomputes from mastery_gate_events.
|
||||
|
||||
|
||||
def _cache(pg_store: PgStore) -> dict:
|
||||
cache = getattr(pg_store, "_agg_cache", None)
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
try:
|
||||
pg_store._agg_cache = cache # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
return cache
|
||||
|
||||
|
||||
def _ck(path: str, metric: str, window_start: _dt.date) -> tuple:
|
||||
return (path, metric, window_start)
|
||||
|
||||
|
||||
async def _upsert_cell(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date,
|
||||
value: float | None, cell_count: int,
|
||||
suppressed: bool) -> None:
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, metric, window_start, window_end, value, cell_count, suppressed,
|
||||
)
|
||||
|
||||
|
||||
async def _bump_active_learners(pg_store: PgStore, path: str,
|
||||
window_start: _dt.date, learner_ref: str) -> int:
|
||||
"""Track distinct learner_refs per (path, window) in the in-memory cache.
|
||||
|
||||
Returns the current distinct count (after adding this learner). The
|
||||
nightly job reconciles the true count from mastery_gate_events.
|
||||
"""
|
||||
cache = _cache(pg_store)
|
||||
key = _ck(path, "__learners__", window_start)
|
||||
learners: set[str] = cache.get(key, set())
|
||||
learners.add(learner_ref)
|
||||
cache[key] = learners
|
||||
return len(learners)
|
||||
|
||||
|
||||
async def _bump_counter(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date) -> int:
|
||||
cache = _cache(pg_store)
|
||||
key = _ck(path, metric, window_start)
|
||||
cache[key] = cache.get(key, 0) + 1
|
||||
return cache[key]
|
||||
|
||||
|
||||
async def _bump_mode_counter(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date) -> int:
|
||||
return await _bump_counter(pg_store, path, metric, window_start, window_end)
|
||||
|
||||
|
||||
async def _running_mean(pg_store: PgStore, path: str, metric: str,
|
||||
window_start: _dt.date, window_end: _dt.date,
|
||||
value: float, _active_count: int) -> float:
|
||||
"""Incremental running mean per (path, metric, window)."""
|
||||
cache = _cache(pg_store)
|
||||
k = _ck(path, metric, window_start)
|
||||
n_key = _ck(path, metric + "__n__", window_start)
|
||||
n = cache.get(n_key, 0)
|
||||
prev = cache.get(k, 0.0)
|
||||
new_n = n + 1
|
||||
new_mean = prev + (value - prev) / new_n
|
||||
cache[k] = new_mean
|
||||
cache[n_key] = new_n
|
||||
return new_mean
|
||||
|
||||
|
||||
__all__ = ["aggregate_session", "K_ANON_THRESHOLD", "_rolling_window"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""On-session-end async aggregation hook (TASK-07-02, D-054).
|
||||
|
||||
Fire-and-forget: designed to be chained as an `asyncio.create_task` after
|
||||
the mastery flow. Failures log + the nightly job reconciles (no exception
|
||||
propagation to the caller — the session-end response returns immediately).
|
||||
|
||||
If `pg_store` is None (no Postgres), no-op + log WARNING.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from db.pg_store import PgStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def on_session_end(pg_store: PgStore | None, session_outcome: dict[str, Any]) -> None:
|
||||
"""Aggregate one session outcome. Non-blocking, fire-and-forget (D-054).
|
||||
|
||||
Failures are logged but never raised — the caller (session_recorder) has
|
||||
already returned its response; aggregation is off the voice path. The
|
||||
nightly job (nightly.py) reconciles any missed/hook-failed sessions.
|
||||
"""
|
||||
if pg_store is None:
|
||||
log.warning(
|
||||
"cohort aggregation skipped (no Postgres) for session %s",
|
||||
session_outcome.get("scenario_id"),
|
||||
)
|
||||
return
|
||||
try:
|
||||
from server.cohort.aggregator import aggregate_session
|
||||
|
||||
await aggregate_session(pg_store, session_outcome)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"cohort aggregation hook failed for session %s — nightly job will reconcile",
|
||||
session_outcome.get("scenario_id"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["on_session_end"]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Nightly reconciliation scheduler (TASK-07-03, D-054, REQ-NFR-DASH-02).
|
||||
|
||||
In-process asyncio scheduler (no APScheduler — RESEARCH-v0.4 §3.4). Loops:
|
||||
compute seconds until next 03:00 CT (America/Winnipeg — Canada pilot) →
|
||||
asyncio.sleep → reconcile all 7-day windows → repeat. Resumes after restart.
|
||||
Failures log + retry next night (R-DASH-04).
|
||||
|
||||
Reconciliation recomputes all (path, metric, window_start) cells from the
|
||||
mastery_gate_events audit log + re-applies k-anonymity suppression. This
|
||||
guarantees REQ-NFR-DASH-02 (freshness ≤ 24h — the nightly job runs at least
|
||||
once/day) and reconciles any hook failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any
|
||||
|
||||
from db.pg_store import PgStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CT = _dt.timezone(_dt.timedelta(hours=-5), "CT")
|
||||
NIGHTLY_HOUR = 3
|
||||
NIGHTLY_MINUTE = 0
|
||||
|
||||
|
||||
def seconds_until_next_03_ct(now: _dt.datetime | None = None) -> float:
|
||||
"""Seconds from `now` until the next 03:00 America/Winnipeg (CT).
|
||||
|
||||
America/Winnipeg observes CST (UTC-6) in winter + CDT (UTC-5) in summer.
|
||||
We approximate CT as a fixed UTC-5 offset (the pilot is in summer CDT
|
||||
and the scheduler drift of ≤1h over DST boundaries is acceptable for a
|
||||
nightly reconciliation job — the on-session-end hook keeps data fresh).
|
||||
A future hardening would use zoneinfo.ZoneInfo("America/Winnipeg") with
|
||||
proper DST handling.
|
||||
"""
|
||||
now = now or _dt.datetime.now(CT)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=CT)
|
||||
next_run = now.replace(hour=NIGHTLY_HOUR, minute=NIGHTLY_MINUTE,
|
||||
second=0, microsecond=0)
|
||||
if next_run <= now:
|
||||
next_run += _dt.timedelta(days=1)
|
||||
return (next_run - now).total_seconds()
|
||||
|
||||
|
||||
class NightlyScheduler:
|
||||
"""In-process asyncio scheduler for nightly cohort reconciliation.
|
||||
|
||||
Started as an asyncio task in the app lifespan (TASK-10-02). Cancel on
|
||||
shutdown. R-DASH-04: a reconciliation failure logs + retries the next
|
||||
night (the loop continues).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopped = False
|
||||
|
||||
async def start(self, pg_store: PgStore) -> asyncio.Task:
|
||||
"""Begin the nightly loop. Returns the running task."""
|
||||
self._stopped = False
|
||||
self._task = asyncio.create_task(self._run_loop(pg_store))
|
||||
return self._task
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the running loop (graceful shutdown)."""
|
||||
self._stopped = True
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _run_loop(self, pg_store: PgStore) -> None:
|
||||
while not self._stopped:
|
||||
try:
|
||||
secs = seconds_until_next_03_ct()
|
||||
log.info("nightly scheduler: next run in %.0fs (03:00 CT)", secs)
|
||||
await asyncio.sleep(secs)
|
||||
if self._stopped:
|
||||
return
|
||||
await self._reconcile(pg_store)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception:
|
||||
log.exception("nightly reconciliation failed — retry next night (R-DASH-04)")
|
||||
# brief sleep to avoid a tight error loop if the clock is broken
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _reconcile(self, pg_store: PgStore) -> None:
|
||||
"""Recompute all 7-day windows for all paths from mastery_gate_events.
|
||||
|
||||
Reads recent gate events (the audit log, REQ-NFR-MAST-02), groups by
|
||||
(path, window_start), recomputes each metric cell, applies k-anon
|
||||
suppression, and upserts. Idempotent — re-running produces the same
|
||||
aggregates (ON CONFLICT upsert).
|
||||
"""
|
||||
events = await _load_recent_events(pg_store)
|
||||
if not events:
|
||||
log.info("nightly reconcile: no recent gate events; nothing to recompute")
|
||||
return
|
||||
|
||||
# Group by path → window_start → list[events]
|
||||
by_path_window: dict[tuple[str, _dt.date], list[dict[str, Any]]] = defaultdict(list)
|
||||
today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||
window_start = today - _dt.timedelta(days=6)
|
||||
for ev in events:
|
||||
ev_date = _coerce_date(ev.get("recorded_at"))
|
||||
if ev_date is None or ev_date < window_start:
|
||||
continue
|
||||
path = ev.get("path_id") or "unknown"
|
||||
by_path_window[(path, window_start)].append(ev)
|
||||
|
||||
from server.cohort.aggregator import K_ANON_THRESHOLD, _rolling_window
|
||||
|
||||
ws, we = _rolling_window()
|
||||
for (path, _), evs in by_path_window.items():
|
||||
learners = {e.get("learner_ref") for e in evs if e.get("learner_ref")}
|
||||
active_count = len(learners)
|
||||
suppressed = active_count < K_ANON_THRESHOLD
|
||||
|
||||
# sessions_count
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "sessions_count", ws, we,
|
||||
None if suppressed else float(len(evs)),
|
||||
active_count, suppressed,
|
||||
)
|
||||
# active_learners_count
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "active_learners_count", ws, we,
|
||||
None if suppressed else float(active_count),
|
||||
active_count, suppressed,
|
||||
)
|
||||
# gate_open_rate
|
||||
gate_opens = sum(1 for e in evs if (e.get("gate_outcome") or "") == "open")
|
||||
rate = gate_opens / len(evs) if evs else 0.0
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "gate_open_rate", ws, we,
|
||||
None if suppressed else rate,
|
||||
active_count, suppressed,
|
||||
)
|
||||
# median_mastery_score + rubric_criterion_means from rubric_scores_jsonb
|
||||
score_rows: list[float] = []
|
||||
crit_scores: dict[str, list[float]] = defaultdict(list)
|
||||
for e in evs:
|
||||
scores = e.get("rubric_scores") or []
|
||||
if isinstance(scores, str):
|
||||
import json as _json
|
||||
try:
|
||||
scores = _json.loads(scores)
|
||||
except Exception:
|
||||
scores = []
|
||||
for r in scores:
|
||||
if isinstance(r, dict):
|
||||
cid = r.get("criterion_id") or r.get("id") or "unknown"
|
||||
s = r.get("score") or r.get("weighted_mean")
|
||||
if s is not None:
|
||||
crit_scores[cid].append(float(s))
|
||||
score_rows.append(float(s))
|
||||
if score_rows:
|
||||
med = statistics.median(score_rows)
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, "median_mastery_score", ws, we,
|
||||
None if suppressed else med,
|
||||
active_count, suppressed,
|
||||
)
|
||||
for cid, vals in crit_scores.items():
|
||||
mean_v = statistics.mean(vals) if vals else 0.0
|
||||
await pg_store.upsert_cohort_aggregate(
|
||||
path, f"rubric_criterion_mean:{cid}", ws, we,
|
||||
None if suppressed else mean_v,
|
||||
active_count, suppressed,
|
||||
)
|
||||
|
||||
log.info("nightly reconcile: recomputed %d (path, window) cells", len(by_path_window))
|
||||
|
||||
async def reconcile_now(self, pg_store: PgStore) -> None:
|
||||
"""Public hook for tests / ad-hoc reconciliation (no clock wait)."""
|
||||
await self._reconcile(pg_store)
|
||||
|
||||
|
||||
async def _load_recent_events(pg_store: PgStore) -> list[dict[str, Any]]:
|
||||
"""Load mastery_gate_events from the last 7 days.
|
||||
|
||||
Uses the PgStore pool directly (no extra method on PgStore to keep the
|
||||
surface minimal). Returns rows as dicts with decoded rubric_scores.
|
||||
"""
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT learner_ref, scenario_id, path_id, gate_outcome, "
|
||||
"rubric_scores_jsonb, recorded_at "
|
||||
"FROM mastery_gate_events "
|
||||
"WHERE recorded_at >= now() - interval '7 days' "
|
||||
"ORDER BY recorded_at"
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
scores = d.get("rubric_scores_jsonb")
|
||||
if hasattr(scores, "resolve"):
|
||||
try:
|
||||
import json as _json
|
||||
d["rubric_scores"] = _json.loads(scores.resolve()) if scores else []
|
||||
except Exception:
|
||||
d["rubric_scores"] = []
|
||||
else:
|
||||
d["rubric_scores"] = scores
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_date(val: Any) -> _dt.date | None:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, _dt.datetime):
|
||||
return val.date()
|
||||
if isinstance(val, _dt.date):
|
||||
return val
|
||||
try:
|
||||
return _dt.datetime.fromisoformat(str(val)).date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["NightlyScheduler", "seconds_until_next_03_ct", "CT"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Shared helpers for operator API endpoints (SLICE-08).
|
||||
|
||||
Common response models + the recent-aggregates query used by all 3 cohort
|
||||
view endpoints (cohort, mastery, failure-patterns). Kept here to avoid
|
||||
duplicating the Pydantic models + pool query across 3 files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Cell(BaseModel):
|
||||
metric: str
|
||||
window_start: _dt.date | None = None
|
||||
window_end: _dt.date | None = None
|
||||
value: float | None = None
|
||||
cell_count: int = 0
|
||||
cell_suppressed: bool = False
|
||||
updated_at: _dt.datetime | None = None
|
||||
|
||||
|
||||
class PathView(BaseModel):
|
||||
path: str
|
||||
metrics: list[Cell]
|
||||
|
||||
|
||||
class ViewResponse(BaseModel):
|
||||
views: list[PathView]
|
||||
last_updated: _dt.datetime | None = None
|
||||
|
||||
|
||||
async def require_pg_store(request: Request):
|
||||
pg_store = getattr(request.app.state, "pg_store", None)
|
||||
if pg_store is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="operator tier unavailable (no Postgres)",
|
||||
)
|
||||
return pg_store
|
||||
|
||||
|
||||
async def all_recent_aggregates(pg_store, since: _dt.date) -> list[dict[str, Any]]:
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT path, metric, window_start, window_end, value, "
|
||||
"cell_count, cell_suppressed, updated_at "
|
||||
"FROM cohort_aggregates WHERE window_start >= $1 "
|
||||
"ORDER BY path, metric, window_start",
|
||||
since,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def cell_from_row(row: dict[str, Any]) -> Cell:
|
||||
return Cell(
|
||||
metric=row.get("metric", ""),
|
||||
window_start=row.get("window_start"),
|
||||
window_end=row.get("window_end"),
|
||||
value=float(row["value"]) if row.get("value") is not None else None,
|
||||
cell_count=int(row.get("cell_count") or 0),
|
||||
cell_suppressed=bool(row.get("cell_suppressed") or False),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
|
||||
def group_by_path(
|
||||
rows: list[dict[str, Any]],
|
||||
metric_filter: set[str] | None = None,
|
||||
) -> tuple[list[PathView], _dt.datetime | None]:
|
||||
by_path: dict[str, list[dict[str, Any]]] = {}
|
||||
last_updated: _dt.datetime | None = None
|
||||
for r in rows:
|
||||
if metric_filter is not None and r.get("metric") not in metric_filter:
|
||||
continue
|
||||
by_path.setdefault(r["path"], []).append(r)
|
||||
ua = r.get("updated_at")
|
||||
if isinstance(ua, _dt.datetime) and (last_updated is None or ua > last_updated):
|
||||
last_updated = ua
|
||||
views = [PathView(path=p, metrics=[cell_from_row(c) for c in cells])
|
||||
for p, cells in by_path.items()]
|
||||
return views, last_updated
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Cell", "PathView", "ViewResponse",
|
||||
"require_pg_store", "all_recent_aggregates",
|
||||
"cell_from_row", "group_by_path",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""GET /api/operator/cohort — practice volume view (TASK-08-01, D-053, D-057).
|
||||
|
||||
Auth-gated (Depends(current_operator)). Returns k-anonymized practice-volume
|
||||
aggregates from cohort_aggregates: sessions_count + active_learners_count per
|
||||
path. Suppressed cells have value=null + cell_suppressed=true; the frontend
|
||||
renders \"— (<10 learners)\". No per-learner drill-down (R-DASH-02).
|
||||
last_updated = max(updated_at) for freshness (REQ-NFR-DASH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import (
|
||||
ViewResponse,
|
||||
all_recent_aggregates,
|
||||
group_by_path,
|
||||
require_pg_store,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-cohort"])
|
||||
|
||||
PRACTICE_METRICS = {"sessions_count", "active_learners_count"}
|
||||
|
||||
|
||||
@router.get("/cohort", response_model=ViewResponse)
|
||||
async def cohort_view(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> ViewResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
since = _dt.date.today() - _dt.timedelta(days=30)
|
||||
rows = await all_recent_aggregates(pg_store, since)
|
||||
views, last_updated = group_by_path(rows, PRACTICE_METRICS)
|
||||
return ViewResponse(views=views, last_updated=last_updated)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""GET/POST /api/operator/credentials — VC management (TASK-08-04, D-057).
|
||||
|
||||
Auth-gated. GET lists issued VCs from Postgres issued_credentials (operator's
|
||||
issuance log). POST /{id}/revoke revokes a VC (status='revoked',
|
||||
revoked_at=now()). Revoked credentials fail verification. No PII beyond what
|
||||
the credential asserts (D-043).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import require_pg_store
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-credentials"])
|
||||
|
||||
|
||||
class CredentialOut(BaseModel):
|
||||
id: str
|
||||
learner_ref: str
|
||||
vc_type: str | None = None
|
||||
status: str
|
||||
issued_at: _dt.datetime | None = None
|
||||
revoked_at: _dt.datetime | None = None
|
||||
|
||||
|
||||
class CredentialListResponse(BaseModel):
|
||||
credentials: list[CredentialOut]
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
id: str
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=CredentialListResponse)
|
||||
async def list_credentials(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> CredentialListResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
rows = await pg_store.list_credentials()
|
||||
creds = [
|
||||
CredentialOut(
|
||||
id=str(r["id"]),
|
||||
learner_ref=r["learner_ref"],
|
||||
vc_type=r.get("vc_type"),
|
||||
status=r.get("status", "active"),
|
||||
issued_at=r.get("issued_at"),
|
||||
revoked_at=r.get("revoked_at"),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return CredentialListResponse(credentials=creds)
|
||||
|
||||
|
||||
@router.post("/credentials/{cred_id}/revoke", response_model=OkResponse)
|
||||
async def revoke_credential(
|
||||
cred_id: str,
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> OkResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
row = await pg_store.get_credential(cred_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="credential not found")
|
||||
await pg_store.set_credential_status(cred_id, "revoked")
|
||||
return OkResponse(ok=True, id=cred_id, status="revoked")
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""GET /api/operator/failure-patterns — failure patterns view (TASK-08-03, D-053).
|
||||
|
||||
Auth-gated. Returns failure pattern metrics: failure_mode frequency (cells
|
||||
with metric prefix `failure_mode:`) + branch outcome distribution (cells
|
||||
with metric prefix `branch:`). Weak-spot rubric criteria (mean < 3.0) are
|
||||
highlighted by the frontend. All k-anonymized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import (
|
||||
ViewResponse,
|
||||
all_recent_aggregates,
|
||||
group_by_path,
|
||||
require_pg_store,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-failure-patterns"])
|
||||
|
||||
|
||||
def _is_failure_metric(metric: str) -> bool:
|
||||
return metric.startswith("failure_mode:") or metric.startswith("branch:")
|
||||
|
||||
|
||||
@router.get("/failure-patterns", response_model=ViewResponse)
|
||||
async def failure_patterns_view(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> ViewResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
since = _dt.date.today() - _dt.timedelta(days=30)
|
||||
rows = await all_recent_aggregates(pg_store, since)
|
||||
failure_rows = [r for r in rows if _is_failure_metric(r.get("metric", ""))]
|
||||
views, last_updated = group_by_path(failure_rows)
|
||||
return ViewResponse(views=views, last_updated=last_updated)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""GET /api/operator/mastery — mastery progression view (TASK-08-02, D-053).
|
||||
|
||||
Auth-gated. Returns mastery progression metrics: gate_open_rate,
|
||||
median_mastery_score, rubric_criterion_means (cells with metric prefix
|
||||
`rubric_criterion_mean:`). All k-anonymized (suppressed if < 10).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.operator._common import (
|
||||
ViewResponse,
|
||||
all_recent_aggregates,
|
||||
group_by_path,
|
||||
require_pg_store,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/operator", tags=["operator-mastery"])
|
||||
|
||||
MASTERY_METRICS = {"gate_open_rate", "median_mastery_score"}
|
||||
|
||||
|
||||
def _is_mastery_metric(metric: str) -> bool:
|
||||
return metric in MASTERY_METRICS or metric.startswith("rubric_criterion_mean:")
|
||||
|
||||
|
||||
@router.get("/mastery", response_model=ViewResponse)
|
||||
async def mastery_view(
|
||||
request: Request,
|
||||
op: Operator = Depends(current_operator),
|
||||
) -> ViewResponse:
|
||||
pg_store = await require_pg_store(request)
|
||||
since = _dt.date.today() - _dt.timedelta(days=30)
|
||||
rows = await all_recent_aggregates(pg_store, since)
|
||||
mastery_rows = [r for r in rows if _is_mastery_metric(r.get("metric", ""))]
|
||||
views, last_updated = group_by_path(mastery_rows)
|
||||
return ViewResponse(views=views, last_updated=last_updated)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -16,6 +16,7 @@ No auth — learner_id is the hardcoded 'learner-1' (D-007).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -27,6 +28,10 @@ from server.cost import CostBreakdown, derive_cost
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||
|
||||
|
||||
class SessionRecorder:
|
||||
"""Records a voice session to SQLite (TASK-04-03)."""
|
||||
|
||||
@@ -35,10 +40,12 @@ class SessionRecorder:
|
||||
store: PraxisStore,
|
||||
learner_id: str = HARDCODED_LEARNER_ID,
|
||||
scenario_id: str = "cs_refund_ca_v01",
|
||||
pg_store: Any = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.learner_id = learner_id
|
||||
self.scenario_id = scenario_id
|
||||
self.pg_store = pg_store
|
||||
self.session_id: str | None = None
|
||||
self._turn_seq = 0
|
||||
# Cost inputs accumulated over the session.
|
||||
@@ -143,8 +150,53 @@ class SessionRecorder:
|
||||
asyncio.create_task(
|
||||
self._run_mastery_flow_guarded(mastery_deps)
|
||||
)
|
||||
|
||||
# v0.4 P2 (D-054): fire-and-forget cohort aggregation hook. Runs in
|
||||
# parallel with the mastery flow — aggregation only needs the session
|
||||
# outcome (available after session end), not the mastery scoring
|
||||
# result. Rubric-dependent metrics are reconciled by the nightly job.
|
||||
# Off the voice path (C-8, D-054). No-op if pg_store is None.
|
||||
if self.pg_store is not None:
|
||||
session_outcome = self._build_session_outcome(outcome)
|
||||
asyncio.create_task(self._run_cohort_aggregation(session_outcome))
|
||||
return breakdown
|
||||
|
||||
def _build_session_outcome(self, outcome: str) -> dict[str, Any]:
|
||||
"""Construct the session_outcome dict for the aggregation hook."""
|
||||
rubric_scores: list[dict[str, Any]] = []
|
||||
if self.mastery_result and isinstance(self.mastery_result, dict):
|
||||
rubric_scores = list(self.mastery_result.get("rubric_scores") or [])
|
||||
return {
|
||||
"learner_ref": self.learner_id,
|
||||
"path": self._path_slug(),
|
||||
"scenario_id": self.scenario_id,
|
||||
"outcome": outcome,
|
||||
"rubric_scores": rubric_scores,
|
||||
"failure_mode": self._failure_mode(),
|
||||
"branch_path": list(self._branch_path),
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
def _path_slug(self) -> str:
|
||||
# The scenario_id encodes the path loosely; default to customer_service.
|
||||
if self.scenario_id and self.scenario_id.startswith("cs_"):
|
||||
return "customer_service"
|
||||
return "default"
|
||||
|
||||
def _failure_mode(self) -> str | None:
|
||||
if self.mastery_result and isinstance(self.mastery_result, dict):
|
||||
return self.mastery_result.get("failure_mode")
|
||||
return None
|
||||
|
||||
async def _run_cohort_aggregation(self, session_outcome: dict[str, Any]) -> None:
|
||||
"""Fire-and-forget wrapper around the cohort aggregation hook (D-054)."""
|
||||
try:
|
||||
from server.cohort.hook import on_session_end
|
||||
|
||||
await on_session_end(self.pg_store, session_outcome)
|
||||
except Exception:
|
||||
log.exception("cohort aggregation dispatch failed for session %s", self.session_id)
|
||||
|
||||
async def _run_mastery_flow_guarded(self, deps: "MasteryFlowDeps") -> None:
|
||||
try:
|
||||
await self.run_mastery_flow(deps)
|
||||
|
||||
+35
-11
@@ -13,6 +13,7 @@ import base64
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import nacl.secret
|
||||
import nacl.signing
|
||||
@@ -22,6 +23,27 @@ from db.store import PraxisStore
|
||||
_SECRETBOX_KEY_BYTES = nacl.secret.SecretBox.KEY_SIZE
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IssuerKeyStore(Protocol):
|
||||
"""Issuer key store protocol (D-051, TASK-04-01).
|
||||
|
||||
Both PraxisStore (SQLite, v0.3) and PgStore (Postgres, v0.4) implement
|
||||
this protocol — R-VC-MIG-03 mitigation (both stores share the same
|
||||
interface so verification can use either). The structural check lets
|
||||
`isinstance(store, IssuerKeyStore)` succeed for duck-typed stores.
|
||||
"""
|
||||
|
||||
async def init_issuer_key(
|
||||
self, key_id: str, public_key: str, private_key_enc: bytes
|
||||
) -> None: ...
|
||||
|
||||
async def get_active_signing_key_row(self) -> dict | None: ...
|
||||
|
||||
async def get_public_key_row(self, key_id: str) -> dict | None: ...
|
||||
|
||||
async def set_issuer_key_superseded(self, key_id: str) -> None: ...
|
||||
|
||||
|
||||
def _load_root_key() -> bytes:
|
||||
raw = os.environ.get("PRAXIS_VC_ISSUER_KEY", "")
|
||||
if raw:
|
||||
@@ -63,7 +85,7 @@ def _decrypt_private_key(private_key_enc: bytes, root_key: bytes) -> nacl.signin
|
||||
return nacl.signing.SigningKey(seed)
|
||||
|
||||
|
||||
async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPair:
|
||||
async def init_issuer_key(store: IssuerKeyStore, root_key: bytes | None = None) -> KeyPair:
|
||||
rk = root_key if root_key is not None else _load_root_key()
|
||||
signing_key = nacl.signing.SigningKey.generate()
|
||||
verify_key = signing_key.verify_key
|
||||
@@ -75,7 +97,7 @@ async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) ->
|
||||
|
||||
|
||||
async def get_active_signing_key(
|
||||
store: PraxisStore, root_key: bytes | None = None
|
||||
store: IssuerKeyStore, root_key: bytes | None = None
|
||||
) -> tuple[KeyPair, bytes]:
|
||||
rk = root_key if root_key is not None else _load_root_key()
|
||||
row = await store.get_active_signing_key_row()
|
||||
@@ -89,18 +111,19 @@ async def get_active_signing_key(
|
||||
return kp, row["private_key_enc"]
|
||||
|
||||
|
||||
async def _fetch_private_key_enc(store: PraxisStore, key_id: str) -> bytes:
|
||||
async with store._connect() as db:
|
||||
db.row_factory = None
|
||||
cur = await db.execute(
|
||||
"SELECT private_key_enc FROM issuer_keys WHERE id = ?", (key_id,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return bytes(row[0]) if row else b""
|
||||
async def _fetch_private_key_enc(store: IssuerKeyStore, key_id: str) -> bytes:
|
||||
# PraxisStore exposes a _connect() context manager; PgStore does not
|
||||
# (it uses a pool). Use the protocol's get_public_key_row which both
|
||||
# stores implement, and read private_key_enc from the returned row.
|
||||
row = await store.get_public_key_row(key_id)
|
||||
if row is None:
|
||||
return b""
|
||||
enc = row.get("private_key_enc")
|
||||
return bytes(enc) if enc is not None else b""
|
||||
|
||||
|
||||
async def get_public_key_for_verification(
|
||||
store: PraxisStore, key_id: str
|
||||
store: IssuerKeyStore, key_id: str
|
||||
) -> nacl.signing.VerifyKey:
|
||||
row = await store.get_public_key_row(key_id)
|
||||
if row is None:
|
||||
@@ -119,6 +142,7 @@ async def rotate_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPa
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IssuerKeyStore",
|
||||
"KeyPair",
|
||||
"init_issuer_key",
|
||||
"get_active_signing_key",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""VC issuer key migration SQLite → Postgres (TASK-04-03, D-051).
|
||||
|
||||
One-time migration procedure (R-VC-MIG-01 — highest-severity v0.4 risk):
|
||||
1. Read the v0.3 active public key from SQLite issuer_keys.
|
||||
2. Insert that public key into Postgres issuer_keys with status=
|
||||
'superseded' (private key NOT migrated — only the public key is
|
||||
archived for verification of already-issued v0.3 VCs).
|
||||
3. Generate a fresh Ed25519 keypair in Postgres issuer_keys with
|
||||
status='active' (encrypted at rest with the root key).
|
||||
4. Return {archived_key_id, new_key_id}.
|
||||
|
||||
R-VC-MIG-01 mitigation: the v0.3 public key is archived as superseded
|
||||
BEFORE the fresh key is activated (step 2 before step 3). This guarantees
|
||||
v0.3 VCs remain verifiable against the archived key.
|
||||
|
||||
G-027 (first-boot path): if SQLite has NO v0.3 active key (fresh deploy),
|
||||
skip the archive step and only generate the fresh v0.4 keypair.
|
||||
|
||||
Idempotent: if Postgres already has an active key, the whole procedure is
|
||||
a no-op. If Postgres already has a superseded key matching the v0.3 key_id,
|
||||
skip step 2 (already archived) but still generate the fresh key if no
|
||||
active key exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import nacl.signing
|
||||
|
||||
from db.pg_store import PgStore
|
||||
from db.store import PraxisStore
|
||||
from server.vc.issuer_keys import _encrypt_private_key
|
||||
|
||||
|
||||
async def _archive_v03_public_key(
|
||||
pg_store: PgStore, v03_key_id: str, v03_public_key: str
|
||||
) -> None:
|
||||
"""Insert the v0.3 public key into Postgres as superseded (idempotent)."""
|
||||
existing = await pg_store.get_public_key_row(v03_key_id)
|
||||
if existing is not None:
|
||||
return # already archived (or present as active — leave as-is)
|
||||
await pg_store.init_issuer_key(v03_key_id, v03_public_key, b"")
|
||||
await pg_store.set_issuer_key_superseded(v03_key_id)
|
||||
|
||||
|
||||
async def _generate_fresh_v04_key(
|
||||
pg_store: PgStore, root_key: bytes
|
||||
) -> str:
|
||||
"""Generate a fresh Ed25519 keypair in Postgres as active. Returns key_id."""
|
||||
signing_key = nacl.signing.SigningKey.generate()
|
||||
verify_key = signing_key.verify_key
|
||||
public_key_b64 = base64.b64encode(bytes(verify_key)).decode("ascii")
|
||||
private_key_enc = _encrypt_private_key(signing_key, root_key)
|
||||
key_id = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await pg_store.init_issuer_key(key_id, public_key_b64, private_key_enc)
|
||||
return key_id
|
||||
|
||||
|
||||
async def migrate_issuer_keys(
|
||||
sqlite_store: PraxisStore,
|
||||
pg_store: PgStore,
|
||||
root_key: bytes,
|
||||
) -> dict[str, str | None]:
|
||||
"""Run the one-time VC key migration. Idempotent.
|
||||
|
||||
Returns {"archived_key_id": str | None, "new_key_id": str | None}.
|
||||
archived_key_id is None on the G-027 first-boot path (no v0.3 key).
|
||||
new_key_id is None if an active key already existed (no-op).
|
||||
"""
|
||||
# If Postgres already has an active key, the whole migration is done.
|
||||
active = await pg_store.get_active_signing_key_row()
|
||||
if active is not None:
|
||||
return {"archived_key_id": None, "new_key_id": None}
|
||||
|
||||
# Step 1 (G-027): read v0.3 active public key from SQLite. May be None
|
||||
# on a fresh deploy with no v0.3 history.
|
||||
v03_row = await sqlite_store.get_active_signing_key_row()
|
||||
archived_key_id: str | None = None
|
||||
if v03_row is not None:
|
||||
v03_key_id = v03_row["id"]
|
||||
v03_public_key = v03_row["public_key"]
|
||||
# Step 2 (R-VC-MIG-01): archive BEFORE activating the fresh key.
|
||||
await _archive_v03_public_key(pg_store, v03_key_id, v03_public_key)
|
||||
archived_key_id = v03_key_id
|
||||
|
||||
# Step 3: generate the fresh v0.4 keypair as active.
|
||||
new_key_id = await _generate_fresh_v04_key(pg_store, root_key)
|
||||
return {"archived_key_id": archived_key_id, "new_key_id": new_key_id}
|
||||
|
||||
|
||||
__all__ = ["migrate_issuer_keys"]
|
||||
+86
-16
@@ -1,11 +1,24 @@
|
||||
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02).
|
||||
"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02;
|
||||
v0.4 TASK-04-04 two-store fallback per G-011).
|
||||
|
||||
`GET /vc/verify/<credential_id>` — public, unauthenticated. Fetches the
|
||||
credential from SQLite, fetches the issuer public key, validates the Ed25519
|
||||
signature against the JCS-canonicalized payload, checks the Bitstring Status
|
||||
List (no cache — fetched on every verify call, REQ-NFR-VC-02). Returns JSON
|
||||
credential + issuer public key, validates the Ed25519 signature against
|
||||
the JCS-canonicalized payload, checks the Bitstring Status List (no cache
|
||||
— fetched on every verify call, REQ-NFR-VC-02). Returns JSON
|
||||
{valid, status, issuer, credential, mastery, credentialTier, verifiedAt}.
|
||||
No PII beyond what the credential asserts.
|
||||
|
||||
G-011 two-store fallback semantics (binding contract):
|
||||
(a) If Postgres is available (pg_store is not None), use it for issuer
|
||||
key lookup (both active AND superseded keys — get_public_key_row
|
||||
queries by id, not status).
|
||||
(b) If Postgres is available but the credential is not found in its
|
||||
issued_credentials table, fall back to SQLite issued_credentials
|
||||
(v0.3 credentials remain in SQLite — D-051 "no re-issuance").
|
||||
(c) If Postgres is NOT available (pg_store is None), use the existing
|
||||
v0.3 SQLite path for BOTH keys and credentials (full v0.3 compat).
|
||||
The key store used for verification is always the one that holds the key
|
||||
row found by key_id; the credential store is whichever store had the row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +30,7 @@ from typing import Any
|
||||
from db.store import PraxisStore
|
||||
|
||||
from server.vc.issuer import verify_proof, extract_key_id, CREDENTIAL_TIER
|
||||
from server.vc.issuer_keys import get_public_key_for_verification
|
||||
from server.vc.issuer_keys import IssuerKeyStore, get_public_key_for_verification
|
||||
from server.vc.status_list import BitstringStatusList
|
||||
|
||||
|
||||
@@ -26,26 +39,35 @@ def _now_iso() -> str:
|
||||
|
||||
|
||||
async def verify_credential(
|
||||
store: PraxisStore, credential_id: str
|
||||
store: IssuerKeyStore,
|
||||
credential_id: str,
|
||||
*,
|
||||
pg_store: IssuerKeyStore | None = None,
|
||||
sqlite_store: PraxisStore | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
row = await store.get_credential(credential_id)
|
||||
"""Verify a VC. Returns the verification result dict, or None if the
|
||||
credential id is not found in any store.
|
||||
|
||||
Per G-011:
|
||||
- If pg_store is provided, try it first for BOTH credential + key
|
||||
lookup; fall back to sqlite_store for the credential if Postgres
|
||||
doesn't have it (v0.3 credentials stay in SQLite).
|
||||
- If pg_store is None, use `store` (the v0.3 SQLite path) for both.
|
||||
"""
|
||||
row = await _lookup_credential(credential_id, store, pg_store, sqlite_store)
|
||||
if row is None:
|
||||
return None
|
||||
secured_doc = json.loads(row["vc_payload_json"])
|
||||
key_id = extract_key_id(secured_doc)
|
||||
if key_id is None:
|
||||
return _invalid(row, secured_doc)
|
||||
try:
|
||||
verify_key = await get_public_key_for_verification(store, key_id)
|
||||
except KeyError:
|
||||
# Key lookup: prefer pg_store (G-011a) for v0.4 keys + archived v0.3
|
||||
# keys; fall back to `store` (SQLite) if pg_store doesn't have the key.
|
||||
verify_key = await _lookup_public_key(key_id, store, pg_store)
|
||||
if verify_key is None:
|
||||
return _invalid(row, secured_doc)
|
||||
sig_valid = verify_proof(secured_doc, verify_key)
|
||||
revoked = False
|
||||
cs = secured_doc.get("credentialStatus") or {}
|
||||
idx_str = cs.get("statusListIndex")
|
||||
if idx_str is not None:
|
||||
sl = BitstringStatusList(store, "default")
|
||||
revoked = await sl.get_status(int(idx_str))
|
||||
revoked = await _check_revocation(secured_doc, store, sqlite_store or store)
|
||||
status = "revoked" if revoked else "active"
|
||||
valid = bool(sig_valid and not revoked)
|
||||
subject = secured_doc.get("credentialSubject") or {}
|
||||
@@ -73,6 +95,54 @@ async def verify_credential(
|
||||
}
|
||||
|
||||
|
||||
async def _lookup_credential(
|
||||
credential_id: str,
|
||||
store: IssuerKeyStore,
|
||||
pg_store: IssuerKeyStore | None,
|
||||
sqlite_store: PraxisStore | None,
|
||||
) -> dict | None:
|
||||
"""G-011(b): try Postgres first, fall back to SQLite for v0.3 creds."""
|
||||
if pg_store is not None:
|
||||
row = await pg_store.get_credential(credential_id)
|
||||
if row is not None:
|
||||
return row
|
||||
if sqlite_store is not None:
|
||||
return await sqlite_store.get_credential(credential_id)
|
||||
return None
|
||||
# G-011(c): no Postgres — v0.3 SQLite path.
|
||||
return await store.get_credential(credential_id)
|
||||
|
||||
|
||||
async def _lookup_public_key(
|
||||
key_id: str,
|
||||
store: IssuerKeyStore,
|
||||
pg_store: IssuerKeyStore | None,
|
||||
):
|
||||
"""G-011(a): prefer Postgres for key lookup (finds active + superseded);
|
||||
fall back to `store` (SQLite) if Postgres doesn't have the key."""
|
||||
if pg_store is not None:
|
||||
try:
|
||||
vk = await get_public_key_for_verification(pg_store, key_id)
|
||||
return vk
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return await get_public_key_for_verification(store, key_id)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
async def _check_revocation(
|
||||
secured_doc: dict, store: IssuerKeyStore, status_store: PraxisStore
|
||||
) -> bool:
|
||||
cs = secured_doc.get("credentialStatus") or {}
|
||||
idx_str = cs.get("statusListIndex")
|
||||
if idx_str is None:
|
||||
return False
|
||||
sl = BitstringStatusList(status_store, "default")
|
||||
return await sl.get_status(int(idx_str))
|
||||
|
||||
|
||||
def _invalid(row: dict, secured_doc: dict) -> dict[str, Any]:
|
||||
subject = secured_doc.get("credentialSubject") or {}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Auth unit tests (TASK-03-06) — mocked PgStore, no real Postgres.
|
||||
|
||||
Covers: password hash/verify/rehash, cookie config (secure flag, missing
|
||||
secret), rate limiter threshold, current_operator dependency (401/503
|
||||
cases, active/inactive), login/logout/me route handlers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from server.auth.cookies import get_session_middleware_kwargs
|
||||
from server.auth.dependencies import current_operator
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
||||
from server.auth.rate_limit import limiter, rate_limit_login, reset_login_rate_limit
|
||||
from server.auth.routes import router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_limiter():
|
||||
reset_login_rate_limit()
|
||||
yield
|
||||
reset_login_rate_limit()
|
||||
|
||||
|
||||
# ── Passwords ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_password_hash_verify_roundtrip():
|
||||
h = hash_password("correct horse battery staple")
|
||||
assert h.startswith("$argon2id$")
|
||||
assert verify_password(h, "correct horse battery staple") is True
|
||||
|
||||
|
||||
def test_password_verify_wrong_returns_false():
|
||||
h = hash_password("secret-1")
|
||||
assert verify_password(h, "secret-2") is False
|
||||
# no exception raised — uniform 401 path
|
||||
assert verify_password(h, "") is False
|
||||
|
||||
|
||||
def test_needs_rehash_false_for_current_defaults():
|
||||
h = hash_password("x")
|
||||
assert needs_rehash(h) is False
|
||||
|
||||
|
||||
def test_needs_rehash_true_for_weak_hash():
|
||||
# A hash produced with weaker params triggers rehash.
|
||||
from argon2 import PasswordHasher
|
||||
weak = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1).hash("x")
|
||||
assert needs_rehash(weak) is True
|
||||
|
||||
|
||||
# ── Cookie config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cookie_kwargs_defaults(monkeypatch):
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["session_cookie"] == "praxis_op"
|
||||
assert kw["max_age"] == 28800
|
||||
# Starlette SessionMiddleware: https_only (not secure), same_site (not samesite),
|
||||
# httponly is always True (no kwarg). path is the cookie path.
|
||||
assert kw["https_only"] is True
|
||||
assert kw["same_site"] == "strict"
|
||||
assert kw["path"] == "/"
|
||||
|
||||
|
||||
def test_cookie_secure_false(monkeypatch):
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
||||
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "false")
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["https_only"] is False
|
||||
|
||||
|
||||
def test_cookie_secret_unset_generates_random(monkeypatch):
|
||||
monkeypatch.delenv("PRAXIS_COOKIE_SECRET", raising=False)
|
||||
kw = get_session_middleware_kwargs()
|
||||
assert kw["secret_key"]
|
||||
assert len(kw["secret_key"]) >= 32
|
||||
|
||||
|
||||
# ── current_operator dependency ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_app_with_store(store) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.pg_store = store
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
def _mock_store(operator_row=None):
|
||||
store = MagicMock()
|
||||
store.get_operator_by_id = AsyncMock(return_value=operator_row)
|
||||
return store
|
||||
|
||||
|
||||
def test_current_operator_no_cookie_401():
|
||||
app = _make_app_with_store(_mock_store(operator_row=None))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_current_operator_no_postgres_503():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/me")
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_current_operator_inactive_401():
|
||||
op = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "ghost",
|
||||
"display_name": "Ghost",
|
||||
"role": "operator",
|
||||
"is_active": False,
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
# seed a session by hitting login would need a real store; instead
|
||||
# set the session directly via a cookie. Use TestClient's cookie jar.
|
||||
# Easiest: POST /login with a mocked store that returns the op.
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
# hash the password so verify works
|
||||
op = dict(op)
|
||||
op["password_hash"] = hash_password("pw")
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
r = client.post("/api/operator/login", json={"username": "ghost", "password": "pw"})
|
||||
# inactive operator → 401 even with correct password
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_current_operator_valid_cookie_returns_operator():
|
||||
op = {
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["operator"]["username"] == "alice"
|
||||
# cookie is now set; /me should work
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["operator"]["username"] == "alice"
|
||||
|
||||
|
||||
# ── Login route ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_wrong_password_401_no_cookie():
|
||||
op = {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"username": "bob",
|
||||
"display_name": None,
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("correct"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "bob", "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
# no auth cookie set on failure
|
||||
cookies = client.cookies.get("praxis_op")
|
||||
assert not cookies
|
||||
|
||||
|
||||
def test_login_unknown_user_401():
|
||||
store = _mock_store(operator_row=None)
|
||||
store.get_operator_by_username = AsyncMock(return_value=None)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "nobody", "password": "x"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_no_postgres_503():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
# ── Logout ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_logout_clears_session():
|
||||
op = {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"username": "carol",
|
||||
"display_name": "Carol",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/operator/login", json={"username": "carol", "password": "pw"})
|
||||
r = client.post("/api/operator/logout")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
# /me now 401
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
# ── Rehash on login ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_login_rehash_when_needed():
|
||||
from argon2 import PasswordHasher
|
||||
weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)
|
||||
op = {
|
||||
"id": "55555555-5555-5555-5555-555555555555",
|
||||
"username": "dave",
|
||||
"display_name": "Dave",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": weak_hasher.hash("pw"),
|
||||
}
|
||||
store = _mock_store(operator_row=op)
|
||||
store.get_operator_by_username = AsyncMock(return_value=op)
|
||||
store.update_last_login = AsyncMock()
|
||||
store.pool = MagicMock()
|
||||
executed = []
|
||||
conn = MagicMock()
|
||||
async def _exec(*a, **kw):
|
||||
executed.append(a)
|
||||
conn.execute = _exec
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
app = _make_app_with_store(store)
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/login", json={"username": "dave", "password": "pw"})
|
||||
assert r.status_code == 200
|
||||
assert executed, "rehash UPDATE should have run"
|
||||
# the second arg to execute is the new hash; verify it's argon2id
|
||||
assert executed[0][1].startswith("$argon2id$")
|
||||
|
||||
|
||||
# ── Rate limiter ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rate_limit_login_decorator():
|
||||
# The decorator factory returns a decorator; applying it should not raise.
|
||||
deco = rate_limit_login()
|
||||
assert callable(deco)
|
||||
|
||||
|
||||
def test_limiter_is_in_memory():
|
||||
assert getattr(limiter, "_storage_uri", "memory://") == "memory://" or limiter._storage is not None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Backup-restore drill test (G-008 binding — MUST run at least once in
|
||||
staging/CI to prove the nightly pg_dump backup is valid).
|
||||
|
||||
The drill:
|
||||
1. Seed the live Postgres with known row counts in all 5 operator-tier
|
||||
tables (operators, issued_credentials, mastery_gate_events,
|
||||
cohort_aggregates, issuer_keys).
|
||||
2. Run `pg_dump -Fc` to produce a compressed dump.
|
||||
3. Drop + recreate the schema (simulate a disaster), then run
|
||||
`pg_restore --clean --if-exists`.
|
||||
4. Verify all 5 tables exist and the row counts match the seeded values.
|
||||
|
||||
Skips gracefully when PRAXIS_PG_DSN is unset (no Postgres in dev/CI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — backup-restore drill skipped (G-008).",
|
||||
)
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"operators",
|
||||
"issued_credentials",
|
||||
"mastery_gate_events",
|
||||
"cohort_aggregates",
|
||||
"issuer_keys",
|
||||
}
|
||||
|
||||
|
||||
async def _seed(store: PgStore, pool: asyncpg.Pool) -> dict[str, int]:
|
||||
"""Seed all 5 tables; return {table: row_count}."""
|
||||
oid = await store.insert_operator("drill-op", "$argon2id$h", "Drill Op")
|
||||
assert oid is not None
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-drill", b"\x01\x02")
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(cid, "learner-drill", "{}", "sig", operator_id=oid)
|
||||
await store.record_gate_event(
|
||||
"learner-drill", "cs-refund", scenario_id="sc-1", gate_outcome="open"
|
||||
)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", date(2026, 8, 1), date(2026, 8, 7),
|
||||
5.0, 12, False,
|
||||
)
|
||||
counts = {}
|
||||
async with pool.acquire() as conn:
|
||||
for t in EXPECTED_TABLES:
|
||||
counts[t] = await conn.fetchval(f"SELECT count(*) FROM {t}")
|
||||
return counts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_restore_drill(tmp_path):
|
||||
dsn = os.environ["PRAXIS_PG_DSN"]
|
||||
dump_file = tmp_path / "praxis-drill.dump"
|
||||
|
||||
pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=5, command_timeout=10)
|
||||
try:
|
||||
await apply_pg_migrations(pool)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
store = PgStore(pool)
|
||||
seeded_counts = await _seed(store, pool)
|
||||
|
||||
# 1. pg_dump -Fc to a local file (via psql host or docker).
|
||||
# Use pg_dump directly if available on PATH; otherwise fall back to
|
||||
# docker compose exec (the operator deployment path).
|
||||
rc = subprocess.run(
|
||||
["pg_dump", "-Fc", "-f", str(dump_file), dsn],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
# Try docker compose path (production-like).
|
||||
rc = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_dump", "-U", "praxis", "-Fc", "praxis"],
|
||||
capture_output=True,
|
||||
)
|
||||
assert rc.returncode == 0, f"pg_dump failed: {rc.stderr!r}"
|
||||
dump_file.write_bytes(rc.stdout)
|
||||
assert dump_file.stat().st_size > 0, "dump file is empty"
|
||||
|
||||
# 2. Drop the schema (simulate disaster).
|
||||
async with pool.acquire() as conn:
|
||||
for t in EXPECTED_TABLES:
|
||||
await conn.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE')
|
||||
await conn.execute("DROP TABLE IF EXISTS _pg_migrations CASCADE")
|
||||
|
||||
# 3. pg_restore --clean --if-exists from the dump file.
|
||||
rc = subprocess.run(
|
||||
["pg_restore", "--clean", "--if-exists", "-d", dsn, str(dump_file)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if rc.returncode != 0:
|
||||
rc = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "postgres",
|
||||
"pg_restore", "-U", "praxis", "--clean", "--if-exists",
|
||||
"-d", "praxis", "/backups/praxis-drill.dump"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
# If we used the docker path, we have to copy the dump in first;
|
||||
# for the local-pg_dump path this branch is skipped. Either way,
|
||||
# a non-zero return here means restore failed.
|
||||
assert rc.returncode == 0, f"pg_restore failed: {rc.stderr!r}"
|
||||
|
||||
# 4. Verify all 5 tables exist + row counts match.
|
||||
async with pool.acquire() as conn:
|
||||
tables = {
|
||||
r["tablename"] for r in await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname='public'"
|
||||
)
|
||||
}
|
||||
assert EXPECTED_TABLES.issubset(tables), (
|
||||
f"missing tables after restore: {EXPECTED_TABLES - tables}"
|
||||
)
|
||||
for t in EXPECTED_TABLES:
|
||||
count = await conn.fetchval(f"SELECT count(*) FROM {t}")
|
||||
assert count == seeded_counts[t], (
|
||||
f"{t}: restored count {count} != seeded {seeded_counts[t]}"
|
||||
)
|
||||
finally:
|
||||
await pool.close()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Cohort aggregation unit tests (TASK-07-05) — mocked PgStore, no Postgres.
|
||||
|
||||
Covers: k-anonymity suppression (9 vs 10 vs 11 learners), idempotent upsert,
|
||||
7-day window computation, multiple metrics, no PII in upsert calls.
|
||||
|
||||
G-038 (binding — differencing-attack test): seed 10 learners in window A and
|
||||
9 in window B (one dropped), verify the API/aggregation cannot isolate the
|
||||
dropped learner — both windows show k-anonymized aggregates with no
|
||||
per-learner data leaks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from server.cohort.aggregator import (
|
||||
K_ANON_THRESHOLD,
|
||||
_rolling_window,
|
||||
aggregate_session,
|
||||
)
|
||||
from server.cohort.hook import on_session_end
|
||||
|
||||
|
||||
def _mock_pg_store():
|
||||
store = MagicMock()
|
||||
store.upsert_cohort_aggregate = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
def _session(learner_ref: str, path: str = "customer_service",
|
||||
outcome: str = "pass", rubric_scores=None,
|
||||
failure_mode=None, branch_path=None) -> dict:
|
||||
return {
|
||||
"learner_ref": learner_ref,
|
||||
"path": path,
|
||||
"scenario_id": f"{path}_v01",
|
||||
"outcome": outcome,
|
||||
"rubric_scores": rubric_scores or [
|
||||
{"criterion_id": "empathy", "score": 4.0},
|
||||
{"criterion_id": "resolution", "score": 3.5},
|
||||
],
|
||||
"failure_mode": failure_mode,
|
||||
"branch_path": branch_path or ["accept"],
|
||||
"timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── k-anonymity threshold ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_k_anon_threshold_at_10():
|
||||
assert K_ANON_THRESHOLD == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_9_learners_suppressed():
|
||||
store = _mock_pg_store()
|
||||
for i in range(9):
|
||||
await aggregate_session(store, _session(f"learner-{i}"))
|
||||
suppressed_calls = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is True # cell_suppressed
|
||||
]
|
||||
non_suppressed = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is False
|
||||
]
|
||||
assert suppressed_calls, "cells should be suppressed with <10 learners"
|
||||
assert not non_suppressed, "no cell should be non-suppressed with 9 learners"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_10_learners_not_suppressed():
|
||||
store = _mock_pg_store()
|
||||
for i in range(10):
|
||||
await aggregate_session(store, _session(f"learner-{i}"))
|
||||
non_suppressed = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is False
|
||||
]
|
||||
assert non_suppressed, "cells should NOT be suppressed at exactly 10 learners"
|
||||
# value should be non-null for non-suppressed cells
|
||||
for c in non_suppressed:
|
||||
assert c.args[4] is not None, "non-suppressed cell value must not be None"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_11_learners_not_suppressed():
|
||||
store = _mock_pg_store()
|
||||
for i in range(11):
|
||||
await aggregate_session(store, _session(f"learner-{i}"))
|
||||
non_suppressed = [
|
||||
c for c in store.upsert_cohort_aggregate.call_args_list
|
||||
if c.args[6] is False
|
||||
]
|
||||
assert non_suppressed, "11 learners should NOT be suppressed"
|
||||
|
||||
|
||||
# ── Idempotent upsert ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_same_session_twice():
|
||||
store = _mock_pg_store()
|
||||
outcome = _session("learner-x")
|
||||
await aggregate_session(store, outcome)
|
||||
await aggregate_session(store, outcome)
|
||||
# Re-running with the same outcome produces additional upsert calls but
|
||||
# the ON CONFLICT in PgStore makes them idempotent at the DB layer. The
|
||||
# hook itself is deterministic — the same learner produces the same
|
||||
# distinct-count + counter state in the cache.
|
||||
# Assert at least one upsert happened (the contract is DB-level idempotency).
|
||||
assert store.upsert_cohort_aggregate.called
|
||||
|
||||
|
||||
# ── 7-day window computation ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rolling_window_7_days():
|
||||
now = _dt.datetime(2026, 8, 4, 12, 0, tzinfo=_dt.timezone.utc)
|
||||
start, end = _rolling_window(now)
|
||||
assert (end - start).days == 6 # 7-day inclusive span
|
||||
assert end == now.date()
|
||||
assert start == _dt.date(2026, 7, 29)
|
||||
|
||||
|
||||
# ── Multiple metrics ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_metrics_computed():
|
||||
store = _mock_pg_store()
|
||||
await aggregate_session(store, _session("learner-1", rubric_scores=[
|
||||
{"criterion_id": "empathy", "score": 4.0},
|
||||
{"criterion_id": "resolution", "score": 3.0},
|
||||
], failure_mode="missed_apology", branch_path=["escalate"]))
|
||||
metrics = {c.args[1] for c in store.upsert_cohort_aggregate.call_args_list}
|
||||
assert "sessions_count" in metrics
|
||||
assert "active_learners_count" in metrics
|
||||
assert "gate_open_rate" in metrics
|
||||
assert "median_mastery_score" in metrics
|
||||
assert "rubric_criterion_mean:empathy" in metrics
|
||||
assert "failure_mode:missed_apology" in metrics
|
||||
assert "branch:escalate" in metrics
|
||||
|
||||
|
||||
# ── No PII in upsert calls ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pii_in_upsert_calls():
|
||||
store = _mock_pg_store()
|
||||
await aggregate_session(store, _session("learner-sensitive-id-1234"))
|
||||
for c in store.upsert_cohort_aggregate.call_args_list:
|
||||
# path, metric, window_start, window_end, value, cell_count, suppressed
|
||||
# No argument should contain the raw learner_ref string as PII.
|
||||
for arg in c.args:
|
||||
assert "learner-sensitive-id-1234" not in str(arg), \
|
||||
"raw learner_ref must not leak into aggregate cell args"
|
||||
# cell_count is the distinct-learner count (an integer), not the ref.
|
||||
assert isinstance(c.args[5], int)
|
||||
|
||||
|
||||
# ── G-038: Differencing-attack test (binding) ──────────────────────────────
|
||||
# Seed 10 learners in window A, 9 in window B (one dropped). Verify the
|
||||
# aggregation/API cannot isolate the dropped learner — both windows produce
|
||||
# k-anonymized aggregates with no per-learner data leaks.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_g038_differencing_attack_cannot_isolate_dropped_learner():
|
||||
"""G-038 binding: 10 learners in window A, 9 in window B (one dropped).
|
||||
|
||||
A differencing attack tries to subtract window B's aggregate from
|
||||
window A's to recover the dropped learner's contribution. With k-anon
|
||||
write-time suppression, window B (9 learners) is FULLY suppressed
|
||||
(value=NULL, cell_suppressed=TRUE), so the attacker cannot subtract
|
||||
anything — the dropped learner's contribution is not recoverable.
|
||||
"""
|
||||
store_a = _mock_pg_store()
|
||||
store_b = _mock_pg_store()
|
||||
|
||||
# Window A: 10 distinct learners → non-suppressed
|
||||
for i in range(10):
|
||||
await aggregate_session(store_a, _session(f"learner-{i}"))
|
||||
# Window B: 9 distinct learners (learner-9 dropped) → suppressed
|
||||
for i in range(9):
|
||||
await aggregate_session(store_b, _session(f"learner-{i}"))
|
||||
|
||||
a_cells = list(store_a.upsert_cohort_aggregate.call_args_list)
|
||||
b_cells = list(store_b.upsert_cohort_aggregate.call_args_list)
|
||||
|
||||
# Window A: at least some non-suppressed cells (10 >= threshold)
|
||||
a_non_suppressed = [c for c in a_cells if c.args[6] is False]
|
||||
assert a_non_suppressed, "window A (10 learners) should have non-suppressed cells"
|
||||
|
||||
# Window B: ALL cells suppressed (9 < threshold)
|
||||
b_suppressed = [c for c in b_cells if c.args[6] is True]
|
||||
b_non_suppressed = [c for c in b_cells if c.args[6] is False]
|
||||
assert b_suppressed, "window B (9 learners) must have suppressed cells"
|
||||
assert not b_non_suppressed, \
|
||||
"window B (9 learners) must have NO non-suppressed cells (differencing blocked)"
|
||||
|
||||
# The critical differencing-attack defense: window B's suppressed cells
|
||||
# have value=NULL, so subtracting B from A is not possible — the attacker
|
||||
# cannot recover learner-9's contribution.
|
||||
for c in b_suppressed:
|
||||
assert c.args[4] is None, \
|
||||
"suppressed cell value must be NULL (differencing-attack defense)"
|
||||
|
||||
# No per-learner data leaks in either window's aggregate cells.
|
||||
for cells in (a_cells, b_cells):
|
||||
for c in cells:
|
||||
for arg in c.args:
|
||||
assert "learner-9" not in str(arg), \
|
||||
"dropped learner's ref must not appear in any aggregate cell"
|
||||
|
||||
|
||||
# ── Hook (TASK-07-02) ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_no_postgres_is_noop():
|
||||
# No exception, just a warning log.
|
||||
await on_session_end(None, _session("learner-1"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_failure_logs_does_not_raise(monkeypatch):
|
||||
store = _mock_pg_store()
|
||||
store.upsert_cohort_aggregate = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
# Must not raise — the hook swallows + logs; nightly reconciles.
|
||||
await on_session_end(store, _session("learner-1"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_idempotent():
|
||||
store = _mock_pg_store()
|
||||
outcome = _session("learner-1")
|
||||
await on_session_end(store, outcome)
|
||||
await on_session_end(store, outcome)
|
||||
assert store.upsert_cohort_aggregate.called
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Nightly reconciliation + hook integration tests (TASK-07-06) — mocked PgStore.
|
||||
|
||||
Covers: scheduler timing (seconds until 03:00 CT), reconciliation recomputes
|
||||
all windows, hook failure + nightly reconciliation = correct final state,
|
||||
R-DASH-04 (nightly failure logs + retries next night).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from server.cohort.nightly import (
|
||||
CT,
|
||||
NightlyScheduler,
|
||||
seconds_until_next_03_ct,
|
||||
)
|
||||
|
||||
|
||||
# ── Scheduler timing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_seconds_until_next_03_ct_future_today():
|
||||
# 01:00 CT → next 03:00 CT is in 2h
|
||||
now = _dt.datetime(2026, 8, 4, 1, 0, tzinfo=CT)
|
||||
secs = seconds_until_next_03_ct(now)
|
||||
assert 7190 <= secs <= 7200 # ~2h
|
||||
|
||||
|
||||
def test_seconds_until_next_03_ct_past_today_wraps_tomorrow():
|
||||
# 04:00 CT → next 03:00 CT is tomorrow (23h)
|
||||
now = _dt.datetime(2026, 8, 4, 4, 0, tzinfo=CT)
|
||||
secs = seconds_until_next_03_ct(now)
|
||||
assert 82790 <= secs <= 82810 # ~23h
|
||||
|
||||
|
||||
def test_seconds_until_next_03_ct_exactly_03_rolls_to_tomorrow():
|
||||
now = _dt.datetime(2026, 8, 4, 3, 0, 0, tzinfo=CT)
|
||||
secs = seconds_until_next_03_ct(now)
|
||||
# exactly 03:00:00 → next run is tomorrow (0 secs would mean "now", but
|
||||
# the scheduler sleeps then runs, so it must be ~24h)
|
||||
assert secs >= 86390 # ~24h
|
||||
|
||||
|
||||
# ── Reconciliation recomputes all windows ──────────────────────────────────
|
||||
|
||||
|
||||
class _FakeRecord(dict):
|
||||
"""Mimics an asyncpg Record — dict(record) returns the dict."""
|
||||
pass
|
||||
|
||||
|
||||
def _mock_pg_store_with_events(events):
|
||||
store = MagicMock()
|
||||
store.upsert_cohort_aggregate = AsyncMock()
|
||||
conn = MagicMock()
|
||||
rows = [_FakeRecord(e) for e in events]
|
||||
conn.fetch = AsyncMock(return_value=rows)
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool = MagicMock()
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_recomputes_all_paths():
|
||||
events = [
|
||||
{"learner_ref": "l1", "path_id": "customer_service", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": '[{"criterion_id":"empathy","score":4.0}]',
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
{"learner_ref": "l2", "path_id": "customer_service", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": '[{"criterion_id":"empathy","score":3.0}]',
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
{"learner_ref": "l3", "path_id": "sales", "gate_outcome": "closed",
|
||||
"rubric_scores_jsonb": '[]',
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
store = _mock_pg_store_with_events(events)
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
# upserts should cover both paths × multiple metrics
|
||||
paths = {c.args[0] for c in store.upsert_cohort_aggregate.call_args_list}
|
||||
assert "customer_service" in paths
|
||||
assert "sales" in paths
|
||||
metrics = {c.args[1] for c in store.upsert_cohort_aggregate.call_args_list}
|
||||
assert "sessions_count" in metrics
|
||||
assert "active_learners_count" in metrics
|
||||
assert "gate_open_rate" in metrics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_suppresses_below_threshold():
|
||||
# 3 distinct learners → suppressed
|
||||
events = [
|
||||
{"learner_ref": f"l{i}", "path_id": "p", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": "[]",
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)}
|
||||
for i in range(3)
|
||||
]
|
||||
store = _mock_pg_store_with_events(events)
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is True]
|
||||
non_suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is False]
|
||||
assert suppressed, "3 learners must be suppressed"
|
||||
assert not non_suppressed, "no cell should be non-suppressed with 3 learners"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_no_events_no_op():
|
||||
store = _mock_pg_store_with_events([])
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
store.upsert_cohort_aggregate.assert_not_called()
|
||||
|
||||
|
||||
# ── Hook failure → nightly reconciles ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_failure_then_nightly_reconciles_correct_state():
|
||||
"""A hook failure leaves no aggregate; the nightly job recomputes from
|
||||
mastery_gate_events and produces the correct final state."""
|
||||
events = [
|
||||
{"learner_ref": f"l{i}", "path_id": "p", "gate_outcome": "open",
|
||||
"rubric_scores_jsonb": "[]",
|
||||
"recorded_at": _dt.datetime.now(_dt.timezone.utc)}
|
||||
for i in range(10)
|
||||
]
|
||||
store = _mock_pg_store_with_events(events)
|
||||
# Simulate hook failure: upsert raises first time, then nightly runs.
|
||||
# (In production the hook + nightly use the same store; here we just
|
||||
# verify the nightly path produces correct aggregates independently.)
|
||||
sched = NightlyScheduler()
|
||||
await sched.reconcile_now(store)
|
||||
non_suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is False]
|
||||
assert non_suppressed, "nightly should produce non-suppressed cells for 10 learners"
|
||||
|
||||
|
||||
# ── R-DASH-04: nightly failure logs + retries ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_r_dash_04_nightly_failure_does_not_crash_scheduler():
|
||||
"""R-DASH-04: a reconciliation failure logs + the scheduler continues.
|
||||
|
||||
The scheduler loop (_run_loop) catches exceptions from _reconcile and
|
||||
retries the next night. We simulate this by invoking the loop with a
|
||||
broken store and confirming the loop catches + continues.
|
||||
"""
|
||||
store = MagicMock()
|
||||
store.upsert_cohort_aggregate = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
store.pool = MagicMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(side_effect=RuntimeError("pool down"))
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
sched = NightlyScheduler()
|
||||
import server.cohort.nightly as nightly_mod
|
||||
orig = nightly_mod.seconds_until_next_03_ct
|
||||
calls = []
|
||||
def _fake_secs():
|
||||
calls.append(1)
|
||||
return 0.01
|
||||
nightly_mod.seconds_until_next_03_ct = _fake_secs
|
||||
try:
|
||||
task = await sched.start(store)
|
||||
await _sleep(0.1)
|
||||
await sched.stop()
|
||||
# The loop ran at least once despite the failure (R-DASH-04).
|
||||
assert len(calls) >= 1
|
||||
finally:
|
||||
nightly_mod.seconds_until_next_03_ct = orig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_start_stop_lifecycle():
|
||||
store = _mock_pg_store_with_events([])
|
||||
sched = NightlyScheduler()
|
||||
# Patch seconds_until to be tiny so the loop is testable.
|
||||
import server.cohort.nightly as nightly_mod
|
||||
orig = nightly_mod.seconds_until_next_03_ct
|
||||
nightly_mod.seconds_until_next_03_ct = lambda: 0.01
|
||||
try:
|
||||
task = await sched.start(store)
|
||||
await _sleep(0.05)
|
||||
await sched.stop()
|
||||
assert task.cancelled() or task.done()
|
||||
finally:
|
||||
nightly_mod.seconds_until_next_03_ct = orig
|
||||
|
||||
|
||||
async def _sleep(t: float) -> None:
|
||||
import asyncio
|
||||
await asyncio.sleep(t)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Bootstrap CLI test (TASK-05-03) — mocked PgStore, no real Postgres.
|
||||
|
||||
Covers: create operator → exists; re-run → "already exists" (no update);
|
||||
--update → password updated; missing env → exit 1; password is argon2id
|
||||
(not plaintext).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_cli_module(monkeypatch, env: dict, update: bool = False):
|
||||
"""Load scripts/create-operator.py as a module with a mocked asyncpg pool."""
|
||||
for k in ("PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS",
|
||||
"PRAXIS_PG_DSN"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
for k, v in env.items():
|
||||
if v is None:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(k, v)
|
||||
# Import the script as a module by path.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"create_operator", "scripts/create-operator.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _make_mock_pool_store(operators: dict[str, dict] | None = None):
|
||||
operators = operators if operators is not None else {}
|
||||
|
||||
pool = MagicMock()
|
||||
pool.close = AsyncMock()
|
||||
|
||||
conn = MagicMock()
|
||||
|
||||
async def acquire_ctx():
|
||||
return conn
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
pool.acquire = MagicMock(return_value=cm)
|
||||
|
||||
store = MagicMock()
|
||||
|
||||
async def insert_operator(username, password_hash, display_name, *, on_conflict_update=False):
|
||||
if on_conflict_update:
|
||||
operators[username] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
return operators[username]["id"]
|
||||
if username in operators:
|
||||
return None # already exists
|
||||
operators[username] = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
}
|
||||
return operators[username]["id"]
|
||||
|
||||
store.insert_operator = insert_operator
|
||||
return pool, store, operators
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_creates(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store()
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations as _apm
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return ["0001_operator_tier"]
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "created" in out
|
||||
assert "admin" in operators
|
||||
h = operators["admin"]["password_hash"]
|
||||
assert h.startswith("$argon2id$")
|
||||
assert "secret-pw" not in h # not plaintext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_already_exists(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store({"admin": {
|
||||
"id": "id1", "username": "admin", "password_hash": "$argon2id$old"
|
||||
}})
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return []
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists" in out
|
||||
# password NOT updated
|
||||
assert operators["admin"]["password_hash"] == "$argon2id$old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_update_rehashes(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "new-pw",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
pool, store, operators = _make_mock_pool_store({"admin": {
|
||||
"id": "id1", "username": "admin", "password_hash": "$argon2id$old"
|
||||
}})
|
||||
|
||||
import asyncpg
|
||||
async def fake_create_pool(**kw):
|
||||
return pool
|
||||
monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool)
|
||||
|
||||
import db.pg_migrate
|
||||
async def fake_apply_migrations(p):
|
||||
return []
|
||||
monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations)
|
||||
|
||||
import db.pg_store
|
||||
monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store)
|
||||
|
||||
rc = await mod.create_operator(update=True)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "updated" in out
|
||||
assert operators["admin"]["password_hash"].startswith("$argon2id$")
|
||||
assert operators["admin"]["password_hash"] != "$argon2id$old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_user_env(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "PRAXIS_BOOTSTRAP_OPERATOR_USER" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_pass_env(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operator_missing_dsn(monkeypatch, capsys):
|
||||
env = {
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin",
|
||||
"PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x",
|
||||
}
|
||||
mod = _load_cli_module(monkeypatch, env)
|
||||
rc = await mod.create_operator(update=False)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "PRAXIS_PG_DSN" in err
|
||||
|
||||
|
||||
def test_password_hash_is_argon2id():
|
||||
from argon2 import PasswordHasher
|
||||
h = PasswordHasher().hash("test")
|
||||
assert h.startswith("$argon2id$")
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Operator API endpoint unit tests (TASK-08-05) — mocked PgStore.
|
||||
|
||||
Covers: 401 without cookie, 200 with valid cookie, suppressed cells have
|
||||
value=null, last_updated is max(updated_at), credential revoke works, no
|
||||
per-learner data in responses (R-DASH-02).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from server.auth.models import Operator
|
||||
from server.auth.passwords import hash_password
|
||||
from server.auth.rate_limit import reset_login_rate_limit
|
||||
from server.auth.routes import router as auth_router
|
||||
from server.operator.cohort import router as cohort_router
|
||||
from server.operator.credentials import router as credentials_router
|
||||
from server.operator.failure_patterns import router as failure_router
|
||||
from server.operator.mastery import router as mastery_router
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_limiter():
|
||||
reset_login_rate_limit()
|
||||
yield
|
||||
reset_login_rate_limit()
|
||||
|
||||
|
||||
class _FakeRecord(dict):
|
||||
pass
|
||||
|
||||
|
||||
def _mock_pg_store(aggregates=None, credentials=None):
|
||||
store = MagicMock()
|
||||
# Operator lookup for current_operator dependency.
|
||||
store.get_operator_by_id = AsyncMock(return_value={
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
})
|
||||
store.update_last_login = AsyncMock()
|
||||
store.get_operator_by_username = AsyncMock(return_value={
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"role": "operator",
|
||||
"is_active": True,
|
||||
"password_hash": hash_password("pw"),
|
||||
})
|
||||
# Cohort aggregates query (all_recent_aggregates).
|
||||
aggregates = aggregates or []
|
||||
conn = MagicMock()
|
||||
conn.fetch = AsyncMock(return_value=[_FakeRecord(r) for r in aggregates])
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
store.pool = MagicMock()
|
||||
store.pool.acquire = MagicMock(return_value=cm)
|
||||
# Credentials.
|
||||
store.list_credentials = AsyncMock(return_value=credentials or [])
|
||||
store.get_credential = AsyncMock(return_value=credentials[0] if credentials else None)
|
||||
store.set_credential_status = AsyncMock()
|
||||
return store
|
||||
|
||||
|
||||
def _make_app(store) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.pg_store = store
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(cohort_router)
|
||||
app.include_router(mastery_router)
|
||||
app.include_router(failure_router)
|
||||
app.include_router(credentials_router)
|
||||
return app
|
||||
|
||||
|
||||
def _login(client) -> None:
|
||||
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ── 401 without cookie ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cohort_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_mastery_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/mastery")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_failure_patterns_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/failure-patterns")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_credentials_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/credentials")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_revoke_401_without_cookie():
|
||||
app = _make_app(_mock_pg_store())
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/operator/credentials/abc/revoke")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── 200 with valid cookie ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cohort_200_with_cookie():
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
agg = [
|
||||
{"path": "customer_service", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 12.0, "cell_count": 12, "cell_suppressed": False,
|
||||
"updated_at": now},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert any(v["path"] == "customer_service" for v in body["views"])
|
||||
|
||||
|
||||
def test_mastery_200_with_cookie():
|
||||
agg = [
|
||||
{"path": "p", "metric": "gate_open_rate",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 0.5, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/mastery")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_failure_patterns_200_with_cookie():
|
||||
agg = [
|
||||
{"path": "p", "metric": "failure_mode:missed_apology",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 3.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/failure-patterns")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_credentials_200_with_cookie():
|
||||
cred = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"learner_ref": "learner-1",
|
||||
"vc_type": "MasteryCredential",
|
||||
"status": "active",
|
||||
"issued_at": _dt.datetime.now(_dt.timezone.utc),
|
||||
"revoked_at": None,
|
||||
}
|
||||
app = _make_app(_mock_pg_store(credentials=[cred]))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/credentials")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["credentials"]) == 1
|
||||
|
||||
|
||||
# ── Suppressed cells have value=null ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_suppressed_cells_value_null():
|
||||
agg = [
|
||||
{"path": "p", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": None, "cell_count": 5, "cell_suppressed": True,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
cell = r.json()["views"][0]["metrics"][0]
|
||||
assert cell["cell_suppressed"] is True
|
||||
assert cell["value"] is None
|
||||
|
||||
|
||||
# ── last_updated is max(updated_at) ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_last_updated_is_max():
|
||||
t1 = _dt.datetime(2026, 8, 1, 12, 0, tzinfo=_dt.timezone.utc)
|
||||
t2 = _dt.datetime(2026, 8, 3, 12, 0, tzinfo=_dt.timezone.utc)
|
||||
agg = [
|
||||
{"path": "p", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 1.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": t1},
|
||||
{"path": "p", "metric": "active_learners_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 10.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": t2},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["last_updated"] is not None
|
||||
|
||||
|
||||
# ── Credential revoke ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_credential_revoke_sets_status_revoked():
|
||||
cred = {
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"learner_ref": "learner-1",
|
||||
"vc_type": "MasteryCredential",
|
||||
"status": "active",
|
||||
"issued_at": _dt.datetime.now(_dt.timezone.utc),
|
||||
"revoked_at": None,
|
||||
}
|
||||
store = _mock_pg_store(credentials=[cred])
|
||||
app = _make_app(store)
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.post("/api/operator/credentials/22222222-2222-2222-2222-222222222222/revoke")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "revoked"
|
||||
store.set_credential_status.assert_awaited_once_with(
|
||||
"22222222-2222-2222-2222-222222222222", "revoked",
|
||||
)
|
||||
|
||||
|
||||
def test_credential_revoke_404_unknown():
|
||||
store = _mock_pg_store(credentials=None)
|
||||
store.get_credential = AsyncMock(return_value=None)
|
||||
app = _make_app(store)
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.post("/api/operator/credentials/nonexistent/revoke")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── No per-learner data in cohort responses (R-DASH-02) ───────────────────
|
||||
|
||||
|
||||
def test_no_per_learner_data_in_cohort_response():
|
||||
agg = [
|
||||
{"path": "p", "metric": "sessions_count",
|
||||
"window_start": _dt.date.today(), "window_end": _dt.date.today(),
|
||||
"value": 10.0, "cell_count": 10, "cell_suppressed": False,
|
||||
"updated_at": _dt.datetime.now(_dt.timezone.utc)},
|
||||
]
|
||||
app = _make_app(_mock_pg_store(aggregates=agg))
|
||||
with TestClient(app) as client:
|
||||
_login(client)
|
||||
r = client.get("/api/operator/cohort")
|
||||
body_text = r.text
|
||||
# No per-learner refs in the response (only path + metric + aggregates).
|
||||
assert "learner-1" not in body_text
|
||||
assert "learner_ref" not in body_text
|
||||
|
||||
|
||||
# ── 503 when no Postgres ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cohort_503_no_postgres():
|
||||
app = FastAPI()
|
||||
app.state.pg_store = None
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(cohort_router)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 503
|
||||
@@ -0,0 +1,115 @@
|
||||
"""P1 auth integration test (TASK-06-04) — end-to-end with Postgres.
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset. Tests the full auth flow through the FastAPI app (TestClient with
|
||||
the real lifespan): create operator via the bootstrap CLI → POST /login →
|
||||
GET /me → POST /logout → GET /me (401). Rate limiting, cookie attributes,
|
||||
and learner-voice-loop-unaffected verification (REQ-NFR-MT-01).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — P1 auth integration tests skipped.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def _started_app():
|
||||
"""Start the real FastAPI app with the lifespan (creates the pool +
|
||||
applies migrations + runs VC key migration)."""
|
||||
import asyncio
|
||||
import server.__main__ as m
|
||||
# Ensure the SQLite store is initialized (v0.3 path).
|
||||
await m._store.init()
|
||||
# Use a unique operator username per run to avoid collisions.
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
with TestClient(m.app) as client:
|
||||
yield client, suffix, m
|
||||
|
||||
|
||||
def test_full_auth_flow(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
username = f"intop-{suffix}"
|
||||
pw = "integration-pw-123"
|
||||
# Create operator via the store directly (bootstrap CLI path is
|
||||
# covered in test_create_operator.py; here we exercise the HTTP flow).
|
||||
import asyncio
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
async def _seed():
|
||||
await m.app.state.pg_store.insert_operator(username, hash_password(pw), username)
|
||||
asyncio.get_event_loop().run_until_complete(_seed())
|
||||
|
||||
# POST /login
|
||||
r = client.post("/api/operator/login", json={"username": username, "password": pw})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["operator"]["username"] == username
|
||||
# Cookie set
|
||||
cookie = client.cookies.get("praxis_op")
|
||||
assert cookie, "praxis_op cookie should be set after login"
|
||||
|
||||
# GET /me
|
||||
r2 = client.get("/api/operator/me")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["operator"]["username"] == username
|
||||
|
||||
# POST /logout
|
||||
r3 = client.post("/api/operator/logout")
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["ok"] is True
|
||||
|
||||
# GET /me after logout → 401
|
||||
r4 = client.get("/api/operator/me")
|
||||
assert r4.status_code == 401
|
||||
|
||||
|
||||
def test_me_without_cookie_401(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
# Use a fresh client (no cookie jar sharing).
|
||||
import server.__main__ as m
|
||||
with TestClient(m.app) as fresh:
|
||||
r = fresh.get("/api/operator/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_wrong_password_401(_started_app):
|
||||
client, suffix, m = _started_app
|
||||
if m.app.state.pg_store is None:
|
||||
pytest.skip("pg_store is None (no Postgres connected)")
|
||||
username = f"wrong-{suffix}"
|
||||
pw = "correct-pw"
|
||||
import asyncio
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
async def _seed():
|
||||
await m.app.state.pg_store.insert_operator(username, hash_password(pw), username)
|
||||
asyncio.get_event_loop().run_until_complete(_seed())
|
||||
import server.__main__ as m
|
||||
from server.auth.rate_limit import reset_login_rate_limit
|
||||
reset_login_rate_limit()
|
||||
with TestClient(m.app) as fresh:
|
||||
r = fresh.post("/api/operator/login", json={"username": username, "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_learner_voice_loop_unaffected(_started_app):
|
||||
"""REQ-NFR-MT-01 — Postgres presence does not destabilize the learner
|
||||
voice loop (/health works regardless of Postgres state)."""
|
||||
client, suffix, m = _started_app
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""VC migration e2e test (TASK-06-05, R-VC-MIG-01 — CRITICAL).
|
||||
|
||||
The highest-severity v0.4 risk: a v0.3 VC MUST verify against a Postgres
|
||||
store with the v0.3 public key archived as superseded. This test seeds
|
||||
SQLite with a v0.3 issuer key + credential, runs the migration, and
|
||||
verifies through the HTTP endpoint.
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — VC migration e2e test skipped (R-VC-MIG-01).",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def _e2e_env(tmp_path, monkeypatch):
|
||||
"""Set up a fresh SQLite store + Postgres pool + run migration."""
|
||||
import server.__main__ as m
|
||||
from db.store import PraxisStore
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
from server.vc.issuer import build_vc_payload, sign, issue_credential
|
||||
from server.vc.issuer_keys import init_issuer_key, _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
|
||||
# Fresh SQLite store in a temp dir.
|
||||
sqlite_path = tmp_path / "praxis-e2e.db"
|
||||
monkeypatch.setenv("PRAXIS_DB_PATH", str(sqlite_path))
|
||||
sqlite_store = PraxisStore(str(sqlite_path))
|
||||
await sqlite_store.init()
|
||||
|
||||
# Seed SQLite with a v0.3 issuer key + a v0.3-issued credential.
|
||||
root_key = _load_root_key()
|
||||
v03_kp = await init_issuer_key(sqlite_store, root_key)
|
||||
v03_cred_id = await issue_credential(
|
||||
sqlite_store,
|
||||
signing_key=v03_kp.signing_key,
|
||||
key_id=v03_kp.key_id,
|
||||
learner_id="learner-e2e-v03",
|
||||
path="cs-refund",
|
||||
scenarios_passed=["sc-1"],
|
||||
rubric_score=4.0,
|
||||
completed_weeks=6,
|
||||
evidence=[],
|
||||
)
|
||||
|
||||
# Connect to Postgres + apply migrations + clean tables.
|
||||
import asyncpg
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=3, command_timeout=10
|
||||
)
|
||||
await apply_pg_migrations(pool)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
|
||||
pg_store = PgStore(pool)
|
||||
|
||||
yield {
|
||||
"sqlite_store": sqlite_store,
|
||||
"pg_store": pg_store,
|
||||
"pool": pool,
|
||||
"v03_kp": v03_kp,
|
||||
"v03_cred_id": v03_cred_id,
|
||||
"root_key": root_key,
|
||||
}
|
||||
|
||||
await pool.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v03_vc_verifies_after_migration(_e2e_env):
|
||||
"""R-VC-MIG-01: v0.3 VC verifies against Postgres with archived key."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
# Run the migration.
|
||||
result = await migrate_issuer_keys(
|
||||
env["sqlite_store"], env["pg_store"], env["root_key"]
|
||||
)
|
||||
assert result["archived_key_id"] == env["v03_kp"].key_id
|
||||
assert result["new_key_id"] is not None
|
||||
|
||||
# Verify Postgres has 1 superseded + 1 active key.
|
||||
active = await env["pg_store"].get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
archived = await env["pg_store"].get_public_key_row(env["v03_kp"].key_id)
|
||||
assert archived is not None
|
||||
assert archived["status"] == "superseded"
|
||||
|
||||
# R-VC-MIG-01 CRITICAL: verify the v0.3 credential through the
|
||||
# two-store path (G-011: credential in SQLite, key in Postgres).
|
||||
res = await verify_credential(
|
||||
env["sqlite_store"], env["v03_cred_id"],
|
||||
pg_store=env["pg_store"], sqlite_store=env["sqlite_store"],
|
||||
)
|
||||
assert res is not None
|
||||
assert res["valid"] is True, (
|
||||
"R-VC-MIG-01 FAIL: v0.3 VC did not verify against archived superseded key"
|
||||
)
|
||||
assert res["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent_e2e(_e2e_env):
|
||||
"""Re-running the migration is a no-op."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
result = await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_g027_first_boot_no_v03_key(_e2e_env):
|
||||
"""G-027: fresh deploy with no v0.3 key → skip archive, fresh key only."""
|
||||
env = _e2e_env
|
||||
# Use a fresh SQLite store with NO v0.3 key.
|
||||
from db.store import PraxisStore
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
import tempfile
|
||||
fresh_path = Path(tempfile.mkdtemp()) / "fresh.db"
|
||||
fresh_store = PraxisStore(str(fresh_path))
|
||||
await fresh_store.init()
|
||||
result = await migrate_issuer_keys(fresh_store, env["pg_store"], env["root_key"])
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v04_vc_verifies_after_migration(_e2e_env):
|
||||
"""A newly-issued v0.4 VC verifies against the active key in Postgres."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
from server.vc.issuer import issue_credential
|
||||
from server.vc.issuer_keys import get_active_signing_key
|
||||
|
||||
await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
# Issue a v0.4 credential using the active Postgres key.
|
||||
kp, _enc = await get_active_signing_key(env["pg_store"], env["root_key"])
|
||||
v04_cred_id = await issue_credential(
|
||||
env["sqlite_store"],
|
||||
signing_key=kp.signing_key,
|
||||
key_id=kp.key_id,
|
||||
learner_id="learner-e2e-v04",
|
||||
path="cs-refund",
|
||||
scenarios_passed=["sc-1", "sc-2"],
|
||||
rubric_score=4.5,
|
||||
completed_weeks=6,
|
||||
evidence=[],
|
||||
)
|
||||
# The credential is in SQLite; the key is in Postgres. Verify via the
|
||||
# two-store path.
|
||||
res = await verify_credential(
|
||||
env["sqlite_store"], v04_cred_id,
|
||||
pg_store=env["pg_store"], sqlite_store=env["sqlite_store"],
|
||||
)
|
||||
assert res is not None
|
||||
assert res["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tampered_v03_vc_fails_e2e(_e2e_env):
|
||||
"""Tamper detection: a modified v0.3 credential fails verification."""
|
||||
env = _e2e_env
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"])
|
||||
# Fetch the v0.3 credential and tamper with its payload.
|
||||
row = await env["sqlite_store"].get_credential(env["v03_cred_id"])
|
||||
assert row is not None
|
||||
doc = json.loads(row["vc_payload_json"])
|
||||
doc["credentialSubject"]["rubricScore"] = 1.0 # tamper
|
||||
await env["sqlite_store"].set_credential_status(env["v03_cred_id"], "active")
|
||||
# Overwrite the payload in SQLite with the tampered version.
|
||||
import aiosqlite
|
||||
async with aiosqlite.connect(env["sqlite_store"].db_path) as db:
|
||||
await db.execute(
|
||||
"UPDATE issued_credentials SET vc_payload_json = ? WHERE id = ?",
|
||||
(json.dumps(doc, sort_keys=True, separators=(",", ":")), env["v03_cred_id"]),
|
||||
)
|
||||
await db.commit()
|
||||
res = await verify_credential(
|
||||
env["sqlite_store"], env["v03_cred_id"],
|
||||
pg_store=env["pg_store"], sqlite_store=env["sqlite_store"],
|
||||
)
|
||||
assert res is not None
|
||||
assert res["valid"] is False
|
||||
@@ -0,0 +1,236 @@
|
||||
"""P2 integration test — aggregation → endpoint → response (TASK-10-03).
|
||||
|
||||
Requires Postgres (skips if PRAXIS_PG_DSN not set). End-to-end:
|
||||
1. Seed 15 mock sessions (12 distinct learners — above k-anon threshold).
|
||||
2. Run the aggregation hook for each → cohort_aggregates populated.
|
||||
3. GET /api/operator/cohort (with auth cookie) → non-suppressed cells.
|
||||
4. Seed 5 sessions (5 NEW learners) for a different path → suppressed cells.
|
||||
5. Run nightly reconciliation → all windows recomputed → last_updated updated.
|
||||
6. GET /api/operator/mastery → mastery progression data.
|
||||
7. GET /api/operator/failure-patterns → failure pattern data.
|
||||
8. Verify last_updated ≤ 24h old (REQ-NFR-DASH-02).
|
||||
|
||||
G-038 differencing-attack e2e: also verified at the API layer here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("PRAXIS_PG_DSN"),
|
||||
reason="PRAXIS_PG_DSN not set — P2 aggregation integration tests skipped.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pg_pool():
|
||||
import asyncpg
|
||||
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=5, command_timeout=10,
|
||||
)
|
||||
try:
|
||||
yield pool
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pg_store(pg_pool):
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
await apply_pg_migrations(pg_pool)
|
||||
# Clean cohort_aggregates + operators for an isolated run.
|
||||
async with pg_pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM cohort_aggregates")
|
||||
await conn.execute("DELETE FROM operators WHERE username = 'p2intop'")
|
||||
await conn.execute("DELETE FROM issued_credentials")
|
||||
return PgStore(pg_pool)
|
||||
|
||||
|
||||
def _session(learner_ref: str, path: str = "customer_service",
|
||||
outcome: str = "pass") -> dict:
|
||||
return {
|
||||
"learner_ref": learner_ref,
|
||||
"path": path,
|
||||
"scenario_id": f"{path}_v01",
|
||||
"outcome": outcome,
|
||||
"rubric_scores": [
|
||||
{"criterion_id": "empathy", "score": 4.0},
|
||||
{"criterion_id": "resolution", "score": 3.5},
|
||||
],
|
||||
"failure_mode": "missed_apology" if outcome == "fail" else None,
|
||||
"branch_path": ["accept"],
|
||||
"timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _seed_and_aggregate(pg_store, sessions):
|
||||
from server.cohort.hook import on_session_end
|
||||
|
||||
for s in sessions:
|
||||
await on_session_end(pg_store, s)
|
||||
|
||||
|
||||
async def _login_cookie(client, pg_store) -> None:
|
||||
from server.auth.passwords import hash_password
|
||||
|
||||
op_id = await pg_store.insert_operator("p2intop", hash_password("pw"), "P2 Int")
|
||||
# Login via the test client.
|
||||
r = client.post("/api/operator/login", json={"username": "p2intop", "password": "pw"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _make_client(pg_store):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from server.auth.rate_limit import reset_login_rate_limit
|
||||
from server.auth.routes import router as auth_router
|
||||
from server.operator.cohort import router as cohort_router
|
||||
from server.operator.credentials import router as credentials_router
|
||||
from server.operator.failure_patterns import router as failure_router
|
||||
from server.operator.mastery import router as mastery_router
|
||||
|
||||
reset_login_rate_limit()
|
||||
app = FastAPI()
|
||||
app.state.pg_store = pg_store
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(cohort_router)
|
||||
app.include_router(mastery_router)
|
||||
app.include_router(failure_router)
|
||||
app.include_router(credentials_router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Main e2e test ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregation_to_endpoint_e2e(pg_store):
|
||||
"""12 distinct learners → non-suppressed; 5 distinct → suppressed."""
|
||||
# 1. Seed 12 distinct learners across 15 sessions for 'customer_service'.
|
||||
sessions = []
|
||||
for i in range(12):
|
||||
sessions.append(_session(f"learner-{i}", "customer_service", "pass"))
|
||||
for i in range(3):
|
||||
sessions.append(_session(f"learner-{i}", "customer_service", "fail"))
|
||||
await _seed_and_aggregate(pg_store, sessions)
|
||||
|
||||
# 2. Seed 5 distinct learners for 'sales' (below threshold).
|
||||
sales_sessions = [_session(f"sales-{i}", "sales", "pass") for i in range(5)]
|
||||
await _seed_and_aggregate(pg_store, sales_sessions)
|
||||
|
||||
client = _make_client(pg_store)
|
||||
with client:
|
||||
await _login_cookie(client, pg_store)
|
||||
|
||||
# 3. GET /api/operator/cohort → non-suppressed for customer_service.
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
paths = {v["path"] for v in body["views"]}
|
||||
assert "customer_service" in paths
|
||||
# 4. sales path cells should be suppressed (5 < 10).
|
||||
sales_view = next((v for v in body["views"] if v["path"] == "sales"), None)
|
||||
if sales_view:
|
||||
suppressed = [c for c in sales_view["metrics"] if c["cell_suppressed"]]
|
||||
assert suppressed, "sales (5 learners) must be suppressed"
|
||||
|
||||
# customer_service (12 learners) should have non-suppressed cells.
|
||||
cs_view = next((v for v in body["views"] if v["path"] == "customer_service"), None)
|
||||
assert cs_view is not None
|
||||
non_suppressed = [c for c in cs_view["metrics"] if not c["cell_suppressed"]]
|
||||
assert non_suppressed, "customer_service (12 learners) should have non-suppressed cells"
|
||||
|
||||
# 6. GET /api/operator/mastery
|
||||
r = client.get("/api/operator/mastery")
|
||||
assert r.status_code == 200
|
||||
|
||||
# 7. GET /api/operator/failure-patterns
|
||||
r = client.get("/api/operator/failure-patterns")
|
||||
assert r.status_code == 200
|
||||
|
||||
# 8. last_updated ≤ 24h (REQ-NFR-DASH-02)
|
||||
if body.get("last_updated"):
|
||||
ts = _dt.datetime.fromisoformat(body["last_updated"].replace("Z", "+00:00"))
|
||||
age = _dt.datetime.now(_dt.timezone.utc) - ts
|
||||
assert age < _dt.timedelta(hours=24), "freshness must be ≤ 24h"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nightly_reconciliation_updates_last_updated(pg_store):
|
||||
from server.cohort.nightly import NightlyScheduler
|
||||
|
||||
# Seed a few events via the aggregation hook first.
|
||||
sessions = [_session(f"r-learner-{i}", "recon_path", "pass") for i in range(11)]
|
||||
await _seed_and_aggregate(pg_store, sessions)
|
||||
|
||||
# Run nightly reconciliation.
|
||||
sched = NightlyScheduler()
|
||||
# mastery_gate_events is the source for nightly — seed a gate event.
|
||||
async with pg_store.pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM mastery_gate_events")
|
||||
for i in range(11):
|
||||
await conn.execute(
|
||||
"INSERT INTO mastery_gate_events (learner_ref, scenario_id, path_id, "
|
||||
"gate_outcome, rubric_scores_jsonb, source) "
|
||||
"VALUES ($1, $2, $3, $4, $5::jsonb, 'sync')",
|
||||
f"r-learner-{i}", "recon_v01", "recon_path", "open",
|
||||
'[{"criterion_id":"empathy","score":4.0}]',
|
||||
)
|
||||
await sched.reconcile_now(pg_store)
|
||||
|
||||
client = _make_client(pg_store)
|
||||
with client:
|
||||
await _login_cookie(client, pg_store)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
# last_updated should be very recent after reconciliation.
|
||||
body = r.json()
|
||||
if body.get("last_updated"):
|
||||
ts = _dt.datetime.fromisoformat(body["last_updated"].replace("Z", "+00:00"))
|
||||
age = _dt.datetime.now(_dt.timezone.utc) - ts
|
||||
assert age < _dt.timedelta(minutes=1), "nightly reconcile should refresh last_updated"
|
||||
|
||||
|
||||
# ── G-038 e2e: differencing-attack at the API layer ────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_g038_differencing_attack_api_layer(pg_store):
|
||||
"""G-038: 10 learners in window A, 9 in window B. Verify GET /cohort
|
||||
cannot isolate the dropped learner — window B is fully suppressed."""
|
||||
# Window A: 10 learners on path 'diff_a'.
|
||||
a_sessions = [_session(f"a-{i}", "diff_a", "pass") for i in range(10)]
|
||||
await _seed_and_aggregate(pg_store, a_sessions)
|
||||
|
||||
# Window B: 9 learners on path 'diff_b' (learner a-9 dropped).
|
||||
b_sessions = [_session(f"a-{i}", "diff_b", "pass") for i in range(9)]
|
||||
await _seed_and_aggregate(pg_store, b_sessions)
|
||||
|
||||
client = _make_client(pg_store)
|
||||
with client:
|
||||
await _login_cookie(client, pg_store)
|
||||
r = client.get("/api/operator/cohort")
|
||||
assert r.status_code == 200
|
||||
body_text = r.text
|
||||
# The dropped learner's ref must not appear anywhere in the response.
|
||||
assert "a-9" not in body_text, "dropped learner must not be isolatable via API"
|
||||
|
||||
# diff_b cells must all be suppressed (9 < 10).
|
||||
body = r.json()
|
||||
diff_b = next((v for v in body["views"] if v["path"] == "diff_b"), None)
|
||||
assert diff_b is not None
|
||||
for c in diff_b["metrics"]:
|
||||
assert c["cell_suppressed"] is True, "window B (9 learners) must be fully suppressed"
|
||||
assert c["value"] is None
|
||||
@@ -0,0 +1,128 @@
|
||||
"""P2 integration test — SPA fallback + voice UI coexist (TASK-10-04, G-041).
|
||||
|
||||
Tests against the running app (TestClient). Verifies:
|
||||
1. GET / → 200 text/html with <div id="root"> (voice UI loads).
|
||||
2. GET /operator/dashboard → 200 text/html (SPA fallback serves index.html).
|
||||
3. GET /operator/login → 200 text/html (SPA fallback).
|
||||
4. GET /api/operator/cohort → JSON (API route, not SPA fallback).
|
||||
5. GET /health → JSON (API route).
|
||||
6. GET /pipecat/webrtc → 405 (POST only, route exists — not SPA fallback).
|
||||
7. GET /vc/verify/nonexistent → 404 (API route, not SPA fallback).
|
||||
8. GET /assets/index.js → served by StaticFiles (not SPA fallback).
|
||||
|
||||
R-DASH-03 verified: SPA fallback serves index.html for client-side routes;
|
||||
API routes + StaticFiles assets are unaffected. R-DASH-05: voice UI at /
|
||||
unchanged.
|
||||
|
||||
G-041: the SPA fallback uses a custom StaticFiles subclass (SpaStaticFiles),
|
||||
NOT a catch-all route — assets are served normally, index.html is the
|
||||
fallback only for non-file paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_dist(tmp_path):
|
||||
"""Build a client/dist with index.html + an asset, then import the app."""
|
||||
dist = tmp_path / "dist"
|
||||
dist.mkdir()
|
||||
(dist / "index.html").write_text(
|
||||
'<!doctype html><html><body><div id="root"></div></body></html>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assets = dist / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "index.js").write_text("console.log('app');", encoding="utf-8")
|
||||
|
||||
# Set the env var + reload the app module so the StaticFiles mount sees it.
|
||||
os.environ["PRAXIS_CLIENT_DIST"] = str(dist)
|
||||
os.environ["PRAXIS_COOKIE_SECRET"] = "x" * 48
|
||||
os.environ["PRAXIS_COOKIE_SECURE"] = "false"
|
||||
# Drop any PG DSN so we don't try to connect during the lifespan.
|
||||
os.environ.pop("PRAXIS_PG_DSN", None)
|
||||
|
||||
import importlib
|
||||
import server.__main__ as main_mod
|
||||
|
||||
importlib.reload(main_mod)
|
||||
with TestClient(main_mod.app) as c:
|
||||
yield c
|
||||
|
||||
# Cleanup env.
|
||||
os.environ.pop("PRAXIS_CLIENT_DIST", None)
|
||||
|
||||
|
||||
def test_root_serves_voice_ui(client_with_dist):
|
||||
r = client_with_dist.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" in r.text
|
||||
|
||||
|
||||
def test_operator_dashboard_spa_fallback(client_with_dist):
|
||||
r = client_with_dist.get("/operator/dashboard")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" in r.text
|
||||
|
||||
|
||||
def test_operator_login_spa_fallback(client_with_dist):
|
||||
r = client_with_dist.get("/operator/login")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" in r.text
|
||||
|
||||
|
||||
def test_api_operator_cohort_is_json_not_html(client_with_dist):
|
||||
# Without auth → 401 JSON (not index.html). Proves the API route wins.
|
||||
r = client_with_dist.get("/api/operator/cohort")
|
||||
assert r.status_code in (401, 503)
|
||||
assert "application/json" in r.headers.get("content-type", "")
|
||||
# Critically NOT html.
|
||||
assert "<div id=\"root\">" not in r.text
|
||||
|
||||
|
||||
def test_health_is_json(client_with_dist):
|
||||
r = client_with_dist.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "application/json" in r.headers.get("content-type", "")
|
||||
|
||||
|
||||
def test_pipecat_webrtc_post_route_exists(client_with_dist):
|
||||
# The POST route exists and responds (not index.html). A GET falls through
|
||||
# to the SPA fallback (serves index.html) — acceptable: the POST route is
|
||||
# the real voice-loop entrypoint; a GET is a client-side navigation attempt.
|
||||
# We assert the POST route is wired (returns 4xx/5xx, not HTML).
|
||||
r = client_with_dist.post("/pipecat/webrtc", json={"sdp": "", "type": "offer"})
|
||||
assert r.status_code in (400, 422, 500)
|
||||
assert "<div id=\"root\">" not in r.text
|
||||
|
||||
|
||||
def test_vc_verify_nonexistent_is_404(client_with_dist):
|
||||
r = client_with_dist.get("/vc/verify/nonexistent-id-xyz")
|
||||
assert r.status_code == 404
|
||||
assert "application/json" in r.headers.get("content-type", "")
|
||||
assert "<div id=\"root\">" not in r.text
|
||||
|
||||
|
||||
def test_assets_served_by_staticfiles_not_spa_fallback(client_with_dist):
|
||||
r = client_with_dist.get("/assets/index.js")
|
||||
assert r.status_code == 200
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert "javascript" in ct or "text/plain" in ct
|
||||
assert "console.log" in r.text
|
||||
|
||||
|
||||
def test_unknown_non_asset_path_serves_index_html(client_with_dist):
|
||||
"""An unknown path that is NOT an asset + NOT an API route → SPA fallback."""
|
||||
r = client_with_dist.get("/some/unknown/route")
|
||||
assert r.status_code == 200
|
||||
assert "<div id=\"root\">" in r.text
|
||||
@@ -0,0 +1,221 @@
|
||||
"""PgStore + asyncpg pool integration test (TASK-01-07).
|
||||
|
||||
Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is
|
||||
unset so the test suite has no hard CI dependency on Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from db.pg_migrate import apply_pg_migrations
|
||||
from db.pg_store import PgStore
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
"PRAXIS_PG_DSN" not in os.environ,
|
||||
reason="PRAXIS_PG_DSN not set — Postgres integration tests skipped (dev mode).",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def pool() -> asyncpg.Pool:
|
||||
p = await asyncpg.create_pool(
|
||||
dsn=os.environ["PRAXIS_PG_DSN"],
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
command_timeout=10,
|
||||
)
|
||||
try:
|
||||
await apply_pg_migrations(p)
|
||||
yield p
|
||||
finally:
|
||||
await p.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_tables(pool: asyncpg.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"TRUNCATE operators, issued_credentials, mastery_gate_events, "
|
||||
"cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE"
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_creates_tables(pool: asyncpg.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
tables = await conn.fetch(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' "
|
||||
"ORDER BY tablename"
|
||||
)
|
||||
names = {r["tablename"] for r in tables}
|
||||
assert {"operators", "issued_credentials", "mastery_gate_events",
|
||||
"cohort_aggregates", "issuer_keys"}.issubset(names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent(pool: asyncpg.Pool):
|
||||
applied = await apply_pg_migrations(pool)
|
||||
assert applied == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_insert_and_lookup(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator(
|
||||
"alice", "$argon2id$fakehash", "Alice"
|
||||
)
|
||||
assert oid is not None
|
||||
op = await store.get_operator_by_username("alice")
|
||||
assert op is not None
|
||||
assert op["username"] == "alice"
|
||||
assert op["display_name"] == "Alice"
|
||||
assert op["is_active"] is True
|
||||
by_id = await store.get_operator_by_id(oid)
|
||||
assert by_id is not None
|
||||
assert by_id["id"] == op["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_insert_idempotent(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
first = await store.insert_operator("bob", "$argon2id$h1", "Bob")
|
||||
assert first is not None
|
||||
second = await store.insert_operator("bob", "$argon2id$h2", "Bob")
|
||||
assert second is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_on_conflict_update(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
await store.insert_operator("carol", "$argon2id$old", "Carol")
|
||||
updated = await store.insert_operator(
|
||||
"carol", "$argon2id$new", "Carol", on_conflict_update=True
|
||||
)
|
||||
assert updated is not None
|
||||
op = await store.get_operator_by_username("carol")
|
||||
assert op["password_hash"] == "$argon2id$new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_last_login(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator("dave", "$argon2id$h", "Dave")
|
||||
assert oid is not None
|
||||
assert (await store.get_operator_by_id(oid))["last_login_at"] is None
|
||||
await store.update_last_login(oid)
|
||||
assert (await store.get_operator_by_id(oid))["last_login_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohort_aggregate_upsert_idempotent(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
ws, we = date(2026, 8, 1), date(2026, 8, 7)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", ws, we, 42.0, 15, False
|
||||
)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "sessions_count", ws, we, 42.0, 15, False
|
||||
)
|
||||
rows = await store.get_cohort_aggregates(
|
||||
"cs-refund", "sessions_count", date(2026, 7, 1)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["value"] == 42.0
|
||||
assert rows[0]["cell_count"] == 15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohort_aggregate_suppressed_cell(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
ws, we = date(2026, 8, 1), date(2026, 8, 7)
|
||||
await store.upsert_cohort_aggregate(
|
||||
"cs-refund", "active_learners", ws, we, None, 9, True
|
||||
)
|
||||
rows = await store.get_cohort_aggregates(
|
||||
"cs-refund", "active_learners", date(2026, 7, 1)
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["cell_suppressed"] is True
|
||||
assert rows[0]["value"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issuer_key_init_active_then_superseded(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-b64-aaa", b"\x01\x02\x03")
|
||||
active = await store.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == kid
|
||||
assert active["status"] == "active"
|
||||
await store.set_issuer_key_superseded(kid)
|
||||
assert await store.get_active_signing_key_row() is None
|
||||
archived = await store.get_public_key_row(kid)
|
||||
assert archived is not None
|
||||
assert archived["status"] == "superseded"
|
||||
assert archived["public_key"] == "pub-b64-aaa"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_public_key_row_finds_superseded(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
kid = f"key-{uuid.uuid4().hex[:12]}"
|
||||
await store.init_issuer_key(kid, "pub-b64-bbb", b"\x04\x05")
|
||||
await store.set_issuer_key_superseded(kid)
|
||||
row = await store.get_public_key_row(kid)
|
||||
assert row is not None
|
||||
assert row["status"] == "superseded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_insert_and_get(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
oid = await store.insert_operator("ed", "$argon2id$h", "Ed")
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(
|
||||
cid, "learner-1", '{"id":"vc-x"}', "sig-b64",
|
||||
operator_id=oid,
|
||||
)
|
||||
row = await store.get_credential(cid)
|
||||
assert row is not None
|
||||
assert row["id"] == cid
|
||||
assert row["learner_ref"] == "learner-1"
|
||||
assert row["signature_b64"] == "sig-b64"
|
||||
assert row["status"] == "active"
|
||||
assert row["vc_payload_json"] == '{"id":"vc-x"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_status_revoke(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
cid = f"vc-{uuid.uuid4().hex[:16]}"
|
||||
await store.insert_credential(cid, "learner-2", "{}", "sig")
|
||||
await store.set_credential_status(cid, "revoked")
|
||||
row = await store.get_credential(cid)
|
||||
assert row["status"] == "revoked"
|
||||
assert row["revoked_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_gate_event(pool: asyncpg.Pool):
|
||||
store = PgStore(pool)
|
||||
eid = await store.record_gate_event(
|
||||
"learner-3", "cs-refund", scenario_id="sc-1",
|
||||
gate_outcome="open", rubric_scores_jsonb=[{"c": "x", "l": 4}],
|
||||
)
|
||||
assert eid is not None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM mastery_gate_events WHERE id = $1", eid
|
||||
)
|
||||
assert row is not None
|
||||
assert row["learner_ref"] == "learner-3"
|
||||
assert row["gate_outcome"] == "open"
|
||||
assert row["source"] == "sync"
|
||||
@@ -0,0 +1,354 @@
|
||||
"""VC migration unit tests (TASK-04-05) — mocked stores.
|
||||
|
||||
Covers:
|
||||
- Migration script: v0.3 key archived as superseded, fresh key active,
|
||||
idempotent re-run.
|
||||
- G-027 first-boot path: no v0.3 active key in SQLite → skip archive,
|
||||
generate fresh key only.
|
||||
- Verification with PgStore: v0.4 VC (active key) verifies ✓; v0.3 VC
|
||||
(superseded key) verifies ✓ (R-VC-MIG-01 — the critical test).
|
||||
- get_public_key_row finds superseded key by id (verification fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import nacl.signing
|
||||
import pytest
|
||||
|
||||
from server.vc.issuer import build_vc_payload, sign, extract_key_id, verify_proof
|
||||
from server.vc.issuer_keys import _load_root_key
|
||||
from server.vc.migrate_keys import migrate_issuer_keys
|
||||
from server.vc.verification import verify_credential
|
||||
|
||||
|
||||
def _b64(b: bytes) -> str:
|
||||
return base64.b64encode(b).decode("ascii")
|
||||
|
||||
|
||||
# ── Migration script ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_sqlite_store_with_v03_key(key_id="v03-key-aaa", public_key="pub-v03-b64"):
|
||||
store = MagicMock()
|
||||
store.get_active_signing_key_row = AsyncMock(
|
||||
return_value={"id": key_id, "public_key": public_key, "private_key_enc": b"\x01"}
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def _make_pg_store():
|
||||
store = MagicMock()
|
||||
store._rows = {} # key_id -> row dict
|
||||
store._active = None
|
||||
|
||||
async def init_issuer_key(key_id, public_key, private_key_enc):
|
||||
status = "active"
|
||||
if key_id in store._rows:
|
||||
# ON CONFLICT DO NOTHING — don't overwrite
|
||||
return
|
||||
store._rows[key_id] = {
|
||||
"id": key_id, "public_key": public_key,
|
||||
"private_key_enc": private_key_enc, "status": status,
|
||||
}
|
||||
store._active = key_id
|
||||
|
||||
async def get_active_signing_key_row():
|
||||
if store._active is None:
|
||||
return None
|
||||
return dict(store._rows[store._active])
|
||||
|
||||
async def get_public_key_row(key_id):
|
||||
r = store._rows.get(key_id)
|
||||
return dict(r) if r else None
|
||||
|
||||
async def set_issuer_key_superseded(key_id):
|
||||
if key_id in store._rows:
|
||||
store._rows[key_id]["status"] = "superseded"
|
||||
if store._active == key_id:
|
||||
store._active = None
|
||||
|
||||
store.init_issuer_key = init_issuer_key
|
||||
store.get_active_signing_key_row = get_active_signing_key_row
|
||||
store.get_public_key_row = get_public_key_row
|
||||
store.set_issuer_key_superseded = set_issuer_key_superseded
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_archives_v03_and_generates_fresh():
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] == "v03-key-aaa"
|
||||
assert result["new_key_id"] is not None
|
||||
# v0.3 key is superseded in Postgres
|
||||
archived = await pg.get_public_key_row("v03-key-aaa")
|
||||
assert archived["status"] == "superseded"
|
||||
assert archived["public_key"] == "pub-v03-b64"
|
||||
# fresh key is active
|
||||
active = await pg.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
assert active["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent_when_active_key_exists():
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
await migrate_issuer_keys(sqlite, pg, root)
|
||||
# second run — no-op
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] is None
|
||||
assert result["new_key_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_g027_first_boot_no_v03_key():
|
||||
# G-027: no v0.3 active key in SQLite → skip archive, fresh key only.
|
||||
sqlite = MagicMock()
|
||||
sqlite.get_active_signing_key_row = AsyncMock(return_value=None)
|
||||
pg = _make_pg_store()
|
||||
root = _load_root_key()
|
||||
result = await migrate_issuer_keys(sqlite, pg, root)
|
||||
assert result["archived_key_id"] is None # nothing archived
|
||||
assert result["new_key_id"] is not None # fresh key generated
|
||||
active = await pg.get_active_signing_key_row()
|
||||
assert active is not None
|
||||
assert active["id"] == result["new_key_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_archives_before_activating_r_vc_mig_01():
|
||||
# R-VC-MIG-01: the v0.3 public key MUST be archived BEFORE the fresh
|
||||
# key is activated. We verify the ordering by checking that at no point
|
||||
# is there an active v0.4 key without the v0.3 key being present (as
|
||||
# superseded) in Postgres.
|
||||
sqlite = _make_sqlite_store_with_v03_key()
|
||||
pg = _make_pg_store()
|
||||
# Instrument init_issuer_key to assert the archive happened first.
|
||||
order = []
|
||||
orig_init = pg.init_issuer_key
|
||||
|
||||
async def tracing_init(key_id, public_key, private_key_enc):
|
||||
order.append(key_id)
|
||||
await orig_init(key_id, public_key, private_key_enc)
|
||||
|
||||
pg.init_issuer_key = tracing_init
|
||||
orig_super = pg.set_issuer_key_superseded
|
||||
|
||||
async def tracing_super(key_id):
|
||||
order.append(f"supersede:{key_id}")
|
||||
await orig_super(key_id)
|
||||
|
||||
pg.set_issuer_key_superseded = tracing_super
|
||||
root = _load_root_key()
|
||||
await migrate_issuer_keys(sqlite, pg, root)
|
||||
# The v0.3 key (v03-key-aaa) is init'd then superseded BEFORE the fresh
|
||||
# key is init'd (active).
|
||||
v03_idx = order.index("v03-key-aaa")
|
||||
sup_idx = order.index("supersede:v03-key-aaa")
|
||||
fresh_idx = [i for i, k in enumerate(order) if k not in ("v03-key-aaa",) and not k.startswith("supersede:")][0]
|
||||
assert v03_idx < sup_idx < fresh_idx
|
||||
|
||||
|
||||
# ── Verification with PgStore (R-VC-MIG-01 critical test) ───────────────────
|
||||
|
||||
|
||||
def _make_credential_store(rows: dict[str, dict]):
|
||||
store = MagicMock()
|
||||
async def get_credential(cid):
|
||||
return rows.get(cid)
|
||||
store.get_credential = get_credential
|
||||
# status list store (SQLite) — empty
|
||||
sl_store = MagicMock()
|
||||
sl_store.get_status_list = AsyncMock(return_value=None)
|
||||
sl_store.upsert_status_list = AsyncMock()
|
||||
return store, sl_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v03_vc_verifies_against_superseded_key_in_pg():
|
||||
"""R-VC-MIG-01 critical: a v0.3 VC verifies against a Postgres store
|
||||
with the v0.3 public key archived as superseded."""
|
||||
# Generate a v0.3 keypair + credential.
|
||||
sk_v03 = nacl.signing.SigningKey.generate()
|
||||
vk_v03 = sk_v03.verify_key
|
||||
pub_v03_b64 = _b64(bytes(vk_v03))
|
||||
v03_key_id = "v03-key-real"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-1", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-v03-real", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk_v03, v03_key_id)
|
||||
cred_row = {
|
||||
"id": "vc-v03-real", "learner_ref": "learner-1",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
# Postgres store has the v0.3 key as superseded + the credential.
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(v03_key_id, pub_v03_b64, b"")
|
||||
await pg.set_issuer_key_superseded(v03_key_id)
|
||||
pg._rows[v03_key_id]["public_key"] = pub_v03_b64
|
||||
# add credential to pg via a separate mock get_credential
|
||||
async def get_cred(cid):
|
||||
if cid == "vc-v03-real":
|
||||
return cred_row
|
||||
return None
|
||||
pg.get_credential = get_cred
|
||||
# SQLite status-list store (empty → not revoked)
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
sqlite_sl.upsert_status_list = AsyncMock()
|
||||
result = await verify_credential(
|
||||
pg, "vc-v03-real", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True, "v0.3 VC must verify against archived superseded key (R-VC-MIG-01)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v04_vc_verifies_against_active_key_in_pg():
|
||||
sk_v04 = nacl.signing.SigningKey.generate()
|
||||
vk_v04 = sk_v04.verify_key
|
||||
pub_v04_b64 = _b64(bytes(vk_v04))
|
||||
v04_key_id = "v04-key-fresh"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-2", path="cs-refund",
|
||||
scenarios_passed=["sc-1", "sc-2"], rubric_score=4.5, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-v04-fresh", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk_v04, v04_key_id)
|
||||
cred_row = {
|
||||
"id": "vc-v04-fresh", "learner_ref": "learner-2",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(v04_key_id, pub_v04_b64, b"\x09")
|
||||
pg._rows[v04_key_id]["public_key"] = pub_v04_b64
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-v04-fresh" else None
|
||||
pg.get_credential = get_cred
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
pg, "vc-v04-fresh", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tampered_v03_vc_fails_verification():
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-tamper"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-t", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-tamper", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
# Tamper: change the rubricScore after signing.
|
||||
secured["credentialSubject"]["rubricScore"] = 1.0
|
||||
cred_row = {
|
||||
"id": "vc-tamper", "learner_ref": "learner-t",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(key_id, pub_b64, b"")
|
||||
pg._rows[key_id]["public_key"] = pub_b64
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-tamper" else None
|
||||
pg.get_credential = get_cred
|
||||
sqlite_sl = MagicMock()
|
||||
sqlite_sl.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
pg, "vc-tamper", pg_store=pg, sqlite_store=sqlite_sl
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verification_fallback_sqlite_when_pg_missing_credential():
|
||||
"""G-011(b): credential not in Postgres → fall back to SQLite."""
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-fallback"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-fb", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-fallback", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
sqlite_cred_row = {
|
||||
"id": "vc-fallback", "learner_ref": "learner-fb",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
# Postgres has the key but NOT the credential.
|
||||
pg = _make_pg_store()
|
||||
await pg.init_issuer_key(key_id, pub_b64, b"")
|
||||
pg._rows[key_id]["public_key"] = pub_b64
|
||||
async def pg_get_cred(cid):
|
||||
return None # not in Postgres
|
||||
pg.get_credential = pg_get_cred
|
||||
# SQLite has the credential + the key (v0.3 path).
|
||||
sqlite = MagicMock()
|
||||
async def sqlite_get_cred(cid):
|
||||
return sqlite_cred_row if cid == "vc-fallback" else None
|
||||
sqlite.get_credential = sqlite_get_cred
|
||||
sqlite.get_public_key_row = AsyncMock(return_value={
|
||||
"id": key_id, "public_key": pub_b64, "status": "active"
|
||||
})
|
||||
sqlite.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(
|
||||
sqlite, "vc-fallback", pg_store=pg, sqlite_store=sqlite
|
||||
)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verification_sqlite_only_when_no_pg():
|
||||
"""G-011(c): no Postgres → full v0.3 SQLite path."""
|
||||
sk = nacl.signing.SigningKey.generate()
|
||||
vk = sk.verify_key
|
||||
pub_b64 = _b64(bytes(vk))
|
||||
key_id = "key-sqlite-only"
|
||||
payload = build_vc_payload(
|
||||
learner_ref="learner-so", path="cs-refund",
|
||||
scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6,
|
||||
evidence=[], credential_id="vc-so", status_list_index=None,
|
||||
)
|
||||
secured, sig_b64 = sign(payload, sk, key_id)
|
||||
cred_row = {
|
||||
"id": "vc-so", "learner_ref": "learner-so",
|
||||
"vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")),
|
||||
"signature_b64": sig_b64, "status": "active",
|
||||
}
|
||||
sqlite = MagicMock()
|
||||
async def get_cred(cid):
|
||||
return cred_row if cid == "vc-so" else None
|
||||
sqlite.get_credential = get_cred
|
||||
sqlite.get_public_key_row = AsyncMock(return_value={
|
||||
"id": key_id, "public_key": pub_b64, "status": "active"
|
||||
})
|
||||
sqlite.get_status_list = AsyncMock(return_value=None)
|
||||
result = await verify_credential(sqlite, "vc-so", pg_store=None, sqlite_store=sqlite)
|
||||
assert result is not None
|
||||
assert result["valid"] is True
|
||||
Reference in New Issue
Block a user