Concurrency & conflicts¶
Several people edit the same project at once. TaskTree's model is last-write-wins, but warn on a real conflict (ADR 0004) — a save normally just succeeds, but if you'd be clobbering someone else's change to the same field, the server refuses with a 409 and the client shows a conflict dialog.
How a save declares what it expects¶
Every PATCH to a task carries if_unchanged_since — the updated_at value the client observed when it loaded the task. The server compares it to the row's current updated_at:
- Unchanged → apply the write.
- Changed → look closer before deciding (next section).
Field-aware conflict detection¶
A blanket "the row changed, so reject" would be far too noisy — someone dragging a task on the canvas bumps updated_at constantly, and that shouldn't block an unrelated edit to the title. So the check is field-aware (amended into ADR 0004):
- The row's
updated_atis ahead of the client'sif_unchanged_since. - And the set of fields actually changed since that timestamp overlaps the fields this request is trying to change.
Only when both hold does the server return 409. The "fields changed since" set comes from querying the audit log for this task's update rows after the client's timestamp and unioning their diff keys. If there's no overlap — the canvas-drag-versus-title-edit case — the write proceeds normally.
The implementation, in the task PATCH handler:
if if_unchanged_since is not None and task.updated_at != if_unchanged_since:
touched = audit.fields_touched_since(db, task.id, if_unchanged_since)
if touched & updates.keys():
return _build_update_conflict(task, updates, db, now)
The conflict response¶
A 409 returns the current server state plus a per-field client/server comparison:
{
"detail": "Task has been modified since the provided timestamp.",
"current_updated_at": "…",
"current_updated_by": "…",
"conflicts": { "title": { "client": "…", "server": "…" } }
}
current_updated_by lets the UI say who you're racing with. The frontend dialog offers Overwrite (re-send the PATCH with the server's current_updated_at as the new if_unchanged_since, forcing last-write-wins) or Cancel (drop the local change and refetch). An empty conflicts map is valid and means the row advanced via a side-channel field you didn't touch — handled without a dialog.
Why clock_timestamp(), not now()¶
This whole mechanism depends on timestamps that are distinct and ordered even within a single transaction — and that's exactly what PostgreSQL now() does not give you.
now() (and SQLAlchemy's default onupdate=func.now()) returns the transaction start time, fixed for the whole transaction. So when one request writes several rows — a PATCH plus a cascade-shift of dependent tasks, say — every row gets an identical updated_at, and every audit row gets an identical created_at. The field-overlap query (created_at > since) would then miss rows it should find, and if_unchanged_since checks in batch operations and tests would break.
clock_timestamp() returns the actual wall-clock time at the moment of the call, so it advances within a transaction. That's why:
Task.updated_atusesonupdate=func.clock_timestamp(), and thePATCHhandler sets it explicitly on each write;AuditLog.created_atdefaults toclock_timestamp()(changed fromnow()precisely so the field-aware 409 query sees every row);- the assignee and membership timestamps follow the same rule.
This was learned the hard way in Phase 1b and is now a standing convention — if you add a write that needs per-write ordering, reach for clock_timestamp().
Cascading writes¶
Some edits intentionally touch more than one row. Moving a task's due date forward can shift its dependents to preserve their slack; each shifted task gets its own audit row (and thus its own task.updated event). Rows produced by one user gesture share a group_id on the audit log, so undo can reverse them atomically. The distinct clock_timestamp() values keep those rows individually addressable by the concurrency checks above.