From e1d871ef08b9e6b49b6bee6e06d853a2f92b6dda Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:11:17 -0500 Subject: [PATCH 1/2] fix(store): preserve prompt deletion ownership across sparse sync --- docs/ARCHITECTURE.md | 2 +- internal/store/store.go | 19 +++++- internal/store/store_test.go | 126 +++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dfe67e5bc..54c9bfab7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ # Architecture -Local `POST /prompts` accepts optional `source_inbox_id` alongside `session_id`, `content`, and `project`. A nonempty ID identifies one prompt within its session: replay returns the existing prompt ID with the same `201` and `{"id":…, "status":"saved"}` response, without another sync mutation or write notification. Distinct IDs may contain identical text. Omitting the ID continues to append a new prompt on every call. Project ownership checks still apply before replay. Prompt sync upserts and exports preserve the optional identity, so replay after sync or import returns the existing prompt without a new mutation. Older payloads without the field remain valid. A pulled prompt upsert cannot move an established nonempty inbox identity to a different session or replace it with another nonempty ID for the same sync ID; it fails with an identity conflict. A missing payload ID retains the existing identity in the same session; a legacy row with no established identity may move sessions or acquire an ID. Import adoption of an inbox identity for the same sync ID refuses cross-project reassignment, comparing canonical effective projects (including session inheritance for blank prompt projects). Deletion tombstones retain optional inbox identity through sync and direct backup. A pulled delete for a live prompt records the live row's session-and-inbox identity, not conflicting identity fields from its payload. Reusing a deleted session-and-inbox ID fails with HTTP `409 Conflict` (no ID, mutation, or write notification), including after restore; a different ID remains a new prompt. Backup import rejects tombstones carrying an inbox ID without a session ID; legacy tombstones without an inbox ID remain valid. +Local `POST /prompts` accepts optional `source_inbox_id` alongside `session_id`, `content`, and `project`. A nonempty ID identifies one prompt within its session: replay returns the existing prompt ID with the same `201` and `{"id":…, "status":"saved"}` response, without another sync mutation or write notification. Distinct IDs may contain identical text. Omitting the ID continues to append a new prompt on every call. Project ownership checks still apply before replay. Prompt sync upserts and exports preserve the optional identity, so replay after sync or import returns the existing prompt without a new mutation. Older payloads without the field remain valid. A pulled prompt upsert cannot move an established nonempty inbox identity to a different session or replace it with another nonempty ID for the same sync ID; it fails with an identity conflict. A missing payload ID retains the existing identity in the same session; a legacy row with no established identity may move sessions or acquire an ID. Import adoption of an inbox identity for the same sync ID refuses cross-project reassignment, comparing canonical effective projects (including session inheritance for blank prompt projects). Deletion tombstones retain optional inbox identity through sync and direct backup. A pulled delete for a live prompt records the live row's session-and-inbox identity, not conflicting identity fields from its payload. Reusing a deleted session-and-inbox ID fails with HTTP `409 Conflict` (no ID, mutation, or write notification), including after restore; a different ID remains a new prompt. Pulled deletes without a live prompt also reject an inbox ID without a nonblank session ID; legacy deletes without an inbox ID remain valid. Sparse pulled deletes inherit project ownership from the live prompt first (which can differ from its session), then the live session or active deleted-session tombstone; project exports can recover ownership from an active deleted-session tombstone after that session is removed. Backup import rejects tombstones carrying an inbox ID without a session ID; legacy tombstones without an inbox ID remain valid. - [How It Works](#how-it-works) - [Session Lifecycle](#session-lifecycle) diff --git a/internal/store/store.go b/internal/store/store.go index 925b861c2..bc6834483 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5727,7 +5727,7 @@ func (s *Store) exportWithProjectScope(project string) (_ *ExportData, err error tombstoneQuery := `SELECT t.sync_id, ifnull(t.session_id, ''), t.project, ifnull(t.source_inbox_id, ''), t.deleted_at FROM prompt_tombstones t` tombstoneArgs := []any{} if project != "" { - tombstoneQuery += ` LEFT JOIN sessions s ON s.id = t.session_id WHERE coalesce(nullif(t.project, ''), nullif(s.project, ''), '') = ?` + tombstoneQuery += ` LEFT JOIN sessions s ON s.id = t.session_id WHERE coalesce(nullif(t.project, ''), nullif(s.project, ''), (SELECT st.project FROM sync_delete_tombstones st WHERE st.entity = 'session' AND st.entity_key = t.session_id AND st.active = 1), '') = ?` tombstoneArgs = append(tombstoneArgs, project) } tombstoneQuery += ` ORDER BY t.sync_id` @@ -11236,8 +11236,8 @@ func (s *Store) applyPromptDeleteTx(tx *sql.Tx, payload syncPromptPayload) error if strings.TrimSpace(payload.SyncID) == "" { return nil } - var sessionID, inboxID string - err := tx.QueryRow(`SELECT session_id, ifnull(source_inbox_id, '') FROM user_prompts WHERE sync_id = ?`, payload.SyncID).Scan(&sessionID, &inboxID) + var sessionID, inboxID, promptProject string + err := tx.QueryRow(`SELECT session_id, ifnull(source_inbox_id, ''), ifnull(project, '') FROM user_prompts WHERE sync_id = ?`, payload.SyncID).Scan(&sessionID, &inboxID, &promptProject) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } @@ -11245,6 +11245,19 @@ func (s *Store) applyPromptDeleteTx(tx *sql.Tx, payload syncPromptPayload) error payload.SessionID = sessionID payload.SourceInboxID = inboxID } + if payload.SourceInboxID != "" && strings.TrimSpace(payload.SessionID) == "" { + return fmt.Errorf("delete prompt %q: session id is required for source inbox id", payload.SyncID) + } + if payload.Project == nil || strings.TrimSpace(*payload.Project) == "" { + owner := promptProject + if strings.TrimSpace(owner) == "" { + err := tx.QueryRow(`SELECT coalesce((SELECT nullif(project, '') FROM sessions WHERE id = ?), (SELECT project FROM sync_delete_tombstones WHERE entity = 'session' AND entity_key = ? AND active = 1), '')`, payload.SessionID, payload.SessionID).Scan(&owner) + if err != nil { + return err + } + } + payload.Project = nullableString(owner) + } if _, err := s.execHook(tx, `DELETE FROM user_prompts WHERE sync_id = ?`, payload.SyncID); err != nil { return err } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0035e36c1..7fcb35742 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1242,6 +1242,132 @@ func TestPromptSparseDeleteRetainsProjectAfterSessionRemoval(t *testing.T) { } } +func TestPulledSparsePromptDeleteSurvivesSessionRemoval(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sparse-owner", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "sparse-key", Op: SyncOpDelete, Payload: `{"sync_id":"sparse-key","session_id":"sparse-owner","source_inbox_id":"inbox","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession("sparse-owner"); err != nil { + t.Fatal(err) + } + exported, err := s.ExportProject("engram") + if err != nil { + t.Fatal(err) + } + if len(exported.PromptTombstones) != 1 || exported.PromptTombstones[0].SyncID != "sparse-key" { + t.Fatalf("missing project delete: %+v", exported.PromptTombstones) + } + other, err := s.ExportProject("other") + if err != nil { + t.Fatal(err) + } + if len(other.PromptTombstones) != 0 { + t.Fatalf("cross-project delete: %+v", other.PromptTombstones) + } + fresh := newTestStore(t) + if _, err := fresh.Import(exported); err != nil { + t.Fatal(err) + } + if err := fresh.CreateSession("sparse-owner", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if _, _, err := fresh.AddPromptWithResult(AddPromptParams{SessionID: "sparse-owner", Project: "engram", SourceInboxID: "inbox", Content: "replay"}); !errors.Is(err, ErrPromptInboxDeleted) { + t.Fatalf("replay: %v", err) + } +} + +func TestExportProjectLegacyPromptDeleteUsesSessionTombstone(t *testing.T) { + s := newTestStore(t) + const sessionID = "legacy-sparse-owner" + const syncID = "legacy-sparse-key" + if err := s.CreateSession(sessionID, "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, Payload: `{"sync_id":"legacy-sparse-key","session_id":"legacy-sparse-owner","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession(sessionID); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`UPDATE prompt_tombstones SET project = NULL WHERE sync_id = ?`, syncID); err != nil { + t.Fatal(err) + } + owner, err := s.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(owner.PromptTombstones) != 1 || owner.PromptTombstones[0].SyncID != syncID { + t.Fatalf("legacy prompt delete missing from owner export: %+v", owner.PromptTombstones) + } + other, err := s.ExportProject("beta") + if err != nil { + t.Fatal(err) + } + if len(other.PromptTombstones) != 0 { + t.Fatalf("legacy prompt delete leaked to other project: %+v", other.PromptTombstones) + } +} + +func TestPulledSparsePromptDeletePrefersLivePromptProject(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("cross-owner", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + const syncID = "cross-project-prompt" + upsert := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpUpsert, Payload: `{"sync_id":"cross-project-prompt","session_id":"cross-owner","project":"beta","content":"cross","source_inbox_id":"inbox"}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, upsert); err != nil { + t.Fatal(err) + } + deletion := SyncMutation{Seq: 2, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, Payload: `{"sync_id":"cross-project-prompt","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession("cross-owner"); err != nil { + t.Fatal(err) + } + beta, err := s.ExportProject("beta") + if err != nil { + t.Fatal(err) + } + if len(beta.PromptTombstones) != 1 || beta.PromptTombstones[0].SyncID != syncID || beta.PromptTombstones[0].Project == nil || *beta.PromptTombstones[0].Project != "beta" { + t.Fatalf("beta lost prompt delete: %+v", beta.PromptTombstones) + } + alpha, err := s.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(alpha.PromptTombstones) != 0 { + t.Fatalf("alpha leaked beta delete: %+v", alpha.PromptTombstones) + } +} + +func TestPulledPromptDeleteRejectsInboxWithoutSession(t *testing.T) { + for _, session := range []string{"", " \t "} { + t.Run(fmt.Sprintf("session_%q", session), func(t *testing.T) { + s := newTestStore(t) + payload := fmt.Sprintf(`{"sync_id":"bad-key","session_id":%q,"source_inbox_id":"inbox","deleted":true}`, session) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "bad-key", Op: SyncOpDelete, Payload: payload}); err == nil { + t.Fatal("accepted invalid inbox identity") + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = ?`, "bad-key"); got != 0 { + t.Fatalf("persisted invalid tombstone: %d", got) + } + }) + } + s := newTestStore(t) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "legacy-key", Op: SyncOpDelete, Payload: `{"sync_id":"legacy-key","deleted":true}`}); err != nil { + t.Fatalf("legacy delete: %v", err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = ?`, "legacy-key"); got != 1 { + t.Fatalf("legacy tombstone: %d", got) + } +} + func TestPromptInboxIdentityDeletedPulledBackfill(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("pulled-backfill-inbox", "engram", "/tmp"); err != nil { From bec5f23fd21bb3323dc25bef5bc78791d4942e57 Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:07:28 -0500 Subject: [PATCH 2/2] fix(store): export resolved prompt tombstone ownership --- docs/ARCHITECTURE.md | 2 +- internal/store/store.go | 5 +- internal/store/store_test.go | 88 +++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 54c9bfab7..b9e830d93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ # Architecture -Local `POST /prompts` accepts optional `source_inbox_id` alongside `session_id`, `content`, and `project`. A nonempty ID identifies one prompt within its session: replay returns the existing prompt ID with the same `201` and `{"id":…, "status":"saved"}` response, without another sync mutation or write notification. Distinct IDs may contain identical text. Omitting the ID continues to append a new prompt on every call. Project ownership checks still apply before replay. Prompt sync upserts and exports preserve the optional identity, so replay after sync or import returns the existing prompt without a new mutation. Older payloads without the field remain valid. A pulled prompt upsert cannot move an established nonempty inbox identity to a different session or replace it with another nonempty ID for the same sync ID; it fails with an identity conflict. A missing payload ID retains the existing identity in the same session; a legacy row with no established identity may move sessions or acquire an ID. Import adoption of an inbox identity for the same sync ID refuses cross-project reassignment, comparing canonical effective projects (including session inheritance for blank prompt projects). Deletion tombstones retain optional inbox identity through sync and direct backup. A pulled delete for a live prompt records the live row's session-and-inbox identity, not conflicting identity fields from its payload. Reusing a deleted session-and-inbox ID fails with HTTP `409 Conflict` (no ID, mutation, or write notification), including after restore; a different ID remains a new prompt. Pulled deletes without a live prompt also reject an inbox ID without a nonblank session ID; legacy deletes without an inbox ID remain valid. Sparse pulled deletes inherit project ownership from the live prompt first (which can differ from its session), then the live session or active deleted-session tombstone; project exports can recover ownership from an active deleted-session tombstone after that session is removed. Backup import rejects tombstones carrying an inbox ID without a session ID; legacy tombstones without an inbox ID remain valid. +Local `POST /prompts` accepts optional `source_inbox_id` alongside `session_id`, `content`, and `project`. A nonempty ID identifies one prompt within its session: replay returns the existing prompt ID with the same `201` and `{"id":…, "status":"saved"}` response, without another sync mutation or write notification. Distinct IDs may contain identical text. Omitting the ID continues to append a new prompt on every call. Project ownership checks still apply before replay. Prompt sync upserts and exports preserve the optional identity, so replay after sync or import returns the existing prompt without a new mutation. Older payloads without the field remain valid. A pulled prompt upsert cannot move an established nonempty inbox identity to a different session or replace it with another nonempty ID for the same sync ID; it fails with an identity conflict. A missing payload ID retains the existing identity in the same session; a legacy row with no established identity may move sessions or acquire an ID. Import adoption of an inbox identity for the same sync ID refuses cross-project reassignment, comparing canonical effective projects (including session inheritance for blank prompt projects). Deletion tombstones retain optional inbox identity through sync and direct backup. A pulled delete for a live prompt records the live row's session-and-inbox identity, not conflicting identity fields from its payload. Reusing a deleted session-and-inbox ID fails with HTTP `409 Conflict` (no ID, mutation, or write notification), including after restore; a different ID remains a new prompt. Pulled deletes without a live prompt also reject an inbox ID without a nonblank session ID; legacy deletes without an inbox ID remain valid. Sparse pulled deletes inherit project ownership from the live prompt first (which can differ from its session), then the live session or active deleted-session tombstone; project exports recover ownership from an active deleted-session tombstone after that session is removed and emit that resolved project for legacy blank-project tombstones, preserving ownership through backup import. Unscoped exports also emit the resolved project so full backup restores retain that ownership. Backup import rejects tombstones carrying an inbox ID without a session ID; legacy tombstones without an inbox ID remain valid. - [How It Works](#how-it-works) - [Session Lifecycle](#session-lifecycle) diff --git a/internal/store/store.go b/internal/store/store.go index bc6834483..87ec589df 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5724,10 +5724,11 @@ func (s *Store) exportWithProjectScope(project string) (_ *ExportData, err error return nil, err } - tombstoneQuery := `SELECT t.sync_id, ifnull(t.session_id, ''), t.project, ifnull(t.source_inbox_id, ''), t.deleted_at FROM prompt_tombstones t` + tombstoneProject := `coalesce(nullif(t.project, ''), nullif(s.project, ''), (SELECT st.project FROM sync_delete_tombstones st WHERE st.entity = 'session' AND st.entity_key = t.session_id AND st.active = 1), '')` + tombstoneQuery := `SELECT t.sync_id, ifnull(t.session_id, ''), ` + tombstoneProject + `, ifnull(t.source_inbox_id, ''), t.deleted_at FROM prompt_tombstones t LEFT JOIN sessions s ON s.id = t.session_id` tombstoneArgs := []any{} if project != "" { - tombstoneQuery += ` LEFT JOIN sessions s ON s.id = t.session_id WHERE coalesce(nullif(t.project, ''), nullif(s.project, ''), (SELECT st.project FROM sync_delete_tombstones st WHERE st.entity = 'session' AND st.entity_key = t.session_id AND st.active = 1), '') = ?` + tombstoneQuery += ` WHERE ` + tombstoneProject + ` = ?` tombstoneArgs = append(tombstoneArgs, project) } tombstoneQuery += ` ORDER BY t.sync_id` diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7fcb35742..1385806be 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1280,6 +1280,47 @@ func TestPulledSparsePromptDeleteSurvivesSessionRemoval(t *testing.T) { } } +func TestPulledSparsePromptDeleteAfterSessionRemoval(t *testing.T) { + s := newTestStore(t) + const sessionID = "removed-before-prompt-delete" + const syncID = "late-sparse-delete" + if err := s.CreateSession(sessionID, "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession(sessionID); err != nil { + t.Fatal(err) + } + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, + Payload: `{"sync_id":"late-sparse-delete","session_id":"removed-before-prompt-delete","source_inbox_id":"inbox","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatalf("pulled delete after session removal: %v", err) + } + owner, err := s.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(owner.PromptTombstones) != 1 || owner.PromptTombstones[0].SyncID != syncID || owner.PromptTombstones[0].Project == nil || *owner.PromptTombstones[0].Project != "alpha" { + t.Fatalf("owner export lost late prompt delete: %+v", owner.PromptTombstones) + } + other, err := s.ExportProject("beta") + if err != nil { + t.Fatal(err) + } + if len(other.PromptTombstones) != 0 { + t.Fatalf("late prompt delete leaked to other project: %+v", other.PromptTombstones) + } + fresh := newTestStore(t) + if _, err := fresh.Import(owner); err != nil { + t.Fatal(err) + } + if err := fresh.CreateSession(sessionID, "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + if _, _, err := fresh.AddPromptWithResult(AddPromptParams{SessionID: sessionID, Project: "alpha", SourceInboxID: "inbox", Content: "replay"}); !errors.Is(err, ErrPromptInboxDeleted) { + t.Fatalf("restoration replay: %v", err) + } +} + func TestExportProjectLegacyPromptDeleteUsesSessionTombstone(t *testing.T) { s := newTestStore(t) const sessionID = "legacy-sparse-owner" @@ -1301,8 +1342,44 @@ func TestExportProjectLegacyPromptDeleteUsesSessionTombstone(t *testing.T) { if err != nil { t.Fatal(err) } - if len(owner.PromptTombstones) != 1 || owner.PromptTombstones[0].SyncID != syncID { - t.Fatalf("legacy prompt delete missing from owner export: %+v", owner.PromptTombstones) + if len(owner.PromptTombstones) != 1 || owner.PromptTombstones[0].SyncID != syncID || owner.PromptTombstones[0].Project == nil || *owner.PromptTombstones[0].Project != "alpha" { + t.Fatalf("legacy prompt delete missing resolved owner: %+v", owner.PromptTombstones) + } + fresh := newTestStore(t) + if _, err := fresh.Import(owner); err != nil { + t.Fatal(err) + } + reexported, err := fresh.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(reexported.PromptTombstones) != 1 || reexported.PromptTombstones[0].SyncID != syncID || reexported.PromptTombstones[0].Project == nil || *reexported.PromptTombstones[0].Project != "alpha" { + t.Fatalf("roundtrip lost legacy prompt delete owner: %+v", reexported.PromptTombstones) + } + unscoped, err := s.Export() + if err != nil { + t.Fatal(err) + } + if len(unscoped.PromptTombstones) != 1 || unscoped.PromptTombstones[0].Project == nil || *unscoped.PromptTombstones[0].Project != "alpha" { + t.Fatalf("full export lost legacy tombstone owner: %+v", unscoped.PromptTombstones) + } + fullRestore := newTestStore(t) + if _, err := fullRestore.Import(unscoped); err != nil { + t.Fatal(err) + } + fullOwner, err := fullRestore.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(fullOwner.PromptTombstones) != 1 || fullOwner.PromptTombstones[0].SyncID != syncID || fullOwner.PromptTombstones[0].Project == nil || *fullOwner.PromptTombstones[0].Project != "alpha" { + t.Fatalf("full export roundtrip lost tombstone owner: %+v", fullOwner.PromptTombstones) + } + fullOther, err := fullRestore.ExportProject("beta") + if err != nil { + t.Fatal(err) + } + if len(fullOther.PromptTombstones) != 0 { + t.Fatalf("full export roundtrip leaked tombstone: %+v", fullOther.PromptTombstones) } other, err := s.ExportProject("beta") if err != nil { @@ -1496,6 +1573,13 @@ func TestImportLegacyEmptySessionTombstone(t *testing.T) { if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = ?`, "legacy-empty-session"); got != 1 { t.Fatalf("legacy tombstone count = %d, want 1", got) } + backup, err := s.Export() + if err != nil { + t.Fatal(err) + } + if len(backup.PromptTombstones) != 1 || backup.PromptTombstones[0].Project == nil || *backup.PromptTombstones[0].Project != "" { + t.Fatalf("unowned legacy tombstone export: %+v", backup.PromptTombstones) + } } func TestPromptInboxIdentityDeletedBackup(t *testing.T) {