Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
Expand Down
22 changes: 18 additions & 4 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''), '') = ?`
tombstoneQuery += ` WHERE ` + tombstoneProject + ` = ?`
tombstoneArgs = append(tombstoneArgs, project)
}
tombstoneQuery += ` ORDER BY t.sync_id`
Expand Down Expand Up @@ -11236,15 +11237,28 @@ 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
}
if err == nil {
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
}
Expand Down
210 changes: 210 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,209 @@ 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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 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"
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 || 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 {
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 {
Expand Down Expand Up @@ -1370,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) {
Expand Down
Loading