Remove and Add are not inverses, so the child came back as a stranger
A parent removed a child from the app and added her straight back, to see what would happen. What happened was that she lost everything and her old tablet kept working.
Twelve lesson assignments, eight progress rows, eleven quiz attempts, four hundred points and one task all stayed attached to the record that had been removed. Her tablet, re-paired, authenticated as the newly created record and showed an empty list. Meanwhile the credential issued to that tablet under the old record was still valid, because removing a child does not revoke her devices.
That was the worst defect found that day, and it was found by the owner doing something entirely ordinary in the product for about fifteen seconds.
The mechanism
Remove was implemented as a soft archive. The row stays, a timestamp is set, and every list is supposed to filter it out. That is a reasonable choice for a record with a history hanging off it, and it is the choice I would make again.
Add creates a row. There is no reconciliation step, no check for an archived record with the same name, no prompt.
So the two operations are not inverses of one another. Remove hides. Add creates. Remove-then-add is not a round trip, it is a fork: one identity becomes two, one of which holds all the history and is invisible, and the other of which is visible and empty.
The person doing it has no way to know that. On screen, the child’s name disappeared and then reappeared. The reasonable inference is that it went and came back.
Any time you implement a delete as an archive, you have created a second identity space that the user cannot see, and you have to decide explicitly what re-creation means. There are only a few honest answers: refuse the re-creation and offer to restore, restore silently and say so, or make the delete real. What you cannot do is nothing, because doing nothing picks the fork.
The credential is the part I would flag hardest
The data loss was recoverable — the rows were all still there, and moving them onto the surviving record was one careful transaction.
The live credential was not that kind of problem. A device token had been issued, scoped to a specific child, and the child it was scoped to had been removed from the household as far as the parent was concerned. The parent’s mental model was that the tablet had been disconnected. The system’s model was that a token existed, referred to a row that still existed, and was therefore valid.
Removing a principal must revoke that principal’s credentials, in the same transaction, or the removal is cosmetic. This is the same rule as deactivating a staff account without killing its sessions, or removing a user from a group while their issued token still carries the old claim. The difference here is only that the principal is a child and the credential is on a tablet in her room.
The related discovery is that the tempting mechanism does not fire. The token table has a foreign key to the device with a cascading delete, which reads, on inspection, as handled — remove the device and its tokens go with it. But both of the paths that remove a device are soft revokes that leave the device row in place, so the cascade never runs. A referential rule written for a hard delete is inert in a system that never hard-deletes, and it is worse than absent because it looks like coverage. Three regression tests now cover it, each one checked to actually fail with the fix removed.
The archived flag has to reach every read path
The re-creation bug was the visible one. Underneath it was a broader version of the same problem, found earlier by reading the queries rather than by using the app.
The archived filter was missing from the query that produces the child summary rows. That single query backs both the children list and the household dashboard, so archiving a child hid her from nothing at all. It was missing from six other queries, including one that joins device activity to produce a “not synced recently” alert — which would have flagged an archived child’s tablet as stale forever, an alert about a child the parent believes she has removed, permanently unresolvable.
And the shared resolver that turns a child identifier in a URL into a record still resolved archived children. That one is the quietest and the worst: it means a delete request for an already-archived child resolves fine, sets the timestamp again, and returns success. From the caller’s side the operation worked. Nothing happened.
A soft delete is not a column. It is a predicate that every read path, every resolver and every uniqueness rule has to know about, and the default in every one of those places is to forget it. The only structural defence I trust is to make the filtered version the thing that is easy to reach — a resolver that excludes archived records by default and requires an explicit argument to include them — so that forgetting produces the safe behaviour rather than the unsafe one.
Where a hard delete is still right
Not everything should be archived. A reward that a parent removes from the store is deactivated rather than deleted, because redemption records point at it and a real delete would erase purchase history — the child’s record of what she bought would lose its referent. Same reasoning for cancelling a savings goal instead of deleting it: points already committed to that goal are ledger rows, and a ledger row must not lose the thing it refers to.
That is the actual test, and it is not about how important the record feels. Delete for real when nothing points at it. Archive when something does, and then go and make every read path, every resolver and every credential aware of the archive. The middle option — archive it and hope the lists filter correctly — is the one that produces two children with the same name, one of whom owns the history and cannot be seen.