Skip to content

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:

  1. miniportal — the FastAPI app itself. Python 3.11, single asyncio event loop. Hosts:
  2. Route handlers (students, journal, notes, quizzes, exams, admin) — see §routes.
  3. Jinja2 template renderer + static asset middleware.
  4. Argon2id password verifier (wired through src/minivault/).
  5. Session middleware (signed cookies via itsdangerous).
  6. SM-2 review scheduler (wired through src/minireview/).
  7. Phase 37 middlewares: rate limiter, body-size cap, prompt-injection filter, CSRF token validator.
  8. Phase 33 conventions: OpenAPI schemas, /health, structured logs.
  9. SQLite file (./portal.db) — one file. WAL mode. Backed up by just portal-backup (a cp with a timestamp).
  10. docs/phase-NN-*/ filesystem — the curriculum source. Read at request time; not copied into the DB.
  11. Reverse proxy (cited, not bundled) — Caddy or nginx in front of miniportal for TLS termination, static asset serving, and HTTP/2. The BLUEPRINT ships an example Caddyfile; the demo binds to localhost and assumes the proxy is provided externally.
  12. Phase 39 grammar tutor (optional) — separate process from Phase 39's capstone. Reachable via http://localhost:8001 or 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 around LYNX_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)

  1. Rate-limit middleware (Phase 37) — token bucket per session + per IP. Limit: 30 req/min/session. On exceed: 429 with Retry-After. Implementation reused from src/miniserve/middlewares/ratelimit.py (Phase 33 + Phase 37).
  2. Body-size cap (Phase 37) — 64 KiB hard cap for any portal POST (exam payloads are tiny). On exceed: 413 Payload Too Large.
  3. 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.
  4. CSRF validator — checks the csrf_token form field matches the signed value in the session cookie. On mismatch: 403.
  5. Schema validator — Pydantic model checks every question_id is one the student is currently attempting, every answer is a non-empty string under 1 KiB. On invalid: 400 with field-level detail.
  6. Auth middleware — verifies session cookie signature, resolves student id, attaches it to the request scope. On invalid: 401.
  7. Handlerroutes/exams.py:submit_exam(...) scores each answer (string-equal modulo whitespace + case; grammar-tutor rubric is deterministic, §A13), records exam_attempt rows, enqueues failed questions into review_card via src/minireview/scheduler.next_review_at(...), emits one event_log row, 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 revocation
  • invite_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's app.py includes 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.py if it exists at Phase 37's close; otherwise the portal contributes the helper upstream into src/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 POST synchronously — 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 miniportal process 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_log row. 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

  1. Over-drawing. No need to show every Jinja2 template. Containers are processes; components are sub-packages.
  2. 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.
  3. Treating the reverse proxy as inside the system. It isn't. The diagrams show the portal binding to localhost; the proxy is outside.
  4. Forgetting the audit edge. The admin-reads-notes flow has a event_log edge — show it.
  5. 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 mathPHASE_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.