This month we have been carrying a learning core — courses, lessons, quizzes, certificates — into VARGATES Medical. The lesson player was ported first playing prose and the document format the group’s own compliance courses use; quizzes followed in a later slice. Everything else gets a deliberate refusal: a card naming what the kind is, and no complete button. A player that draws nothing for a lesson it does not understand is indistinguishable from a lesson its author left empty, and the student cannot tell which happened. So the refusal was the right default.

But a refusal is not a plan. Three kinds had neither: video, 3D and SCORM packages. This week the first of them started to play. It is the smallest of the three and the most likely to appear in real content, and it is worth a material not because a video plays now, but because of what the obvious way of doing it would have shipped.

The one-liner that ships broken

The videos table has a column called playback_url. Its name reads as the answer to “where do I point the player”, and the obvious feature is one line: an <iframe> whose source is that column.

The column does not hold a playback URL. It is written by the webhook of the video hosting service when transcoding finishes, and what the webhook writes there is the HLS manifest — an .m3u8 file — falling back to the hosted player’s embed address only when the service omits the manifest. So the column holds one of two different kinds of thing, and the row does not say which.

A manifest inside a frame offers the learner a file download. A manifest as the source of a <video> element plays in Safari and in no other browser, because HLS outside Safari needs a media-source library the page does not have. The one-line version is therefore broken in the majority case and works in the minority one — which is exactly the shape of defect that reaches production, because whoever tries it once in Safari sees it work.

The unambiguous value on the row is stream_id, the hosting service’s own handle. Its hosted player is a public address built from that handle; it needs no dependency, no key and no signing, because the course itself sits behind our login. So the player prefers the handle, and accepts playback_url only when it already is an embed address — checked by a prefix match that includes the trailing slash, rather than by substring, because an address on a look-alike domain starts with the same letters and is a different origin.

Eight outcomes, and only two of them may be completed

A video lesson is completed by hand after watching; the server keeps no watch-percentage gate to defer to. That leaves one question the page has to answer for itself: when may the complete button be offered? The answer is a table, kept in one module so that no second copy of the rule can start disagreeing with the first.

What the player does with a lesson’s video, and whether completion is offered
OutcomeThe learner is toldComplete offered
playnothing — they watch ityes
absentthe lesson has no video yetyes
processingit is being preparedno
failedit could not be preparedno
unplayablethis screen cannot show itno
loadinga spinnerno
unreadablethe video could not be loadedno
forbiddenaccess to this material was refusedno

The row worth arguing about is absent, and it is settled by consistency rather than by taste. The player already lets an empty prose lesson be completed, on the grounds that a lesson nobody has written yet must not hold the whole course — and its certificate — behind it for ever. A video lesson with no video is that lesson. Every other row is the opposite case: something is there and cannot be shown, or is not known yet, and offering completion there would let a student finish a course they were never shown.

The pull request described seven outcomes. The eighth, forbidden, was added in review: every non-404 failure had told the learner to check their connection and try again, including the 403 the server raises when material access has been withdrawn — advice a student could follow for ever. A refusal now has its own sentence and says nothing about the connection.

Two 404s that mean different things

The route that returns a lesson’s video answers 404 in two situations. One is “this lesson has no video row” — an ordinary state, an author who has not uploaded yet. The other is “lesson not found”, and that one can come back over a video that exists.

The reason is a fallback designed on purpose. One person can walk both demo paths: they enrol as a learner, then create an organisation of their own, and creating it makes it the active one. Their enrolment stays where it was. The learner path that opened the lesson resolves it against the organisation the enrolment lives in; the material routes underneath still resolve it against the organisation the request is acting in. When those two differ, the page renders and the video answers 404.

Reading both 404s as “no video” would have put this lesson has no video yet in front of that learner and offered to mark the lesson complete — a false statement plus a completion they did not earn. So the client returns “none” only for the route’s own sentence and lets the other through as a failure. That is a safeguard, not a fix: a learner in that state is today told the video could not be loaded, which is true and unhelpful — they should simply be shown the video, and until the backend defect below is closed they are not. Matching on a message string is not lovely, and it is deliberate: the status alone cannot carry the distinction, and an error-code envelope for one endpoint would be a larger change than the defect warrants. The safeguard is a test on each side of the wire — the backend test reads the constant out of the TypeScript file and compares it with the sentence the router raises, so a reword on either side fails a test instead of telling a learner something false.

The backend defect is filed on its own, with the reproduction and the acceptance, and it is the third instance of one class in this codebase: first, access was checked in nine places and one was missing; then access was widened for shared courses but resolution was not, and resolution was centralised; now resolution is centralised but the organisation handed to it is still computed per route. Each fix closed the gap one level down and left the next level open.

What review changed

