Skip to content

--if-match optimistic locking: etag skew on never-updated beans, and lost updates under concurrent writes #205

Description

@Poltergeist

Hi! While building an agent pipeline that uses beans tags as its state machine (multiple beans update writers coordinating via --if-match), I hit two related write-safety problems in optimistic concurrency control. Both reproduce on a fresh beans init project. Happy to split this into two issues if you prefer — they likely have different root causes, but both undermine --if-match.

Version: beans 0.4.2 (670ecf3) built 2026-03-10T20:07:43Z (Homebrew cask), macOS (Darwin 25.5.0, arm64)


Bug A: the etag reported for a never-updated bean is rejected by --if-match

For a bean that has been created but never updated, the etag returned by beans show --etag-only, beans show --json, and the GraphQL bean { etag } query does not match the etag that beans update --if-match (and the GraphQL updateBean ifMatch) validates against. After the bean's first update, all sources agree again.

How to reproduce

cd "$(mktemp -d)" && beans init

ID=$(beans create "fresh bean" --json | jq -r '.bean.id')
ETAG=$(beans show "$ID" --etag-only)
beans update "$ID" --tag demo --if-match "$ETAG" --json

Observed

{"success":false,"error":"etag mismatch: provided fc54528769e5b4a3, current is 86d287f91a16aad0","code":"CONFLICT"}

Expected

The update succeeds — nothing touched the bean between the read and the write.

Notes

  • The etag in the beans create --json response is accepted by a subsequent --if-match, so the divergence is specifically between the read path (show/GraphQL query) and the write path's validation, and only for never-updated beans. After one update, show and the update response report the same etag and --if-match works as expected. This suggests the two paths hash different serializations of the same on-disk bean until the first update rewrites the file in canonical form.
  • Practical impact: any client that does the documented read-then-CAS dance (show --etag-onlyupdate --if-match) deterministically fails on every freshly created bean.

Bug B: two concurrent updates with the same etag can both succeed — one write is silently lost

--if-match appears to be validated as read-check-write inside each process with no cross-process mutual exclusion, so two concurrent beans update invocations carrying the same etag can both pass the check. Both report "success": true, and the loser's changes are silently overwritten by the winner's file write.

How to reproduce

(Occasionally needs a couple of runs — it's a race — but it hits most of the time on an M-series MacBook.)

cd "$(mktemp -d)" && beans init

ID=$(beans create "race bean" --json | jq -r '.bean.id')
beans update "$ID" --tag primed >/dev/null      # first update, to avoid Bug A
E=$(beans show "$ID" --etag-only)

beans update "$ID" --tag from-writer-A --if-match "$E" --json | jq -c '{success}' &
beans update "$ID" --tag from-writer-B --if-match "$E" --json | jq -c '{success}' &
wait

beans show "$ID" --json | jq -c '.tags'

Observed

{"success":true}
{"success":true}
["primed","from-writer-A"]

Both writers report success, but from-writer-B is gone — its write was clobbered without any error.

Expected

Exactly one writer succeeds; the other gets the CONFLICT error. (Or, absent --if-match, concurrent updates merge rather than last-writer-wins — but at minimum a CAS that reports success shouldn't lose its write.)

Notes

  • This matters a lot for the "let your coding agent manage tasks" use case: multiple agent sessions updating beans concurrently is exactly the scenario --if-match seems designed for, and today it doesn't provide the guarantee. I'm currently working around it by serializing all writes through an external lock, which works fine — so no urgency on my end, just wanted it on your radar.
  • A file lock (e.g. flock/O_EXCL lockfile in .beans/) held across the read-validate-write span, or write-to-temp + atomic-rename with a re-validate, would likely close the window.

Thanks for beans — the tag/GraphQL combination is a great fit for agent workflows. 🙏

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions