#Staples and the purchase stream
A check-off is two writes. The purchases/{id} document is the truth: it
carries its own id, so two offline clients checking off the same staple cannot
collide. Both the document and the cache carry sessionId, the trip the
purchase happened on, which is how a purchase knows its shop: the session owns
the shop, so re-attributing a whole trip stays one write and no purchase is ever
rewritten. It is absent on entries written before the field existed and on
purchases that honestly have no trip — a promotion seed, a receipt read days
later, a past purchase typed in by hand — and absent means "no evidence about
where this was bought", never "bought anywhere". rebuildPurchaseCache carries
it back from the truth, or the repair would erase every shop the app had
learned. staple.purchaseHistory[] is a denormalized read cache capped at 20 —
rendering forty staples costs forty reads rather than three hundred, and the
cadence engine never needs more than the last six intervals.
The cache is rebuildable. rebuildPurchaseCache re-reads the newest twenty
purchase documents and rewrites the array, which is the repair for the one known
failure mode: a capped array cannot use arrayUnion, so two concurrent appends
can drop an entry. Losing one is tolerable precisely because it is not the truth.
A staple seeded by promotion writes its seed to both, or a rebuild would erase
history the cache claimed to have.
buildCheckOffWrites builds both payloads without performing them, so a test can
assert directly that at is a plain device-clock number and never a
serverTimestamp() sentinel.
Item-to-staple resolution at add time tries exact canonicalName matching
first, then the trigram shortlist in src/lib/resolve/ with the model seam
behind it; at check-off only the exact match is consulted. An unresolved item
still checks off normally.