English · Español
Theory 01 — Architecture of the learner portal¶
🇪🇸 La arquitectura es deliberadamente pequeña: un proceso FastAPI, plantillas Jinja2, HTMX para interactividad progresiva, SQLite como almacén, Argon2id para contraseñas, cookies firmadas para sesiones, y los middlewares de la fase 37 ya aplicados. Sin React, sin Vue, sin colas, sin microservicios. Por diseño.
Why C4 again¶
We use the same C4 lens as Phase 39 — System Context → Container → Component, plus a sequence diagram for the load-bearing flow. Two static diagrams, one dynamic diagram. The portal is even smaller than the tutor; if anything, the diagrams are easier to draw.
Level 1 — System Context¶
┌──────────────────────────────┐
│ │
┌────────────────┐ HTTPS │ lynx-cortex-portal │
│ Student │──────────────►│ (single FastAPI process) │
│ (browser) │◄──────────────│ │
└────────────────┘ HTML/JSON │ │
└──┬──────────┬──────────┬─────┘
│ │ │
┌────────────────┐ HTTPS │ │ │
│ Teacher / │─────────────────►│ │ │
│ Admin │◄─────────────────│ │ │
└────────────────┘ HTML/JSON │ │
│ │
┌──────────▼───┐ ┌───▼──────────┐
│ SQLite │ │ Phase 39 │
│ (file) │ │ grammar │
│ │ │ tutor │
└──────────────┘ │ (optional) │
└──────────────┘
┌──────────────────────┐
│ docs/phase-NN-*/ │
│ (filesystem; read │
│ at request time) │
└──────────────────────┘
The portal has two human user kinds (student, teacher/admin), one storage dependency (a SQLite file on the same host), one read-only dependency on the filesystem (the curriculum markdown), and one optional external system (the Phase 39 grammar tutor, used only for "drill" links). No DB server, no Redis, no Kafka, no S3.
Render this in docs/phase-41-learner-portal/diagrams/c4-context.mmd.
Level 2 — Container¶
At the Container level, the portal is a single process. We list the containers visible in the demo:
miniportal— the FastAPI app itself. Python 3.11, single asyncio event loop. Hosts:- Route handlers (students, journal, notes, quizzes, exams, admin) — see §routes.
- Jinja2 template renderer + static asset middleware.
- Argon2id password verifier (wired through
src/minivault/). - Session middleware (signed cookies via
itsdangerous). - SM-2 review scheduler (wired through
src/minireview/). - Phase 37 middlewares: rate limiter, body-size cap, prompt-injection filter, CSRF token validator.
- Phase 33 conventions: OpenAPI schemas,
/health, structured logs. - SQLite file (
./portal.db) — one file. WAL mode. Backed up byjust portal-backup(acpwith a timestamp). docs/phase-NN-*/filesystem — the curriculum source. Read at request time; not copied into the DB.- Reverse proxy (cited, not bundled) — Caddy or nginx in front of
miniportalfor TLS termination, static asset serving, and HTTP/2. The BLUEPRINT ships an exampleCaddyfile; the demo binds tolocalhostand assumes the proxy is provided externally. - Phase 39 grammar tutor (optional) — separate process from Phase 39's capstone. Reachable via
http://localhost:8001or similar. Used only for "drill" links from the portal's exam UI.
Render in docs/phase-41-learner-portal/diagrams/c4-container.mmd.
Why each technology choice¶
- FastAPI — the framework Phase 33 already builds against. Reusing it costs zero new dependencies; switching would force two HTTP stories in one repo.
- Jinja2 — FastAPI's default-recommended templating. Server-rendered HTML. No client-side templating, no hydration.
- HTMX — gives partial-DOM swaps, inline forms, lazy-loaded fragments via simple
hx-*HTML attributes. The pages work without JS at all; HTMX only sharpens UX. This is the explicit way aroundLYNX_CORTEX.md§10's anti-goal of heavyweight JS frameworks: we get most of the SPA ergonomics with zero JS toolchain. The trade-off is documented loudly in lab 00 (no client-side state, no offline mode). - SQLite — file-based, WAL-mode. Concurrent writers serialize on one writer lock; for ≤ 50 students with low write rate, this is more than enough. The BLUEPRINT records the migration trigger to Postgres (e.g., > 50 concurrent writers, or cross-host deployment).
- Argon2id (via
argon2-cffi) — modern memory-hard KDF; winner of the 2015 Password Hashing Competition; RFC 9106. Parameters tuned in lab 02 to ~250–500 ms per verify on the target CPU. itsdangerous— pure-Python signed cookies, no DB session table needed for the common path. Pepper rotation (a periodic key change) invalidates all sessions, by design.- CSRF tokens — double-submit cookie pattern, also via
itsdangerous. Form-rendered HTMX requests include the token as a header. - Reverse proxy: Caddy or nginx (cited, not required) — TLS termination, static asset serving. The portal does not own TLS; it speaks HTTP on localhost.
Level 3 — Components inside miniportal¶
src/miniportal/
├── app.py # FastAPI app factory, middleware stack
├── auth.py # Login, logout, passwordless-first-login,
│ # session issue / verify, CSRF helpers
├── models.py # SQLModel/Pydantic — leaving choice open until
│ # BLUEPRINT; tables: student, session,
│ # phase_progress, note, quiz_attempt,
│ # exam_attempt, review_card, event_log
├── events.py # Closed set of event types; emit helper
├── routes/
│ ├── students.py # /me, /me/preferences, /me/dashboard
│ ├── journal.py # /phase/{nn} → render markdown; record event
│ ├── notes.py # /phase/{nn}/notes (CRUD; per-student rows)
│ ├── quizzes.py # /phase/{nn}/quiz (per-question score)
│ ├── exams.py # /phase/{nn}/exam (records attempt; enqueues
│ │ # failed questions into review deck)
│ └── admin.py # /admin/students (create/invite/list/audit)
├── templates/ # Jinja2 templates + HTMX partials
└── static/ # CSS (Pico.css or similar minimalist), no JS
Two sibling modules (separate package, separate BLUEPRINT, separate tests) the portal depends on:
src/minivault/ # Argon2id-based password vault
├── BLUEPRINT.md
├── hash.py # hash(password, pepper) → str
├── verify.py # verify(hash, password, pepper) → bool
├── pepper.py # load_pepper() reads from env or file;
│ # rotate_pepper() invalidates sessions
└── kdf.py # KDF for encryption-at-rest of small
# secrets (e.g., invite-token nonces),
# derived from the pepper
src/minireview/ # Spaced repetition scheduler
├── BLUEPRINT.md
├── sm2.py # SM-2 update rule (lab 04)
├── fsrs.py # FSRS variant behind a feature flag (lab 04)
├── scheduler.py # next_review_at(card, grade, now)
└── queue.py # SQL: SELECT cards WHERE next_review_at<=now
# ORDER BY next_review_at ASC LIMIT N
Each gets its own BLUEPRINT.md and README.md per CLAUDE.md §1.
Route inventory (canonical)¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
none | Landing: link to login or "request invite from your teacher". |
| GET | /login |
none | Login form. |
| POST | /login |
none | Submit credentials; issue session; CSRF protected. |
| POST | /logout |
session | Invalidate session; CSRF protected. |
| GET | /invite/{token} |
none | Render set-password form if token valid. |
| POST | /invite/{token} |
none | Set password; revoke token; issue session. |
| GET | /me |
student | Dashboard: progress, review-deck size, last activity. |
| GET | /me/preferences |
student | Toggle Spanish summary visibility, etc. |
| POST | /me/preferences |
student | Save preferences; CSRF protected. |
| GET | /phase/{nn} |
student | Render the phase README + theory index. Records event_log. |
| GET | /phase/{nn}/theory/{slug} |
student | Render the theory file. Records event_log. |
| GET | /phase/{nn}/lab/{slug} |
student | Render lab statement. Records event_log. |
| GET | /phase/{nn}/notes |
student | List + create form for the student's own notes. |
| POST | /phase/{nn}/notes |
student | Create / update note; CSRF protected. |
| GET | /phase/{nn}/quiz |
student | Quiz UI (subject matter: grammar tutor per §A13). |
| POST | /phase/{nn}/quiz |
student | Submit quiz; per-question score; CSRF protected. |
| GET | /phase/{nn}/exam |
student | Exam UI (proctored: timer, no re-submit). |
| POST | /phase/{nn}/exam |
student | Submit exam; failed questions enqueued; CSRF protected. |
| GET | /me/reviews |
student | The review deck for "today". |
| POST | /me/reviews/{card_id} |
student | Submit a review answer; SM-2 schedule updated; CSRF protected. |
| GET | /admin/students |
admin | List of all students + last-active + deck size. |
| POST | /admin/students |
admin | Create a new student; respond with invite link; CSRF protected. |
| GET | /admin/students/{id} |
admin | Per-student detail: progress, notes, exam history, event log. Read is audited. |
| GET | /health |
none | Liveness. |
Every state-changing route is CSRF-protected. Every authenticated route checks the session cookie and resolves the student id. Every authenticated route emits one event_log row per request.
The canonical "submit exam answer" sequence¶
The load-bearing user flow. Rendered in docs/phase-41-learner-portal/diagrams/sequence-submit-exam.mmd.
Request shape¶
POST /phase/13/exam HTTP/1.1
Host: portal.example
Cookie: session=<itsdangerous-signed>
Content-Type: application/x-www-form-urlencoded
Content-Length: 184
csrf_token=<itsdangerous-signed>
&question_id=q-013-04
&answer=Yesterday%20I%20went%20to%20the%20store
&question_id=q-013-05
&answer=She%20has%20eaten
&question_id=q-013-06
&answer=I%20will%20write%20it
Middleware chain (in order)¶
- Rate-limit middleware (Phase 37) — token bucket per session + per IP. Limit: 30 req/min/session. On exceed: 429 with
Retry-After. Implementation reused fromsrc/miniserve/middlewares/ratelimit.py(Phase 33 + Phase 37). - Body-size cap (Phase 37) — 64 KiB hard cap for any portal POST (exam payloads are tiny). On exceed: 413 Payload Too Large.
- Injection-filter middleware (Phase 37) — applied to free-form text fields only (notes, answers); rejects requests containing known prompt-injection markers (
ignore previous instructions,system:, etc.). On match: 400 with a structured error. The filter is conservative; false-positive rate measured in lab 05. - CSRF validator — checks the
csrf_tokenform field matches the signed value in the session cookie. On mismatch: 403. - Schema validator — Pydantic model checks every
question_idis one the student is currently attempting, everyansweris a non-empty string under 1 KiB. On invalid: 400 with field-level detail. - Auth middleware — verifies session cookie signature, resolves student id, attaches it to the request scope. On invalid: 401.
- Handler —
routes/exams.py:submit_exam(...)scores each answer (string-equal modulo whitespace + case; grammar-tutor rubric is deterministic, §A13), recordsexam_attemptrows, enqueues failed questions intoreview_cardviasrc/minireview/scheduler.next_review_at(...), emits oneevent_logrow, returns a partial HTML fragment (HTMX swap) with the per-question result.
Response shape (HTMX partial)¶
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<div id="exam-result" class="result">
<h2>Exam — Phase 13 — submitted</h2>
<table class="grades">
<tr><td>q-013-04</td><td class="ok">correct</td></tr>
<tr><td>q-013-05</td><td class="wrong">expected: "She <em>has</em> eaten"</td></tr>
<tr><td>q-013-06</td><td class="ok">correct</td></tr>
</table>
<p>2 of 3 correct. 1 question added to your review deck;
next review: tomorrow.</p>
<a hx-get="/me/reviews" hx-target="#main">Open review deck</a>
</div>
Error shape (failed CSRF, for example)¶
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
<div id="exam-result" class="result error">
<h2>Submission rejected</h2>
<p>Your form was tampered with or expired. Please reload and
try again.</p>
</div>
Errors are HTML partials too — HTMX swaps them in place so the user sees an inline message rather than a generic browser error.
The database schema (sketch)¶
Full DDL lives in the BLUEPRINT; the conceptual model:
student(id, username, email_optional, role, password_hash, created_at, last_active_at)session(token_signature, student_id, issued_at, last_seen_at)— optional; for revocationinvite_token(nonce, student_id, issued_at, expires_at, used_at_nullable)phase_progress(student_id, phase_nn, first_seen_at, last_seen_at, quiz_passed_bool, exam_passed_bool)note(id, student_id, phase_nn, body, marker_tags, created_at, updated_at)quiz_attempt(id, student_id, phase_nn, question_id, answer, correct_bool, submitted_at)exam_attempt(id, student_id, phase_nn, question_id, answer, correct_bool, submitted_at)review_card(id, student_id, question_id, ease_factor, interval_days, repetitions, next_review_at)event_log(id, student_id, actor_id, event_type, target, sha_optional, occurred_at)
Indices on (student_id, phase_nn), (student_id, next_review_at), and (occurred_at).
How Phase 37 is composed, not re-implemented¶
The portal does not reinvent any security primitive Phase 37 built. Specifically:
- Rate limiter — imported from
src/miniserve/middlewares/ratelimit.py; the portal'sapp.pyincludes it with the portal's token-bucket parameters. - Injection filter — imported from
src/miniserve/middlewares/injection.py; applied to a configured set of fields, not all bodies. - Body-size cap — imported from
src/miniserve/middlewares/bodysize.py; configured smaller (64 KiB) than the Phase 39 default (256 KiB). - CSRF helper — imported from
src/miniserve/middlewares/csrf.pyif it exists at Phase 37's close; otherwise the portal contributes the helper upstream intosrc/miniserve/(so the grammar tutor benefits too) — per A12's flexibility clause this is a Phase 37 revision, not a Phase 41 fork.
What the portal adds on top:
- Password vault (
src/minivault/) — Argon2id wrapper, peppered, with rotation. - Session middleware — signed-cookie-only sessions; no DB session table in the common path.
- Invite-token flow — one-time, signed, time-limited.
The threat model for the portal extends security/THREATS.md with three new rows (drafted in lab 05): "session hijack via stolen cookie", "invite-token replay", "admin god-role abuse".
What this architecture deliberately excludes¶
- JavaScript framework. Anti-goal. HTMX + Jinja2.
- GraphQL. REST + form-encoded. Schemas are Pydantic.
- Background workers / queue. The review-deck update is computed on
POSTsynchronously — it is one SQL upsert. - Search index. SQLite FTS5 is available but not enabled in v1; full-text search of notes is a Phase 41+ candidate.
- Multi-tenant cohort isolation. One SQLite file per cohort = run a second
miniportalprocess if you need a second cohort. - WebSockets / SSE. No live streaming. HTMX
hx-trigger="every 30s"is enough for the auto-refreshing review deck.
What this architecture deliberately includes (and why)¶
- Process logging in the request path. Yes, every authenticated request writes one
event_logrow. Yes, that is a write on the hot path. The volume is bounded (≤ 100 students × ≤ 1 req/min average ≪ SQLite's write capacity), and the value (theory 00 §process-visibility) justifies it. - HTMX partials over JSON endpoints. A future ask "expose an API for a mobile client" is a Phase 41+ amendment; v1 returns HTML.
- Synchronous review-deck math. The SM-2 update is cheap (constant time per card); doing it inline avoids a queue, a worker, and a class of timing bugs.
- CSRF on every state-changing route. Including the admin-only ones. The cost is one signed cookie; the value is correctness.
Pitfalls when drawing the diagrams¶
- Over-drawing. No need to show every Jinja2 template. Containers are processes; components are sub-packages.
- Drawing the curriculum as an external system. The curriculum is the filesystem — same host, no network. Draw it as a storage dependency, not a separate system.
- Treating the reverse proxy as inside the system. It isn't. The diagrams show the portal binding to localhost; the proxy is outside.
- Forgetting the audit edge. The admin-reads-notes flow has a
event_logedge — show it. - Drawing Phase 39 as required. It is optional. Make the edge dashed.
What this theory does NOT cover¶
- Why FastAPI — Phase 33.
- Why injection-filter middleware looks the way it does — Phase 37.
- Why Argon2id over bcrypt / scrypt — covered in
src/minivault/BLUEPRINT.md(lab 02). - The SM-2 math —
PHASE_41_PLAN.md§2 + lab 04. - Email delivery, SSO, payments — explicitly out of scope.
Next: docs/phase-41-learner-portal/lab/00-bring-up-and-first-student.md (pre-written separately) — bootstrap the FastAPI app, create the first admin via just portal-admin, redeem the invite, create the first student.