- PHASE5C_ENROLLMENT_COMMUNICATIONS.md: the full design doc.
- RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md: every REQUIRES GOOGLE WORKSPACE ADMIN ACTION step
(mailbox, service account, domain-wide delegation scoped to gmail.send only, SPF/DKIM/DMARC),
environment variables, testing procedure, production secret handling, rotation, revocation,
troubleshooting, quotas.
- RUNBOOK_STUDENT_ACTIVATION.md: day-to-day operation of the pipeline from /admin.
- WEBSITE_ENROLLMENT_INTEGRATION.md: explicit website-vs-RootNode responsibility boundary,
the new contact_email field, and closes the 'RootNode doesn't send activation email yet'
known limitation this doc previously documented.
- RUNBOOK_SELF_PACED_ENROLLMENT.md, RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md: point at the new
automatic-email behavior and the Resend activation control.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md, ENGINEERING_SOP.md: close the enrollment-
communication gap; new ENGINEERING_SOP §16.
- PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md: cross-references and a
Phase 5C production-validation section.
- platform/test/e2e-phase5c.js (29 checks): activation-vs-confirmation routing,
duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry
classification, resend/regenerate token invalidation, activation/reset single-use and
expiration, HTML-injection regression, production token-secrecy gating via a real container
recreate with COOKIE_SECURE=1, minor/contact-email resolution, RBAC scoping, metrics/audit
hygiene, and a real Platform API restart.
- infra/scripts/drills/test-phase5c-enrollment-activation.js (10 checks): one continuous
story — website enrollment through a real rendered/extracted activation email, a real Lab
launch and scenario completion, a second enrollment's confirmation email, a full
password-reset round trip, and the minor/no-email contact-email path.
- infra/scripts/gws-email-test.js: the opt-in (RUN_GWS_EMAIL_TEST=true) live Google Workspace
delivery test — does nothing without the explicit flag, sends exactly one message to one
operator-configured recipient when enabled.
- rootnode_notifications_total{notification_type,status} (computed fresh from the DB, like
the existing enrollment_breakdown gauge — never drifts from what's actually stored),
rootnode_notification_queue_depth, rootnode_notification_oldest_queued_seconds,
rootnode_notification_delivery_attempts_total{provider,result} (a real in-process counter —
per-attempt outcomes aren't reconstructable from the DB after the fact),
rootnode_student_activations_{success,invalid}_total. Every label is a bounded enum —
never student_id/email/token/notification_id/external_event_id.
- notification-routes.js: global list (owner/admin only), per-student list (scoped via
requireStudentRelationshipAccess), resend-activation (mechanically the same as
regenerate — Phase 5A's token model already guarantees only the latest is ever valid, so one
action covers both), a dev-only preview route (unconditionally refuses when
COOKIE_SECURE=1, mirroring student-auth-routes.js's existing dev_reset_token gate).
- NotificationsView.jsx: owner/admin global notification list, paginated, matching
AuditView.jsx's exact convention.
- StudentsView.jsx: student detail gained a Resend activation control and a Notifications
history card; Credentials card now explains the automatic-email behavior.
- AdminPortal.jsx: new Notifications nav entry (owner/admin only).
- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a
genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven
enrollment-level idempotency, no separate notification dedup needed).
- admin-routes.js: create-student/convert/reset-password now enqueue the corresponding
notification using the SAME token already minted synchronously (never mints twice, which
would silently invalidate the token just handed back in the response). Fixed a real bug:
request-password-reset would mint a reset token for a never-activated student, which makes
no sense and would confuse them — now falls through to the generic anti-enumeration response.
- admin-programs-routes.js: manual enrollment triggers the same notification decision as the
website path.
- student-auth-routes.js: /activate records a metric; /change-password and /reset-password
both queue a password_changed security notice; /request-password-reset now actually delivers
email (production still never returns the token — unchanged anti-enumeration/isProduction
gating, just now with a real send behind it).
- rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification
visibility rule (cohort-shared or an enrollment they created), reusable beyond just
enrollments.
- server.js: wires notification-routes.js and starts the notification worker in main().
- docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE
(was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
- email/provider.js: the seam. EMAIL_PROVIDER unset always defaults to the dev provider,
regardless of any other setting — misconfiguration means email silently doesn't send for
real, never that a dev/test run accidentally reaches a real student.
- email/gws-provider.js: Gmail API users.messages.send via a service account with Workspace
domain-wide delegation, impersonating one dedicated mailbox. No human password, no SMTP
basic auth, no googleapis/google-auth-library dependency — a hand-rolled RS256 JWT-bearer
OAuth exchange + one REST call (~120 lines), matching this codebase's established
hand-roll-before-adding-a-dependency convention.
- email/dev-provider.js: never makes a network call, returns the rendered message back to the
caller for dev/test inspection. Supports magic +forcetempfail/+forcepermfail recipient
markers so the permanent test suite can deterministically exercise retry/failure
classification without needing a real flaky provider.
- email/templates.js: activation/enrollment-confirmation/password-reset/password-changed,
HTML + plain-text, no external images/tracking/JS. Every interpolated value is HTML-escaped
in the HTML body (a real injection finding — display_name can come straight from a website
integration event).
- notification-worker.js: a setInterval poller (same pattern as sessions.js's sweeper), claims
via notifications.js, renders via templates.js, builds activation/reset URLs from
PUBLIC_APP_ORIGIN, stores a dev-only preview (never in production) so tests can extract the
real URL without a mailbox.
- migrations/021: notification_events (queued/sending/sent/failed/cancelled, bounded
exponential backoff via attempt_count/next_attempt_at, a classified last_error_code, never a
raw stack trace or provider credential).
- notifications.js: enqueue (records a no-recipient case as already-failed rather than
silently dropping it), claimNext (FOR UPDATE SKIP LOCKED — safe if two worker processes ever
run), markSent/markFailedOrRetry (bounded retry, terminal-state token scrubbing),
notifyForEnrollment (the shared activation-vs-confirmation decision every enrollment-creation
path calls), maskEmail/toAdminView (the only shapes any admin API ever returns).
- enrollment.js: enrollmentContactEmail() — reads back the minor/no-email transitional
contact-email convention (stored in the existing enrollments.metadata JSONB, never written to
students.email) that createFromIntegrationEvent/createManualEnrollment now accept.
- PHASE5B_SELF_PACED_ENROLLMENT.md: the full design doc.
- RUNBOOK_SELF_PACED_ENROLLMENT.md: operational runbook for /admin -> Programs.
- WEBSITE_ENROLLMENT_INTEGRATION.md: the complete contract for the separate website team —
endpoint, auth, request/response schema, idempotency/retry rules, student matching, minor/
no-email behavior, error responses, what not to send, local testing, production-secret
handling. Written to be usable without reading the Platform API source.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the self-registration/curriculum-assignment/
delivery-mode/enrollment-model gaps this doc previously flagged as Phase 5+ scope.
- ENGINEERING_SOP.md: new §15. LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md,
README.md, platform/README.md: cross-references and a Phase 5B production-validation section.
- platform/test/e2e-phase5b.js (32 checks): program seed, entitlement gating
(pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/
reaper-reconciliation/recreate persistence, server-derived idempotent completion via a full
real capstone completion, the integration boundary (auth, idempotency, real concurrency,
unknown/inactive program rejection, minor/no-email path, no card data), RBAC scoping, a
regression test for the process-crash bug this suite found, Phase 5A smoke, and a real
Platform API restart.
- infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js (15 checks): one continuous story —
activation, a simulated website enrollment event, real Lab work, Lab destroy, a new session,
Continue Learning, a fresh Lab, full real capstone completion, Completed transition, and a
second uninvolved student proven unable to reach any of it.
- metrics.js: rootnode_enrollments_total{status,delivery_mode}, rootnode_selfpaced_active_
enrollments, rootnode_program_completions_total{program_id}, rootnode_enrollment_events_
{created,duplicate,rejected}_total. Every label is a bounded enum or an admin-curated slug —
never student_id/email/enrollment_id/external_reference.
- integration-routes.js: POST /api/integrations/enrollments — separate from student/instructor
session auth entirely (no cookie read), pre-shared-secret header auth matching the existing
Gateway<->Provisioner trust-boundary pattern. Idempotent via a DB unique constraint on the
caller's own event_id (not app-level check-then-insert) — proven safe under real concurrent
duplicate requests. Student resolution is create-or-match by email OR login_identifier
(minor/no-email support), reusing Phase 5A's exact credential-issuing path. A small,
provider-neutral status vocabulary (paid/active/confirmed/pending/awaiting_payment) — never
a raw payment-provider object model.
- docker-compose.dev.yml: INTEGRATION_SHARED_SECRET, optional in dev (fails closed if unset).
- ProgramsTab.jsx: Active/Completed programs, real progress, deterministic Continue Learning
(reuses the same launch/poll pattern MyLearningTab already established), entitlement-blocked
states shown with a real reason, never a silent dead end.
- StudentDashboard.jsx: new My Programs tab. api.js: programs/program/launchProgram.
- enrollment.js: server-only status transitions (pending->active->completed, cancel),
deriveEnrollmentAccess (pure, derived-at-read-time like classroom.js's derivePresence —
'expired' is never stored, computed from access_ends_at), maybeCompleteEnrollment
(idempotent, server-derived from the existing capstone achievement).
- environment-view.js: publicEnvView/launchEnvironmentForStudent extracted out of server.js's
original /api/environment/launch route body so the new entitlement-gated self-paced launch
reuses the identical disposable-Lab sequence instead of a second copy — behavior for the
existing cohort/pilot route is completely unchanged.
- program-status.js: combines an enrollment with real curriculum/progress state into one view
(access, percent-complete, current stage, deterministic next-activity/resume target) — shared
by the student and admin surfaces so both show identical information.
- selfpaced-routes.js: GET /api/student/programs(/:id), POST .../launch — entitlement enforced
server-side before ever touching the Provisioner, never just hidden in the UI.
- rbac.js: requireEnrollmentAccess — a plain instructor may manage an enrollment they created
or that belongs to a student sharing one of their cohorts; a self-paced enrollment can have
no cohort at all, so the existing cohort-only checks can't cover it.
- server.js: found and fixed a real bug — POST /api/progress/record's handler predated the
wrap() convention, so an unexpected internal error (a malformed environment_id) crashed the
entire process instead of just 500ing that request. Now wrapped + input-validated.
- migrations/019: programs table (slug PK, matching assessment_definitions/cohorts
convention), family (init/compile/root) and delivery_mode (self_paced/cohort/hybrid) as real
CHECK-constrained enums, versioned like assessment_definitions so an edit to
curriculum_path/delivery_mode never silently changes what an existing enrollment means. Seeds
exactly one program wrapping the existing RootNode/Kubernetes curriculum and capstone — no
new curriculum content invented.
- programs.js: list/get/create/update, with automatic version bump only when a
snapshot-relevant field actually changes.
- PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md: the full design doc.
- RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md, RUNBOOK_REMOTE_CLASSROOM.md: operational runbooks.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the 'no real student authentication' gap this
doc previously flagged as the one item blocking self-paced delivery.
- ENGINEERING_SOP.md §5 rewritten (was describing a pre-Phase-2A state no longer true of any
deployed version of this codebase) plus a new §14; roadmap item 3 updated.
- LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md, README.md, platform/README.md:
cross-references and a Phase 5A production-validation section.
- platform/test/e2e-phase5a.js (21 checks): the two-student RBAC matrix (student can't reach
any /api/admin/* route, assigned/unassigned/viewer instructor access) and the full password
security checklist (correct/wrong/unknown-identifier/deactivated/immediate-deactivation/
rate-limit/rate-limit-survives-a-real-restart/change-password/session-revocation/
forgot-password anti-enumeration/admin-issued reset/CSRF), plus a classroom lifecycle smoke
pass. Replaces the throwaway smoke5a/smoke5b scripts used during development.
- infra/scripts/drills/test-phase5a-rehearsal.js (15 checks): 5 pilot students with real
seeded pre-existing progress/attempt/achievement history, a live classroom, help requests,
announcements, a REAL Platform API restart mid-rehearsal proving classroom/session/presence
recovery, then converting two students mid-rehearsal and confirming zero data was orphaned.
- Both run against a live docker-compose.dev.yml stack over real HTTP, not mocks.
- StudentsView: credential status in the roster table, a create-student form
(email/login-ID/cohort), convert/reset-password/revoke-sessions controls per student, a
bulk CSV import form. Every one-time activation/reset link is surfaced exactly once, never
re-fetchable.
- ClassroomView: cohort picker -> schedule/list classes -> a live dashboard (participants with
presence/Lab-status/current-work, help queue with acknowledge/resolve, current-activity
setter, announcement poster, start/end/cancel, Prepare Labs). Polls the dashboard endpoint
rather than a second push channel.
- AdminPortal: new Classroom nav entry. CohortsView's existing quick-add-student call updated
for createStudent's new object-argument signature.
- LoginScreen (credentialed, now the default entry point) with links to legacy pilot entry
and forgot-password; SetPasswordScreen (shared activate/reset UI, driven by
?activate=/?reset= query params — no router library, matching this app's existing
state-machine-per-shell convention); ForgotPasswordScreen (anti-enumeration, dev-only
reset-token surface).
- ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted,
guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's
exact API), announcements, Need Help form. No chat, no video, no shell access.
- ProfileTab: identity, cohort, progress, achievements, active-session count,
change-password (gated on has_credentials).
- PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new
tabs; api.js/Platform.module.css extended to match.
- classroom.js: create/start/end/cancel (DB-level partial unique index guarantees at most one
live classroom per cohort; app layer catches 23505 and returns a clean ALREADY_LIVE), join/
leave/heartbeat, derivePresence (pure, derived from last_seen_at at read time — no stored
status to go stale), help-request create/acknowledge/resolve, announcements, current-activity
(guidance only, never redirects a browser or marks work complete), append-only event log.
- admin-classroom-routes.js: instructor-facing classroom API, RBAC via rbac.js's
requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolves
through to the classroom's cohort, same owner/admin/instructor/viewer rules as everywhere
else cohort-scoped). The dashboard route (GET /api/admin/classroom-sessions/:id) combines
real Provisioner Lab state and real academic records with classroom.js's own data — no
second Lab-state or academic-state system.
- classroom-routes.js: student-facing API (live classroom, join/leave/heartbeat, help-request,
announcements), requireEnrolledClassroom middleware.
- prepare-labs.js: shared prepareLabsForCohort, extracted so the existing cohort-level and new
classroom-level Prepare Labs routes share one implementation; maps Phase 4.1 admission
control's CAPACITY_EXCEEDED into a capacity_waiting per-student result.
- admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions,
/sessions, /import (transactional CSV import — whole file validated, one Postgres
transaction, never accepts a password/hash column from input). Fixed GET
/api/admin/students to select an explicit safe column list instead of SELECT * — the new
password_hash/credential_token_hash columns must never be exposed through this route.
- server.js: /api/login now also blocks on credential_token_hash (not just password_hash) —
closes a real window where a student mid-conversion stayed reachable via the old
bare-student_id path. studentSession middleware now re-verifies status='active' on every
request, not just at session creation, matching instructorSession's existing behavior.
- sessions.js: revokeAllStudentSessions, listStudentSessions.
- rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the
new classroom/help-request RBAC (next commit) reuses the exact same authorization rule
instead of a parallel copy.
- student-csv.js + test: hand-rolled parser (no new dependency), unit tested.
- prometheus.yml.j2: activated the platform-api scrape job.
- migrations/017: email, login_identifier, password_hash, last_login_at, credential_token_*
on students. Fully additive, both nullable/independent, no destructive change.
- students-auth.js: shared credential/token lifecycle (issue/consume/find), reusing
instructors' exact scrypt password.js verbatim.
- student-auth-routes.js: /api/student/login, /activate, /change-password,
/request-password-reset (anti-enumeration, dev-only token echo), /reset-password, /profile.
- metrics.js: hand-rolled /metrics Prometheus exporter, login success/failure counters, no
student/instructor ID ever used as a label value.
- migrations/018: classroom_sessions/participants/events/announcements/help_requests, with a
partial unique index enforcing at most one live classroom per cohort at the DB level.
Master overview (PHASE4_1_PREPRODUCTION_HARDENING.md) covering all 27
substantive items, plus REMOTE_AND_SELF_PACED_ARCHITECTURE.md (reviews
what already works for remote/self-paced delivery - student-driven
provisioning, cohort-less students, idle-reaper-destroy-then-continue -
and the one real gap: student authentication, explicitly Phase 5 scope,
not built here).
Updated PRODUCTION_NETWORK_SECURITY.md (the Gateway-to-Lab gap is now
RESOLVED, with the full design writeup and a new control-plane-vs-data-plane
section), PRODUCTION_DEPLOYMENT.md (four-cert TLS bundle),
PRODUCTION_VALIDATION_CHECKLIST.md (Router mTLS check, admission control
checks), PHASE4_PRODUCTION_INFRASTRUCTURE.md and ENGINEERING_SOP.md and
LAB_ARCHITECTURE.md (stale 'known gap' pointers updated to point at the
resolution), BACKUP_DR.md (destructive drill), README.md and
platform/README.md (Phase 4.1 pointers, lab-router component, updated
known-limitations).
Also fixes a real pre-existing bug found while auditing what gets
committed: infra/.gitignore's patterns were written repo-root-relative
(e.g. "infra/terraform/...") but the file lives in infra/, so git resolved
them relative to THAT directory and none of them ever matched anything -
a real .terraform/ provider cache showed up as untracked when it should
have been ignored. Fixed by dropping the redundant leading "infra/" from
every pattern.
Status: PHASE 4.1 COMPLETE / PRE-PRODUCTION HARDENING COMPLETE / READY FOR
HARDWARE DEPLOYMENT.
- infra/scripts/simulate-two-host/: real two-Docker-network simulation
(Gateway genuinely isolated from any Lab bridge network) proving the
Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the
Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router
access).
- infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback,
CI-fails-closed (6 required failure modes), fake cohort dress rehearsal
(14/14, one continuous narrative through the real app).
- infra/ci/gitea-workflows/build-and-test.yml: new security-scan job
(npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source),
severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks,
MEDIUM/LOW reported only). Gates build-images alongside regression.
Found and fixed two real pre-existing bugs via the CI-fails-closed drill:
staging-smoke-test.sh and production-smoke-test.sh both had a classic
set -e gotcha (curl ... && echo "OK" as one statement - a failing command
that isn't last in an && chain doesn't trigger -e) that let an
unreachable/broken endpoint fall through silently instead of failing the
smoke test.
platform/test/e2e-phase3a.js updated for the new paginated
/api/admin/audit-events response shape (Phase 4.1 pagination work).
infra/backup/test-destructive-restore-drill.sh - realistic academy data
(owner, admin instructor, cohort, 3 students, progress, attempts, an
assessment definition, achievements) seeded through the REAL Platform API,
encrypted-backed-up, the source Postgres container genuinely destroyed
(docker rm, not just left running alongside a second one), a fresh
Postgres created from nothing, restored, and the real Platform API booted
again against the restored database and used to log in as the restored
admin instructor over HTTP. Never touches the developer's real database.
Confirmed passing.
- Real server-side pagination (platform/api/src/pagination.js) on the audit
log (was a flat LIMIT 200), student attempt history (was LIMIT 100), and
assessment session history (was fully unbounded - the one genuine growth
risk found). Frontend updated (AuditView, AssessmentsView, StudentsView).
- Admission control: resource-policy.yaml's new (disabled-by-default)
admission_control.max_concurrent_labs, checked before touching Docker at
all, returns a clean 503 CAPACITY_EXCEEDED instead of failing mid-provision.
Relayed as a clean student-facing message, never a raw 500. New
GET /capacity endpoint feeds the instructor dashboard.
- cohort_instructors.role_in_cohort (existed since Phase 2B, never read) is
now enforced: teacher vs viewer, rbac.js's
requireCohortWriteAccess/requireStudentCohortWriteAccess.
- GET /api/version - app version, deployed Git SHA, latest migration as a
schema-version stand-in.
- Structured assessment authoring UI (AssessmentsView.jsx's PoolSpecEditor)
replaces the raw-JSON pool_spec textarea, backed by a new
GET /api/admin/scenario-catalog endpoint. Owner-only raw-JSON fallback
remains for anything the structured editor doesn't cover.
- NEEDS_ATTENTION_FAIL_THRESHOLD/STALE_DAYS moved to env-var configuration.
Full regression suite (e2e-phase2a/2b/3a/3b, 67/67) re-run clean after
these changes; frontend production build confirmed clean.
Replaces the in-memory sliding-window Map (reset on every restart, not
shareable across instances) with a login_attempts table (migration 016).
No Redis added - login is not a hot path, and this is a single indexed
range-scan query. Restart behavior confirmed for real:
platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine
Platform API restart in the same run.
scenario_attempts.status = 'abandoned' (a valid value since Phase 3B,
never previously set by anything) now gets set on: environment
destroy/reset, logout, session expiry (piggybacked on the existing hourly
session sweeper - no new background job), and switching to a different
scenario while one is still open. Never an academic failure marker -
result stays NULL, history is preserved. Call-site wiring in
server.js/admin-routes.js lands in the next commit alongside other
platform hardening in those same files.
Adds platform/lab-router/ — a narrow service that runs on the Lab Host,
authenticates the Gateway via mTLS, and resolves an opaque routing token to
a local container purely from Docker label state (no Postgres access, no
callback to the Provisioner). Replaces the Gateway's env.internal_ip
assumption (only valid when Gateway and Lab share a Docker daemon) with a
data-driven direct/router decision per lab_host
(lab_hosts.routing_mode/router_endpoint, migration 004), resolved in one
place (platform/gateway/src/lab-endpoint.js's labTarget()) that every
proxy/fetch call site in server.js now goes through.
Extends the existing Docker TLS CA (infra/scripts/docker-tls/generate-docker-tls.sh)
with a second mTLS leaf pair (router-server/gateway-client) for this new
data-plane connection, deployed by the docker-tls Ansible role into a
separate directory from the control-plane pair. Firewall
(rn-lab-host-01.rules) replaces the old speculative wide port-range
allowance with one narrow rule: platform_ip -> the Router's port only.
dev-local stays routing_mode: direct — zero behavior change for local dev,
confirmed by the full existing regression suite passing unmodified.
Master overview (PHASE4_PRODUCTION_INFRASTRUCTURE.md) plus five detail docs
(PRODUCTION_DEPLOYMENT, PRODUCTION_NETWORK_SECURITY, BACKUP_DR,
OBSERVABILITY, CAPACITY_PLANNING), a master PRODUCTION_VALIDATION_CHECKLIST,
and 12 runbooks (first deployment, normal deploy, rollback, Postgres
restore, VM rebuild for both hosts, Docker TLS rotation, TLS renewal, owner
recovery, stuck Lab cleanup, failed migration, backup failure, disk
failure, Lab Host resource pressure).
PRODUCTION_NETWORK_SECURITY.md prominently documents the one genuine
architectural gap this phase surfaced: the Gateway's existing
env.internal_ip mechanism for reaching a Lab container assumes Gateway and
Lab share a Docker daemon, which breaks once Labs run on a genuinely
separate rn-lab-host-01 VM. The network-policy half is built (a firewall
allowance for Gateway->Lab-Host on a published port range); the
application half (Provisioner/Gateway address handling) is an explicit,
reviewed, NOT-YET-BUILT follow-up flagged there rather than silently
decided.
Updated LAB_ARCHITECTURE.md, ENGINEERING_SOP.md, README.md, and
platform/README.md with Phase 4 pointers, consistent with how every prior
phase updated these.
Status: PHASE 4 BUILD COMPLETE / PRODUCTION DEPLOYMENT PENDING HARDWARE.
Unauthenticated liveness/readiness probes for uptime monitoring and Ansible
deploy smoke tests (Phase 4). API's checks Postgres reachability via
SELECT 1; Gateway's answers locally without proxying anywhere, so it can
distinguish 'Gateway itself is down' from 'something behind it is down'.
No student/instructor data exposed.
Verified live against the real dev stack: both return 200 with the
expected JSON body. Full regression suite re-run after this change:
e2e-phase2a (10/10), e2e-phase2b (12/12), e2e-phase3a (21/21), e2e-phase3b
(24/24) - all passing, no regressions. Frontend build and the Lab image
(both the multi-service dev compose and the standalone single-container
Dockerfile) also confirmed still building/booting correctly.
boot-labs.js drives the real admin Prepare-Labs endpoint (reusing the
existing session-helper.js from Phase 3A) and records boot time/failure
rate; sample-resources.sh samples real docker stats for running Labs.
capacity-model.js turns measured per-Lab CPU/memory into a recommended/
warning/hard-admission-control concurrency threshold - no concurrency
number is hardcoded as a marketing figure, every input is explicitly
placeholder data until measured against the real host.
Plus deploy/rollback/smoke-test scripts used by the CI/CD and deployment
runbooks. All syntax-checked; validate-compose.sh and capacity-model.js
actually run against real inputs.
Automatic test/build/tag/staging-deploy on push to main; production
promotion stays a manual, human-run step (deploy-production.sh) - never
automated. SHA-tagged images always, semantic tags only on deliberate
release, never :latest alone in staging/production. No paid CI provider or
registry required (uses Gitea's own). Real regression job installs all
services, applies real migrations against a service Postgres, runs the
infra validation scripts.
Validated as real YAML (python3 yaml parse).
pg_dump -> gzip -> gpg AES-256 symmetric encryption, plaintext deleted
immediately, optional off-host rsync-over-SSH copy (Hetzner Storage Box or
any SSH target), retention pruning, Prometheus textfile-collector metrics
for backup success/age/size.
Genuinely tested end-to-end, not just written: real production migrations
(17 total, correct cross-service order) applied to a disposable Postgres,
sample data inserted, backed up, restored into a second disposable
Postgres, verified table-for-table and byte-for-byte. Exit 0.
Named-bridge-network production Compose (not host networking, unlike dev)
for rn-platform-01; egress-proxy + agents for rn-lab-host-01. Caddy reverse
proxy, single public origin, Grafana restricted to management_cidr.
iptables-restore firewall rulesets for all three hosts (Proxmox host,
rn-platform-01, rn-lab-host-01) codifying the Platform/Lab-Host boundary:
no student-to-student traffic, no direct Proxmox exposure, no direct
Postgres/Provisioner exposure from the Lab Host.
Mutual TLS for the Provisioner<->Lab Host Docker connection: certificate
generation/verification scripts, dockerd TLS config, firewall-restricted
port 2376. Application support already existed since Phase 1; this is
purely the operational half.
Validated: docker compose config, caddy validate, firewall structural lint,
and a real openssl s_server/s_client mTLS handshake proving the cert chain
and mutual-auth rejection (legitimate client verified, rogue client
correctly rejected with verify code 19).
common/firewall/docker-tls/platform/lab-host/monitoring-agent/backup roles,
orchestrated by site.yml with the ordering that matters (firewall/TLS land
before the services that depend on them). Ansible Vault for secrets, no new
tool introduced for this project's modest secret surface. Explicit
dev/staging/production environment separation, no secrets committed
(.gitignore covers vault plaintext, TLS keys, Terraform state, *.env).
Validated: ansible-playbook --syntax-check clean.
New docs/PHASE3B_LEARNING_AND_ASSESSMENT.md covers the attempt/learning-record model,
progressive hints (including the Gateway response-rewrite mechanism), curriculum completion
rules, the assessment/exam engine and its timer/draw integrity guarantees, the RootNode
Capstone design, achievement records, the deliberately minimal versioning model, instructor
analytics, the Phase 3B RBAC matrix, security/integrity assumptions, dev/test commands, known
limitations, and the Phase 4 boundary.
Updates ENGINEERING_SOP.md (roadmap items 2/7/8 now done, new section 12, corrects a stale
capstone-placeholder description in section 4), LAB_ARCHITECTURE.md's status line,
README.md (corrects the 'Lab image unmodified' claim now that its frontend bundle carries
Phase 3B UX, plus new platform properties), platform/README.md, and platform/api/README.md.
24 cases: attempt telemetry (start/fail/pass counters, duration), progressive hint sequencing
and exhaustion, hint persistence through a real Lab reset, curriculum stage lock/unlock,
capstone lock-before-prerequisites/unlock-after/random-draw-includes-required-Real-Ops/
cannot-double-start/completion-records-achievement, exam assignment/draw/timer/hint-policy,
cross-student session-read rejection (404, not 403 - consistent with the existing don't-confirm-
existence posture), two-student isolation, timer survives a Lab reset, an assessment session
survives a REAL docker compose restart api, and instructor analytics/needs-attention/RBAC.
Found and fixed two real product bugs in the process: result needed to become nullable (an
in-progress attempt has none yet) and hint_count needed a real DEFAULT 0 (Phase 2B left it
NULL/undefaulted since it wasn't measured yet - it is now, and NULL + 1 stays NULL in SQL).
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted
unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice,
Assessments (available/active-with-live-countdown/history), Capstone (locked checklist ->
unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard).
Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has
stripped hints (detected via scenario.hints being undefined but hint_count present - standalone
dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner
shows a live countdown and completion count whenever a Platform assessment is running,
rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
New /api/admin routes: cohort learning summary (completion by stage, active learners),
per-student learning detail (attempts, hint usage, assessment history, capstone state),
scenario analytics (completion rate, avg attempts, failure/hint frequency, avg time), and a
needs-attention view (repeated failures, hints used but still stuck, inactivity, an expired
assessment - deliberately instructional wording, not punitive, with an explicit test asserting
that). Assessment definition CRUD (admin/owner only), cohort assignment and session view/cancel
(any instructor role, cohort-scoped for a plain instructor via the existing Phase 3A RBAC
helpers - no new authorization primitives needed).
New Learning and Assessments tabs in the admin portal, visible to every instructor role;
definition-authoring controls are shown but the server 403s a create/edit from a non-admin/
owner role, same pattern CohortsView already uses for admin-only controls.
New learning-routes.js: scenario start/attempt-state/hint (sequential reveal, 409 once
exhausted, policy-aware under an active assessment), GET /api/curriculum/state (rule-driven,
alongside the unchanged /api/curriculum/path), assessments list/start/active/detail/submit,
GET /api/capstone, GET /api/achievements, GET /api/attempts/recent.
/api/progress/record now routes through attempts.recordValidation instead of a flat insert,
and, on a pass, checks curriculum/capstone achievement consequences and, if the scenario is
part of an active assessment, updates that session (assessment context is derived entirely
server-side from assessment_session_scenarios, never from anything the client asserts).
Gateway changes: two new relay routes (/lab/:token/api/scenarios/:id/start and .../hint,
mirroring the existing validate/answer pattern - pure Platform API operations, the Lab has no
attempt/hint concept) and a response rewrite on GET /lab/:token/api/scenarios/:id that strips
the Lab's own hints array (replacing it with a bare hint_count) so progressive reveal is
actually server-gated instead of just UI-collapsed - the Lab image's bytes stay untouched;
standalone/single-container dev mode never goes through the Gateway and keeps the full array
unaffected.
New achievements table: stage_completed/curriculum_completed/assessment_completed/
capstone_completed, idempotent (WHERE NOT EXISTS guard, not a unique-index dance — simple and
correct at this write volume). Not a certificate — a durable internal record Phase 4/5 can
render one from later. recordAssessmentAchievement only fires on an actual pass, defensively,
even though today's only completion_rule already guarantees that.
New tables: assessment_definitions (reusable template — pool_spec, duration, hint_policy,
completion/unlock rules, a plain incrementing version bumped only on semantic edits),
assessment_cohort_assignments, assessment_sessions (one per student attempt, full snapshot of
what was drawn and under what rules — never affected by a later definition edit),
assessment_session_scenarios (the drawn set, ordered, with a real FK to scenario_attempts).
Deliberately does not touch/reuse exam_sessions (migration 006) — that was already scoped in
Phase 2B as historical-import-only, never the live mechanism; these tables are the real thing.
drawScenarios: every 'required' scenario id included, plus N random picks per pool group,
shuffled together (tiny seeded PRNG, mulberry32 — no new dependency; the seed is recorded for
audit, never shown to the student before the draw). One assessment at a time per student.
Timer is server-authoritative (expires_at fixed at draw time) and lazily expired — checked
whenever a session is touched, no background cron. Ships two seeded defaults: rootnode-capstone
(Real Ops scenarios required, weighted toward operational ability) and rootnode-quick-assessment
(a general example exam matching the spec's category-draw example).
Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.
curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
Was ambiguous — backend/frontend committed lockfiles, api/gateway/provisioner didn't, with
no stated reason either way. Now explicit: every first-party Node service commits its
package-lock.json, validated with npm ci before each commit that touches it.
backend/ and frontend/ already had committed lockfiles; platform/api/ just gained one.
gateway/ and provisioner/ never had one generated at all — not a deliberate policy, an
inconsistency. Generated via npm install --package-lock-only and validated with a clean
npm ci for both (72 and 153 packages respectively, no resolution surprises).
Was generated locally during Phase 3A work but never committed. Verified it corresponds
to the current package.json via a clean npm ci (118 packages, no resolution changes).
New docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md covers the schema, persistent session
design, instructor accounts, the full RBAC matrix, cohort scoping (including the documented
raw-environment-id simplification), student session hardening (explicitly still
classroom/pilot identification, not production auth), CSRF, rate limiting, audit logging,
the API inventory, a security hardening checklist, test results, known limitations, the
future OIDC path, and production HTTPS/cookie expectations — with an explicit BUILT NOW vs.
FUTURE split.
Updates ENGINEERING_SOP.md (new §9/§10, closing a stale '(§9)' forward-reference that dated
back to Phase 2B), LAB_ARCHITECTURE.md status line, README.md, platform/README.md, and
platform/api/README.md (bootstrap-owner usage, new config vars, no more ADMIN_PASSWORD).