xavier-ramirez.com
← Guardrails & deletion

Your own lesson pipeline · 05 · 30 Aug 2026 · 11 min read

Publishing & fidelity

Diff, don't republish — and watch the fidelity rate like it's a classroom.

The tempting way to get content into a learning platform is to export the whole course and import it every time something changes. One package, one call, no state to keep, and obviously correct — the course now contains exactly what you meant it to. It is also the version that orphans student submissions, duplicates every assignment, burns an API quota you will need in September, scales with course size instead of change, and leaves a teacher with no way to ask what changed on Tuesday.
01 · Spine02 · Alignment03 · Appropriateness04 · Guardrails & deletion05 · Publishing & fidelity06 · Efficacy

This post deepens the publishing stage — the moment the pipeline stops being your system and starts being someone else's.

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 diff

Send what changed, not what is

Post 4’s compiler gives you the publishable set for a lesson version. What a platform needs is the delta between that and what it already has. Those are different objects, and the gap between them is where all four of the full-republish failures live — one of which is much worse than the others.

one course, two strategies412 elements · 7 change a night

↳ Drag back to 1: on the first night both modes send the whole course, because it has to be loaded once. Every night after that is the argument.

Submissions stranded
297 vs 0
API calls
126 vs 22
Objects created
5,768 vs 412
Elements tonight
412 vs 7
Republishing is proportional to the course and diffing is proportional to the change, and only one of those grows with your success. But the quota is the cost you can see. The one that loses you a district is the dark bar: delete-and-recreate strands every submission attached to the old object, so the gradebook column is empty and a teacher spends her Sunday re-entering marks. Updating in place through a stable external id is the whole difference, and it costs one column.
sql/publish_snapshot.sql
-- One row per element per run. Append-only: snapshots are history, not state.
CREATE TABLE publish_snapshot (
  district_id    text        NOT NULL,
  env            text        NOT NULL,
  course_id      text        NOT NULL,       -- "crs_88214"
  lesson_id      text        NOT NULL,       -- "lsn_4c19"
  lesson_version int         NOT NULL,       -- the version compiled
  snapshot_id    text        NOT NULL,       -- "snap_2026_08_25"
  element_key    text        NOT NULL,       -- stable across runs; becomes the external id
  element_kind   text        NOT NULL,       -- 'page' | 'assignment' | 'quiz_item' | 'file'
  content_hash   text        NOT NULL,       -- post 1's block hash, or a hash of the rendered element
  first_published_at timestamptz NOT NULL,   -- carried forward across runs, never recomputed
  captured_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (district_id, env, course_id, snapshot_id, element_key)
);
src/publish/diff.ts
export type Op = "add" | "update" | "remove";

export interface ElementDelta {
  elementKey: string;      // stable identity — this is what makes the publish idempotent
  op: Op;
  kind: string;
  contentHash: string | null;
}

export function diff(current: SnapshotRow[], previous: SnapshotRow[]): ElementDelta[] {
  const prev = new Map(previous.map((r) => [r.elementKey, r.contentHash]));
  const out: ElementDelta[] = [];
  for (const row of current) {
    const before = prev.get(row.elementKey);
    if (before === undefined) out.push({ ...row, op: "add" });
    else if (before !== row.contentHash) out.push({ ...row, op: "update" });  // update, never delete+create
    prev.delete(row.elementKey);
  }
  for (const [elementKey] of prev) out.push({ elementKey, op: "remove", kind: "", contentHash: null });
  return out;
}

Updating rather than deleting-and-creating is the line that protects student work, and it only exists because the element key is stable. That key is carried into the platform as an external identifier, so a re-publish finds and updates the existing object instead of creating a second one beside it. Get this wrong and the first symptom is not an error; it is a teacher with two copies of every assignment.

Why snapshot instead of diffing on the fly?
Because lessons change for two different reasons, and a naive diff cannot tell them apart. A teacher edits a block: three elements change. A district raises its reading-level threshold and a hundred lessons recompile: forty thousand elements change. Diffed on the fly, those are indistinguishable from each other and from a broken gate job that failed everything open. The snapshot pins the lesson version and the rubric version alongside the elements, so a diff is computed within a configuration and a configuration change is reported as its own event. Attribution of your own content movements stops being optional the moment a district asks why every lesson changed on the same night.

The adapter

Every platform has its own shape, and none of it is your engine's business

The wrong shape here is a class per platform with the packaging baked in, each with its own loop, its own retry, its own quiet divergence. Six months later one of them has a bug the others do not. Instead, every adapter declares a capability descriptor and one generic engine reads it.

src/publish/capabilities.ts
export interface LmsCapabilities {
  id: string;
  transport: "rest" | "cartridge_package" | "deep_link";
  supportedKinds: ElementKind[];         // what survives; everything else degrades or drops
  degradations: Partial<Record<ElementKind, ElementKind | "drop">>;
  maxBatchSize: number;
  maxPackageBytes: number | null;        // package imports have a size ceiling; REST does not
  supportsUpdateInPlace: boolean;        // false => removal must become unpublish, not delete
  supportsDeleteWithSubmissions: boolean;
  rateLimitPerMinute: number;
  externalIdField: string | null;        // null => no idempotency; see below
}

