This repository has been archived on 2026-09-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
praxis/.ciagent/PLAN-v0.4-operator-tier.md
Praxis CI 6ab40c6f25 docs(milestone): merge phase/00 pre-execution → milestone/v0.4-operator-tier
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---
2026-08-04 00:39:53 +00:00

72 KiB
Raw Permalink Blame History

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).
  • 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 /logoutDepends(current_operator) — clears request.session, returns {ok: true}. (Stateless — client also clears cookie; D-056.)
    • GET /meDepends(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/loginLogin component
    • /operator/dashboardDashboard 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)