diff --git a/docs/codebase/prompt-inbox-provenance.md b/docs/codebase/prompt-inbox-provenance.md index a0dfda73c..47183c7a4 100644 --- a/docs/codebase/prompt-inbox-provenance.md +++ b/docs/codebase/prompt-inbox-provenance.md @@ -44,4 +44,4 @@ Each PR is at most **400 authored diff lines**, includes its own tests/docs with ## Limitations -Pending retry UX and any cryptographic offline issuer still require design and tests; none may turn chunk/import history into authority. Session registration and its storage schema exist. SQLite records `local_creation_project` only on newly inserted CreateSession/StartSession rows. NULL means unknown/unverified for pre-migration, imported, rescued and remotely created sessions. A pulled upsert into a locally created session preserves its existing marker. Eligibility requires the current owner to match that stored creation owner exactly; identity repair preserves the nullable value. A store read returns current owner and local eligibility, not cloud authority. Cloud storage supports explicit immutable prompt pair claims against registered sessions, without deriving claims from chunks or prompt history. `ClaimPromptPair` assumes its caller has authenticated the actor and checked both project grants; it does not perform authorization. Authenticated registration and pair-claim routes exist; no verified delete admission or client handshake is implemented yet. The current cloud behavior does **not** enforce this RFC. +Pending retry UX and any cryptographic offline issuer still require design and tests; none may turn chunk/import history into authority. Session registration and its storage schema exist. SQLite records `local_creation_project` only on newly inserted CreateSession/StartSession rows. Newly inserted keyed `AddPromptWithResult` rows also record their original session ID, inbox ID and prompt project; existing/replayed, idless, imported and pulled rows do not acquire this marker. The live-row read requires exact agreement with all three original values, including beta prompt ownership under an alpha session. This marker is local creation evidence, not cloud authority; it is not retained on deletion. NULL means unknown/unverified for pre-migration, imported, rescued and remotely created sessions. A pulled upsert into a locally created session preserves its existing marker. Eligibility requires the current owner to match that stored creation owner exactly; identity repair preserves the nullable value. A store read returns current owner and local eligibility, not cloud authority. Cloud storage supports explicit immutable prompt pair claims against registered sessions, without deriving claims from chunks or prompt history. `ClaimPromptPair` assumes its caller has authenticated the actor and checked both project grants; it does not perform authorization. Authenticated registration and pair-claim routes exist; no verified delete admission or client handshake is implemented yet. The current cloud behavior does **not** enforce this RFC. diff --git a/internal/store/prompt_local_origin_test.go b/internal/store/prompt_local_origin_test.go new file mode 100644 index 000000000..f3dc18cbe --- /dev/null +++ b/internal/store/prompt_local_origin_test.go @@ -0,0 +1,166 @@ +package store + +import "testing" + +func TestLocalPromptCreationIdentity(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("session", "alpha", "/work"); err != nil { + t.Fatal(err) + } + check := func(syncID, session, inbox, project string, eligible bool) { + t.Helper() + gotSession, gotInbox, gotProject, gotEligible, err := s.LocalPromptCreationIdentity(syncID) + if err != nil || gotSession != session || gotInbox != inbox || gotProject != project || gotEligible != eligible { + t.Fatalf("syncID %q: %q %q %q %v %v", syncID, gotSession, gotInbox, gotProject, gotEligible, err) + } + } + check("", "", "", "", false) + keyed := AddPromptParams{SessionID: "session", Project: "beta", SourceInboxID: "inbox", Content: "local"} + id, inserted, err := s.AddPromptWithResult(keyed) + if err != nil || !inserted { + t.Fatalf("insert: %v %v", inserted, err) + } + var localSyncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, id).Scan(&localSyncID); err != nil { + t.Fatal(err) + } + check(localSyncID, "session", "inbox", "beta", true) + replay, inserted, err := s.AddPromptWithResult(keyed) + if err != nil || inserted || replay != id { + t.Fatalf("replay: %d %v %v", replay, inserted, err) + } + check(localSyncID, "session", "inbox", "beta", true) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "remote", Op: SyncOpUpsert, Payload: `{"sync_id":"remote","session_id":"session","source_inbox_id":"remote-key","project":"beta","content":"remote"}`, Source: SyncSourceRemote, Project: "beta"}); err != nil { + t.Fatal(err) + } + check("remote", "", "", "", false) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 2, Entity: SyncEntityPrompt, EntityKey: "remote", Op: SyncOpUpsert, Payload: `{"sync_id":"remote","session_id":"session","source_inbox_id":"remote-key","project":"beta","content":"changed"}`, Source: SyncSourceRemote, Project: "beta"}); err != nil { + t.Fatal(err) + } + check("remote", "", "", "", false) + if _, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "session", Project: "beta", SourceInboxID: "remote-key", Content: "collision"}); err != nil || inserted { + t.Fatalf("collision: %v %v", inserted, err) + } + check("remote", "", "", "", false) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 3, Entity: SyncEntityPrompt, EntityKey: "different", Op: SyncOpUpsert, Payload: `{"sync_id":"different","session_id":"session","source_inbox_id":"inbox","project":"beta","content":"collision"}`, Source: SyncSourceRemote, Project: "beta"}); err == nil { + t.Fatal("pulled collision accepted") + } + check(localSyncID, "session", "inbox", "beta", true) + backup, err := s.Export() + if err != nil { + t.Fatal(err) + } + imported := newTestStore(t) + if _, err := imported.Import(backup); err != nil { + t.Fatal(err) + } + session, inbox, project, eligible, err := imported.LocalPromptCreationIdentity(localSyncID) + if err != nil || session != "" || inbox != "" || project != "" || eligible { + t.Fatalf("import promoted identity: %q %q %q %v %v", session, inbox, project, eligible, err) + } + plain, inserted, err := s.AddPromptIfMissing(AddPromptParams{SessionID: "session", Project: "beta", Content: "plain"}) + if err != nil || !inserted { + t.Fatalf("idless: %v %v", inserted, err) + } + var plainSyncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, plain).Scan(&plainSyncID); err != nil { + t.Fatal(err) + } + check(plainSyncID, "", "", "", false) + idless, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "session", Project: "beta", Content: "another idless"}) + if err != nil || !inserted { + t.Fatalf("idless AddPromptWithResult: %v %v", inserted, err) + } + var idlessSyncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, idless).Scan(&idlessSyncID); err != nil { + t.Fatal(err) + } + check(idlessSyncID, "", "", "", false) + check("missing", "", "", "", false) + if _, err := s.DB().Exec(`UPDATE user_prompts SET project='gamma' WHERE id=?`, id); err != nil { + t.Fatal(err) + } + check(localSyncID, "", "", "", false) + if _, err := s.DB().Exec(`UPDATE user_prompts SET project='beta', source_inbox_id='other' WHERE id=?`, id); err != nil { + t.Fatal(err) + } + check(localSyncID, "", "", "", false) + if err := s.CreateSession("other", "alpha", "/work"); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`UPDATE user_prompts SET source_inbox_id='inbox', session_id='other' WHERE id=?`, id); err != nil { + t.Fatal(err) + } + check(localSyncID, "", "", "", false) + deleteID, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "session", Project: "beta", SourceInboxID: "delete-inbox", Content: "delete"}) + if err != nil || !inserted { + t.Fatalf("delete insert: %v %v", inserted, err) + } + var deleteSyncID string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, deleteID).Scan(&deleteSyncID); err != nil { + t.Fatal(err) + } + check(deleteSyncID, "session", "delete-inbox", "beta", true) + if err := s.DeletePrompt(deleteID); err != nil { + t.Fatal(err) + } + check(deleteSyncID, "", "", "", false) +} + +func TestLocalPromptCreationIdentityDuplicateSyncID(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("session", "alpha", "/work"); err != nil { + t.Fatal(err) + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "session", Project: "beta", SourceInboxID: "key", Content: "first"}) + 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) + } + if _, err := s.DB().Exec(`INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES (?,'session','duplicate','beta','second')`, syncID); err != nil { + t.Fatal(err) + } + session, inbox, project, eligible, err := s.LocalPromptCreationIdentity(syncID) + if err != nil || session != "" || inbox != "" || project != "" || eligible { + t.Fatalf("duplicate promoted identity: %q %q %q %v %v", session, inbox, project, eligible, err) + } +} + +func TestLocalPromptCreationIdentityMigration(t *testing.T) { + cfg := FallbackConfig(t.TempDir()) + s, err := New(cfg) + if err != nil { + t.Fatal(err) + } + if _, err = s.DB().Exec(`INSERT INTO sessions(id,project,directory) VALUES ('old','alpha','/work')`); err != nil { + t.Fatal(err) + } + if _, err = s.DB().Exec(`INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('old-prompt','old','key','beta','old')`); err != nil { + t.Fatal(err) + } + if _, err = s.DB().Exec(`ALTER TABLE user_prompts DROP COLUMN local_creation_session_id`); err != nil { + t.Fatal(err) + } + if _, _, _, _, err = s.LocalPromptCreationIdentity("old-prompt"); err == nil { + t.Fatal("missing column should fail closed") + } + if err = s.Close(); err != nil { + t.Fatal(err) + } + s, err = New(cfg) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := s.Close(); err != nil { + t.Error(err) + } + }() + session, inbox, project, eligible, err := s.LocalPromptCreationIdentity("old-prompt") + if err != nil || session != "" || inbox != "" || project != "" || eligible { + t.Fatalf("migration: %q %q %q %v %v", session, inbox, project, eligible, err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index b1aeaedf0..5d04f9ce5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1254,6 +1254,9 @@ func (s *Store) migrate() error { id INTEGER PRIMARY KEY AUTOINCREMENT, sync_id TEXT, source_inbox_id TEXT, + local_creation_session_id TEXT, + local_creation_inbox_id TEXT, + local_creation_project TEXT, session_id TEXT NOT NULL, content TEXT NOT NULL, project TEXT, @@ -1410,6 +1413,11 @@ func (s *Store) migrate() error { if err := s.addColumnIfNotExists("user_prompts", "source_inbox_id", "TEXT"); err != nil { return err } + for _, column := range []string{"local_creation_session_id", "local_creation_inbox_id", "local_creation_project"} { + if err := s.addColumnIfNotExists("user_prompts", column, "TEXT"); err != nil { + return err + } + } if err := s.addColumnIfNotExists("prompt_tombstones", "source_inbox_id", "TEXT"); err != nil { return err } @@ -3854,12 +3862,17 @@ func (s *Store) AddPromptWithResult(p AddPromptParams) (int64, bool, error) { } } syncID := newSyncID("prompt") - query := `INSERT INTO user_prompts (sync_id, session_id, content, project, source_inbox_id) VALUES (?, ?, ?, ?, ?)` + query := `INSERT INTO user_prompts (sync_id, session_id, content, project, source_inbox_id, local_creation_session_id, local_creation_inbox_id, local_creation_project) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` if p.SourceInboxID != "" { query += ` ON CONFLICT(session_id, source_inbox_id) WHERE source_inbox_id IS NOT NULL DO NOTHING` } + var creationSession, creationProject any + if p.SourceInboxID != "" { + creationSession, creationProject = p.SessionID, nullableString(p.Project) + } res, err := s.execHook(tx, query, - syncID, p.SessionID, content, nullableString(p.Project), nullableString(p.SourceInboxID)) + syncID, p.SessionID, content, nullableString(p.Project), nullableString(p.SourceInboxID), + creationSession, nullableString(p.SourceInboxID), creationProject) if err != nil { return err } @@ -3899,6 +3912,38 @@ func (s *Store) AddPromptWithResult(p AddPromptParams) (int64, bool, error) { return promptID, inserted, nil } +// LocalPromptCreationIdentity returns a live locally inserted keyed identity by sync ID. +// Unknown, ambiguous and mismatched identities fail closed; deleted rows have no fallback. +func (s *Store) LocalPromptCreationIdentity(syncID string) (session, inbox, project string, eligible bool, err error) { + if syncID == "" { + return "", "", "", false, nil + } + rows, err := s.db.Query(`SELECT local_creation_session_id, local_creation_inbox_id, local_creation_project, + ifnull(session_id,''), ifnull(source_inbox_id,''), ifnull(project,'') FROM user_prompts WHERE sync_id=? LIMIT 2`, syncID) + if err != nil { + return "", "", "", false, err + } + defer func() { _ = rows.Close() }() + if !rows.Next() { + return "", "", "", false, rows.Err() + } + var originalSession, originalInbox, originalProject sql.NullString + if err := rows.Scan(&originalSession, &originalInbox, &originalProject, &session, &inbox, &project); err != nil { + return "", "", "", false, err + } + if rows.Next() { + return "", "", "", false, nil + } + if err := rows.Err(); err != nil { + return "", "", "", false, err + } + if originalSession.String == "" || originalInbox.String == "" || originalProject.String == "" || + originalSession.String != session || originalInbox.String != inbox || originalProject.String != project { + return "", "", "", false, nil + } + return session, inbox, project, true, nil +} + func (s *Store) AddPromptIfMissing(p AddPromptParams) (int64, bool, error) { p.Project, _ = NormalizeProject(p.Project) content, _ := s.prepareStoredContent(p.Content)