Retire previous passages when sibling returns successful empty replacement
3 files changed,
+170
−17
docs/superpowers/specs/2026-09-07-empty-sibling-replacement.md+13 −0internal/memoria/siblingsync.go+10 −17internal/memoria/siblingsync_empty_test.go+147 −0
diff --git a/docs/superpowers/specs/2026-09-07-empty-sibling-replacement.md b/docs/superpowers/specs/2026-09-07-empty-sibling-replacement.md| new file mode 100644 |
| index 0000000..952384d |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-07-empty-sibling-replacement.md |
| @@ -0,0 +1,13 @@ |
| +# Successful empty sibling replacement |
| + |
| +Status: bounded draft implementation, independent Fable review pending. Base a012c8f. No merge/deploy approval. |
| + |
| +Authority: current Memoria design §4.1 revision replacement and decision8. Reset and post-restore reconciliation remain governed by §4.1's completed-marker/count requirement; this change implements no reset handling. |
| + |
| +Reproduced before implementation in commit762a8b7: Poll ingests revision7, then consumes a non-gone revision8 row and successful empty/whitespace S3 response. It reports Empty and advances the cursor while retaining revision7 in Document, Chunk and the index. A complete current body with no passages cannot justify retaining the previous passages. |
| + |
| +Change only applyRow's successful empty-body branch: invoke existing deleteBySiblingItem, scoped by configured source plus keyed external item ID, and count actual retirements in Deleted alongside Empty. An unseen/repeated empty item deletes zero records. Index-removal or DB errors abort the page before cursor advancement. Existing 404 Missing and other errors never enter this branch. Do not manufacture a title, empty passage or source deletion. |
| + |
| +Existing Store.Delete removes index entries before its DB transaction, and refuses DB deletion on index failure. This draft uses that established path; it does not claim to implement the future durable tombstone log, derived-artifact storage, atomic multi-item feed application or reset reconciliation. Those are independent missing capabilities. A DB failure after index removal retains stored rows but may temporarily reduce index results until rebuild; this is existing deletion behavior, not newly asserted atomicity. |
| + |
| +Tests exercise actual Poll through its real HTTP client: nonempty-to-empty and whitespace replacement, prior index removal, cross-source same-ID isolation, repeated replacement,404/403/500 retention, index failure with unchanged cursor, and409 reset retention. Regression ran red before implementation; removing retirement through a Go overlay reproduces red after implementation. No browser/UI copy changes. |
diff --git a/internal/memoria/siblingsync.go b/internal/memoria/siblingsync.go| index 1b7af1e..7367242 100644 |
| --- a/internal/memoria/siblingsync.go |
| +++ b/internal/memoria/siblingsync.go |
| @@ -42,7 +42,7 @@ type PollResult struct { |
| Missing int |
| // Empty counts items the sibling served with no body at all. |
| // |
| - // Skipped, not stored: a stub page has no passages, so there is |
| + // Not stored; any previous copy is retired. A stub has no passages, so there is |
| // nothing to retrieve and nothing to cite. Counted rather than passed |
| // over in silence, because a source that is suddenly all stubs is a |
| // fact about the sibling worth being able to see. |
| @@ -159,22 +159,15 @@ func (s *Store) applyRow(ctx context.Context, c *sibling.Client, sib sibling.Sib |
| return err |
| } |
| - // **An empty item is skipped and counted, not stored and not failed.** |
| - // |
| - // This said `AllowTitleOnly: true` and claimed it stopped one empty |
| - // document blocking a catch-up. It did the opposite: no Title is |
| - // passed here — a sibling's S3 gives a body and nothing else — so |
| - // Ingest refused with "neither a body nor a title", the row failed, |
| - // the cursor stayed put, and **every subsequent poll of that sibling |
| - // failed on the same row forever**. One stub page in Docs and nothing |
| - // from Docs is ever ingested again. A review found it. |
| - // |
| - // Skipping is right rather than a fallback title: a stub page has no |
| - // passages, so there is nothing to retrieve and nothing to cite, and |
| - // inventing a title from an empty body would put a document in the |
| - // corpus whose only content is its own name. The count is what keeps |
| - // it from being silent. |
| + // A successful empty replacement has no passages. Retire any previous |
| + // copy of this item; merely skipping would leave its old revision indexed. |
| + // Refusals and failures returned above never enter this replacement path. |
| if strings.TrimSpace(string(body)) == "" { |
| + n, err := s.deleteBySiblingItem(ctx, sib.Name, row.ItemID) |
| + if err != nil { |
| + return err |
| + } |
| + res.Deleted += n |
| res.Empty++ |
| return nil |
| } |
| @@ -279,7 +272,7 @@ func (s *Store) upsertFeed(ctx context.Context, app string, fields map[string]an |
| }) |
| } |
| -// deleteBySiblingItem applies one `gone` row. |
| +// deleteBySiblingItem retires one gone or successfully emptied item. |
| // |
| // It resolves the sibling's item id the same way Ingest stores it — the |
| // keyed fingerprint over (source, key) — because that is the only handle |
diff --git a/internal/memoria/siblingsync_empty_test.go b/internal/memoria/siblingsync_empty_test.go| new file mode 100644 |
| index 0000000..15aa4ca |
| --- /dev/null |
| +++ b/internal/memoria/siblingsync_empty_test.go |
| @@ -0,0 +1,147 @@ |
| +package memoria |
| + |
| +import ( |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "amadan.net/oficina/suite" |
| +) |
| + |
| +// Regression: a successful empty replacement used to leave the prior |
| +// revision in storage and the index after Poll advanced its cursor. |
| +// Mutation checked 2026-09-07: removing the empty-path retirement makes |
| +// both cases fail with the prior document still held. |
| +func TestEmptySiblingReplacementRetiresPriorPassages(t *testing.T) { |
| + for _, body := range []string{"", " \n\t "} { |
| + t.Run(body, func(t *testing.T) { |
| + ctx := context.Background() |
| + s := storeForTest(t) |
| + f := &feedServer{t: t, pages: map[string]suite.Feed{ |
| + "": {Cursor: "c1", Rows: []suite.FeedRow{row("item", 7, false)}}, |
| + "c1": {Cursor: "c1", Rows: []suite.FeedRow{}}, |
| + }, bodies: map[string]string{"item": "Synthetic retained passage from an earlier revision."}} |
| + sib, client := f.start(t) |
| + if _, err := s.Poll(ctx, client, sib); err != nil { |
| + t.Fatal(err) |
| + } |
| + assertHeld(t, s, "Docs", "item", 7) |
| + n, err := s.Count() |
| + if err != nil || n == 0 { |
| + t.Fatalf("initial index count=%d error=%v", n, err) |
| + } |
| + f.pages["c1"] = suite.Feed{Cursor: "c2", Rows: []suite.FeedRow{row("item", 8, false)}} |
| + f.pages["c2"] = suite.Feed{Cursor: "c2", Rows: []suite.FeedRow{}} |
| + f.bodies["item"] = body |
| + res, err := s.Poll(ctx, client, sib) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Empty != 1 { |
| + t.Fatalf("empty=%d", res.Empty) |
| + } |
| + if heldCount(t, s, "Docs") != 0 { |
| + t.Fatal("successful empty replacement retained the old document") |
| + } |
| + n, err = s.Count() |
| + if err != nil || n != 0 { |
| + t.Fatalf("stale index count=%d error=%v", n, err) |
| + } |
| + if cursorOf(t, s, "Docs") != "c2" { |
| + t.Fatal("replacement cursor not applied") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The successful replacement must stay scoped to its source and remain |
| +// retryable if index removal fails; an HTTP refusal is never an empty body. |
| +func TestEmptySiblingReplacementBoundaries(t *testing.T) { |
| + for _, mode := range []string{"empty", "missing", "forbidden", "outage", "index-failure"} { |
| + t.Run(mode, func(t *testing.T) { |
| + ctx := context.Background() |
| + s := storeForTest(t) |
| + for _, source := range []string{"Docs", "Sheets"} { |
| + if _, err := s.Ingest(ctx, IngestSpec{Source: source, Key: "item", ItemID: "item", SiblingRevision: 7, Body: "Synthetic old indexed content."}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + f := &feedServer{t: t, pages: map[string]suite.Feed{ |
| + "": {Cursor: "next", Rows: []suite.FeedRow{row("item", 8, false)}}, |
| + "next": {Cursor: "next", Rows: []suite.FeedRow{}}, |
| + }, bodies: map[string]string{"item": ""}} |
| + switch mode { |
| + case "missing": |
| + f.failS3 = map[string]bool{"item": true} |
| + case "forbidden": |
| + f.forbidS3 = map[string]bool{"item": true} |
| + case "outage": |
| + f.brokeS3 = map[string]bool{"item": true} |
| + case "index-failure": |
| + if err := s.ix.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + sib, client := f.start(t) |
| + res, err := s.Poll(ctx, client, sib) |
| + wantsError := mode == "forbidden" || mode == "outage" || mode == "index-failure" |
| + if (err != nil) != wantsError { |
| + t.Fatalf("error=%v, wantsError=%v", err, wantsError) |
| + } |
| + assertHeld(t, s, "Sheets", "item", 7) |
| + if mode == "empty" { |
| + if heldCount(t, s, "Docs") != 0 || res.Deleted != 1 || res.Empty != 1 { |
| + t.Fatalf("replacement result=%+v", res) |
| + } |
| + // Replaying the same successful replacement cannot report a second deletion. |
| + if err := s.setCursor(ctx, "Docs", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := s.Poll(ctx, client, sib) |
| + if err != nil || again.Deleted != 0 || again.Empty != 1 { |
| + t.Fatalf("replay=%+v error=%v", again, err) |
| + } |
| + } else { |
| + assertHeld(t, s, "Docs", "item", 7) |
| + if res.Deleted != 0 || res.Empty != 0 { |
| + t.Fatalf("failure counted as replacement: %+v", res) |
| + } |
| + if wantsError && cursorOf(t, s, "Docs") != "" { |
| + t.Fatal("failed replacement advanced cursor") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A reset response is a failed poll until a complete reconciliation protocol |
| +// exists; it cannot be interpreted as a successful empty replacement. |
| +func TestSiblingResetRetainsCorpus(t *testing.T) { |
| + ctx := context.Background() |
| + s := storeForTest(t) |
| + if _, err := s.Ingest(ctx, IngestSpec{Source: "Docs", Key: "item", ItemID: "item", SiblingRevision: 7, Body: "Synthetic retained source content."}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.setCursor(ctx, "Docs", "old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + _, _ = w.Write([]byte(`{"code":"CURSOR_RESET"}`)) |
| + })) |
| + defer srv.Close() |
| + res, err := s.Poll(ctx, sibling.NewForTest(), sibling.Sibling{Name: "Docs", BaseURL: srv.URL, Token: "synthetic"}) |
| + if err == nil || res.Deleted != 0 { |
| + t.Fatalf("reset result=%+v error=%v", res, err) |
| + } |
| + assertHeld(t, s, "Docs", "item", 7) |
| + if cursorOf(t, s, "Docs") != "old" { |
| + t.Fatal("reset advanced cursor") |
| + } |
| + n, err := s.Count() |
| + if err != nil || n == 0 { |
| + t.Fatalf("reset removed index: count=%d error=%v", n, err) |
| + } |
| +} |