docs(phase2b): document admin and platform-data architecture

New docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md covers the Platform API
split, Postgres schema and migration guarantees/rollback, the session
cookie architecture (including the whoami-routing-bug fix), the admin
portal, the API/security boundary, known limitations, and Phase 3+
future work.

Updates ENGINEERING_SOP.md §8, LAB_ARCHITECTURE.md, README.md, and
platform/README.md to reflect Platform API as the sole Provisioner
caller and Postgres as authoritative for identity/progress as of
Phase 2B.
This commit is contained in:
2026-08-18 12:23:17 -05:00
parent 6f1d202242
commit 08ff5315de
5 changed files with 278 additions and 40 deletions
+19 -7
View File
@@ -134,11 +134,18 @@ layers rather than changing the image itself:
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker - `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`). networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity → environment - `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
→ routing token, proxies HTTP/WebSocket into that one student's container, never a raw scenario attempts, student/admin sessions, instructor/admin control plane. No k3s, no
container IP/port (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`). `--privileged` (`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
server-controlled session cookie (not a client-suppliable header/param), proxies
HTTP/WebSocket into that one student's container, never a raw container IP/port
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session → - The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
Reset/Recreate) lives in `frontend/src/platform/` — a small addition alongside the existing, Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal
(cohorts, students, classroom "Prepare Labs," environment controls) lives in
`frontend/src/admin/` at the `/admin` route — both small additions alongside the existing,
unmodified Lab UI, not a rewrite of it. unmodified Lab UI, not a rewrite of it.
**Never publish the raw privileged container** — front it with isolation (that's what the **Never publish the raw privileged container** — front it with isolation (that's what the
@@ -151,9 +158,14 @@ Key properties of the platform (not this image):
- One container **per student**, its own Docker network, its own firewall chain — never a - One container **per student**, its own Docker network, its own firewall chain — never a
shared cluster between learners. Verified with two simultaneous students: separate clusters, shared cluster between learners. Verified with two simultaneous students: separate clusters,
separate progress, no cross-access. separate progress, no cross-access.
- Progress/identity live outside the disposable container (a persistent identity instance - Progress/identity live outside the disposable container, in the Platform API's own
running this same image, currently SQLite-backed, moving to the Platform's PostgreSQL in a PostgreSQL (authoritative as of Phase 2B) — destroying or recreating a student's Lab never
later phase) — destroying or recreating a student's Lab never loses their academic record. loses their academic record. Verified with a full class-sized (3-student) cohort workflow:
provision, complete different scenarios, reset one student's Lab, destroy all Labs after
class — progress survives every step.
- Instructors manage cohorts, students, curriculum assignment, and classroom Lab prep
(provision an entire cohort before class, verify readiness, destroy Labs after) from
`/admin` — no manual database, Docker, or Kubernetes access needed.
- CPU/memory/PID/storage limits are enforced per container, config-driven. - CPU/memory/PID/storage limits are enforced per container, config-driven.
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open. - Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh - Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
+9 -7
View File
@@ -171,13 +171,15 @@ section is just the map.
environment per student — its own Docker network, its own container running the existing, environment per student — its own Docker network, its own container running the existing,
unmodified Lab image, its own firewall-enforced egress. Postgres-backed registry unmodified Lab image, its own firewall-enforced egress. Postgres-backed registry
(`environments`, `environment_events`, `lab_hosts`), idle reaper, startup reconciliation. (`environments`, `environment_events`, `lab_hosts`), idle reaper, startup reconciliation.
- **What it is not:** directly reachable by a student, even now — the Gateway (§9) sits in - **What it is not:** directly reachable by a student or the Gateway anymore — as of Phase 2B,
front of it and is the only caller. Still exercised directly via its internal API the Platform API (§9) is the *only* caller (student launch/reset AND admin prepare/destroy/
(shared-secret auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing. reset both go through it). Still exercised directly via its internal API (shared-secret
- **Where identity lives:** unchanged from §5 — `students`/`progress` are still on the Lab auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing.
image's own SQLite. `environments.student_id` is deliberately opaque (no FK) until a later, - **Where identity lives:** as of Phase 2B, Postgres is authoritative —
separate migration puts roster/progress on the same Postgres instance as the environment `students`/`cohorts`/`progress`/`scenario_attempts` live in the same `rootnode_platform`
registry. database as `environments`, owned by the Platform API's own migrations. See §9.
`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 - **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 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 is a two-VM Proxmox split (Platform VM / Lab Host VM); the dev compose file is explicitly
+8 -7
View File
@@ -1,12 +1,13 @@
# RootNode Academy — Ephemeral Lab Architecture # RootNode Academy — Ephemeral Lab Architecture
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) IMPLEMENTED, Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md` and (Platform API split, Postgres academic data, server sessions, admin portal) IMPLEMENTED,
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` for the concrete schema/API/test results, and 2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`platform/provisioner/README.md` for how to run the stack. This document remains the and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for concrete schema/API/test results, and
architecture record for the full ephemeral-lab direction; instructor dashboard, production `platform/README.md` for how to run the stack. This document remains the architecture record
auth/SSO, and production Proxmox deployment are still future work (Phase 2B — see the Phase 2A for the full ephemeral-lab direction; production OIDC/SSO, per-instructor accounts, and
doc's §9). Supersedes the namespace-isolation production Proxmox deployment are still future work (Phase 3+ — see the Phase 2B doc's §13).
Supersedes the namespace-isolation
(`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see (`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see
`scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here. `scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here.
+213
View File
@@ -0,0 +1,213 @@
# RootNode Academy — Phase 2B: Admin Portal & Platform Data
Status: **IMPLEMENTED and end-to-end tested, 2026-08-18.** Builds on
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (Phase 2A milestone
`42dfb1c31b599d5e86b64915beb4b871b2e91055`). This doc covers: the platform/lab split, the
Postgres academic schema and SQLite migration, server-controlled sessions, and the
instructor/admin control plane.
---
## 1. Split: Platform API vs. Lab image
Phase 2A's "identity" instance was a full, `--privileged` Lab image booting an unused k3s
cluster just to serve roster/curriculum/progress APIs. Phase 2B replaces it with
`platform/api/` — a plain Node/Express service: no k3s, no `--privileged`, no terminal
runtime, no nginx (serves the built frontend directly via `express.static`). Startup is
instant instead of a 1-2 minute k3s cold start — directly observable in testing (see §8).
The Lab image (`Dockerfile`, `backend/server.js`, `scripts/entrypoint.sh`) is **completely
unchanged in this phase** — it's still the disposable-per-student, nested-k3s, terminal +
scenario-execution engine, exactly as Phase 1/2A built it. Platform API never runs
setup/validate/teardown commands itself; it only relays outcomes the Lab already produced
(see §7, "Progress relay," carried over from Phase 2A and extended).
Platform API owns: students, cohorts, enrollments, curriculum assignments, progress, scenario
attempts, student/admin session identity, and — new in Phase 2B — **all** Provisioner calls,
both student-initiated (launch/reset) and admin-initiated (prepare-labs, destroy-labs, reset,
destroy). The Gateway no longer calls the Provisioner directly at all; it proxies
`/api/environment/*` and `/api/admin/*` to Platform API, which is now the single place that
holds the Provisioner's shared secret.
---
## 2. PostgreSQL academic schema
New tables in the existing `rootnode_platform` database (same instance the Provisioner's
`lab_hosts`/`environments`/`environment_events` already live in — different migration
directory, same Postgres):
- `students` — `student_id` stays the same opaque TEXT identifier used everywhere else
(no breaking change to `environments.student_id`, which remains a plain reference with no
FK, exactly as Phase 1 designed it).
- `cohorts` / `cohort_enrollments` — replaces the old single `students.cohort` TEXT column
with a real many-to-many model (a student can move between cohorts over time; the old
model couldn't represent that at all).
- `curriculum_assignments` — records which curriculum path a cohort or student is on. Only
one path exists today (`rootnode-path`) — this table exists so assigning it is a real,
auditable instructor action, not because multiple paths exist yet.
- `progress` — same shape as the SQLite table it replaces.
- `scenario_attempts` — **new capability**, not a migration of anything that existed before.
One row per validate/answer outcome (result, validation count, timestamp, which
environment), giving real per-attempt history instead of just an aggregate status +
counter. `hint_count` is present but always NULL — the existing Lab engine's hints are
static UI content only (`frontend/src/components/ScenarioPanel.jsx`), nothing server-side
ever records a hint view. Documented here rather than inventing a number; the column is
ready if hint-view tracking is ever added to the Lab engine.
- `exam_sessions` — historical import of the old SQLite `sessions` table. **Scope note:**
exam mode's *live* session bookkeeping (start/shuffle/submit/abandon) stays Lab-image-local
for Phase 2B — productionizing it would need the same relay pattern progress got in Phase
2A, and it isn't exercised by the Phase 2B acceptance test. This table makes historical exam
records survive the migration and visible to instructors; it isn't written to by new exam
sessions yet. Flagged as a real gap, not silently ignored — see §13.
## 3. SQLite → Postgres migration
`platform/api/migrate-sqlite.js`. Run once as the cutover:
```bash
SQLITE_PATH=/path/to/progress.db PLATFORM_DB_URL=postgres://... node migrate-sqlite.js
```
- **Backs up first, unconditionally** — copies the SQLite file to
`<path>.backup-<timestamp>` before reading anything. The backup is never deleted or
modified by the script.
- **Idempotent** — every write uses `ON CONFLICT`; re-running re-syncs from the SQLite source
rather than duplicating or erroring.
- **Never silently drops data** — prints and writes a JSON report
(`migration-report-<timestamp>.json`) with exact counts (students, progress rows,
exam_sessions, cohorts created, enrollments created) and anything that couldn't be read
(e.g. a missing table), with the reason.
- **Preserves the old `students.cohort` column** as a real cohort + enrollment row instead of
discarding it.
- **Rollback:** Postgres only becomes authoritative once this has run and the Platform API is
pointed at it — nothing here modifies or deletes the SQLite source. To roll back: stop
Platform API, point student/instructor traffic back at a SQLite-backed service (the old
Phase 2A "identity" image is unmodified and still buildable/runnable — see
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`), and restore from the backup file if the live
SQLite file was somehow touched (it shouldn't have been — the migration is read-only against
it).
See the final report for actual migrated record counts from this implementation's test run.
---
## 3. Student session — productionized ownership
Phase 2A's `?sid=` was explicitly pilot-only: a client-supplied query parameter, trivially
changeable. Phase 2B replaces it with a server-controlled session:
- `POST /api/login {student_id}` (unchanged trigger — pick-your-name, no password, still
pilot-grade identity, stated as such) creates a session server-side
(`platform/api/src/sessions.js`, in-memory, 8h TTL) and returns an opaque, CSPRNG token as
an `HttpOnly`, `SameSite=Lax` cookie (`rn_session`). The browser cannot read or fabricate
this value.
- Platform API derives `student_id` **only** from this cookie
(`studentSession` middleware) — never from a header or query parameter a client controls.
- The Gateway resolves "who is this browser" by calling Platform API's
`GET /api/session/whoami` (forwarding the cookie), then compares that student_id against
the environment's actual owning `student_id` (from the Provisioner, via Platform API) —
**on every proxied Lab request, HTTP and WebSocket, not just the initial navigation.** A
short-TTL cache (`SESSION_CACHE_TTL_MS`, 5s) keeps the extra round trip cheap.
- Changing the token, URL, query parameter, or environment ID in the browser has **no effect**
on who the Gateway thinks is asking — that's determined entirely by the unforgeable cookie.
Verified by testing: a valid session for Student A requesting Student B's `/lab/<token>/`
gets **403**, not just an unset-parameter fallback.
**Explicitly not production SSO/OIDC.** Sessions are in-memory (a Platform API restart logs
everyone out — acceptable for a pilot/classroom tool, not silently ignored, see §13), and
login is still pick-your-name with no password. Production OIDC/Google Workspace SSO remains
Phase 3+ (§13/§14).
### Routing-hint cookie — a real bug found while building this
The Gateway's unprefixed fallback route (needed so the *unmodified* Lab UI's root-relative
`/api/scenarios`, `/shell-ws`, etc. resolve correctly once a browser is inside `/lab/<token>/`
— see Phase 2A's cookie design) initially used "does this session currently own a running
environment" as the signal for "should I try routing this to the Lab." That's wrong: it's
true on the *dashboard* too, not just inside the lab page, and it hijacked
`GET /api/session/whoami` (and would have hijacked `/api/environment/*`, `/api/admin/*`,
`/api/roster*`) to the student's Lab container — which doesn't have those routes at all —
whenever a student had ever launched a lab. Confirmed by testing (whoami returned the SPA's
`index.html` instead of JSON).
Fixed with an explicit allowlist (`LAB_OWNED_PATHS` in `platform/gateway/src/server.js`) of
the specific unprefixed paths the Lab UI actually calls
(`/api/scenarios*`, `/api/bundles`, `/api/sessions*`, `/api/cache*`, `/api/health`,
`/shell-ws`, `/assets/*`) — everything else always goes to Platform API regardless of whether
the student has a running lab. `LAB_ACTIVE_COOKIE` (set only on an actual `/lab/<token>/`
visit) is a secondary gate on top of that, not the sole signal.
---
## 4-9. Admin/Instructor portal
New `/admin` route in the same frontend bundle (`frontend/src/admin/`) — a separate React tree
from the student Platform Shell, gated by its own login (`rn_admin` session cookie, completely
separate cookie/token namespace from `rn_session` — a student session can never call an admin
route and vice versa).
**Dashboard** (`AdminDashboard.jsx`) — active cohorts, total students, Labs by state
(running/provisioning/error/idle), recent completions, recent environment failures. No
Prometheus/Grafana — this is "is class ready / is anything on fire," not an observability
platform (explicit non-goal).
**Cohorts** (`CohortsView.jsx`) — create/edit, roster (add/view students), curriculum
assignment, per-stage progress bars aggregated across the cohort, and the classroom
preparation workflow:
- **Prepare Labs** — finds enrolled active students, provisions anyone without a usable
environment. Runs concurrently (`Promise.allSettled`) — sequential would make a real cohort
prep take the better part of an hour, since each provision is a genuine k3s cold start.
Returns a per-student readiness report.
- **Destroy Cohort Labs** — destroys every enrolled student's environment. Academic progress
lives entirely in Postgres, untouched by this — by construction, not by a separate
safeguard, since the destroy path never touches `students`/`progress`/`scenario_attempts`.
**Students** (`StudentsView.jsx`) — per-student curriculum progress, recent attempts
(scenario, result, validation count, timestamp), current environment status, and controls:
provision / reset-recreate / destroy / view event history.
**Admin auth** (`platform/api/src/admin-auth.js`) — a single instructor credential
(`ADMIN_PASSWORD` env var, the same secret-injection channel every other component here
uses), verified via `scrypt` + `timingSafeEqual` rather than a plain string comparison — no
plaintext password ever sits in a database or gets logged. Explicitly not OIDC; documented as
Phase 3+ future work.
---
## 10-12. API routes, security boundary
See the route table in the final report. Boundary preserved throughout:
```
Student browser → Gateway → Platform API → Provisioner → Lab Host
Admin browser → Gateway → Platform API → Provisioner → Lab Host
```
Browser JavaScript never reaches the Provisioner directly in either path — Platform API is
now the *only* thing holding `PROVISIONER_SHARED_SECRET`.
---
## 13. Known limitations
- **Sessions are in-memory, single-process.** A Platform API restart invalidates every student
and admin session. Acceptable at pilot/classroom scale; would need a shared store (Postgres
or Redis) before this runs across multiple Platform API instances.
- **Exam mode is not fully productionized** — live exam sessions stay Lab-local; only
historical records migrate to Postgres. See §2.
- **`identity`'s Phase 2A `network_mode: bridge` requirement no longer applies** — Platform
API replaces it entirely and doesn't run k3s, so that whole class of problem is gone, not
just documented around.
- **Admin credential is a single shared password**, not per-instructor accounts — fine for a
single-instructor pilot, not a multi-instructor deployment model.
- **`?sid=` is gone entirely** (Phase 2A's mechanism) — nothing reads it anymore; a stale
bookmarked URL containing it is harmless, the parameter is simply ignored.
## Future (Phase 3+)
Production OIDC/SSO, per-instructor accounts, full students/progress historical fidelity for
exam mode, shared session store for horizontal scaling, everything already listed as
out-of-scope in Phase 2A/2B (parent portal, billing, capstone engine, production Proxmox,
full observability, multi-host scheduling, public enrollment).
+29 -19
View File
@@ -6,42 +6,52 @@ Start here, then go to the component you need:
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design: - `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference: `docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`. `provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
scenario attempts, student/admin sessions, and the instructor/admin control plane. No k3s, no
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
booting an unused k3s cluster just to serve these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware - `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing, student identity glue). Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`. routing derived from a server-controlled session — not a client-suppliable header/param).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3 (session ownership).
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through. - `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, a persistent - `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
"identity" instance, Gateway) on one machine. This is the Phase 1/2A **local dev exception** Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
— production is the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1. the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1.
## Running the full stack locally ## Running the full stack locally
```bash ```bash
cd platform cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
``` ```
Wait for `identity` to boot (it runs a real k3s cluster, same as any Lab — check with No k3s boot to wait for at this layer anymore (Platform API starts instantly) — the whole
`curl http://localhost:7554/api/health`), then the whole platform is reachable at platform is reachable at `http://localhost:4200` (the Gateway) right away: students at `/`,
`http://localhost:4200` (the Gateway) — that's the one origin a student's browser would use. instructors at `/admin`.
## Tests ## Tests
- `provisioner/test/run.js` — fast, isolated Provisioner tests (network/container/firewall - `provisioner/test/run.js` — fast, isolated Provisioner tests (network/container/firewall
mechanics, no Gateway/identity involved). Run against `provisioner/docker-compose.dev.yml`. mechanics, no Gateway/Platform API involved). Run against `provisioner/docker-compose.dev.yml`.
- `test/e2e-phase2a.js` — slow, full-stack tests against the compose file above: roster → - `test/e2e-phase2a.js` — student-flow regression through real session cookies: roster → login
login → curriculum → launch → Gateway routing → cross-student rejection → progress relay → → curriculum → launch → Gateway routing → cross-student rejection → progress relay → reset.
reset. Real k3s boots, budget several minutes. Run whenever the Gateway, Provisioner API - `test/e2e-phase2b.js` — the classroom acceptance workflow: instructor creates a cohort, adds
surface, or the identity backend's progress/roster/curriculum routes change. students, assigns curriculum, Prepare Labs (concurrent), students complete different
scenarios, instructor resets one, instructor destroys all cohort Labs after class — progress
survives every step. Needs `ADMIN_PASSWORD` set (see `platform/.env`).
Neither substitutes for the manual real-Lab-image checks (node-pty, terminal WebSocket, Both e2e suites are slow (real k3s boots) and need the full stack up. Neither substitutes for
`kubectl`, scenario validation) in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a the manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation)
browser-equivalent client and aren't automated here. in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
aren't automated here.
## Known limitations ## Known limitations
See `provisioner/README.md` (Phase 1) and `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
(Phase 2A) for the full, honest lists — including the `identity` service's `network_mode: 2A), and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 (Phase 2B) for the full, honest lists —
bridge` requirement and its unused-k3s resource cost, both flagged as real, not swept under including in-memory sessions, the single shared admin credential, and exam mode's live
the rug. bookkeeping staying Lab-local.