# RootNode Academy — Engineering SOP
Living doc. Covers the "how" of building this platform — the "why" and business/curriculum
process live in the Business Pack (`~/Documents/CDAA Technologies/07 RootNode academy/RootNode_Academy_Business_Pack/`),
this doc doesn't duplicate that. Update this file in the same commit as the change it describes.
## 1. Where this actually lives
- **Repo:** `~/Dev/Gitea/ced4568/rootnode-academy` — remote is **Gitea**
(`git.synthossystems.com/ced4568/rootnode-academy`), not GitHub.
- **Gotcha:** `~/Dev/GitHub/ced4568/kubekosh` is a *different* clone of the same original
upstream fork, still pointed at GitHub, and it's behind (missing the RootNode logo commit
and the Real Ops enforce-limits scenario as of this writing). It's not deployed anywhere.
Don't edit it by mistake — always confirm `git remote -v` shows `gitea:` before making changes.
- Known-good baseline as of this doc: commit `6ddfb1c` ("add probe-port-mismatch and
enforce-limits to rootnode-real-ops bundle"). Everything below builds on top of that,
clean working tree.
## 2. Architecture at a glance
```
backend/server.js Express API + SQLite (better-sqlite3) + WebSocket PTY terminal — the
"Lab image," single-tenant, unmodified by the Platform work below
frontend/ Vite + React SPA, built to frontend/dist and served by nginx
scenarios/data/ One JSON file per scenario (task or MCQ)
scenarios/bundles/ Flat scenario_id lists grouped by cert track (basics/appdev/admin/security/real-ops)
scenarios/rootnode-path.json The RootNode guided learning path (staged, see §4)
scripts/entrypoint.sh Container boot: starts k3s, nginx, node backend, sets the shell banner/PS1
Dockerfile ubuntu:22.04 + k3s + node20 + nginx, single image, one port (80)
platform/provisioner/ Phase 1 Platform Provisioner — disposable per-student Lab lifecycle,
PostgreSQL registry, egress firewall. See §8 and
platform/provisioner/README.md.
platform/egress-proxy/ Shared allowlisting egress proxy every Lab environment's traffic must
pass through.
```
Single Express process, single SQLite file at `/data/progress.db` (or `$PROGRESS_DB`),
single embedded k3s cluster **per Lab environment**. The Lab image itself is still
single-tenant by design — see §5 for identity, and §8 for how Phase 1 multiplies it into a
real multi-student platform without modifying it.
## 3. Local dev / safe smoke-testing
Never test schema changes against the real `/data/progress.db` first. Point at a scratch file:
```bash
cd backend && npm install # first time only
node --check server.js # syntax check, catches typos fast
PROGRESS_DB=/tmp/scratch/progress.db node server.js &
curl -s http://localhost:4000/api/health
# ...exercise the endpoints you changed...
fuser -k 4000/tcp # stop it
```
To test a migration against **real prior data** without touching prod: copy the actual
`/data/progress.db` out of the running container/volume, point `PROGRESS_DB` at the copy,
boot, and verify the data you expect is still there before ever pointing at the real file.
kubectl/k3s-backed endpoints (`/setup`, `/validate`, `/context`, the WS terminal) won't work
outside the container — that's expected. API-and-DB-only endpoints (roster, login, progress,
curriculum, bundles, scenarios list) all smoke-test fine on a bare host.
## 4. Curriculum content
Two separate structures, don't confuse them:
- **Bundles** (`scenarios/bundles/*.json`) — flat scenario_id lists, one per cert track.
Loaded by `loadBundles()`/`GET /api/bundles`. Adding a bundle file here = adding a new
cert-track library. Every scenario referenced must have a matching file in `scenarios/data/`.
- **RootNode path** (`scenarios/rootnode-path.json`) — the guided onboarding sequence,
separate from bundles on purpose (it's *staged*, bundles are *flat*, and the bundle loader
would choke on a staged file if it were dropped in `scenarios/bundles/`). Loaded by
`loadCurriculumPath()`/`GET /api/curriculum/path` (raw content) and, as of Phase 3B,
`computeCurriculumState()`/`GET /api/curriculum/state` (rule-driven lock/complete/next-scenario
state — see `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §4). Stages are Foundation → Workloads →
Networking → Configuration → Troubleshooting → Real Ops → Capstone, built by reusing existing
`1-k8s-basics` and `rootnode-real-ops` scenarios grouped by their existing `category` field —
no new scenario content was written for this. Each stage carries a `completion_rule`
(`all_required`/`threshold`/`prerequisites`). **Capstone is no longer a hardcoded placeholder**
— it's `"type": "capstone"` with an `"unlock_rule"` and a `"capstone_assessment_id"` pointing
at the `rootnode-capstone` assessment definition (Postgres, `assessment_definitions` table),
which is where its actual scenario draw lives — see the Phase 3B doc §7. Don't put real
scenarios directly into the JSON stage's `scenario_ids` for capstone; that field stays empty
by design, the draw is defined in the assessment definition instead.
To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
`scenarios/data/*.json` ids. No backend code change needed — it's read fresh via
`POST /api/cache/reload` or on server restart.
## 5. Student identity — what's built, what isn't
**Built (backend only, as of this doc):**
- `students` table: `student_id` (`student-NNN`, sequential), `display_name`, `cohort`,
`created_at`, `last_active_at`.
- Login model: **instructor roster, pick-your-name, no password.** `POST /api/roster` adds
a student (instructor-only action, currently unauthenticated at the API level — see
"not built" below). `GET /api/roster?cohort=X` lists names for a dropdown.
`POST /api/login` just validates the id exists and returns it; the frontend is expected to
hold onto `student_id` and send it back as an `X-Student-Id` header on every request.
- `progress` table now keyed on `(student_id, scenario_id)` instead of `scenario_id` alone —
each student's completion/attempts/hints are isolated. Verified by smoke test: two students
answering the same scenario don't affect each other's state, and pre-migration data was
preserved under `student_id = 'unassigned'`.
- Exam `sessions` now carry `student_id` (set at session start), so exam history and the
timer-reset-on-submit logic are correctly scoped per student instead of leaking across
whoever's browser hits the API next.
**Not built yet — do not assume these exist:**
- **No frontend UI.** No login screen, no name picker, no roster-add form. The API works;
nothing calls it yet. This is the next slice.
- **No auth on `/api/roster` POST.** Anyone who can reach the API can add a "student."
Fine for a dev instance, not fine once this is reachable by actual students — needs an
instructor-only gate before that happens (ties into the instructor dashboard, roadmap
item 3).
- **No concurrency / isolation.** Exam sessions are still global-single-active
(`WHERE status='active' LIMIT 1` — see `server.js`), and there is one shared k3s cluster
for the whole instance. Per-student identity ≠ per-student environment. **Multiple students
cannot yet use this platform at the same time without stepping on each other.** That's
roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called
out — this SOP is not claiming that gate is cleared.
## 6. Schema migration pattern
SQLite can't `ALTER` a primary key. The codebase's existing convention (see `progress` table
history) is:
1. `PRAGMA table_info(
)` → check if the new column/shape is already there (idempotent,
safe to run on every boot).
2. Simple new nullable column → `ALTER TABLE ... ADD COLUMN`.
3. Primary-key or structural change → build `_new` with the new shape, `INSERT ...
SELECT` the old data across with sane defaults for new required columns, `DROP TABLE`,
`RENAME TO`. Never drop data — give it a legacy/default value (`'unassigned'`, etc.)
instead of discarding rows.
4. Test the migration against a **copy of real data** (§3) before it ever runs against
`/data/progress.db` in the container.
## 7. Roadmap status
Order agreed 2026-08-17/18. Status as of this doc:
1. RootNode identity/branding pass — **partial.** Shell banner fixed
(`⎈ RootNode Academy Lab — node: k8s-lab`). Upstream KubeKosh attribution left untouched in
footer/README/LICENSE. Broader "make Real Ops first-class" UI work not started.
2. RootNode curriculum mode — **done**. Rule-driven stage completion/unlock as of Phase 3B
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
`frontend/src/platform/`.
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
identification, not strong student auth — documented, not silently glossed over
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
owner/admin/instructor roles, audit log.
5. Per-student isolation — **Phase 1 + Phase 2A + Phase 2B + Phase 3A done**, see §8/§9/§10.
Ephemeral per-student Lab environments, Gateway-mediated routing with persistent
server-controlled sessions, a full instructor/admin control plane with real per-person
accounts and RBAC, and a working entry→dashboard→launch→lab→progress flow are implemented
and end-to-end tested on a single dev machine, including a 3-student cohort classroom
workflow and a real Platform API restart mid-session. **Still not cleared for minors** — the
production two-VM Proxmox split this depends on doesn't exist yet, and OIDC/SSO is a
documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
§16).
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
7. Capstone/exam engine — **done** as of Phase 3B (§12). RootNode Capstone (Real Ops-weighted,
unlockable by curriculum-completion rule, not a permanent placeholder) plus a general exam
engine (definitions, cohort assignment, random scenario draw, server-authoritative timer).
8. Progressive hint levels — **done** as of Phase 3B (§12). Server-enforced sequential reveal,
tracked per attempt, policy-aware under assessment mode (free/disabled/flagged).
9. Production observability — not started.
10. CI scenario testing — not started.
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
Next slice: production OIDC/SSO, the production two-VM Proxmox deployment, and teaching-module
scenario content are the biggest remaining items. See
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §21 for the full Phase 4 boundary.
## 8. Platform Provisioner (Phase 1)
Full design: `docs/LAB_ARCHITECTURE.md` + `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational
reference (config, local dev, known limitations): `platform/provisioner/README.md`. This
section is just the map.
- **What it is:** an internal-only service that provisions a disposable, fully isolated Lab
environment per student — its own Docker network, its own container running the existing,
unmodified Lab image, its own firewall-enforced egress. Postgres-backed registry
(`environments`, `environment_events`, `lab_hosts`), idle reaper, startup reconciliation.
- **What it is not:** directly reachable by a student or the Gateway anymore — as of Phase 2B,
the Platform API (§9) is the *only* caller (student launch/reset AND admin prepare/destroy/
reset both go through it). Still exercised directly via its internal API (shared-secret
auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing.
- **Where identity lives:** as of Phase 2B, Postgres is authoritative —
`students`/`cohorts`/`progress`/`scenario_attempts` live in the same `rootnode_platform`
database as `environments`, owned by the Platform API's own migrations. See §9. As of Phase
3A, `instructors`/`sessions`/`cohort_instructors`/`audit_events` live there too. See §10.
`environments.student_id` is still a plain opaque reference, not a real FK — that's a
deliberate, documented choice (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §2), not an oversight.
- **Local dev:** `platform/provisioner/docker-compose.dev.yml` runs Postgres + the egress
proxy + the Provisioner on one machine — the documented Phase 1 dev exception. Production
is a two-VM Proxmox split (Platform VM / Lab Host VM); the dev compose file is explicitly
not that.
- **Test procedure:** `platform/provisioner/test/run.js` — 12 automated integration tests
against a live dev stack (isolation, firewall ordering, reset/destroy cleanup, audit
events). Some properties (idle reaper timing, remote TLS lab-host, reconciliation-on-crash,
real-image k3s behavior) were validated manually during implementation rather than
automated — see the Phase 1 final report for what and why.
## 9. Platform API (Phase 2B)
Full design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference:
`platform/api/README.md`.
- **What it is:** `platform/api/` — a lightweight Node/Express service (no k3s, no
`--privileged`, no Lab terminal runtime) owning students, cohorts, enrollments, curriculum
assignments, progress, scenario attempts, and — as of Phase 3A — instructor accounts,
persistent sessions, and the audit log (§10). The sole caller of the Provisioner for both the
student launch/reset path and the instructor/admin control plane.
- **What it is not:** reachable directly by a browser — always through the Gateway
(`platform/gateway`), same trust boundary the Provisioner has relative to this service.
- **Local dev:** part of the same `platform/docker-compose.dev.yml` stack as the Provisioner —
no separate compose file.
- **Test procedure:** `platform/test/e2e-phase2a.js` (student flow) and
`platform/test/e2e-phase2b.js` (classroom acceptance workflow) — both need a bootstrapped
instructor account as of Phase 3A, see §10.
## 10. Identity and instructor access (Phase 3A)
Full design: `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`. Operational reference:
`platform/api/README.md` (bootstrap, config).
- **What it is:** persistent Postgres-backed sessions (student + instructor, survive a Platform
API restart), real per-instructor accounts (`owner`/`admin`/`instructor` roles, scrypt
password hashing, per-instructor salt), server-enforced RBAC on every `/api/admin/*` route,
cohort-scoped access for the plain `instructor` role, CSRF double-submit protection, login
rate limiting, and a platform-level `audit_events` log.
- **What it replaced:** Phase 2B's in-memory session `Map`s (gone on every restart) and the
single shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation).
- **Bootstrap:** `node platform/api/bootstrap-owner.js ` — the only
supported way to create the first instructor account; no manual database editing.
- **Test procedure:** `platform/test/e2e-phase3a.js` — 21 automated tests covering session
persistence (including a real `docker compose restart api`), instructor auth, RBAC, cohort
scoping, CSRF, rate limiting, password change, and audit logging.
- **Known limitations, future OIDC path, production HTTPS expectations:** see
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §15–17.
## 11. Dependency lock convention
Every first-party Node service commits its `package-lock.json`: `backend/`, `frontend/`,
`platform/api/`, `platform/gateway/`, `platform/provisioner/`. This wasn't consistently true
before Phase 3B — `platform/api/`'s lockfile existed locally but was never committed, and
`platform/gateway/`/`platform/provisioner/` never had one generated at all. Neither was a
deliberate policy; both were oversights, fixed in Phase 3B (`chore(deps)` commits).
Going forward: after any `npm install` that changes a service's dependencies, the updated
`package-lock.json` is part of the same commit as the `package.json` change — never left
untracked, never regenerated-and-ignored. Validate a lockfile actually matches its
`package.json` with `npm ci` (fails loudly on drift) before committing either.
## 12. Learning & Assessment Engine (Phase 3B)
Full design: `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
- **What it is:** the teaching/assessment layer on top of everything Phases 1–3A built —
detailed per-attempt telemetry (`scenario_attempts`, evolved from Phase 2B's one-row-per-click
shape into a real attempt lifecycle), server-enforced progressive hints, rule-driven
curriculum stage/capstone unlock logic, a full exam + capstone assessment engine
(`assessment_definitions`/`assessment_sessions`/`assessment_session_scenarios`), and
persistent `achievements` records.
- **Where the boundary moved:** the Gateway now rewrites one Lab response
(`GET /lab/:token/api/scenarios/:scenarioId`, stripping `hints`) so progressive hints can be
genuinely server-gated rather than just UI-collapsed — the Lab image's own code/bytes are
still untouched; this is a response rewrite at the Gateway boundary, same pattern as every
other Lab-mediation decision made in earlier phases. The Lab's own frontend bundle *did*
change this phase (`ScenarioPanel.jsx`, new `ProgressiveHints`/`AssessmentBanner` components)
— Phase 3B explicitly authorized enhancing Lab UX, unlike prior phases.
- **Test procedure:** `platform/test/e2e-phase3b.js` — covers attempt telemetry, hint
sequencing/persistence-through-Lab-reset, curriculum/capstone lock-unlock, the exam engine
(random draw, timer, expiry, restart persistence), two-student isolation, and instructor
analytics/RBAC.
- **Known limitations:** see the Phase 3B doc §20 — no automatic abandoned-attempt detection, no
per-cohort needs-attention threshold configuration, raw-JSON pool_spec authoring in the admin
UI (correct, not yet a guided form).