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.
↳ 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.
- A republish orphans student work. Delete and recreate an assignment and the submissions attached to the old object are stranded, the gradebook column is empty, and a teacher spends her Sunday re-entering marks. This is not a data problem; it is the single fastest way to lose a district.
- Quota is a shared, finite budget. Every course in the district draws from the same allowance. A full re-import of forty courses at two in the morning is quota you cannot spend on the urgent correction at twenty to ten on the morning somebody notices an error.
- Republish is proportional to the course; diff is proportional to the change. A stable unit changes a handful of blocks a week. One of those numbers grows with your success and one does not.
- Republish destroys the why. After a wholesale rewrite, nothing entered and nothing left — the course simply is. The moment a teacher asks why her lesson looks different today, you have nothing to answer with.
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.
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.
- Capabilities are data, not code. Adding a platform is a descriptor plus a transport function; the batching, pacing, checkpointing, degradation and dead-lettering are inherited.
- Degradations are an explicit, auditable policy. An interactive block that becomes a static page is a known loss, declared up front and counted, not a surprise a teacher discovers. A kind mapped to drop must never be silently dropped — it is dead-lettered with a reason.
- A platform that cannot delete with submissions gets removals routed to unpublish. Generically, once, in the engine. A special case is where somebody eventually deletes an assignment thirty children had already submitted to.
- No external id field means you cannot be idempotent. That is a fact about the platform, and the honest response is a descriptor that says so and an engine that refuses to auto-publish to it — rather than a retry that quietly duplicates a course.
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.
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.
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.
- The last row cannot be measured from a response code. An importer returning success tells you it accepted the element, not that a child can use it. Rendering fidelity needs a real check — a headless fetch of the published page against an expected-elements manifest, run on a sample.
- Instrument per lesson, per course, per publish. A district-level average hides the one course that fell off a cliff.
- Alert on a relative drop against that binding’s own trailing baseline. A text-heavy lesson round-trips near-perfectly and an interactive one does not; an absolute floor either screams constantly or never fires.
- Report degradation separately from rejection. Losing two elements to a transport that cannot carry them is a product decision to revisit in the editor’s block palette. Losing one to a sanitiser is a packaging bug. One alert for both tells you nothing.
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.
- The timestamp advances on completion only. Not on start, not on “the job ran”. A publish with dead-lettered elements does not advance it.
- Never-published is stale, not exempt. The most common form of this bug is a binding that never worked once and was therefore never compared to anything.
- The freshness checker is a separate process. A checker that runs inside the publish job dies with the publish job.
- The alert names the binding, not the job. “This district, this course, this lesson, twenty-one days stale” is actionable; “publish-engine warnings elevated” is not.
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.
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
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.
The lessons are live and the classrooms are real — now the harder question of whether any of it taught anything.
Compared to what? →