The first version of the branch decided “this is a video lesson” from the lesson’s type alone. That looked safe, and the reason it was not is a default: the lesson model’s content_type defaults to video, on the column and on the create request. The author had used that fact to argue that a video lesson with no video is an ordinary case — which it is — and had written a test pinning the wrong claim. The same fact cuts the other way. A lesson written as prose and saved without naming a type is also an ordinary row, and reading it as a video would have replaced its text with “this lesson has no video yet” and offered the complete button, because the second request honestly reports that no video row exists. Silent, and completable over a body the learner was never shown.

Fixed before merge: only a row with nothing written in it is read as a video. A typed-video row that carries a body falls through to the card that names the kind, exactly where it was before this branch existed. Both directions are pinned by tests that fail when either half of the rule is removed.

Two smaller ones from the same review: an empty video title produced a frame with an empty accessible name, which a screen reader announces as “frame” and nothing else — the frame now falls back to the lesson’s title; and the refusal outcome above.

Evidence, and the test that proved nothing

Fifteen deliberate mutations were applied to the slice — the video branch removed, the manifest preferred over the handle, any 404 read as “no video”, the embed prefix matched by substring, a pending video read as unplayable, the frame stripped of its title, and so on. One was invalid; the other fourteen are now caught by a failing test. One of them survived at first, and it was a real finding.

The test for the late-answer race — lesson A’s video arriving after the learner has already moved to lesson B — mounted the page a second time instead of re-rendering it. Each mount had its own generation counter, so the late answer landed on a component whose counter had never moved, and the guard was never consulted. The test passed with the guard deleted. It was rewritten to re-render, as the neighbouring race test in the same file already did, and its assertion was widened to the complete button, because a stale answer that enables the button is the half that lets a student complete a lesson over a video they were never shown. Then the mutant died.

The end-to-end gate seeds a third lesson — a video with its row — and walks it in a browser: it asserts the frame’s source is the embed and is not the manifest, asserts the frame is named by the video’s own title (deliberately different from the lesson’s, or the assertion could not tell the two readings apart) and marks it complete. Three required lessons, so course progress moves 0 → 33 → 67 → 100. The first run of the gate flaked on an unrelated film page of the product’s own surface; it was measured rather than retried, answered on its own Issue, and the clean run was the second.

Seeded rather than authored, and that is stated rather than glossed. The acceptance asked for a lesson authored through the product where the product can author it. It cannot: there is no video editor anywhere in the ported tree, while the translation files carry about forty keys for one — somebody upstream built the screen and the port did not bring it. A kind the player can now play and the product cannot author is half a circuit. It is deliberately left unfiled until the 3D and SCORM decisions are taken, because all three may want one answer rather than three Issues about one missing authoring screen.

What production can show: nothing

The change was verified live on both surfaces shortly after the merge. Measured against production after the merge: 156 lessons, all of them prose or quiz, and zero rows in the videos table. Identical to the measurement before the merge.

So the honest statement is that the build is live and the change is inert. Every branch it adds is reached only by a lesson of type video, and there are none. Nothing could regress, and nothing can be demonstrated: the production check after merge can show that the player still serves a prose lesson and that the route still answers, and not that a video plays there. Content is somebody else’s work, and it will show up when there is content.

What is still refused — and the kind nobody had counted

Two of the three named kinds remain on their card, and the measurements taken alongside this work changed what each of them is.

3D was expected to be a rendering question. It is not. There are no 3D assets in the frontend’s public folder, no 3D renderer among its 39 dependencies, and zero rows in the 3D-model table in production — the column for a model address exists and nothing in the codebase writes one. The repository’s own stack table lists a 3D renderer, and the code does not bear that out. So the decision in front of this Issue is not which library to use; it is whether the models exist anywhere at all, and if they live in the standalone simulator rather than on the web, whether the web answer is “link to the simulator” rather than “render”. Until that is decided, the named card is the correct behaviour, not a placeholder.

SCORM waits on a separate question about what a package’s sign-in credential may look like, because the ported credential carries a token shape the login path deliberately refuses. “The package installed and the frame rendered” is not evidence of anything until that path is driven end to end.

What we take from it

  • A field is named for what somebody once meant it to hold, not for what writes into it today. Read the writer before trusting the name.

  • When one endpoint answers the same status for two different facts, the client must not launder them into one — and the split has to be pinned on both sides of the wire, or a reword undoes it silently.

  • A default on a model is a claim about every row created without a decision. The same default that makes one branch ordinary makes another branch dangerous.

  • A mutation that survives is worth more than fourteen that die: it is the test that was proving nothing.

  • A change that is live and inert should be described as live and inert. The check that could not run is part of the report, not a thing to leave out of it.

The product: VARGATES Medical. The lesson player and the learning core described here are part of its web platform.