diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index debfdc0d3..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. 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. 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/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 de223181e..66d86b661 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 } @@ -2399,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") @@ -3831,11 +3837,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 { @@ -5665,7 +5672,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 = ? @@ -5682,7 +5689,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,10 +5870,43 @@ 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, 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 { + 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 == "" { + 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) - 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) @@ -9126,8 +9166,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 } @@ -9682,7 +9722,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 ( @@ -9709,7 +9749,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) @@ -11003,17 +11043,31 @@ func (s *Store) applyPromptUpsertTx(tx *sql.Tx, payload syncPromptPayload) error } var existingID int64 - err = tx.QueryRow(`SELECT id FROM user_prompts WHERE sync_id = ? ORDER BY id DESC LIMIT 1`, payload.SyncID).Scan(&existingID) + 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 + } + } + 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, - `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 @@ -11026,9 +11080,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 6f47d155d..179621668 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -812,6 +812,265 @@ 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 func() { + if err := rows.Close(); err != nil { + t.Errorf("close prompt sync mutations: %v", err) + } + }() + 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 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 { + 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 TestPromptInboxIdentityStoreRetryReplaysCompetingWrite(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("retry-inbox", "engram", "/tmp"); err != nil { @@ -4568,6 +4827,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 { @@ -6857,6 +7168,131 @@ 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 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 + 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 {