Concurrent edits
Overview
Section titled “Overview”Two users can open the same record at the same time. They can both type changes into their copies of the edit form. The first one to save lands their change. The second one’s save is rejected — the system refuses to silently overwrite the changes the first user made. This page describes how that detection happens, what the user sees when it fires, and how to recover.
How the detection works
Section titled “How the detection works”Every record carries a hidden version stamp called RowVersion. The stamp updates automatically every time the record is saved. When you open an edit form, you load the current RowVersion alongside the data. When you save, the form sends that RowVersion back as a precondition: only update this record if its current RowVersion still matches what I loaded.
If a second user has saved the record since you loaded it, the stamp has changed. The system sees the mismatch and rejects your save with a 412 Precondition Failed error.
What it looks like
Section titled “What it looks like”When the rule fires, you see a message in the edit form:
This record has been modified by another user. Reload to see the latest version.
Your unsaved changes stay in the form — they aren’t lost — but you can’t save them on top of the newer version. You need to either reload to see what the other user did, or copy your changes elsewhere first.
How to recover
Section titled “How to recover”- Note what you’d changed — copy any new values out of the form (or screenshot if there are many).
- Reload the page — this fetches the latest version of the record, including the other user’s changes.
- Reapply your changes on top of the latest version — now that you’re starting from the up-to-date data, your save will succeed.
If your changes were only an addition (a new note, a new tag) that doesn’t conflict with what the other user did, step 3 is straightforward. If you both edited the same fields, you’ll need to make a call about which value to keep.
Two ways the rule fires
Section titled “Two ways the rule fires”The detection has two layers, both producing the same outcome from the user’s perspective:
Explicit precondition (the common case)
Section titled “Explicit precondition (the common case)”Every PATCH and PUT request includes the RowVersion as an If-Match header. The command handler checks it as the first thing it does — before any database work. This is the “user loaded an hour ago, someone else has saved since” case. It returns the 412 cleanly.
Race-window fallback
Section titled “Race-window fallback”There’s a tiny window between the precondition check passing and the save committing in which a third party could still update the record. EF Core detects this race at commit time and throws an exception. A global filter translates that exception into the same 412 response so the experience is consistent regardless of which layer caught the conflict.
Where this lives: src/services/api/app/app.api/Filters/DbConcurrencyExceptionFilter.cs.
Concurrency happens per-record
Section titled “Concurrency happens per-record”The check is per record, not per session or per page. If you’re editing two related records in different tabs (say an address in one tab and a scheme that uses that address in another), the two saves are independent — one can succeed while the other gets caught.
Records that don’t track concurrency
Section titled “Records that don’t track concurrency”A small number of low-traffic records don’t carry a RowVersion stamp. In those cases the rule above doesn’t apply: a “last writer wins” race is theoretically possible but in practice these are reference-data records that only Admins touch. Every primary entity (Address, Scheme, Company, Investment Event, Occupier Event, Portfolio, Market Boundary) tracks concurrency.
Mermaid: how the conflict path goes
Section titled “Mermaid: how the conflict path goes”sequenceDiagram participant A as User A participant B as User B participant S as Server A->>S: Load Scheme 42 (RowVersion = V1) B->>S: Load Scheme 42 (RowVersion = V1) A->>S: Save (If-Match: V1) → succeeds, V1 → V2 B->>S: Save (If-Match: V1) Note over S: V1 ≠ V2, reject S-->>B: 412 Precondition Failed Note over B: "This record has been modified by another user..." B->>S: Reload Scheme 42 (RowVersion = V2) B->>S: Save (If-Match: V2) → succeeds, V2 → V3Related rules
Section titled “Related rules”- Every entity page’s On save section references this — the per-entity 412 path goes through here.
Where this lives
Section titled “Where this lives”- Per-entity
If-Matchcheck: eachPatch*CommandHandler.csandReplace*CommandHandler.cs(e.g.ReplaceSchemeCommandHandler.cs:42) - Global exception filter:
src/services/api/app/app.api/Filters/DbConcurrencyExceptionFilter.cs - Filter registration:
src/services/api/app/app.api/Program.cs(AddControllers) - RowVersion property on every entity:
[Timestamp] public byte[]? RowVersionon each model undersrc/common/models/Models/