Files
praxis/.ciagent/ARCHITECTURE.md
T
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

43 KiB
Raw Blame History

Praxis — Architecture (Research-Refined)

Status: Research-refined (Phase 0 RESEARCH stage). Informed by .ciagent/RESEARCH.md — web-verified vendor catalogs, GitHub metadata, official docs.

High-Level Topology

Three-tier architecture per PRD §7:

┌──────────────────────────────────────────────────────────┐
│  Client (Android, iOS, Web, WhatsApp, USSD)              │
│  - Voice I/O, cached scenarios, offline scenarios        │
└────────────────┬─────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────┐
│  Edge / Region (per market)                              │
│  - ASR + TTS (low-latency, local accent models)          │
│  - Scenario runtime + role orchestration                 │
│  - Caching layer                                         │
└────────────────┬─────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────┐
│  Core Platform                                           │
│  - LLM tutor (long-context, persona-aware, safety-tuned) │
│  - Scenario Authoring & Tagging                          │
│  - Mastery Rubric Engine                                 │
│  - User state, progress, credentialing                  │
│  - Analytics                                             │
└──────────────────────────────────────────────────────────┘

LLM Foundation (D-003, D-020 — research-verified)

Open-weights models hosted via Ollama Cloud direct API (https://ollama.com/api/chat + OLLAMA_API_KEY) — no local daemon required for v0.1.

Model Verified status Role Context Mode
gemma4:cloud Real, current (256K ctx, Text+Image, "Low Usage" tier) Role-play fast path / persona turns 256K standard
deepseek-v4-flash:cloud Real, current (1M ctx, 284B MoE / 13B active, "Medium Usage" tier) Coaching debrief + scenario-branch decisions 1M no-think (latency); think/max-think reserved for offline analysis

Post-pilot cost-reduction path: self-host gemma4:e4b (edge, native audio modality, 9.6GB) on partner hardware for the ≤$3/learner/month target. Architecture must keep the model-call layer swappable (D-020).

Notable future option: gemma4:e2b/e4b support Text+Image+Audio input — potential future Ollama-hosted ASR for cost reduction (not v0.1; dedicated Deepgram is lower-latency + more accent-robust).

v0.1 Component Map (research-refined minimal viable voice loop)

Client: React + WebRTC (Pipecat client SDK)
   │  audio in/out (WebRTC, UDP, sub-50ms)
   ▼
Pipecat server (Python)
   ├─ VAD: Silero
   ├─ STT: Deepgram Nova-3 (cloud, streaming, WebSocket)
   ├─ LLM: Ollama Cloud direct API (https://ollama.com/api/chat)
   │     ├─ gemma4:cloud         (role-play fast path)
   │     └─ deepseek-v4-flash:cloud (debrief, no-think mode)
   ├─ TTS: Cartesia Sonic (cloud, ~120ms)  ← behind interface
   │     └─ fallback: Piper (self-hosted, ~80ms)  ← R4 mitigation
   ├─ Scenario runtime: Pipecat Flows + YAML→Pydantic scenarios
   ├─ Guardrail layer: pluggable interface (v0.1: Customer Service ruleset)
   └─ Learner state: SQLite (praxis.db, single-learner, no auth)

v0.1 deliberately excludes: edge-region split, multi-market deployment, caching layer, scenario authoring tools, mastery engine, credentialing, analytics, WhatsApp/USSD surfaces.

Latency Budget (< 600ms end-to-end — research-revised)

Segment Budget Source / note
Client capture + WebRTC uplink ~50ms WebRTC UDP, Canada region
ASR (Deepgram Nova-3 first partial) ~250ms Vendor claim; R1: measure in Phase 1
LLM first token (gemma4:cloud direct API) ~200ms R3: measure in Phase 1
TTS first audio (Cartesia Sonic) ~120ms Vendor/leaderboard; R2: measure in Phase 1
WebRTC downlink + playback ~50ms
Total (all-cloud target) ~670ms ⚠️ Marginally over 600ms
Total (Piper TTS mitigation) ~550ms R4: pre-stage Piper self-hosted on pilot server

R4 — single biggest v0.1 technical risk: the all-cloud three-hop path likely lands ~670ms. The TTS service MUST sit behind an interface (D-014) and Piper-on-pilot-server MUST be pre-staged as the likely production v0.1 TTS. This is the first Phase 1 spike.

Critical Risks to Engineer Around

  1. Accent robustness — even a great LLM fails if ASR mishears the learner. Canadian English/French accents, code-switching.
  2. Hallucinated advice in safety-sensitive domains — health, electrical. Domain-specific guardrails, escalation, disclaimers. (v0.1 uses Customer Service path, lower risk, but architecture must support the guardrail layer.)
  3. Cost per learner per month must stay ≤ $3 in target markets. v0.1 Canada pilot relaxes this, but architecture must not bake in assumptions that violate it.
  4. Ollama model availability / cost:cloud variants imply hosted inference; verify pricing and rate limits at research phase.

Deployment (v0.1)

  • Single-region pilot (Canada)
  • LLM via Ollama Cloud direct API (no local daemon)
  • ASR via Deepgram cloud (North American endpoint)
  • TTS: Cartesia cloud (quality benchmark) + Piper self-hosted on pilot server (R4 latency mitigation, likely production v0.1)
  • Pipecat server on single pilot host (Python)
  • Client: React web app (Pipecat client SDK, WebRTC transport)
  • SQLite local file (praxis.db) on pilot host

Open Architecture Questions (resolved by research)

Question (from initial ARCHITECTURE.md) Resolution
Client framework React + WebRTC via Pipecat client SDK (D-015)
Streaming transport WebRTC (Pipecat); WebSocket dev fallback (D-016)
ASR/TTS provider Deepgram Nova-3 (ASR, D-013); Cartesia Sonic + Piper fallback (TTS, D-014)
Learner state store SQLite confirmed (D-007 → 0.90)
Ollama deployment Ollama Cloud direct API (D-020)
Scenario definition format YAML DSL → Pydantic → Pipecat Flows (D-018)

Open Architecture Questions (remaining for PLAN stage)

  • R1-R4 latency spikes (see Risks below) — first Phase 1 tasks
  • Pipecat Flows schema mapping for the one branch point (escalate vs accept) in the refund scenario
  • Guardrail ruleset concrete implementation (D-019) — system-prompt template + output filter
  • SQLite schema for session log + progress + scenario state
  • OLLAMA_API_KEY + DEEPGRAM_API_KEY + CARTESIA_API_KEY secret management (extend config.secrets.scopes)

v0.2 Deployment Architecture (Proxmox LXC + Docker-in-LXC)

Status: Research-refined (v0.2 RESEARCH stage). Informed by .ciagent/RESEARCH.md — Proxmox VE wiki, coreci script analysis, Docker/systemd ecosystem. Decisions: D-021 (LXC deploy), D-022 (Docker in LXC, nesting=1), D-023 (FastAPI StaticFiles), D-024 (infra-only keys), D-025/D-029 (build inside CT), D-026 (coreci secrets), D-027 (auto VMID), D-028 (Docker via apt), D-030 (vmbr0 DHCP).

Docker-in-LXC Topology

                    ┌─────────────────────────────────────────────────────────┐
                    │  Proxmox VE Host (PROXMOX_NODE)                          │
                    │  (D-026: secrets sourced from ~/coreci/.ciagent/         │
                    │   .env.secrets + praxis .ciagent/.env.secrets)           │
                    │                                                          │
                    │  Deploy operator runs:                                   │
                    │    scripts/proxmox/lxc-deploy.sh                         │
                    │     ├─ stage-snippet.sh  (upload hookscript to snippets) │
                    │     ├─ lxc-clone.sh      (POST /nodes/{node}/lxc)        │
                    │     ├─ lxc-config.sh     (PUT /config + SSH lxc.env)     │
                    │     ├─ lxc-start.sh      (POST /status/start)            │
                    │     └─ health-check.sh   (poll /health:8789)             │
                    │                                                          │
                    │  ┌────────────────────────────────────────────────────┐  │
                    │  │  LXC Container (VMID: auto via pve_nextid, D-027)  │  │
                    │  │  hostname: praxis                                   │  │
                    │  │  memory: 4096MB   rootfs: 16GB   (bumped from 2/8) │  │
                    │  │  features: nesting=1                                │  │
                    │  │  net0: bridge=vmbr0, ip=dhcp  (D-030)              │  │
                    │  │  hookscript: local:snippets/praxis-firstboot.sh     │  │
                    │  │  lxc.environment: GITEA_TOKEN, DEEPGRAM_API_KEY,   │  │
                    │  │    PRAXIS_PORT=8789, PRAXIS_HOST=0.0.0.0, ...       │  │
                    │  │                                                    │  │
                    │  │  post-start hook (runs on PVE host, pct exec → CT): │  │
                    │  │   1. apt install docker.io docker-compose-v2 git    │  │
                    │  │   2. git clone praxis repo → /opt/praxis            │  │
                    │  │   3. install-service.sh (user + env + systemd unit) │  │
                    │  │   4. systemctl start praxis                         │  │
                    │  │      → ExecStartPre: docker compose build           │  │
                    │  │      → ExecStart: docker compose up (foreground)    │  │
                    │  │                                                    │  │
                    │  │  ┌──────────────────────────────────────────────┐  │  │
                    │  │  │  Docker daemon                                │  │  │
                    │  │  │  ┌────────────────────────────────────────┐  │  │  │
                    │  │  │  │  praxis container                       │  │  │  │
                    │  │  │  │  image: python:3.12-slim + deps + dist │  │  │  │
                    │  │  │  │  ports: 8789:8789                      │  │  │  │
                    │  │  │  │  env_file: /etc/praxis/server.env      │  │  │  │
                    │  │  │  │  volume: praxis-db → /app/data         │  │  │  │
                    │  │  │  │  restart: unless-stopped               │  │  │  │
                    │  │  │  │                                         │  │  │  │
                    │  │  │  │  uvicorn 0.0.0.0:8789                  │  │  │  │
                    │  │  │  │  ├─ GET  /health       (FastAPI)       │  │  │  │
                    │  │  │  │  ├─ POST /pipecat/webrtc (FastAPI)     │  │  │  │
                    │  │  │  │  └─ GET  / ... (StaticFiles client/dist)│  │  │  │
                    │  │  │  └────────────────────────────────────────┘  │  │  │
                    │  │  └──────────────────────────────────────────────┘  │  │
                    │  └────────────────────────────────────────────────────┘  │
                    │           │                                              │
                    │       vmbr0 (bridge) ──── DHCP ──── CT eth0              │
                    └───────────┬──────────────────────────────────────────────┘
                                │  <ct-bridge-ip>:8789
                    ┌───────────▼───────────────────────┐
                    │  Operator / Learner (browser)     │
                    │  http://<ct-ip>:8789               │
                    │  (direct access, no proxy/TLS)    │
                    └───────────────────────────────────┘

Image Build Pipeline (Multi-stage Dockerfile)

Two-stage build, Debian-slim bases, python -m server entrypoint:

Stage 1: client-builder (node:22-slim)
  COPY client/package.json client/package-lock.json
  RUN npm ci                          ← cached unless deps change
  COPY client/
  RUN npm run build                   ← tsc -b && vite build → client/dist/

Stage 2: server (python:3.12-slim)
  RUN apt-get install gcc g++ libasound2-dev   ← only if source compilation
  COPY pyproject.toml
  RUN pip install --no-cache-dir .   ← pipecat-ai[deepgram,cartesia,piper,webrtc] + deps
  COPY server/ scenarios/ db/
  COPY --from=client-builder /app/client/dist ./client/dist
  EXPOSE 8789
  CMD ["python", "-m", "server"]      ← calls uvicorn.run(app, host=HOST, port=PORT)

Why Debian-slim (not Alpine): numpy + pipecat-ai native extensions compile against glibc; musl wheels are less universally available. The ~50MB size saving of Alpine isn't worth the compatibility risk.

Why python -m server (not uvicorn server.__main__:app): Matches the existing entrypoint (server/__main__.py:main()) which reads PRAXIS_HOST/PRAXIS_PORT from env and calls uvicorn.run(...). Single uvicorn process is correct for WebRTC/WebSocket (long-lived connections, not request-per-response).

Secret Injection Chain

~/coreci/.ciagent/.env.secrets          praxis/.ciagent/.env.secrets
  PROXMOX_API_URL                         GITEA_TOKEN
  PROXMOX_API_TOKEN                       DEEPGRAM_API_KEY
  PROXMOX_NODE                            CARTESIA_API_KEY (empty, D-024)
  PROXMOX_STORAGE                         OLLAMA_API_KEY (empty, D-024)
  PROXMOX_TEMPLATE_VOLID
  PROXMOX_TLS_SKIP_VERIFY
           │                    │
           └────────┬───────────┘
                    ▼
          lxc-deploy.sh sources both
                    │
                    ▼
          lxc-config.sh (SSH to PVE host)
          writes /etc/pve/lxc/<vmid>.conf:
            lxc.environment: GITEA_TOKEN=<token>
            lxc.environment: DEEPGRAM_API_KEY=<key>
            lxc.environment: PRAXIS_PORT=8789
            lxc.environment: PRAXIS_HOST=0.0.0.0
            lxc.environment: OLLAMA_BASE_URL=https://ollama.com/v1
            ...
                    │
                    ▼  (CT boots; systemd PID 1 has these env vars)
          firstboot-hook.sh → pct exec install-service.sh
                    │
                    ▼
          /etc/praxis/server.env  (root:praxis, chmod 0640)
            GITEA_TOKEN=<token>
            DEEPGRAM_API_KEY=<key>
            PRAXIS_PORT=8789
            ...
                    │
                    ▼
          praxis.service (EnvironmentFile=/etc/praxis/server.env)
          → ExecStart: docker compose up
                    │
                    ▼
          docker-compose.yml (env_file: /etc/praxis/server.env)
                    │
                    ▼
          Docker container (os.environ)
          → server/__main__.py reads PRAXIS_HOST, PRAXIS_PORT, DEEPGRAM_API_KEY, ...

.gitignore coverage: .env, .env.secrets, .env.* are all gitignored in praxis (verified). No secrets are committed.

CT Resource Sizing

Resource Coreci default Praxis v0.2 Rationale
Memory 2048 MB 4096 MB Docker daemon (~200MB) + build peak (~1.2GB pip) + runtime (~500MB) + headroom
Rootfs 8 GB 16 GB Docker engine (~400MB) + build layers (~1.6GB) + final image (~1GB) + repo + apt + headroom
CPU cores (default) 2 Sufficient for build + single-learner runtime
Swap (default) 0 LXC swap is host swap; not needed for pilot

Configured via lxc-clone.sh (memory=${PROXMOX_MEMORY_MB:-4096}, rootfs=${storage}:16) or env vars in the deploy script.

Health-Check Path

lxc-deploy.sh
  └─ health-check.sh <vmid>
       │
       ├─ PRAXIS_HEALTH_URL set? → use directly
       │
       └─ else: pve_get /nodes/{node}/lxc/{vmid}/interfaces
            │
            ├─ jq: .[] | select(.name != "lo") | (.inet? // .ip? // empty)
            │  (NOT .hwaddr — P18 bug fix from coreci)
            │
            └─ health_url = http://<bridge-ip>:8789/health
                 │
                 └─ poll curl -fsS --connect-timeout 2 $health_url
                    for PRAXIS_HEALTH_TIMEOUT seconds (default 300s)

Timing: CT start → DHCP lease (~5s) → firstboot hook: apt install Docker (~90s) + git clone (~10s) + install-service + systemctl start (~120s: docker compose build + up) → uvicorn binds :8789 → health passes. Total: ~3-5 min. PRAXIS_HEALTH_TIMEOUT=300 (5 min) covers this with margin.

Firstboot Hook Sequence

Proxmox invokes hookscript at post-start phase (runs on PVE HOST):
  $1 = VMID, $2 = phase

  Phase: post-start
    │
    ├─ 1. pct exec <vmid> -- apt-get install docker.io docker-compose-v2 git curl
    │     (D-028: Docker via apt inside CT)
    │
    ├─ 2. pct exec <vmid> -- git clone https://<GITEA_TOKEN>@git.cloudinit.dev/coreci/praxis.git /opt/praxis
    │     (D-029: clone inside CT, self-contained)
    │
    ├─ 3. pct exec <vmid> -- sh /opt/praxis/scripts/install-service.sh
    │     │
    │     ├─ create praxis user (useradd --system, add to docker group)
    │     ├─ mkdir /var/lib/praxis/data /var/log/praxis /etc/praxis
    │     ├─ write /etc/praxis/server.env from lxc.environment vars
    │     ├─ install praxis.service systemd unit
    │     └─ systemctl daemon-reload && enable praxis && restart praxis
    │           │
    │           ├─ ExecStartPre: docker compose build  (TimeoutStartSec=300)
    │           └─ ExecStart: docker compose up  (foreground, Type=simple)
    │
    └─ 4. (hook exits 0; external health-check.sh polls /health:8789)

Idempotency: The hook checks if praxis is already installed + active before re-running (mirrors coreci's pattern at firstboot-hook.sh:82). Re-running lxc-deploy.sh against a healthy CT skips the hook entirely (P16 idempotency via ct_exists + ct_running + health-check).

What's Reused Verbatim from CoreCI vs Adapted

Component Verdict Notes
api.sh Verbatim REQ-DEPLOY-03. PVE REST helpers are project-agnostic.
lxc-start.sh Verbatim POST /status/start is identical.
proxy/ct-exists.sh Verbatim Used by lxc-deploy.sh idempotency; no proxy dependency in the helper.
lxc-clone.sh Adapted hostname=praxis, memory=4096, rootfs=16, features=nesting=1 (kept).
lxc-config.sh Adapted hookscript=praxis-firstboot.sh, lxc.environment vars for praxis.
health-check.sh Adapted /health (not /healthz), port 8789, PRAXIS_* env names, timeout 300s.
rollback.sh Adapted Remove proxy backend-remove (no proxy in v0.2).
stage-snippet.sh Adapted SNIPPET_NAME=praxis-firstboot.sh, praxis repo raw URL.
timing.sh Adapted Metric prefix: praxis_deploy_timing_.
lxc-deploy.sh Adapted Remove PROXY_VMID/BACKEND_DOMAIN steps; VMID=auto (D-027).
firstboot-hook.sh Heavy adaptation Docker install + git clone + compose build/up (not host-fetch binary).
install-service.sh Heavy adaptation praxis user (docker group), /etc/praxis/server.env, praxis.service (docker compose up).

v0.2 Deployment Risks (from RESEARCH.md)

ID Risk Mitigation
R-DEPLOY-01 Pipecat wheel missing → source compilation OOM Pre-test docker build locally; bump memory if needed
R-DEPLOY-02 systemd TimeoutStartSec insufficient for build+up Set 300-600s or split build into separate oneshot service
R-DEPLOY-03 CT can't reach Gitea/apt mirrors Validate internet access; fallback to host-clone+pct-push (D-025 hybrid)
R-DEPLOY-04 Docker-in-LXC on ZFS rootfs Check storage type; use local (directory) if ZFS
R-DEPLOY-05 journald log flooding from compose up Log rotation or StandardOutput=null for pilot
R-DEPLOY-06 First-boot build > 5 min (NFR breach) Pre-build on host + docker load fallback

v0.3 Architecture (Mastery Scoring + Competency Rubrics + VC)

Status: Released (v0.1.5, merged to main). Research-refined (v0.3 RESEARCH stage). Decisions: D-031 (operator tier, overrides D-007 for operator surface), D-032 (mastery gate), D-033 (W3C VC 2.0), D-034 (k-anonymity), D-035 (IRT 1PL), D-036 (scenario library), D-037 (path structure), D-038..D-049 (clarify). v0.4 note: The operator-tier sections below (auth, cohort aggregation, Postgres) were anticipatory in v0.3 and are now confirmed/refined in the v0.4 section (§ v0.4 Operator-Tier Architecture). The v0.3 mastery/VC/IRT sections are released and unchanged.

Hybrid Storage Topology (D-031 — confirmed in v0.4)

Learner-local state stays in SQLite (D-007 preserved); operator-tier state goes to a new Postgres service. The two stores never share a session and never join via cross-DB FKs (learner_ref is an opaque string in Postgres).

LXC Container (v0.2 4GB → v0.4 6GB)
  Docker daemon
  ├── praxis container (v0.2 + v0.3 + v0.4 additions)
  │     ├─ uvicorn 0.0.0.0:8789
  │     ├─ GET  /health                 (v0.2)
  │     ├─ POST /pipecat/webrtc          (v0.2)
  │     ├─ /vc/verify/<id>              (v0.3 — public, unauthenticated)
  │     ├─ /api/operator/*              (v0.4 — operator auth gate — D-057)
  │     ├─ GET  / ... StaticFiles + SPA fallback (v0.2 + v0.4 SPA fallback for /operator/*)
  │     ├─ SQLite /app/data/praxis.db   (v0.2 + v0.3 tables: learner_ability, mastery_progress, issuer_keys, issued_credentials, status_lists)
  │     └─ Postgres pool (asyncpg)      (v0.4 — operator tier — D-050)
  │
  └── postgres container (v0.4 — D-040)
        ├─ postgres:16-slim
        ├─ pgdata named volume
        ├─ pgbackups named volume (nightly pg_dump — D-055)
        ├─ praxis-net internal Docker network only (no published port)
        ├─ pg_isready healthcheck
        └─ Tables: operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys

v0.3 Component Map (mastery + VC + IRT — released, unchanged)

Pipecat server (Python)
   ├─ ... (v0.2 voice loop unchanged) ...
   ├─ Rubric engine (server/mastery/)
   │     ├─ rubric_loader.py    (rubrics/<skill>.yaml → Pydantic)
   │     ├─ rubric_scorer.py    (rule-based: signals → 1-5, deterministic — REQ-NFR-MAST-01)
   │     ├─ evidence_extractor.py (LLM extracts quotes+signals, temp=0, JSON-schema)
   │     └─ mastery_score.py    (weighted mean + conjunctive floor + path gate)
   ├─ IRT engine (server/mastery/irt.py)
   │     ├─ 1PL/Rasch: P(success) = logistic(θ  b)
   │     ├─ Bayesian θ update per session (<100ms — REQ-NFR-IRT-01)
   │     └─ θ persisted to SQLite learner_ability (D-046)
   ├─ Scenario library (server/scenarios/library.py)
   │     ├─ scenarios/<path>/<id>.yaml + scenarios/index.yaml (semver, rubric_criteria mapping)
   │     └─ AI variation review pipeline (_pending/ → expert review → library)
   ├─ Path engine (server/paths/)
   │     ├─ paths/<slug>.yaml (6-week structure, mastery gates — D-037)
   │     └─ progression: current_week advances on gate-open (D-048)
   └─ VC issuer (server/vc/)
         ├─ issuer.py (Ed25519, pynacl + canonicaljson + base58, eddsa-jcs-2022)
         ├─ status_list.py (Bitstring Status List v1.0)
         ├─ verification.py (public GET /vc/verify/<id> — D-043)
         └─ issuer_keys.py (Ed25519 key lifecycle: active/superseded, encrypted at rest — D-042)

Mastery Scoring Flow (off the voice path)

Session end (server/session_recorder.py)
  │
  ├─ 1. Evidence extraction (LLM, async, off-voice-path)
  │     deepseek-v4-flash:cloud, temp=0
  │     Input: session turns + scenario.rubric_criteria
  │     Output (JSON-schema-validated): [{criterion_id, quote, signals: [...]}]
  │     Guard: fuzzy-match quote vs transcript → reject+re-extract on mismatch (R-MAST-02)
  │
  ├─ 2. Rule-based scoring (deterministic, no LLM — REQ-NFR-MAST-01)
  │     rubric_scorer.py: signals → 1-5 level per criterion
  │
  ├─ 3. Mastery Score (deterministic)
  │     scenario_score = weighted_mean(levels, weights)
  │     scenario_pass = scenario_score ≥ 3.0 AND every criterion ≥ 2 (conjunctive floor)
  │     path MasteryScore = mean(scenario_scores for passing scenarios only)
  │     path gate open = ≥3 distinct scenarios passed AND MasteryScore ≥ 3.5 (D-032)
  │
  ├─ 4. IRT θ update (deterministic, <100ms — REQ-NFR-IRT-01)
  │     θ ← θ + (outcome  P) × σ²/(σ² + 1); persist to SQLite learner_ability (D-046)
  │
  ├─ 5. Progression (deterministic)
  │     gate open → advance current_week (D-048)
  │     week-final gate open → issue VC (REQ-MAST-03)
  │     record mastery_gate_event in Postgres (REQ-NFR-MAST-02)
  │
  └─ 6. Cohort aggregation (async, k-anonymized)
        on-session-end hook → upsert k-anonymized aggregate to Postgres (D-045)
        nightly reconciliation reconciles 7-day windows

VC Issuance + Verification Flow

Mastery gate opens (week-final)
  ├─ issuer.py: build payload {scenariosPassed, rubricScore, completedWeeks:6, evidence, validUntil:+3y}
  │             canonicalize (JCS) → sign Ed25519 → store in Postgres issued_credentials
  └─ Verification (third party): GET /vc/verify/<id> → fetch pubkey from verificationMethod URL
        → validate Ed25519 sig → check Status List → return {valid, status, issuer, mastery, verifiedAt}

Postgres Schema (operator tier — D-040)

Tables: operators (id, username, password_hash argon2id), issued_credentials (id, learner_ref opaque-string, vc_payload_json, signature_b64, status, issued_at), mastery_gate_events (id, learner_ref, path, week, scenarios_passed_json, rubric_scores_json, gate_opened_at — REQ-NFR-MAST-02 audit), cohort_aggregates (path, week, window_start/end, metric, value, cell_suppressed — k-anon via write-time suppression, weekly partitions), issuer_keys (id, public_key Multikey, private_key_enc, status active|superseded). gen_random_uuid() in PG16 (no extension). No cross-DB FKs.

CT Resource Sizing (v0.3 bump)

Resource v0.2 v0.3 Rationale
Memory 4096 MB 6144 MB Postgres ~1GB + praxis ~2GB + build headroom (R-MT-01)
Rootfs 16 GB 16 GB Postgres data on named volume, not rootfs
CPU 2 2-4 Postgres + praxis concurrent; 2 floor, 4 preferred

v0.3 Risks (from RESEARCH.md)

Top risks for PLAN: R-MAST-01 (N=3 thin for credential → label formative), R-AUTH-01 (Secure cookie + no-TLS pilot), R-MT-01 (Postgres resource contention), R-VC-01 (custom VC code ~200 LOC), R-MAST-02 (LLM hallucinated quotes → fuzzy-match guard), R-IRT-01 (cold-start θ → fall back to scenario.difficulty until ≥5 sessions). Full table in RESEARCH.md.

v0.4 note: R-AUTH-01 is resolved in v0.4 via config-driven PRAXIS_COOKIE_SECURE (see § v0.4 Operator-Tier Architecture). R-MT-01 is confirmed + mitigated (6GB CT, 03:00 CT nightly jobs).


v0.4 Operator-Tier Architecture (Cohort Dashboard + Auth + Postgres)

Status: Research-refined (v0.4 RESEARCH stage). Informed by .ciagent/RESEARCH-v0.4-operator-tier.md. Decisions: D-040 (Postgres 2nd service), D-050 (asyncpg pool + service DNS), D-051 (VC key migration), D-052 (operator bootstrap), D-053 (3 dashboard views), D-054 (async hook + nightly job), D-055 (pg_dump backup), D-056 (signed stateless cookies), D-057 (server-side auth enforcement). v0.3 audit: 2 anticipatory assumptions overturned (asyncpg min_size 2→1, weekly partitions→plain table), 1 refined (Secure cookie → config-driven). See RESEARCH-v0.4 § v0.3 Assumption Audit.

v0.4 Component Map (additions to v0.3)

Pipecat server (Python)
   ├─ ... (v0.2 voice loop + v0.3 mastery/VC/IRT unchanged) ...
   ├─ Operator auth NEW (server/auth/)  (v0.4 — D-041, D-056, D-057)
   │     ├─ Starlette SessionMiddleware (itsdangerous-signed cookie = HMAC-SHA256 — D-056)
   │     │     ├─ cookie: praxis_op, httpOnly, SameSite=Strict, max_age=28800 (8h)
   │     │     ├─ secure: config-driven PRAXIS_COOKIE_SECURE (default true; false for HTTP pilot — R-AUTH-01)
   │     │     └─ secret: PRAXIS_COOKIE_SECRET (≥32 bytes, from env)
   │     ├─ argon2id passwords (argon2-cffi PasswordHasher — defaults: t=3, m=64MiB, p=4 — exceeds OWASP)
   │     │     └─ check_needs_rehash() on login for param upgrades
   │     ├─ current_operator Depends (router-level dependencies=[...] on /api/operator/* — D-057)
   │     ├─ slowapi 5/min login rate-limit (in-memory, single-instance — D-041)
   │     └─ Auth middleware: 401 on missing/invalid/expired cookie for every /api/operator/* request
   ├─ Cohort aggregation NEW (server/cohort/)  (v0.4 — D-045, D-053, D-054)
   │     ├─ on-session-end hook (async fire-and-forget asyncio.Task — D-054)
   │     │     └─ chained after mastery flow; reads session outcome + rubric scores
   │     │        → k-anonymized aggregate upsert to Postgres (idempotent by window)
   │     ├─ nightly reconciliation job (in-process asyncio scheduler, 03:00 CT — D-054)
   │     │     └─ recomputes all 7-day windows; idempotent upsert by (path, metric, window_start)
   │     └─ k-anonymity suppression (write-time: COUNT(DISTINCT learner_ref) >= 10, else cell_suppressed=TRUE — D-034)
   ├─ Operator API NEW (server/operator/)  (v0.4 — D-053, D-057)
   │     ├─ POST /api/operator/login    (rate-limited 5/min, not auth-gated)
   │     ├─ POST /api/operator/logout   (auth-gated)
   │     ├─ GET  /api/operator/me       (auth-gated — React route guard)
   │     ├─ GET  /api/operator/cohort   (auth-gated — practice volume view)
   │     ├─ GET  /api/operator/mastery  (auth-gated — mastery progression view)
   │     ├─ GET  /api/operator/failure-patterns (auth-gated — failure patterns view)
   │     └─ GET/POST /api/operator/credentials (auth-gated — VC issuance log + revocation)
   └─ Postgres store NEW (db/pg_store.py + db/pg_migrations/)  (v0.4 — D-040, D-050)
         ├─ asyncpg pool (app.state.pg_pool via lifespan — D-050)
         │     └─ create_pool(min_size=1, max_size=10, command_timeout=10)
         ├─ pg_migrate.py (mirrors db/migrate.py pattern — ordered .sql, _pg_migrations table)
         └─ IssuerKeyStore protocol (PraxisStore + PgStore both implement — D-051 migration)

Client (React)
   ├─ ... (v0.2 voice UI unchanged at /) ...
   ├─ React Router NEW (react-router-dom@^7)  (v0.4 — D-044)
   │     └─ <BrowserRouter> wraps App.tsx; catch-all route serves voice UI at /
   └─ /operator/* NEW (v0.4 — cohort dashboard UI, auth-gated — D-044, D-053)
         ├─ /operator/login     (login form → POST /api/operator/login)
         ├─ /operator/dashboard (3 views: practice, mastery, failure-patterns)
         ├─ Auth gate: GET /api/operator/me on mount → redirect to /operator/login if 401
         ├─ Read-only tables + inline SVG sparklines (zero-dep, ~50 LOC)
         └─ Freshness indicator: "Last updated: Xh ago" (from cohort_aggregates.updated_at)

Postgres container (v0.4 — D-040)
   ├─ postgres:16-slim
   ├─ pgdata named volume (PGDATA=/var/lib/postgresql/data/pgdata)
   ├─ pgbackups named volume (nightly pg_dump -Fc — D-055)
   ├─ praxis-net bridge network (no published port, no internal: true)
   ├─ pg_isready healthcheck (10s interval, 5 retries, 5s timeout)
   ├─ depends_on: service_healthy on praxis
   └─ Tables: operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys

Postgres Service in docker-compose (D-040, D-050)

# Shape only — not for commit (v0.4 P1 implementation)
services:
  praxis:
    # ... existing v0.2 fields unchanged ...
    depends_on:
      postgres:
        condition: service_healthy
    networks: [praxis-net]

  postgres:
    image: postgres:16-slim
    restart: unless-stopped
    environment:
      POSTGRES_USER: praxis
      POSTGRES_PASSWORD: ${PRAXIS_PG_PASSWORD}
      POSTGRES_DB: praxis
      PGDATA: /var/lib/postgresql/data/pgdata
    env_file:
      - path: /etc/praxis/server.env
        required: false
    volumes:
      - pgdata:/var/lib/postgresql/data
      - pgbackups:/backups
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U praxis -d praxis"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [praxis-net]
    # NOTE: no `ports:` — not exposed to the LXC host bridge (D-040)

volumes:
  praxis-data:    # existing v0.2
    driver: local
  pgdata:         # NEW v0.4
    driver: local
  pgbackups:      # NEW v0.4
    driver: local

networks:
  praxis-net:     # NEW v0.4
    driver: bridge

Connection DSN (D-050): postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis (host = service name on praxis-net).

asyncpg Pool (D-050)

# Shape only — lifespan context manager
from contextlib import asynccontextmanager
import asyncpg

@asynccontextmanager
async def lifespan(app):
    app.state.pg_pool = await asyncpg.create_pool(
        dsn=os.environ["PRAXIS_PG_DSN"],
        min_size=1,        # D-050 (lower than v0.3 anticipatory min_size=2)
        max_size=10,
        command_timeout=10,
    )
    try:
        yield
    finally:
        await app.state.pg_pool.close()

app = FastAPI(lifespan=lifespan)

The PraxisStore (aiosqlite) keeps its current per-call connect pattern — pools are independent and must not be shared (different backends, different lifecycles).

Auth Middleware Flow (D-056, D-057)

Client request → /api/operator/cohort
  │
  ├─ Starlette SessionMiddleware
  │     ├─ reads praxis_op cookie
  │     ├─ validates HMAC-SHA256 signature (itsdangerous)
  │     ├─ checks max_age (8h expiry)
  │     └─ populates request.session = {operator_id, issued_at} (or empty if invalid)
  │
  ├─ current_operator Depends (router-level)
  │     ├─ reads request.session["operator_id"]
  │     ├─ if missing → 401 "not authenticated"
  │     ├─ fetches operator from Postgres operators table
  │     ├─ if not found / not is_active → 401 + clear cookie
  │     └─ returns Operator (injected into route)
  │
  └─ Route handler (GET /api/operator/cohort)
        └─ queries Postgres cohort_aggregates (k-anonymized) → returns JSON

Login flow:

POST /api/operator/login {username, password}
  │
  ├─ slowapi rate-limit check (5/min per IP — D-041)
  │     └─ if exceeded → 429 + Retry-After
  │
  ├─ fetch operator by username from Postgres
  ├─ argon2-cffi PasswordHasher().verify(stored_hash, password)
  │     ├─ if invalid → 401 (increment rate-limit counter)
  │     └─ if valid → check_needs_rehash(stored_hash) → rehash if params bumped
  │
  └─ Set signed cookie: request.session["operator_id"] = op.id
     → response 200 {operator: {id, username, display_name}}

React route guard (UX only — server is authority per D-057):

/operator/dashboard mount
  │
  ├─ GET /api/operator/me (with cookie)
  │     ├─ 200 → render dashboard
  │     └─ 401 → redirect to /operator/login

Aggregation Pipeline (D-045, D-053, D-054)

Session end (server/session_recorder.py)
  │
  ├─ 1. Mastery flow (asyncio.Task — existing v0.3 pattern)
  │     └─ evidence → rubric score → IRT θ → gate check → VC issuance
  │
  └─ 2. Cohort aggregation hook (asyncio.Task — v0.4, chained after mastery)
        ├─ reads session outcome + rubric scores + scenario failure_mode
        ├─ computes k-anonymized aggregate for (path, metric, window_start)
        ├─ COUNT(DISTINCT learner_ref) >= 10 check
        │     ├─ if ≥10 → upsert value to cohort_aggregates
        │     └─ if <10 → upsert with cell_suppressed=TRUE, value=NULL
        └─ failures log + nightly job reconciles (idempotent)

Nightly reconciliation (in-process asyncio scheduler, 03:00 CT)
  ├─ recomputes all 7-day windows for all paths
  ├─ idempotent upsert by (path, metric, window_start)
  └─ guarantees REQ-NFR-DASH-02 (freshness ≤ 24h)

3 Dashboard Views (D-053)

View Endpoint Metrics (k-anonymized, 7-day windows)
Practice volume GET /api/operator/cohort sessions/day per path; total sessions; active learners (suppressed if <10)
Mastery progression GET /api/operator/mastery % learners at each week (1-6); gate-open rate; median mastery_score; rubric criterion means
Failure patterns GET /api/operator/failure-patterns top failure_modes by frequency; rubric criteria with mean < 3.0; branch outcome distribution

All views: read-only tables + inline SVG sparklines; no per-learner drill-down (k-anon); suppressed cells shown as "— (<10 learners)".

Postgres Schema (operator tier — D-040, refined by D-050..D-053)

Tables: operators (id UUID DEFAULT gen_random_uuid(), username TEXT UNIQUE, password_hash TEXT argon2id, display_name TEXT, role TEXT DEFAULT 'operator', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ), issued_credentials (id UUID, operator_id UUID REFERENCES operators, learner_ref TEXT opaque, vc_type TEXT, payload_jsonb JSONB, issued_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ), mastery_gate_events (id UUID, learner_ref TEXT, scenario_id TEXT, path_id TEXT, gate_outcome TEXT, recorded_at TIMESTAMPTZ, source TEXT DEFAULT 'sync'), cohort_aggregates (path TEXT, metric TEXT, window_start DATE, window_end DATE, value NUMERIC, cell_count INTEGER, cell_suppressed BOOLEAN, updated_at TIMESTAMPTZ, PRIMARY KEY (path, metric, window_start) — plain table, not partitioned (v0.4 scale; add partitioning post-pilot)), issuer_keys (id TEXT, public_key TEXT, private_key_enc BYTEA, status TEXT active|superseded, created_at TIMESTAMPTZ). gen_random_uuid() in PG16 core (no extension). No cross-DB FKs.

VC Key Migration (D-042, D-051)

v0.4 first boot:
  │
  ├─ 1. Postgres issuer_keys table created (pg_migrate.py)
  │
  ├─ 2. Read v0.3 active public key from SQLite issuer_keys
  │     └─ insert into Postgres issuer_keys with status='superseded'
  │        (private key NOT migrated — only public key archived for verification)
  │
  ├─ 3. Generate fresh Ed25519 keypair in Postgres issuer_keys (status='active')
  │     └─ private key encrypted at rest via nacl.SecretBox (PRAXIS_VC_ISSUER_KEY root key)
  │
  └─ 4. Verification endpoint (server/vc/verification.py):
        ├─ extract key_id from proof.verificationMethod
        ├─ get_public_key_for_verification(store, key_id)
        │     └─ queries by id (not status) → finds active OR superseded keys
        └─ verify_proof(secured_doc, verify_key)
           ├─ v0.3 VCs → v0.3 key_id → archived (superseded) public key → verifies ✓
           └─ v0.4 VCs → v0.4 key_id → active public key → verifies ✓

IssuerKeyStore protocol: the existing server/vc/issuer_keys.py functions take a PraxisStore (SQLite). v0.4 refactors to an IssuerKeyStore protocol/ABC with methods init_issuer_key, get_active_signing_key_row, get_public_key_row, set_issuer_key_superseded. Both PraxisStore (SQLite, for v0.3 compat) and PgStore (Postgres, for v0.4) implement it.

Backup Strategy (D-055)

Host-side cron (decoupled from praxis service uptime):
  03:30 CT nightly:
    docker compose exec -T postgres pg_dump -U praxis -Fc praxis \
      -f /backups/praxis-$(date +%u).dump
  → pgbackups named volume, %u = day-of-week 1-7 → rolling 7-file retention

Restore drill:
  docker compose exec postgres pg_restore -U praxis -d praxis \
    --clean --if-exists /backups/praxis_3.dump
  (never restore into live DB without stopping praxis first)

CT Resource Sizing (v0.4 bump)

Resource v0.2 v0.3 (anticipatory) v0.4 (confirmed) Rationale
Memory 4096 MB 6144 MB 6144 MB Postgres ~400MB + praxis ~500MB + Docker ~200MB + build headroom ~1GB + margin
Rootfs 16 GB 16 GB 16 GB Postgres data on pgdata named volume, not rootfs; pgbackups on named volume
CPU 2 2-4 2-4 Postgres + praxis concurrent; 2 floor, 4 preferred

v0.4 Risks (from RESEARCH-v0.4-operator-tier.md)

Top risks for PLAN: R-AUTH-01 (Secure cookie + no-TLS → config-driven flag, grill must sign off), R-VC-MIG-01 (VC key migration loses v0.3 public key → archive as superseded before activating new key), R-DASH-03 (SPA fallback breaks voice UI → catch-all route before StaticFiles mount), R-MT-01 (Postgres resource contention → 03:00 CT nightly jobs, 6GB CT). Full table (20 risks) in RESEARCH-v0.4-operator-tier.md.

v0.4 New Dependencies

Pip (pyproject.toml): asyncpg>=0.29 (Postgres driver), argon2-cffi>=23.1 (password hashing), slowapi>=0.1 (rate limiting). pynacl, canonicaljson, base58 already present (v0.3).

Npm (client/package.json): react-router-dom@^7 (React routing for /operator/*). No chart library — inline SVG sparklines (zero deps).