Credited twice, and nobody could explain the balance
A child did one piece of work and was paid for it twice. A parent asked where the points went and nobody could answer. Why a total you can edit cannot be trusted.
A parent looks at her child’s points and says the total looks wrong. There are two possibilities: the child has been paid twice for one piece of work, or something she earned has gone missing. You open the record and it shows a number. Just a number. Nothing about how it came to be.
That is the whole problem with storing a total. The app in question lets children earn points for finishing work and spend them on rewards their parents set up. The obvious way to build that is a points figure on each child’s record: add on earn, subtract on spend, show it on screen. That design survives about a week of real use.
What it costs is that every “the total looks wrong” becomes an argument nobody can settle, because there is no evidence either way. A nine-year-old who asks where her points went gets a shrug. A mistaken award cannot be put right without destroying the record of both the award and the correction. And in the case that actually happened, one piece of work was paid for more than once, first by a retried submission and later through a loophole the first fix left open.
What was actually going on
Four things go wrong with a single editable total, and they apply to every system that counts anything — stock, money, leave days.
A retried submission pays twice. A tablet on a poor connection submits finished work, the reply is lost on the way back, the app sends it again. The server has no way to recognise the second one as the same event, so it pays again. Devices retry. That is not an edge case, it is Tuesday.
Two changes at once lose one of them. Two devices, one total. A spend and an earn arriving together produce whichever was written last, and the other is gone with no trace that it ever existed.
Nobody can answer the question people actually ask. A total can tell you the number. It cannot tell you a single thing about how that number came to be, so every report that it looks wrong becomes an unresolvable argument.
Nobody can correct a mistake safely. Adjusting the total destroys the evidence of both the original award and the correction.
What we changed
Points are no longer stored as a total. Every earn, every spend and every correction is written down as its own entry — which child, how much, why, and what caused it. The balance is the sum of the entries, worked out whenever it is asked for. There is no column holding a total anywhere.
Three things follow. A duplicate award cannot be recorded, because the system refuses a second entry for the same child, reason and cause — refuses, rather than checks first and hopes. The retried submission that would have paid twice hits that refusal and gets the same answer it got the first time. Corrections are new entries with a negative amount, never edits, so the history stays true: this was awarded, then reversed, on this date, for this reason. And work still waiting for a parent’s approval is visibly pending and kept out of what the child can spend, rather than credited early and clawed back, which is the worst possible experience.
The loophole the ledger did not close
Here is where the reasoning was incomplete, and it matters more than the design.
The rule against duplicates was written around the attempt. Same attempt sent twice, refused. Correct. It did nothing about a child simply starting a new attempt at the same exercise. New attempt, new entry, more points. The exercise could be farmed indefinitely, and the ledger recorded every payment as legitimate, which under the rule as written they were.
The requirement had been stated as “do not pay twice on a retry” when what was meant was “pay once per piece of work, ever”. Those are different sentences and they produce different rules. The rule was rewritten around the exercise itself, with the earliest submitted attempt deciding the outcome. A retry now gets an identical answer. A retake reports zero points earned, honestly, rather than quietly paying again.
A daily ceiling per child was added on top. It is a blunt instrument and a useful one: it bounds the damage from any farming route nobody has thought of yet, including the ones future features will introduce. The ledger gives you correctness for the cases you modelled; the ceiling gives you a bounded loss for the cases you did not.
The rule about automation
Some submitted work is checked automatically before a parent sees it. The verdict is one of three — passes, unsure, fails — and only a confident pass results in automatic approval. Everything else goes to a person.
The rule, stated plainly: the automated check may approve, and may never reject.
That asymmetry is deliberate and comes from what the two mistakes cost. A wrong approval means a child was paid for work that was not quite done, a small and recoverable loss. A wrong rejection means taking away a reward a child genuinely earned, and she will not experience that as a software defect. She will experience it as unfairness, and no later correction undoes that.
Generalised: when automating a judgement, work out which direction of error is recoverable and only automate that direction. Most such problems have this asymmetry, and most implementations ignore it because accuracy is measured as a single number.
The mechanism
The refusal is a unique index across child, reason, reference type and reference id, so a duplicate cannot be inserted — the guarantee holds under concurrent writes, which an application-level “check first” does not. Idempotency implemented as check-then-insert is a race condition with good intentions; implemented as a constraint it is a guarantee. A rejection writes no entry at all unless it overrides a previous approval, in which case it writes the reversal.
The wider lesson is about idempotency keys: the key must be the identity of the business event, not the identity of the request. A request id deduplicates network retries. Only a business key deduplicates the thing you actually care about happening twice.
One operational habit if you take nothing else: whenever you find yourself writing UPDATE ... SET quantity = quantity + n, stop and ask what happens when that statement runs twice. If you cannot
answer with a constraint rather than a promise, you have a ledger-shaped problem wearing a column.
Where this ends up
The points in Sazinga Engage are a ledger for exactly this reason: a reward loop that credits completed work has to survive a device on a poor connection submitting the same piece of work twice.