A sale leaves the device with a cashier id. The cloud has never seen that person. Sync invents a shared user named Cashier so the foreign key passes. Reports light up under one ghost. Alice and Bob never own a row.
That is not a missing column. It is attribution satisfied locally and meaningless in the roster. The counter of “who sold this” looked fine on the tablet. Push is where the lie became permanent.
Invariant: if a sale names a cashier, that id must already be a real roster user. Otherwise the device is unlinked for attribution and must not push as if it were.
Why a valid FK is still wrong
Nothing crashed. The cloud accepted the insert. The join from sale → user succeeds. The wrong model is: the tablet needs someone on the row, so inventing “Cashier” at push time is fine.
Person and device are not the same thing. A tablet setting, a shared PIN, or a placeholder name can satisfy a NOT NULL. None of them are a person on the team list. Every later report that groups by cashier collapses onto the ghost.
Three paths in one lab
I ran node lab/synthetic-cashier-push.mjs in this repo on 10 August 2026. One in-memory cloud. Roster starts with Alice only.
| Path | What we need | Result |
|---|---|---|
| mint “Cashier” on push | FK always passes | ghost on roster; report under Cashier; Alice orphaned |
| stamp Alice’s roster id | attribution sticks | report under Alice; no synthetic user |
| unknown cashier id | do not invent a person | accepted: false, reason: "unknown_cashier"; no sale stored |
Trimmed report:
{
"synthetic": {
"createdSynthetic": true,
"ghostName": "Cashier",
"reportByCashier": { "Cashier": 1 },
"aliceOrphaned": true
},
"roster": {
"reportByCashier": { "Alice": 1 },
"syntheticCount": 0
},
"unknown": {
"accepted": false,
"reason": "unknown_cashier",
"salesOnCloud": 0
},
"allPassed": true
}
The interesting failure is not that the synthetic insert works. It is that Alice was on the roster and still got zero sales. Push never asked who was active. It manufactured a sink.
Roster first, then stamp
Pull (or cache) the team list before you treat a device as ready to sell under a name. Active cashier is a roster UUID. Switching cashier changes the stamp on new sales — it does not mint a new cloud user.
Push may append sales and sessions. It must not upsert a synthetic staff row to make foreign keys happy. Unknown id → reject (this lab) or leave the device unlinked until someone signs in. Do not “fix” attribution with a shared ghost.
function pushSale({ cashierUserId, ...payload }) {
if (!cashierUserId || !roster.has(cashierUserId)) {
return { accepted: false, reason: "unknown_cashier" };
}
// insert sale stamped with cashierUserId — never create the user here
}
You still need a real sign-in / PIN gate on the device. Rejecting an unknown id does not create a roster. It only refuses to invent one.
Decision boundary
Rejected: device role as cashier_user_id, and auto-create "Cashier" on push. Both satisfy storage and destroy “who sold this.”
This note is the wrong tool when you truly have a single operator, no per-person reports, and no multi-cashier audit — you still want one real roster row for that owner, but the urgency is lower than a multi-person till.
What this does not solve
Shared one-PIN-for-everyone is a sibling footgun; fixing push does not fix it. An empty offline roster needs an explicit unlink / block-sell policy — not covered beyond reject. Void and override rules are separate from who is stamped on the sale.
Offline writes need identities they can mint without coordination for events, and people they must not mint to paper over a missing roster. Sales can be append-only with UUIDs and still lie about the cashier. Boring on purpose. The synthetic user is the exciting bug.