Save can't overwrite another account's row

Before a save-or-update runs, the server checks that every row it would touch already belongs to your account — and refuses if one doesn't.

What it is

Org-scoped tables use a global primary key rather than a composite one, so a blind save-on-conflict could match and overwrite a row belonging to another account. The proxy therefore pre-checks ownership of every conflict key in the batch, and refuses the whole request if any existing row belongs elsewhere. The same check runs one axis down for per-user rows, and one axis sideways for records a restricted sales rep isn't allowed to see.

Also called: upsert guard · conflicting row belongs to another organization · id collision

See it
Save can't overwrite another account's row
Apr
May
Jun
Jul
Marlin Hoover — Equipment Storage
Framing
Gideon Alt — 40×64 Shop
Concrete
Ronan Petsch — Hobby Shop
Trim
Sutter Kline — Hobby Shop
Framing
Ronan Petsch — Hobby Shop
Concrete
Framing crewConcrete crewTrim crew
An error toast reading 'a conflicting row belongs to another organization' over a bulk import progress bar. Source: src/app/api/db/route.ts upsert branch. Sample data — no customer information appears here.
How it works
  1. 1The conflict columns are read from the request (defaulting to 'id') and every row must supply them.
  2. 2Fast path: one batched select fetches all matching ids with their org_id in a single round-trip.
  3. 3If any existing row's org_id differs from the caller's, the request is refused with a 403.
  4. 4For per-user tables, a matching row owned by a different user is refused the same way.
  5. 5For lead tables, existing rows are re-selected through the caller's visibility scope; an id that exists but doesn't come back is a record they may not overwrite.
  6. 6Composite or custom conflict targets fall back to a per-row check, since those call sites are single-row writes.
Why we built it

The comment states the hole precisely: 'a blind ON CONFLICT (id) DO UPDATE would match — and overwrite — a row belonging to ANOTHER org, with stampOrg then rewriting that row's org_id to the caller's.' Because the database driver 'offers no WHERE on the conflict target', the check has to happen before the statement. The batching is a performance fix with its own story: the original per-row pre-check meant 'a 3,200-lead import = ~3,264 round-trips', so the fast path does the same ownership check in one query per 50-row chunk.

The problem
  • A save keyed on a global id could silently take over another account's row and relabel it.
  • Hiding a record from a restricted rep didn't stop them overwriting it by id.
  • Ownership checking one row at a time made bulk imports unusably slow.
Sound familiar?
What you get
An id collision across accounts fails loudly instead of quietly rewriting data.
Visibility rules apply to writes, not just reads.
Large imports stay fast because ownership is verified in batches.

See it on your own jobs

Twenty minutes, your numbers, no slide deck. We’ll build one of your real buildings in front of you and send you the estimate link at the end — yours to keep either way.

or keep browsing features →