The lesson needed two facts to be true, and the screen only asked for one
A parent wrote a lesson, published it, and the child’s tablet showed an empty list. No error, no empty-state explanation, nothing. Just no lesson.
I found it by querying the database rather than by opening the app, which is the only reason it took minutes instead of an afternoon. The assignments table had no rows in it at all.
The child’s device fetches its lesson list from a route that does two things: it joins the assignments table to find lessons given to this particular child, and it filters on the lesson’s status being published. Both facts have to be true. Either one alone produces an empty list, and the list has no way to distinguish “nothing assigned” from “assigned but still a draft”, because from the query’s point of view they are the same absence.
The parent had made exactly one of those two facts true.
Where the two halves lived
Publish was a button on the lesson editor. Prominent, primary, obviously the thing you press when you have finished writing.
Assign was a route that existed, worked, and was reachable from precisely one place: an overflow menu behind a row of three dots, on the lessons list, a screen back.
So the flow a parent naturally takes — write, review, publish — never passes through the second half. She did everything the screen asked of her. The screen then displayed a “Published” chip, which is a true statement about the lesson and a completely misleading statement about whether anyone will ever see it.
The server was right the whole time
This is the part I keep coming back to.
The server behaved exactly as designed and the tests for it were already written and already passing. There was a test proving an unpublished lesson returns a 404 even when it is assigned. There was a test proving a lesson not assigned to this child returns a 404. There was a case covering a draft assigned to the same child. Someone — me — had sat down and enumerated the combinations of the two preconditions and covered them.
Every one of those tests is correct and none of them could have caught this. They verify that the conjunction is enforced. They cannot tell you whether a user is able to satisfy the conjunction, because the tests supply both facts directly. A test of a rule proves the rule holds. It says nothing about whether the product makes the rule reachable.
That gap has a shape you can look for. Any time a backend requires two or more independent conditions before something becomes visible, ask which screen makes each condition true, and how many taps and how much prior knowledge separate them. If they are on different screens, the condition that is not on the screen with the confirmation button is the one that will be missing in production.
The fix was about where the state is shown, not about the flow
The tempting fix is a wizard: force the assignment step into the publish flow so it cannot be skipped. I did not do that, because it makes the common case worse for a parent who has already assigned the lesson and just wants to publish an edit.
What changed instead is that the editor now carries the assignment state in its footer, permanently visible. Either it names who the lesson is for, or it says the lesson has not been sent to anyone and offers to choose. That footer shows on drafts too, which is the detail that matters most: the parent learns the lesson currently reaches nobody before tapping Publish, not after. And publishing with an empty assignee set opens the picker rather than leaving a “Published” chip over a dead end.
One further refinement fell out of watching it used. If the household has exactly one child, publishing assigns to her rather than presenting a picker with one option in it. A choice with a single possible answer is not a choice, it is an obstacle wearing the costume of one.
There was also a small correctness fix in the same place. When the assignment sheet saves, it hands the updated lesson back to the editor directly instead of triggering a refetch. A refetch races the write it was caused by, and it re-initialises the editor, which quietly discards half-typed edits. If a child component has just made the server change something, it already knows the new state. Passing it up is both faster and more correct than asking the server again.
Two red herrings, and the lesson in them
Before finding the empty table I chased two things that looked like evidence and were not.
The first was two households with confusingly similar names on the same account family. It looked like the lesson might simply be in the wrong household. It was not — the recent lesson and the paired device were demonstrably in the same one, which took a single query to establish and which I should have established before theorising.
The second cost more. Two log files on the server appeared to show relevant traffic. Both were stale. One held traffic from the previous version of the product, weeks earlier. The other held identifiers from a development database that do not exist in production at all — which is a wonderfully clear tell once you notice it, because an id that cannot exist in the system you are debugging proves the file is not about that system.
Check the timestamp and the identifier space of a log before you reason from a single line in it. A stale log is worse than no log, because no log makes you go and look at the data, and a stale log makes you confident about the wrong thing.
The thing that would not have helped
The obvious product answer to “the child did not see the new lesson” is a push notification. It was raised, and it was the wrong answer here for a structural reason worth stating.
The child’s lesson list is a pull. The device asks the server what it has. A notification that arrived perfectly, on time, with the right text, would have opened the app onto exactly the same empty list, because the emptiness was in the query and not in the delivery.
Notifications tell someone to go and look. They do not change what is there when they arrive. If the data is wrong, better delivery just gets the user to the wrong answer sooner.