From 1d6fc86b63d1d3fe63512df3c45d05cd6327d00b Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:17:48 -0500 Subject: [PATCH 1/6] feat(sync): retain admitted prompt identity across replicas --- docs/ARCHITECTURE.md | 2 +- internal/store/export_project_query_test.go | 2 +- internal/store/store.go | 84 +++++++------ internal/store/store_test.go | 132 ++++++++++++++++++++ 4 files changed, 183 insertions(+), 37 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index debfdc0d3..f943f509d 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. Sync/import propagation and replay after deletion are not yet supported. +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. Replay after deletion is not yet blocked; deletion tombstones do not retain the inbox identity. - [How It Works](#how-it-works) - [Session Lifecycle](#session-lifecycle) diff --git a/internal/store/export_project_query_test.go b/internal/store/export_project_query_test.go index 8d349ec78..91af3fd14 100644 --- a/internal/store/export_project_query_test.go +++ b/internal/store/export_project_query_test.go @@ -40,7 +40,7 @@ func TestExportProjectQueriesUseProjectIndexes(t *testing.T) { } assertExportQueryUsesIndex(t, s, captured, "SELECT id, ifnull(project, ''), directory", "idx_sessions_project") assertExportQueryUsesIndex(t, s, captured, "SELECT "+observationSelectColumns, "idx_obs_project") - assertExportQueryUsesIndex(t, s, captured, "SELECT id, ifnull(sync_id, '') as sync_id, session_id, content, ifnull(project, '') as project, created_at FROM user_prompts", "idx_prompts_project") + assertExportQueryUsesIndex(t, s, captured, "SELECT id, ifnull(sync_id, '') as sync_id, session_id, content, ifnull(project, '') as project, created_at, ifnull(source_inbox_id, '') FROM user_prompts", "idx_prompts_project") } func assertExportQueryUsesIndex(t *testing.T, s *Store, queries []exportedQuery, prefix, index string) { diff --git a/internal/store/store.go b/internal/store/store.go index 977b20844..4345f05bc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -292,12 +292,13 @@ type UpdateObservationParams struct { } type Prompt struct { - ID int64 `json:"id"` - SyncID string `json:"sync_id"` - SessionID string `json:"session_id"` - Content string `json:"content"` - Project string `json:"project,omitempty"` - CreatedAt string `json:"created_at"` + SourceInboxID string `json:"source_inbox_id,omitempty"` + ID int64 `json:"id"` + SyncID string `json:"sync_id"` + SessionID string `json:"session_id"` + Content string `json:"content"` + Project string `json:"project,omitempty"` + CreatedAt string `json:"created_at"` } type AddPromptParams struct { @@ -603,14 +604,15 @@ type syncObservationPayload struct { } type syncPromptPayload struct { - SyncID string `json:"sync_id"` - SessionID string `json:"session_id"` - Content string `json:"content"` - Project *string `json:"project,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - Deleted bool `json:"deleted,omitempty"` - DeletedAt *string `json:"deleted_at,omitempty"` - HardDelete bool `json:"hard_delete,omitempty"` + SourceInboxID string `json:"source_inbox_id,omitempty"` + SyncID string `json:"sync_id"` + SessionID string `json:"session_id"` + Content string `json:"content"` + Project *string `json:"project,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Deleted bool `json:"deleted,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` + HardDelete bool `json:"hard_delete,omitempty"` } // syncRelationPayload is the wire format for a memory_relations row sent over @@ -2385,9 +2387,9 @@ func (s *Store) evaluateCloudUpgradeLegacyMutationTx(tx *sql.Tx, mutation SyncMu if op == SyncOpUpsert { var local syncPromptPayload err := tx.QueryRow( - `SELECT sync_id, session_id, content, project, created_at FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, + `SELECT sync_id, session_id, content, project, created_at, ifnull(source_inbox_id, '') FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, body.SyncID, - ).Scan(&local.SyncID, &local.SessionID, &local.Content, &local.Project, &local.CreatedAt) + ).Scan(&local.SyncID, &local.SessionID, &local.Content, &local.Project, &local.CreatedAt, &local.SourceInboxID) if err != nil && !errors.Is(err, sql.ErrNoRows) { return cloudUpgradeLegacyMutationEvaluation{}, err } @@ -3830,11 +3832,12 @@ func (s *Store) AddPromptWithResult(p AddPromptParams) (int64, bool, error) { return err } return s.enqueueSyncMutationTx(tx, SyncEntityPrompt, syncID, SyncOpUpsert, syncPromptPayload{ - SyncID: syncID, - SessionID: p.SessionID, - Content: content, - Project: nullableString(p.Project), - CreatedAt: createdAt, + SyncID: syncID, + SessionID: p.SessionID, + Content: content, + Project: nullableString(p.Project), + CreatedAt: createdAt, + SourceInboxID: p.SourceInboxID, }) }) if err != nil { @@ -5664,7 +5667,7 @@ func (s *Store) exportWithProjectScope(project string) (_ *ExportData, err error } // Prompts - promptQuery := "SELECT id, ifnull(sync_id, '') as sync_id, session_id, content, ifnull(project, '') as project, created_at FROM user_prompts" + promptQuery := "SELECT id, ifnull(sync_id, '') as sync_id, session_id, content, ifnull(project, '') as project, created_at, ifnull(source_inbox_id, '') FROM user_prompts" promptArgs := []any{} if project != "" { promptQuery += ` WHERE id IN (SELECT id FROM user_prompts WHERE project = ? @@ -5681,7 +5684,7 @@ func (s *Store) exportWithProjectScope(project string) (_ *ExportData, err error defer promptRows.Close() for promptRows.Next() { var p Prompt - if err := promptRows.Scan(&p.ID, &p.SyncID, &p.SessionID, &p.Content, &p.Project, &p.CreatedAt); err != nil { + if err := promptRows.Scan(&p.ID, &p.SyncID, &p.SessionID, &p.Content, &p.Project, &p.CreatedAt, &p.SourceInboxID); err != nil { return nil, err } data.Prompts = append(data.Prompts, p) @@ -5863,9 +5866,9 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { return nil, fmt.Errorf("import prompt %d: %w", p.ID, err) } res, err := s.execHook(tx, - `INSERT INTO user_prompts (sync_id, session_id, content, project, created_at) - SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM user_prompts WHERE sync_id = ?)`, - syncID, p.SessionID, p.Content, p.Project, p.CreatedAt, syncID, + `INSERT INTO user_prompts (sync_id, session_id, content, project, created_at, source_inbox_id) + SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM user_prompts WHERE sync_id = ? OR (session_id = ? AND source_inbox_id = ?))`, + syncID, p.SessionID, p.Content, p.Project, p.CreatedAt, nullableString(p.SourceInboxID), syncID, p.SessionID, nullableString(p.SourceInboxID), ) if err != nil { return nil, fmt.Errorf("import prompt %d: %w", p.ID, err) @@ -9125,8 +9128,8 @@ func (s *Store) enqueueRescuedProjectMutationsTx(tx *sql.Tx, target string, sess } for _, id := range p.PromptIDs { var payload syncPromptPayload - err := tx.QueryRow(`SELECT sync_id, session_id, content, project, created_at FROM user_prompts WHERE id = ? AND project = ?`, id, target). - Scan(&payload.SyncID, &payload.SessionID, &payload.Content, &payload.Project, &payload.CreatedAt) + err := tx.QueryRow(`SELECT sync_id, session_id, content, project, created_at, ifnull(source_inbox_id, '') FROM user_prompts WHERE id = ? AND project = ?`, id, target). + Scan(&payload.SyncID, &payload.SessionID, &payload.Content, &payload.Project, &payload.CreatedAt, &payload.SourceInboxID) if errors.Is(err, sql.ErrNoRows) { continue } @@ -9681,7 +9684,7 @@ func (s *Store) backfillPromptSyncMutationsTx(tx *sql.Tx, project string, source mutationSource := backfillMutationSource(source) // ── Live prompts ────────────────────────────────────────────────────────── rows, err := s.queryItHook(tx, ` - SELECT p.sync_id, p.session_id, p.content, p.project, p.created_at + SELECT p.sync_id, p.session_id, p.content, p.project, p.created_at, ifnull(p.source_inbox_id, '') FROM user_prompts p LEFT JOIN sessions s ON s.id = p.session_id WHERE ( @@ -9708,7 +9711,7 @@ func (s *Store) backfillPromptSyncMutationsTx(tx *sql.Tx, project string, source var pending []syncPromptPayload for rows.Next() { var payload syncPromptPayload - if err := rows.Scan(&payload.SyncID, &payload.SessionID, &payload.Content, &payload.Project, &payload.CreatedAt); err != nil { + if err := rows.Scan(&payload.SyncID, &payload.SessionID, &payload.Content, &payload.Project, &payload.CreatedAt, &payload.SourceInboxID); err != nil { return closeRowsWithError(rows, err) } pending = append(pending, payload) @@ -11002,17 +11005,27 @@ func (s *Store) applyPromptUpsertTx(tx *sql.Tx, payload syncPromptPayload) error } var existingID int64 + if payload.SourceInboxID != "" { + var owner string + err := tx.QueryRow(`SELECT sync_id FROM user_prompts WHERE session_id = ? AND source_inbox_id = ?`, payload.SessionID, payload.SourceInboxID).Scan(&owner) + if err == nil && owner != payload.SyncID { + return fmt.Errorf("prompt inbox identity conflict for session %q and inbox ID %q", payload.SessionID, payload.SourceInboxID) + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } err = tx.QueryRow(`SELECT id FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, payload.SyncID).Scan(&existingID) if err == sql.ErrNoRows { if strings.TrimSpace(payload.CreatedAt) == "" { _, err = s.execHook(tx, - `INSERT INTO user_prompts (sync_id, session_id, content, project) VALUES (?, ?, ?, ?)`, - payload.SyncID, payload.SessionID, payload.Content, payload.Project, + `INSERT INTO user_prompts (sync_id, session_id, content, project, source_inbox_id) VALUES (?, ?, ?, ?, ?)`, + payload.SyncID, payload.SessionID, payload.Content, payload.Project, nullableString(payload.SourceInboxID), ) } else { _, err = s.execHook(tx, - `INSERT INTO user_prompts (sync_id, session_id, content, project, created_at) VALUES (?, ?, ?, ?, ?)`, - payload.SyncID, payload.SessionID, payload.Content, payload.Project, payload.CreatedAt, + `INSERT INTO user_prompts (sync_id, session_id, content, project, created_at, source_inbox_id) VALUES (?, ?, ?, ?, ?, ?)`, + payload.SyncID, payload.SessionID, payload.Content, payload.Project, payload.CreatedAt, nullableString(payload.SourceInboxID), ) } return err @@ -11025,9 +11038,10 @@ func (s *Store) applyPromptUpsertTx(tx *sql.Tx, payload syncPromptPayload) error SET session_id = ?, content = ?, project = ?, + source_inbox_id = COALESCE(?, source_inbox_id), created_at = CASE WHEN ? = '' THEN created_at ELSE ? END WHERE id = ?`, - payload.SessionID, payload.Content, payload.Project, strings.TrimSpace(payload.CreatedAt), payload.CreatedAt, existingID, + payload.SessionID, payload.Content, payload.Project, nullableString(payload.SourceInboxID), strings.TrimSpace(payload.CreatedAt), payload.CreatedAt, existingID, ) return err } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 69634f019..9ed27a1a7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -812,6 +812,138 @@ func TestPromptInboxIdentityStore(t *testing.T) { } } +func TestPromptInboxIdentitySyncRoundTrip(t *testing.T) { + source := newTestStore(t) + remote := newTestStore(t) + if err := source.CreateSession("sync-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + sessionBackup, err := source.Export() + if err != nil { + t.Fatal(err) + } + if _, err := remote.Import(sessionBackup); err != nil { + t.Fatal(err) + } + enrollTestProject(t, remote, "engram") + enrollTestProject(t, source, "engram") + for _, id := range []string{"a", "b"} { + if _, _, err := source.AddPromptWithResult(AddPromptParams{SessionID: "sync-inbox", Project: "engram", Content: "same", SourceInboxID: id}); err != nil { + t.Fatal(err) + } + } + rows, err := source.DB().Query(`SELECT payload FROM sync_mutations WHERE entity = ? AND op = ? ORDER BY seq`, SyncEntityPrompt, SyncOpUpsert) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + nextSeq := int64(1) + for rows.Next() { + var payload string + if err := rows.Scan(&payload); err != nil { + t.Fatal(err) + } + var wire syncPromptPayload + if err := json.Unmarshal([]byte(payload), &wire); err != nil { + t.Fatal(err) + } + if wire.SourceInboxID == "" { + t.Fatalf("missing inbox identity: %s", payload) + } + if err := remote.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: nextSeq, Entity: SyncEntityPrompt, EntityKey: wire.SyncID, Op: SyncOpUpsert, Payload: payload}); err != nil { + t.Fatal(err) + } + nextSeq++ + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + var count int + if err := remote.DB().QueryRow(`SELECT count(*) FROM user_prompts WHERE session_id = ? AND source_inbox_id IN ('a','b')`, "sync-inbox").Scan(&count); err != nil || count != 2 { + t.Fatalf("remote identities: %d %v", count, err) + } + before, inserted, err := remote.AddPromptWithResult(AddPromptParams{SessionID: "sync-inbox", Project: "engram", Content: "same", SourceInboxID: "a"}) + if err != nil || inserted || before == 0 { + t.Fatalf("replay: %d %v %v", before, inserted, err) + } +} + +func TestPromptInboxIdentitySyncConflict(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("conflict-session", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if _, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "conflict-session", Project: "engram", Content: "original", SourceInboxID: "shared"}); err != nil { + t.Fatal(err) + } + payload := `{"sync_id":"different-sync-id","session_id":"conflict-session","content":"replacement","source_inbox_id":"shared"}` + err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "different-sync-id", Op: SyncOpUpsert, Payload: payload}) + if err == nil || !strings.Contains(err.Error(), "prompt inbox identity conflict") { + t.Fatalf("expected explicit identity conflict, got %v", err) + } +} + +func TestPromptInboxIdentityLegacyPayload(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("legacy-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if _, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "legacy-inbox", Project: "engram", Content: "original", SourceInboxID: "retained"}); err != nil { + t.Fatal(err) + } + var syncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE session_id = ?`, "legacy-inbox").Scan(&syncID); err != nil { + t.Fatal(err) + } + payload := fmt.Sprintf(`{"sync_id":%q,"session_id":"legacy-inbox","content":"updated"}`, syncID) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpUpsert, Payload: payload}); err != nil { + t.Fatal(err) + } + var identity string + if err := s.DB().QueryRow(`SELECT source_inbox_id FROM user_prompts WHERE sync_id = ?`, syncID).Scan(&identity); err != nil || identity != "retained" { + t.Fatalf("legacy update identity = %q, err %v", identity, err) + } + id, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "legacy-inbox", Project: "engram", Content: "replay", SourceInboxID: "retained"}) + if err != nil || inserted || id == 0 { + t.Fatalf("replay = %d, %v, %v", id, inserted, err) + } +} + +func TestPromptInboxIdentityExportImport(t *testing.T) { + source := newTestStore(t) + if err := source.CreateSession("export-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b"} { + if _, _, err := source.AddPromptWithResult(AddPromptParams{SessionID: "export-inbox", Project: "engram", Content: "same", SourceInboxID: id}); err != nil { + t.Fatal(err) + } + } + data, err := source.Export() + if err != nil { + t.Fatal(err) + } + if len(data.Prompts) != 2 || data.Prompts[0].SourceInboxID != "a" || data.Prompts[1].SourceInboxID != "b" { + t.Fatalf("export identities: %+v", data.Prompts) + } + restored := newTestStore(t) + if _, err := restored.Import(data); err != nil { + t.Fatal(err) + } + var before int + if err := restored.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&before); err != nil { + t.Fatal(err) + } + _, inserted, err := restored.AddPromptWithResult(AddPromptParams{SessionID: "export-inbox", Project: "engram", Content: "same", SourceInboxID: "a"}) + if err != nil || inserted { + t.Fatalf("restored replay: %v %v", inserted, err) + } + var after int + if err := restored.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&after); err != nil || after != before { + t.Fatalf("mutation count %d -> %d: %v", before, after, err) + } +} + func TestPromptInboxIdentityStoreConcurrentReplay(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("concurrent-inbox", "engram", "/tmp"); err != nil { From 36b630d0c80bf9a16846462dbaf819bd4c611246 Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:23:00 -0500 Subject: [PATCH 2/6] test(store): check prompt sync cursor close error --- internal/store/store_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9ed27a1a7..b97b38cd7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -836,7 +836,11 @@ func TestPromptInboxIdentitySyncRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + t.Errorf("close prompt sync mutations: %v", err) + } + }() nextSeq := int64(1) for rows.Next() { var payload string From 435047742ad90737ffc7bef15195faa97e131b6f Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:23:06 -0500 Subject: [PATCH 3/6] fix(store): preserve established prompt inbox identity on pull --- docs/ARCHITECTURE.md | 2 +- internal/store/store.go | 6 +- internal/store/store_test.go | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f943f509d..dad1cf625 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. Replay after deletion is not yet blocked; deletion tombstones do not retain the inbox identity. +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. Replay after deletion is not yet blocked; deletion tombstones do not retain the inbox identity. - [How It Works](#how-it-works) - [Session Lifecycle](#session-lifecycle) diff --git a/internal/store/store.go b/internal/store/store.go index 4345f05bc..cfb8c2a8c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -11015,7 +11015,11 @@ func (s *Store) applyPromptUpsertTx(tx *sql.Tx, payload syncPromptPayload) error return err } } - err = tx.QueryRow(`SELECT id FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, payload.SyncID).Scan(&existingID) + var existingSessionID, existingSourceInboxID string + err = tx.QueryRow(`SELECT id, session_id, ifnull(source_inbox_id, '') FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, payload.SyncID).Scan(&existingID, &existingSessionID, &existingSourceInboxID) + if err == nil && existingSourceInboxID != "" && (existingSessionID != payload.SessionID || (payload.SourceInboxID != "" && existingSourceInboxID != payload.SourceInboxID)) { + return fmt.Errorf("prompt inbox identity conflict for sync ID %q: existing session %q and inbox ID %q, received session %q and inbox ID %q", payload.SyncID, existingSessionID, existingSourceInboxID, payload.SessionID, payload.SourceInboxID) + } if err == sql.ErrNoRows { if strings.TrimSpace(payload.CreatedAt) == "" { _, err = s.execHook(tx, diff --git a/internal/store/store_test.go b/internal/store/store_test.go index b97b38cd7..1eba4fb74 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -887,6 +887,129 @@ func TestPromptInboxIdentitySyncConflict(t *testing.T) { } } +func TestPromptInboxIdentityRejectsSameSyncIDRebinding(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("rebind-session", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "rebind-session", Project: "engram", Content: "original", SourceInboxID: "a"}) + if err != nil { + t.Fatal(err) + } + var syncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id = ?`, id).Scan(&syncID); err != nil { + t.Fatal(err) + } + payload := fmt.Sprintf(`{"sync_id":%q,"session_id":"rebind-session","content":"replacement","source_inbox_id":"b"}`, syncID) + err = s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpUpsert, Payload: payload}) + if err == nil || !strings.Contains(err.Error(), "prompt inbox identity conflict") { + t.Fatalf("expected explicit identity conflict, got %v", err) + } + var identity, content string + if err := s.DB().QueryRow(`SELECT source_inbox_id, content FROM user_prompts WHERE id = ?`, id).Scan(&identity, &content); err != nil { + t.Fatal(err) + } + if identity != "a" || content != "original" { + t.Fatalf("prompt after rejected rebind = (%q, %q)", identity, content) + } + var cursor int64 + if err := s.DB().QueryRow(`SELECT last_pulled_seq FROM sync_state WHERE target_key = ?`, DefaultSyncTargetKey).Scan(&cursor); err != nil { + t.Fatal(err) + } + if cursor != 0 { + t.Fatalf("pull cursor after rejected rebind = %d", cursor) + } + got, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "rebind-session", Project: "engram", Content: "replay", SourceInboxID: "a"}) + if err != nil || inserted || got != id { + t.Fatalf("replay A = (%d, %v, %v), want (%d, false, nil)", got, inserted, err, id) + } +} + +func TestPromptInboxIdentityRejectsSessionMove(t *testing.T) { + for _, tc := range []struct { + name, incomingID string + }{ + {name: "same ID", incomingID: "a"}, + {name: "omitted ID"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + for _, session := range []string{"s1", "s2"} { + if err := s.CreateSession(session, "engram", "/tmp"); err != nil { + t.Fatal(err) + } + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "s1", Project: "engram", Content: "original", SourceInboxID: "a"}) + if err != nil { + t.Fatal(err) + } + var syncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id = ?`, id).Scan(&syncID); err != nil { + t.Fatal(err) + } + payload := fmt.Sprintf(`{"sync_id":%q,"session_id":"s2","content":"replacement","source_inbox_id":%q}`, syncID, tc.incomingID) + err = s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpUpsert, Payload: payload}) + if err == nil || !strings.Contains(err.Error(), "prompt inbox identity conflict") { + t.Fatalf("expected explicit identity conflict, got %v", err) + } + var session, identity, content string + if err := s.DB().QueryRow(`SELECT session_id, source_inbox_id, content FROM user_prompts WHERE id = ?`, id).Scan(&session, &identity, &content); err != nil { + t.Fatal(err) + } + if session != "s1" || identity != "a" || content != "original" { + t.Fatalf("prompt after rejected move = (%q, %q, %q)", session, identity, content) + } + var cursor int64 + if err := s.DB().QueryRow(`SELECT last_pulled_seq FROM sync_state WHERE target_key = ?`, DefaultSyncTargetKey).Scan(&cursor); err != nil { + t.Fatal(err) + } + if cursor != 0 { + t.Fatalf("pull cursor after rejected move = %d", cursor) + } + got, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "s1", Project: "engram", Content: "replay", SourceInboxID: "a"}) + if err != nil || inserted || got != id { + t.Fatalf("replay s1/a = (%d, %v, %v), want (%d, false, nil)", got, inserted, err, id) + } + }) + } +} + +func TestPromptInboxIdentityLegacyUpgradeAndMove(t *testing.T) { + s := newTestStore(t) + for _, session := range []string{"legacy-s1", "legacy-s2"} { + if err := s.CreateSession(session, "engram", "/tmp"); err != nil { + t.Fatal(err) + } + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "legacy-s1", Project: "engram", Content: "original"}) + if err != nil { + t.Fatal(err) + } + var syncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id = ?`, id).Scan(&syncID); err != nil { + t.Fatal(err) + } + for seq, payload := range []string{ + fmt.Sprintf(`{"sync_id":%q,"session_id":"legacy-s2","content":"moved"}`, syncID), + fmt.Sprintf(`{"sync_id":%q,"session_id":"legacy-s2","content":"upgraded","source_inbox_id":"a"}`, syncID), + } { + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: int64(seq + 1), Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpUpsert, Payload: payload}); err != nil { + t.Fatal(err) + } + } + var session, identity, content string + if err := s.DB().QueryRow(`SELECT session_id, source_inbox_id, content FROM user_prompts WHERE id = ?`, id).Scan(&session, &identity, &content); err != nil { + t.Fatal(err) + } + if session != "legacy-s2" || identity != "a" || content != "upgraded" { + t.Fatalf("upgraded prompt = (%q, %q, %q)", session, identity, content) + } + got, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "legacy-s2", Project: "engram", Content: "replay", SourceInboxID: "a"}) + if err != nil || inserted || got != id { + t.Fatalf("replay upgraded identity = (%d, %v, %v), want (%d, false, nil)", got, inserted, err, id) + } +} + func TestPromptInboxIdentityLegacyPayload(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("legacy-inbox", "engram", "/tmp"); err != nil { From 6e870d17ad734a3bec99095eca451b49e96146ea Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:53:47 -0500 Subject: [PATCH 4/6] fix(store): retain inbox identity during legacy prompt repair --- internal/store/store.go | 4 +++ internal/store/store_test.go | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/internal/store/store.go b/internal/store/store.go index cfb8c2a8c..a16a8e80b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2401,6 +2401,10 @@ func (s *Store) evaluateCloudUpgradeLegacyMutationTx(tx *sql.Tx, mutation SyncMu body.Content = strings.TrimSpace(local.Content) changed = true } + if body.SourceInboxID == "" && err == nil && local.SourceInboxID != "" { + body.SourceInboxID = local.SourceInboxID + changed = true + } missing := []string{} if strings.TrimSpace(body.SessionID) == "" { missing = append(missing, "session_id") diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1eba4fb74..b4cf1f07a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4728,6 +4728,58 @@ func TestUpgradeRepairDryRunAndApply(t *testing.T) { } }) + t.Run("legacy prompt repair retains authoritative inbox identity", func(t *testing.T) { + for _, tc := range []struct { + name, localID, suppliedID, wantID string + }{ + {"missing identity", "local-inbox", "", "local-inbox"}, + {"supplied identity", "local-inbox", "supplied-inbox", "supplied-inbox"}, + {"no local identity", "", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("prompt-repair-session", "prompt-repair-project", "/tmp/prompt-repair"); err != nil { + t.Fatalf("create session: %v", err) + } + if _, err := s.AddPrompt(AddPromptParams{SessionID: "prompt-repair-session", Project: "prompt-repair-project", Content: "authoritative content", SourceInboxID: tc.localID}); err != nil { + t.Fatalf("add prompt: %v", err) + } + if err := s.EnrollProject("prompt-repair-project"); err != nil { + t.Fatalf("enroll project: %v", err) + } + var syncID string + if err := s.db.QueryRow(`SELECT sync_id FROM user_prompts WHERE session_id = ?`, "prompt-repair-session").Scan(&syncID); err != nil { + t.Fatalf("lookup prompt sync ID: %v", err) + } + legacy, err := json.Marshal(syncPromptPayload{SyncID: syncID, SessionID: "prompt-repair-session", SourceInboxID: tc.suppliedID}) + if err != nil { + t.Fatalf("encode legacy payload: %v", err) + } + if _, err := s.execHook(s.db, `UPDATE sync_mutations SET payload = ? WHERE entity = ? AND entity_key = ? AND op = ?`, string(legacy), SyncEntityPrompt, syncID, SyncOpUpsert); err != nil { + t.Fatalf("seed legacy mutation: %v", err) + } + report, err := s.RepairCloudUpgrade("prompt-repair-project", true) + if err != nil { + t.Fatalf("repair legacy prompt: %v", err) + } + if !report.Applied { + t.Fatalf("expected applied repair, got %+v", report) + } + var payload string + if err := s.db.QueryRow(`SELECT payload FROM sync_mutations WHERE entity = ? AND entity_key = ? AND op = ? ORDER BY seq DESC LIMIT 1`, SyncEntityPrompt, syncID, SyncOpUpsert).Scan(&payload); err != nil { + t.Fatalf("load repaired payload: %v", err) + } + var repaired syncPromptPayload + if err := decodeSyncPayload([]byte(payload), &repaired); err != nil { + t.Fatalf("decode repaired payload: %v", err) + } + if repaired.SourceInboxID != tc.wantID || repaired.Content != "authoritative content" { + t.Fatalf("repaired prompt = %+v, want inbox ID %q and authoritative content", repaired, tc.wantID) + } + }) + } + }) + t.Run("legacy relation mutation payload is repaired from authoritative local relation", func(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("legacy-rel-s1", "legacy-rel-proj", "/tmp/legacy-rel"); err != nil { From 77ecbd39e32ceba4af60e8e865802a4f386217df Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:14:10 -0500 Subject: [PATCH 5/6] fix(store): adopt imported inbox ID on legacy prompt --- internal/store/store.go | 23 +++++++++++++ internal/store/store_test.go | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/internal/store/store.go b/internal/store/store.go index a16a8e80b..7e2d63c7f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5869,6 +5869,29 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { } else if err != sql.ErrNoRows { return nil, fmt.Errorf("import prompt %d: %w", p.ID, err) } + if p.SourceInboxID != "" { + var existingID int64 + var existingSession, existingIdentity string + err := tx.QueryRow(`SELECT id, session_id, ifnull(source_inbox_id, '') FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, syncID).Scan(&existingID, &existingSession, &existingIdentity) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("import prompt %d: lookup identity: %w", p.ID, err) + } + if err == nil { + if existingSession != p.SessionID || (existingIdentity != "" && existingIdentity != p.SourceInboxID) { + return nil, fmt.Errorf("import prompt %d: conflicting inbox identity", p.ID) + } + if existingIdentity == "" { + res, err := s.execHook(tx, `UPDATE user_prompts SET source_inbox_id = ? WHERE id = ? AND (source_inbox_id IS NULL OR source_inbox_id = '') AND NOT EXISTS (SELECT 1 FROM user_prompts WHERE session_id = ? AND source_inbox_id = ?)`, p.SourceInboxID, existingID, p.SessionID, p.SourceInboxID) + if err != nil { + return nil, fmt.Errorf("import prompt %d: adopt identity: %w", p.ID, err) + } + updated, err := res.RowsAffected() + if err != nil || updated != 1 { + return nil, fmt.Errorf("import prompt %d: inbox identity already owned: %v", p.ID, err) + } + } + } + } res, err := s.execHook(tx, `INSERT INTO user_prompts (sync_id, session_id, content, project, created_at, source_inbox_id) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM user_prompts WHERE sync_id = ? OR (session_id = ? AND source_inbox_id = ?))`, diff --git a/internal/store/store_test.go b/internal/store/store_test.go index b4cf1f07a..8c3edfdc3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -7069,6 +7069,73 @@ func TestImportOlderObservationDoesNotResurrectLocalDeletion(t *testing.T) { } } +func TestImportAdoptsLegacyPromptInboxIdentity(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("adopt-session", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "adopt-session", Project: "engram", Content: "original"}) + if err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + incoming := &ExportData{Prompts: []Prompt{{SyncID: syncID, SessionID: "adopt-session", SourceInboxID: "inbox-a", Content: "imported", Project: "engram"}}} + result, err := s.Import(incoming) + if err != nil || result.PromptsImported != 0 { + t.Fatalf("import = %+v, %v", result, err) + } + if got := scalarString(t, s, `SELECT ifnull(source_inbox_id, '') FROM user_prompts WHERE id = ?`, id); got != "inbox-a" { + t.Fatalf("identity = %q", got) + } + replayed, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "adopt-session", Project: "engram", Content: "replay", SourceInboxID: "inbox-a"}) + if err != nil || inserted || replayed != id { + t.Fatalf("replay = %d, %v, %v; original %d", replayed, inserted, err, id) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE session_id = ?`, "adopt-session"); got != 1 { + t.Fatalf("prompt count = %d", got) + } +} + +func TestImportRejectsConflictingPromptInboxIdentity(t *testing.T) { + for _, tc := range []struct { + name, localSession, localIdentity, incomingSession, incomingIdentity string + owner bool + }{ + {"established", "s1", "original", "s1", "different", false}, + {"cross session", "s1", "", "s2", "incoming", false}, + {"already owned", "s1", "", "s1", "incoming", true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + for _, session := range []string{"s1", "s2"} { + if err := s.CreateSession(session, "engram", "/tmp"); err != nil { + t.Fatal(err) + } + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: tc.localSession, Project: "engram", Content: "original", SourceInboxID: tc.localIdentity}) + if err != nil { + t.Fatal(err) + } + if tc.owner { + if _, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "s1", Project: "engram", Content: "owner", SourceInboxID: tc.incomingIdentity}); err != nil { + t.Fatal(err) + } + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + _, err = s.Import(&ExportData{Prompts: []Prompt{{SyncID: syncID, SessionID: tc.incomingSession, SourceInboxID: tc.incomingIdentity, Content: "replacement", Project: "engram"}}}) + if err == nil { + t.Fatal("expected identity conflict") + } + if got := scalarString(t, s, `SELECT ifnull(source_inbox_id, '') FROM user_prompts WHERE id = ?`, id); got != tc.localIdentity { + t.Fatalf("identity changed to %q", got) + } + if got := scalarString(t, s, `SELECT content FROM user_prompts WHERE id = ?`, id); got != "original" { + t.Fatalf("content changed to %q", got) + } + }) + } +} + func TestImportPromptIdentityAndTombstoneOrdering(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("import-prompt-session", "engram", "/tmp"); err != nil { From ed3e050df028fd8374a7ac9917827c189b5951ee Mon Sep 17 00:00:00 2001 From: Daniel Rosales <111561081+dnlrsls@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:07:55 -0500 Subject: [PATCH 6/6] fix(store): reject cross-project prompt identity adoption --- docs/ARCHITECTURE.md | 2 +- internal/store/store.go | 16 ++++++++-- internal/store/store_test.go | 58 ++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dad1cf625..e6110dfab 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. Replay after deletion is not yet blocked; deletion tombstones do not retain the inbox identity. +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). Replay after deletion is not yet blocked; deletion tombstones do not retain the inbox identity. - [How It Works](#how-it-works) - [Session Lifecycle](#session-lifecycle) diff --git a/internal/store/store.go b/internal/store/store.go index 7e2d63c7f..b009dba2b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5871,13 +5871,23 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { } if p.SourceInboxID != "" { var existingID int64 - var existingSession, existingIdentity string - err := tx.QueryRow(`SELECT id, session_id, ifnull(source_inbox_id, '') FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, syncID).Scan(&existingID, &existingSession, &existingIdentity) + var existingSession, existingIdentity, existingProject, sessionProject string + err := tx.QueryRow(`SELECT p.id, p.session_id, ifnull(p.source_inbox_id, ''), ifnull(p.project, ''), ifnull(s.project, '') FROM user_prompts p LEFT JOIN sessions s ON s.id = p.session_id WHERE p.sync_id = ? ORDER BY p.id DESC LIMIT 1`, syncID).Scan(&existingID, &existingSession, &existingIdentity, &existingProject, &sessionProject) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("import prompt %d: lookup identity: %w", p.ID, err) } if err == nil { - if existingSession != p.SessionID || (existingIdentity != "" && existingIdentity != p.SourceInboxID) { + localProject := strings.TrimSpace(existingProject) + incomingProject := strings.TrimSpace(p.Project) + if localProject == "" { + localProject = strings.TrimSpace(sessionProject) + } + if incomingProject == "" { + incomingProject = strings.TrimSpace(sessionProject) + } + localProject, _ = NormalizeProject(localProject) + incomingProject, _ = NormalizeProject(incomingProject) + if existingSession != p.SessionID || localProject != incomingProject || (existingIdentity != "" && existingIdentity != p.SourceInboxID) { return nil, fmt.Errorf("import prompt %d: conflicting inbox identity", p.ID) } if existingIdentity == "" { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3678b5655..1e579754e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -7135,6 +7135,64 @@ func TestImportAdoptsLegacyPromptInboxIdentity(t *testing.T) { } } +func TestImportPromptInboxIdentityRequiresMatchingEffectiveProject(t *testing.T) { + for _, tc := range []struct { + name, localProject, incomingProject string + conflict bool + }{ + {"different shared projects", "alpha", "beta", true}, + {"same project", "alpha", "alpha", false}, + {"normalized project", " Alpha ", "ALPHA", false}, + {"canonical repeated separators", "Alpha--Project", "alpha-project", false}, + {"legacy blank project inherits session", "", "alpha", false}, + {"blank local inherits different session project", "", "beta", true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("shared-project-session", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "shared-project-session", Project: "alpha", Content: "original"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE user_prompts SET project = ? WHERE id = ?`, tc.localProject, id); err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + _, err = s.Import(&ExportData{Prompts: []Prompt{{ + SyncID: syncID, + SessionID: "shared-project-session", + Project: tc.incomingProject, + SourceInboxID: "inbox-project", + Content: "replacement", + }}}) + if tc.conflict && err == nil { + t.Fatal("expected project conflict") + } + if !tc.conflict && err != nil { + t.Fatal(err) + } + wantIdentity := "inbox-project" + if tc.conflict { + wantIdentity = "" + } + if got := scalarString(t, s, `SELECT ifnull(source_inbox_id, '') FROM user_prompts WHERE id = ?`, id); got != wantIdentity { + t.Fatalf("identity = %q, want %q", got, wantIdentity) + } + if got := scalarString(t, s, `SELECT project FROM user_prompts WHERE id = ?`, id); got != tc.localProject { + t.Fatalf("project = %q, want %q", got, tc.localProject) + } + if got := scalarString(t, s, `SELECT content FROM user_prompts WHERE id = ?`, id); got != "original" { + t.Fatalf("content = %q, want original", got) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts`); got != 1 { + t.Fatalf("row count = %d, want 1", got) + } + }) + } +} + func TestImportRejectsConflictingPromptInboxIdentity(t *testing.T) { for _, tc := range []struct { name, localSession, localIdentity, incomingSession, incomingIdentity string