My language-learning app keeps a strength map for its grammar drills. Each grammar point gets one entry: a strength from 0 to 5, attempts, successes, misses, a streak, when it was last seen, and whether the last answer was right. The project's process note calls it a strength map for spaced repetition and prices a lost entry at "one redundant review." Every task on the project that touches persistent or synced state now declares how much loss is acceptable, in product terms, before review starts. A review finding whose worst case stays inside that tolerance is a P2 at most, and an escalation has to offer weakening the requirement next to iterating and deferring. The rule came out of syncing this strength map to the server.
The first attempt lived inside the task that built the drill stage. Between 2026-08-16 and 2026-08-18 it went through five review-and-fix cycles, and each one found a new gap in the same family. The gaps were absence versus an explicit empty value, valid versus corrupt local data, a resurrection race between an in-flight write and sign-out's clear, cache ownership on login, and a keepalive body-size cap that broke sign-out's flush for any real account. I descoped it on 2026-08-18. The follow-up was a design task, and its protocol went through thirteen versions. What shipped is a durable per-device operation queue serialized by the Web Locks API, with epochs, a quarantine, a loss log and a two-phase garbage collector. It's 3,214 lines of production code and 4,353 lines of tests. Its one remaining race is modeled at about one in a million, a model rather than a measurement.
Adversarial review has no built-in "good enough." Any interleaving that can lose data reads as blocking, so review becomes a one-way ratchet toward the most elaborate protocol that closes every finding. The only counterweight is a loss tolerance declared in product terms before review starts.
This covers one feature, read from three notes, one design study and one plan revision in the app's repository. Ten review rounds is the process note's count, and plan revision 3 credits "the 10-round-reviewed design." The design note's own finding map counts nine design rounds plus three code-review rounds during implementation. The design note also numbers its versions v1 to v13, and no other source disputes that count, so the account below follows the versions.
Each Round Found a Real Bug
Round 1 found 7 P1s and 2 P2s against a merge that worked out what was new by subtracting a stored baseline from local state. A patch closed them on paper, and round 2 found 7 P1s in the patches. Round 3 accepted a rebuild around a durable per-device operation queue as the correct structural replacement, then found 5 P1s and 3 P2s in its multi-tab and reset handling. Rounds 4 and 5 rejected their versions outright.
One of those versions gave each tab a writer identity in sessionStorage that was meant to be exclusive, and the browser's Duplicate Tab copies sessionStorage. The next added a heartbeat and a fingerprint and still read with getItem and wrote with setItem as two steps another tab could get between. Round 5 then told the design to stop approximating mutual exclusion on localStorage, which has none, and to take a Web Lock instead.
Version 6 did that, with one global lock around every mutation of every writer record. Round 6 confirmed it closed the original lost-update family among the tabs that take the lock.
From there the rounds found completions rather than architecture. Round 6 found 5 P1s and 2 P2s, in its own words "narrow spec-completion, no new architecture." One was a fallback path for browsers without Web Locks that a Locks-capable tab could still race. Another was no policy for a lock holder that freezes. The quarantine and loss log also weren't idempotent.
Round 7 found 3 P1s and 4 P2s. The one-year reclaim of abandoned fallback records reopened the race the isolation rule had closed. A quarantined entry could be applied twice after reconciliation. Nobody had shown the 3-second lock timeout composing with the FIFO push chain. Round 8 found that the reclaim fix still rewrote the whole record while marking it.
Round 9 found two line-level items, and one of them was the risk estimate. The note had put the residual race at one part in 10¹⁰, taking a year of no writes as the exposure window. The reviewer pointed out that the year is already conditioned on the owner writing nothing, so it can't be the window. The reclaim marks a record first. It comes back at least an hour later to re-read the record and delete it, and an owner who writes during that hour cancels the delete. So the real window is the second phase's own synchronous critical section, a few milliseconds. The GC interval is one hour, 3,600,000 ms. A few milliseconds in 3,600,000 is about 10⁻⁶, ten thousand times the earlier figure. Version 10 took the reviewer's model, and the uniform-arrival assumption went in beside it.
Then implementation started, and stage-2 code review on PR #22 forced three more versions. Version 11 added an explicit blockedAtSeq field to the push response, after a client-side heuristic was shown by counterexample to drop an entry the server had never applied. Version 12 came from four findings in one cluster about deleting a writer's storage key. It routes every such delete through one primitive, destructiveRecordOp, which re-checks fresh state under the lock before it acts. Version 13 changed a closed push chain so a stale reference to it hands any new work to the current chain. It also reordered the reclaim's three writes so the record delete comes last and a failure at any step can be retried.
Every one of these was a real defect. The resurrection race from the first attempt is the clearest case: a write still in flight when the learner signs out can land after sign-out has cleared the device. If the reviewer's question is whether some interleaving can lose or corrupt a learner's data, the resurrection race is a P1, and so is everything else on the list.
The process note names the mechanism. With no instruction, "any interleaving that can lose or diverge data reads as a blocking [P1]." Every interleaving this design worried about could lose or diverge something. The task never said how much loss was acceptable, so the bar was zero. I was consulted after round 4 and again after round 5, and the escalations gave me two choices, keep iterating or defer. I chose to keep iterating both times. Weakening the requirement was never on the list, because nothing in the process put it there.
What the Strength Map Needed
On 2026-08-19 I directed a persistence study to treat a much simpler design as the default, per-item last-write-wins plus a reset tombstone. The op-queue stayed only if the study found a concrete product requirement that it alone met. In that design each item is one row holding its value, an updatedAt time and a deviceId. On sync the later updatedAt overwrites the other side outright, and a tie is broken by comparing the two deviceId strings. A reset writes one tombstone per account. Any cached item older than the tombstone that hasn't been answered since is discarded, so a device that missed the reset can't undo it.
The study then counted what the op-queue costs. The server core in lib/ledger-sync.ts is 685 lines and the queue-entry types in lib/ledger-ops.ts are 134. On the client, lib/ledger-client-sync.ts holds the writer record, the lock wrapper, the push chain, destructiveRecordOp and the loss log in 1,070 lines. lib/ledger-runtime.ts is 887 lines of writer-identity minting, guest adoption, collision re-mint, orphan drain and GC, and lib/ledger-client-gc.ts is 438 lines of candidate scanning. That's 3,214 lines of production code. Six test files add 4,353, for 7,567 in all. The study's estimate for the replacement, a per-item merge function, its unit tests and a thin route and store handler, is 400 to 700 lines.
The op-queue bar, file by file, in the order drawn: production, then tests.
- 685 lib/ledger-sync.ts · fingerprints, apply, seal, reconcile
- 134 lib/ledger-ops.ts · queue-entry shapes
- 1,070 lib/ledger-client-sync.ts · writer record, Web Locks, push chain
- 887 lib/ledger-runtime.ts · identity, adoption, re-mint, orphan drain, GC
- 438 lib/ledger-client-gc.ts · orphan and GC scanning
- 849 tests/ledger-sync.test.ts
- 1,175 tests/ledger-client-sync.test.tsx
- 926 tests/ledger-runtime.test.tsx
- 595 tests/ledger-client-gc.test.tsx
- 349 tests/providers-ledger.test.tsx
- 459 tests/ledger-store.test.ts
Line counts are the persistence study’s own; the op-queue total leaves out the ledger-specific slices of the store and route files the study counts separately. The 400 to 700 is the study’s estimate for the merge function, its tests and a thin handler.
source: vocabinator research/2026-08-19-persistence-upgrade.md lines 42-63, 76-77, 331-366, 451-453 · syncCost.data.ts
What last-write-wins gives up shows on the design note's own worked example. Two devices answer the same card, the Spanish por-or-para item, before either has synced. Device W1 answers right, then wrong: 2 attempts, 1 success, 1 miss, strength 0, streak 0. Device W2 answers right three times: 3 attempts, 3 successes, strength 3, streak 3. The op-queue folds all five answers in either push order, so the server ends at 5 attempts, 4 successes and 1 miss, with strength 3. Only the streak and whether the last answer was right depend on which batch lands last.
Under last-write-wins the later device's state replaces the row. If W2 lands last, the server keeps W2's three answers and loses W1's two, and strength is 3, the same as the op-queue's. If W1 lands last, it keeps W1's two and loses W2's three, and strength is 0. The process note calls this a strength map for spaced repetition. A strength of 0 where the learner earned 3 is the redundant review it means, a card the learner already knew, shown again.
The design note’s worked two-device example, both push orders, with the same two batches merged under the persistence study’s last-write-wins reading. Under the op-queue, streak and last-correct follow arrival order; attempts, successes and misses do not.
source: vocabinator sync design note lines 137-148 (the worked two-device example); research/2026-08-19-persistence-upgrade.md lines 162-175 · syncMerge.data.ts
The study stated the tolerance in the terms the product cares about. The most that can be lost is one device's unsynced answers on an item that a second device also answered before either synced again. In ordinary use that's a handful of answers per affected item, because a learner rarely drills the same item on two devices inside the same short window. It's unbounded only when one device stays offline through a long session while another drills the same item. Confirmed server state for any item a losing write didn't touch is never lost, and a reset always beats a stale answer through the tombstone. The process note's version is one sentence: "last-write-wins plus a reset tombstone would have met the actual need."
The Fix Is a Rule for the Reviewer
The rule went into the process scaffold I run my projects from (PR #11 there) and was adopted into this repository on 2026-08-19. It touches four files. The task board's README requires a ## Constraints section on any task that touches persistent or synced state, declaring a correctness tolerance. Its example is "losing a few unsynced items is acceptable; last-write-wins is fine." The review prompt reads that section. This excerpt is the end of the prompt's one paragraph, unedited:
Honor the task file's declared correctness tolerances (`## Constraints`):a finding whose worst-case impact stays within a declared tolerance is[P2] at most, never [P1]; if the task touches persistent or synced stateand declares no tolerance, flag that gap as a [P2] against the task filerather than defaulting to a zero-loss bar.The project's AGENTS.md requires any divergence escalation, meaning successive rounds that each produce new blocking findings, to offer three options: iterate, defer, or weaken the requirement. The triage prompt filters harvested review findings through the target task's tolerance before they become a follow-up task's requirements. In its words, "transferring findings verbatim as spec is how review bars ratchet past the product's needs."
With no tolerance line, a reviewer looking at a real race had one honest severity to give it, P1. The third escalation option answers the other half. A reviewer who sees a real race can't weaken the requirement on its own authority. An operator handed only "iterate" or "defer" on a correct finding will usually iterate, as I did twice. With the option written into the escalation, the operator sets the tolerance while the round's blocking findings are on the table.
It's a judgment rule, enforced by the review prompt rather than by a gate. A required section with no bar on its content tends to fill with boilerplate that satisfies the gate and nothing else, and no mechanical test identifies which tasks are stateful.
Isn't This Just YAGNI?
The objection runs like this. "You aren't gonna need it" already tells a builder not to write 7,567 lines for a flashcard counter, so a new rule adds nothing. YAGNI is advice to the builder, and the builder took it. I descoped the first attempt on 2026-08-18 because it was absorbing review rounds inside a task whose acceptance criteria were met rounds earlier. The descope note then recorded the accumulated findings as requirements on the follow-up task, so they wouldn't be lost: absence versus empty, tri-state validity, conflict resolution, push serialization, fail-closed timeouts, cache ownership, the keepalive cap. The zero-loss bar arrived in the design task as binding spec before any reviewer had looked at the new design.
The bar was set in two places, the triage step that wrote the findings into the design task's spec and the reviewer's severity scale, and the tolerance line binds both to the product's consequence. A builder applying YAGNI can't overrule a P1 that the process treats as blocking.
Limits
This is one feature on one app, a learner-scale, single-account, few-device product. The ~10⁻⁶ residual is modeled under a stated uniform-arrival assumption and has never been measured. The loss rate of last-write-wins under real multi-device use isn't measured either. The study bounds it in words, a handful of answers per affected item. The line counts are the study's own, from 2026-08-19, and two of the client files and three of the test files have grown since. The study's recommendation to replace the op-queue is research, contingent on accepting the stated tolerance, and it hasn't been adopted. Plan revision 3's authorization of the op-queue design, with its two loss policies, still stands in the plan. No later task or note proposes adopting last-write-wins for the strength map. What did ship is the review rule.
The op-queue keeps every answer in either push order. It took thirteen versions and 7,567 lines to get there, for a value whose loss the process note prices at one redundant review. With the README's example line in the task's ## Constraints section, a finding that could lose a few unsynced answers on one card would have reached the reviewer capped at P2. The escalations after rounds 4 and 5 would have carried a third option.