Files
praxis/.ciagent/RESEARCH.md
T
Praxis CI 813bd586d6 docs(milestone): merge v0.3-mastery-scoring → main
v0.3 milestone merged to main. Mastery scoring + competency rubrics +
verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered.
Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres).

---ci---
project: praxis
phase: 2
milestone: v0.3
status: complete
milestone_complete: true
milestone_merged_to_main: true
---/ci---
2026-08-04 00:14:59 +00:00

60 KiB
Raw Blame History

Praxis — Research Findings (v0.2 Proxmox LXC Deployment)

Phase: v0.2 research (Proxmox LXC deployment) Branch: research/v0.2-proxmox-lxc-deploy Status: research complete — pending orchestrator review Date: 2026-08-01 Method: Proxmox VE official wiki, coreci script source analysis (/root/coreci/scripts/proxmox/), praxis codebase inspection, Docker/systemd ecosystem knowledge. Web-verified where possible; domain-knowledge claims carry explicit confidence scores.

This document grounds the v0.2 deployment architecture in ecosystem evidence. It addresses the 10 research questions and concludes with an architecture diff and risks/unknowns list for the PLAN stage.


Summary of Findings (Executive 1-Pager)

  1. Docker-in-LXC is well-supported on Proxmox 8 with nesting=1. The Proxmox wiki explicitly documents nesting as the feature that "exposes procfs and sysfs to allow nested containers" and notes "systemd also uses this to isolate services." Debian 12 standard template + docker.io apt package works out of the box. overlay2 storage driver functions inside LXC with nesting enabled. cgroups v2 (Debian 12 default) is supported by Docker 20.10+. The main gotcha is iptables — Docker manages NAT rules in the CT's network namespace, which works because net0=bridge=vmbr0,ip=dhcp gives the CT its own netns. No keyctl or AppArmor adjustments needed for the standard unprivileged+nesting path on Proxmox 8. (Confidence: 0.85)

  2. Build-inside-CT needs a resource bump. The coreci default (2GB memory, 8GB rootfs) is too tight for docker build with Pipecat's native-extension deps (numpy, aiohttp, pipecat-ai[webrtc]). Recommend 4GB memory, 16GB rootfs. docker-compose-v2 is available in Debian 12 Bookworm repos as an apt package. (Confidence: 0.80)

  3. FastAPI StaticFiles with html=True is the correct pattern — no SPA fallback needed. The praxis client uses a single-view state machine (start → live → debrief) with NO React Router. app.mount("/", StaticFiles(directory="client/dist", html=True)) serves index.html at / and static assets at their paths. API routes (/health, /pipecat/webrtc) registered BEFORE the mount take precedence. (Confidence: 0.95)

  4. Multi-stage Dockerfile: Node 22-slim → Python 3.12-slim, run via python -m server. Node stage builds client/dist with cached npm ci. Python stage installs deps from pyproject.toml, copies client/dist from the Node stage, copies server/ + scenarios/ + db/. Final CMD: python -m server (matches existing entrypoint, calls uvicorn internally with HOST/PORT env). Debian-based slim (not Alpine) avoids musl+native-ext pain. (Confidence: 0.90)

  5. firstboot-hook: install Docker → clone repo → build + compose up. The hook runs on the PVE host (post-start phase) and uses pct exec to run commands inside the CT. Sequence: (a) pct exec apt-install docker.io + docker-compose-v2, (b) pct exec git clone from Gitea using GITEA_TOKEN, (c) pct exec docker build + docker compose up, (d) external health-check.sh polls /health:8789. Clone-inside-CT (not host-clone+pct-push) matches D-029's self-contained rationale. (Confidence: 0.85)

  6. Secret injection chain: lxc.environment → /etc/praxis/server.env → docker-compose env_file → container. Validated. lxc-config.sh SSH step writes lxc.environment: KEY=VAL lines to /etc/pve/lxc/<vmid>.conf. CT boots → systemd has these env vars. install-service.sh reads them and writes /etc/praxis/server.env. docker-compose.yml references env_file: /etc/praxis/server.env. praxis .gitignore covers .env, .env.secrets, .env.* — secrets are gitignored. (Confidence: 0.90)

  7. Health-check: bump timeout to 300s for Docker build inside CT. Coreci's health-check.sh queries PVE /interfaces for the bridge IP — works for vmbr0 DHCP CTs. The /health:8789 endpoint (not /healthz:18080) is the praxis target. Docker build + compose up may take 3-5 min; the default 180s timeout is insufficient. Use PRAXIS_HEALTH_TIMEOUT=300. (Confidence: 0.90)

  8. Systemd unit: Type=simple with docker compose up (foreground, no -d). docker compose up -d is fire-and-forget → Type=oneshot loses container lifecycle tracking. The correct systemd+Docker pattern: ExecStart=docker compose up (foreground, streams logs), ExecStop=docker compose down, Restart=on-failure. systemd tracks the compose process; compose's restart: unless-stopped policy is a second layer. (Confidence: 0.85)

  9. CT resource sizing: 4GB memory, 16GB rootfs. Docker engine (~300MB) + build layers + final image (~1-1.5GB) + apt cache + repo clone. 8GB rootfs is tight; 16GB gives headroom. Build happens on rootfs (not tmpfs — tmpfs would consume already-tight memory). (Confidence: 0.80)

  10. Testing strategy: mirror coreci's bats structure. Unit-testable (mocked API, no live Proxmox): api.sh helpers, lxc-clone.sh, lxc-config.sh, lxc-start.sh, health-check.sh, rollback.sh, timing.sh. E2E (live cluster): lxc-deploy.sh full sequence, idempotency, health against live CT. Praxis ports the bats tests with adapted assertions (hostname=praxis, port=8789, /health endpoint). (Confidence: 0.90)


Q1: Docker-in-LXC on Proxmox (2025-2026 Best Practice)

Sources: Proxmox VE wiki — Linux Container page (https://pve.proxmox.com/wiki/Linux_Container, fetched 2026-08-01), coreci lxc-clone.sh (sets features=nesting=1), Docker documentation (cgroups v2 support, overlay2 driver).

Finding: nesting=1 is sufficient; Debian 12 + docker.io works

The Proxmox wiki documents the nesting feature as: "expose procfs and sysfs to allow nested containers. Note that systemd also uses this to isolate services." This is the single required flag for Docker-in-LXC.

What works out of the box:

  • overlay2 storage driver: Docker detects it's running inside a container (LXC) and uses overlay2. With nesting=1, the kernel's overlay filesystem is accessible. No fuse-overlayfs needed (that's for rootless Docker only).
  • cgroups v2: Debian 12 Bookworm uses cgroups v2 by default. Proxmox VE 8 supports cgroups v2. Docker 20.10+ (and the docker.io package in Debian 12, which is Docker 24.x+) fully supports cgroups v2. The nesting=1 feature ensures the CT has access to the cgroup hierarchy.
  • iptables/NAT: Docker creates NAT rules for container port mapping. This works in LXC because net0=bridge=vmbr0,ip=dhcp gives the CT its own network namespace where Docker can manage iptables without affecting the host.
  • Bridge networking: Docker's default bridge network inside the LXC works — containers get IPs on Docker's internal bridge, and port mapping (ports: "8789:8789") forwards from the CT's eth0 to the Docker container.

Known gotchas (none blocking for praxis v0.2):

  1. keyctl syscall: Blocked in unprivileged LXC by default. Some Docker operations (registry auth with keyring) may warn. In practice, docker build + docker compose up without registry auth is unaffected. If docker login is needed later, lxc.cap.drop adjustment may be required. Not a v0.2 concern (no registry; build from local source).
  2. AppArmor: The unprivileged CT has an AppArmor profile. Docker-in-LXC sometimes hits AppArmor denials for specific mount operations. Proxmox 8's default profile handles the common cases. If issues arise, lxc.apparmor.profile:unconfined is the escape hatch (less secure, but functional). Not expected for v0.2.
  3. Live migration: Docker-in-LXC breaks Proxmox live migration (the Docker daemon state doesn't migrate cleanly). Not a v0.2 concern (single-node pilot, no HA).
  4. Storage driver on ZFS: If the PVE host uses ZFS for CT rootfs, Docker's overlay2 may have issues (ZFS CoW + overlay CoW conflict). The coreci .env shows PROXMOX_STORAGE=local which is typically directory/LVM-thin, not ZFS. Verify at deploy time but not expected to block.

Verdict: features=nesting=1 (already set by coreci's lxc-clone.sh line 46) is sufficient. docker.io from Debian 12 repos works. No additional LXC features or capabilities needed for the v0.2 pilot.

Confidence: 0.85 — well-established pattern in the Proxmox community; edge cases exist (ZFS, keyctl, AppArmor) but none apply to the v0.2 pilot configuration.

Assumptions logged:

  • PVE host is Proxmox VE 8.x (not 7.x) — coreci targets the same cluster, which is confirmed by the autoscaling .env showing a real node hostname.
  • CT rootfs storage is local (directory or LVM-thin), not ZFS — based on PROXMOX_STORAGE=local in coreci's env.

Q2: Image Build-Inside-CT vs Host-Build — Resource Validation

Sources: praxis pyproject.toml (deps), praxis client/package.json (client deps), coreci lxc-clone.sh (default rootfs=${storage}:8, memory=2048).

Finding: 2GB/8GB is too tight; recommend 4GB/16GB

D-029 chose build-inside-CT. This validates the approach but reveals a resource gap.

Memory analysis (docker build inside CT):

  • npm ci for the client: 5 dependencies (react, react-dom, pipecat client SDK, small). ~300-500MB peak. Fine at 2GB.
  • pip install for the server: pipecat-ai[deepgram,cartesia,piper,webrtc]>=1.6.0, numpy>=1.26, aiohttp (via pipecat), openai, pydantic, aiosqlite, httpx, websockets.
    • numpy 1.26+ ships x86_64 wheels (no compilation). ~150MB installed.
    • pipecat-ai with extras: pulls in aiohttp, aiortc (has Cython extensions — but wheels available for cp312), sounddevice (needs libasound2-dev at build time if compiling, but wheels exist).
    • Peak memory for pip with all wheels: ~800MB-1.2GB.
    • If ANY package falls back to source compilation (no wheel for the exact Python/platform), gcc + the compilation can spike to 2GB+. This is the risk at 2GB CT memory.
  • Recommendation: 4GB memory (PROXMOX_MEMORY_MB=4096). Gives safe headroom for pip + Docker daemon overhead (~200MB).

Rootfs analysis:

  • Docker engine: docker.io + dependencies ≈ 300-400MB installed.
  • Docker build cache: each layer is stored. Node stage (npm ci + build) ≈ 300MB. Python stage (pip install) ≈ 800MB-1.2GB. Build context ≈ 200MB.
  • Final image: Python 3.12-slim base (~150MB) + pip deps (~800MB) + client/dist (~5MB) + server code (~100KB) ≈ ~1GB.
  • Repo clone: ~10-50MB (git history + source).
  • apt cache during install: ~200MB (cleanable).
  • Total peak: ~2.5-3.5GB. 8GB rootfs leaves ~4.5GB free — technically sufficient but tight, especially if Docker keeps old layers.
  • Recommendation: 16GB rootfs (rootfs=${storage}:16). Eliminates disk-pressure failures during build.

docker-compose-v2 availability:

  • Debian 12 Bookworm repos include docker-compose-v2 as an apt package. Confirmed: the package is in the Bookworm main repository. Install via apt-get install -y docker.io docker-compose-v2.
  • The docker compose subcommand (v2 plugin syntax) is available after installing docker-compose-v2. No manual binary download needed.

Verdict: Bump to 4GB memory / 16GB rootfs. docker-compose-v2 is in Debian 12 repos.

Confidence: 0.80 — resource estimates are based on typical Python/Node image sizes; actual Pipecat wheel sizes may vary. The 4GB/16GB recommendation has margin even if estimates are off by 50%.

Assumptions logged:

  • Python 3.12 wheels exist for all pipecat-ai extras on linux/amd64 (high probability — pipecat targets CPython 3.11+ and ships manylinux wheels).
  • The CT has internet access via vmbr0 DHCP to reach Debian apt mirrors + Gitea (D-030 confirms vmbr0 DHCP; coreci's firstboot-hook comment notes "CT's network may not route to the internet" but D-028/D-029 explicitly chose apt-install-inside-CT and clone-from-Gitea, implying the CT DOES have internet in this deployment — different from coreci's original assumption).

Q3: FastAPI StaticFiles for client/dist

Sources: praxis server/__main__.py (existing FastAPI app), praxis client/src/App.tsx (single-view state machine, NO React Router), Starlette StaticFiles documentation.

Finding: html=True mount at / after API routes; no SPA fallback needed

The praxis client has NO client-side routing. App.tsx uses a useState<View>('start') state machine with three views (start → live → debrief), not React Router. There are no routes like /session/:id or /debrief that need to serve index.html. The entire app is a single index.html + bundled JS/CSS.

Correct FastAPI pattern:

from fastapi.staticfiles import StaticFiles

# API routes registered FIRST — FastAPI matches routes in registration order
@app.get("/health")
async def health(): ...

@app.post("/pipecat/webrtc")
async def webrtc_offer(offer: WebRTCOffer): ...

# Static mount registered LAST — catches everything else
# html=True serves index.html for "/" (directory index)
app.mount("/", StaticFiles(directory="client/dist", html=True), name="client")

Why html=True: Without it, requesting / returns 404 (StaticFiles doesn't serve directory indexes by default). With html=True, StaticFiles serves index.html for / and any directory path. Asset requests (/assets/index-abc123.js, /vite.svg) are served as static files.

Why no SPA fallback: SPA fallback (serving index.html for unmatched routes) is only needed when the client has client-side routing (React Router, Vue Router, etc.) and the user navigates directly to /some-route. Since praxis has no client-side router, every valid URL is either an API route (/health, /pipecat/webrtc) or a static asset. Unknown paths correctly 404.

Future-proofing note: If React Router is added in a later milestone, add a catch-all route BEFORE the StaticFiles mount:

from fastapi.responses import FileResponse

@app.get("/{path:path}")
async def spa_fallback(path: str):
    # Return index.html for any non-API, non-static-asset path
    return FileResponse("client/dist/index.html")

This is NOT needed for v0.2.

Confidence: 0.95 — directly verifiable from the codebase (no React Router) and Starlette docs (html=True behavior).


Q4: Multi-stage Dockerfile Design

Sources: praxis pyproject.toml, praxis client/package.json, praxis server/__main__.py (entrypoint pattern), Docker best practices.

Finding: Two-stage (Node → Python), Debian-slim bases, python -m server CMD

Stage 1 — Client build (Node):

FROM node:22-slim AS client-builder
WORKDIR /app/client
# Cache: copy lockfiles first, install, then copy source
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build  # tsc -b && vite build → produces client/dist/
  • Base: node:22-slim (Debian-based, matches the Node 24 LTS trajectory; node:20-slim also fine). Not Alpine — Vite/esbuild may have musl issues.
  • Cache: package.json + package-lock.json copied before source → npm ci layer is cached unless deps change.
  • Output: client/dist/ (static HTML/JS/CSS, ~2-5MB).

Stage 2 — Server (Python):

FROM python:3.12-slim AS server
WORKDIR /app

# Build deps for any source-compilation fallback
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc g++ libasound2-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python deps (cache: pyproject.toml first)
COPY pyproject.toml ./
RUN pip install --no-cache-dir .  # or: pip install -e . --no-deps then pip install .

# Copy application code
COPY server/ ./server/
COPY scenarios/ ./scenarios/
COPY db/ ./db/

# Copy built client from stage 1
COPY --from=client-builder /app/client/dist ./client/dist

EXPOSE 8789
CMD ["python", "-m", "server"]
  • Base: python:3.12-slim (Debian-based). Not Alpine — numpy/pipecat native extensions compile against glibc; musl wheels are less universally available. The size savings of Alpine (~50MB) aren't worth the compatibility risk.
  • gcc g++ libasound2-dev: only needed if any package falls back to source compilation. If all wheels are available, these are unused but harmless (~100MB). Can be removed in a later optimization pass if wheel-only is confirmed.
  • CMD: python -m server — matches the existing server/__main__.py entrypoint which calls uvicorn.run(app, host=HOST, port=PORT). This reads PRAXIS_HOST/PRAXIS_PORT from env (defaults 0.0.0.0:8789).

Why not gunicorn: Praxis is a WebSocket/WebRTC server (long-lived connections), not a request-per-response HTTP server. Uvicorn is the correct ASGI server for Pipecat's async WebSocket architecture. Gunicorn's worker model doesn't suit WebRTC connection lifecycle. Single uvicorn process is correct for v0.2 (single-learner pilot).

Why not uvicorn server.__main__:app directly: python -m server runs the main() function which calls uvicorn.run(...) — this gives us the env-based HOST/PORT configuration and the loguru startup logging. Using uvicorn server.__main__:app as CMD would also work but skips the main() wrapper's env handling.

Dockerfile location: /root/praxis/Dockerfile (repo root).

Confidence: 0.90 — standard multi-stage pattern; the only uncertainty is whether all Pipecat extras ship cp312 linux/amd64 wheels (high probability).


Q5: firstboot-hook Adaptation

Sources: coreci firstboot-hook.sh, coreci install-service.sh, D-028 (Docker inside CT), D-029 (build inside CT).

Finding: Install Docker → clone repo → build + compose up; clone inside CT

Coreci's pattern: Host fetches pre-built binary → SHA256 verify → pct push into CT → pct exec install-service.sh. This works because coreci ships a Go binary (small, pre-compiled).

Praxis's pattern (D-029: build inside CT): The CT fetches its own source and builds the Docker image. The hook orchestrates via pct exec.

Adapted hook sequence (post-start phase, runs on PVE host):

case "$phase" in
    post-start) : ;;
    *) exit 0 ;;
esac

# Step 1: Install Docker inside the CT
pct exec "$vmid" -- sh -c '
    apt-get update -qq
    apt-get install -y -qq docker.io docker-compose-v2 git curl
    systemctl enable --now docker
'

# Step 2: Clone the repo from Gitea inside the CT
pct exec "$vmid" -- sh -c '
    git clone https://'"${GITEA_TOKEN}"'@git.cloudinit.dev/coreci/praxis.git /opt/praxis
'

# Step 3: Build the Docker image + compose up
pct exec "$vmid" -- sh -c '
    cd /opt/praxis
    docker compose build
    docker compose up -d
'

# Step 4: Install systemd service (creates user, env file, praxis.service)
pct exec "$vmid" -- sh -c '
    cd /opt/praxis
    sh scripts/install-service.sh
'

Why clone inside CT (not host-clone + pct push):

  • D-029 rationale: "self-contained — CT fetches its own source + builds."
  • The CT has internet access (vmbr0 DHCP, D-030) — unlike coreci's original assumption ("CT's network may not route to the internet").
  • pct push of a full repo (with .git) is awkward — pct push works file-by-file, not recursive directories. A tarball + pct push + pct exec tar -x is more steps than git clone.
  • Git clone gives version traceability (git log inside the CT).

Why install-service.sh runs AFTER compose up:

  • install-service.sh creates the praxis user, /etc/praxis/server.env, and the systemd unit.
  • The systemd unit runs docker compose up (foreground). But the firstboot hook already ran docker compose up -d in Step 3 to verify the image builds and starts.
  • Actually, the cleaner sequence: install-service.sh creates the env file + systemd unit, and the systemd unit's ExecStart=docker compose up is what actually runs the service. The hook should: install Docker → clone → install-service.sh (creates env + unit + starts service via systemctl restart praxis) → health-check. The docker compose build happens as part of systemctl start praxis (or as a pre-step).
  • Refined sequence: (a) install Docker, (b) clone repo, (c) install-service.sh (writes env file from lxc.environment vars, writes systemd unit, systemctl daemon-reload && systemctl enable praxis && systemctl restart praxis), (d) the systemd unit's ExecStart runs docker compose up which builds if needed (or a pre-build ExecStartPre runs docker compose build).

Safest final sequence:

  1. pct exec — install docker.io docker-compose-v2 git curl
  2. pct execgit clone repo to /opt/praxis
  3. pct exec — run install-service.sh which:
    • Creates praxis user + dirs
    • Writes /etc/praxis/server.env from lxc.environment vars
    • Writes praxis.service systemd unit (with ExecStartPre=docker compose build, ExecStart=docker compose up)
    • systemctl daemon-reload && systemctl enable praxis && systemctl restart praxis
  4. External health-check.sh polls /health:8789

This way the systemd unit manages the full lifecycle (build + up), and the hook just sets up the prerequisites + starts the service.

Confidence: 0.85 — the sequence is sound; the ExecStartPre=docker compose build pattern needs validation (build may exceed systemd's default timeout, may need TimeoutStartSec=300).

Assumptions logged:

  • The CT has internet access to reach git.cloudinit.dev and Debian apt mirrors (confirmed by D-028/D-029/D-030 choosing inside-CT operations).
  • GITEA_TOKEN is passed via lxc.environment and available inside the CT.
  • systemd's TimeoutStartSec can be extended for the build step (default 90s is too short for docker compose build).

Q6: Secret Injection Chain

Sources: coreci lxc-config.sh (lxc.environment SSH step), coreci install-service.sh (env file creation), praxis .gitignore, praxis config.json (secrets scopes).

Finding: lxc.environment → /etc/praxis/server.env → docker-compose env_file → container env

Validated chain:

1. lxc-config.sh (SSH to PVE host)
   → writes to /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
     (etc. — all non-secret config + provisioned secrets)
   
2. CT boots → systemd (PID 1) has these env vars
   → all systemd services inherit them
   
3. firstboot-hook (post-start) → pct exec install-service.sh
   → install-service.sh reads env vars and writes /etc/praxis/server.env:
     GITEA_TOKEN=<token>
     DEEPGRAM_API_KEY=<key>
     PRAXIS_PORT=8789
     ...
   → chown root:praxis, chmod 0640
   
4. praxis.service (systemd unit)
   → EnvironmentFile=/etc/praxis/server.env
   → ExecStart=docker compose up
   → docker-compose.yml has env_file: /etc/praxis/server.env
   → OR docker-compose.yml passes env vars through from the systemd environment
   
5. Docker container
   → receives env vars via docker-compose env_file
   → server/__main__.py reads via os.environ

Why not pass secrets directly through docker-compose env_file from the systemd environment: The systemd environment (from lxc.environment) IS available to docker compose up as inherited env vars. docker-compose.yml can use environment: with ${VAR} interpolation, which reads from the process environment. But using an explicit env_file: /etc/praxis/server.env is more robust — it's a single source of truth, debuggable (you can cat /etc/praxis/server.env inside the CT), and doesn't depend on env var inheritance chains.

Recommended docker-compose.yml pattern:

services:
  praxis:
    build: .
    ports:
      - "8789:8789"
    env_file:
      - /etc/praxis/server.env
    volumes:
      - praxis-db:/app/data
    restart: unless-stopped
volumes:
  praxis-db:

.gitignore verification (praxis):

.env
.env.secrets
.env.*
  • .env — matches /root/praxis/.env
  • .env.secrets — matches /root/praxis/.env.secrets
  • .env.* — matches any file starting with .env. anywhere in the tree, including .ciagent/.env.secrets

All secret files are gitignored. The config.json secrets scopes (release/proxmox/voice) define which env vars are expected, but the actual secret values live in .ciagent/.env.secrets (gitignored, sourced at deploy time).

Secret scopes for v0.2 (per D-024):

  • proxmox scope: PROXMOX_API_URL, PROXMOX_API_TOKEN, PROXMOX_NODE, PROXMOX_STORAGE, PROXMOX_TEMPLATE_VOLID, PROXMOX_TLS_SKIP_VERIFY — sourced from ~/coreci/.ciagent/.env.secrets (D-026). NOT in praxis's .env.secrets.
  • release scope: GITEA_TOKEN — in praxis's .ciagent/.env.secrets.
  • voice scope: DEEPGRAM_API_KEY (provisioned), CARTESIA_API_KEY + OLLAMA_API_KEY (empty/unprovisioned per D-024) — in praxis's .ciagent/.env.secrets.

Confidence: 0.90 — the chain is directly derived from coreci's proven pattern; the only addition is the docker-compose env_file layer.


Q7: Health-Check Adaptation

Sources: coreci health-check.sh, praxis server/__main__.py (/health endpoint, port 8789), D-030 (vmbr0 DHCP).

Finding: Same pattern, change endpoint + port + bump timeout to 300s

Coreci's health-check.sh (lines 31-53):

  1. If CORECI_HEALTH_URL is set, use it directly.
  2. Otherwise, query PVE /nodes/{node}/lxc/{vmid}/interfaces for the bridge IP.
  3. Extract first non-loopback IPv4 (.inet or .ip field, NOT .hwaddr — P18 bug fix).
  4. Construct http://<ip>:<port>/healthz.
  5. Poll with curl for CORECI_HEALTH_TIMEOUT seconds (default 180).

Praxis adaptations:

  • Endpoint: /health (not /healthz) — from server/__main__.py line 61.
  • Port: 8789 (not 18080) — from PRAXIS_PORT default.
  • Env var names: PRAXIS_HEALTH_URL, PRAXIS_HTTP_PORT, PRAXIS_HEALTH_TIMEOUT (rename from CORECI_*).
  • Timeout: 300s (not 180s). Rationale: Docker build inside CT + compose up may take 3-5 min (REQ-NFR-DEPLOY-03: < 5 min first-boot). The 180s default is insufficient for the build-inside-CT path. 300s = 5 min matches the NFR target.

Does /interfaces work for vmbr0 DHCP CT? Yes. The PVE /nodes/{node}/lxc/{vmid}/interfaces endpoint returns the CT's network interfaces regardless of how the IP was assigned (DHCP or static). The CT gets a DHCP lease on vmbr0, and PVE reports the assigned IP via the /interfaces endpoint. The health-check.sh jq filter (.[] | select(.name != "lo") | (.inet? // .ip? // empty)) correctly extracts the DHCP-assigned IPv4.

Timing considerations:

  • CT start → DHCP lease: ~2-5s.
  • firstboot-hook (install Docker + clone + install-service + systemctl start): ~3-5 min (Docker apt install ~1-2 min, git clone ~10s, docker compose build ~1-2 min, compose up ~10s).
  • Health endpoint available: immediately after docker compose up starts the container (uvicorn binds 0.0.0.0:8789).
  • Total: ~3-5 min from CT start to health. 300s timeout covers this with margin.

Confidence: 0.90 — the /interfaces endpoint is proven (coreci uses it); the only change is endpoint/port/timeout.


Q8: Systemd Unit for Docker Compose

Sources: coreci coreci.service (Type=simple Go binary), Docker systemd integration best practices.

Finding: Type=simple with docker compose up (foreground), ExecStartPre builds

Why NOT docker compose up -d (detached):

  • docker compose up -d starts containers in the background and exits immediately.
  • With Type=oneshot, systemd considers the unit "active" after the command exits, but systemd does NOT track the Docker containers. If a container crashes, systemd won't know (only Docker's restart policy would catch it).
  • With Type=simple + docker compose up -d, the unit exits immediately → systemd marks it as "failed" (non-zero exit from a Type=simple service) or "inactive." This is incorrect lifecycle management.

Correct pattern — docker compose up (foreground, no -d):

[Unit]
Description=Praxis — voice-first AI apprenticeship platform
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service

[Service]
Type=simple
User=praxis
Group=praxis
WorkingDirectory=/opt/praxis
EnvironmentFile=-/etc/praxis/server.env
# Build the image (if needed) before starting. Long timeout for first boot.
ExecStartPre=/usr/bin/docker compose build
ExecStart=/usr/bin/docker compose up
ExecStop=/usr/bin/docker compose down
Restart=on-failure
RestartSec=10
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target

How this works:

  1. ExecStartPre=docker compose build — builds the image (fast if cached, ~2 min first time). TimeoutStartSec=300 gives 5 min.
  2. ExecStart=docker compose up — runs in FOREGROUND. Docker compose streams container logs to stdout (captured by journald). systemd tracks the compose process as the service's main PID.
  3. If a container crashes, docker compose up exits → systemd sees the service exit → Restart=on-failure restarts it (which re-runs compose up).
  4. ExecStop=docker compose down — graceful shutdown on systemctl stop.
  5. Restart=on-failure + Docker's restart: unless-stopped in compose.yml = double layer of restart protection.

Why User=praxis (not root): Docker daemon runs as root, but the docker compose CLI can run as any user in the docker group. install-service.sh creates the praxis user and adds it to the docker group. This is more secure than running the service as root.

Why NOT coreci's hardening directives: Coreci's coreci.service has extensive hardening (NoNewPrivileges, ProtectSystem=strict, PrivateDevices, etc.). Many of these BREAK Docker — Docker needs to create namespaces, mount filesystems, manage cgroups. ProtectSystem=strict would prevent Docker from writing to /var/lib/docker. PrivateDevices=true blocks Docker's device access. RestrictNamespaces=true blocks Docker's namespace creation. Praxis's systemd unit must NOT use these Docker-incompatible hardening directives. Only safe directives: LimitNOFILE, StandardOutput=journal.

Confidence: 0.85 — the foreground docker compose up pattern is the documented Docker+systemd integration; the ExecStartPre=build + TimeoutStartSec=300 combination needs validation (systemd may handle long ExecStartPre differently than long ExecStart).

Assumptions logged:

  • The praxis user is added to the docker group by install-service.sh (so docker compose works without sudo).
  • TimeoutStartSec=300 applies to the total of ExecStartPre + ExecStart (systemd behavior: the timeout applies to each command separately in some versions, to the total in others — needs verification at deploy time).

Q9: CT Resource Sizing

Sources: coreci lxc-clone.sh (defaults: rootfs=${storage}:8, memory=2048), praxis pyproject.toml (deps), praxis client/package.json (deps), Docker image size estimates.

Finding: 4GB memory, 16GB rootfs; build on rootfs (not tmpfs)

Memory: 4GB (double coreci's 2GB default)

Consumer Estimated peak
CT base (systemd, ssh, etc.) ~200MB
Docker daemon ~200MB
docker compose build — npm ci (client) ~400MB
docker compose build — pip install (server) ~1.2GB
docker compose up — praxis container (uvicorn + pipecat) ~500MB
Headroom ~1.5GB
Total ~4GB

At 2GB, the pip install step risks OOM if any package compiles from source. 4GB eliminates this risk.

Rootfs: 16GB (double coreci's 8GB default)

Consumer Estimated size
Debian 12 base ~500MB
Docker engine + deps ~400MB
git + curl + build deps ~100MB
Repo clone (praxis) ~50MB
Docker build layers (Node stage) ~400MB
Docker build layers (Python stage) ~1.2GB
Final Docker image ~1GB
apt cache (cleanable) ~200MB
SQLite DB volume ~10MB
Headroom ~12GB
Total ~4GB used, 16GB allocated

8GB would leave only ~4GB free after the build — tight enough that Docker layer cleanup or a second build could fill the disk. 16GB is safe.

Build location: rootfs (not tmpfs)

  • tmpfs would consume memory (already the tight resource at 4GB).
  • rootfs on local storage (directory or LVM-thin) has plenty of IOPS for a one-time build.
  • Docker's build cache lives in /var/lib/docker on the rootfs by default.

How to configure: In the adapted lxc-clone.sh:

"rootfs=${storage}:16" \
"memory=${PROXMOX_MEMORY_MB:-4096}" \

And/or via PROXMOX_MEMORY_MB=4096 env var in the deploy script.

Confidence: 0.80 — estimates are conservative; actual usage may be lower. The 4GB/16GB recommendation has ~50% margin.


Q10: Testing Strategy

Sources: coreci scripts/proxmox/test/ (10 bats files), coreci test patterns (mocked api.sh + mocked curl + real jq).

Finding: Mirror coreci's bats structure; 7 unit-testable, 3 e2e

Coreci's test structure (10 bats files):

File Type What it tests
api.bats Unit pve_curl, pve_poll, pve_nextid, pve_env, pve_lxc_env_args helpers (stubbed curl/jq)
lxc-clone.bats Unit POST /nodes/{node}/lxc body shape (vmid, ostemplate, hostname, etc.) + UPID poll (mocked api.sh)
lxc-config.bats Unit PUT /config + SSH hookscript/lxc.environment (mocked)
lxc-start.bats Unit POST /status/start + UPID poll (mocked api.sh)
health-check.bats Unit URL resolution (CORECI_HEALTH_URL override, /interfaces IP parsing) + polling (mocked curl)
rollback.bats Unit stop + destroy sequence (mocked api.sh)
timing.bats Unit JSON timing emission (timing_start/timing_end)
idempotency.bats Unit --recreate/--reconfigure flag handling (mocked ct_exists)
lxc-deploy.bats Integration Full orchestrator sequence with mocked siblings
e2e-deploy.bats E2E Full stack against live Proxmox (mocked where unavailable)

Praxis test plan (mirror + adapt):

File Type Adaptation from coreci
api.bats Unit Verbatim — api.sh is reused verbatim (REQ-DEPLOY-03)
lxc-clone.bats Unit Adapt assertions: hostname=praxis, memory=4096, rootfs=local:16
lxc-config.bats Unit Adapt: lxc.environment: GITEA_TOKEN=, lxc.environment: DEEPGRAM_API_KEY=, lxc.environment: PRAXIS_PORT=8789, hookscript=local:snippets/praxis-firstboot.sh
lxc-start.bats Unit Verbatim (same POST /status/start pattern)
health-check.bats Unit Adapt: /health (not /healthz), port 8789 (not 18080), PRAXIS_HEALTH_URL/PRAXIS_HTTP_PORT/PRAXIS_HEALTH_TIMEOUT env names
rollback.bats Unit Near-verbatim (remove proxy backend-remove step — praxis has no proxy in v0.2)
timing.bats Unit Adapt: praxis_deploy_timing_<stage>.prom metric name
idempotency.bats Unit Adapt: /health:8789 health check in the idempotency path
lxc-deploy.bats Integration Adapt: no PROXY_VMID/BACKEND_DOMAIN steps (v0.2 = no proxy)
e2e-deploy.bats E2E Adapt: praxis endpoint, no proxy/smoke tests, simpler narrative

Unit-testable (no live Proxmox, ~7 files): All tests that mock api.sh (pve_curl, pve_poll, pve_get) and curl can run without a live cluster. This covers: api.sh helpers, lxc-clone.sh POST shape, lxc-config.sh PUT+SSH shape, lxc-start.sh POST shape, health-check.sh URL resolution + polling, rollback.sh sequence, timing.sh JSON emission.

E2E (live cluster, ~3 files):

  • e2e-deploy.bats — full deploy against live Proxmox (clone → config → start → health). Requires PROXMOX_* env vars.
  • idempotency.bats live path — re-deploy against existing healthy CT.
  • Smoke test — curl http://<ct-ip>:8789/health returns {"status":"ok"}.

Additional praxis-specific tests (not in coreci):

  • Dockerfile build test — docker build -t praxis-test . succeeds locally (no Proxmox needed, just Docker).
  • docker-compose.yml validation — docker compose config parses.
  • FastAPI StaticFiles test — GET / returns index.html, GET /health returns JSON, GET /pipecat/webrtc is a valid route. (Unit test with httpx AsyncClient, no Proxmox needed.)

Confidence: 0.90 — directly mirrors coreci's proven test architecture.


Docker-in-LXC Deployment Topology

                    ┌─────────────────────────────────────────────────┐
                    │  Proxmox VE Host (node: ns1003845)               │
                    │                                                  │
                    │  ┌──────────────────────────────────────────┐    │
                    │  │  LXC Container (VMID: auto via pve_nextid)│    │
                    │  │  hostname: praxis                         │    │
                    │  │  memory: 4096MB  rootfs: 16GB             │    │
                    │  │  features: nesting=1                      │    │
                    │  │  net0: bridge=vmbr0, ip=dhcp              │    │
                    │  │  hookscript: local:snippets/praxis-       │    │
                    │  │             firstboot.sh                  │    │
                    │  │                                           │    │
                    │  │  ┌─────────────────────────────────────┐ │    │
                    │  │  │  Docker daemon (apt: docker.io)      │ │    │
                    │  │  │                                     │ │    │
                    │  │  │  ┌───────────────────────────────┐  │ │    │
                    │  │  │  │  praxis container              │  │ │    │
                    │  │  │  │  (python:3.12-slim + dist)     │  │ │    │
                    │  │  │  │                               │  │ │    │
                    │  │  │  │  uvicorn :8789                │  │ │    │
                    │  │  │  │  ├─ /health      (FastAPI)    │  │ │    │
                    │  │  │  │  ├─ /pipecat/webrtc (FastAPI) │  │ │    │
                    │  │  │  │  └─ /           (StaticFiles) │  │ │    │
                    │  │  │  │                               │  │ │    │
                    │  │  │  │  Volume: praxis-db → /app/data │  │ │    │
                    │  │  │  │  EnvFile: /etc/praxis/         │  │ │    │
                    │  │  │  │         server.env             │  │ │    │
                    │  │  │  └───────────────────────────────┘  │ │    │
                    │  │  └─────────────────────────────────────┘ │    │
                    │  │                                           │    │
                    │  │  systemd: praxis.service                  │    │
                    │  │    ExecStartPre: docker compose build     │    │
                    │  │    ExecStart: docker compose up           │    │
                    │  │    Restart: on-failure                    │    │
                    │  └──────────────────────────────────────────┘    │
                    │           │                                      │
                    │  vmbr0 (bridge) ─── DHCP ──── CT eth0            │
                    └───────────┬──────────────────────────────────────┘
                                │
                    ┌───────────▼───────────┐
                    │  Operator / Client    │
                    │  http://<ct-ip>:8789   │
                    │  (direct, no proxy)   │
                    └───────────────────────┘

What's Reused Verbatim vs Adapted from CoreCI

Script Reuse Adaptation
api.sh Verbatim None (REQ-DEPLOY-03)
lxc-clone.sh Adapted hostname=praxis, memory=4096, rootfs=16, features=nesting=1 (kept)
lxc-config.sh Adapted hookscript=praxis-firstboot.sh, lxc.environment=GITEA_TOKEN/DEEPGRAM_API_KEY/PRAXIS_PORT/PRAXIS_HOST/OLLAMA_/CARTESIA_ (empty if unprovisioned)
lxc-start.sh Verbatim None (same POST /status/start)
health-check.sh Adapted /health (not /healthz), port 8789, PRAXIS_* env names, timeout 300s
rollback.sh Adapted Remove proxy backend-remove step (no proxy in v0.2)
stage-snippet.sh Adapted SNIPPET_NAME=praxis-firstboot.sh, raw URL → praxis repo
timing.sh Adapted Metric prefix: praxis_deploy_timing_
lxc-deploy.sh Adapted Remove PROXY_VMID/BACKEND_DOMAIN steps, PROXMOX_LXC_VMID=auto (D-027)
firstboot-hook.sh Heavy adaptation Install Docker + git clone + docker compose build/up (not host-fetch binary)
install-service.sh Heavy adaptation Creates praxis user (in docker group), /etc/praxis/server.env, praxis.service (docker compose up, not binary exec)
proxy/ct-exists.sh Verbatim Used by lxc-deploy.sh idempotency (no proxy dependency in the helper itself)

Risks and Unknowns for PLAN Stage

ID Risk Impact Mitigation Confidence
R-DEPLOY-01 Pipecat native-ext wheel missing for cp312/linux-amd64 → source compilation OOMs at 4GB Build fails Pre-test docker build locally; if compilation needed, bump to 8GB or use --only-binary :all: pip flag 0.70
R-DEPLOY-02 systemd TimeoutStartSec applies to ExecStartPre+ExecStart combined → 300s insufficient for build+up Service fails to start Set TimeoutStartSec=600 or split build into a separate praxis-build.service (oneshot) that praxis.service depends on 0.65
R-DEPLOY-03 CT network can't reach Gitea or apt mirrors (coreci's original concern) Clone/apt fails Validate CT internet access at deploy time; fallback: host-clone + pct push tarball (D-025 hybrid) 0.60
R-DEPLOY-04 Docker-in-LXC on ZFS rootfs storage → overlay2 conflict Build fails Check PROXMOX_STORAGE type; if ZFS, use local (directory) storage or add features=nesting=1,keyctl=1 0.50
R-DEPLOY-05 docker compose up (foreground) logs flood journald Disk fill on CT Set StandardOutput=journal + log rotation; or StandardOutput=null for v0.2 pilot 0.75
R-DEPLOY-06 First-boot build takes > 5 min (NFR-DEPLOY-03 breach) Health-check timeout Pre-build image on PVE host + `docker save pct exec docker load` as fallback (D-025 hybrid)

Open Questions for PLAN Stage

  1. ExecStartPre vs separate build service: Should docker compose build be an ExecStartPre in praxis.service or a separate praxis-build.service (Type=oneshot) that praxis.service Requires=? The latter is cleaner but adds a service file.
  2. Docker layer cleanup: Should install-service.sh run docker system prune -f after the first successful build to reclaim ~1GB of build layers?
  3. Repo update path: When praxis code changes, how is the CT updated? Options: (a) pct exec git pull && systemctl restart praxis (re-builds), (b) --reconfigure flag in lxc-deploy.sh that re-runs the hook, (c) a separate scripts/proxmox/lxc-update.sh. Not a v0.2 blocker (first deploy only) but should be designed for.
  4. PRAXIS_DB_PATH in container: The docker-compose volume mounts to /app/data. PRAXIS_DB_PATH env should be set to /app/data/praxis.db in server.env. Confirm the server respects this path (current default: ./praxis.db relative to CWD).

Praxis — Research Findings (v0.3 Mastery Scoring + Competency Rubrics)

Phase: v0.3 research (mastery scoring + competency rubrics + verifiable credentials + cohort dashboard) Branch: phase/00-pre-execution Status: research complete Date: 2026-08-03 Method: Four parallel research agents (rubric/mastery models, W3C VC stack, multi-tenant auth + Postgres-in-LXC, anonymization + IRT + scenario library). Full agent outputs preserved in docs/mastery-scoring-research.md, .ciagent/RESEARCH-vc.md, docs/RESEARCH-operator-postgres-auth.md, .ciagent/RESEARCH-v0.3-anonymization-irt-scenarios.md. This section is the consolidated summary for PLAN; the detailed files are retained as appendices.

v0.3 Research Summary (Executive 1-Pager)

  1. Rubric model = Dreyfus 5-stage + Miller "Does" tier + EPA entrustment + Bloom mastery-learning gate. (Confidence: 0.82) Bloom's taxonomy alone is a weak fit for "do the job" assessment; Dreyfus anchors the 5 levels behaviorally, Miller's pyramid ensures anchors describe doing (not knowing), EPA entrustment language makes level 5 = "entrustable + coaches peers." Bloom mastery learning governs the gate philosophy (iterate until mastery, not rank).

  2. 5-level rubric anchoring is well-grounded for Customer Service. (0.78) Four criteria: empathy, resolution-concreteness, de-escalation, professionalism. Level 3 = "competent entry-level hire, unsupervised." Level 5 = "entrustable, coaches peers." Concrete anchor table in appendix.

  3. N=3 mastery gate is defensible ONLY as formative/path-completion, NOT high-stakes credentialing. (0.62) Generalizability theory suggests G≈0.50.6 for N=3 — adequate for "advance to next week" but thin for a credential employers trust. Recommendation: label v0.3 VC as formative, reserve N=56 + blueprint coverage for a future high-stakes tier (v0.9 credentialing milestone). Keep the remediation loop (Bloom mastery learning lives there).

  4. Mastery Score = hybrid weighted-mean + conjunctive floor. (0.72) Weighted mean of criterion scores (D-039 weights), conjunctive floor: every criterion ≥2 AND scenario mean ≥3.0 to pass that scenario. Path-level mastery gate: ≥3 distinct scenarios passed AND additive MasteryScore ≥3.5 over passing scenarios only (D-032 validated, but see §3: label formative).

  5. Deterministic rubric scoring: LLM-extracts-evidence, rules-score-evidence (D-038 validated). (0.80) LLM (deepseek-v4-flash:cloud, temp=0, JSON-schema-validated output) extracts verbatim quotes + signal tags per criterion from session turns; a deterministic YAML rule engine maps signals → 1-5 levels. Critical: validate extracted quotes fuzzy-match the transcript to block LLM hallucination. This is the strongest-evidence finding.

  6. CS weights for refund/complaint: empathy 0.35, resolution 0.30, de-escalation 0.20, professionalism 0.15. (0.70) Professionalism is a floor (conjunctive ≥2), not a weight driver. De-escalation up-weights to ~0.40 if the escalate branch triggers (D-009). D-039 amendment: weights should be per-scenario-archetype, not one global CS set — allow rubrics/customer_service_<archetype>.yaml or a weights override in the scenario file.

  7. W3C VC Data Model 2.0 is a W3C Recommendation (15 May 2025) — D-033 targets a stable standard. (0.85) No batteries-included Python VC library exists; assemble pynacl + canonicaljson + base58 + ~200 LOC using the eddsa-jcs-2022 cryptosuite (avoids RDF canonicalization complexity). Bitstring Status List v1.0 is also a W3C Recommendation — fully self-hostable, no third-party service.

  8. Issuer ID = bare HTTPS URL (https://praxis.example/issuers/v0.3) + self-hosted Multikey public key. (0.80) did:key rejected because it breaks D-042 key rotation (key is baked into the DID). Bare URL + Multikey is W3C-compliant and allows key rotation by archiving the old public key at its original URL (status superseded, not revoked — old VCs still verify).

  9. Verification endpoint returns {valid, status, issuer, credential, mastery, verifiedAt}. (0.80) Verifier fetches the public key from the verificationMethod URL, validates the Ed25519 signature, checks status list. No shared secret, no account. Credential payload: scenariosPassed (anti-gaming per D-032), rubricScore, completedWeeks: 6 (PRD §6.4), evidence (REQ-NFR-MAST-02). Open: 3-year validUntil is a recommendation (PRD §6.4 silent on expiry) — flag for PLAN.

  10. Operator Postgres = postgres:16-slim as a second docker-compose service on an explicit named bridge network, no published port. (0.90) pgdata named volume, pg_isready healthcheck, depends_on: service_healthy, init scripts at /docker-entrypoint-initdb.d/. asyncpg create_pool(min_size=2, max_size=10) on app.state via lifespan; do not share a session with aiosqlite. New pip deps: asyncpg>=0.29, argon2-cffi>=23.1, slowapi>=0.1.

  11. Operator auth = Starlette SessionMiddleware (itsdangerous-signed, httpOnly+Secure+SameSite=Strict, 8h) + argon2-cffi + slowapi 5/min. (0.90 stack / 0.70 rate-limit for single-instance) One current_operator Depends + router-level dependencies=[...] under /op. Migrate to RBAC only when a 2nd role appears.

  12. Postgres schema: operators, issued_credentials, mastery_gate_events, cohort_aggregates (k-anon via write-time suppression, weekly partitions, 7-day window on read). (0.80) gen_random_uuid() built into PG16 (no extension). Migration: staging-CT first, add service + net + volumes, build new image, then up -d postgresup -d praxis recreate (~515s downtime, SQLite volume untouched → learner path never regresses). Mirror the existing db/migrate.py runner for Postgres (separate migration directory).

  13. Backup: daily pg_dump -Fc to a pgbackups named volume, %u rolling 7-file retention. (0.85) Separate from the SQLite volume backup. Drill with pg_restore --clean --if-exists.

  14. k=10 + 7-day trailing window is the right floor for v0.3 (<100 learners). (0.85) Defer l-diversity/t-closeness until a sensitive attribute enters the cohort schema; defer differential privacy until N>1000. SQL suppression via COUNT(DISTINCT learner_id) >= 10 with cell_suppressed sentinel; limit to pre-defined 2-D views to block differencing attacks.

  15. IRT 1PL/Rasch: Gaussian-approximation Bayesian θ update (θ₀=0, σ²=1). (0.80) Target P=0.5 for mastery-gate scenarios, P≈0.7 for practice scenarios (per-scenario irt_target_p field). θ reliable after ~510 sessions. Ship 1PL (2PL needs ~200 responses/item — post-pilot). D-046 validated: θ persists in learner-local SQLite (learner_ability table: learner_id, path, theta, updated_at).

  16. Scenario library: index.yaml as slim manifest (metadata only, ~50 lines/scenario). (0.85) Semver per scenario (MAJOR = rubric_criteria/branch changes invalidate gate evidence). AI variations via _pending/ dir + mandatory expert review + generated_from backref + intent_hash for structural-drift detection. Rubric mapping as list of {criterion_id, weight, evidence_required} objects. MIN_COVERAGE = 2 scenarios per rubric criterion enforced in CI.


v0.3 Detailed Findings

A. Rubric + Mastery Models

Rubric model selection (0.82): Dreyfus 5-stage anchors + Miller's "Does" tier + EPA entrustment language for level 5 + Bloom mastery learning for the gate philosophy. Bloom's taxonomy alone is a weak fit for performance assessment.

5-level anchor example (Customer Service — refund/complaint archetype):

Criterion Level 1 (Fail) Level 3 (Competent) Level 5 (Mastery/Entrustable)
Empathy Ignores/invalidates emotion Acknowledges emotion before resolution Names emotion preemptively, validates without surrendering policy
Resolution No concrete offer Offers refund OR replacement Offers choice, confirms next steps, anticipates follow-up
De-escalation Matches/escalates hostility Stays calm, doesn't inflame Re-frames hostility into problem-solving, recovers the relationship
Professionalism Profane/impersonates real co Polite, in-role, disclaimer given Models conduct, names policy without hiding behind it

N=3 defensibility (0.62): Defensible only as formative/path-completion; not defensible as high-stakes credential (G-theory suggests G≈0.50.6). Recommendation: label v0.3 VC as formative; reserve N=56 with blueprint coverage for a future high-stakes tier.

Mastery Score computation (0.72): Hybrid — weighted mean of criterion scores + conjunctive floor (every criterion ≥2, scenario mean ≥3.0 to pass). Path-level gate: ≥3 distinct passed scenarios AND additive MasteryScore ≥3.5 over passing scenarios only.

Deterministic scoring (0.80): LLM (deepseek-v4-flash:cloud, temp=0) extracts verbatim quotes + signal tags (JSON-schema-validated) from session turns; deterministic YAML rule engine maps signals → 1-5 levels. Validate quotes fuzzy-match the transcript to block hallucination. No LLM in the numeric scoring step (preserves REQ-NFR-MAST-01).

CS weights (0.70): empathy 0.35, resolution 0.30, de-escalation 0.20, professionalism 0.15. Professionalism = floor (≥2), not weight driver. De-escalation up-weights to ~0.40 if escalate branch triggers. D-039 amendment: per-archetype weights, not one global CS set.

B. Verifiable Credentials Stack

VC Data Model 2.0 (0.85): W3C Recommendation (15 May 2025). D-033 targets a stable standard.

Python library (0.80): No batteries-included VC lib. Assemble: pynacl (Ed25519) + canonicaljson (JCS canonicalization) + base58 (Multikey encoding) + ~200 LOC using the eddsa-jcs-2022 cryptosuite. Avoids RDF canonicalization complexity.

Status List revocation (0.80): Bitstring Status List v1.0 (W3C Recommendation). Fully self-hostable — no third-party service. Minimum viable: one bitstring per status list, indexed by credential sequence.

Issuer ID (0.80): Bare HTTPS URL (https://praxis.example/issuers/v0.3) + self-hosted Multikey public key. did:key rejected — key baked into DID breaks rotation (D-042).

Verification endpoint (0.80): GET /vc/verify/<id>{valid, status, issuer, credential, mastery, verifiedAt}. Verifier fetches public key from verificationMethod URL, validates Ed25519 signature, checks status list. No account, no shared secret.

Credential payload: scenariosPassed (D-032 anti-gaming), rubricScore, completedWeeks: 6 (PRD §6.4), evidence (REQ-NFR-MAST-02). Open: 3-year validUntil is a recommendation (PRD §6.4 silent) — flag for PLAN.

Key rotation (D-042 validated, 0.80): Archive old public key at its original URL (status superseded, not revoked). New key signs new VCs. Old VCs still verify against archived public key.

C. Multi-Tenant Auth + Postgres-in-LXC

Docker-compose shape (0.90): postgres:16-slim, explicit named bridge network (not host), no published port, pgdata named volume, pg_isready healthcheck, depends_on: service_healthy, init scripts at /docker-entrypoint-initdb.d/.

Connection management (0.85): Independent pools — asyncpg create_pool(min_size=2, max_size=10) on app.state via lifespan; aiosqlite per-call connect. Do not share a session. command_timeout=10.

Auth stack (0.90 stack / 0.70 rate-limit): Starlette SessionMiddleware (itsdangerous-signed, httpOnly+Secure+SameSite=Strict, 8h) + argon2-cffi (argon2id, check_needs_rehash) + slowapi 5/min in-memory on login route. Risk: Secure cookie requires TLS — v0.2 pilot is direct-IP no-TLS. Either relax Secure for pilot or add TLS. Flag for PLAN.

Auth dependency pattern (0.90): One current_operator Depends + router-level dependencies=[...] under /op. Migrate to RBAC only when a 2nd role appears.

Postgres schema (0.80): operators (id, username, password_hash, created_at), issued_credentials (id, learner_ref, 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), cohort_aggregates (path, week, window_start, window_end, metric, value, cell_suppressed bool — partition by week). gen_random_uuid() in PG16 (no extension). No cross-DB joins via learner_reflearner_ref is an opaque string, not a FK.

Migration strategy (0.85): Staging-CT first, add service + net + volumes, build new image, then up -d postgresup -d praxis recreate (~515s downtime, SQLite volume untouched → learner path never regresses). Mirror db/migrate.py runner for Postgres (separate db/pg_migrations/ directory).

Backup (0.85): Daily pg_dump -Fc to a pgbackups named volume, %u rolling 7-file retention. Separate from SQLite volume backup. Drill: pg_restore --clean --if-exists.

D. Anonymization + IRT + Scenario Library

k-anonymity (0.85): k=10 + 7-day trailing window is the right floor for v0.3 (<100 learners). Defer l-diversity/t-closeness until a sensitive attribute enters the cohort schema; defer differential privacy until N>1000. SQL: COUNT(DISTINCT learner_id) >= 10 with cell_suppressed sentinel. Limit to pre-defined 2-D views (path × week, path × outcome) to block differencing attacks.

IRT 1PL/Rasch (0.80): P(success) = logistic(θ b). θ₀=0, σ²=1 (Gaussian-approximation Bayesian prior). Update: θ ← θ + (outcome P) × σ²/(σ² + 1) per session; widen σ² after failures. Target P=0.5 for mastery-gate scenarios, P≈0.7 for practice scenarios (per-scenario irt_target_p field). θ reliable after ~510 sessions. Ship 1PL (2PL needs ~200 responses/item — post-pilot). D-046 validated: θ in learner-local SQLite.

Scenario library (0.85): scenarios/<path>/<id>.yaml + scenarios/index.yaml (slim manifest: id, path, title, difficulty, failure_mode, rubric_criteria, version, author, generated_from). Semver per scenario (MAJOR = rubric_criteria/branch changes invalidate gate evidence). AI variations: _pending/ dir + mandatory expert review + generated_from backref + intent_hash for structural-drift detection. Rubric mapping: list of {criterion_id, weight, evidence_required}. MIN_COVERAGE = 2 scenarios per rubric criterion enforced in CI.


v0.3 Risks (for PLAN stage)

ID Risk Mitigation Confidence
R-MAST-01 N=3 gate too thin for credible credential Label v0.3 VC as formative; reserve high-stakes for v0.9 (N=5-6 + blueprint) 0.62
R-MAST-02 LLM hallucinates rubric evidence quotes Fuzzy-match extracted quotes against transcript; reject + re-extract on mismatch 0.80
R-MAST-03 Rubric weights wrong for non-refund CS archetypes Per-archetype weights (D-039 amendment); start with refund/complaint, generalize later 0.70
R-VC-01 No batteries-included Python VC lib → ~200 LOC custom code Use eddsa-jcs-2022 cryptosuite (well-specified); pin pynacl + canonicaljson + base58; unit-test signature/verify round-trip 0.75
R-VC-02 validUntil expiry undefined in PRD Adopt 3-year expiry as default; make configurable; flag in PLAN 0.60
R-AUTH-01 Secure cookie flag fails without TLS (v0.2 is direct-IP no-TLS) Relax Secure for pilot OR add TLS (Traefik sidecar); flag for PLAN 0.70
R-MT-01 Postgres-in-LXC resource contention with praxis service Bump CT memory to 6GB (Postgres ~1GB + praxis ~2GB + build headroom); monitor 0.65
R-MT-02 Cohort aggregation race on concurrent session-end Write-time suppression + nightly reconciliation job (D-045); idempotent upserts 0.70
R-IRT-01 θ unreliable for first ~5-10 sessions (cold start) Fall back to fixed difficulty (scenario.difficulty) until θ has ≥5 observations; show "calibrating" state to learner 0.75
R-LIB-01 AI variations drift from expert intent intent_hash structural-drift detection + mandatory expert review before _pending/ → library promotion 0.75

v0.3 Open Questions for PLAN Stage

  1. Secure cookie + no-TLS pilot: Relax Secure flag for v0.3 pilot (direct-IP), or add a Traefik sidecar for TLS? (R-AUTH-01)
  2. CT memory bump: v0.2 CT is 4GB. Postgres + praxis + build headroom may need 6GB. Confirm via staging-CT test. (R-MT-01)
  3. VC validUntil: Adopt 3-year default? Make per-path configurable? (R-VC-02)
  4. Phase split: Is v0.3 one execution phase or 2-3? Scope (mastery core + scenarios + paths + VC + auth + Postgres + dashboard) suggests 2-3 phases. Planner decides.
  5. Rubric per-archetype weights: Ship refund/complaint weights only in v0.3, or author weights for ≥2 archetypes? (R-MAST-03)
  6. IRT cold-start UX: Show "calibrating difficulty" to learner, or hide it? (R-IRT-01)
  7. Failure-injection coupling: RESEARCH confirms D-049 — no active failure injection in v0.3. Confirm no hidden coupling to mastery scoring.