fix(versions): an applier-path body write leaves a version row (BUG-3327) (#1709)

* fix(versions): an applier-path body write leaves a version row (BUG-3327)

A content write that takes the designated-applier path (any API, CLI or
MCP body edit while a browser tab has the item open) wrote no version:
applierFirstWrite runs the row write with Content nil, and the store's
version write lives only on the content path. An agent's edit made while
someone had the item open was missing from History. The tab's later flush
could cover it only partly: attributed to the tab's user, and throttled
away entirely when that tab had flushed within the hour.

ItemUpdate.ExternalContent (internal-only) now carries the body the
applier sends to the live document. The store versions it as it would
Content (same throttle, the writer's attribution) in the row write's own
transaction, and never writes it to items.content. That version stores the
FULL replaced body, not a reverse patch: patches resolve backwards from
the row, and on this path the row keeps the old body until the flush
lands.

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

* fix(versions): the tab flush after an applier edit does not store the replaced body twice (BUG-3327 review)

Codex round 1, P2: the tab's flush that lands an applier edit in the row
wrote its own version of the row's still-old body. That duplicated the
applier row and put the agent's change in History under the tab user's
row, leaving the agent's own row an empty change. An unforced version
write now skips a body the newest version already holds verbatim as a
full-body, non-create row. Create rows are excluded: the first edit still
owes its own row. Forced writes (restore) are unaffected. In a throttled
sequence (A->B versioned, B->A throttled, A->C) the skip loses nothing:
the body is already in that newest row.

The test now covers the flush: no second row, the chain still resolves,
the agent's row reads previous -> next, and the oldest version resolves.
A failed apply after the row write leaves a version of a body that did
exist (round 1 P2 #1), which is accepted rather than compensated.

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

* fix(versions): only a flush skips a duplicate version (BUG-3327 review)

Codex round 2, P2: the duplicate skip matched any unforced write. After a
user's A->B (versioned) and a throttled B->A, a different writer's A->C
was skipped and its change read as the first writer's. The skip now
applies only to a collab-snapshot flush, the one writer that follows an
applier edit. A new test leg fails without that limit.

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

* fix(versions): recognise the applier row positively before a flush skips its duplicate (BUG-3327 review)

Codex round 3, P2: limiting the skip to flushes was not enough. An
ordinary flush after a throttled revert to an earlier versioned body
matched on content alone and lost its version. The row an applier edit
wrote is now recognised positively: full body, not a create row, equal to
the row's body, and not older than the item's content_flushed_at. Every
direct content write bumps that timestamp in the same write as its
version, so only an applier row can postdate it. The comparison is
not-before because both are stored to the second; the residual (a
versioned edit and its throttled revert inside one second, then a flush)
is written down at the check. A new leg fails without the timestamp test.

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

* fix(versions): no content_flushed_at is no evidence for the flush dedupe (BUG-3327 review)

Codex round 4, P2: a NULL or empty content_flushed_at (migration 052
leaves it NULL on items with op-log rows) made the applier-row check
answer true without any comparison, so an ordinary flush on such an item
could lose its version. A missing timestamp now means the skip does not
apply. The cost is a duplicate row, never a lost one. A store test covers
both arms and fails on the old answer.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
This commit is contained in:
xarmian
2026-10-01 23:56:37 -04:00
committed by GitHub
parent 39a5259551
commit d5ca8a7eb0
5 changed files with 361 additions and 5 deletions
+10
View File
@@ -1334,6 +1334,16 @@ type ItemUpdate struct {
// bracketing version. Internal-only (`json:"-"`): the store honours it, but
// no HTTP client can set it. Only consulted when content actually changes.
ForceVersion bool `json:"-"`
// ExternalContent is the body an update sends to the item's LIVE
// collaborative document instead of to items.content: the designated-
// applier path, taken while a tab has the item open (BUG-3327). The row
// write runs with Content nil, so without this the store never reached
// its version write and the edit was in no version. The store versions
// ExternalContent exactly as it would Content (same throttle, attribution
// and reverse patch of the body before the edit) and never writes it to
// the row; the tab's next flush does. Internal-only, and ignored when
// Content is set.
ExternalContent *string `json:"-"`
// ActorUserID is the request's user, stamped on a version row this update
// writes (PLAN-2348 U2). Internal-only; empty for system writers.
ActorUserID string `json:"-"`
@@ -0,0 +1,204 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// BUG-3327: a body edit that takes the designated-applier path (a tab has the
// item open) wrote NO version row: the row write runs with Content nil, and
// the store's version write lives only on the content path. An agent's edit
// made while someone had the item open was therefore missing from History.
// It must leave a version of the body it replaced, attributed to the writer,
// resolvable BEFORE the tab's flush lands in items.content.
func TestBUG3327_ApplierPathBodyEditLeavesAVersion(t *testing.T) {
srv := testServerWithCollab(t)
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
// Long and nearly identical bodies, so a reverse patch is smaller than
// the full body and the store would choose it: the mutant this test must
// catch is the applier path writing a patch against a stale row.
shared := strings.Repeat("A paragraph that both bodies share, line after line.\n", 40)
previous := shared + "PREVIOUS ending, written before any tab connected"
next := shared + "NEW ending, sent by an agent through the applier"
if rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug,
map[string]interface{}{"content": previous}); rr.Code != http.StatusOK {
t.Fatalf("seed PATCH: %d %s", rr.Code, rr.Body.String())
}
conn, resp, err := dialCollab(t, ts.URL, item.ID, nil, "")
if err != nil {
status := ""
if resp != nil {
status = resp.Status
}
t.Fatalf("dialCollab: %v (%s)", err, status)
}
stop := applierEcho(t, conn)
t.Cleanup(stop)
waitForApplierPath(t, srv, slug, item.Slug, item.ID)
before, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
// An AGENT's edit while the user's tab is open: the case History lost.
rr := doRequestWithHeaders(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug,
map[string]interface{}{"content": next},
map[string]string{"X-Pad-Agent": "test-agent"})
if rr.Code != http.StatusOK {
t.Fatalf("applier-path PATCH: %d %s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "applied_pending_flush") {
t.Fatalf("the PATCH did not take the applier path (no content_outcome): %s", rr.Body.String())
}
after, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before)+1 {
t.Fatalf("versions %d -> %d: the applier-path edit left no version row", len(before), len(after))
}
// Resolve against the row as it stands NOW, before any flush: the row
// still holds `previous`, and the new version must still read as it.
row, err := srv.store.GetItem(item.ID)
if err != nil {
t.Fatal(err)
}
resolved, err := srv.store.ListItemVersionsResolved(item.ID, row.Content)
if err != nil {
t.Fatal(err)
}
newest := resolved[0]
if newest.Content != previous {
t.Errorf("newest version resolves to %q, want the replaced body %q", newest.Content, previous)
}
if newest.CreatedBy != "agent" {
t.Errorf("newest version created_by = %q, want agent", newest.CreatedBy)
}
// The tab's flush lands the new body in the row (codex review). It must
// not store the replaced body a second time, which would put the agent's
// change in History under the tab user's row; and the chain must still
// resolve, with the agent's row's change reading previous -> next.
if rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"?source=collab-snapshot",
map[string]interface{}{"content": next}); rr.Code != http.StatusOK {
t.Fatalf("flush PATCH: %d %s", rr.Code, rr.Body.String())
}
flushed, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if len(flushed) != len(after) {
t.Errorf("versions %d -> %d across the flush: the replaced body was stored twice", len(after), len(flushed))
}
row, err = srv.store.GetItem(item.ID)
if err != nil {
t.Fatal(err)
}
if row.Content != next {
t.Fatalf("flush did not land: row = %q", row.Content)
}
resolved, err = srv.store.ListItemVersionsResolved(item.ID, row.Content)
if err != nil {
t.Fatal(err)
}
if resolved[0].Content != previous || resolved[0].CreatedBy != "agent" {
t.Errorf("after the flush, newest version = %q by %q, want the replaced body by agent", resolved[0].Content, resolved[0].CreatedBy)
}
d, err := srv.store.GetItemVersionDiff(item.ID, resolved[0].ID, row.Content)
if err != nil {
t.Fatal(err)
}
if d.Before != previous || d.After != next {
t.Errorf("the agent's version records %q -> %q, want previous -> next", d.Before, d.After)
}
// Older versions still resolve: the seed write's version is the body
// before `previous`, which was empty.
if last := resolved[len(resolved)-1]; last.Content != "" {
t.Errorf("oldest version resolves to %q, want the empty initial body", last.Content)
}
}
// The flush dedupe is limited to flushes (codex review, round 2): a user's
// A->B (versioned) then B->A (throttled, same writer) leaves the newest row
// holding A, and a DIFFERENT writer's A->C must still get its own row rather
// than have its change read as the first writer's.
func TestBUG3327_DedupeSkipsOnlyFlushes(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
patch := func(content string, headers map[string]string) {
t.Helper()
rr := doRequestWithHeaders(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug,
map[string]interface{}{"content": content}, headers)
if rr.Code != http.StatusOK {
t.Fatalf("PATCH %q: %d %s", content, rr.Code, rr.Body.String())
}
}
agent := map[string]string{"X-Pad-Agent": "test-agent"}
patch("body A", nil) // the user writes A
patch("body B", agent) // a different writer: versions A as a full body
patch("body A", agent) // same writer within the hour: throttled, no row
before, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if len(before) == 0 || before[0].IsDiff || before[0].Content != "body A" {
t.Fatalf("fixture: the newest version must be a full-body \"body A\", got %+v", before)
}
patch("body C", nil) // the user again: a different writer from the newest row
after, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before)+1 {
t.Fatalf("versions %d -> %d: the user's A->C was deduped against the agent's row", len(before), len(after))
}
}
// Codex review, round 3: an ORDINARY flush (no applier edit before it) after a
// throttled revert to a versioned body must still version. Only an
// applier-path row, which postdates the item's last direct content write, may
// be treated as the flush's duplicate.
func TestBUG3327_FlushAfterThrottledRevertStillVersions(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
item := createTaskWithFields(t, srv, slug, "Item", `{"status":"open"}`)
patch := func(path, content string, headers map[string]string) {
t.Helper()
rr := doRequestWithHeaders(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+path,
map[string]interface{}{"content": content}, headers)
if rr.Code != http.StatusOK {
t.Fatalf("PATCH %q: %d %s", content, rr.Code, rr.Body.String())
}
}
agent := map[string]string{"X-Pad-Agent": "test-agent"}
patch("", "body A", nil)
patch("", "body B", agent) // versions A as a full body
// The revert must land in a LATER second than A's version, or this is the
// documented same-second residual rather than the case under test.
time.Sleep(1100 * time.Millisecond)
patch("", "body A", agent) // throttled: no row, but content_flushed_at moves
before, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
patch("?source=collab-snapshot", "body C", nil) // an ordinary tab flush
after, err := srv.store.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before)+1 {
t.Fatalf("versions %d -> %d: an ordinary flush was treated as an applier duplicate", len(before), len(after))
}
}
@@ -282,6 +282,12 @@ func (s *Server) applierFirstWrite(
// write is gated on Content != nil, so a nil here means the row write touches
// neither items.content nor the version chain's content bracket.
rowInput.Content = nil
// ...but the edit still owes a version of the body it replaces (BUG-3327):
// it used to write none, so an agent's edit made while a tab had the item
// open was missing from History. ExternalContent versions it in this same
// row write's transaction, before the live document changes, and is never
// written to the row.
rowInput.ExternalContent = &content
updated, uerr := s.store.UpdateItemWithParentLink(item.ID, rowInput, openChildrenPrecheck, parentLink)
if uerr != nil {
@@ -0,0 +1,60 @@
package store
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3327: a collab-snapshot flush skips the version of a body the newest
// version already holds ONLY when that newest row is recognised as the
// applier edit's: content_flushed_at present and not newer than it. A NULL
// content_flushed_at (migration 052 leaves it so on items with op-log rows)
// is no evidence, so the flush versions (codex review, round 4).
func TestBUG3327_FlushDedupeNeedsTheFlushTimestamp(t *testing.T) {
for _, tc := range []struct {
name string
nullFlushed bool
wantDelta int
}{
{"applier row recognised: flush skips the duplicate", false, 0},
{"no content_flushed_at: no evidence, flush versions", true, 1},
} {
t.Run(tc.name, func(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Dedupe")
coll := createTestCollection(t, s, ws.ID, "Tasks")
item := createTestItem(t, s, ws.ID, coll.ID, "Subject", "body A")
// The applier-path edit: versions body A as a full body, the row
// keeps body A.
next := "body B"
if _, err := s.UpdateItemWithParentLink(item.ID, models.ItemUpdate{
ExternalContent: &next, LastModifiedBy: "agent", VersionSource: "cli",
}, nil, nil); err != nil {
t.Fatal(err)
}
if tc.nullFlushed {
if _, err := s.db.Exec(s.q(`UPDATE items SET content_flushed_at = NULL WHERE id = ?`), item.ID); err != nil {
t.Fatal(err)
}
}
before, err := s.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if _, err := s.UpdateItemWithParentLink(item.ID, models.ItemUpdate{
Content: &next, LastModifiedBy: "user", VersionSource: "collab-snapshot",
}, nil, nil); err != nil {
t.Fatal(err)
}
after, err := s.ListItemVersions(item.ID)
if err != nil {
t.Fatal(err)
}
if got := len(after) - len(before); got != tc.wantDelta {
t.Fatalf("flush added %d version rows, want %d", got, tc.wantDelta)
}
})
}
}
+81 -5
View File
@@ -2789,9 +2789,16 @@ func (s *Store) updateItemWithParentLinkOnce(
ts := now()
// Create version if content is changing
// Create version if content is changing. The new body is Content, or on
// the applier path ExternalContent, which goes to the live document
// rather than the row (BUG-3327): either way the body before the edit
// is what a version must keep.
bodyEditedWithoutVersion := false
if input.Content != nil && *input.Content != existing.Content {
newBody := input.Content
if newBody == nil {
newBody = input.ExternalContent
}
if newBody != nil && *newBody != existing.Content {
createdBy := input.LastModifiedBy
if createdBy == "" {
createdBy = "user"
@@ -2828,14 +2835,36 @@ func (s *Store) updateItemWithParentLinkOnce(
if err != nil {
return nil, fmt.Errorf("check version throttle: %w", err)
}
// BUG-3327: an applier-path edit already stored this exact body as
// a full-body version, and the tab's flush that follows would store
// it a second time. That second row would then carry the agent's
// change in History under the tab user's name, leaving the agent's
// own row as an empty change. So a FLUSH (collab-snapshot) skips a
// version whose body the newest version already holds verbatim.
// Only a flush: any other writer's edit owes its own row even when
// an earlier throttled edit brought the body back to a versioned
// one (codex review), or that writer's change would read as the
// earlier writer's.
if shouldVersion && source == "collab-snapshot" {
dup, derr := s.newestVersionIsUnflushedApplierRow(tx, id, existing.Content)
if derr != nil {
return nil, fmt.Errorf("check duplicate version: %w", derr)
}
shouldVersion = !dup
}
}
if shouldVersion {
vid := newID()
versionContent := existing.Content
isDiff := false
patch := diff.CreateReversePatch(existing.Content, *input.Content)
if diff.IsDiffSmaller(patch, existing.Content) {
patch := diff.CreateReversePatch(existing.Content, *newBody)
// A reverse patch is resolved backwards from the ROW's body, which
// on the applier path (ExternalContent, BUG-3327) is still the old
// body until the tab's flush lands: a patch would resolve against
// the wrong base in that window. The full body resolves the same
// whatever the row holds.
if input.Content != nil && diff.IsDiffSmaller(patch, existing.Content) {
versionContent = patch
isDiff = true
}
@@ -2847,7 +2876,7 @@ func (s *Store) updateItemWithParentLinkOnce(
// under the item lock (this runs in the update's own tx).
// PLAN-2348 U2: an update row holds the body BEFORE its edit, so
// its recorded change is that body → the one replacing it.
added, removed := diff.LineCounts(existing.Content, *input.Content)
added, removed := diff.LineCounts(existing.Content, *newBody)
_, err = tx.Exec(s.q(`
INSERT INTO item_versions (id, item_id, content, change_summary, created_by, source, is_diff, created_at, version_seq,
user_id, lines_added, lines_removed, is_create)
@@ -5597,6 +5626,53 @@ func buildItemSort(sort string, dialect Dialect) string {
// and the pool test's item leg was a TITLE-only update, which never reaches
// this branch. A test that exercises one arm of an optional path is not
// evidence about the other arm.
// newestVersionIsUnflushedApplierRow reports whether the item's newest
// version is the row an applier-path edit wrote (BUG-3327) for exactly
// body, still waiting for the tab's flush to land the new body in the row.
// Such a row is recognised positively, not by its content alone (codex
// review): it is a full-body, non-create row, and it is NEWER than the
// item's content_flushed_at. Every direct content write bumps
// content_flushed_at in the same write as its version, so only an
// applier-path version (whose row write leaves content alone) can postdate
// it. A throttled revert to an earlier versioned body moves
// content_flushed_at past that version, so it does not match (but see the
// same-second residual below).
func (s *Store) newestVersionIsUnflushedApplierRow(q rowQueryer, itemID, body string) (bool, error) {
var content, createdAt string
var flushedAt sql.NullString
var isDiff, isCreate bool
err := q.QueryRow(s.q(`
SELECT v.content, v.is_diff, v.is_create, v.created_at, i.content_flushed_at
FROM item_versions v
JOIN items i ON i.id = v.item_id
WHERE v.item_id = ?
ORDER BY v.created_at DESC, v.version_seq DESC
LIMIT 1
`), itemID).Scan(&content, &isDiff, &isCreate, &createdAt, &flushedAt)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
if isDiff || isCreate || content != body {
return false, nil
}
// No timestamp is no evidence (codex review): migration 052 leaves
// content_flushed_at NULL on items with op-log rows, and treating that as
// "unflushed" would let an ordinary flush lose its version. Without it the
// skip does not apply, and the cost is a duplicate row, never a lost one.
if !flushedAt.Valid || flushedAt.String == "" {
return false, nil
}
// Not-before rather than after: both are stored at one-second
// resolution, and an applier edit routinely lands in the same second as
// the last direct write. The residual is a versioned A->B and a throttled
// revert B->A inside ONE second, then a flush: that flush's version is
// skipped and its change reads under A->B's row.
return !parseTime(createdAt).Before(parseTime(flushedAt.String)), nil
}
func (s *Store) shouldCreateItemVersion(q rowQueryer, itemID, actor, source string) (bool, error) {
var createdBy, src, createdAt string
err := q.QueryRow(s.q(`