The gate
Publishability is a join key, not a check you remember to run
Four things have to be true before a block reaches a child: it passed the rubric, a human with the right role approved it, every asset in it has usable rights, and its alignment claim is one the district accepts. Model those as four checks and you have four things to forget. Model them as an inner join and there is no code path that produces a publishable lesson without them.
↳ Every one of these actions is a feature you shipped on purpose. None of them is malicious.
- ✕Gate decision, at the current rubric versionthe new block in version 4 has no gate decision yet
- ✕Review receipt, for this exact versionsigned for version 3, and this is version 4
- ✓Asset rights, unexpired and usable here14 assets, all licensed for this district, none expired
- ✓An alignment claim of a class this district acceptsclaim is inferred, and this district accepts inferred
- You could write this with outer joins and predicates in the WHERE clause. The result set would be identical and the design would be far worse. A WHERE clause is a line someone deletes at six on a Friday while chasing an empty-lesson bug, and the query keeps running and returns more rows, which looks like the fix. An inner join to a required table cannot be edited out without the query failing loudly.
- The accepted alignment classes come from district config, not the query. Post 2’s classes become a policy parameter: a district that refuses inferred alignment gets fewer lessons, visibly, rather than the same lessons with a different label.
- Asset rights have an expiry, and expiry is a join predicate. A licensed image whose term ended last month must stop being publishable without anyone running a job.
- Review receipts are keyed by version. Approving version 3 does not approve version 4. That single line is what makes the editor safe.
The corollary is an interface requirement rather than a backend one: because approval is keyed to a version, editing an approved lesson must visibly return it to review. A product that lets a teacher edit after approval without changing its state is not saving her a step; it is silently publishing unreviewed content under someone else’s signature.
The editor
Every edit is an observation, and observations are the product
The editor is where the real work happens, and the architectural decision is whether an edit is a mutation or an event. Make it a mutation and you get a simple interface and lose everything. Make it an event and post 3’s entire feedback corpus falls out for free.
- An edit produces a new version, never a modified one. Blocks are content-addressed and immutable, so an edit is: hash the new text, write a new block, write a new version whose block list differs in one position. Undo is selecting an earlier version.
- The operation is recorded, not just the outcome. Replace, delete, insert, swap-asset and regenerate are different signals about your generator, and a diff between two block lists cannot tell them apart.
- Ask why, cheaply, once. A row of reason chips — too hard, wrong for my class, inaccurate, style — attached to a delete or a large rewrite converts an unlabelled signal into a labelled one at the cost of one click.
- Regeneration is an edit with a prompt attached. When a teacher regenerates one block, the request, the objective and the surrounding blocks all go into the provenance, because “regenerate this in the context of that” is the most informative thing she ever does.
The uploaded case is the one teams get wrong. A teacher uploading her own photograph is the most natural action in the editor and the one with the least clear provenance — it may contain a child, it may be someone else’s work, it may be perfectly fine. It cannot be treated as pre-approved because a human chose it; that is precisely the assumption that puts a photograph of a real classroom into a package bound for another district.
Isolation
District and environment are partition keys, and the contractor login is why
Every row and query carries a district and an environment, and storage is namespaced by both. Post 1 defined that type and threaded it through; this post adds the layer underneath it, for when the application is not the thing making the query.
- Partition by district, always. Content tables, property tables and the publish ledger. Cross-district reads are not merely filtered, they are physically separate.
- Row-level security sits below the application. Application filters protect you from application bugs. A policy on the executing database role protects you from the SQL console, the reporting tool and the notebook someone ran on a Sunday.
- Roles are teacher, curriculum reviewer, safety reviewer, contractor and admin. Only a safety reviewer may clear a sensitivity hold; only a curriculum reviewer or an admin may approve a version for publish; a contractor is scoped to one district and cannot read student data at all.
- The contractor role is the honest reason. It is a login you do not administer, held by a freelance instructional designer who is also building units for a competing publisher next Tuesday. Design for that user and the internal cases take care of themselves.
An empty role list is a rejection, not a default. The failure you are avoiding is the one where a district’s identity provider stops emitting a claim after a routine upgrade and every token quietly becomes an unscoped one.
Deletion
A deletion that stops at your database is not a deletion
A records request, a parental opt-out, or a district offboarding lands as a tombstone. Deleting the rows is the easy part and the least of it — and one of the six steps below is different in kind from the others.
↳ Five of these six steps are ordinary engineering. The sixth is the one you can only get right before the first training run.
- The content side is straightforward. Blocks a teacher authored, lesson versions, edit events, review receipts — scoped, partitioned, deletable, with the mutation recorded as a receipt.
- The student side reaches outside. Interaction telemetry, but also the grade a passback wrote into a gradebook you do not own, and any free-text response cached anywhere in the scoring path.
- The publish ledger answers the question that matters. Which courses, in which platform instances, did this content reach, and under which publish id. Without it you cannot un-publish, and post 2’s alignment retractions cannot fan out either.
- And one destination cannot be reached at all. Which is the next section, and it is why the design decision comes before the first model.
When a removal call fails — an expired token, a maintenance window, a course an administrator has locked — you retry with backoff, and until it succeeds the request is not complete. That state has to be visible: an open-request gauge with an age, alerting against your statutory clock, not a swallowed exception in a log nobody reads. A half-finished deletion looks exactly like a finished one from the inside.
If student work ever entered a training corpus or a retained evaluation set, deletion cannot reach it. You can delete the row. You cannot un-train the weights, and you cannot honestly claim otherwise. That is not a compliance edge case; it is a design constraint that has to be settled before the first fine-tune runs, because afterwards there is no remedy. The constraint that makes the rest tractable is boring and absolute: student-authored text never enters a training corpus — not de-identified, not aggregated, not “just for the eval set”. De-identification of free-text writing by children is not a solved problem, and you should not be the team that discovers how unsolved it is. Teacher edits are staff data and are governed rather than forbidden; they are made by adults in a professional capacity, under a contract you can write, which is why post 3’s feedback loop lives there on purpose. And if you must evaluate against real student responses, do it in-session, in memory, against a scoped store with a short retention — and record that you did.
Some destinations also accept a removal and give you no way to verify it landed. You cannot prove content is gone from someone else’s system; you can prove you asked. Record the request payload hash, the response status and the response body as the receipt, and describe it in exactly those terms in your own documentation. Overclaiming here is how a compliance answer becomes a false statement.
Audit
A trail, not a second copy of the content
One append-only line per publish, per gate-decision batch and per deletion. It is deliberately boring.
- Ids, counts and hashes only. No block bodies, no student text, no teacher free-text. An audit log containing the content is a second breach surface with worse retention rules than the first.
- The approver and their role are the reconstruction key. A year later, “who authorised this content reaching third graders in this district” has an answer that is a query rather than a meeting.
- Deletion lines carry the same shape. Actor, subject, and one receipt per step — enough to show a fan-out happened without restating what was deleted.
- Append-only, or it is not evidence. Object storage with versioning and a retention lock; a log you can edit proves nothing.
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
The image never went through generation, so it never met the classifier. A teacher swapped it in from her own files — almost certainly a photo of her own class, uploaded with entirely good intentions — and the pipeline treated a human choice as its own authorisation. That is the shape of every leak in this post: the guardrail was attached to the generation path rather than to the publish path, and the editor is a supported, encouraged route that bypasses it.
The structural fix is the join. An asset with no rights row is not publishable, full stop, and an uploaded asset requires a human reviewer’s signature before it gets one — which means the upload button’s real cost is a review queue, and if the product cannot afford that queue then it cannot afford the button. Both are honest positions; shipping the button without the queue is not.
Two things should also have existed from day one and probably did not. First, image provenance in the same shape as text provenance: every asset knows whether a model, a licence, or a person put it there, and the publish compiler treats those differently. Second, a per-publish ledger — because the parent’s complaint is about one course and your actual exposure is every course that lesson reached.
The bonus consequence is what makes this urgent rather than merely bad. If that photograph was in a lesson used to build any evaluation or training set, the deletion you are about to run cannot reach it, and no amount of engineering afterwards changes that. The decision that protects you was made — or not made — before the first training run, which is why it belongs in the architecture and not in the policy document.
Approved content meets an importer you do not control — diffed publishes that do not strand student work, capability descriptors, and the number that says how much of your lesson survived.
Diff, do not republish →