xavier-ramirez.com
← How a standard becomes a lesson

Your own lesson pipeline · 01 · 26 Aug 2026 · 10 min read

Your own lesson pipeline: the spine

Clone it and run it — a standard code to a published assignment, end to end.

The tempting way to build this is a form and a prompt. A text box on the front, a frontier model in the middle, a course export on the back — and three weeks later nobody can answer why the lesson a teacher approved on Monday is different on Thursday, or which of four hundred items came from the source passage you have just discovered is wrong. The system is opaque exactly where you need to audit it. This post builds the spine instead: a request, a generation worker, an immutable block store, a gate, a review surface and one publish.
01 · Spine02 · Alignment03 · Appropriateness04 · Guardrails & deletion05 · Publishing & fidelity06 · Efficacy

This post builds every stage at its thinnest honest version, so the later posts have somewhere to land.

Worked exampleThis series uses a K–8 supplemental curriculum publisher as its running example — standards frameworks from three states, generated interactive lessons, the teachers who edit them, and the learning-management courses in two districts the platform publishes into. The pipeline itself is domain-agnostic; the content just happens to belong to third graders.

The decision

Where the model call lives decides everything else

Before any of the code, one question, and it is the one that determines whether a bad afternoon at your model provider is an operational condition or a broken product. The same request, run four ways.

one request, four ways
Generation runs…
The model provider is…

↳ Start in the handler, with the provider throttling — the August configuration. Then move the generator behind the log and change nothing else.

The API returns504 Gateway Timeout
The teacher waits30 s
She seesAn error, and no record that she asked
nothing written down cannot be replayed
A transient upstream problem has become a broken product during first period. Worse: nothing recorded that this lesson was wanted, so the incident is unrecoverable in the strict sense — you cannot afterwards generate what the outage ate, because nobody wrote down that anyone asked.

The request

A prompt is not a contract

Start at the edge, because everything downstream inherits its mistakes. The thing a user creates is not a prompt — it is a durable record carrying the scope the entire pipeline will run under. The prompt is one implementation detail of one generator, and it will change three times before the quarter ends. The request will not.

src/api/requests.ts
import Fastify from "fastify";
import { producer } from "../stream/producer.js";
import { scopeFromClaims } from "../security/scope.js";
import type { LessonRequest } from "../contracts/request.js";

const app = Fastify({ logger: true });

app.post("/requests", async (req, reply) => {
  const scope = scopeFromClaims(req.user);   // district + env, from the token, never the body
  const body = req.body as Partial<LessonRequest>;
  if (!body.standardCode || !body.gradeBand) return reply.code(400).send({ ok: false });

  const request: LessonRequest = {
    requestId: `req_${randomId()}`,
    districtId: scope.districtId,
    env: scope.env,
    standardCode: body.standardCode,               // "3.OA.A.1"
    frameworkId: scope.frameworkId,                // pinned per district
    frameworkVersion: scope.frameworkVersion,      // "2019.1" — revisions change the claim
    gradeBand: body.gradeBand,                     // "3"
    readingLevelTarget: body.readingLevelTarget ?? null,  // 1.0 on grade; 0.6 two years below
    locale: body.locale ?? "en-US",
    requestedBy: scope.userId,
    requestedAt: new Date().toISOString(),         // server-stamped, never client-trusted
  };

  await producer.send({
    topic: "lesson.requests",
    messages: [{ key: `${scope.districtId}:${request.requestId}`, value: JSON.stringify(request) }],
  });
  return reply.code(202).send({ ok: true, requestId: request.requestId });
});
Why is generation a queue consumer and not the request handler?
Because a model call is an unbounded, retryable, externally rate-limited operation, and you must not synchronise one with a request a human is watching. A four-block lesson is four to six model calls plus retrieval; on a bad afternoon that is ninety seconds, and on a worse one it is a rejection from a provider whose capacity you do not control. Behind the log it is a different failure: backpressure shows up as consumer lag, which is a number you alert on and a queue you can drain. And the coupling is worse than the latency — an inline generator ties your ability to re-run to your ability to serve.

The pipeline

Six stages, one log

Each stage reads a topic and writes a topic. That is the entire architecture, and it is deliberate: any stage can be stopped, rewritten and restarted from its own position without coordinating with the others.

TopicKeyCompactedContents
lesson.requestsdistrictId:requestIdnoevery lesson asked for
lesson.blocksdistrictId:blockHashnogenerated blocks with provenance
lesson.artifactsdistrictId:lessonIdyescurrent version of each lesson
lesson.reviewsdistrictId:lessonIdnoedits, approvals, rejections
lesson.publishesdistrictId:courseIdnowhat went where, and when
content.flagsdistrictId:blockHashnogate decisions and their reasons
src/contracts/block.ts
export interface ContentBlock {
  blockHash: string;          // sha256 of (kind, body, assets) — its identity IS its content
  kind: "explanation" | "worked_example" | "practice_item" | "image" | "interactive";
  body: string;               // markdown or a structured item, per kind
  assetIds: string[];         // object-store keys; images carry their own rights record
  provenance: BlockProvenance;
}

export interface BlockProvenance {
  requestId: string;          // "req_5510"
  objectiveId: string;        // "obj_7d31" — what this block is FOR, not just what it is about
  generator: "model" | "human" | "imported";
  modelVersion: string | null;      // null when a human wrote it
  promptHash: string | null;        // the exact prompt, hashed — re-derivable, not guessable
  retrievalSnapshot: string | null; // ids + versions of every source passage used
  createdAt: string;
}
Why content-address the blocks?
Because it makes three otherwise painful things free. Deduplication: the same explanation generated twice for two districts is one object, stored once. Diffing: a lesson’s version graph is a list of hashes, so “what changed between v3 and v4” is a set difference rather than a text diff over prose. And recall: when a source passage is retracted, every block whose retrieval snapshot contains it is a query, and every lesson containing those blocks is one join away — which is the only mechanism by which a content recall is a Tuesday afternoon rather than a quarter. It also removes a class of bug by construction: a block cannot be edited in place, so “what did the teacher actually approve” has an exact answer forever.
workers/generate.py
"""Consumes lesson.requests, emits lesson.blocks. Stateless; safe to run N of these."""
from lessonpipe.contracts import LessonRequest, ContentBlock, BlockProvenance
from lessonpipe.graph import objectives_for
from lessonpipe.retrieval import approved_sources
from lessonpipe.stream import consume, publish
from lessonpipe.hashing import block_hash, prompt_hash


def handle(request: LessonRequest) -> None:
    # Objectives come from the content graph, not from the model. Post 2 owns this edge.
    for objective in objectives_for(request.standard_code, request.framework_version):
        sources = approved_sources(
            district_id=request.district_id,      # retrieval is scoped, always
            framework_version=request.framework_version,
            objective_id=objective.id,
        )
        prompt = render_prompt(objective, sources, request.grade_band, request.reading_level_target)
        for draft in call_model(prompt):          # retries and rate limits live here, not in the API
            block = ContentBlock(
                block_hash=block_hash(draft.kind, draft.body, draft.asset_ids),
                kind=draft.kind,
                body=draft.body,
                asset_ids=draft.asset_ids,
                provenance=BlockProvenance(
                    request_id=request.request_id,
                    objective_id=objective.id,
                    generator="model",
                    model_version=draft.model_version,
                    prompt_hash=prompt_hash(prompt),
                    retrieval_snapshot=[s.version_key for s in sources],
                    created_at=now_iso(),
                ),
            )
            publish("lesson.blocks", key=f"{request.district_id}:{block.block_hash}", value=block)


consume("lesson.requests", handle)

Isolation

District and environment are partition keys, not afterthoughts

Two districts are two different public bodies who happen to share your cluster, and a test environment must never leak into production. So scope is not a filter someone remembers to apply — it is in the key, the partition and the namespace.

src/contracts/scope.ts
export interface TenantScope {
  districtId: string;         // "riverbend"
  env: "dev" | "prod";
}

/** The one place a scope becomes a string. Schema names, topic keys, object prefixes. */
export function namespace(scope: TenantScope): string {
  return `${scope.districtId}__${scope.env}`;
}

export function requestKey(scope: TenantScope, requestId: string): string {
  return `${scope.districtId}:${requestId}`;
}

Partitioning keeps districts apart; it does not keep children safe. Publishability as a join key, row-level security bound to the executing role, and the external contractor login that makes both necessary are post 4’s job.

The gate and the publish

The thin end: one check, one course

The gate reads blocks, runs a small set of checks, and writes a decision. The publisher assembles approved blocks into a lesson and pushes it at a local mock platform. That is the minimum viable pipeline, and it is enough to prove the spine end to end.

src/contracts/lesson.ts
export interface LessonVersion {
  lessonId: string;            // "lsn_4c19" — stable across versions
  version: number;             // 1, 2, 3 — monotonic per lesson
  districtId: string;
  env: "dev" | "prod";
  standardCode: string;        // "3.OA.A.1"
  frameworkVersion: string;    // "2019.1"
  blockHashes: string[];       // ordered; the lesson IS this list
  derivedFrom: number | null;  // the version this one was edited from
  createdBy: string;           // "usr_2210" — a teacher, or "system"
  createdAt: string;
}
Run it yourself · open-lesson-pipeline @ v1-spine
Bring up the stack, seed two districts with synthetic frameworks and courses, then watch one standard code become a published assignment — twice, once with the model provider forced to fail.
terminal
git clone https://github.com/xaviramirezcom/open-lesson-pipeline
cd open-lesson-pipeline
git checkout v1-spine
pnpm install && uv sync
pnpm lesson up              # queue + postgres + object store + a mock LMS, via docker compose
pnpm lesson seed            # two districts, three frameworks, synthetic courses and rosters

# Ask for one lesson and follow it through every stage.
pnpm lesson request --district riverbend --standard 3.OA.A.1 --grade 3
pnpm lesson trace req_5510

# The same request, with the generator's model provider forced to fail.
pnpm lesson request --district riverbend --standard 3.OA.A.1 --grade 3 --chaos model-429
pnpm lesson trace req_5510 --show-lag
The healthy trace prints six hops: one request, three objectives resolved, fourteen blocks each with a prompt hash and a retrieval snapshot, eleven passes and three holds, a lesson at version one, and a publish. The chaos run prints something more useful: the API still returns in under fifty milliseconds, the request sits durably on the log, consumer lag climbs, and when the provider recovers the worker drains it and the lesson appears — late, but never lost. Same request, same failure, and the only thing that decided whether a teacher saw an error was where the model call lived.

Teaching-grade reference implementation, not a production courseware platform. It reproduces the ideas and the queue/warehouse integration shape; bring your own model keys, curriculum data and LMS credentials. LMS adapters run against a local mock by default. MIT-licensed. View the repo →

Explain it back

A team ships the generator inline with the request handler — the teacher clicks Generate, the handler retrieves, calls the model four times, assembles the lesson and returns it, so lessons appear “instantly” and there is no queue to operate. It works beautifully through a pilot with forty teachers. In August, six districts onboard at once, every teacher in the country builds their first unit in the same two weeks, and the product is unusable for eleven days. What went wrong architecturally, and what should the handler’s only job have been?
Reveal a model answer

They put an unbounded, externally rate-limited operation in the hot path of a human interaction. Generation latency is not a property of your code — it is a property of a provider’s capacity, and in August you are competing for that capacity with every other education company having the same August. When the provider throttles, an inline handler converts a queueing problem into an availability problem, at exactly the moment a district’s first impression is being formed.

The handler’s one job is to accept and durably record: validate the scope, stamp the time, write to the request topic, return an id. Everything slow, failable or provider-dependent belongs behind the log, where a capacity shortfall shows up as consumer lag — a number you can watch drain, and a number you can throw workers at — instead of as a spinner.

The bonus consequence is quieter and worse. With generation inline, the pipeline has no record of what was asked for — only of what came back, if anything did. So the August incident is unrecoverable in the strict sense: you cannot afterwards generate the lessons the eleven days ate, because nobody wrote down that they were wanted. A durable request log turns the same incident into a backlog you drain overnight and an apology with a delivery date in it.

Next in the series · 02
Alignment & the content graph

The toy objective lookup becomes a real graph — standard codes that normalise, alignment claims with a class and a provenance, and the crosswalk that swallows a whole strand while reporting perfect coverage.

See the collapse