A skill vanished

User config lived in a JSON document. One field, skillMap, held saved skills. The map started as skill 111.

Two saves ran at once: skill 222 and skill 333. Both returned success. After a refresh, 222 was gone and 333 was there. The client logged no error. The stored map had gone from skill 111 alone to 111 and 333.

Both requests read the same map

Each save reads skillMap, adds its own key, and replaces the whole field. Both requests read the same snapshot, S. Request A writes S plus skill 222. Request B writes S plus skill 333. The document service accepts both patches. The later replace wins, and the other key is gone. Neither call knows the map changed under it.

Lost update

Step 1 of 4 · Initial state

In progress

Request A

Save skill 222

Snapshot S + A

{
  "111": "9001",
  "222": "9002"
}

Waiting

Request B

Save skill 333

Snapshot S + B

{
  "111": "9001",
  "333": "9003"
}

Waiting

PATCH /skillMap · replace only

DocumentService

Stored skillMap

{
  "111": "9001"
}

The document initially contains skill 111.

Step 1 of 4, Initial state. The document initially contains skill 111.

Replacing the whole document is a worse version of the same mistake. The object also held sibling fields — defaultFolderId and schemaVersion. Putting it back from a partial client view can wipe those. The store exposed JSON Patch so a write can touch only the field it means to change:

PATCH /objects/{id}
Content-Type: application/json-patch+json

A patch that replaces the whole map is still a lost update.

Make the map the version

RFC 6902 defines a test operation: if the current value at a path does not match, the whole patch is rejected and nothing changes. Pair test with replace and the stored map is the version. There is no separate version field. The client does not send a version number; the value is the version.

On POST /v1/saved-skills, after validating the new entry, and assuming the config object already exists for the user:

  1. Read user_config_{userId} and keep skillMap exactly as stored.
  2. Build a new map by setting that one key — entry file id and createdAt — and leave every other key as it was.
  3. Patch only skillMap, guarded by the value just read.

The test value must be the map returned by the read. The replace value is that map with this request’s one key applied. If skillMap is missing, use add for /skillMap and skip test.

If test fails, the document service returns 409 — another write landed first. Re-read and run the same decision again: build the new map from the fresh snapshot, then test plus replace. Do not write the stale map. A few attempts is enough. If they are exhausted, return a retryable 503. The caller’s retry is safe when a save that already landed is treated as an already-saved conflict.

The second write has to notice

Saving skill 222 on top of skill 111 sends this patch. test asserts the map just read. replace is that map with one key added.

[
  {
    "op": "test",
    "path": "/skillMap",
    "value": {
      "111": { "fileId": "9001", "createdAt": "2026-09-01T12:00:00Z" }
    }
  },
  {
    "op": "replace",
    "path": "/skillMap",
    "value": {
      "111": { "fileId": "9001", "createdAt": "2026-09-01T12:00:00Z" },
      "222": { "fileId": "9002", "createdAt": "2026-09-22T15:00:00Z" }
    }
  }
]

Request A’s test matches, so its replace lands. Request B still asserts the old map. That patch has to fail.

Protected write

Step 1 of 5 · Initial state

In progress

Request A

Save skill 222

Snapshot S + A

{
  "111": "9001",
  "222": "9002"
}

Waiting

Request B

Save skill 333

Snapshot S + B

{
  "111": "9001",
  "333": "9003"
}

Waiting

PATCH /skillMap · test + replace

DocumentService

Stored skillMap

{
  "111": "9001"
}

The document initially contains skill 111.

Step 1 of 5, Initial state. The document initially contains skill 111.

Request A returns 200. Request B’s test of snapshot S returns 409, and nothing is written for that attempt. B re-reads the map, adds skill 333, and retries. Both keys remain.

A create race stays separate: a 409 on create means the instance already exists, then fall into this patch path. An update that patches only defaultFolderId does not collide with this write — different paths, different fields.

The lesson I keep: when the store speaks RFC 6902, use test as the lock. Read-modify-write of a whole map is how concurrent saves drop each other’s keys.