// The numbers below are placeholders. Read the real ones off each platform's current
// published limits and your own instance's configuration — never hard-code a guess.
export const MAJOR_LMS: LmsCapabilities = {
  id: "canvas",
  transport: "rest",
  supportedKinds: ["page", "assignment", "quiz_item", "file"],
  degradations: { interactive: "page", drag_and_drop: "page", hotspot: "drop" },
  maxBatchSize: 50,
  maxPackageBytes: null,
  supportsUpdateInPlace: true,
  supportsDeleteWithSubmissions: false,   // unpublish instead — the engine handles this generically
  rateLimitPerMinute: 180,
  externalIdField: "integration_id",
};
src/publish/engine.ts
import type { LmsCapabilities } from "./capabilities.js";
import { lastCheckpoint, saveCheckpoint } from "./checkpoint.js";
import { deadLetter } from "./deadletter.js";
import { unpublish } from "./unpublish.js";

export async function publishBatches(
  caps: LmsCapabilities,
  publishId: string,
  deltas: ElementDelta[],
  send: (batch: ElementDelta[]) => Promise<BatchResult>,
): Promise<void> {
  const [removals, rest] = partition(deltas, (d) => d.op === "remove");
  if (removals.length && !caps.supportsDeleteWithSubmissions) await unpublish(publishId, removals);

  const resume = (await lastCheckpoint(publishId))?.batchIndex ?? -1;
  for (let i = 0; i * caps.maxBatchSize < rest.length; i++) {
    if (i <= resume) continue;                                  // already durably accepted
    const batch = rest.slice(i * caps.maxBatchSize, (i + 1) * caps.maxBatchSize);
    const res = await send(batch);                              // paced by rateLimitPerMinute
    for (const r of res.rejected) await deadLetter(publishId, r.elementKey, r.reason);
    await saveCheckpoint({ publishId, batchIndex: i, accepted: res.accepted,
                           rejected: res.rejected.length, at: new Date().toISOString() });
  }
}

The checkpoint is written after a batch is accepted, so a crash between batch 7 and 8 resumes at 8 rather than replaying an entire district. Idempotency is the external id’s job at the element level and the checkpoint’s job at the batch level; you need both.

Partial failure is the normal case, not the exception. A fifty-element batch comes back accepted with three rejected — a maths fragment the importer’s sanitiser stripped, a file type the instance disallows, a validation rule you will never see documented. Failing the publish throws away forty-seven good elements over three bad ones. Dropping the three makes the dashboard green and the lesson wrong. You dead-letter each element with the importer’s verbatim reason and surface the dead-letter rate beside the publish’s success flag.

src/publish/deadletter.ts
export interface DeadLetterRow {
  publishId: string;       // "pub_7f31"
  districtId: string;      // "riverbend"
  courseId: string;        // "crs_88214"
  lessonId: string;        // "lsn_4c19"
  elementKey: string;
  elementKind: string;
  reason: string;          // importer-reported, verbatim, never rewritten or prettified
  batchIndex: number;
  at: string;
}

A publish that reports success while dropping elements is worse than one that fails, because it does not wake anyone up. It wakes a nine-year-old up, in a lesson with a hole in it.

Fidelity

The one number that tells you how much of the lesson survived

Fidelity rate is elements submitted versus elements that render correctly in the student view. The denominator matters, and it is not “elements authored” — a block the gate held and a block type the transport cannot carry are both real losses, but they are losses you caused before the request left your building, and folding them into one rate makes every cause look like the importer’s fault.

one lesson, one publishfidelity 85.7%
Where did the elements go?
Renders in the student view · -1
An image whose link returns a permission error. No response code could have told you this: an importer returning success means it accepted the element, not that a child can use it.

The denominator is elements submitted, not elements authored. Report the funnel and alert on the last step — one number for all four causes tells you nothing about which one moved, and the number worth staring at here is not the 85.7% but the two elements lost to transport. That loss belongs to your editor’s block palette, and it should have been visible there rather than discovered by a teacher on a Monday.

src/publish/fidelity.ts
import { db } from "../db/client.js";

export async function checkFidelity(
  districtId: string, courseId: string, lessonId: string, observed: number,
): Promise<{ baseline: number; dropPct: number; alert: boolean }> {
  const { rows } = await db.query(
    `SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY fidelity) AS baseline
       FROM publish_audit
      WHERE district_id = $1 AND course_id = $2 AND lesson_id = $3
        AND ts >= now() - interval '30 days'`,
    [districtId, courseId, lessonId],
  );
  const baseline = rows[0]?.baseline ?? observed;
  const dropPct = baseline > 0 ? (baseline - observed) / baseline : 0;
  return { baseline, dropPct, alert: dropPct >= 0.1 };     // relative, never absolute
}

