Skip to content

Conflict Resolution

The staleness signal tells the user when the data on screen has fallen behind. But a destructive bulk action — the items page’s Delete being the canonical example — can already be in flight by the time the signal would have surfaced. The user selected fifty items, walked away to make coffee, came back, and clicked Delete. In the intervening minutes, some of those cards may have been received, retired, or replaced from another browser. The system needs to catch that at click time, not after the destructive call has already gone out.

The conflict-resolution preflight does exactly that. The hook is useBulkSelectionStaleGuard, and it sits on the boundary between the user clicking Delete and the delete handler actually fanning out the per-card DELETE calls.

The bulk-delete flow runs through useDeleteItems. On the user’s Delete click, the hook first hands the selection off to the stale-guard preflight. The guard takes an rId-set snapshot of each selected item from the local cache, fires a fresh refreshCardsForItems against the same eids, and compares. If any item’s rId set differs from the snapshot, the guard returns abort and the page renders a stale-selection banner that forces the user to refresh and re-click. Otherwise the delete fans out.

The sequence below shows both branches of the preflight.

PlantUML diagram

The guard uses the same primitive the staleness signal uses: the set of card rId values for each item. The rId is the bitemporal record identifier on the kanban-card wrapper. It changes every time the backend mutates the card, which is exactly the signal “this card is not what you thought it was when you selected the row”.

The guard reads the wrapper rId (card.rId), not the payload entity id (card.payload.eId). The two are distinct: payload.eId is the card’s stable identity, which survives mutations; rId is the record identifier, which advances on every mutation. Using payload.eId would miss the case where the same card has new contents — exactly the case that matters for stale-selection detection.

useBulkSelectionStaleGuard(opts) takes two functions from the calling hook’s ItemCardsContext:

  • getCards(eid) — the synchronous point read for the cache snapshot.
  • refreshCardsForItems(eids) — the unconditional fetch for the post-snapshot read.

It returns five methods that the calling hook and the page-level banner use together:

MethodPurpose
armAndCheck(items): Promise<boolean>Run the snapshot, refresh, and diff. Returns true when any item’s rId set changed (caller must abort) or false when safe to proceed. Sets staleSelection to the input on a true result.
staleSelection: Item[] | nullWhen non-null, the most recent armAndCheck found the selection stale. Drives the visibility of the stale-selection banner.
refresh(): Promise<void>The banner’s Refresh button. Re-fetches the cards for the current staleSelection and clears the banner. Resolves only after the network round-trip completes — the banner stays visible until then.
acknowledge(): voidClears staleSelection without re-fetching. Used when the user re-issues Delete from scratch.
dismiss(): voidClears staleSelection without re-fetching. Used when the user dismisses the banner without acting.

The diff loop walks the selected items one at a time. The verdict is stale on the first mismatch — the loop exits without checking the rest. Four sub-cases arise:

Cached state (before refresh)Refreshed stateVerdict
{rId-A, rId-B}{rId-A, rId-B}Not stale
{rId-A, rId-B}{rId-A, rId-C}Stale (one card replaced)
{rId-A, rId-B}{}Stale (all cards gone)
{}{rId-A}Stale (cards appeared since selection)

A fifth case — the refresh resolves to no verdict for an eid (the cardsForItems fetch failed for that one item with the fetch-failed sentinel) — is treated specially: the guard skips that eid in the diff. Treating a fetch failure as “no cards exist” would set a false-positive staleSelection the user could never clear, because the cache never actually changed. The user’s destructive operation then either succeeds because the other eids were fine, or hits a separate error path if its prerequisites depended on the failed read.

Empty selections short-circuit to false (safe to proceed) without issuing any network call. Items without an entityId are filtered before the eids list is built; an all-no-id selection short-circuits the same way.

The page-level surface that consumes staleSelection is a banner above the items grid with two controls:

  • Refresh — calls useBulkSelectionStaleGuard.refresh(). The button stays in its busy state until the round-trip completes; the banner clears only when the refresh resolves. This is the explicit “apply the server state” contract — the banner does not hide on a still-pending refresh.
  • Dismiss — calls dismiss(). The banner closes. The next Delete click will re-arm the guard from scratch.

The Delete button itself is gated by the page state: while staleSelection !== null, Delete is disabled. The user must acknowledge the staleness (Refresh or Dismiss) before re-attempting.

Why preflight, not server-side detection alone

Section titled “Why preflight, not server-side detection alone”

The backend already enforces If-Match on per-card mutations: a stale card delete would fail at the per-card DELETE level with a 412 / 409. But that puts the failure inside the bulk operation, after some of the per-card calls have already succeeded. The user sees a partial-failure toast with no easy recovery — half the cards are gone and the other half are blocked, with no signal about which set they should be looking at.

The preflight catches the same condition before any destructive call is made. The user sees a single banner that names the affected selection, can refresh, and re-clicks Delete against a fresh selection. Server-side optimistic locking is still the backstop for the per-card path; the preflight is what makes the user-visible failure mode clean.

The choice is recorded in ADR-003: Concurrent-Edit Detection Strategy.

The guard and the staleness signal share refreshCardsForItems as their refresh primitive but do not coordinate further:

  • A concurrent markItemStale for an unrelated eid that fires while armAndCheck is in flight goes through the provider’s microtask coalescer and produces a separate refresh batch. The guard’s snapshot and verdict are computed against its own refresh result for its own eids, so unrelated bus traffic does not change the verdict.
  • A concurrent markItemStale for one of the eids the guard is checking would cause an additional refresh for the same eid via the bus path. Both refreshes hit the BFF; the guard’s verdict reflects its own promise’s resolution, which is the read that defines the user’s interaction with this Delete click.
  • The signal-driven banner in the detail panel and the guard-driven banner above the grid are independent and can coexist.

The two banners are visually distinct (different placement, different copy) so the user can tell which one they need to act on.

  • The guard does not have its own tuning knobs. It consumes the same refreshCardsForItems path the rest of the cache uses, which is bounded by the BFF and backend behaviour described in Data Flow and Caching.
  • The preflight adds one round-trip per Delete click for the full selected-eid set. This is a deliberate cost in exchange for the cleaner failure mode above.
  • Today only bulk delete uses the guard. The contract is generic enough to extend to other destructive bulk actions when they need the same protection.