Files
pad/internal/server
xarmian 371ea3a145 fix: a tab's older content write can no longer land over its newer one (BUG-3080) (#1435)
* fix: a tab's older content write can no longer land over its newer one (BUG-3080)

The item pane's teardown flushes (collab snapshot and raw markdown) are
fire-and-forget PATCHes carrying the whole body. Two can be in flight at once,
for example after a cancelled unload, more typing, then a real close. When
they arrive in the wrong order, the older one puts old text back.

The client cannot order them: the second is sent from a page that is going
away. A version token cannot either: both are dispatched before the tab sees
either result, so both carry the same token, and the older landing first would
get the NEWER refused. So the order is decided at the server, from what the
tab says:

- Every item-pane content PATCH now carries client_write {tab, n}. The tab is
  a per-page-load random id (never a session or user id), and n only rises.
  This covers all five content writes the pane sends, not only teardown, so an
  ordinary slow-then-fast pair is ordered too. That is intended.
- The server keeps the highest n APPLIED per (item, tab) in one bounded map
  (10,000 keys, 10 minutes idle). A lower n answers 409 superseded_write,
  naming the n it lost to, and writes nothing. The pane treats that answer as
  success and never retries.
- The per-key lock is held from the check through the write, so the older
  write cannot pass its check and then land last.
- Per instance, and forgotten on restart: a missing mark refuses nothing, so
  every gap degrades to today's behaviour. A write without client_write (the
  CLI, MCP) is never compared against a mark. The CLAUDE.md note sits beside
  the SSE budget.
- Refusals are counted in pad_content_writes_superseded_total.

Tests: Go handler tests on both paths, including a concurrent case held open
through a test-only seam, age and bound, and unstamped writes. Playwright
holds a real older flush and delivers it last. Removing the server check,
releasing the lock early, or not sending the stamp each turns a test red.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(server): client_write orders CONTENT writes only (BUG-3080, codex round 1)

A stamp on a PATCH that carries no content (a field or title write) used to
advance the (item, tab) mark. That could refuse this tab's next real content
write with a lower n. The stamp now neither orders nor marks a write that
carries no body. The pane never stamps such writes, so this pins the contract
rather than a current path. A test covers it, and dropping the gate turns it
red (409 superseded_by 50).

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(server): the client_write mark moves when the write commits, not at the response (BUG-3080, codex round 2)

The mark was advanced only on the 200 exit. A write that committed and then
failed in response enrichment (a 500) left it behind, so this tab's older
write could pass its check and overwrite what was just stored. It now moves at
the point every write path has confirmed its commit. That is argued from the
code: there is no seam to make enrichment fail on demand.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 13:54:24 -04:00
..