The silent killer

A course that stopped updating three weeks ago

The district’s access token expires on a Tuesday. The publish is rejected. The error is caught, logged at warning level, and the job exits successfully — because it did run, it just did not accomplish anything. The nightly dashboard is green. The course sits frozen at whatever it was on the fourth, teachers keep assigning from it, and the corrections your team has shipped since — including the one that fixed a wrong answer key — have reached nobody. Somebody notices in a month, usually a parent.

The fix is not a better try-catch. It is structural: every course-and-lesson binding carries a freshness target and a last-successful-publish timestamp, and the alert fires on the absence of a success rather than the presence of an error. A caught exception can be swallowed. A timestamp that fails to advance cannot be.

src/publish/freshness.ts
interface Binding {
  districtId: string; courseId: string; lessonId: string; lms: string;
  freshnessSlaMinutes: number;              // e.g. 1440 for a nightly-synced course
  lastSuccessfulPublishAt: string | null;   // advanced ONLY on a fully completed publish
}

export async function staleBindings(now = Date.now()): Promise<Binding[]> {
  const bindings = await db.query<Binding>("SELECT * FROM publish_bindings WHERE active");
  return bindings.rows.filter((b) => {
    // Never-published is stale, not absent. A null must page someone.
    const last = b.lastSuccessfulPublishAt ? Date.parse(b.lastSuccessfulPublishAt) : 0;
    return now - last > b.freshnessSlaMinutes * 60_000;
  });
}

Monitor for the thing that should have happened, not just the thing that went wrong. The same table gives you the publish ledger — every lesson, every course, every publish id, appended as batches are accepted. It costs one table and a little write volume, and it is what makes two earlier posts work: post 2’s alignment retraction needs to know which courses hold a claim you have revoked, and post 4’s deletion fan-out needs to know where content went. Without it, both stop at your database.

Run it yourself · open-lesson-pipeline @ v5-publish
Publish the same course twice against the local mock platform and read the quota meter, then break fidelity down into its losses.
terminal
git clone https://github.com/xaviramirezcom/open-lesson-pipeline
cd open-lesson-pipeline
git checkout v5-publish
pnpm install && uv sync
pnpm lesson up
pnpm lesson seed --with-submissions      # the mock course already has student work in it

# The naive nightly publish: export the course, import the course.
pnpm lesson publish --course crs_88214 --mode republish

# The same course, same night, diffed against the previous snapshot.
pnpm lesson publish --course crs_88214 --mode diff

# Where the elements went.
pnpm lesson fidelity --course crs_88214 --lesson lsn_4c19
The republish run sends 412 elements in nine batches, creates 412 new objects, and the mock reports sixty-three orphaned submissions — sixty-three pieces of student work now attached to assignments nobody can see. The diff run sends 7 elements in one batch, updates them in place through the external id, and reports none. Same course, same content, same night: a fifty-to-one difference in quota and a difference in kind in what happened to children’s work. Then the funnel prints, and the number worth staring at is not the 85.7% but the two elements lost to transport — that loss was decided in the editor, when someone chose a block type the platform cannot carry, and it should have been visible there rather than here.

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 district reports that your content “keeps breaking” — teachers are finding blank pages and dead interactives, seemingly at random, across several courses. Your publish dashboard is entirely green: every nightly job completed, no errors, no failed batches. The team wants to know whether the generator has regressed. Where do you look first, and in what order?
Reveal a model answer

Not at the generator. A green dashboard reporting completion is exactly the signature of the silent failures, so start with the metrics a “completed” job cannot fake. First, the last-successful-publish timestamp per binding: if some courses are weeks stale, teachers are seeing content from before a fix you shipped, and “random” is really “whichever courses stopped updating, and when”. Second, the fidelity trend per binding against its own trailing median — a drop concentrated in one element kind points at a platform-side change, typically a release tightening a sanitiser, and that is a packaging fix rather than a content one. Third, the dead-letter rate and the reasons verbatim; a publish that accepts forty-seven and discards three reports success, and three per publish across forty courses is a hundred and twenty holes a week.

Only then look at the degradation counts, and only to answer a different question: whether your editor is offering block types this district’s platform cannot carry. If so, the fix is in the block palette, not in the publisher — you are authoring content that was never going to survive, and every publish is faithfully delivering less than you made.

The bonus consequence explains the word “random”. If you have been running in republish mode, every night created new objects with new ids, so a teacher’s links, her gradebook columns and any student’s in-progress work point at objects that no longer exist. The blankness is not a rendering bug at all — it is the accumulated wreckage of a publish strategy, and it will keep looking random because it correlates with when each teacher last touched the course rather than with anything in the content.

Next in the series · 06
Efficacy & incrementality

The lessons are live and the classrooms are real — now the harder question of whether any of it taught anything.

Compared to what?