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---
60 KiB
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)
-
Docker-in-LXC is well-supported on Proxmox 8 with
nesting=1. The Proxmox wiki explicitly documentsnestingas 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.ioapt 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 becausenet0=bridge=vmbr0,ip=dhcpgives the CT its own netns. Nokeyctlor AppArmor adjustments needed for the standard unprivileged+nesting path on Proxmox 8. (Confidence: 0.85) -
Build-inside-CT needs a resource bump. The coreci default (2GB memory, 8GB rootfs) is too tight for
docker buildwith Pipecat's native-extension deps (numpy, aiohttp, pipecat-ai[webrtc]). Recommend 4GB memory, 16GB rootfs.docker-compose-v2is available in Debian 12 Bookworm repos as an apt package. (Confidence: 0.80) -
FastAPI StaticFiles with
html=Trueis 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) -
Multi-stage Dockerfile: Node 22-slim → Python 3.12-slim, run via
python -m server. Node stage buildsclient/distwith cachednpm ci. Python stage installs deps frompyproject.toml, copiesclient/distfrom the Node stage, copiesserver/+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) -
firstboot-hook: install Docker → clone repo → build + compose up. The hook runs on the PVE host (post-start phase) and uses
pct execto run commands inside the CT. Sequence: (a)pct execapt-install docker.io + docker-compose-v2, (b)pct execgit clone from Gitea using GITEA_TOKEN, (c)pct execdocker 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) -
Secret injection chain: lxc.environment → /etc/praxis/server.env → docker-compose env_file → container. Validated.
lxc-config.shSSH step writeslxc.environment: KEY=VALlines to/etc/pve/lxc/<vmid>.conf. CT boots → systemd has these env vars.install-service.shreads them and writes/etc/praxis/server.env.docker-compose.ymlreferencesenv_file: /etc/praxis/server.env. praxis.gitignorecovers.env,.env.secrets,.env.*— secrets are gitignored. ✅ (Confidence: 0.90) -
Health-check: bump timeout to 300s for Docker build inside CT. Coreci's
health-check.shqueries PVE/interfacesfor the bridge IP — works for vmbr0 DHCP CTs. The/health:8789endpoint (not/healthz:18080) is the praxis target. Docker build + compose up may take 3-5 min; the default 180s timeout is insufficient. UsePRAXIS_HEALTH_TIMEOUT=300. (Confidence: 0.90) -
Systemd unit:
Type=simplewithdocker compose up(foreground, no -d).docker compose up -dis fire-and-forget →Type=oneshotloses 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'srestart: unless-stoppedpolicy is a second layer. (Confidence: 0.85) -
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)
-
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. Nofuse-overlayfsneeded (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.iopackage in Debian 12, which is Docker 24.x+) fully supports cgroups v2. Thenesting=1feature 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=dhcpgives 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):
keyctlsyscall: Blocked in unprivileged LXC by default. Some Docker operations (registry auth with keyring) may warn. In practice,docker build+docker compose upwithout registry auth is unaffected. Ifdocker loginis needed later,lxc.cap.dropadjustment may be required. Not a v0.2 concern (no registry; build from local source).- 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:unconfinedis the escape hatch (less secure, but functional). Not expected for v0.2. - 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).
- 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
.envshowsPROXMOX_STORAGE=localwhich 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
.envshowing a real node hostname. - CT rootfs storage is
local(directory or LVM-thin), not ZFS — based onPROXMOX_STORAGE=localin 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 cifor the client: 5 dependencies (react, react-dom, pipecat client SDK, small). ~300-500MB peak. Fine at 2GB.pip installfor 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(needslibasound2-devat 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-v2as an apt package. Confirmed: the package is in the Bookworm main repository. Install viaapt-get install -y docker.io docker-compose-v2. - The
docker composesubcommand (v2 plugin syntax) is available after installingdocker-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-slimalso fine). Not Alpine — Vite/esbuild may have musl issues. - Cache:
package.json+package-lock.jsoncopied before source →npm cilayer 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 existingserver/__main__.pyentrypoint which callsuvicorn.run(app, host=HOST, port=PORT). This readsPRAXIS_HOST/PRAXIS_PORTfrom env (defaults0.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 pushof a full repo (with.git) is awkward —pct pushworks file-by-file, not recursive directories. A tarball +pct push+pct exec tar -xis more steps thangit clone.- Git clone gives version traceability (
git loginside the CT).
Why install-service.sh runs AFTER compose up:
install-service.shcreates thepraxisuser,/etc/praxis/server.env, and the systemd unit.- The systemd unit runs
docker compose up(foreground). But the firstboot hook already randocker compose up -din 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 upis what actually runs the service. The hook should: install Docker → clone → install-service.sh (creates env + unit + starts service viasystemctl restart praxis) → health-check. Thedocker compose buildhappens as part ofsystemctl 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 runsdocker compose upwhich builds if needed (or a pre-build ExecStartPre runsdocker compose build).
Safest final sequence:
pct exec— installdocker.io docker-compose-v2 git curlpct exec—git clonerepo to/opt/praxispct exec— runinstall-service.shwhich:- Creates
praxisuser + dirs - Writes
/etc/praxis/server.envfrom lxc.environment vars - Writes
praxis.servicesystemd unit (withExecStartPre=docker compose build,ExecStart=docker compose up) systemctl daemon-reload && systemctl enable praxis && systemctl restart praxis
- Creates
- External
health-check.shpolls/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.devand Debian apt mirrors (confirmed by D-028/D-029/D-030 choosing inside-CT operations). GITEA_TOKENis passed vialxc.environmentand available inside the CT.- systemd's
TimeoutStartSeccan be extended for the build step (default 90s is too short fordocker 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):
proxmoxscope: 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.releasescope: GITEA_TOKEN — in praxis's.ciagent/.env.secrets.voicescope: 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):
- If
CORECI_HEALTH_URLis set, use it directly. - Otherwise, query PVE
/nodes/{node}/lxc/{vmid}/interfacesfor the bridge IP. - Extract first non-loopback IPv4 (
.inetor.ipfield, NOT.hwaddr— P18 bug fix). - Construct
http://<ip>:<port>/healthz. - Poll with curl for
CORECI_HEALTH_TIMEOUTseconds (default 180).
Praxis adaptations:
- Endpoint:
/health(not/healthz) — fromserver/__main__.pyline 61. - Port:
8789(not18080) — fromPRAXIS_PORTdefault. - Env var names:
PRAXIS_HEALTH_URL,PRAXIS_HTTP_PORT,PRAXIS_HEALTH_TIMEOUT(rename fromCORECI_*). - 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 upstarts 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 -dstarts 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'srestartpolicy 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:
ExecStartPre=docker compose build— builds the image (fast if cached, ~2 min first time).TimeoutStartSec=300gives 5 min.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.- If a container crashes,
docker compose upexits → systemd sees the service exit →Restart=on-failurerestarts it (which re-runs compose up). ExecStop=docker compose down— graceful shutdown onsystemctl stop.Restart=on-failure+ Docker'srestart: unless-stoppedin 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
praxisuser is added to thedockergroup byinstall-service.sh(sodocker composeworks without sudo). TimeoutStartSec=300applies 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
localstorage (directory or LVM-thin) has plenty of IOPS for a one-time build. - Docker's build cache lives in
/var/lib/dockeron 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.batslive path — re-deploy against existing healthy CT.- Smoke test —
curl http://<ct-ip>:8789/healthreturns{"status":"ok"}.
Additional praxis-specific tests (not in coreci):
Dockerfilebuild test —docker build -t praxis-test .succeeds locally (no Proxmox needed, just Docker).docker-compose.ymlvalidation —docker compose configparses.- FastAPI StaticFiles test —
GET /returns index.html,GET /healthreturns JSON,GET /pipecat/webrtcis 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
- ExecStartPre vs separate build service: Should
docker compose buildbe anExecStartPreinpraxis.serviceor a separatepraxis-build.service(Type=oneshot) thatpraxis.serviceRequires=? The latter is cleaner but adds a service file. - Docker layer cleanup: Should
install-service.shrundocker system prune -fafter the first successful build to reclaim ~1GB of build layers? - Repo update path: When praxis code changes, how is the CT updated? Options: (a)
pct exec git pull && systemctl restart praxis(re-builds), (b)--reconfigureflag in lxc-deploy.sh that re-runs the hook, (c) a separatescripts/proxmox/lxc-update.sh. Not a v0.2 blocker (first deploy only) but should be designed for. - PRAXIS_DB_PATH in container: The docker-compose volume mounts to
/app/data.PRAXIS_DB_PATHenv should be set to/app/data/praxis.dbinserver.env. Confirm the server respects this path (current default:./praxis.dbrelative 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-executionStatus: 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 indocs/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)
-
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).
-
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.
-
N=3 mastery gate is defensible ONLY as formative/path-completion, NOT high-stakes credentialing. (0.62) Generalizability theory suggests G≈0.5–0.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=5–6 + blueprint coverage for a future high-stakes tier (v0.9 credentialing milestone). Keep the remediation loop (Bloom mastery learning lives there).
-
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).
-
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.
-
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>.yamlor a weights override in the scenario file. -
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 theeddsa-jcs-2022cryptosuite (avoids RDF canonicalization complexity). Bitstring Status List v1.0 is also a W3C Recommendation — fully self-hostable, no third-party service. -
Issuer ID = bare HTTPS URL (
https://praxis.example/issuers/v0.3) + self-hosted Multikey public key. (0.80)did:keyrejected 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 (statussuperseded, not revoked — old VCs still verify). -
Verification endpoint returns
{valid, status, issuer, credential, mastery, verifiedAt}. (0.80) Verifier fetches the public key from theverificationMethodURL, 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-yearvalidUntilis a recommendation (PRD §6.4 silent on expiry) — flag for PLAN. -
Operator Postgres =
postgres:16-slimas a second docker-compose service on an explicit named bridge network, no published port. (0.90)pgdatanamed volume,pg_isreadyhealthcheck,depends_on: service_healthy, init scripts at/docker-entrypoint-initdb.d/. asyncpgcreate_pool(min_size=2, max_size=10)onapp.statevia lifespan; do not share a session with aiosqlite. New pip deps:asyncpg>=0.29,argon2-cffi>=23.1,slowapi>=0.1. -
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) Onecurrent_operatorDepends+ router-leveldependencies=[...]under/op. Migrate to RBAC only when a 2nd role appears. -
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, thenup -d postgres→up -d praxisrecreate (~5–15s downtime, SQLite volume untouched → learner path never regresses). Mirror the existingdb/migrate.pyrunner for Postgres (separate migration directory). -
Backup: daily
pg_dump -Fcto apgbackupsnamed volume,%urolling 7-file retention. (0.85) Separate from the SQLite volume backup. Drill withpg_restore --clean --if-exists. -
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) >= 10withcell_suppressedsentinel; limit to pre-defined 2-D views to block differencing attacks. -
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_pfield). θ reliable after ~5–10 sessions. Ship 1PL (2PL needs ~200 responses/item — post-pilot). D-046 validated: θ persists in learner-local SQLite (learner_abilitytable: learner_id, path, theta, updated_at). -
Scenario library:
index.yamlas 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_frombackref +intent_hashfor structural-drift detection. Rubric mapping as list of{criterion_id, weight, evidence_required}objects.MIN_COVERAGE = 2scenarios 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.5–0.6). Recommendation: label v0.3 VC as formative; reserve N=5–6 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_ref — learner_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 postgres → up -d praxis recreate (~5–15s 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 ~5–10 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
- Secure cookie + no-TLS pilot: Relax
Secureflag for v0.3 pilot (direct-IP), or add a Traefik sidecar for TLS? (R-AUTH-01) - CT memory bump: v0.2 CT is 4GB. Postgres + praxis + build headroom may need 6GB. Confirm via staging-CT test. (R-MT-01)
- VC
validUntil: Adopt 3-year default? Make per-path configurable? (R-VC-02) - 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.
- Rubric per-archetype weights: Ship refund/complaint weights only in v0.3, or author weights for ≥2 archetypes? (R-MAST-03)
- IRT cold-start UX: Show "calibrating difficulty" to learner, or hide it? (R-IRT-01)
- Failure-injection coupling: RESEARCH confirms D-049 — no active failure injection in v0.3. Confirm no hidden coupling to mastery scoring.