6ab40c6f25
Phase 0 complete — v0.4 operator tier pre-execution artifacts: - PROJECT.md (v0.4 scope validated, D-050..D-057) - REQUIREMENTS.md (8 active REQs: REQ-MT-01/02, REQ-AUTH-01, REQ-DASH-01 + 4 NFRs) - ARCHITECTURE.md (operator Postgres + auth + dashboard + aggregation + VC migration) - PERSONAS.md (6 active personas — frontend + devops reactivated) - RESEARCH-v0.4-operator-tier.md (7 domains, 20 risks, confidence 0.70-0.95) - PLAN-v0.4-operator-tier.md (2 execution phases, 10 slices, 52 tasks, 8/8 REQ) - GRILL-v0.4.md (proceed-with-conditions, 6 MUST binding decisions) ---ci--- project: praxis phase: 0 milestone: v0.4 status: complete requirements: covered: [] partial: [] ---/ci---
772 lines
72 KiB
Markdown
772 lines
72 KiB
Markdown
# 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) | |