[ norboten ]

Pipelines

Everything that turns source into something a learner or a viewer sees, step by step. Each pipeline names its entry point, its inputs and outputs, and what makes it refuse.

1. A lab, from a pull request to a learner's machine

labs/<track>/<id>/  ──lint──►  gate (every base image)  ──merge──►  tag v*  ──►  OCI artifact  ──►  u in the TUI
 lab.yaml                      break → all checks fail                         ghcr.io/…/norboten-labs/<id>
 briefing.md                   solution → all pass                              signed with cosign
 break/ check/ hints.yaml      reboot → all pass again
 solution/ theory.yaml journal.md
Step Entry point Refuses when
Lint norboten dev lint (make lint-labs, pre-commit, CI) the manifest breaks docs/lab-spec.md: an objective with no check, a check id out of order, a hint ladder short of four levels, a guest script importing outside the standard library, a journal missing a section
Gate norboten dev validate <lab> (lab-validate.yml, a matrix of the labs a change touches × the images each supports) a check passes on the broken machine, or fails after the reference solution, before or after the reboot
Publish lab-publish.yml on a v* tag — builds images/Dockerfile.lab (FROM scratch, one layer with the lab at /lab), pushes :<version> and :latest, signs the digest keylessly
Pull u in the Labs section → labs.store.pull the tag does not resolve, the manifest id does not match the repository name

A change to the runner, the guest-facing engine code, the image registry or the baseline role validates every lab, not just the touched ones (.github/scripts/changed_labs.py).

2. A golden base image

upstream cloud image ──fetch, verify sha256──► Lima VM ──ansible lab_baseline──► finalize.sh ──► qemu-img convert -c
(pinned in images/registry.yaml)                                                  (no build traces)     images/out/<id>-<arch>.qcow2 + .json
                                                                                                         ──oras push──► ghcr.io/…/norboten-base/<id>:<version>
                                                                                                         ──PR──► new digest in registry.yaml

Entry point: images/build.py <id> (make image IMAGE=…), image-publish.yml on demand. The upstream download is resumable and digest-checked before anything boots. The baseline installs every package the track needs (nothing is installed while a learner works), puts GRUB and the kernel on the serial console, sets a persistent journal and per-command history, removes what slows a cold boot (NoCloud-only cloud-init, a 1 s GRUB timeout, chrony's initial step on Alpine), and installs git for the recording diffs. A new digest in registry.yaml re-runs the gate for every lab.

Measured builds (Apple Silicon, 2026-09-12): alpine 90 s / 105 MB, rocky-10 166 s / 835 MB. The three Ubuntu 26.04 images are measured when they are first built.

3. A theory question

The repository's banks (quizzes/*.yaml, each lab's theory.yaml) are validated against docs/quiz-spec.md, and every question with a verify block is executed by quiz-verify.yml. New questions are drafted on a maintainer's machine (norboten dev draft-questions) or, for a learner's own practice bank, by g on Theory — through cli/src/norboten/questions/pipeline.py, never on the server:

generate ──► schema ──► solve blind ×2 ──► critic ──► execute ──► dedup ──► a draft
 writer       Question    other models,     a second    container,  against    with provenance
 model        model +     no key in the     defensible  no network  the banks  → read → a bank
              spec rules  prompt            answer?
Stage Rejects
setup fewer than two usable models other than the writer (--min-verifiers)
schema anything the Question model or the spec refuses
blind solve any verifier answering differently from the key, or calling it ambiguous
critic a second defensible answer, a wrong fact, a trick question
execute a verify snippet whose output is not the key (for a learner, also one Docker could not run)
dedup a prompt 85% similar (difflib) to one already in a bank or a draft

A surviving draft keeps its provenance: the writer, each solver's answer and note, the critic's verdict, the sandbox's output. A maintainer reads it before it moves into a published bank.

4. A recording, and a live session

p / P on a lab ──► PTY recorder ──► frames (asciicast v2) ──┬──► ~/.norboten/plays/*.cast + .log.json
                     │                commands              │
                     └── git in the guest ──► diffs ─────────┘
                                                            └── P only: POST /play/sessions/<id>/frames every ~2 s
                                                                   ├─► play_batches (PostgreSQL, 7 days)
                                                                   └─► PUBLISH play:<id> (Redis) ──► SSE ──► every viewer

The shell runs in a pseudo-terminal and bytes are copied both ways, so pipes, editors and colour record exactly. Commands are lifted from what was typed; diffs come from keeping the guest's watched directories under git for the session (/var/lib/norboten/watch, with the work tree pointing at /etc so no .git appears there), and a diff is attributed to the last command that finished before it was taken. A viewer's stream subscribes to the Redis channel first, replays what is already stored, then forwards what is published — so a viewer can arrive before the first frame or long after it.

The site's canned recordings are made the same way with a scripted typist: norboten dev record <slug> boots the lab on a fresh VM, types site/streams/<slug>.script, and writes site/streams/<slug>.json. Only the typing is scheduled; the machine, its output and its diffs are real.

5. The consultant's index

docs/*.md ─┐
journals ──┼─ without the walkthrough ─┐
briefings ─┤                           ├─► passages (≤180 words, split at headings) ─► BM25 index (in memory, at API start)
questions ─┘   no solution file,       │                                          └─► site/dist/ask-index.json (at site build)
               no level 3–4 hint ───────┘

retrieval.build_corpus() builds both from the same function, so the API and the browser fallback search the same passages. The API ranks with BM25 (k1 1.5, b 0.75), hands the top five to the model chosen from NORBOTEN_CONSULTANT_MODELS (Ollama first), and puts the answer through the tutor's guard (norboten.tutor.guards) before it leaves. The browser, when the API cannot be reached, ranks the same passages with the same tokenizer (site/static/ask-engine.js) and quotes the best three.

6. Analytics

users + attempts ──► pandas frames ──► charts (matplotlib → SVG) + facts.json ──► /analytics/
(PostgreSQL nightly,                    TF-IDF + networkx relation graph
 or seed/accounts.json at site build)   k-means on topic ratings

python -m norboten_api.analytics --database-url … --out <site>/analytics on the server (norboten-analytics.timer, 04:10), or from the sample population during site/build.py. Ratings are replayed through the same accounts.rate the API uses, so the rating charts are not a second implementation. The page's sentences read their numbers from facts.json, which the job writes with the charts.

7. The site

site/build.py renders everything from the repository — labs, briefings, journals, docs, question counts, the sample population, the recordings, the captures, the analytics — into site/dist (224 pages). site/capture.py (make captures) renders the TUI screenshots by driving the real app headlessly. The deploy workflow builds the site against the production API and rsyncs it to the server, where Caddy serves it.

8. The server

See CI/CD for the workflow and Deploying the server for the machine. In one line: push to main → tests → API image → site → rsync → deploy.sh, which waits for /readyz and rolls back by itself.