Skip to content

feat(collection): refuse stale edits instead of silently overwriting them (#207) - #350

Merged
untraceablez merged 1 commit into
mainfrom
feat/207-concurrent-edits
Aug 21, 2026
Merged

feat(collection): refuse stale edits instead of silently overwriting them (#207)#350
untraceablez merged 1 commit into
mainfrom
feat/207-concurrent-edits

Conversation

@untraceablez

Copy link
Copy Markdown
Collaborator

Closes the MVP of #207 and the Settings & multi-device foundation milestone.

Why

ADR 0001 keeps scryme single-collection on purpose — and the common shape of that is a household sharing one collection from two devices. Until now every mutation was last-write-wins: two people editing the same stack silently destroyed each other's change, with nothing to notice it by. The ADR explicitly took on responsibility for making that safe.

The distinction that does the work

Rather than versioning every write, the guard turns on the difference between two kinds of edit:

  • Relative edits stay unguarded. /+ and Add to collection are commutative — two people each adding a copy should end at +2, which is exactly what last-write-wins already gives. Refusing the second would refuse a correct outcome and make the buttons feel broken.
  • Absolute edits are guarded. Setting a quantity, a finish, a condition, or deleting a stack replaces state the editor was looking at. If it moved underneath them, applying it destroys someone else's work. These send the version they were shown and are refused on mismatch.

Every mutation bumps version, so an absolute edit notices a relative one that happened under it.

A refusal has to be useful

A 409 returns the panel's current state with a note, not a bare error. Failing without showing what the row is now would cost two edits instead of one — theirs, and the editor's own work with no way to re-apply it.

The JSON API mirrors it: version on CollectionRowOut, optional version in the PATCH body and ?version= on DELETE, and a 409 body carrying current_version so a client can retry deliberately rather than blindly.

Sending no version opts out, so an already-open tab and every existing API client keep working exactly as before. This is a safety net, not a gate.

One thing that would have silently broken it: htmx doesn't swap non-2xx responses, so the conflict body would have been computed and thrown away. A small htmx:beforeSwap handler lets 409 through.

Deliberately not included

The live-sync / SSE half. #207 scopes its own MVP to "version column + 409-on-conflict + a merge prompt" and says the stream should be shared with #166 — which is cross-app work, currently parked.

Verification

  • 1306 passed, 100% coverage, 0 missed lines (21 new tests). ruff and mkdocs --strict clean. Migration 0034 applies and downgrades cleanly.
  • Tests pin both halves of the rule: that two people each adding a copy get two copies, and that a stale set-quantity/delete is refused with the other person's change intact.
  • Verified live against the dev instance: the form carries the current version; a stale edit → 409 with the quantity untouched; a current-version edit → 200; a relative +1 applied while still bumping the version. Dev state restored afterwards.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH

…them (#207)

ADR 0001 keeps scryme single-collection on purpose, and the common case for that is a household
sharing one collection from two devices. Until now every mutation was last-write-wins: two people
editing the same stack silently destroyed each other's change, with nothing to notice it by.

The guard turns on the distinction that actually matters, rather than versioning everything:

* **Relative edits stay unguarded.** `−`/`+` and "add to collection" are commutative — two people
  each adding a copy should end at +2, which is what last-write-wins already gives. Refusing the
  second would refuse a correct outcome and make the buttons feel broken.
* **Absolute edits are guarded.** Setting a quantity, a finish, a condition, or deleting a stack
  replaces state the editor was *looking at*; if it moved underneath them, applying it destroys
  someone else's work. These carry the version they were shown and are refused on mismatch.

Every mutation bumps `collection_card.version`, so an absolute edit notices a relative one that
happened under it.

A refusal returns the panel's **current state** with a note, at HTTP 409 — not a bare error. Failing
without showing what the row is now would cost two edits rather than one: theirs, and the editor's
own work with no way to re-apply it. The JSON API mirrors this, with `version` on
`CollectionRowOut`, an optional `version` on PATCH and `?version=` on DELETE, and a 409 body
carrying `current_version` so a client can retry deliberately rather than blindly.

Sending no version opts out, so an already-open tab and existing API clients keep working exactly as
before — this is a safety net, not a gate.

htmx does not swap non-2xx responses, so a `htmx:beforeSwap` handler lets 409 through; without it
the conflict body would be computed and then thrown away.

Deliberately not included: the live-sync/SSE half. #207 scopes its own MVP to "version column +
409 + merge prompt" and says the stream should be shared with #166 — which is cross-app work.

Migration 0034. 21 new tests; suite 1306 passing at 100% coverage. Verified against the dev
instance: stale edit → 409 with the quantity untouched, current-version edit → 200, and a relative
+1 applied while still bumping the version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH
Comment on lines +271 to +272
assert (await client.delete(
f"/api/v1/collection/{stack.id}?version={stale}")).status_code == 409
Comment on lines +276 to +277
assert (await client.delete(
f"/api/v1/collection/{stack.id}?version={stack.version}")).status_code == 200
import sqlalchemy as sa
from alembic import op

revision: str = "0034_stack_version"
from alembic import op

revision: str = "0034_stack_version"
down_revision: str | None = "0033_client_token"
@untraceablez
untraceablez merged commit 120cda2 into main Aug 21, 2026
9 checks passed
@untraceablez
untraceablez deleted the feat/207-concurrent-edits branch August 21, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant