diff --git a/DOCS.md b/DOCS.md index 20a874d7a..be4a82118 100644 --- a/DOCS.md +++ b/DOCS.md @@ -225,6 +225,16 @@ Engram exposes two different runtimes. Keep routes split by runtime: - **Cloud runtime (`engram cloud serve`)** - `GET /health` (cloud service health) - `GET /sync/pull`, `GET /sync/pull/{chunkID}`, `POST /sync/push`, `POST /sync/mutations/push`, `GET /sync/mutations/pull` (cloud sync transport) + - `POST /sync/session-authorities` registers a session explicitly with JSON `session_id` and owner `project`. + Managed principals need a project grant; legacy authenticated tokens use the configured allowlist. + Grant normalization does not change the stored owner: a grant for `alpha-foo` can authorize `alpha/foo`, whose owner remains `alpha/foo`. + In insecure no-auth mode registration returns 401 without writing; oversized JSON returns 413. + Chunks and imported sessions never bootstrap registration: an imported/offline session needs deliberate reauthorization. + Matching owner replay returns 200; conflicting owner returns 409. Registration alone does not authorize prompt claims or deletes. + - `POST /sync/prompt-pair-claims` requires a bearer token and JSON `session_id`, `source_inbox_id`, `sync_id`, `owner_project`, and `project` (prompt storage project). + `owner_project` is an authorization selector, **not** authority: the server checks grants/legacy allowlist for both projects before looking up the independently registered session, then requires its stored owner to match the selector. Grant aliases do not rewrite stored project identity. + Missing registration, owner mismatch, and authority disappearance during claim all return the same generic 404 JSON body with `error_code: session_authority_unavailable`; an absent old-server route returns a 404 without that code. Matching claim replay returns 200, competing pair binding 409. Invalid JSON/unknown fields return 400, oversized bodies 413, and insecure no-auth mode 401. + `MutationTransport.RegisterSessionAuthority` and `ClaimPromptPair` can POST these JSON requests over the configured transport; non-200 responses are errors, with an absent old-server route classified as `server_unsupported`. Autosync does not invoke these methods yet. Clients must explicitly register the session under its owner and claim the pair under dual authorization before relying on remote pair provenance. Delete enforcement and the client handshake/pending retry path are not implemented yet; this route alone does not make unverified deletes safe. - `GET /dashboard/*` HTML routes (browser dashboard) Dashboard route tree (`engram cloud serve`): @@ -780,7 +790,7 @@ Cloud server startup fails closed when the token is missing unless `ENGRAM_CLOUD Cloud server always requires `ENGRAM_CLOUD_ALLOWED_PROJECTS` (comma-separated), including insecure mode, so project scope remains server-enforced. `ENGRAM_CLOUD_TOKEN` + `ENGRAM_CLOUD_ALLOWED_PROJECTS` are server-side requirements for authenticated mode and must be configured before `engram cloud serve` (or compose startup). Authenticated mode also requires an explicit non-default `ENGRAM_JWT_SECRET`; implicit development defaults are rejected. -Dashboard requests support browser login in authenticated mode: use `/dashboard/login` to exchange the bearer token for an HttpOnly dashboard cookie scoped to `/dashboard`. Protected `/dashboard/*` HTML routes require that cookie and do **not** treat raw `Authorization: Bearer ...` headers as an authenticated browser session. Sync API routes (`/sync/pull`, `/sync/pull/{chunkID}`, `/sync/push`, `/sync/mutations/push`, `/sync/mutations/pull`) remain header-auth only. For cloud authenticated sync and admin requests, the Authorization parser trims outer whitespace and requires exactly two whitespace-delimited fields: a case-insensitive `Bearer` scheme and one credential. Tabs or multiple spaces between fields are accepted; whitespace embedded in the credential and a scheme glued to the credential are rejected. This describes field separation, not an RFC credential-character grammar; it does not describe the local `ENGRAM_HTTP_TOKEN` parser. In insecure mode (`ENGRAM_CLOUD_INSECURE_NO_AUTH=1` + no `ENGRAM_CLOUD_TOKEN`), dashboard auth is bypassed and `/dashboard/login` redirects to `/dashboard/`. +Dashboard requests support browser login in authenticated mode: use `/dashboard/login` to exchange the bearer token for an HttpOnly dashboard cookie scoped to `/dashboard`. Protected `/dashboard/*` HTML routes require that cookie and do **not** treat raw `Authorization: Bearer ...` headers as an authenticated browser session. Sync API routes (`/sync/pull`, `/sync/pull/{chunkID}`, `/sync/push`, `/sync/mutations/push`, `/sync/mutations/pull`, `/sync/session-authorities`, `/sync/prompt-pair-claims`) remain header-auth only. For cloud authenticated sync and admin requests, the Authorization parser trims outer whitespace and requires exactly two whitespace-delimited fields: a case-insensitive `Bearer` scheme and one credential. Tabs or multiple spaces between fields are accepted; whitespace embedded in the credential and a scheme glued to the credential are rejected. This describes field separation, not an RFC credential-character grammar; it does not describe the local `ENGRAM_HTTP_TOKEN` parser. In insecure mode (`ENGRAM_CLOUD_INSECURE_NO_AUTH=1` + no `ENGRAM_CLOUD_TOKEN`), dashboard auth is bypassed and `/dashboard/login` redirects to `/dashboard/`. `ENGRAM_CLOUD_ADMIN` is optional in authenticated mode. Its sessions can access the existing dashboard admin read surfaces, project sync controls, and audit logs, but managed-user, token, and project-grant mutations require a managed admin token. `ENGRAM_CLOUD_ADMIN` is rejected in insecure mode (`ENGRAM_CLOUD_INSECURE_NO_AUTH=1`) to avoid an incoherent admin/browser auth path. diff --git a/cmd/engram/autosync_e2e_test.go b/cmd/engram/autosync_e2e_test.go index daadf4050..e4fa5b718 100644 --- a/cmd/engram/autosync_e2e_test.go +++ b/cmd/engram/autosync_e2e_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "reflect" "sync" "sync/atomic" "testing" @@ -18,6 +19,49 @@ import ( _ "modernc.org/sqlite" ) +func TestMutationTransportAdapterForwardsPromptAuthority(t *testing.T) { + var paths []string + var bodies []map[string]string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil || r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("unexpected request security or method: TLS=%v method=%s auth=%q", r.TLS != nil, r.Method, r.Header.Get("Authorization")) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + paths = append(paths, r.URL.Path) + bodies = append(bodies, body) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer srv.Close() + trustTLSServer(t, srv) + + mt, err := remote.NewMutationTransport(srv.URL, "test-token") + if err != nil { + t.Fatal(err) + } + adapter := &mutationTransportAdapter{remote: mt} + if err := adapter.RegisterSessionAuthority("session-1", "alpha"); err != nil { + t.Fatal(err) + } + if err := adapter.ClaimPromptPair("session-1", "inbox-2", "sync-3", "alpha", "beta"); err != nil { + t.Fatal(err) + } + wantPaths := []string{"/sync/session-authorities", "/sync/prompt-pair-claims"} + wantBodies := []map[string]string{ + {"session_id": "session-1", "project": "alpha"}, + {"session_id": "session-1", "source_inbox_id": "inbox-2", "sync_id": "sync-3", "owner_project": "alpha", "project": "beta"}, + } + if !reflect.DeepEqual(paths, wantPaths) || !reflect.DeepEqual(bodies, wantBodies) { + t.Fatalf("requests: paths=%v bodies=%v; want paths=%v bodies=%v", paths, bodies, wantPaths, wantBodies) + } +} + // ─── E2E Round-trip test (REQ-212) ─────────────────────────────────────────── // TestAutosyncPushPullRoundTrip tests the full push/pull cycle using a real @@ -305,6 +349,33 @@ func (s *autosyncFakeStore) ListPendingSyncMutations(_ string, limit int) ([]sto return result, nil } +func (s *autosyncFakeStore) MaxPendingSyncMutationSeq(string) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + var max int64 + for _, mutation := range s.mutations { + max = mutation.seq + } + return max, nil +} + +func (s *autosyncFakeStore) ListPendingSyncMutationsAfterSeq(target string, after int64, limit int) ([]store.SyncMutation, error) { + pending, err := s.ListPendingSyncMutations(target, len(s.mutations)) + if err != nil { + return nil, err + } + var page []store.SyncMutation + for _, mutation := range pending { + if mutation.Seq > after { + page = append(page, mutation) + if len(page) == limit { + break + } + } + } + return page, nil +} + func (s *autosyncFakeStore) CountPendingNonEnrolledSyncMutations(_ string) ([]store.PendingSyncMutationProjectCount, error) { return nil, nil } diff --git a/cmd/engram/cloud.go b/cmd/engram/cloud.go index b12ab1127..691012bae 100644 --- a/cmd/engram/cloud.go +++ b/cmd/engram/cloud.go @@ -222,12 +222,12 @@ var runUpgradeRemirror = func(s *store.Store, project string, cc *cloudconfig.Co func cmdCloud(cfg store.Config) { if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: engram cloud [options]") - fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap") + fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap, attest-prompt-source") exitFunc(1) } if os.Args[2] == "--help" || os.Args[2] == "-h" || os.Args[2] == "help" { fmt.Println("usage: engram cloud [options]") - fmt.Println("supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap") + fmt.Println("supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap, attest-prompt-source") return } @@ -248,9 +248,11 @@ func cmdCloud(cfg store.Config) { cmdCloudRepair() case "bootstrap": cmdCloudBootstrap() + case "attest-prompt-source": + cmdCloudAttestPromptSource(cfg) default: fmt.Fprintf(os.Stderr, "unknown cloud command: %s\n", os.Args[2]) - fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap") + fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, unenroll, config, serve, upgrade, repair, bootstrap, attest-prompt-source") exitFunc(1) } } diff --git a/cmd/engram/cloud_prompt_source_attestation.go b/cmd/engram/cloud_prompt_source_attestation.go new file mode 100644 index 000000000..694c32016 --- /dev/null +++ b/cmd/engram/cloud_prompt_source_attestation.go @@ -0,0 +1,119 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/url" + "os" + "strings" + + "github.com/Gentleman-Programming/engram/v2/internal/cloud/remote" + "github.com/Gentleman-Programming/engram/v2/internal/cloudconfig" + "github.com/Gentleman-Programming/engram/v2/internal/store" +) + +const promptSourceAttestationUsage = "usage: engram cloud attest-prompt-source --sync-id --owner-project " + +type promptSourceAttester interface { + AttestPromptSource(sessionID, sourceInboxID, syncID, ownerProject, promptProject string) (int64, error) +} + +// The preview is observed data; the owner is a separate human assertion. +func attestPromptSource(s *store.Store, syncID, owner, endpoint string, remote promptSourceAttester, input io.Reader, output io.Writer) error { + preview, found, err := s.PreviewPromptSource(syncID) + if err != nil { + return fmt.Errorf("prompt source preview failed: %w", err) + } + if !found { + return fmt.Errorf("no unambiguous complete prompt source preview for exact sync ID") + } + if _, err := fmt.Fprintf(output, "Observed source: session=%q source_inbox=%q sync_id=%q prompt_project=%q kind=%q\nHuman-asserted owner: %q\n", preview.SessionID, preview.SourceInboxID, preview.SyncID, preview.Project, preview.Kind, owner); err != nil { + return fmt.Errorf("attestation preview output failed: %w", err) + } + if _, err := fmt.Fprint(output, "Send this exact tuple for remote attestation? Type yes or no: "); err != nil { + return fmt.Errorf("attestation confirmation question output failed: %w", err) + } + line, err := bufio.NewReader(input).ReadString('\n') + if err != nil && err != io.EOF { + return fmt.Errorf("confirmation input failed: %w", err) + } + if strings.TrimSpace(line) != "yes" { + if strings.TrimSpace(line) == "no" { + return fmt.Errorf("attestation declined; no remote call made") + } + return fmt.Errorf("invalid confirmation; type yes or no; no remote call made") + } + id, err := remote.AttestPromptSource(preview.SessionID, preview.SourceInboxID, preview.SyncID, owner, preview.Project) + if err != nil { + return fmt.Errorf("remote attestation failed: %w", err) + } + if id <= 0 { + return fmt.Errorf("remote attestation returned no positive ID; local confirmation not saved") + } + if err := s.ConfirmPromptSourceAttestation(endpoint, preview, owner, id); err != nil { + return fmt.Errorf("remote attestation succeeded (ID %d), but local confirmation failed: %w; do not assume local authorization", id, err) + } + if _, err := fmt.Fprintf(output, "Remote attestation ID %d confirmed locally for sync ID %q\n", id, syncID); err != nil { + return fmt.Errorf("remote attestation confirmed locally, but success output failed: %w", err) + } + return nil +} + +// Validate the endpoint before creating transport or persisting it as remote_target. +func promptSourceAttestationEndpoint(raw string) (string, error) { + endpoint, err := cloudconfig.ValidateServerURL(raw) + if err != nil { + return "", fmt.Errorf("invalid cloud server URL") + } + parsed, err := url.Parse(endpoint) + if err != nil || parsed.User != nil { + return "", fmt.Errorf("cloud server URL credentials are not allowed") + } + return endpoint, nil +} + +func cmdCloudAttestPromptSource(cfg store.Config) { + args := os.Args[3:] + if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") { + fmt.Println(promptSourceAttestationUsage) + return + } + if len(args) != 4 || args[0] != "--sync-id" || args[2] != "--owner-project" || strings.TrimSpace(args[1]) == "" || strings.TrimSpace(args[3]) == "" { + fmt.Fprintln(os.Stderr, promptSourceAttestationUsage) + fatal(fmt.Errorf("one exact sync ID and an asserted owner project are required")) + return + } + cc, err := resolveCloudRuntimeConfig(cfg) + if err != nil { + fatal(fmt.Errorf("cloud runtime configuration unavailable")) + return + } + if cc == nil || cc.ServerURL == "" { + fatal(fmt.Errorf("cloud server URL is required")) + return + } + endpoint, err := promptSourceAttestationEndpoint(cc.ServerURL) + if err != nil { + fatal(err) + return + } + if strings.TrimSpace(cc.Token) == "" { + fatal(fmt.Errorf("human bearer token required for remote dual-grant enforcement")) + return + } + transport, err := remote.NewMutationTransport(endpoint, cc.Token) + if err != nil { + fatal(fmt.Errorf("cloud transport configuration rejected")) + return + } + s, err := storeNew(cfg) + if err != nil { + fatal(fmt.Errorf("local store unavailable: %w", err)) + return + } + defer func() { _ = s.Close() }() + if err := attestPromptSource(s, args[1], args[3], endpoint, transport, os.Stdin, os.Stdout); err != nil { + fatal(err) + } +} diff --git a/cmd/engram/cloud_prompt_source_attestation_test.go b/cmd/engram/cloud_prompt_source_attestation_test.go new file mode 100644 index 000000000..19ca1521b --- /dev/null +++ b/cmd/engram/cloud_prompt_source_attestation_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/Gentleman-Programming/engram/v2/internal/store" +) + +type fakePromptAttester struct { + calls int + id int64 + err error +} + +func (f *fakePromptAttester) AttestPromptSource(session, inbox, syncID, owner, project string) (int64, error) { + f.calls++ + if session != "session" || inbox != "inbox" || syncID != "exact" || owner != "owner" || project != "prompt" { + return 0, errors.New("wrong tuple") + } + return f.id, f.err +} + +type failingPromptWriter struct { + calls int + failAt int +} + +func (w *failingPromptWriter) Write(p []byte) (int, error) { + w.calls++ + if w.calls == w.failAt { + return 0, io.ErrClosedPipe + } + return len(p), nil +} + +func TestPromptSourceAttestationNoPreview(t *testing.T) { + s, err := store.New(testConfig(t)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + remote := &fakePromptAttester{id: 42} + var out bytes.Buffer + err = attestPromptSource(s, "missing", "owner", "https://cloud.example.test", remote, strings.NewReader("yes\n"), &out) + if err == nil || !strings.Contains(err.Error(), "no unambiguous") || remote.calls != 0 || out.Len() != 0 { + t.Fatalf("error=%v calls=%d output=%q", err, remote.calls, out.String()) + } +} + +func TestPromptSourceAttestationOutputFailures(t *testing.T) { + for _, tc := range []struct { + name string + failAt int + wantCalls int + wantMarkers int + }{ + {"preview", 1, 0, 0}, + {"question", 2, 0, 0}, + {"success", 3, 1, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + s, err := store.New(testConfig(t)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if err := s.CreateSession("session", "owner", "/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 ('exact','session','inbox','prompt','body')`); err != nil { + t.Fatal(err) + } + remote := &fakePromptAttester{id: 42} + out := &failingPromptWriter{failAt: tc.failAt} + err = attestPromptSource(s, "exact", "owner", "https://cloud.example.test", remote, strings.NewReader("yes\n"), out) + if !errors.Is(err, io.ErrClosedPipe) || remote.calls != tc.wantCalls { + t.Fatalf("error=%v remote calls=%d want %d", err, remote.calls, tc.wantCalls) + } + var count int + if err := s.DB().QueryRow(`SELECT count(*) FROM prompt_source_confirmations`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != tc.wantMarkers { + t.Fatalf("markers=%d want %d", count, tc.wantMarkers) + } + }) + } +} + +func TestPromptSourceAttestationEndpointRejectsUserinfo(t *testing.T) { + for _, raw := range []string{"https://user:secret@cloud.example.test", "https://user@cloud.example.test"} { + _, err := promptSourceAttestationEndpoint(raw) + if err == nil || strings.Contains(err.Error(), "user") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), raw) { + t.Fatalf("endpoint error unsafe or missing: %v", err) + } + } + if endpoint, err := promptSourceAttestationEndpoint("https://cloud.example.test"); err != nil || endpoint != "https://cloud.example.test" { + t.Fatalf("valid endpoint=%q error=%v", endpoint, err) + } +} + +func TestPromptSourceAttestationConfirmation(t *testing.T) { + for _, tc := range []struct { + name, answer string + id int64 + remoteErr error + wantErr string + calls int + }{ + {"accepted", "yes", 42, nil, "", 1}, + {"declined", "no", 42, nil, "declined", 0}, + {"invalid", "maybe", 42, nil, "confirmation", 0}, + {"remote failure", "yes", 0, errors.New("denied"), "remote attestation failed", 1}, + {"no positive ID", "yes", 0, nil, "positive", 1}, + {"local owner conflict after remote success", "yes", 42, nil, "remote attestation succeeded (ID 42), but local confirmation failed", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := testConfig(t) + s, err := store.New(cfg) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if err := s.CreateSession("session", "owner", "/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 ('exact','session','inbox','prompt','body')`); err != nil { + t.Fatal(err) + } + if tc.name == "local owner conflict after remote success" { + if _, err := s.DB().Exec(`UPDATE sessions SET project='different' WHERE id='session'`); err != nil { + t.Fatal(err) + } + } + remote := &fakePromptAttester{id: tc.id, err: tc.remoteErr} + var out bytes.Buffer + err = attestPromptSource(s, "exact", "owner", "https://cloud.example.test", remote, strings.NewReader(tc.answer+"\n"), &out) + if (err == nil) != (tc.wantErr == "") || tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err=%v want %q", err, tc.wantErr) + } + if remote.calls != tc.calls { + t.Fatalf("remote calls=%d want %d", remote.calls, tc.calls) + } + for _, want := range []string{"session", "inbox", "exact", "prompt", "live", "Human-asserted owner: \"owner\""} { + if !strings.Contains(out.String(), want) { + t.Errorf("preview %q missing %q", out.String(), want) + } + } + var count int + if err := s.DB().QueryRow(`SELECT count(*) FROM prompt_source_confirmations WHERE remote_target=? AND sync_id='exact'`, "https://cloud.example.test").Scan(&count); err != nil { + t.Fatal(err) + } + expected := 0 + if tc.wantErr == "" { + expected = 1 + } + if count != expected { + t.Fatalf("confirmations=%d want %d", count, expected) + } + }) + } +} diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 18b13983c..81edee472 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -316,6 +316,14 @@ type mutationTransportAdapter struct { remote *remote.MutationTransport } +func (a *mutationTransportAdapter) RegisterSessionAuthority(sessionID, ownerProject string) error { + return a.remote.RegisterSessionAuthority(sessionID, ownerProject) +} + +func (a *mutationTransportAdapter) ClaimPromptPair(sessionID, inboxID, syncID, ownerProject, promptProject string) error { + return a.remote.ClaimPromptPair(sessionID, inboxID, syncID, ownerProject, promptProject) +} + func (a *mutationTransportAdapter) PushMutations(entries []autosync.MutationEntry) (*autosync.PushMutationsResult, error) { remoteEntries := make([]remote.MutationEntry, len(entries)) for i, e := range entries { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8884cb4e9..e18e8eef2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,8 @@ # 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), and appends a canonical local upsert for enrolled projects, leaving earlier journal entries unchanged so acknowledgement of an in-flight original cannot consume the adopted identity. 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. Any pulled delete claiming a session-and-inbox pair held by another live prompt is quarantined before a tombstone is recorded, even when its sync ID resolves to a live prompt. Backup import rejects a tombstone that would delete a matched live prompt belonging to a different effective project; the import rolls back atomically. 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 quarantine an inbox ID without a nonblank session ID as dead-letter evidence and advance the pull cursor without writing a tombstone; 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 their own established prompt tombstone, 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. Once a tombstone establishes a session ID, inbox ID, or nonempty project for a sync ID, later deletes and backup imports cannot change that component: conflicting pulled deletes are quarantined with their raw evidence and advance the cursor, while conflicting imports fail atomically. Projects are compared after normalization; sparse legacy repeats retain established components, and missing components may be filled consistently. + - [How It Works](#how-it-works) - [Session Lifecycle](#session-lifecycle) - [MCP Tools](#mcp-tools) diff --git a/docs/codebase/prompt-inbox-provenance.md b/docs/codebase/prompt-inbox-provenance.md new file mode 100644 index 000000000..baebc137a --- /dev/null +++ b/docs/codebase/prompt-inbox-provenance.md @@ -0,0 +1,47 @@ +# RFC: authenticated prompt inbox provenance before remote deletion + +**Status: proposed end-to-end contract; cloud registration, pair-claim and explicit source-attestation routes exist, local origin marking and eligible-local-origin autosync preflight are implemented, but no verified cloud delete gate exists.** This RFC defines the minimum non-cryptographic authority needed before #1464 can enter the merge queue. The RFC alone does not establish end-to-end enforcement. The baseline is tracker `313f5269`; the review thread on #1464 records the hold. #1240 is separate. + +## Decision in one minute + +Local SQLite remains authoritative for local writes; cloud is a replication boundary. A cloud writer's authorization for prompt project `beta` alone cannot establish that its claimed `(session_id, source_inbox_id)` belongs to an `alpha` session. Neither accepted prompt history nor a cloud session index built from uploaded chunks is independent proof. Remote pair reservation requires an explicit, authenticated session registration and an immutable cross-project pair claim. A missing claim is pending/fail-loud, not an authoritative remote tombstone. + +This is a server-enforced authorization contract, not a new inference rule based on `project = session.project`. Legitimate beta prompts under alpha sessions must remain possible. + +## Authority and lifecycle (registration and claim routes exist; delete gate pending) + +| Step | Required authority and durable effect | +| --- | --- | +| Register session | An authenticated principal authorized for owner project `alpha` explicitly registers `session_id` as a globally unique session identity owned by `alpha`. Registration records the authorized principal/context and owner project independently of chunks and prompt mutations. An identical replay is idempotent; a different owner or incompatible registration for the same ID is a conflict, never a reassignment. The registry must not be populated from `cloud_project_sessions`, uploaded chunks, imported history, or a prompt upsert/delete. | +| Claim prompt pair | For a beta prompt referencing an alpha session, the claimant must hold authorization for **both** alpha and beta at claim time. Registration must already be verified. An authorized claim durably binds `(session_id, source_inbox_id)` to `(sync_id, beta)`; matching replay is idempotent and any competing sync ID or project is a conflict. Claims cannot bootstrap registration or be inferred from a beta upsert. Same-project claims still require session-owner and prompt-project authority; no special weaker bootstrap. | +| Apply verified delete | A delete may reserve or replay a remote pair only against the existing verified binding, with beta authorization for the mutation. It need not require renewed alpha authorization: alpha consent was checked at the original claim. Validate sync ID, session ID, inbox ID and beta project against that binding. A beta-only writer cannot create or change the claim, even by sending an upsert followed by a delete; an alpha-only writer cannot claim/delete beta. | + +Registration and claim are explicit authenticated server operations (`POST /sync/session-authorities` and `POST /sync/prompt-pair-claims`); autosync now performs the handshake only for independently eligible local keyed prompt mutations. The verify-only `POST /sync/prompt-source-attestations/verify` accepts JSON `audit_id` (positive integer), `session_id`, `source_inbox_id`, `sync_id`, `owner_project` (the asserted owner), and `prompt_project` (the observed prompt project). An identified authenticated human or service principal must have **current grants to both projects** before the global audit row is read. Only an exact match of the audit ID and all five binding fields returns `200` with `{"status":"ok","attestation_id":}`. Missing or mismatched rows return `409`; absent grants return `403`, missing identity `401`, invalid or trailing input `400`, oversized input `413`, and storage failures `500`. Verification does not write registration, claims, or attestations and does not authorize a delete. For older sources, `POST /sync/prompt-source-attestations` accepts `session_id`, `source_inbox_id`, `sync_id`, `owner_project`, and `prompt_project` from an authenticated human principal with current grants to both projects. It registers the owner and claims the exact pair before appending an attestation attributed to that principal. The route records a present-day assertion, **not** proof of historical creation; a failed final append does not attest the source even if registration or claim succeeded. Attestation does not admit remote deletes or authorize local origin markers. The verified-delete gate and client-side use of explicit old/imported source reauthorization remain pending. Atomic uniqueness and conflict checks must survive concurrent retries. Authentication means server-verified principal and project grants, not fields supplied in the payload; no cryptographic offline capability is assumed. Revocation after a verified claim does not erase that historical binding, but current beta mutation authorization remains necessary. Idless legacy prompts remain pair-less and must not acquire a source inbox binding through inference. + +## Unverified deletes and compatibility + +- A delete without a proven binding, whether offline, delivered before registration/claim, imported, or from old history, must fail loudly or remain visibly pending. It must not create an authoritative remote tombstone, reserve a pair, or advance the **remote pull cursor as an authoritative delete**. Retrying after explicit authorization is allowed; transport and cursor handling must distinguish pending/unverified evidence from admitted mutations. Do not quietly acknowledge such a push as a successful delete. +- Preserve local-first deletion and its local tombstone. A local `POST /prompts` reusing a tombstoned pair continues to return 409 even after cloud reauthorization; reauthorization grants cloud provenance, not permission to reuse a deleted identity. Existing backup import skips prompts whose pair has a tombstone, and a backup file is not independent proof of pair ownership. Do not silently promote historical imported prompts or tombstones into verified cloud claims. +- An imported or offline alpha session needs explicit **source reauthorization under alpha** by a currently authorized principal before cloud registration, followed by dual alpha+beta authorization for a cross-project pair claim. A chunk, old cloud mutation, old project index, or pre-policy history cannot substitute. Existing keyed prompt claims and deletes lacking this evidence are unverified; attestation applies prospectively to the verified registration/claim, never retroactively proves an old event. +- Tradeoff: an offline prompt deleted before any successful sync cannot remotely reserve its inbox pair until an authorized pair claim is made. If the underlying prompt is no longer present, the client needs a deliberate, auditable claim of its retained local identity/tombstone, not a reconstructed cloud upsert. An imported backup cannot independently establish that authority. Keep unverified legacy state distinguishable from verified state in storage, export, diagnostics, and replay. +- Preserve existing visible sync policy: blocked sync is explicit rather than silently dropped; local tombstones survive retries; proven pair 409 replay protection, idless behavior, scoped project exports, and ordinary push/pull cursor and deferred/dead-letter semantics remain intact for admitted mutations. A protocol change must not strand unrelated admitted pull mutations behind an unverified historical record; quarantine/pending evidence is visible but never treated as an authoritative delete. + +## Acceptance matrix for implementation slices + +| Scenario | Expected outcome | +| --- | --- | +| Alpha-only, beta-only, dual grants | Alpha-only can register alpha but cannot claim/delete beta; beta-only cannot register or claim alpha, but may delete an already verified beta binding; dual grants can claim the registered cross-project pair. | +| Spoof upsert then delete | Beta-only upsert claiming alpha session/inbox cannot mint registration or pair proof; subsequent delete cannot reserve remotely, including when a chunk index contains the purported session. | +| Valid cross-project | Alpha registration, dual-authorized beta pair claim, then beta-authorized delete and restore/replay preserve the verified binding and scoped beta export. | +| Duplicate/conflict | Identical registration/claim/delete retry is idempotent; session owner collision, pair sync-ID/project collision, and mismatch on delete reject without changing binding, tombstone, or authoritative cursor. Concurrent claim retries have the same result. | +| Offline and out of order | Local tombstone persists; delete before registration/claim stays visibly pending without remote reservation or authoritative cursor advancement; explicit alpha reauthorization then dual claim permits retry. | +| Legacy and backup | Idless remains pair-less; pre-policy mutations/imported chunks remain unverified; backup import cannot authorize a claim, skips tombstoned prompt pairs locally, and later `POST /prompts` replay of that pair still yields 409 after reauthorization. | +| Existing policy | Blocked sync remains visible; proven tombstone replay and scoped exports work; admitted push/pull, deferred replay, and cursor progress retain their existing semantics. | + +## Reviewable delivery sequence + +Each PR is at most **400 authored diff lines**, includes its own tests/docs with behavior, and targets tracker #1464; no slice alone authorizes queueing. Slice 1 is this RFC only: **no behavior change**. Then separate bounded slices should establish (2) explicit authenticated session registration and persistence, (3) dual-authorized immutable pair claims and verified cloud mutation admission, and (4) local offline/import reauthorization, pending delete handling, cursor/export compatibility and end-to-end tests. Split a slice further if its end-to-end safety or line budget cannot fit; do not ship a server-only rejection before a viable client handshake. Validate exact integrated tracker head and review before reconsidering #1464. #1240 remains separate. + +## 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. 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. Explicit local `DeletePrompt` and `DeleteSession` retain the verified original triple in separate nullable tombstone columns in the same transaction; migration defaults to NULL. The exact-sync-ID lookup uses that triple only after no live row exists, and only if it agrees with the tombstone identity. Imported/pulled tombstones and tombstone replay cannot create or promote local origin. Ambiguous sync IDs, idless prompts and mismatched identities remain ineligible. 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. Autosync checks the exact journal sync ID, session, inbox and prompt project against independent locally recorded prompt origin and the live session's local creation owner; it registers that owner, then claims the pair before pushing a keyed prompt mutation. An unavailable capability, missing or mismatched origin, or failed registration/claim leaves the mutation pending and unpushed; other eligible entries in the same project batch may still push and ack independently. When all push failures are deterministic local origin or owner denials, autosync still pulls admitted inbound mutations and advances their cursor while retaining a persisted degraded outbound status (`prompt_provenance_blocked`), never acknowledging blocked entries or marking sync healthy. Remote registration/claim errors, local provenance read errors, and mixed push failures involving transport or other unknown failures still skip pull. This inbound progress does not authorize the blocked prompt or its remote delete. Idless prompts do not claim pairs. A locally deleted session has no live session provenance lookup, so its prompt tombstone remains pending even if its prompt origin is marked; T4b3b or a separately reviewed locally proven session-tombstone mechanism must address it, never project inference. This is a client preflight only: old/imported sources lack explicit reauthorization, and the cloud still does **not** enforce verified delete admission. diff --git a/docs/engram-cloud/quickstart.md b/docs/engram-cloud/quickstart.md index 507f57805..b18537db7 100644 --- a/docs/engram-cloud/quickstart.md +++ b/docs/engram-cloud/quickstart.md @@ -246,6 +246,16 @@ engram sync --cloud --project my-project --- +## Confirm one prompt source (human-token only) + +For an existing local prompt, use its **exact** sync ID and explicitly assert the owner project: + +```bash +engram cloud attest-prompt-source --sync-id --owner-project +``` + +Review the observed session, source inbox, prompt project, sync ID, and live/deleted kind alongside the separately labeled human-asserted owner. Type `yes` to send that tuple; `no`, invalid input, or a missing/ambiguous local preview sends nothing. A configured human bearer token is required; the remote service enforces both project grants. Only a successful remote attestation with a positive ID is recorded locally against the effective validated cloud endpoint. This command does not import, pull, or delete data. + ## Common Failure Reasons | Reason code | Meaning | diff --git a/docs/engram-cloud/troubleshooting.md b/docs/engram-cloud/troubleshooting.md index 58384719e..d60bb0cc1 100644 --- a/docs/engram-cloud/troubleshooting.md +++ b/docs/engram-cloud/troubleshooting.md @@ -65,6 +65,10 @@ engram cloud config --clear `cloud config --clear` clears only the persisted `cloud.json` server URL and token. Active `ENGRAM_CLOUD_SERVER` and `ENGRAM_CLOUD_TOKEN` overrides remain effective and are reported by the command and status output; unset them separately when you need them inactive. +## Prompt source attestation did not complete + +`engram cloud attest-prompt-source --sync-id --owner-project ` requires a configured cloud endpoint and human bearer token. If the local preview is absent or ambiguous, verify the exact sync ID; no remote request is sent. A declined or invalid confirmation also sends nothing. A remote denial can mean either project grant is missing: check both grants with your cloud administrator rather than treating the prompt project as ownership proof. If the remote call succeeds but local confirmation fails, the error includes the remote attestation ID; **do not assume local authorization**. Resolve the local mismatch and retry deliberately. No import, pull, or deletion is part of this command. + ## Cloud project was recreated or deleted When a project's cloud data was deleted or recreated while the correct local data is already acknowledged, replay the current local project state with: diff --git a/internal/cloud/autosync/manager.go b/internal/cloud/autosync/manager.go index af3f3f867..197aa0f99 100644 --- a/internal/cloud/autosync/manager.go +++ b/internal/cloud/autosync/manager.go @@ -106,6 +106,39 @@ type irreparableSyncMutationQuarantiner interface { QuarantineIrreparableSyncMutations(targetKey, project string, apply bool) (store.SyncMutationQuarantineReport, error) } +// promptPreflightError means this entry was rejected before transport and remains pending. +type promptPreflightError struct{ err error } + +func (e *promptPreflightError) Error() string { return e.err.Error() } +func (e *promptPreflightError) Unwrap() error { return e.err } + +// Only a tree made entirely of known per-entry denials may bypass the push gate. +func safeOutboundBlock(err error) bool { + if err == nil { + return false + } + switch e := err.(type) { + case *nonEnrolledPendingError, *promptPreflightError: + return true + case interface{ Unwrap() []error }: + children := e.Unwrap() + if len(children) == 0 { + return false + } + for _, child := range children { + if !safeOutboundBlock(child) { + return false + } + } + return true + default: + if inner := errors.Unwrap(err); inner != nil { + return safeOutboundBlock(inner) + } + return false + } +} + type nonEnrolledPendingError struct { counts []store.PendingSyncMutationProjectCount } @@ -120,6 +153,23 @@ type CloudTransport interface { PullMutations(sinceSeq int64, limit int) (*PullMutationsResponse, error) } +// Provenance capabilities are optional for legacy transports/stores, but keyed +// prompts fail closed when either capability is unavailable. +type pendingMutationPager interface { + ListPendingSyncMutationsAfterSeq(targetKey string, afterSeq int64, limit int) ([]store.SyncMutation, error) + MaxPendingSyncMutationSeq(targetKey string) (int64, error) +} + +type localPromptProvenance interface { + LocalSessionProvenance(id string) (owner string, eligible bool, err error) + LocalPromptCreationIdentity(syncID string) (session, inbox, project string, eligible bool, err error) +} + +type promptAuthorityTransport interface { + RegisterSessionAuthority(sessionID, ownerProject string) error + ClaimPromptPair(sessionID, sourceInboxID, syncID, ownerProject, promptProject string) error +} + // transportStatusError is an optional interface that transport errors may implement. // BW5: Allows Manager to detect 401 (auth_required) vs 403 (policy_forbidden) // vs generic transport failures without importing the remote package. @@ -491,25 +541,28 @@ func (m *Manager) cycle(ctx context.Context) { m.leaseHeld = true m.mu.Unlock() - // Push, then pull. A typed non-enrollment block applies only to outbound - // mutations, so inbound replication can still progress without changing the - // final degraded state that explains the blocked outbound backlog. + // Only exclusively local per-entry preflight blocks can leave inbound + // replication independent of the outbound failure. if err := m.push(ctx); err != nil { - var blocked *nonEnrolledPendingError - if !errors.As(err, &blocked) { + if !safeOutboundBlock(err) { reasonCode := classifyTransportError(err) m.recordFailureWithReason(autosyncFailureMessage(m.cfg.TargetKey, fmt.Sprintf("push: %v", err), err), reasonCode) return } blockedMessage := err.Error() - m.recordBlocked(blockedMessage, constants.ReasonNonEnrolledPendingMutations) + blockedReason := constants.ReasonNonEnrolledPendingMutations + var nonEnrolled *nonEnrolledPendingError + if !errors.As(err, &nonEnrolled) { + blockedReason = "prompt_provenance_blocked" + } + m.recordBlocked(blockedMessage, blockedReason) if err := m.pullPreservingSyncState(ctx); err != nil { reasonCode := classifyTransportError(err) m.recordFailureWithReason(autosyncFailureMessage(m.cfg.TargetKey, fmt.Sprintf("pull: %v", err), err), reasonCode) return } - if err := m.recordBlockedAfterSuccess(blockedMessage, constants.ReasonNonEnrolledPendingMutations); err != nil { + if err := m.recordBlockedAfterSuccess(blockedMessage, blockedReason); err != nil { reasonCode := classifyTransportError(err) m.recordFailureWithReason(autosyncFailureMessage(m.cfg.TargetKey, fmt.Sprintf("persist blocked state after successful pull: %v", err), err), reasonCode) } @@ -618,11 +671,47 @@ func (m *Manager) push(ctx context.Context) error { } } - pending, err := m.store.ListPendingSyncMutations(m.cfg.TargetKey, m.cfg.PushBatchSize) + pager, ok := m.store.(pendingMutationPager) + if !ok { + return fmt.Errorf("bounded pending mutation pagination unavailable") + } + // Snapshot the eligible journal after repair: new enqueues belong to a later cycle. + highWater, err := pager.MaxPendingSyncMutationSeq(m.cfg.TargetKey) if err != nil { - return fmt.Errorf("list pending: %w", err) + return fmt.Errorf("read push high-water: %w", err) } - if len(pending) == 0 { + var failures []error + var afterSeq int64 + seen := false + for { + if err := ctx.Err(); err != nil { + return errors.Join(append(failures, err)...) + } + pending, err := pager.ListPendingSyncMutationsAfterSeq(m.cfg.TargetKey, afterSeq, m.cfg.PushBatchSize) + if err != nil { + return errors.Join(append(failures, fmt.Errorf("list pending: %w", err))...) + } + page := make([]store.SyncMutation, 0, len(pending)) + for _, mut := range pending { + if mut.Seq <= afterSeq { + return errors.Join(append(failures, fmt.Errorf("pending pagination did not advance"))...) + } + if mut.Seq > highWater { + break + } + page = append(page, mut) + afterSeq = mut.Seq + } + if len(page) == 0 { + break + } + seen = true + failures = append(failures, m.pushPage(ctx, page)...) + if len(pending) < m.cfg.PushBatchSize || afterSeq >= highWater { + break + } + } + if !seen { counts, err := m.store.CountPendingNonEnrolledSyncMutations(m.cfg.TargetKey) if err != nil { return fmt.Errorf("count pending non-enrolled mutations: %w", err) @@ -633,6 +722,10 @@ func (m *Manager) push(ctx context.Context) error { return nil } + return errors.Join(failures...) +} + +func (m *Manager) pushPage(ctx context.Context, pending []store.SyncMutation) []error { // Group by project (preserve order). Empty or padded project values are invalid // for cloud transport: never send them, but continue with healthy project groups. groups := make(map[string][]store.SyncMutation) @@ -653,22 +746,43 @@ func (m *Manager) push(ctx context.Context) error { for _, project := range order { if err := ctx.Err(); err != nil { failures = append(failures, err) - return errors.Join(failures...) + return failures } batch := groups[project] - entries := make([]MutationEntry, len(batch)) - seqs := make([]int64, len(batch)) - for i, mut := range batch { - entries[i] = MutationEntry{ - Project: mut.Project, - Entity: mut.Entity, - EntityKey: mut.EntityKey, - Op: mut.Op, - Payload: json.RawMessage(mut.Payload), + entries := make([]MutationEntry, 0, len(batch)) + seqs := make([]int64, 0, len(batch)) + for _, mut := range batch { + if mut.Entity == store.SyncEntityPrompt { + var identity struct { + SyncID string `json:"sync_id"` + Session string `json:"session_id"` + Inbox string `json:"source_inbox_id"` + Project string `json:"project"` + } + if err := json.Unmarshal([]byte(mut.Payload), &identity); err != nil { + failures = append(failures, fmt.Errorf("prompt seq %d: invalid identity: %w", mut.Seq, err)) + continue + } + if identity.SyncID == "" || identity.SyncID != mut.EntityKey || identity.Project != project || identity.Session == "" { + failures = append(failures, fmt.Errorf("prompt seq %d: invalid journal identity", mut.Seq)) + continue + } + if identity.Inbox != "" { + if err := m.preflightPrompt(mut, identity.SyncID, identity.Session, identity.Inbox, identity.Project); err != nil { + failures = append(failures, fmt.Errorf("prompt seq %d: %w", mut.Seq, err)) + continue + } + } } - seqs[i] = mut.Seq + entries = append(entries, MutationEntry{ + Project: mut.Project, Entity: mut.Entity, EntityKey: mut.EntityKey, + Op: mut.Op, Payload: json.RawMessage(mut.Payload), + }) + seqs = append(seqs, mut.Seq) + } + if len(entries) == 0 { + continue } - result, err := m.transport.PushMutations(entries) if err != nil { failures = append(failures, &projectTransportFailure{project: project, err: err}) @@ -684,11 +798,47 @@ func (m *Manager) push(ctx context.Context) error { } if err := m.store.AckSyncMutationSeqs(m.cfg.TargetKey, seqs); err != nil { failures = append(failures, fmt.Errorf("ack project %q: %w", project, err)) - return errors.Join(failures...) + return failures } } - return errors.Join(failures...) + return failures +} + +func (m *Manager) preflightPrompt(mut store.SyncMutation, syncID, session, inbox, project string) error { + if syncID == "" || session == "" || inbox == "" || project == "" || + syncID != mut.EntityKey || project != mut.Project { + return &promptPreflightError{err: fmt.Errorf("unverified keyed prompt mutation identity")} + } + local, ok := m.store.(localPromptProvenance) + if !ok { + return fmt.Errorf("local prompt provenance unavailable") + } + remote, ok := m.transport.(promptAuthorityTransport) + if !ok { + return fmt.Errorf("remote prompt authority unavailable") + } + originalSession, originalInbox, originalProject, eligible, err := local.LocalPromptCreationIdentity(syncID) + if err != nil { + return fmt.Errorf("read local prompt origin: %w", err) + } + if !eligible || originalSession != session || originalInbox != inbox || originalProject != project { + return &promptPreflightError{err: fmt.Errorf("unverified keyed prompt origin")} + } + owner, eligible, err := local.LocalSessionProvenance(session) + if err != nil { + return fmt.Errorf("read local session origin: %w", err) + } + if !eligible || owner == "" { + return &promptPreflightError{err: fmt.Errorf("unverified local session owner")} + } + if err := remote.RegisterSessionAuthority(session, owner); err != nil { + return fmt.Errorf("register session authority: %w", err) + } + if err := remote.ClaimPromptPair(session, inbox, syncID, owner, project); err != nil { + return fmt.Errorf("claim prompt pair: %w", err) + } + return nil } // ─── Pull ──────────────────────────────────────────────────────────────────── diff --git a/internal/cloud/autosync/manager_test.go b/internal/cloud/autosync/manager_test.go index 0ed7174d0..afb9a7a20 100644 --- a/internal/cloud/autosync/manager_test.go +++ b/internal/cloud/autosync/manager_test.go @@ -19,29 +19,30 @@ import ( // ─── Fakes ─────────────────────────────────────────────────────────────────── type fakeLocalStore struct { - mu sync.Mutex - mutations []store.SyncMutation - syncState *store.SyncState - leaseOwner string - leaseCalls int - pushErr error - pullErr error - failureMessage string - failureReason string - blockedReason string - blockedMessage string - appliedMuts []store.SyncMutation - acquireGranted bool - ackedSeqs []int64 - ackErr error - healthyCalls int + mu sync.Mutex + mutations []store.SyncMutation + syncState *store.SyncState + leaseOwner string + leaseCalls int + pushErr error + pullErr error + failureMessage string + failureReason string + blockedReason string + blockedMessage string + appliedMuts []store.SyncMutation + acquireGranted bool + ackedSeqs []int64 + ackErr error + healthyCalls int + staleHighWater bool blockedAfterSuccessCalls int blockedAfterSuccessErr error nonEnrolledCounts []store.PendingSyncMutationProjectCount - deferredProjects []string - listDeferredErr error - listedTargets []string - replayedScopes []string + deferredProjects []string + listDeferredErr error + listedTargets []string + replayedScopes []string } func newFakeLocalStore() *fakeLocalStore { @@ -61,7 +62,16 @@ func (s *fakeLocalStore) GetSyncState(_ string) (*store.SyncState, error) { if s.pullErr != nil { return nil, s.pullErr } - return s.syncState, nil + state := *s.syncState + if s.staleHighWater { + return &state, nil + } + for _, mutation := range s.mutations { + if mutation.Seq > state.LastEnqueuedSeq { + state.LastEnqueuedSeq = mutation.Seq + } + } + return &state, nil } func (s *fakeLocalStore) ListPendingSyncMutations(_ string, limit int) ([]store.SyncMutation, error) { @@ -80,6 +90,34 @@ func (s *fakeLocalStore) ListPendingSyncMutations(_ string, limit int) ([]store. return s.mutations[:n], nil } +func (s *fakeLocalStore) MaxPendingSyncMutationSeq(string) (int64, error) { + var max int64 + for _, mutation := range s.mutations { + if mutation.Seq > max { + max = mutation.Seq + } + } + return max, nil +} + +func (s *fakeLocalStore) ListPendingSyncMutationsAfterSeq(_ string, afterSeq int64, limit int) ([]store.SyncMutation, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.pushErr != nil { + return nil, s.pushErr + } + var page []store.SyncMutation + for _, mutation := range s.mutations { + if mutation.Seq > afterSeq { + page = append(page, mutation) + if len(page) == limit { + break + } + } + } + return page, nil +} + func (s *fakeLocalStore) CountPendingNonEnrolledSyncMutations(_ string) ([]store.PendingSyncMutationProjectCount, error) { s.mu.Lock() defer s.mu.Unlock() @@ -328,6 +366,132 @@ func attemptedProjects(t *fakeCloudTransport) []string { return projects } +type provenanceLocalStore struct { + *fakeLocalStore + owner, session, inbox, project string + eligible bool + originErr, ownerErr error +} + +func (s *provenanceLocalStore) LocalSessionProvenance(string) (string, bool, error) { + return s.owner, s.eligible, s.ownerErr +} +func (s *provenanceLocalStore) LocalPromptCreationIdentity(string) (string, string, string, bool, error) { + return s.session, s.inbox, s.project, s.eligible, s.originErr +} + +type provenanceTransport struct { + *fakeCloudTransport + calls []string + registerErr, claimErr error +} + +func (t *provenanceTransport) RegisterSessionAuthority(session, owner string) error { + t.calls = append(t.calls, "register:"+session+":"+owner) + return t.registerErr +} +func (t *provenanceTransport) ClaimPromptPair(session, inbox, syncID, owner, project string) error { + t.calls = append(t.calls, "claim:"+session+":"+inbox+":"+syncID+":"+owner+":"+project) + return t.claimErr +} + +func TestManagerKeyedPromptPreflight(t *testing.T) { + for _, tc := range []struct { + name string + eligible bool + registerErr, claimErr error + wantCalls string + }{ + {"success", true, nil, nil, "[register:s:alpha claim:s:i:p:alpha:beta]"}, + {"missing origin", false, nil, nil, "[]"}, + {"registration fails", true, errors.New("register denied"), nil, "[register:s:alpha]"}, + {"claim fails", true, nil, errors.New("claim denied"), "[register:s:alpha claim:s:i:p:alpha:beta]"}, + } { + t.Run(tc.name, func(t *testing.T) { + ls := &provenanceLocalStore{fakeLocalStore: newFakeLocalStore(), owner: "alpha", session: "s", inbox: "i", project: "beta", eligible: tc.eligible} + ls.mutations = []store.SyncMutation{ + {Seq: 1, Entity: "prompt", EntityKey: "p", Op: "delete", Project: "beta", Payload: `{"sync_id":"p","session_id":"s","source_inbox_id":"i","project":"beta"}`}, + {Seq: 2, Entity: "obs", EntityKey: "healthy", Op: "upsert", Project: "beta"}, + } + tr := &provenanceTransport{fakeCloudTransport: newFakeTransport(), registerErr: tc.registerErr, claimErr: tc.claimErr} + tr.pushResult = &PushMutationsResult{AcceptedSeqs: []int64{2}} + if tc.name == "success" { + tr.pushResult = &PushMutationsResult{AcceptedSeqs: []int64{1, 2}} + } + err := New(ls, tr, DefaultConfig()).push(context.Background()) + if (err == nil) != (tc.name == "success") { + t.Fatalf("push error: %v", err) + } + if got := fmt.Sprint(tr.calls); got != tc.wantCalls { + t.Fatalf("preflight calls %s, want %s", got, tc.wantCalls) + } + if tc.name != "success" && (len(tr.attempted) != 1 || len(tr.attempted[0]) != 1 || tr.attempted[0][0].EntityKey != "healthy" || fmt.Sprint(ls.ackedSeqs) != "[2]") { + t.Fatalf("unsafe push or ack: attempts=%v ack=%v", tr.attempted, ls.ackedSeqs) + } + }) + } +} + +func TestManagerPromptPreflightNoImplicitAuthority(t *testing.T) { + for _, tc := range []struct { + name, payload string + eligible bool + wantPush bool + }{ + {"idless", `{"sync_id":"p","session_id":"s","project":"beta"}`, false, true}, + {"imported keyed", `{"sync_id":"p","session_id":"s","source_inbox_id":"i","project":"beta"}`, false, false}, + {"mismatched project", `{"sync_id":"p","session_id":"s","source_inbox_id":"i","project":"alpha"}`, true, false}, + {"deleted session", `{"sync_id":"p","session_id":"s","source_inbox_id":"i","project":"beta"}`, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + ls := &provenanceLocalStore{fakeLocalStore: newFakeLocalStore(), owner: "alpha", session: "s", inbox: "i", project: "beta", eligible: tc.eligible} + ls.mutations = []store.SyncMutation{{Seq: 1, Entity: "prompt", EntityKey: "p", Project: "beta", Op: "delete", Payload: tc.payload}} + tr := &provenanceTransport{fakeCloudTransport: newFakeTransport()} + tr.pushResult = &PushMutationsResult{AcceptedSeqs: []int64{1}} + err := New(ls, tr, DefaultConfig()).push(context.Background()) + if (err == nil) != tc.wantPush || (len(tr.attempted) == 1) != tc.wantPush || (len(ls.ackedSeqs) == 1) != tc.wantPush { + t.Fatalf("error=%v attempts=%v ack=%v", err, tr.attempted, ls.ackedSeqs) + } + if len(tr.calls) != 0 { + t.Fatalf("unexpected authority calls: %v", tr.calls) + } + }) + } +} + +func TestManagerStaleEnqueuedStateDoesNotHidePending(t *testing.T) { + ls := newFakeLocalStore() + ls.staleHighWater = true + ls.mutations = []store.SyncMutation{{Seq: 5, Entity: "obs", EntityKey: "later", Op: "upsert", Project: "alpha"}} + tr := newFakeTransport() + tr.pushResult = &PushMutationsResult{AcceptedSeqs: []int64{5}} + if err := New(ls, tr, DefaultConfig()).push(context.Background()); err != nil { + t.Fatal(err) + } + if fmt.Sprint(ls.ackedSeqs) != "[5]" { + t.Fatalf("pending mutation hidden by stale state: ack=%v", ls.ackedSeqs) + } +} + +func TestManagerBlockedFirstPageDoesNotStarveLaterEntries(t *testing.T) { + ls := &provenanceLocalStore{fakeLocalStore: newFakeLocalStore()} + ls.mutations = []store.SyncMutation{ + {Seq: 1, Entity: "prompt", EntityKey: "unverified", Op: "delete", Project: "beta", Payload: `{"sync_id":"unverified","session_id":"s","source_inbox_id":"i","project":"beta"}`}, + {Seq: 2, Entity: "obs", EntityKey: "healthy", Op: "upsert", Project: "beta"}, + {Seq: 3, Entity: "obs", EntityKey: "later", Op: "upsert", Project: "alpha"}, + } + tr := &provenanceTransport{fakeCloudTransport: newFakeTransport()} + tr.pushResultByProject = map[string]*PushMutationsResult{"beta": {AcceptedSeqs: []int64{2}}, "alpha": {AcceptedSeqs: []int64{3}}} + cfg := DefaultConfig() + cfg.PushBatchSize = 1 + if err := New(ls, tr, cfg).push(context.Background()); err == nil || !strings.Contains(err.Error(), "unverified") { + t.Fatalf("expected visible blocked origin, got %v", err) + } + if fmt.Sprint(ls.ackedSeqs) != "[2 3]" || fmt.Sprint(attemptedProjects(tr.fakeCloudTransport)) != "[beta alpha]" { + t.Fatalf("starved healthy entries: ack=%v attempts=%v", ls.ackedSeqs, attemptedProjects(tr.fakeCloudTransport)) + } +} + // ─── Push ack safety regressions ───────────────────────────────────────────── func TestManagerPushNoPendingDoesNotPushOrAck(t *testing.T) { @@ -757,6 +921,91 @@ func TestManagerPushStopsBeforeLaterProjectsWhenCanceled(t *testing.T) { } } +type cursorProvenanceStore struct{ *provenanceLocalStore } + +func (s *cursorProvenanceStore) ApplyPulledMutationPreservingSyncState(target string, mutation store.SyncMutation) error { + if err := s.fakeLocalStore.ApplyPulledMutationPreservingSyncState(target, mutation); err != nil { + return err + } + s.syncState.LastPulledSeq = mutation.Seq + return nil +} + +type cursorProvenanceTransport struct { + *provenanceTransport + since []int64 +} + +func (t *cursorProvenanceTransport) PullMutations(since int64, limit int) (*PullMutationsResponse, error) { + t.since = append(t.since, since) + if since >= 7 { + atomic.AddInt32(&t.pullCalls, 1) + return &PullMutationsResponse{}, nil + } + return t.fakeCloudTransport.PullMutations(since, limit) +} + +func TestManagerCycleKeyedPromptBlockAllowsInboundProgress(t *testing.T) { + for _, tc := range []struct { + name string + eligible bool + registerErr, claimErr error + originErr, ownerErr error + pull bool + }{ + {name: "missing origin", pull: true}, + {name: "missing owner", eligible: true, pull: true, ownerErr: nil}, + {name: "registration transport error", eligible: true, registerErr: errors.New("transport down")}, + {name: "claim transport error", eligible: true, claimErr: errors.New("transport down")}, + {name: "origin read error", eligible: true, originErr: errors.New("database unavailable")}, + {name: "owner read error", eligible: true, ownerErr: errors.New("database unavailable")}, + } { + t.Run(tc.name, func(t *testing.T) { + ls := &provenanceLocalStore{fakeLocalStore: newFakeLocalStore(), owner: "alpha", session: "s", inbox: "i", project: "beta", eligible: tc.eligible, originErr: tc.originErr, ownerErr: tc.ownerErr} + if tc.name == "missing owner" { + ls.owner = "" + } + ls.mutations = []store.SyncMutation{{Seq: 1, Entity: store.SyncEntityPrompt, EntityKey: "p", Op: "delete", Project: "beta", Payload: `{"sync_id":"p","session_id":"s","source_inbox_id":"i","project":"beta"}`}} + tr := &cursorProvenanceTransport{provenanceTransport: &provenanceTransport{fakeCloudTransport: newFakeTransport(), registerErr: tc.registerErr, claimErr: tc.claimErr}} + tr.pullResult = &PullMutationsResponse{Mutations: []PulledMutation{{Seq: 7, Project: "remote", Entity: store.SyncEntitySession, EntityKey: "remote-s", Op: "upsert", Payload: json.RawMessage(`{"id":"remote-s","project":"remote"}`)}}} + mgr := New(&cursorProvenanceStore{provenanceLocalStore: ls}, tr, DefaultConfig()) + mgr.cycle(context.Background()) + if tc.pull { + mgr.cycle(context.Background()) + } + st := mgr.Status() + if tc.pull { + if atomic.LoadInt32(&tr.pullCalls) != 2 || len(ls.appliedMuts) != 1 || ls.syncState.LastPulledSeq != 7 || fmt.Sprint(tr.since) != "[0 7]" { + t.Fatalf("inbound stalled or replayed: pulls=%d applied=%v cursor=%d since=%v", tr.pullCalls, ls.appliedMuts, ls.syncState.LastPulledSeq, tr.since) + } + } else if tr.pullCalls != 0 || len(ls.appliedMuts) != 0 || st.BackoffUntil == nil || st.ConsecutiveFailures != 1 || ls.failureMessage == "" { + t.Fatalf("uncertain preflight must skip pull and record failure: pulls=%d applied=%v status=%+v persisted=%q", tr.pullCalls, ls.appliedMuts, st, ls.failureMessage) + } + if len(tr.attempted) != 0 || len(ls.ackedSeqs) != 0 { + t.Fatalf("blocked prompt pushed/acked: %v %v", tr.attempted, ls.ackedSeqs) + } + if tc.pull && (st.Phase != PhasePushFailed || st.ReasonCode != "prompt_provenance_blocked" || st.LastSyncAt == nil || ls.blockedReason != st.ReasonCode || ls.blockedAfterSuccessCalls != 2 || ls.healthyCalls != 0) { + t.Fatalf("blocked status not retained: %+v persisted=%q healthy=%d", st, ls.blockedReason, ls.healthyCalls) + } + }) + } +} + +func TestManagerCyclePromptBlockMixedWithTransportFailureSkipsPull(t *testing.T) { + ls := &provenanceLocalStore{fakeLocalStore: newFakeLocalStore()} + ls.mutations = []store.SyncMutation{ + {Seq: 1, Entity: store.SyncEntityPrompt, EntityKey: "p", Op: "delete", Project: "beta", Payload: `{"sync_id":"p","session_id":"s","source_inbox_id":"i","project":"beta"}`}, + {Seq: 2, Entity: "obs", EntityKey: "other", Op: "upsert", Project: "alpha"}, + } + tr := &provenanceTransport{fakeCloudTransport: newFakeTransport()} + tr.pushErrByProject = map[string]error{"alpha": errors.New("transport down")} + mgr := New(ls, tr, DefaultConfig()) + mgr.cycle(context.Background()) + if tr.pullCalls != 0 || mgr.Status().Phase != PhasePushFailed || mgr.Status().BackoffUntil == nil || ls.healthyCalls != 0 || len(ls.ackedSeqs) != 0 { + t.Fatalf("mixed failure must skip pull and stay degraded: pulls=%d status=%+v ack=%v", tr.pullCalls, mgr.Status(), ls.ackedSeqs) + } +} + func TestManagerCyclePartialPushFailureSkipsPullAndHealthyState(t *testing.T) { ls := newFakeLocalStore() ls.mutations = []store.SyncMutation{ diff --git a/internal/cloud/cloudserver/cloudserver.go b/internal/cloud/cloudserver/cloudserver.go index d8309e958..f4b388666 100644 --- a/internal/cloud/cloudserver/cloudserver.go +++ b/internal/cloud/cloudserver/cloudserver.go @@ -315,6 +315,10 @@ func (s *CloudServer) routes() { s.mux.HandleFunc("GET /sync/pull", s.withAuth(s.handlePullManifest)) s.mux.HandleFunc("GET /sync/pull/{chunkID}", s.withAuth(s.handlePullChunk)) s.mux.HandleFunc("POST /sync/push", s.withAuth(s.handlePushChunk)) + s.mux.HandleFunc("POST /sync/session-authorities", s.withAuth(s.handleRegisterSessionAuthority)) + s.mux.HandleFunc("POST /sync/prompt-pair-claims", s.withAuth(s.handlePromptPairClaim)) + s.mux.HandleFunc("POST /sync/prompt-source-attestations", s.withAuth(s.handlePromptSourceAttestation)) + s.mux.HandleFunc("POST /sync/prompt-source-attestations/verify", s.withAuth(s.handleVerifyPromptSourceAttestation)) s.mux.HandleFunc("POST /sync/mutations/push", s.withAuth(s.handleMutationPush)) s.mux.HandleFunc("GET /sync/mutations/pull", s.withAuth(s.handleMutationPull)) s.mux.HandleFunc("GET /admin/users", s.withAuth(s.handleAdminListUsers)) diff --git a/internal/cloud/cloudserver/prompt_pair_claim.go b/internal/cloud/cloudserver/prompt_pair_claim.go new file mode 100644 index 000000000..24b248bf2 --- /dev/null +++ b/internal/cloud/cloudserver/prompt_pair_claim.go @@ -0,0 +1,121 @@ +package cloudserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" + "github.com/Gentleman-Programming/engram/v2/internal/store" +) + +type promptPairClaimStore interface { + GetSessionAuthority(context.Context, string) (*cloudstore.SessionAuthority, error) + ClaimPromptPair(context.Context, string, string, string, string, string) error +} + +func sessionAuthorityUnavailable(w http.ResponseWriter) { + jsonResponse(w, http.StatusNotFound, map[string]string{ + "error": "session authority unavailable", "error_code": "session_authority_unavailable", + }) +} + +func (s *CloudServer) handlePromptPairClaim(w http.ResponseWriter, r *http.Request) { + // Even if project policy is configured, an insecure server cannot issue claims. + principal, hasPrincipal := PrincipalFromContext(r.Context()) + usablePrincipal := hasPrincipal && strings.TrimSpace(principal.ID) != "" + if s.auth == nil || (s.principalAuth != nil && !usablePrincipal) { + http.Error(w, "authentication unavailable", http.StatusUnauthorized) + return + } + // Never fall back to a legacy allowlist for a managed principal, or to + // unrestricted policy when the required project authorizer is missing. + if usablePrincipal && usesManagedProjectGrants(principal) { + if s.principalProject == nil { + http.Error(w, "project authorization unavailable", http.StatusForbidden) + return + } + } else if s.projectAuth == nil { + http.Error(w, "project authorization unavailable", http.StatusForbidden) + return + } + var payload struct { + SessionID string `json:"session_id"` + SourceInboxID string `json:"source_inbox_id"` + SyncID string `json:"sync_id"` + OwnerProject string `json:"owner_project"` + Project string `json:"project"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.pushBodyLimit())) + decoder.DisallowUnknownFields() + invalid := func(err error) { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "prompt pair claim request too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "invalid prompt pair claim request", http.StatusBadRequest) + } + if err := decoder.Decode(&payload); err != nil { + invalid(err) + return + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + invalid(err) + return + } + sessionID := strings.TrimSpace(payload.SessionID) + inboxID := strings.TrimSpace(payload.SourceInboxID) + syncID := strings.TrimSpace(payload.SyncID) + owner := strings.TrimSpace(payload.OwnerProject) + project := strings.TrimSpace(payload.Project) + if sessionID == "" || inboxID == "" || syncID == "" || owner == "" || project == "" { + http.Error(w, "session_id, source_inbox_id, sync_id, owner_project and project are required", http.StatusBadRequest) + return + } + owner, _ = store.NormalizeProject(owner) + project, _ = store.NormalizeProject(project) + if strings.TrimSpace(owner) == "" || strings.TrimSpace(project) == "" { + http.Error(w, "owner_project and project are required", http.StatusBadRequest) + return + } + // Check BOTH grants before looking up the globally unique session ID. + if !s.authorizeProjectScope(r.Context(), w, owner) || !s.authorizeProjectScope(r.Context(), w, project) { + return + } + claims, ok := s.store.(promptPairClaimStore) + if !ok { + http.Error(w, "prompt pair claim store unavailable", http.StatusInternalServerError) + return + } + authority, err := claims.GetSessionAuthority(r.Context(), sessionID) + if err != nil { + http.Error(w, "session authority storage unavailable", http.StatusInternalServerError) + return + } + if authority == nil || authority.OwnerProject != owner { + sessionAuthorityUnavailable(w) + return + } + actor := "legacy:authenticated" + if usablePrincipal { + actor = strings.TrimSpace(principal.ID) + } + if err := claims.ClaimPromptPair(r.Context(), sessionID, inboxID, syncID, project, actor); err != nil { + if errors.Is(err, cloudstore.ErrPromptPairClaimConflict) { + http.Error(w, "prompt pair claim conflict", http.StatusConflict) + return + } + if errors.Is(err, cloudstore.ErrSessionAuthorityNotFound) { + sessionAuthorityUnavailable(w) + return + } + http.Error(w, "prompt pair claim storage unavailable", http.StatusInternalServerError) + return + } + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"}) +} diff --git a/internal/cloud/cloudserver/prompt_pair_claim_test.go b/internal/cloud/cloudserver/prompt_pair_claim_test.go new file mode 100644 index 000000000..c6853424b --- /dev/null +++ b/internal/cloud/cloudserver/prompt_pair_claim_test.go @@ -0,0 +1,267 @@ +package cloudserver + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/Gentleman-Programming/engram/v2/internal/cloud" + cloudauth "github.com/Gentleman-Programming/engram/v2/internal/cloud/auth" + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" +) + +type claimAuthOnly struct{} + +func (claimAuthOnly) Authorize(*http.Request) error { return nil } + +type pairClaimStore struct { + fakeStore + owner string + lookup, claims int + session, inbox, syncID, project, actor string + lookupErr, claimErr error +} + +func (s *pairClaimStore) GetSessionAuthority(_ context.Context, _ string) (*cloudstore.SessionAuthority, error) { + s.lookup++ + if s.lookupErr != nil { + return nil, s.lookupErr + } + if s.owner == "" { + return nil, nil + } + return &cloudstore.SessionAuthority{OwnerProject: s.owner}, nil +} +func (s *pairClaimStore) ClaimPromptPair(_ context.Context, session, inbox, syncID, project, actor string) error { + s.claims++ + s.session, s.inbox, s.syncID, s.project, s.actor = session, inbox, syncID, project, actor + return s.claimErr +} + +func TestPromptPairClaimAdmission(t *testing.T) { + principal := cloudauth.Principal{ID: "managed", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + body := `{"session_id":" s ","source_inbox_id":" i ","sync_id":" y ","owner_project":"alpha","project":"beta"}` + cases := []struct { + name, body, owner string + grants []string + token string + lookupErr, claimErr error + limit int64 + want, lookups, claims int + requests int + }{ + {name: "dual grant", body: body, owner: "alpha", grants: []string{"alpha", "beta"}, token: "token", want: 200, lookups: 1, claims: 1}, + {name: "beta only", body: body, owner: "alpha", grants: []string{"beta"}, token: "token", want: 403}, + {name: "alpha only", body: body, owner: "alpha", grants: []string{"alpha"}, token: "token", want: 403}, + {name: "missing", body: body, grants: []string{"alpha", "beta"}, token: "token", want: 404, lookups: 1}, + {name: "wrong selector", body: body, owner: "other", grants: []string{"alpha", "beta"}, token: "token", want: 404, lookups: 1}, + {name: "replay", body: body, owner: "alpha", grants: []string{"alpha", "beta"}, token: "token", want: 200, lookups: 2, claims: 2, requests: 2}, + {name: "conflict", body: body, owner: "alpha", grants: []string{"alpha", "beta"}, token: "token", claimErr: cloudstore.ErrPromptPairClaimConflict, want: 409, lookups: 1, claims: 1}, + {name: "claim failure", body: body, owner: "alpha", grants: []string{"alpha", "beta"}, token: "token", claimErr: errors.New("secret database failure"), want: 500, lookups: 1, claims: 1}, + {name: "authority vanished", body: body, owner: "alpha", grants: []string{"alpha", "beta"}, token: "token", claimErr: cloudstore.ErrSessionAuthorityNotFound, want: 404, lookups: 1, claims: 1}, + {name: "lookup failure", body: body, grants: []string{"alpha", "beta"}, token: "token", lookupErr: errors.New("secret database failure"), want: 500, lookups: 1}, + {name: "no token", body: body, grants: []string{"alpha", "beta"}, want: 401}, + {name: "spoof actor", body: strings.TrimSuffix(body, "}") + `,"actor":"admin"}`, grants: []string{"alpha", "beta"}, token: "token", want: 400}, + {name: "unknown field", body: strings.TrimSuffix(body, "}") + `,"unknown":1}`, grants: []string{"alpha", "beta"}, token: "token", want: 400}, + {name: "empty identity", body: strings.Replace(body, `"sync_id":" y "`, `"sync_id":" "`, 1), grants: []string{"alpha", "beta"}, token: "token", want: 400}, + {name: "malformed", body: `{`, grants: []string{"alpha", "beta"}, token: "token", want: 400}, + {name: "trailing", body: body + ` {}`, grants: []string{"alpha", "beta"}, token: "token", want: 400}, + {name: "oversize", body: body, grants: []string{"alpha", "beta"}, token: "token", limit: 20, want: 413}, + {name: "oversize trailing", body: body + strings.Repeat(" ", 100), grants: []string{"alpha", "beta"}, token: "token", limit: int64(len(body) + 10), want: 413}, + } + var unavailableBody string + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := &pairClaimStore{owner: tc.owner, lookupErr: tc.lookupErr, claimErr: tc.claimErr} + opts := []Option{WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{principal.ID: tc.grants}})} + if tc.limit > 0 { + opts = append(opts, WithMaxPushBodyBytes(tc.limit)) + } + srv := New(st, resolvingAuth{principals: map[string]cloudauth.Principal{"token": principal}}, 0, opts...) + requests := tc.requests + if requests == 0 { + requests = 1 + } + var w *httptest.ResponseRecorder + for i := 0; i < requests; i++ { + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-pair-claims", strings.NewReader(tc.body)) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("request %d status=%d body=%q; want %d", i, w.Code, w.Body.String(), tc.want) + } + } + if st.lookup != tc.lookups || st.claims != tc.claims { + t.Fatalf("lookup=%d claims=%d; want %d/%d", st.lookup, st.claims, tc.lookups, tc.claims) + } + if st.claims != 0 && (st.session != "s" || st.inbox != "i" || st.syncID != "y" || st.project != "beta" || st.actor != principal.ID) { + t.Fatalf("claim fields: %+v", st) + } + if tc.name == "missing" || tc.name == "wrong selector" || tc.name == "authority vanished" { + var payload struct { + ErrorCode string `json:"error_code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil || payload.ErrorCode != "session_authority_unavailable" { + t.Fatalf("unexpected authority response: %q (%v)", w.Body.String(), err) + } + if unavailableBody != "" && unavailableBody != w.Body.String() { + t.Fatalf("authority response differs: %q vs %q", unavailableBody, w.Body.String()) + } + unavailableBody = w.Body.String() + } + if tc.want == 500 && strings.Contains(w.Body.String(), "secret") { + t.Fatalf("database detail leaked: %q", w.Body.String()) + } + }) + } +} + +func TestPromptPairClaimOwnerGrantAliasDoesNotChangeIdentity(t *testing.T) { + principal := cloudauth.Principal{ID: "managed", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + st := &pairClaimStore{owner: "alpha/foo"} + srv := New(st, resolvingAuth{principals: map[string]cloudauth.Principal{"token": principal}}, 0, + WithPrincipalProjectAuthorizer(normalizedAuthorityGrants{grants: map[string]string{principal.ID: "alpha-foo"}})) + body := `{"session_id":"s","source_inbox_id":"i","sync_id":"y","owner_project":"alpha/foo","project":"alpha/foo"}` + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-pair-claims", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != 200 || st.lookup != 1 || st.claims != 1 || st.project != "alpha/foo" { + t.Fatalf("status=%d lookup=%d claims=%d project=%q body=%q", w.Code, st.lookup, st.claims, st.project, w.Body.String()) + } +} + +func TestPromptPairClaimRejectsMissingProjectPolicy(t *testing.T) { + principal := cloudauth.Principal{ID: "managed", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + legacy, err := cloudauth.NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32)) + if err != nil { + t.Fatal(err) + } + legacy.SetBearerToken("legacy-token") + body := `{"session_id":"s","source_inbox_id":"i","sync_id":"y","owner_project":"alpha","project":"beta"}` + for _, tc := range []struct { + name, token string + auth Authenticator + opts []Option + }{ + {"managed lacks grants", "token", resolvingAuth{principals: map[string]cloudauth.Principal{"token": principal}}, []Option{WithProjectAuthorizer(legacy)}}, + {"legacy lacks allowlist", "legacy-token", claimAuthOnly{}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &pairClaimStore{owner: "alpha"} + srv := New(st, tc.auth, 0, tc.opts...) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-pair-claims", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+tc.token) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != 403 || st.lookup != 0 || st.claims != 0 { + t.Fatalf("status=%d lookup=%d claims=%d body=%q", w.Code, st.lookup, st.claims, w.Body.String()) + } + }) + } +} + +func TestPromptPairClaimPersistsOverHTTP(t *testing.T) { + dsn := os.Getenv("CLOUDSTORE_TEST_DSN") + if dsn == "" { + t.Skip("CLOUDSTORE_TEST_DSN not set (requires Postgres)") + } + if !strings.HasPrefix(dsn, "postgres://") && !strings.HasPrefix(dsn, "postgresql://") { + t.Skip("test requires URL-style CLOUDSTORE_TEST_DSN") + } + ctx := context.Background() + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = admin.Close() }) + schema := fmt.Sprintf("cloudserver_pair_claim_%d", time.Now().UnixNano()) + if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`) }) + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + cs, err := cloudstore.New(cloud.Config{DSN: dsn + sep + "search_path=" + schema}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + principal := cloudauth.Principal{ID: "p-owner", Kind: cloudauth.PrincipalKindHuman, Role: cloudauth.RoleMember, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + srv := New(cs, resolvingAuth{principals: map[string]cloudauth.Principal{"token": principal}}, 0, + WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{principal.ID: {"alpha", "beta"}}})) + post := func(path, body string) int { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + return w.Code + } + registration := `{"session_id":"session","project":"alpha"}` + claim := `{"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"alpha","project":"beta"}` + if code := post("/sync/session-authorities", registration); code != 200 { + t.Fatalf("registration status=%d", code) + } + for _, want := range []int{200, 200} { + if code := post("/sync/prompt-pair-claims", claim); code != want { + t.Fatalf("claim status=%d want=%d", code, want) + } + } + if code := post("/sync/prompt-pair-claims", strings.Replace(claim, `"sync_id":"sync"`, `"sync_id":"other"`, 1)); code != 409 { + t.Fatalf("conflict status=%d", code) + } + persisted, err := cs.GetPromptPairClaim(ctx, "session", "inbox") + if err != nil || persisted == nil || persisted.SyncID != "sync" || persisted.PromptProject != "beta" || persisted.ClaimedBy != principal.ID { + t.Fatalf("persisted claim=%+v err=%v", persisted, err) + } +} + +func TestPromptPairClaimInsecureAndLegacy(t *testing.T) { + legacy, err := cloudauth.NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32)) + if err != nil { + t.Fatal(err) + } + legacy.SetBearerToken("legacy-token") + legacy.SetAllowedProjects([]string{"alpha", "beta"}) + body := `{"session_id":"s","source_inbox_id":"i","sync_id":"y","owner_project":"alpha","project":"beta"}` + for _, tc := range []struct { + name string + auth Authenticator + project string + want, lookups, claims int + }{ + {"legacy allowed", legacy, "beta", 200, 1, 1}, {"legacy denied", legacy, "gamma", 403, 0, 0}, + {"insecure allowed", nil, "beta", 401, 0, 0}, {"insecure forbidden", nil, "gamma", 401, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &pairClaimStore{owner: "alpha"} + srv := New(st, tc.auth, 0, WithProjectAuthorizer(legacy)) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-pair-claims", strings.NewReader(strings.Replace(body, `"project":"beta"`, `"project":"`+tc.project+`"`, 1))) + if tc.auth != nil { + req.Header.Set("Authorization", "Bearer legacy-token") + } + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.lookup != tc.lookups || st.claims != tc.claims { + t.Fatalf("status=%d lookup=%d claims=%d body=%q", w.Code, st.lookup, st.claims, w.Body.String()) + } + if st.claims > 0 && st.actor != "legacy:sync" { + t.Fatalf("actor=%q", st.actor) + } + }) + } +} diff --git a/internal/cloud/cloudserver/prompt_source_attestation.go b/internal/cloud/cloudserver/prompt_source_attestation.go new file mode 100644 index 000000000..e0637ea7e --- /dev/null +++ b/internal/cloud/cloudserver/prompt_source_attestation.go @@ -0,0 +1,196 @@ +package cloudserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + cloudauth "github.com/Gentleman-Programming/engram/v2/internal/cloud/auth" + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" + "github.com/Gentleman-Programming/engram/v2/internal/store" +) + +type promptSourceAttestationVerifier interface { + VerifyPromptSourceAttestation(context.Context, int64, string, string, string, string, string) (bool, error) +} + +// handleVerifyPromptSourceAttestation checks a prior audit assertion without +// registering authority, claiming a pair, appending an audit row, or admitting deletion. +func (s *CloudServer) handleVerifyPromptSourceAttestation(w http.ResponseWriter, r *http.Request) { + principal, ok := PrincipalFromContext(r.Context()) + if s.auth == nil || !ok || strings.TrimSpace(principal.ID) == "" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + if s.principalProject == nil { + http.Error(w, "project authorization unavailable", http.StatusForbidden) + return + } + var p struct { + AuditID int64 `json:"audit_id"` + SessionID string `json:"session_id"` + SourceInboxID string `json:"source_inbox_id"` + SyncID string `json:"sync_id"` + OwnerProject string `json:"owner_project"` + PromptProject string `json:"prompt_project"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.pushBodyLimit())) + decoder.DisallowUnknownFields() + invalid := func(err error) { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "verification request too large", http.StatusRequestEntityTooLarge) + } else { + http.Error(w, "invalid verification request", http.StatusBadRequest) + } + } + if err := decoder.Decode(&p); err != nil { + invalid(err) + return + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + invalid(err) + return + } + if p.AuditID <= 0 || strings.TrimSpace(p.SessionID) == "" || strings.TrimSpace(p.SourceInboxID) == "" || strings.TrimSpace(p.SyncID) == "" || strings.TrimSpace(p.OwnerProject) == "" || strings.TrimSpace(p.PromptProject) == "" { + http.Error(w, "audit id and all verification fields are required", http.StatusBadRequest) + return + } + owner, _ := store.NormalizeProject(strings.TrimSpace(p.OwnerProject)) + prompt, _ := store.NormalizeProject(strings.TrimSpace(p.PromptProject)) + if strings.TrimSpace(owner) == "" || strings.TrimSpace(prompt) == "" { + http.Error(w, "projects are required", http.StatusBadRequest) + return + } + // Current grants to both projects must precede the global audit lookup. + if err := s.principalProject.AuthorizeProjectForPrincipal(r.Context(), principal, owner); err != nil { + http.Error(w, "owner project forbidden", http.StatusForbidden) + return + } + if err := s.principalProject.AuthorizeProjectForPrincipal(r.Context(), principal, prompt); err != nil { + http.Error(w, "prompt project forbidden", http.StatusForbidden) + return + } + verifier, ok := s.store.(promptSourceAttestationVerifier) + if !ok { + http.Error(w, "attestation store unavailable", http.StatusInternalServerError) + return + } + found, err := verifier.VerifyPromptSourceAttestation(r.Context(), p.AuditID, p.SessionID, p.SourceInboxID, p.SyncID, owner, prompt) + if err != nil { + http.Error(w, "verification storage unavailable", http.StatusInternalServerError) + return + } + if !found { + http.Error(w, "attestation binding conflict", http.StatusConflict) + return + } + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok", "attestation_id": p.AuditID}) +} + +type promptSourceAttestationStore interface { + RegisterSessionAuthority(context.Context, string, string, string) error + ClaimPromptPair(context.Context, string, string, string, string, string) error + AttestPromptSource(context.Context, string, string, string, string, string, string) (*cloudstore.PromptSourceAttestation, error) +} + +func (s *CloudServer) handlePromptSourceAttestation(w http.ResponseWriter, r *http.Request) { + principal, ok := PrincipalFromContext(r.Context()) + if s.auth == nil || !ok || strings.TrimSpace(principal.ID) == "" { + http.Error(w, "human authentication required", http.StatusUnauthorized) + return + } + if principal.Kind != cloudauth.PrincipalKindHuman { + http.Error(w, "human principal required", http.StatusForbidden) + return + } + if s.principalProject == nil { + http.Error(w, "project authorization unavailable", http.StatusForbidden) + return + } + var p struct { + SessionID string `json:"session_id"` + SourceInboxID string `json:"source_inbox_id"` + SyncID string `json:"sync_id"` + OwnerProject string `json:"owner_project"` + PromptProject string `json:"prompt_project"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.pushBodyLimit())) + decoder.DisallowUnknownFields() + invalid := func(err error) { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "attestation request too large", http.StatusRequestEntityTooLarge) + } else { + http.Error(w, "invalid attestation request", http.StatusBadRequest) + } + } + if err := decoder.Decode(&p); err != nil { + invalid(err) + return + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + invalid(err) + return + } + session, inbox, syncID := strings.TrimSpace(p.SessionID), strings.TrimSpace(p.SourceInboxID), strings.TrimSpace(p.SyncID) + owner, prompt := strings.TrimSpace(p.OwnerProject), strings.TrimSpace(p.PromptProject) + if session == "" || inbox == "" || syncID == "" || owner == "" || prompt == "" { + http.Error(w, "all attestation fields are required", http.StatusBadRequest) + return + } + owner, _ = store.NormalizeProject(owner) + prompt, _ = store.NormalizeProject(prompt) + if strings.TrimSpace(owner) == "" || strings.TrimSpace(prompt) == "" { + http.Error(w, "projects are required", http.StatusBadRequest) + return + } + // Both current grants precede any global session identity access. + if err := s.principalProject.AuthorizeProjectForPrincipal(r.Context(), principal, owner); err != nil { + http.Error(w, "owner project forbidden", http.StatusForbidden) + return + } + if err := s.principalProject.AuthorizeProjectForPrincipal(r.Context(), principal, prompt); err != nil { + http.Error(w, "prompt project forbidden", http.StatusForbidden) + return + } + attestations, ok := s.store.(promptSourceAttestationStore) + if !ok { + http.Error(w, "attestation store unavailable", http.StatusInternalServerError) + return + } + actor := strings.TrimSpace(principal.ID) + if err := attestations.RegisterSessionAuthority(r.Context(), session, owner, actor); err != nil { + if errors.Is(err, cloudstore.ErrSessionAuthorityConflict) { + http.Error(w, "session authority conflict", http.StatusConflict) + } else { + http.Error(w, "registration storage unavailable", http.StatusInternalServerError) + } + return + } + if err := attestations.ClaimPromptPair(r.Context(), session, inbox, syncID, prompt, actor); err != nil { + if errors.Is(err, cloudstore.ErrPromptPairClaimConflict) { + http.Error(w, "prompt pair conflict", http.StatusConflict) + } else if errors.Is(err, cloudstore.ErrSessionAuthorityNotFound) { + sessionAuthorityUnavailable(w) + } else { + http.Error(w, "claim storage unavailable", http.StatusInternalServerError) + } + return + } + record, err := attestations.AttestPromptSource(r.Context(), session, inbox, syncID, owner, prompt, actor) + if err != nil { + if errors.Is(err, cloudstore.ErrPromptSourceAttestationUnbound) { + http.Error(w, "attestation binding conflict", http.StatusConflict) + } else { + http.Error(w, "attestation storage unavailable", http.StatusInternalServerError) + } + return + } + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok", "attestation_id": record.ID}) +} diff --git a/internal/cloud/cloudserver/prompt_source_attestation_test.go b/internal/cloud/cloudserver/prompt_source_attestation_test.go new file mode 100644 index 000000000..613027292 --- /dev/null +++ b/internal/cloud/cloudserver/prompt_source_attestation_test.go @@ -0,0 +1,203 @@ +package cloudserver + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudauth "github.com/Gentleman-Programming/engram/v2/internal/cloud/auth" + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" +) + +type attestationTestStore struct { + fakeStore + registration, claim, attest int + actor string + registrationErr, claimErr, attestErr error +} + +func (s *attestationTestStore) RegisterSessionAuthority(_ context.Context, _, _, actor string) error { + s.registration++ + s.actor = actor + return s.registrationErr +} +func (s *attestationTestStore) ClaimPromptPair(_ context.Context, _, _, _, _, actor string) error { + s.claim++ + s.actor = actor + return s.claimErr +} +func (s *attestationTestStore) AttestPromptSource(_ context.Context, _, _, _, _, _, actor string) (*cloudstore.PromptSourceAttestation, error) { + s.attest++ + s.actor = actor + if s.attestErr != nil { + return nil, s.attestErr + } + return &cloudstore.PromptSourceAttestation{ID: 42, ActorID: actor}, nil +} + +type verifyAttestationStore struct { + attestationTestStore + calls int + found bool + err error + id int64 + fields [5]string +} + +func (s *verifyAttestationStore) VerifyPromptSourceAttestation(_ context.Context, id int64, session, inbox, syncID, owner, prompt string) (bool, error) { + s.calls++ + s.id = id + s.fields = [5]string{session, inbox, syncID, owner, prompt} + return s.found, s.err +} + +func TestVerifyPromptSourceAttestationRoute(t *testing.T) { + body := `{"audit_id":42,"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"alpha","prompt_project":"beta"}` + human := cloudauth.Principal{ID: "human", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + service := cloudauth.Principal{ID: "service", Kind: cloudauth.PrincipalKindServiceAccount, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + cases := []struct { + name, body string + principal cloudauth.Principal + grants []string + found bool + err error + limit int64 + status, calls int + }{ + {name: "human exact", body: body, principal: human, grants: []string{"alpha", "beta"}, found: true, status: 200, calls: 1}, + {name: "service exact", body: body, principal: service, grants: []string{"alpha", "beta"}, found: true, status: 200, calls: 1}, + {name: "missing row", body: body, principal: human, grants: []string{"alpha", "beta"}, status: 409, calls: 1}, + {name: "store error", body: body, principal: human, grants: []string{"alpha", "beta"}, err: errors.New("unavailable"), status: 500, calls: 1}, + {name: "owner denied", body: body, principal: human, grants: []string{"beta"}, status: 403}, + {name: "prompt denied", body: body, principal: human, grants: []string{"alpha"}, status: 403}, + {name: "missing principal", body: body, status: 401}, + {name: "unidentified", body: body, principal: cloudauth.Principal{Kind: cloudauth.PrincipalKindServiceAccount}, status: 401}, + {name: "zero id", body: strings.Replace(body, `42`, `0`, 1), principal: human, grants: []string{"alpha", "beta"}, status: 400}, + {name: "fractional id", body: strings.Replace(body, `42`, `1.5`, 1), principal: human, grants: []string{"alpha", "beta"}, status: 400}, + {name: "missing field", body: strings.Replace(body, `"sync"`, `""`, 1), principal: human, grants: []string{"alpha", "beta"}, status: 400}, + {name: "unknown field", body: strings.TrimSuffix(body, "}") + `,"actor_id":"spoof"}`, principal: human, grants: []string{"alpha", "beta"}, status: 400}, + {name: "trailing", body: body + ` {}`, principal: human, grants: []string{"alpha", "beta"}, status: 400}, + {name: "oversized", body: body, principal: human, grants: []string{"alpha", "beta"}, limit: 20, status: 413}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := &verifyAttestationStore{found: tc.found, err: tc.err} + opts := []Option{WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{tc.principal.ID: tc.grants}})} + if tc.limit > 0 { + opts = append(opts, WithMaxPushBodyBytes(tc.limit)) + } + srv := New(st, claimAuthOnly{}, 0, opts...) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-source-attestations/verify", strings.NewReader(tc.body)) + req = req.WithContext(WithPrincipal(req.Context(), tc.principal)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.status || st.calls != tc.calls || st.registration != 0 || st.claim != 0 || st.attest != 0 { + t.Fatalf("status=%d body=%q verify=%d writes=%d/%d/%d", w.Code, w.Body.String(), st.calls, st.registration, st.claim, st.attest) + } + if tc.status == 200 && (st.id != 42 || st.fields != [5]string{"session", "inbox", "sync", "alpha", "beta"} || !strings.Contains(w.Body.String(), `"status":"ok"`)) { + t.Fatalf("verification response=%q id=%d fields=%v", w.Body.String(), st.id, st.fields) + } + }) + } +} + +func TestPromptSourceAttestationBearerBoundary(t *testing.T) { + body := `{"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"alpha","prompt_project":"beta"}` + human := cloudauth.Principal{ID: "human", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + legacy := cloudauth.Principal{ID: "legacy:sync", Kind: cloudauth.PrincipalKindLegacy, Source: cloudauth.PrincipalSourceLegacyEnvSync, Enabled: true} + service := cloudauth.Principal{ID: "service", Kind: cloudauth.PrincipalKindServiceAccount, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + auth := resolvingAuth{ + principals: map[string]cloudauth.Principal{"human-token": human, "legacy-token": legacy, "service-token": service}, + errors: map[string]error{"revoked-token": cloudauth.ErrTokenRevoked, "disabled-token": cloudauth.ErrPrincipalDisabled}, + } + for _, tc := range []struct { + name, token string + want, calls int + }{ + {name: "human with dual grants", token: "human-token", want: 200, calls: 1}, + {name: "missing header", want: 401}, + {name: "revoked token", token: "revoked-token", want: 401}, + {name: "disabled principal", token: "disabled-token", want: 401}, + {name: "legacy token", token: "legacy-token", want: 403}, + {name: "service token", token: "service-token", want: 403}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &attestationTestStore{} + srv := New(st, auth, 0, WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{"human": {"alpha", "beta"}}})) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-source-attestations", strings.NewReader(body)) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.registration != tc.calls || st.claim != tc.calls || st.attest != tc.calls { + t.Fatalf("status=%d body=%q calls=%d/%d/%d; want %d and %d each", w.Code, w.Body.String(), st.registration, st.claim, st.attest, tc.want, tc.calls) + } + if tc.calls == 1 { + if st.actor != human.ID { + t.Fatalf("actor=%q, want %q", st.actor, human.ID) + } + var response struct { + Status string `json:"status"` + AttestationID int64 `json:"attestation_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.Status != "ok" || response.AttestationID != 42 { + t.Fatalf("response=%q, decoded=%+v, err=%v", w.Body.String(), response, err) + } + } + }) + } +} + +func TestPromptSourceAttestationAdmission(t *testing.T) { + body := `{"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"alpha","prompt_project":"beta"}` + human := cloudauth.Principal{ID: "human", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + cases := []struct { + name, body string + principal cloudauth.Principal + grants []string + regErr, claimErr, attErr error + limit int64 + want, reg, claim, att int + }{ + {name: "dual grants", body: body, principal: human, grants: []string{"alpha", "beta"}, want: 200, reg: 1, claim: 1, att: 1}, + {name: "owner grant absent", body: body, principal: human, grants: []string{"beta"}, want: 403}, + {name: "prompt grant absent", body: body, principal: human, grants: []string{"alpha"}, want: 403}, + {name: "no principal", body: body, want: 401}, + {name: "blank principal", body: body, principal: cloudauth.Principal{Kind: cloudauth.PrincipalKindHuman}, want: 401}, + {name: "service", body: body, principal: cloudauth.Principal{ID: "service", Kind: cloudauth.PrincipalKindServiceAccount, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true}, want: 403}, + {name: "owner conflict", body: body, principal: human, grants: []string{"alpha", "beta"}, regErr: cloudstore.ErrSessionAuthorityConflict, want: 409, reg: 1}, + {name: "pair conflict", body: body, principal: human, grants: []string{"alpha", "beta"}, claimErr: cloudstore.ErrPromptPairClaimConflict, want: 409, reg: 1, claim: 1}, + {name: "attestation unbound", body: body, principal: human, grants: []string{"alpha", "beta"}, attErr: cloudstore.ErrPromptSourceAttestationUnbound, want: 409, reg: 1, claim: 1, att: 1}, + {name: "persistence failure", body: body, principal: human, grants: []string{"alpha", "beta"}, attErr: errors.New("database unavailable"), want: 500, reg: 1, claim: 1, att: 1}, + {name: "unknown actor", body: strings.TrimSuffix(body, "}") + `,"actor_id":"spoof"}`, principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "malformed", body: "{", principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "blank", body: strings.Replace(body, `"sync_id":"sync"`, `"sync_id":" "`, 1), principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "trailing", body: body + ` {}`, principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "oversize", body: body, principal: human, grants: []string{"alpha", "beta"}, limit: 20, want: 413}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := &attestationTestStore{registrationErr: tc.regErr, claimErr: tc.claimErr, attestErr: tc.attErr} + opts := []Option{WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{tc.principal.ID: tc.grants}})} + if tc.limit > 0 { + opts = append(opts, WithMaxPushBodyBytes(tc.limit)) + } + srv := New(st, claimAuthOnly{}, 0, opts...) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-source-attestations", strings.NewReader(tc.body)) + req = req.WithContext(WithPrincipal(req.Context(), tc.principal)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.registration != tc.reg || st.claim != tc.claim || st.attest != tc.att { + t.Fatalf("status=%d body=%q calls=%d/%d/%d, want %d and %d/%d/%d", w.Code, w.Body.String(), st.registration, st.claim, st.attest, tc.want, tc.reg, tc.claim, tc.att) + } + if st.attest > 0 && st.actor != "human" { + t.Fatalf("actor=%q", st.actor) + } + }) + } +} diff --git a/internal/cloud/cloudserver/session_authority.go b/internal/cloud/cloudserver/session_authority.go new file mode 100644 index 000000000..37c0c9f02 --- /dev/null +++ b/internal/cloud/cloudserver/session_authority.go @@ -0,0 +1,84 @@ +package cloudserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" + "github.com/Gentleman-Programming/engram/v2/internal/store" +) + +// Session registration is intentionally independent of the chunk-derived index. +type sessionAuthorityRegistrar interface { + RegisterSessionAuthority(context.Context, string, string, string) error +} + +func (s *CloudServer) handleRegisterSessionAuthority(w http.ResponseWriter, r *http.Request) { + var payload struct { + SessionID string `json:"session_id"` + Project string `json:"project"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.pushBodyLimit())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + http.Error(w, "session authority request too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "invalid session authority request", http.StatusBadRequest) + return + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + http.Error(w, "session authority request too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "invalid session authority request", http.StatusBadRequest) + return + } + sessionID, project := strings.TrimSpace(payload.SessionID), strings.TrimSpace(payload.Project) + if sessionID == "" || project == "" { + http.Error(w, "session_id and project are required", http.StatusBadRequest) + return + } + project, _ = store.NormalizeProject(project) + project = strings.TrimSpace(project) + if project == "" { + http.Error(w, "project is required", http.StatusBadRequest) + return + } + principal, hasPrincipal := PrincipalFromContext(r.Context()) + usablePrincipal := hasPrincipal && strings.TrimSpace(principal.ID) != "" + if !usablePrincipal && s.auth == nil { + http.Error(w, "authentication unavailable", http.StatusUnauthorized) + return + } + if !s.authorizeProjectScope(r.Context(), w, project) { + return + } + actor := "legacy:authenticated" + if usablePrincipal { + actor = strings.TrimSpace(principal.ID) + } + registrar, ok := s.store.(sessionAuthorityRegistrar) + if !ok { + http.Error(w, "session authority store unavailable", http.StatusInternalServerError) + return + } + if err := registrar.RegisterSessionAuthority(r.Context(), sessionID, project, actor); err != nil { + if errors.Is(err, cloudstore.ErrSessionAuthorityConflict) { + http.Error(w, "session authority conflict", http.StatusConflict) + } else { + http.Error(w, "session authority storage unavailable", http.StatusInternalServerError) + } + return + } + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok"}) +} diff --git a/internal/cloud/cloudserver/session_authority_test.go b/internal/cloud/cloudserver/session_authority_test.go new file mode 100644 index 000000000..4878df201 --- /dev/null +++ b/internal/cloud/cloudserver/session_authority_test.go @@ -0,0 +1,207 @@ +package cloudserver + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/Gentleman-Programming/engram/v2/internal/cloud" + cloudauth "github.com/Gentleman-Programming/engram/v2/internal/cloud/auth" + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" +) + +type authorityTestStore struct { + fakeStore + calls int + session, project, actor string + err error +} + +func (s *authorityTestStore) RegisterSessionAuthority(_ context.Context, session, project, actor string) error { + s.calls++ + s.session, s.project, s.actor = session, project, actor + return s.err +} + +// Grant matching normalizes authorization keys, not persisted owner identity. +type normalizedAuthorityGrants struct{ grants map[string]string } + +func (a normalizedAuthorityGrants) AuthorizeProjectForPrincipal(_ context.Context, principal cloudauth.Principal, project string) error { + if a.grants[principal.ID] == cloudstore.NormalizeProjectGrant(project) { + return nil + } + return fmt.Errorf("project grant denied") +} + +func (a normalizedAuthorityGrants) EnrolledProjectsForPrincipal(_ context.Context, principal cloudauth.Principal) ([]string, error) { + return []string{a.grants[principal.ID]}, nil +} + +func TestSessionAuthorityProjectPolicy(t *testing.T) { + legacy, err := cloudauth.NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32)) + if err != nil { + t.Fatal(err) + } + legacy.SetBearerToken("legacy-token") + legacy.SetAllowedProjects([]string{"alpha"}) + for _, tc := range []struct { + name, token, project string + auth Authenticator + want, calls int + }{ + {"legacy allowed", "legacy-token", "alpha", legacy, 200, 1}, + {"legacy denied", "legacy-token", "beta", legacy, 403, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &authorityTestStore{} + srv := New(st, tc.auth, 0, WithPrincipalProjectAuthorizer(normalizedAuthorityGrants{grants: map[string]string{}})) + req := httptest.NewRequest(http.MethodPost, "/sync/session-authorities", strings.NewReader(`{"session_id":"s","project":"`+tc.project+`"}`)) + req.Header.Set("Authorization", "Bearer "+tc.token) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.calls != tc.calls { + t.Fatalf("status=%d calls=%d body=%q", w.Code, st.calls, w.Body.String()) + } + }) + } + managed := cloudauth.Principal{ID: "managed", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + st := &authorityTestStore{} + srv := New(st, resolvingAuth{principals: map[string]cloudauth.Principal{"token": managed}}, 0, + WithPrincipalProjectAuthorizer(normalizedAuthorityGrants{grants: map[string]string{"managed": "alpha-foo"}})) + req := httptest.NewRequest(http.MethodPost, "/sync/session-authorities", strings.NewReader(`{"session_id":"s","project":"alpha/foo"}`)) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != 200 || st.project != "alpha/foo" || st.calls != 1 { + t.Fatalf("status=%d project=%q calls=%d body=%q", w.Code, st.project, st.calls, w.Body.String()) + } + for _, project := range []string{"alpha", "beta"} { + t.Run("insecure "+project, func(t *testing.T) { + insecureStore := &authorityTestStore{} + insecure := New(insecureStore, nil, 0, WithProjectAuthorizer(legacy)) + w := httptest.NewRecorder() + body := `{"session_id":"s","project":"` + project + `"}` + insecure.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/sync/session-authorities", strings.NewReader(body))) + if w.Code != 401 || insecureStore.calls != 0 { + t.Fatalf("insecure registration status=%d calls=%d body=%q", w.Code, insecureStore.calls, w.Body.String()) + } + }) + } +} + +func TestSessionAuthorityRegistrationPersistsOverHTTP(t *testing.T) { + dsn := os.Getenv("CLOUDSTORE_TEST_DSN") + if dsn == "" { + t.Skip("CLOUDSTORE_TEST_DSN not set (requires Postgres)") + } + if !strings.HasPrefix(dsn, "postgres://") && !strings.HasPrefix(dsn, "postgresql://") { + t.Skip("test requires URL-style CLOUDSTORE_TEST_DSN") + } + ctx := context.Background() + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = admin.Close() }) + schema := fmt.Sprintf("cloudserver_authority_%d", time.Now().UnixNano()) + if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`) }) + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + cs, err := cloudstore.New(cloud.Config{DSN: dsn + separator + "search_path=" + schema}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + principal := cloudauth.Principal{ID: "p-owner", Kind: cloudauth.PrincipalKindHuman, Role: cloudauth.RoleMember, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + srv := New(cs, resolvingAuth{principals: map[string]cloudauth.Principal{"token": principal}}, 0, + WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{principal.ID: {"alpha", "beta"}}})) + post := func(project string) int { + t.Helper() + body := `{"session_id":"real-store-session","project":"` + project + `"}` + req := httptest.NewRequest(http.MethodPost, "/sync/session-authorities", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + return w.Code + } + if code := post("alpha"); code != http.StatusOK { + t.Fatalf("registration status=%d", code) + } + if code := post("alpha"); code != http.StatusOK { + t.Fatalf("replay status=%d", code) + } + if code := post("beta"); code != http.StatusConflict { + t.Fatalf("owner conflict status=%d", code) + } + authority, err := cs.GetSessionAuthority(ctx, "real-store-session") + if err != nil || authority == nil || authority.OwnerProject != "alpha" || authority.RegisteredBy != principal.ID { + t.Fatalf("persisted authority=%+v err=%v", authority, err) + } +} + +func TestSessionAuthorityRegistration(t *testing.T) { + principal := cloudauth.Principal{ID: "p-alpha", Kind: cloudauth.PrincipalKindHuman, Role: cloudauth.RoleMember, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + authn := resolvingAuth{principals: map[string]cloudauth.Principal{"token": principal}} + cases := []struct { + name, body, token string + grants []string + storeErr error + unsupported bool + limit int64 + want int + calls int + }{ + {name: "missing token", body: `{"session_id":"s","project":"alpha"}`, grants: []string{"alpha"}, want: 401}, + {name: "beta only", body: `{"session_id":"s","project":"alpha"}`, token: "token", grants: []string{"beta"}, want: 403}, + {name: "alpha registration", body: `{"session_id":" s ","project":" alpha "}`, token: "token", grants: []string{"alpha"}, want: 200, calls: 1}, + {name: "conflicting owner", body: `{"session_id":"s","project":"alpha"}`, token: "token", grants: []string{"alpha"}, storeErr: cloudstore.ErrSessionAuthorityConflict, want: 409, calls: 1}, + {name: "storage failure", body: `{"session_id":"s","project":"alpha"}`, token: "token", grants: []string{"alpha"}, storeErr: errors.New("db down"), want: 500, calls: 1}, + {name: "unsupported store", body: `{"session_id":"s","project":"alpha"}`, token: "token", grants: []string{"alpha"}, unsupported: true, want: 500}, + {name: "empty session", body: `{"session_id":" ","project":"alpha"}`, token: "token", grants: []string{"alpha"}, want: 400}, + {name: "empty project", body: `{"session_id":"s","project":" "}`, token: "token", grants: []string{"alpha"}, want: 400}, + {name: "actor spoof", body: `{"session_id":"s","project":"alpha","actor":"fake"}`, token: "token", grants: []string{"alpha"}, want: 400}, + {name: "trailing JSON", body: `{"session_id":"s","project":"alpha"} {}`, token: "token", grants: []string{"alpha"}, want: 400}, + {name: "malformed", body: `{"session_id":`, token: "token", grants: []string{"alpha"}, want: 400}, + {name: "oversize", body: `{"session_id":"s","project":"alpha"}`, token: "token", grants: []string{"alpha"}, limit: 12, want: 413}, + {name: "oversize trailing", body: `{"session_id":"s","project":"alpha"} ` + strings.Repeat(" ", 40), token: "token", grants: []string{"alpha"}, limit: 38, want: 413}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := &authorityTestStore{} + var backing ChunkStore = st + if tc.unsupported { + backing = &fakeStore{sessions: map[string]map[string]struct{}{"alpha": {"s": {}}}} + } + opts := []Option{WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{principal.ID: tc.grants}})} + if tc.limit > 0 { + opts = append(opts, WithMaxPushBodyBytes(tc.limit)) + } + st.err = tc.storeErr + srv := New(backing, authn, 0, opts...) + req := httptest.NewRequest(http.MethodPost, "/sync/session-authorities", strings.NewReader(tc.body)) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.calls != tc.calls { + t.Fatalf("status=%d body=%q calls=%d; want status=%d calls=%d", w.Code, w.Body.String(), st.calls, tc.want, tc.calls) + } + if tc.calls == 1 && (st.session != "s" || st.project != "alpha" || st.actor != principal.ID) { + t.Fatalf("registration = %q %q %q", st.session, st.project, st.actor) + } + }) + } +} diff --git a/internal/cloud/cloudstore/cloudstore.go b/internal/cloud/cloudstore/cloudstore.go index 6ede7268c..26e3a6e6e 100644 --- a/internal/cloud/cloudstore/cloudstore.go +++ b/internal/cloud/cloudstore/cloudstore.go @@ -766,6 +766,31 @@ func (cs *CloudStore) migrate(ctx context.Context) error { END IF; END $$`, `CREATE UNIQUE INDEX IF NOT EXISTS cloud_chunks_project_chunk_uidx ON cloud_chunks (project_name, chunk_id)`, + `CREATE TABLE IF NOT EXISTS cloud_session_authority ( + session_id TEXT PRIMARY KEY, + owner_project TEXT NOT NULL, + registered_by TEXT NOT NULL, + registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS cloud_prompt_pair_claims ( + session_id TEXT NOT NULL REFERENCES cloud_session_authority(session_id), + source_inbox_id TEXT NOT NULL, + sync_id TEXT NOT NULL UNIQUE, + prompt_project TEXT NOT NULL, + claimed_by TEXT NOT NULL, + claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (session_id, source_inbox_id) + )`, + `CREATE TABLE IF NOT EXISTS cloud_prompt_source_attestations ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id TEXT NOT NULL, + source_inbox_id TEXT NOT NULL, + sync_id TEXT NOT NULL, + owner_project TEXT NOT NULL, + prompt_project TEXT NOT NULL, + actor_id TEXT NOT NULL, + attested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, `CREATE TABLE IF NOT EXISTS cloud_project_sessions ( project_name TEXT NOT NULL, session_id TEXT NOT NULL, diff --git a/internal/cloud/cloudstore/prompt_pair_claim.go b/internal/cloud/cloudstore/prompt_pair_claim.go new file mode 100644 index 000000000..fad741812 --- /dev/null +++ b/internal/cloud/cloudstore/prompt_pair_claim.go @@ -0,0 +1,83 @@ +package cloudstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +var ErrSessionAuthorityNotFound = errors.New("cloudstore: session authority not found") +var ErrPromptPairClaimConflict = errors.New("cloudstore: prompt pair claim conflict") + +type PromptPairClaim struct { + SessionID string + SourceInboxID string + SyncID string + PromptProject string + ClaimedBy string + ClaimedAt time.Time +} + +// ClaimPromptPair persists an already-authorized binding. The caller must verify +// the actor's grants for both the registered owner and the prompt project. +func (cs *CloudStore) ClaimPromptPair(ctx context.Context, sessionID, sourceInboxID, syncID, promptProject, actor string) error { + if cs == nil || cs.db == nil { + return fmt.Errorf("cloudstore: not initialized") + } + sessionID, sourceInboxID, syncID, promptProject, actor = strings.TrimSpace(sessionID), strings.TrimSpace(sourceInboxID), strings.TrimSpace(syncID), strings.TrimSpace(promptProject), strings.TrimSpace(actor) + if sessionID == "" || sourceInboxID == "" || syncID == "" || promptProject == "" || actor == "" { + return fmt.Errorf("cloudstore: session id, inbox id, sync id, prompt project and actor are required") + } + var inserted string + err := cs.db.QueryRowContext(ctx, ` + INSERT INTO cloud_prompt_pair_claims (session_id, source_inbox_id, sync_id, prompt_project, claimed_by) + SELECT session_id, $2, $3, $4, $5 FROM cloud_session_authority WHERE session_id = $1 + ON CONFLICT DO NOTHING RETURNING sync_id`, sessionID, sourceInboxID, syncID, promptProject, actor).Scan(&inserted) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("cloudstore: insert prompt pair claim: %w", err) + } + existing, err := cs.GetPromptPairClaim(ctx, sessionID, sourceInboxID) + if err != nil { + return err + } + if existing != nil { + if existing.SyncID == syncID && existing.PromptProject == promptProject { + return nil + } + return fmt.Errorf("%w: pair already bound", ErrPromptPairClaimConflict) + } + var registered bool + if err := cs.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM cloud_session_authority WHERE session_id = $1)`, sessionID).Scan(®istered); err != nil { + return fmt.Errorf("cloudstore: check session authority: %w", err) + } + if !registered { + return fmt.Errorf("%w: %q", ErrSessionAuthorityNotFound, sessionID) + } + return fmt.Errorf("%w: sync id already bound", ErrPromptPairClaimConflict) +} + +// GetPromptPairClaim returns nil for an unclaimed pair; chunk indexes never confer a claim. +func (cs *CloudStore) GetPromptPairClaim(ctx context.Context, sessionID, sourceInboxID string) (*PromptPairClaim, error) { + if cs == nil || cs.db == nil { + return nil, fmt.Errorf("cloudstore: not initialized") + } + sessionID, sourceInboxID = strings.TrimSpace(sessionID), strings.TrimSpace(sourceInboxID) + if sessionID == "" || sourceInboxID == "" { + return nil, fmt.Errorf("cloudstore: session id and inbox id are required") + } + var claim PromptPairClaim + err := cs.db.QueryRowContext(ctx, `SELECT session_id, source_inbox_id, sync_id, prompt_project, claimed_by, claimed_at FROM cloud_prompt_pair_claims WHERE session_id = $1 AND source_inbox_id = $2`, sessionID, sourceInboxID).Scan(&claim.SessionID, &claim.SourceInboxID, &claim.SyncID, &claim.PromptProject, &claim.ClaimedBy, &claim.ClaimedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cloudstore: get prompt pair claim: %w", err) + } + return &claim, nil +} diff --git a/internal/cloud/cloudstore/prompt_pair_claim_test.go b/internal/cloud/cloudstore/prompt_pair_claim_test.go new file mode 100644 index 000000000..940cbc9bf --- /dev/null +++ b/internal/cloud/cloudstore/prompt_pair_claim_test.go @@ -0,0 +1,147 @@ +package cloudstore + +import ( + "context" + "errors" + "sync" + "testing" +) + +func TestPromptPairClaimRequiresRegistration(t *testing.T) { + cs := openIsolatedCloudStore(t) + ctx := context.Background() + if _, err := cs.db.ExecContext(ctx, `INSERT INTO cloud_project_sessions (project_name, session_id) VALUES ('alpha', 'unverified')`); err != nil { + t.Fatal(err) + } + if err := cs.ClaimPromptPair(ctx, "unverified", "inbox", "sync", "beta", "actor"); !errors.Is(err, ErrSessionAuthorityNotFound) { + t.Fatalf("chunk index authorized claim: %v", err) + } + claim, err := cs.GetPromptPairClaim(ctx, "unverified", "inbox") + if err != nil || claim != nil { + t.Fatalf("unverified claim persisted: %+v %v", claim, err) + } +} + +func TestPromptPairClaimCrossProjectReplayAndConflicts(t *testing.T) { + cs := openIsolatedCloudStore(t) + ctx := context.Background() + for _, registration := range [][2]string{{"alpha-session", "alpha/foo"}, {"other-session", "alpha-foo"}} { + if err := cs.RegisterSessionAuthority(ctx, registration[0], registration[1], "registrar"); err != nil { + t.Fatal(err) + } + } + if err := cs.ClaimPromptPair(ctx, "alpha-session", "inbox", "sync", "beta", "first"); err != nil { + t.Fatal(err) + } + original, err := cs.GetPromptPairClaim(ctx, "alpha-session", "inbox") + if err != nil || original == nil || original.PromptProject != "beta" || original.ClaimedBy != "first" || original.ClaimedAt.IsZero() { + t.Fatalf("claim: %+v %v", original, err) + } + if err := cs.ClaimPromptPair(ctx, "alpha-session", "inbox", "sync", "beta", "second"); err != nil { + t.Fatal(err) + } + for _, args := range [][4]string{ + {"alpha-session", "inbox", "other-sync", "beta"}, + {"alpha-session", "inbox", "sync", "gamma"}, + {"alpha-session", "other-inbox", "sync", "beta"}, + {"other-session", "inbox", "sync", "beta"}, + } { + if err := cs.ClaimPromptPair(ctx, args[0], args[1], args[2], args[3], "competitor"); !errors.Is(err, ErrPromptPairClaimConflict) { + t.Fatalf("%v: %v", args, err) + } + } + after, err := cs.GetPromptPairClaim(ctx, "alpha-session", "inbox") + if err != nil || *after != *original { + t.Fatalf("audit changed: %+v %v", after, err) + } +} + +func TestPromptPairClaimConcurrentWriters(t *testing.T) { + cs := openIsolatedCloudStore(t) + ctx := context.Background() + if err := cs.RegisterSessionAuthority(ctx, "session", "alpha", "registrar"); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + results := make(chan error, 20) + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + syncID := "same" + if i%2 == 1 { + syncID = "different" + } + results <- cs.ClaimPromptPair(ctx, "session", "inbox", syncID, "beta", "actor") + }(i) + } + wg.Wait() + close(results) + success, conflicts := 0, 0 + for err := range results { + switch { + case err == nil: + success++ + case errors.Is(err, ErrPromptPairClaimConflict): + conflicts++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + if success != 10 || conflicts != 10 { + t.Fatalf("success=%d conflicts=%d", success, conflicts) + } +} + +func TestPromptPairClaimConcurrentSyncReuseAcrossPairs(t *testing.T) { + cs := openIsolatedCloudStore(t) + ctx := context.Background() + if err := cs.RegisterSessionAuthority(ctx, "session", "alpha", "registrar"); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + results := make(chan error, 20) + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + inbox := "first" + if i%2 == 1 { + inbox = "second" + } + results <- cs.ClaimPromptPair(ctx, "session", inbox, "shared-sync", "beta", "actor") + }(i) + } + wg.Wait() + close(results) + var successes, conflicts int + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrPromptPairClaimConflict): + conflicts++ + default: + t.Fatalf("unexpected claim error: %v", err) + } + } + if successes != 10 || conflicts != 10 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } +} + +func TestPromptPairClaimRejectsBlankInputs(t *testing.T) { + cs := openIsolatedCloudStore(t) + for i := 0; i < 5; i++ { + args := [5]string{"session", "inbox", "sync", "beta", "actor"} + args[i] = " " + if err := cs.ClaimPromptPair(context.Background(), args[0], args[1], args[2], args[3], args[4]); err == nil { + t.Fatalf("accepted blank field %d", i) + } + } + for _, args := range [][2]string{{" ", "inbox"}, {"session", " "}} { + if _, err := cs.GetPromptPairClaim(context.Background(), args[0], args[1]); err == nil { + t.Fatalf("accepted blank lookup: %v", args) + } + } +} diff --git a/internal/cloud/cloudstore/prompt_source_attestation.go b/internal/cloud/cloudstore/prompt_source_attestation.go new file mode 100644 index 000000000..9fbda478c --- /dev/null +++ b/internal/cloud/cloudstore/prompt_source_attestation.go @@ -0,0 +1,81 @@ +package cloudstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +var ErrPromptSourceAttestationUnbound = errors.New("cloudstore: prompt source attestation requires exact session authority and prompt pair claim") + +// PromptSourceAttestation is an independent, append-only record of an explicit +// human source assertion; it does not alter registration or claim audit history. +type PromptSourceAttestation struct { + ID int64 + SessionID string + SourceInboxID string + SyncID string + OwnerProject string + PromptProject string + ActorID string + AttestedAt time.Time +} + +// VerifyPromptSourceAttestation checks only the immutable audit row identity. +// Authorization of both projects must happen before this global lookup. +func (cs *CloudStore) VerifyPromptSourceAttestation(ctx context.Context, id int64, sessionID, sourceInboxID, syncID, ownerProject, promptProject string) (bool, error) { + if cs == nil || cs.db == nil { + return false, fmt.Errorf("cloudstore: not initialized") + } + if id <= 0 || sessionID == "" || sourceInboxID == "" || syncID == "" || ownerProject == "" || promptProject == "" { + return false, nil + } + var found int + err := cs.db.QueryRowContext(ctx, `SELECT 1 FROM cloud_prompt_source_attestations + WHERE id = $1 AND session_id = $2 AND source_inbox_id = $3 AND sync_id = $4 + AND owner_project = $5 AND prompt_project = $6`, id, sessionID, sourceInboxID, syncID, ownerProject, promptProject).Scan(&found) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("cloudstore: verify prompt source attestation: %w", err) + } + return found == 1, nil +} + +// AttestPromptSource records an already-authorized human assertion. Storage +// does not check grants: the future authenticated server route must verify the +// actor's current grants to both owner and prompt projects before calling it. +func (cs *CloudStore) AttestPromptSource(ctx context.Context, sessionID, sourceInboxID, syncID, ownerProject, promptProject, actorID string) (*PromptSourceAttestation, error) { + if cs == nil || cs.db == nil { + return nil, fmt.Errorf("cloudstore: not initialized") + } + values := []*string{&sessionID, &sourceInboxID, &syncID, &ownerProject, &promptProject, &actorID} + for _, value := range values { + *value = strings.TrimSpace(*value) + if *value == "" { + return nil, fmt.Errorf("cloudstore: all attestation fields are required") + } + } + const query = `INSERT INTO cloud_prompt_source_attestations + (session_id, source_inbox_id, sync_id, owner_project, prompt_project, actor_id) + SELECT a.session_id, c.source_inbox_id, c.sync_id, a.owner_project, c.prompt_project, $6 + FROM cloud_session_authority a + JOIN cloud_prompt_pair_claims c ON c.session_id = a.session_id + WHERE a.session_id = $1 AND c.source_inbox_id = $2 AND c.sync_id = $3 + AND a.owner_project = $4 AND c.prompt_project = $5 + RETURNING id, session_id, source_inbox_id, sync_id, owner_project, prompt_project, actor_id, attested_at` + var record PromptSourceAttestation + err := cs.db.QueryRowContext(ctx, query, sessionID, sourceInboxID, syncID, ownerProject, promptProject, actorID). + Scan(&record.ID, &record.SessionID, &record.SourceInboxID, &record.SyncID, &record.OwnerProject, &record.PromptProject, &record.ActorID, &record.AttestedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPromptSourceAttestationUnbound + } + if err != nil { + return nil, fmt.Errorf("cloudstore: attest prompt source: %w", err) + } + return &record, nil +} diff --git a/internal/cloud/cloudstore/prompt_source_attestation_test.go b/internal/cloud/cloudstore/prompt_source_attestation_test.go new file mode 100644 index 000000000..b3ae1c0f7 --- /dev/null +++ b/internal/cloud/cloudstore/prompt_source_attestation_test.go @@ -0,0 +1,115 @@ +package cloudstore + +import ( + "context" + "errors" + "testing" +) + +func TestVerifyPromptSourceAttestation(t *testing.T) { + cs := openIsolatedCloudStore(t) + ctx := context.Background() + if err := cs.RegisterSessionAuthority(ctx, "session", "owner", "registrar"); err != nil { + t.Fatal(err) + } + if err := cs.ClaimPromptPair(ctx, "session", "inbox", "sync", "prompt", "claimer"); err != nil { + t.Fatal(err) + } + row, err := cs.AttestPromptSource(ctx, "session", "inbox", "sync", "owner", "prompt", "human") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + id int64 + fields [5]string + found bool + }{ + {"exact", row.ID, [5]string{"session", "inbox", "sync", "owner", "prompt"}, true}, + {"missing", row.ID + 1, [5]string{"session", "inbox", "sync", "owner", "prompt"}, false}, + {"wrong inbox", row.ID, [5]string{"session", "other", "sync", "owner", "prompt"}, false}, + {"wrong owner", row.ID, [5]string{"session", "inbox", "sync", "other", "prompt"}, false}, + {"wrong prompt", row.ID, [5]string{"session", "inbox", "sync", "owner", "other"}, false}, + {"wrong session", row.ID, [5]string{"other", "inbox", "sync", "owner", "prompt"}, false}, + {"wrong sync", row.ID, [5]string{"session", "inbox", "other", "owner", "prompt"}, false}, + {"invalid id", 0, [5]string{"session", "inbox", "sync", "owner", "prompt"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + f := tc.fields + got, err := cs.VerifyPromptSourceAttestation(ctx, tc.id, f[0], f[1], f[2], f[3], f[4]) + if err != nil { + t.Fatal(err) + } + if got != tc.found { + t.Fatalf("verified=%v want %v", got, tc.found) + } + }) + } +} + +func TestPromptSourceAttestationExactAuthorityAndAppendOnlyAudit(t *testing.T) { + cs := openIsolatedCloudStore(t) + ctx := context.Background() + if _, err := cs.db.ExecContext(ctx, `INSERT INTO cloud_project_sessions (project_name, session_id) VALUES ('owner', 'unregistered')`); err != nil { + t.Fatal(err) + } + if _, err := cs.AttestPromptSource(ctx, "unregistered", "inbox", "sync", "owner", "prompt", "human"); !errors.Is(err, ErrPromptSourceAttestationUnbound) { + t.Fatalf("indexed session conferred authority: %v", err) + } + if err := cs.RegisterSessionAuthority(ctx, "session", "owner", "registrar"); err != nil { + t.Fatal(err) + } + if _, err := cs.AttestPromptSource(ctx, "session", "inbox", "sync", "owner", "prompt", "human"); !errors.Is(err, ErrPromptSourceAttestationUnbound) { + t.Fatalf("unclaimed pair conferred authority: %v", err) + } + if err := cs.ClaimPromptPair(ctx, "session", "inbox", "sync", "prompt", "claimer"); err != nil { + t.Fatal(err) + } + before, err := cs.GetPromptPairClaim(ctx, "session", "inbox") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + args [6]string + }{ + {"wrong session", [6]string{"other", "inbox", "sync", "owner", "prompt", "human"}}, + {"wrong inbox", [6]string{"session", "other", "sync", "owner", "prompt", "human"}}, + {"wrong sync", [6]string{"session", "inbox", "other", "owner", "prompt", "human"}}, + {"wrong owner", [6]string{"session", "inbox", "sync", "prompt", "prompt", "human"}}, + {"wrong prompt", [6]string{"session", "inbox", "sync", "owner", "owner", "human"}}, + } { + t.Run(tc.name, func(t *testing.T) { + a := tc.args + if _, err := cs.AttestPromptSource(ctx, a[0], a[1], a[2], a[3], a[4], a[5]); !errors.Is(err, ErrPromptSourceAttestationUnbound) { + t.Fatalf("accepted mismatch: %v", err) + } + }) + } + for i := 0; i < 6; i++ { + a := [6]string{"session", "inbox", "sync", "owner", "prompt", "human"} + a[i] = " " + if _, err := cs.AttestPromptSource(ctx, a[0], a[1], a[2], a[3], a[4], a[5]); err == nil { + t.Fatalf("accepted blank field %d", i) + } + } + first, err := cs.AttestPromptSource(ctx, "session", "inbox", "sync", "owner", "prompt", "human-one") + if err != nil { + t.Fatal(err) + } + second, err := cs.AttestPromptSource(ctx, "session", "inbox", "sync", "owner", "prompt", "human-two") + if err != nil { + t.Fatal(err) + } + if first.ID == second.ID || first.AttestedAt.IsZero() || second.AttestedAt.IsZero() || first.ActorID != "human-one" || second.ActorID != "human-two" { + t.Fatalf("audit records not independent: %+v %+v", first, second) + } + var count int + if err := cs.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM cloud_prompt_source_attestations`).Scan(&count); err != nil || count != 2 { + t.Fatalf("count=%d err=%v", count, err) + } + after, err := cs.GetPromptPairClaim(ctx, "session", "inbox") + if err != nil || *before != *after { + t.Fatalf("claim audit changed: %+v %+v %v", before, after, err) + } +} diff --git a/internal/cloud/cloudstore/session_authority.go b/internal/cloud/cloudstore/session_authority.go new file mode 100644 index 000000000..0cbea75bd --- /dev/null +++ b/internal/cloud/cloudstore/session_authority.go @@ -0,0 +1,72 @@ +package cloudstore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// ErrSessionAuthorityConflict means a session ID is already owned by another project. +var ErrSessionAuthorityConflict = errors.New("cloudstore: session authority conflict") + +type SessionAuthority struct { + SessionID string + OwnerProject string + RegisteredBy string + RegisteredAt time.Time +} + +// RegisterSessionAuthority persists an already-authorized registration. Callers must +// independently authenticate the actor and authorize the owner project. +func (cs *CloudStore) RegisterSessionAuthority(ctx context.Context, sessionID, project, actor string) error { + if cs == nil || cs.db == nil { + return fmt.Errorf("cloudstore: not initialized") + } + sessionID, project, actor = strings.TrimSpace(sessionID), strings.TrimSpace(project), strings.TrimSpace(actor) + if sessionID == "" || project == "" || actor == "" { + return fmt.Errorf("cloudstore: session id, project and actor are required") + } + // The primary key serializes concurrent writers without modifying the + // original registration on replay or conflict. + var owner string + err := cs.db.QueryRowContext(ctx, ` + INSERT INTO cloud_session_authority (session_id, owner_project, registered_by) + VALUES ($1, $2, $3) + ON CONFLICT (session_id) DO NOTHING + RETURNING owner_project`, sessionID, project, actor).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + err = cs.db.QueryRowContext(ctx, `SELECT owner_project FROM cloud_session_authority WHERE session_id = $1`, sessionID).Scan(&owner) + } + if err != nil { + return fmt.Errorf("cloudstore: register session authority: %w", err) + } + if owner != project { + return fmt.Errorf("%w: session %q is owned by another project", ErrSessionAuthorityConflict, sessionID) + } + return nil +} + +// GetSessionAuthority reports nil for an unregistered session, regardless of +// whether a chunk-derived session index contains that ID. +func (cs *CloudStore) GetSessionAuthority(ctx context.Context, sessionID string) (*SessionAuthority, error) { + if cs == nil || cs.db == nil { + return nil, fmt.Errorf("cloudstore: not initialized") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, fmt.Errorf("cloudstore: session id is required") + } + var authority SessionAuthority + err := cs.db.QueryRowContext(ctx, `SELECT session_id, owner_project, registered_by, registered_at FROM cloud_session_authority WHERE session_id = $1`, sessionID). + Scan(&authority.SessionID, &authority.OwnerProject, &authority.RegisteredBy, &authority.RegisteredAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cloudstore: get session authority: %w", err) + } + return &authority, nil +} diff --git a/internal/cloud/cloudstore/session_authority_test.go b/internal/cloud/cloudstore/session_authority_test.go new file mode 100644 index 000000000..fa2a483cc --- /dev/null +++ b/internal/cloud/cloudstore/session_authority_test.go @@ -0,0 +1,100 @@ +package cloudstore + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + + "github.com/Gentleman-Programming/engram/v2/internal/cloud" +) + +func TestSessionAuthorityExplicitRegistration(t *testing.T) { + ctx := context.Background() + cs := openIsolatedCloudStore(t) + if _, err := cs.db.ExecContext(ctx, `INSERT INTO cloud_project_sessions (project_name, session_id) VALUES ('imported', 'session-1')`); err != nil { + t.Fatal(err) + } + if authority, err := cs.GetSessionAuthority(ctx, "session-1"); err != nil || authority != nil { + t.Fatalf("index must not confer authority: %+v, %v", authority, err) + } + if err := cs.RegisterSessionAuthority(ctx, " session-1 ", " alpha ", " actor-1 "); err != nil { + t.Fatal(err) + } + if err := cs.RegisterSessionAuthority(ctx, "session-1", "alpha", "actor-2"); err != nil { + t.Fatalf("same owner replay: %v", err) + } + authority, err := cs.GetSessionAuthority(ctx, "session-1") + if err != nil || authority == nil || authority.OwnerProject != "alpha" || authority.RegisteredBy != "actor-1" || authority.RegisteredAt.IsZero() { + t.Fatalf("original registration: %+v, %v", authority, err) + } + if err := cs.RegisterSessionAuthority(ctx, "session-1", "beta", "actor-3"); !errors.Is(err, ErrSessionAuthorityConflict) { + t.Fatalf("expected conflict, got %v", err) + } + var schema string + if err := cs.db.QueryRowContext(ctx, `SHOW search_path`).Scan(&schema); err != nil { + t.Fatal(err) + } + dsn := os.Getenv("CLOUDSTORE_TEST_DSN") + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + reopened, err := New(cloud.Config{DSN: dsn + sep + "search_path=" + schema}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reopened.Close() }) + persisted, err := reopened.GetSessionAuthority(ctx, "session-1") + if err != nil || persisted == nil || *persisted != *authority { + t.Fatalf("reopened authority: %+v, %v", persisted, err) + } +} + +func TestSessionAuthorityConcurrentOwners(t *testing.T) { + ctx := context.Background() + cs := openIsolatedCloudStore(t) + var wg sync.WaitGroup + results := make(chan error, 16) + for i := 0; i < 16; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + project := "alpha" + if i%2 == 1 { + project = "beta" + } + results <- cs.RegisterSessionAuthority(ctx, "raced", project, "actor") + }(i) + } + wg.Wait() + close(results) + var success, conflict int + for err := range results { + switch { + case err == nil: + success++ + case errors.Is(err, ErrSessionAuthorityConflict): + conflict++ + default: + t.Fatalf("unexpected registration error: %v", err) + } + } + if success != 8 || conflict != 8 { + t.Fatalf("success=%d conflict=%d", success, conflict) + } +} + +func TestSessionAuthorityRejectsBlankInputs(t *testing.T) { + cs := openIsolatedCloudStore(t) + for _, args := range [][3]string{{" ", "alpha", "actor"}, {"session", " ", "actor"}, {"session", "alpha", " "}} { + if err := cs.RegisterSessionAuthority(context.Background(), args[0], args[1], args[2]); err == nil { + t.Fatalf("accepted blank registration: %q", args) + } + } + if _, err := cs.GetSessionAuthority(context.Background(), " "); err == nil { + t.Fatal("accepted blank lookup") + } +} diff --git a/internal/cloud/remote/transport.go b/internal/cloud/remote/transport.go index cf1a28fee..9144d113f 100644 --- a/internal/cloud/remote/transport.go +++ b/internal/cloud/remote/transport.go @@ -360,6 +360,118 @@ func NewMutationTransport(baseURL, token string) (*MutationTransport, error) { }, nil } +// RegisterSessionAuthority registers a session under its owner project. +func (mt *MutationTransport) RegisterSessionAuthority(sessionID, ownerProject string) error { + return mt.postProvenance("register session authority", "session-authorities", struct { + SessionID string `json:"session_id"` + Project string `json:"project"` + }{sessionID, ownerProject}) +} + +// ClaimPromptPair binds a source inbox to a prompt sync identity. +func (mt *MutationTransport) ClaimPromptPair(sessionID, sourceInboxID, syncID, ownerProject, promptProject string) error { + return mt.postProvenance("claim prompt pair", "prompt-pair-claims", struct { + SessionID string `json:"session_id"` + SourceInboxID string `json:"source_inbox_id"` + SyncID string `json:"sync_id"` + OwnerProject string `json:"owner_project"` + Project string `json:"project"` + }{sessionID, sourceInboxID, syncID, ownerProject, promptProject}) +} + +// AttestPromptSource records the server-issued identity for a prompt source. +func (mt *MutationTransport) AttestPromptSource(sessionID, sourceInboxID, syncID, ownerProject, promptProject string) (int64, error) { + const operation = "attest prompt source" + for _, field := range []string{sessionID, sourceInboxID, syncID, ownerProject, promptProject} { + if strings.TrimSpace(field) == "" { + return 0, fmt.Errorf("cloud: %s: all fields are required", operation) + } + } + body, err := json.Marshal(struct { + SessionID string `json:"session_id"` + SourceInboxID string `json:"source_inbox_id"` + SyncID string `json:"sync_id"` + OwnerProject string `json:"owner_project"` + PromptProject string `json:"prompt_project"` + }{sessionID, sourceInboxID, syncID, ownerProject, promptProject}) + if err != nil { + return 0, fmt.Errorf("cloud: marshal %s: %w", operation, err) + } + req, err := http.NewRequest(http.MethodPost, mt.baseURL+"/sync/prompt-source-attestations", bytes.NewReader(body)) + if err != nil { + return 0, fmt.Errorf("cloud: build %s request", operation) + } + req.Header.Set("Content-Type", "application/json") + mt.setAuthorization(req) + resp, err := mt.httpClient.Do(req) + if err != nil { + // HTTP client errors may include server-controlled URLs or redirect text. + return 0, fmt.Errorf("cloud: %s request failed", operation) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return 0, &HTTPStatusError{Operation: operation, StatusCode: resp.StatusCode, Body: http.StatusText(resp.StatusCode)} + } + const maxResponseBytes = 64 << 10 + response, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil || len(response) > maxResponseBytes { + return 0, fmt.Errorf("cloud: invalid %s response", operation) + } + var result struct { + Status string `json:"status"` + AttestationID int64 `json:"attestation_id"` + } + decoder := json.NewDecoder(bytes.NewReader(response)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&result); err != nil || result.Status != "ok" || result.AttestationID <= 0 { + return 0, fmt.Errorf("cloud: invalid %s response", operation) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return 0, fmt.Errorf("cloud: invalid %s response", operation) + } + return result.AttestationID, nil +} + +func (mt *MutationTransport) postProvenance(operation, path string, payload any) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("cloud: marshal %s: %w", operation, err) + } + req, err := http.NewRequest(http.MethodPost, mt.baseURL+"/sync/"+path, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("cloud: build %s request: %w", operation, err) + } + req.Header.Set("Content-Type", "application/json") + mt.setAuthorization(req) + resp, err := mt.httpClient.Do(req) + if err != nil { + return fmt.Errorf("cloud: %s: %w", operation, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + response, _ := io.ReadAll(resp.Body) + return newProvenanceHTTPStatusError(operation, resp.StatusCode, response) + } + var result struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.Status != "ok" { + return fmt.Errorf("cloud: invalid %s response", operation) + } + return nil +} + +// Recognize only the claim route's explicit authority code; retain the existing +// mutation 404 mapping for absent routes and every other response. +func newProvenanceHTTPStatusError(operation string, status int, body []byte) error { + parsed := newHTTPStatusError(operation, status, body).(*HTTPStatusError) + if status == http.StatusNotFound && parsed.ErrorCode == "session_authority_unavailable" && operation == "claim prompt pair" { + return parsed + } + return newMutationHTTPStatusError(operation, status, body) +} + func (mt *MutationTransport) setAuthorization(req *http.Request) { if mt.token != "" { req.Header.Set("Authorization", "Bearer "+mt.token) diff --git a/internal/cloud/remote/transport_provenance_test.go b/internal/cloud/remote/transport_provenance_test.go new file mode 100644 index 000000000..a27298758 --- /dev/null +++ b/internal/cloud/remote/transport_provenance_test.go @@ -0,0 +1,185 @@ +package remote + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMutationProvenanceRequests(t *testing.T) { + for _, tc := range []struct { + name, path, body string + call func(*MutationTransport) error + }{ + {"register", "/sync/session-authorities", `{"session_id":"session","project":"owner"}`, func(mt *MutationTransport) error { return mt.RegisterSessionAuthority("session", "owner") }}, + {"claim", "/sync/prompt-pair-claims", `{"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"owner","project":"prompt"}`, func(mt *MutationTransport) error { + return mt.ClaimPromptPair("session", "inbox", "sync", "owner", "prompt") + }}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + if r.Method != http.MethodPost || r.URL.Path != tc.path || r.Header.Get("Authorization") != "Bearer secret" || r.Header.Get("Content-Type") != "application/json" || string(data) != tc.body { + t.Errorf("request method=%s path=%s auth=%q content=%q body=%q", r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type"), data) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + mt, err := NewMutationTransport(server.URL, "secret") + if err != nil { + t.Fatal(err) + } + mt.httpClient = server.Client() + if err := tc.call(mt); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestMutationProvenanceStatus(t *testing.T) { + for _, tc := range []struct { + name string + status int + body, code string + }{ + {"unauthorized", 401, `{"error":"denied"}`, ""}, {"forbidden", 403, `{"error":"denied"}`, ""}, + {"conflict", 409, `{"error":"conflict"}`, ""}, {"oversize", 413, `{"error":"too large"}`, ""}, + {"authority unavailable", 404, `{"error":"session authority unavailable","error_code":"session_authority_unavailable"}`, "session_authority_unavailable"}, + {"old server", 404, "404 page not found", "server_unsupported"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + mt, err := NewMutationTransport(server.URL, "") + if err != nil { + t.Fatal(err) + } + for i, call := range []func() error{func() error { return mt.RegisterSessionAuthority("s", "owner") }, func() error { return mt.ClaimPromptPair("s", "i", "y", "owner", "prompt") }} { + var status *HTTPStatusError + wantCode := tc.code + if i == 0 && tc.status == 404 { + wantCode = "server_unsupported" + } + if err := call(); !errors.As(err, &status) || status.StatusCode != tc.status || status.ErrorCode != wantCode { + t.Fatalf("error=%v status=%+v", err, status) + } + } + }) + } +} + +func TestMutationProvenanceRejectsUnverifiedSuccess(t *testing.T) { + for _, body := range []string{"", `{"status":"pending"}`, `login`} { + t.Run(body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + mt := mustNewMutationTransport(t, server.URL, "") + if err := mt.ClaimPromptPair("s", "i", "y", "owner", "prompt"); err == nil { + t.Fatal("accepted response without confirmed claim") + } + }) + } +} + +func TestAttestPromptSource(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if r.Method != http.MethodPost || r.URL.Path != "/sync/prompt-source-attestations" || r.Header.Get("Authorization") != "Bearer secret" || r.Header.Get("Content-Type") != "application/json" || string(body) != `{"session_id":"s","source_inbox_id":"i","sync_id":"y","owner_project":"owner","prompt_project":"prompt"}` { + t.Errorf("unexpected attestation request: method=%s path=%s body=%q", r.Method, r.URL.Path, body) + } + _, _ = w.Write([]byte(`{"status":"ok","attestation_id":42}`)) + })) + defer server.Close() + mt := mustNewMutationTransport(t, server.URL, "secret") + mt.httpClient = server.Client() + id, err := mt.AttestPromptSource("s", "i", "y", "owner", "prompt") + if err != nil || id != 42 { + t.Fatalf("id=%d err=%v", id, err) + } +} + +func TestAttestPromptSourceRejectsInvalidResponse(t *testing.T) { + for _, body := range []string{"", `{}`, `{"status":"ok"}`, `{"status":"pending","attestation_id":1}`, `{"status":"ok","attestation_id":0}`, `{"status":"ok","attestation_id":-1}`, `{"status":"ok","attestation_id":"1"}`, `{"status":"ok","attestation_id":1} {}`} { + t.Run(body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })) + defer server.Close() + mt := mustNewMutationTransport(t, server.URL, "") + if id, err := mt.AttestPromptSource("s", "i", "y", "owner", "prompt"); err == nil || id != 0 { + t.Fatalf("id=%d err=%v", id, err) + } + }) + } +} + +func TestAttestPromptSourceStatusRedactsToken(t *testing.T) { + for _, code := range []int{401, 403, 404, 409} { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":"secret","error_code":"secret"}`)) + })) + mt := mustNewMutationTransport(t, server.URL, "secret") + mt.httpClient = server.Client() + _, err := mt.AttestPromptSource("s", "i", "y", "owner", "prompt") + var status *HTTPStatusError + if !errors.As(err, &status) || status.StatusCode != code || strings.Contains(err.Error(), "secret") || strings.Contains(status.Body, "secret") { + t.Errorf("code=%d status=%+v err=%v", code, status, err) + } + server.Close() + } +} + +func TestAttestPromptSourceInvalidInputAndOversizedResponse(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, _ = w.Write([]byte(`{"status":"ok","attestation_id":1,"padding":"` + strings.Repeat("x", 65536) + `"}`)) + })) + defer server.Close() + mt := mustNewMutationTransport(t, server.URL, "") + if _, err := mt.AttestPromptSource(" ", "i", "y", "owner", "prompt"); err == nil || requests != 0 { + t.Fatalf("invalid input sent request: requests=%d err=%v", requests, err) + } + if _, err := mt.AttestPromptSource("s", "i", "y", "owner", "prompt"); err == nil || requests != 1 { + t.Fatalf("oversized response accepted: requests=%d err=%v", requests, err) + } +} + +func TestAttestPromptSourceNetworkFailureRedactsToken(t *testing.T) { + mt := mustNewMutationTransport(t, "https://cloud.example.test", "secret") + mt.httpClient = &http.Client{Transport: remoteRoundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("network failure with bearer secret") + })} + id, err := mt.AttestPromptSource("s", "i", "y", "owner", "prompt") + if err == nil || id != 0 { + t.Fatalf("id=%d err=%v", id, err) + } + if strings.Contains(err.Error(), "secret") { + t.Fatalf("network error leaked bearer token: %v", err) + } +} + +func TestMutationProvenanceNetworkFailure(t *testing.T) { + mt, err := NewMutationTransport("http://cloud.example.test", "") + if err != nil { + t.Fatal(err) + } + mt.httpClient = &http.Client{Transport: remoteRoundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("offline") + })} + if err := mt.ClaimPromptPair("s", "i", "y", "owner", "prompt"); err == nil { + t.Fatal("network failure accepted") + } + if _, err := NewMutationTransport("http://example.com", "secret"); err == nil { + t.Fatal("insecure bearer accepted") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 4afaca9ff..a28aacb75 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1051,11 +1051,13 @@ func (s *Server) handleAddPrompt(w http.ResponseWriter, r *http.Request) { } } - id, err := s.store.AddPrompt(body) + id, inserted, err := s.store.AddPromptWithResult(body) if err != nil { switch { case errors.Is(err, store.ErrPromptContentRequired): jsonError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, store.ErrPromptInboxDeleted): + jsonError(w, http.StatusConflict, err.Error()) case writeOwnershipError(w, body.SessionID, err): default: jsonError(w, http.StatusInternalServerError, err.Error()) @@ -1063,7 +1065,9 @@ func (s *Server) handleAddPrompt(w http.ResponseWriter, r *http.Request) { return } - s.notifyWrite() + if inserted { + s.notifyWrite() + } jsonResponse(w, http.StatusCreated, map[string]any{"id": id, "status": "saved"}) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a69bfa5d4..a0047984d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2088,6 +2088,85 @@ func TestSyncStatusResolvesProjectSelectors(t *testing.T) { } } +func TestPromptInboxIdentityHTTP(t *testing.T) { + st := newServerTestStore(t) + srv := New(st, 0) + h := srv.Handler() + var writes atomic.Int32 + srv.SetOnWrite(func() { writes.Add(1) }) + if err := st.CreateSession("inbox-http", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + request := func(body string) (int, string) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/prompts", strings.NewReader(body))) + return rec.Code, rec.Body.String() + } + payload := `{"session_id":"inbox-http","project":"engram","content":"same","source_inbox_id":"a"}` + code, first := request(payload) + if code != http.StatusCreated || writes.Load() != 1 { + t.Fatalf("first: %d %s writes=%d", code, first, writes.Load()) + } + var created struct { + ID int64 `json:"id"` + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(first), &created); err != nil { + t.Fatalf("decode first response: %v", err) + } + if created.ID <= 0 || created.Status != "saved" { + t.Fatalf("first response: id=%d status=%q body=%s", created.ID, created.Status, first) + } + code, replay := request(payload) + if code != http.StatusCreated || replay != first || writes.Load() != 1 { + t.Fatalf("replay: %d %s writes=%d", code, replay, writes.Load()) + } + code, rejected := request(`{"session_id":"inbox-http","project":"other","content":"same","source_inbox_id":"a"}`) + var rejection map[string]string + if err := json.Unmarshal([]byte(rejected), &rejection); err != nil { + t.Fatalf("decode wrong-project response: %v", err) + } + if code != http.StatusBadRequest || rejection["code"] != "session_project_mismatch" || + rejection["error"] != "session project does not match requested project" || writes.Load() != 1 { + t.Fatalf("wrong project: %d %s writes=%d", code, rejected, writes.Load()) + } +} + +func TestPromptInboxDeletedReplayHTTP(t *testing.T) { + st := newServerTestStore(t) + srv := New(st, 0) + h := srv.Handler() + if err := st.CreateSession("deleted-http", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + id, err := st.AddPrompt(store.AddPromptParams{SessionID: "deleted-http", Project: "engram", Content: "same", SourceInboxID: "one"}) + if err != nil { + t.Fatal(err) + } + if err := st.DeletePrompt(id); err != nil { + t.Fatal(err) + } + var writes atomic.Int32 + srv.SetOnWrite(func() { writes.Add(1) }) + var before, after int + if err := st.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&before); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/prompts", strings.NewReader(`{"session_id":"deleted-http","project":"engram","content":"same","source_inbox_id":"one"}`))) + if rec.Code != http.StatusConflict || strings.Contains(rec.Body.String(), `"id"`) || writes.Load() != 0 { + t.Fatalf("replay: status=%d body=%s writes=%d", rec.Code, rec.Body.String(), writes.Load()) + } + if err := st.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&after); err != nil || after != before { + t.Fatalf("mutations %d -> %d: %v", before, after, err) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/prompts", strings.NewReader(`{"session_id":"deleted-http","project":"engram","content":"same","source_inbox_id":"two"}`))) + if rec.Code != http.StatusCreated || writes.Load() != 1 { + t.Fatalf("new ID: status=%d body=%s writes=%d", rec.Code, rec.Body.String(), writes.Load()) + } +} + // ─── OnWrite Notification Tests ────────────────────────────────────────────── func TestOnWriteCalledAfterSuccessfulWrites(t *testing.T) { 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/prompt_local_origin_test.go b/internal/store/prompt_local_origin_test.go new file mode 100644 index 000000000..879200460 --- /dev/null +++ b/internal/store/prompt_local_origin_test.go @@ -0,0 +1,424 @@ +package store + +import "testing" + +func TestPreviewPromptSource(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: "local", Content: "local"}) + if err != nil { + t.Fatal(err) + } + var key string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, id).Scan(&key); err != nil { + t.Fatal(err) + } + type previewCase struct { + name, key string + want PromptSourcePreview + found bool + } + cases := []previewCase{ + {"verified local", key, PromptSourcePreview{"session", "local", "beta", key, "live"}, true}, + {"missing", "missing", PromptSourcePreview{}, false}, + {"near-match sync ID", key + "-other", PromptSourcePreview{}, false}, + {"empty key", "", PromptSourcePreview{}, false}, + } + check := func(tc previewCase) { + t.Helper() + t.Run(tc.name, func(t *testing.T) { + got, found, err := s.PreviewPromptSource(tc.key) + if err != nil || found != tc.found || got != tc.want { + t.Fatalf("preview %q: got %+v, found %v, err %v; want %+v, found %v", tc.key, got, found, err, tc.want, tc.found) + } + }) + } + for _, tc := range cases { + check(tc) + } + if _, err := s.DB().Exec(`UPDATE user_prompts SET local_creation_session_id=NULL,local_creation_inbox_id=NULL,local_creation_project=NULL WHERE id=?`, id); err != nil { + t.Fatal(err) + } + check(previewCase{"unmarked local row", key, cases[0].want, true}) + + fixtures := []struct { + name, statement string + }{ + {"old imported-style row", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('old','session','old-key','beta','old')`}, + {"tombstone", `INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('deleted','session','gone','beta')`}, + {"duplicate live", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('old','session','second','beta','duplicate')`}, + {"live plus tombstone", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('deleted','session','live','beta','conflict')`}, + {"idless", `INSERT INTO user_prompts(sync_id,session_id,project,content) VALUES ('idless','session','beta','idless')`}, + {"blank project", `INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES ('blank-project','session','inbox','','blank')`}, + {"blank session", `INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('blank-session','','inbox','beta')`}, + {"whitespace-only identity", `INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('whitespace-identity',' ',' ',' ')`}, + } + for i, fixture := range fixtures { + if _, err := s.DB().Exec(fixture.statement); err != nil { + t.Fatalf("%s: %v", fixture.name, err) + } + if i == 0 { + check(previewCase{fixture.name, "old", PromptSourcePreview{"session", "old-key", "beta", "old", "live"}, true}) + } + if i == 1 { + check(previewCase{fixture.name, "deleted", PromptSourcePreview{"session", "gone", "beta", "deleted", "deleted"}, true}) + } + } + for _, tc := range []previewCase{ + {"duplicate live", "old", PromptSourcePreview{}, false}, + {"live plus tombstone", "deleted", PromptSourcePreview{}, false}, + {"idless", "idless", PromptSourcePreview{}, false}, + {"blank project", "blank-project", PromptSourcePreview{}, false}, + {"blank session", "blank-session", PromptSourcePreview{}, false}, + {"whitespace-only identity", "whitespace-identity", PromptSourcePreview{}, false}, + } { + check(tc) + } + var originCount int + if err := s.DB().QueryRow(`SELECT count(*) FROM user_prompts WHERE id=? AND local_creation_session_id IS NULL AND local_creation_inbox_id IS NULL AND local_creation_project IS NULL`, id).Scan(&originCount); err != nil || originCount != 1 { + t.Fatalf("origin changed: %d %v", originCount, err) + } +} + +func TestPreviewPromptSourceDBError(t *testing.T) { + s := newTestStore(t) // isolated temp database; schema damage cannot affect another test. + if _, err := s.DB().Exec(`DROP TABLE prompt_tombstones`); err != nil { + t.Fatal(err) + } + if got, found, err := s.PreviewPromptSource("missing"); err == nil || found || got != (PromptSourcePreview{}) { + t.Fatalf("DB error: %+v %v %v", got, found, err) + } +} + +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, "session", "delete-inbox", "beta", true) + var origin string + if err := s.DB().QueryRow(`SELECT local_creation_project FROM prompt_tombstones WHERE sync_id=?`, deleteSyncID).Scan(&origin); err != nil || origin != "beta" { + t.Fatalf("local tombstone origin: %q %v", origin, err) + } +} + +func TestLocalPromptOriginSessionDeleteAndUntrustedTombstones(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("alpha-session", "alpha", "/work"); err != nil { + t.Fatal(err) + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "alpha-session", Project: "beta", SourceInboxID: "beta-inbox", Content: "local"}) + 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.DeleteSession("alpha-session"); err != nil { + t.Fatal(err) + } + if session, inbox, project, ok, err := s.LocalPromptCreationIdentity(syncID); err != nil || !ok || session != "alpha-session" || inbox != "beta-inbox" || project != "beta" { + t.Fatalf("session delete: %q %q %q %v %v", session, inbox, project, ok, err) + } + checkUnknown := func(key string) { + t.Helper() + if a, b, c, ok, err := s.LocalPromptCreationIdentity(key); err != nil || ok || a != "" || b != "" || c != "" { + t.Fatalf("untrusted %s: %q %q %q %v %v", key, a, b, c, ok, err) + } + } + if _, err := s.DB().Exec(`INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('imported','alpha-session','import-key','beta')`); err != nil { + t.Fatal(err) + } + checkUnknown("imported") + backup, err := s.Export() + if err != nil { + t.Fatal(err) + } + imported := newTestStore(t) + if _, err := imported.Import(backup); err != nil { + t.Fatal(err) + } + if a, b, c, ok, err := imported.LocalPromptCreationIdentity(syncID); err != nil || ok || a != "" || b != "" || c != "" { + t.Fatalf("imported tombstone promoted: %q %q %q %v %v", a, b, c, ok, err) + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 11, Entity: SyncEntityPrompt, EntityKey: "pulled", Op: SyncOpDelete, Payload: `{"sync_id":"pulled","session_id":"alpha-session","source_inbox_id":"pull-key","project":"beta","deleted":true,"hard_delete":true}`, Source: SyncSourceRemote, Project: "beta"}); err != nil { + t.Fatal(err) + } + checkUnknown("pulled") + if _, err := s.DB().Exec(`UPDATE prompt_tombstones SET project='gamma' WHERE sync_id=?`, syncID); err != nil { + t.Fatal(err) + } + checkUnknown(syncID) + if _, err := s.DB().Exec(`UPDATE prompt_tombstones SET project='beta' WHERE sync_id=?`, syncID); err != nil { + t.Fatal(err) + } + if err := s.CreateSession("other", "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 (?,'other','duplicate','beta','duplicate')`, syncID); err != nil { + t.Fatal(err) + } + checkUnknown(syncID) +} + +func TestLocalPromptOriginDeleteBoundaries(t *testing.T) { + checkUnknown := func(t *testing.T, s *Store, key string) { + t.Helper() + a, b, c, ok, err := s.LocalPromptCreationIdentity(key) + if err != nil || ok || a != "" || b != "" || c != "" { + t.Fatalf("unexpected origin: %q %q %q %v %v", a, b, c, ok, err) + } + } + newLocal := func(t *testing.T, s *Store, inbox string) (int64, string) { + t.Helper() + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "alpha-session", Project: "beta", SourceInboxID: inbox, Content: "local"}) + if err != nil { + t.Fatal(err) + } + var key string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, id).Scan(&key); err != nil { + t.Fatal(err) + } + return id, key + } + for _, tc := range []struct { + name string + run func(*testing.T, *Store) + }{ + {"existing remote tombstone", func(t *testing.T, s *Store) { + id, key := newLocal(t, s, "remote-inbox") + if _, err := s.DB().Exec(`INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES (?,'alpha-session','remote-inbox','beta')`, key); err != nil { + t.Fatal(err) + } + if err := s.DeletePrompt(id); err != nil { + t.Fatal(err) + } + checkUnknown(t, s, key) + }}, + {"pulled delete of live local prompt", func(t *testing.T, s *Store) { + _, key := newLocal(t, s, "pulled-inbox") + err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 21, Entity: SyncEntityPrompt, EntityKey: key, Op: SyncOpDelete, Payload: `{"sync_id":"` + key + `","session_id":"alpha-session","source_inbox_id":"pulled-inbox","project":"beta","deleted":true,"hard_delete":true}`, Source: SyncSourceRemote, Project: "beta"}) + if err != nil { + t.Fatal(err) + } + checkUnknown(t, s, key) + }}, + {"duplicate sync ID at deletion", func(t *testing.T, s *Store) { + id, key := newLocal(t, s, "first-inbox") + if _, err := s.DB().Exec(`INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES (?,'alpha-session','second-inbox','beta','duplicate')`, key); err != nil { + t.Fatal(err) + } + if err := s.DeletePrompt(id); err != nil { + t.Fatal(err) + } + checkUnknown(t, s, key) + }}, + {"replay retains origin and conflict fails closed", func(t *testing.T, s *Store) { + id, key := newLocal(t, s, "replay-inbox") + if err := s.DeletePrompt(id); err != nil { + t.Fatal(err) + } + tx, err := s.DB().Begin() + if err != nil { + t.Fatal(err) + } + project := "beta" + if err := s.recordPromptTombstoneTx(tx, key, "alpha-session", &project, "replay-inbox", Now()); err != nil { + _ = tx.Rollback() + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + a, b, c, ok, err := s.LocalPromptCreationIdentity(key) + if err != nil || !ok || a != "alpha-session" || b != "replay-inbox" || c != "beta" { + t.Fatalf("replay origin: %q %q %q %v %v", a, b, c, ok, err) + } + tx, err = s.DB().Begin() + if err != nil { + t.Fatal(err) + } + if err := s.recordPromptTombstoneTx(tx, key, "alpha-session", &project, "conflicting-inbox", Now()); err == nil { + _ = tx.Rollback() + t.Fatal("conflicting replay accepted") + } + if err := tx.Rollback(); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("alpha-session", "alpha", "/work"); err != nil { + t.Fatal(err) + } + tc.run(t, s) + }) + } +} + +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(`INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('old-deleted','old','key','beta')`); err != nil { + t.Fatal(err) + } + if _, err = s.DB().Exec(`ALTER TABLE prompt_tombstones DROP COLUMN local_creation_session_id`); 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) + } + }() + for _, key := range []string{"old-prompt", "old-deleted"} { + session, inbox, project, eligible, err := s.LocalPromptCreationIdentity(key) + if err != nil || session != "" || inbox != "" || project != "" || eligible { + t.Fatalf("migration %s: %q %q %q %v %v", key, session, inbox, project, eligible, err) + } + } +} diff --git a/internal/store/prompt_source_confirmation.go b/internal/store/prompt_source_confirmation.go new file mode 100644 index 000000000..4915c38e9 --- /dev/null +++ b/internal/store/prompt_source_confirmation.go @@ -0,0 +1,109 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +var ErrPromptSourceConfirmation = errors.New("prompt source confirmation rejected") + +// ConfirmPromptSourceAttestation records an explicit owner assertion only after +// the caller reports remote attestation success. The observed tuple is not +// ownership proof. A tuple recheck cannot detect change-away-and-back without +// a revision; autosync must revalidate cloud authorization before using it. +// Newer IDs are safe only within the same remote target while it retains the +// same Postgres identity sequence. Callers must supply an endpoint identity +// independent of local sync TargetKey and recheck cloud state before use. +func (s *Store) ConfirmPromptSourceAttestation(remoteTarget string, preview PromptSourcePreview, assertedOwner string, remoteAttestationID int64) error { + if strings.TrimSpace(remoteTarget) == "" || strings.TrimSpace(assertedOwner) == "" || remoteAttestationID <= 0 || strings.TrimSpace(preview.SyncID) == "" || + strings.TrimSpace(preview.SessionID) == "" || strings.TrimSpace(preview.SourceInboxID) == "" || + strings.TrimSpace(preview.Project) == "" || (preview.Kind != "live" && preview.Kind != "deleted") { + return ErrPromptSourceConfirmation + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + rows, err := tx.Query(`SELECT ifnull(session_id,''),ifnull(source_inbox_id,''),ifnull(project,''),ifnull(sync_id,''),'live' + FROM user_prompts WHERE sync_id=? + UNION ALL + SELECT ifnull(session_id,''),ifnull(source_inbox_id,''),ifnull(project,''),ifnull(sync_id,''),'deleted' + FROM prompt_tombstones WHERE sync_id=? LIMIT 2`, preview.SyncID, preview.SyncID) + if err != nil { + return err + } + var current PromptSourcePreview + if !rows.Next() { + err = rows.Err() + _ = rows.Close() + if err != nil { + return err + } + return ErrPromptSourceConfirmation + } + if err = rows.Scan(¤t.SessionID, ¤t.SourceInboxID, ¤t.Project, ¤t.SyncID, ¤t.Kind); err != nil { + _ = rows.Close() + return err + } + ambiguous := rows.Next() + err = rows.Err() + closeErr := rows.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if ambiguous || current != preview || strings.TrimSpace(current.SessionID) == "" || strings.TrimSpace(current.SourceInboxID) == "" || strings.TrimSpace(current.Project) == "" { + return ErrPromptSourceConfirmation + } + // A live session can contradict an assertion, but absence is not evidence + // against a deleted prompt. Neither session.project nor tombstones grant ownership. + var observedOwner string + err = tx.QueryRow(`SELECT project FROM sessions WHERE id=?`, preview.SessionID).Scan(&observedOwner) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil && strings.TrimSpace(observedOwner) != "" && observedOwner != assertedOwner { + return ErrPromptSourceConfirmation + } + var existing PromptSourcePreview + var owner string + var attestationID int64 + err = tx.QueryRow(`SELECT session_id,source_inbox_id,prompt_project,sync_id,kind,asserted_owner_project,remote_attestation_id + FROM prompt_source_confirmations WHERE remote_target=? AND sync_id=?`, remoteTarget, preview.SyncID). + Scan(&existing.SessionID, &existing.SourceInboxID, &existing.Project, &existing.SyncID, &existing.Kind, &owner, &attestationID) + if err == nil { + if existing.SessionID != preview.SessionID || existing.SourceInboxID != preview.SourceInboxID || + existing.Project != preview.Project || existing.SyncID != preview.SyncID || owner != assertedOwner { + return ErrPromptSourceConfirmation + } + if remoteAttestationID <= attestationID { + if remoteAttestationID == attestationID && existing.Kind == preview.Kind { + return tx.Commit() + } + return ErrPromptSourceConfirmation + } + if existing.Kind != "live" && existing.Kind != "deleted" { + return ErrPromptSourceConfirmation + } + if _, err := tx.Exec(`UPDATE prompt_source_confirmations SET kind=?,remote_attestation_id=? WHERE remote_target=? AND sync_id=?`, + preview.Kind, remoteAttestationID, remoteTarget, preview.SyncID); err != nil { + return err + } + return tx.Commit() + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + _, err = tx.Exec(`INSERT INTO prompt_source_confirmations + (remote_target,sync_id,session_id,source_inbox_id,prompt_project,kind,asserted_owner_project,remote_attestation_id) + VALUES (?,?,?,?,?,?,?,?)`, remoteTarget, preview.SyncID, preview.SessionID, preview.SourceInboxID, preview.Project, preview.Kind, assertedOwner, remoteAttestationID) + if err != nil { + return fmt.Errorf("insert prompt source confirmation: %w", err) + } + return tx.Commit() +} diff --git a/internal/store/prompt_source_confirmation_test.go b/internal/store/prompt_source_confirmation_test.go new file mode 100644 index 000000000..bb870f4ac --- /dev/null +++ b/internal/store/prompt_source_confirmation_test.go @@ -0,0 +1,184 @@ +package store + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestConfirmPromptSourceAttestation(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", SourceInboxID: "inbox", Project: "beta", Content: "text"}) + if err != nil { + t.Fatal(err) + } + var key string + if err := s.DB().QueryRow(`SELECT sync_id FROM user_prompts WHERE id=?`, id).Scan(&key); err != nil { + t.Fatal(err) + } + preview, ok, err := s.PreviewPromptSource(key) + if err != nil || !ok { + t.Fatalf("preview: %v %v", ok, err) + } + one, two := "https://one.example", "https://two.example" + confirm := func(target string, p PromptSourcePreview, owner string, audit int64) error { + return s.ConfirmPromptSourceAttestation(target, p, owner, audit) + } + count := func() int { + t.Helper() + var n int + if err := s.DB().QueryRow(`SELECT count(*) FROM prompt_source_confirmations`).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + for _, tc := range []struct { + name, target, owner string + preview PromptSourcePreview + audit int64 + }{ + {"blank target", " ", "alpha", preview, 1}, + {"contradictory session", one, "gamma", preview, 1}, + {"blank owner", one, " ", preview, 1}, + {"invalid audit", one, "alpha", preview, 0}, + {"near match ID", one, "alpha", PromptSourcePreview{SessionID: preview.SessionID, SourceInboxID: preview.SourceInboxID, Project: preview.Project, SyncID: key + "-other", Kind: "live"}, 1}, + {"mismatched project", one, "alpha", PromptSourcePreview{SessionID: preview.SessionID, SourceInboxID: preview.SourceInboxID, Project: "gamma", SyncID: key, Kind: "live"}, 1}, + {"mismatched kind", one, "alpha", PromptSourcePreview{SessionID: preview.SessionID, SourceInboxID: preview.SourceInboxID, Project: preview.Project, SyncID: key, Kind: "deleted"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := confirm(tc.target, tc.preview, tc.owner, tc.audit); err == nil || count() != 0 { + t.Fatalf("accepted invalid confirmation: %v", err) + } + }) + } + if err := confirm(one, preview, "alpha", 42); err != nil { + t.Fatal(err) + } + if err := confirm(one, preview, "alpha", 42); err != nil || count() != 1 { + t.Fatalf("idempotent replay: %v", err) + } + if err := confirm(two, preview, "alpha", 1); err != nil || count() != 2 { + t.Fatalf("independent target: %v", err) + } + if err := confirm(two, preview, "alpha", 1); err != nil { + t.Fatalf("second target replay: %v", err) + } + if err := confirm(one, preview, "alpha", 43); err != nil { + t.Fatal(err) + } + if err := confirm(one, preview, "alpha", 42); err == nil { + t.Fatal("older same-target replay accepted") + } + if err := confirm(two, preview, "alpha", 2); err != nil { + t.Fatalf("second target blocked by first target ID: %v", err) + } + var auditOne, auditTwo int64 + if err := s.DB().QueryRow(`SELECT remote_attestation_id FROM prompt_source_confirmations WHERE remote_target=? AND sync_id=?`, one, key).Scan(&auditOne); err != nil { + t.Fatal(err) + } + if err := s.DB().QueryRow(`SELECT remote_attestation_id FROM prompt_source_confirmations WHERE remote_target=? AND sync_id=?`, two, key).Scan(&auditTwo); err != nil { + t.Fatal(err) + } + if auditOne != 43 || auditTwo != 2 { + t.Fatalf("cross-target update: %d %d", auditOne, auditTwo) + } + backup, err := s.Export() + if err != nil { + t.Fatal(err) + } + serialized, err := json.Marshal(backup) + if err != nil { + t.Fatal(err) + } + if !json.Valid(serialized) || strings.Contains(string(serialized), "prompt_source_confirmations") || strings.Contains(string(serialized), one) { + t.Fatal("export leaked confirmation") + } + imported := newTestStore(t) + if _, err := imported.Import(backup); err != nil { + t.Fatal(err) + } + var importedCount int + if err := imported.DB().QueryRow(`SELECT count(*) FROM prompt_source_confirmations`).Scan(&importedCount); err != nil || importedCount != 0 { + t.Fatalf("imported confirmation: %d %v", importedCount, err) + } + if err := s.DeletePrompt(id); err != nil { + t.Fatal(err) + } + deleted, ok, err := s.PreviewPromptSource(key) + if err != nil || !ok || deleted.Kind != "deleted" { + t.Fatalf("deleted preview: %+v %v %v", deleted, ok, err) + } + if err := confirm(one, deleted, "alpha", 43); err == nil { + t.Fatal("same-target conflicting kind accepted") + } + if err := confirm(one, preview, "alpha", 44); err == nil { + t.Fatal("stale live preview accepted") + } + if err := confirm(one, deleted, "alpha", 44); err != nil { + t.Fatal(err) + } + if err := confirm(two, deleted, "alpha", 2); err == nil { + t.Fatal("second target same-ID kind replay accepted") + } + if err := confirm(two, deleted, "alpha", 3); err != nil { + t.Fatal(err) + } + if err := confirm(one, deleted, "alpha", 43); err == nil { + t.Fatal("older deleted replay accepted") + } + if err := confirm(one, deleted, "gamma", 45); err == nil { + t.Fatal("changed owner accepted") + } + if _, err := s.DB().Exec(`DELETE FROM prompt_tombstones WHERE sync_id=?`, key); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`INSERT INTO user_prompts(sync_id,session_id,source_inbox_id,project,content) VALUES (?,?,?,?,?)`, key, preview.SessionID, preview.SourceInboxID, preview.Project, "restored"); err != nil { + t.Fatal(err) + } + if err := confirm(one, deleted, "alpha", 47); err == nil { + t.Fatal("stale deleted preview accepted") + } + if err := confirm(one, preview, "alpha", 44); err == nil { + t.Fatal("old marker reused for restore") + } + if err := confirm(one, preview, "alpha", 48); err != nil { + t.Fatal(err) + } + if err := confirm(one, preview, "alpha", 49); err != nil { + t.Fatal(err) + } +} + +func TestConfirmDeletedPromptWithoutSession(t *testing.T) { + s := newTestStore(t) + if _, err := s.DB().Exec(`INSERT INTO prompt_tombstones(sync_id,session_id,source_inbox_id,project) VALUES ('deleted','gone','inbox','beta')`); err != nil { + t.Fatal(err) + } + p, ok, err := s.PreviewPromptSource("deleted") + if err != nil || !ok { + t.Fatalf("preview: %v %v", ok, err) + } + if err := s.ConfirmPromptSourceAttestation("https://one.example", p, "unique-owner-not-in-export", 987654321); err != nil { + t.Fatal(err) + } + backup, err := s.Export() + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(backup) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "unique-owner-not-in-export") || strings.Contains(string(data), "987654321") { + t.Fatal("export leaked owner or audit ID") + } + if _, err := s.DB().Exec(`UPDATE prompt_tombstones SET source_inbox_id='changed' WHERE sync_id='deleted'`); err != nil { + t.Fatal(err) + } + if err := s.ConfirmPromptSourceAttestation("https://one.example", p, "unique-owner-not-in-export", 987654321); err == nil { + t.Fatal("stale tombstone accepted") + } +} diff --git a/internal/store/session_identity_repair.go b/internal/store/session_identity_repair.go index 8001fb000..866228539 100644 --- a/internal/store/session_identity_repair.go +++ b/internal/store/session_identity_repair.go @@ -79,8 +79,8 @@ func (s *Store) ApplySessionIdentityRepair(plan SessionIdentityRepairPlan) (Sess return errors.New("session identity repair evidence changed since plan") } result.SessionIdentityRepairPlan = current.plan - insert, err := s.execHook(tx, `INSERT INTO sessions(id,project,ownership_mode,directory,started_at,ended_at,summary,runtime_lease_expires_at) - SELECT ?,project,ownership_mode,directory,started_at,ended_at,summary,runtime_lease_expires_at FROM sessions WHERE id=?`, plan.ReplacementID, plan.SourceID) + insert, err := s.execHook(tx, `INSERT INTO sessions(id,project,ownership_mode,directory,started_at,ended_at,summary,runtime_lease_expires_at,local_creation_project) + SELECT ?,project,ownership_mode,directory,started_at,ended_at,summary,runtime_lease_expires_at,local_creation_project FROM sessions WHERE id=?`, plan.ReplacementID, plan.SourceID) if err := identityAffected(insert, err, 1, "insert canonical session"); err != nil { return err } @@ -183,9 +183,10 @@ func (s *Store) inspectSessionIdentityTx(tx *sql.Tx, source, replacement string) } plan := SessionIdentityRepairPlan{SourceID: source, ReplacementID: replacement} var mode, directory, started string + var creationProject sql.NullString var ended, summary, lease sql.NullString - err := tx.QueryRow(`SELECT ifnull(project,''),ifnull(ownership_mode,''),directory,started_at,ended_at,summary,runtime_lease_expires_at - FROM sessions WHERE id=?`, source).Scan(&plan.Project, &mode, &directory, &started, &ended, &summary, &lease) + err := tx.QueryRow(`SELECT ifnull(project,''),ifnull(ownership_mode,''),directory,started_at,ended_at,summary,runtime_lease_expires_at,local_creation_project + FROM sessions WHERE id=?`, source).Scan(&plan.Project, &mode, &directory, &started, &ended, &summary, &lease, &creationProject) if err != nil { return snapshot, fmt.Errorf("session identity source: %w", err) } @@ -205,7 +206,7 @@ func (s *Store) inspectSessionIdentityTx(tx *sql.Tx, source, replacement string) // Fingerprint source values and all dependent columns, not merely their counts. // A matching hash is a stale-plan check, not proof that a row is safe: the // explicit ownership, journal, tombstone, and delivery checks below provide that. - evidence := []any{source, replacement, plan.Project, mode, directory, started, ended, summary, lease} + evidence := []any{source, replacement, plan.Project, mode, directory, started, ended, summary, lease, creationProject} if err := inspectIdentityObservations(tx, source, &snapshot, &evidence); err != nil { return snapshot, err } diff --git a/internal/store/session_provenance_test.go b/internal/store/session_provenance_test.go new file mode 100644 index 000000000..2ab5ae802 --- /dev/null +++ b/internal/store/session_provenance_test.go @@ -0,0 +1,150 @@ +package store + +import "testing" + +func TestLocalSessionProvenance(t *testing.T) { + s := newTestStore(t) + check := func(id, owner string, eligible bool) { + t.Helper() + gotOwner, gotEligible, err := s.LocalSessionProvenance(id) + if err != nil || gotOwner != owner || gotEligible != eligible { + t.Fatalf("%q: owner=%q eligible=%v err=%v", id, gotOwner, gotEligible, err) + } + } + if err := s.CreateSession("new", "alpha", "/work"); err != nil { + t.Fatal(err) + } + check("new", "alpha", true) + if _, err := s.DB().Exec(`UPDATE sessions SET project='beta' WHERE id='new'`); err != nil { + t.Fatal(err) + } + check("new", "beta", false) + if _, err := s.DB().Exec(`UPDATE sessions SET project='alpha' WHERE id='new'`); err != nil { + t.Fatal(err) + } + check("new", "alpha", true) + if err := s.StartSession("running", "alpha", "/work"); err != nil { + t.Fatal(err) + } + check("running", "alpha", true) + if _, err := s.DB().Exec(`INSERT INTO sessions(id,project,directory) VALUES ('imported','alpha','/work'),('pulled','alpha','/work')`); err != nil { + t.Fatal(err) + } + if _, err := s.Import(&ExportData{Sessions: []Session{{ID: "backup", Project: "alpha", Directory: "/work", StartedAt: Now()}}}); err != nil { + t.Fatal(err) + } + check("backup", "alpha", false) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{ + Seq: 1, Entity: SyncEntitySession, EntityKey: "remote", Op: SyncOpUpsert, + Payload: `{"id":"remote","project":"alpha","directory":"/work","started_at":"2025-01-01 00:00:00"}`, + Source: SyncSourceRemote, Project: "alpha", + }); err != nil { + t.Fatal(err) + } + check("remote", "alpha", false) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{ + Seq: 2, Entity: SyncEntitySession, EntityKey: "new", Op: SyncOpUpsert, + Payload: `{"id":"new","project":"alpha","directory":"/work","started_at":"2025-01-01 00:00:00"}`, + Source: SyncSourceRemote, Project: "alpha", + }); err != nil { + t.Fatal(err) + } + check("new", "alpha", true) + for _, id := range []string{"imported", "pulled", "backup", "remote"} { + check(id, "alpha", false) + if err := s.CreateSession(id, "alpha", "/work"); err != nil { + t.Fatal(err) + } + if err := s.StartSession(id, "alpha", "/work"); err != nil { + t.Fatal(err) + } + check(id, "alpha", false) + } + check("missing", "", false) +} + +func TestLocalSessionProvenanceMigration(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 ('legacy','alpha','/work')`); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`ALTER TABLE sessions DROP COLUMN local_creation_project`); err != nil { + t.Fatal(err) + } + if _, _, err := s.LocalSessionProvenance("legacy"); err == nil { + t.Fatal("missing provenance column did not 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) + } + }() + owner, eligible, err := s.LocalSessionProvenance("legacy") + if err != nil || owner != "alpha" || eligible { + t.Fatalf("migration: %q %v %v", owner, eligible, err) + } +} + +func TestLocalSessionProvenanceRepairRejectsChangedOrigin(t *testing.T) { + s := newTestStore(t) + seedSessionIdentityRepair(t, s, false) + plan, err := s.PlanSessionIdentityRepair("", "repaired") + if err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`UPDATE sessions SET local_creation_project='alpha' WHERE id=''`); err != nil { + t.Fatal(err) + } + if _, err := s.ApplySessionIdentityRepair(plan); err == nil { + t.Fatal("repair accepted stale creation-owner evidence") + } + owner, eligible, err := s.LocalSessionProvenance("repaired") + if err != nil || owner != "" || eligible { + t.Fatalf("stale repair inserted replacement: %q %v %v", owner, eligible, err) + } +} + +func TestLocalSessionProvenanceRepair(t *testing.T) { + for _, tc := range []struct { + name string + creationProject any + eligible bool + }{{"unknown", nil, false}, {"local", "alpha", true}} { + t.Run(tc.name, func(t *testing.T) { + s := newTestStore(t) + seedSessionIdentityRepair(t, s, false) + if _, err := s.DB().Exec(`UPDATE sessions SET local_creation_project=? WHERE id=''`, tc.creationProject); err != nil { + t.Fatal(err) + } + plan, err := s.PlanSessionIdentityRepair("", "repaired") + if err != nil { + t.Fatal(err) + } + if _, err := s.ApplySessionIdentityRepair(plan); err != nil { + t.Fatal(err) + } + owner, eligible, err := s.LocalSessionProvenance("repaired") + if err != nil || owner != "alpha" || eligible != tc.eligible { + t.Fatalf("repair: %q %v %v", owner, eligible, err) + } + if _, err := s.DB().Exec(`UPDATE sessions SET project='beta' WHERE id='repaired'`); err != nil { + t.Fatal(err) + } + owner, eligible, err = s.LocalSessionProvenance("repaired") + if err != nil || owner != "beta" || eligible { + t.Fatalf("changed repaired owner: %q %v %v", owner, eligible, err) + } + }) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index e97ab5120..3b6e788f0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -61,6 +61,7 @@ var ( ErrSessionDeleteBlocked = errors.New("session deletion is blocked while cloud sync enrollment is active") ErrObservationNotFound = errors.New("observation not found") ErrPromptNotFound = errors.New("prompt not found") + ErrPromptInboxDeleted = errors.New("prompt inbox identity was deleted") ErrProjectNotFound = errors.New("project not found") ErrProjectRequired = errors.New("project identity is required") ErrInvalidSessionOwnershipMode = errors.New("invalid session ownership mode") @@ -105,6 +106,8 @@ var ( ErrPulledSessionIdentityInvalid = errors.New("pulled session identity is invalid") // ErrPulledObservationIdentityInvalid identifies a pulled observation whose payload and mutation identities disagree. ErrPulledObservationIdentityInvalid = errors.New("pulled observation identity is invalid") + // ErrPulledPromptIdentityInvalid identifies a pulled prompt delete with an unusable keyed identity. + ErrPulledPromptIdentityInvalid = errors.New("pulled prompt identity is invalid") // ErrPulledSessionDirectoryInvalid identifies a pulled or imported session that // has no concrete directory and therefore cannot be admitted as cloud state. ErrPulledSessionDirectoryInvalid = errors.New("pulled session directory is invalid") @@ -292,18 +295,20 @@ 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 { - SessionID string `json:"session_id"` - Content string `json:"content"` - Project string `json:"project,omitempty"` + SessionID string `json:"session_id"` + Content string `json:"content"` + Project string `json:"project,omitempty"` + SourceInboxID string `json:"source_inbox_id,omitempty"` } // TruncationMetadata describes storage content processing after private-tag redaction. @@ -349,6 +354,7 @@ const ( SyncSessionIdentityInvalidReasonCode = "sync_session_identity_invalid" SyncObservationIdentityInvalidReasonCode = "sync_observation_identity_invalid" + SyncPromptIdentityInvalidReasonCode = "sync_prompt_identity_invalid" SyncParentSessionMissingReasonCode = "pulled_parent_session_missing" // relationDeferredOuterProjectAuthoritativeReasonCode records that a deferred @@ -602,14 +608,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 @@ -673,13 +680,22 @@ type backupObservation struct { } // ExportData is the full serializable direct-backup dump of the engram database. +type PromptTombstone struct { + SyncID string `json:"sync_id"` + SessionID string `json:"session_id"` + Project *string `json:"project,omitempty"` + SourceInboxID string `json:"source_inbox_id,omitempty"` + DeletedAt string `json:"deleted_at"` +} + type ExportData struct { - Version string `json:"version"` - ExportedAt string `json:"exported_at"` - Sessions []Session `json:"sessions"` - Observations []Observation `json:"observations"` - Prompts []Prompt `json:"prompts"` - Relations []BackupRelation `json:"relations,omitempty"` + PromptTombstones []PromptTombstone `json:"prompt_tombstones,omitempty"` + Version string `json:"version"` + ExportedAt string `json:"exported_at"` + Sessions []Session `json:"sessions"` + Observations []Observation `json:"observations"` + Prompts []Prompt `json:"prompts"` + Relations []BackupRelation `json:"relations,omitempty"` } // MarshalJSON projects observations through the backup-only form so direct @@ -690,15 +706,16 @@ func (d ExportData) MarshalJSON() ([]byte, error) { observations[i] = backupObservation{Observation: observation, Pinned: observation.Pinned} } return json.Marshal(struct { - Version string `json:"version"` - ExportedAt string `json:"exported_at"` - Sessions []Session `json:"sessions"` - Observations []backupObservation `json:"observations"` - Prompts []Prompt `json:"prompts"` - Relations []BackupRelation `json:"relations,omitempty"` + Version string `json:"version"` + ExportedAt string `json:"exported_at"` + Sessions []Session `json:"sessions"` + Observations []backupObservation `json:"observations"` + Prompts []Prompt `json:"prompts"` + Relations []BackupRelation `json:"relations,omitempty"` + PromptTombstones []PromptTombstone `json:"prompt_tombstones,omitempty"` }{ Version: d.Version, ExportedAt: d.ExportedAt, Sessions: d.Sessions, - Observations: observations, Prompts: d.Prompts, Relations: d.Relations, + Observations: observations, Prompts: d.Prompts, Relations: d.Relations, PromptTombstones: d.PromptTombstones, }) } @@ -725,12 +742,13 @@ type exportedSessionDirectory struct { // token fails the whole unmarshal with an error naming the offending session. func (d *ExportData) UnmarshalJSON(data []byte) error { var decoded struct { - Version string `json:"version"` - ExportedAt string `json:"exported_at"` - Sessions []json.RawMessage `json:"sessions"` - Observations []backupObservation `json:"observations"` - Prompts []Prompt `json:"prompts"` - Relations []BackupRelation `json:"relations"` + Version string `json:"version"` + ExportedAt string `json:"exported_at"` + Sessions []json.RawMessage `json:"sessions"` + Observations []backupObservation `json:"observations"` + Prompts []Prompt `json:"prompts"` + Relations []BackupRelation `json:"relations"` + PromptTombstones []PromptTombstone `json:"prompt_tombstones"` } if err := json.Unmarshal(data, &decoded); err != nil { return err @@ -761,7 +779,7 @@ func (d *ExportData) UnmarshalJSON(data []byte) error { } *d = ExportData{ Version: decoded.Version, ExportedAt: decoded.ExportedAt, Sessions: sessions, - Observations: observations, Prompts: decoded.Prompts, Relations: decoded.Relations, + Observations: observations, Prompts: decoded.Prompts, Relations: decoded.Relations, PromptTombstones: decoded.PromptTombstones, } return nil } @@ -1188,7 +1206,8 @@ func (s *Store) migrate() error { started_at TEXT NOT NULL DEFAULT (datetime('now')), ended_at TEXT, summary TEXT, - runtime_lease_expires_at TEXT + runtime_lease_expires_at TEXT, + local_creation_project TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project); @@ -1234,6 +1253,10 @@ func (s *Store) migrate() error { CREATE TABLE IF NOT EXISTS user_prompts ( 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, @@ -1243,11 +1266,29 @@ func (s *Store) migrate() error { CREATE TABLE IF NOT EXISTS prompt_tombstones ( sync_id TEXT PRIMARY KEY, + source_inbox_id TEXT, + local_creation_session_id TEXT, + local_creation_inbox_id TEXT, + local_creation_project TEXT, session_id TEXT, project TEXT, deleted_at TEXT NOT NULL DEFAULT (datetime('now')) ); + -- Local-only human/server confirmation; never part of sync or backup payloads. + CREATE TABLE IF NOT EXISTS prompt_source_confirmations ( + remote_target TEXT NOT NULL CHECK(length(trim(remote_target)) > 0), + sync_id TEXT NOT NULL, + session_id TEXT NOT NULL, + source_inbox_id TEXT NOT NULL, + prompt_project TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('live','deleted')), + asserted_owner_project TEXT NOT NULL, + remote_attestation_id INTEGER NOT NULL CHECK(remote_attestation_id > 0), + confirmed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (remote_target, sync_id) + ); + CREATE TABLE IF NOT EXISTS sync_delete_tombstones ( entity TEXT NOT NULL, entity_key TEXT NOT NULL, @@ -1369,6 +1410,9 @@ func (s *Store) migrate() error { if err := s.addColumnIfNotExists("sessions", "runtime_lease_expires_at", "TEXT"); err != nil { return err } + if err := s.addColumnIfNotExists("sessions", "local_creation_project", "TEXT"); err != nil { + return err + } // Legacy rows remain unclassified unless their persisted identity proves a // deterministic manual-save owner. Never infer ownership from an ID alone. if _, err := s.execHook(s.db, ` @@ -1383,6 +1427,22 @@ func (s *Store) migrate() error { if err := s.addColumnIfNotExists("user_prompts", "sync_id", "TEXT"); err != nil { return err } + 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 + } + for _, column := range []string{"local_creation_session_id", "local_creation_inbox_id", "local_creation_project"} { + if err := s.addColumnIfNotExists("prompt_tombstones", column, "TEXT"); err != nil { + return err + } + } if err := s.addColumnIfNotExists("sync_delete_tombstones", "last_remote_mutation_seq", "INTEGER"); err != nil { return err } @@ -1398,7 +1458,9 @@ func (s *Store) migrate() error { CREATE INDEX IF NOT EXISTS idx_obs_deleted ON observations(deleted_at); CREATE INDEX IF NOT EXISTS idx_obs_dedupe ON observations(normalized_hash, project, scope, type, title, created_at DESC); CREATE INDEX IF NOT EXISTS idx_prompts_sync_id ON user_prompts(sync_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_prompts_source_inbox ON user_prompts(session_id, source_inbox_id) WHERE source_inbox_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_prompt_tombstones_project ON prompt_tombstones(project, deleted_at DESC); + CREATE INDEX IF NOT EXISTS idx_prompt_tombstones_inbox ON prompt_tombstones(session_id, source_inbox_id) WHERE source_inbox_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_sync_delete_tombstones_project ON sync_delete_tombstones(project, deleted_at DESC); CREATE INDEX IF NOT EXISTS idx_sync_mutations_target_seq ON sync_mutations(target_key, seq); CREATE INDEX IF NOT EXISTS idx_sync_mutations_pending ON sync_mutations(target_key, acked_at, seq); @@ -2379,9 +2441,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 } @@ -2393,6 +2455,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") @@ -2880,6 +2946,21 @@ func normalizeFTSSQL(ddl string) string { // ─── Sessions ──────────────────────────────────────────────────────────────── +// LocalSessionProvenance returns the persisted owner and whether this store +// independently created the session. An unknown or absent row is never eligible +// for automatic cloud registration; project ownership alone is not proof. +func (s *Store) LocalSessionProvenance(id string) (owner string, eligible bool, err error) { + var creationProject sql.NullString + err = s.db.QueryRow(`SELECT ifnull(project,''), local_creation_project FROM sessions WHERE id=?`, id).Scan(&owner, &creationProject) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return owner, creationProject.Valid && creationProject.String != "" && owner == creationProject.String, nil +} + func (s *Store) CreateSession(id, project, directory string) error { return s.CreateSessionWithOwnershipMode(id, project, directory, SessionOwnershipShared) } @@ -3767,16 +3848,23 @@ func (s *Store) markReviewed(id int64, project string) error { // ─── User Prompts ──────────────────────────────────────────────────────────── func (s *Store) AddPrompt(p AddPromptParams) (int64, error) { - // Normalize project name before storing + id, _, err := s.AddPromptWithResult(p) + return id, err +} + +// AddPromptWithResult reports whether a prompt was inserted rather than replayed. +func (s *Store) AddPromptWithResult(p AddPromptParams) (int64, bool, error) { p.Project, _ = NormalizeProject(p.Project) content, _ := s.prepareStoredContent(p.Content) if content == "" { - return 0, ErrPromptContentRequired + return 0, false, ErrPromptContentRequired } var promptID int64 + inserted := false err := s.withTx(func(tx *sql.Tx) error { + inserted = false { // Settle ownership first: an unowned legacy session adopts this // write's project rather than rejecting the write forever. @@ -3786,18 +3874,44 @@ func (s *Store) AddPrompt(p AddPromptParams) (int64, error) { } p.Project = resolved } + if p.SourceInboxID != "" { + deleted, err := promptInboxDeletedTx(tx, p.SessionID, p.SourceInboxID) + if err != nil { + return err + } + if deleted { + return ErrPromptInboxDeleted + } + } syncID := newSyncID("prompt") - res, err := s.execHook(tx, - `INSERT INTO user_prompts (sync_id, session_id, content, project) VALUES (?, ?, ?, ?)`, - syncID, p.SessionID, content, nullableString(p.Project), - ) + 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), + creationSession, nullableString(p.SourceInboxID), creationProject) if err != nil { return err } + if p.SourceInboxID != "" { + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return tx.QueryRow(`SELECT id FROM user_prompts WHERE session_id = ? AND source_inbox_id = ?`, p.SessionID, p.SourceInboxID).Scan(&promptID) + } + } promptID, err = res.LastInsertId() if err != nil { return err } + inserted = true var createdAt string if err := tx.QueryRow(`SELECT created_at FROM user_prompts WHERE id = ?`, promptID).Scan(&createdAt); err != nil { return err @@ -3806,17 +3920,111 @@ func (s *Store) AddPrompt(p AddPromptParams) (int64, 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 { - return 0, err + return 0, false, err + } + return promptID, inserted, nil +} + +// PromptSourcePreview describes observed prompt data, not ownership or authority. +type PromptSourcePreview struct { + SessionID string + SourceInboxID string + Project string + SyncID string + Kind string // live or deleted +} + +// PreviewPromptSource reads one exact sync ID from live prompts or tombstones. +// Ambiguous or incomplete observations return no preview; project is prompt data only. +func (s *Store) PreviewPromptSource(syncID string) (PromptSourcePreview, bool, error) { + if strings.TrimSpace(syncID) == "" { + return PromptSourcePreview{}, false, nil + } + rows, err := s.db.Query(`SELECT ifnull(session_id,''), ifnull(source_inbox_id,''), ifnull(project,''), ifnull(sync_id,''), 'live' + FROM user_prompts WHERE sync_id = ? + UNION ALL + SELECT ifnull(session_id,''), ifnull(source_inbox_id,''), ifnull(project,''), ifnull(sync_id,''), 'deleted' + FROM prompt_tombstones WHERE sync_id = ? LIMIT 2`, syncID, syncID) + if err != nil { + return PromptSourcePreview{}, false, err + } + defer func() { _ = rows.Close() }() + var preview PromptSourcePreview + if !rows.Next() { + return preview, false, rows.Err() + } + if err := rows.Scan(&preview.SessionID, &preview.SourceInboxID, &preview.Project, &preview.SyncID, &preview.Kind); err != nil { + return PromptSourcePreview{}, false, err + } + if rows.Next() { + return PromptSourcePreview{}, false, nil + } + if err := rows.Err(); err != nil { + return PromptSourcePreview{}, false, err + } + if strings.TrimSpace(preview.SessionID) == "" || strings.TrimSpace(preview.SourceInboxID) == "" || + strings.TrimSpace(preview.Project) == "" || strings.TrimSpace(preview.SyncID) == "" { + return PromptSourcePreview{}, false, nil + } + return preview, true, nil +} + +// LocalPromptCreationIdentity returns verified local creation identity by exact sync ID. +// A tombstone is eligible only when its separately recorded local origin matches. +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() { + if err := rows.Err(); err != nil { + return "", "", "", false, err + } + var originalSession, originalInbox, originalProject sql.NullString + err := s.db.QueryRow(`SELECT local_creation_session_id, local_creation_inbox_id, local_creation_project, + ifnull(session_id,''), ifnull(source_inbox_id,''), ifnull(project,'') FROM prompt_tombstones WHERE sync_id=?`, syncID). + Scan(&originalSession, &originalInbox, &originalProject, &session, &inbox, &project) + if errors.Is(err, sql.ErrNoRows) { + return "", "", "", false, nil + } + if 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 + } + var originalSession, originalInbox, originalProject sql.NullString + if err := rows.Scan(&originalSession, &originalInbox, &originalProject, &session, &inbox, &project); err != nil { + return "", "", "", false, err } - return promptID, nil + 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) { @@ -4057,20 +4265,23 @@ func (s *Store) DeleteSession(id string) error { } deletedAt := Now() - promptRows, err := s.queryItHook(tx, `SELECT sync_id, session_id, ifnull(project, '') FROM user_prompts WHERE session_id = ? ORDER BY id ASC`, id) + promptRows, err := s.queryItHook(tx, `SELECT id, sync_id, session_id, ifnull(project, ''), ifnull(source_inbox_id, '') FROM user_prompts WHERE session_id = ? ORDER BY id ASC`, id) if err != nil { return fmt.Errorf("delete session: load prompts: %w", err) } var prompts []syncPromptPayload + var promptIDs []int64 for promptRows.Next() { var prompt syncPromptPayload - if err := promptRows.Scan(&prompt.SyncID, &prompt.SessionID, &prompt.Project); err != nil { + var promptID int64 + if err := promptRows.Scan(&promptID, &prompt.SyncID, &prompt.SessionID, &prompt.Project, &prompt.SourceInboxID); err != nil { return closeRowsWithError(promptRows, fmt.Errorf("delete session: load prompts: %w", err)) } if strings.TrimSpace(derefString(prompt.Project)) == "" { prompt.Project = nullableString(project) } prompts = append(prompts, prompt) + promptIDs = append(promptIDs, promptID) } if err := promptRows.Close(); err != nil { return err @@ -4078,8 +4289,8 @@ func (s *Store) DeleteSession(id string) error { if err := promptRows.Err(); err != nil { return err } - for _, prompt := range prompts { - if err := s.recordPromptTombstoneTx(tx, prompt.SyncID, prompt.SessionID, prompt.Project, deletedAt); err != nil { + for i, prompt := range prompts { + if err := s.recordLocalPromptTombstoneTx(tx, promptIDs[i], prompt.SyncID, prompt.SessionID, prompt.Project, prompt.SourceInboxID, deletedAt); err != nil { return fmt.Errorf("delete session: record prompt tombstone: %w", err) } } @@ -4151,7 +4362,7 @@ func (s *Store) DeletePrompt(id int64) error { return s.withTx(func(tx *sql.Tx) error { var payload syncPromptPayload var project string - if err := tx.QueryRow(`SELECT sync_id, session_id, ifnull(project, '') FROM user_prompts WHERE id = ?`, id).Scan(&payload.SyncID, &payload.SessionID, &project); err != nil { + if err := tx.QueryRow(`SELECT sync_id, session_id, ifnull(project, ''), ifnull(source_inbox_id, '') FROM user_prompts WHERE id = ?`, id).Scan(&payload.SyncID, &payload.SessionID, &project, &payload.SourceInboxID); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: prompt #%d", ErrPromptNotFound, id) } @@ -4171,6 +4382,9 @@ func (s *Store) DeletePrompt(id int64) error { payload.HardDelete = true payload.DeletedAt = &now + if err := s.recordLocalPromptTombstoneTx(tx, id, payload.SyncID, payload.SessionID, payload.Project, payload.SourceInboxID, now); err != nil { + return fmt.Errorf("delete prompt: record tombstone: %w", err) + } res, err := s.execHook(tx, `DELETE FROM user_prompts WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete prompt: %w", err) @@ -4182,9 +4396,6 @@ func (s *Store) DeletePrompt(id int64) error { if n == 0 { return fmt.Errorf("%w: prompt #%d", ErrPromptNotFound, id) } - if err := s.recordPromptTombstoneTx(tx, payload.SyncID, payload.SessionID, payload.Project, now); err != nil { - return fmt.Errorf("delete prompt: upsert tombstone: %w", err) - } enrolled, err := isProjectEnrolledTx(tx, project) if err != nil { return fmt.Errorf("delete prompt: check enrollment: %w", err) @@ -5532,18 +5743,18 @@ func (s *Store) ExportLocalDeleteTombstones(project string) ([]SyncMutation, err promptArgs = append(promptArgs, project) } promptRows, err := s.queryItHook(s.db, ` - SELECT p.sync_id, ifnull(p.session_id, ''), coalesce(nullif(p.project, ''), nullif(s.project, ''), ifnull((SELECT st.project FROM sync_delete_tombstones st WHERE st.entity = 'session' AND st.entity_key = p.session_id AND st.active = 1), '')), p.deleted_at + SELECT p.sync_id, ifnull(p.session_id, ''), coalesce(nullif(p.project, ''), nullif(s.project, ''), ifnull((SELECT st.project FROM sync_delete_tombstones st WHERE st.entity = 'session' AND st.entity_key = p.session_id AND st.active = 1), '')), p.deleted_at, ifnull(p.source_inbox_id, '') FROM prompt_tombstones p LEFT JOIN sessions s ON s.id = p.session_id WHERE `+promptWhere, promptArgs...) if err != nil { return nil, fmt.Errorf("export local prompt tombstones: %w", err) } for promptRows.Next() { - var syncID, sessionID, rowProject, deletedAt string - if err := promptRows.Scan(&syncID, &sessionID, &rowProject, &deletedAt); err != nil { + var syncID, sessionID, rowProject, deletedAt, inboxID string + if err := promptRows.Scan(&syncID, &sessionID, &rowProject, &deletedAt, &inboxID); err != nil { return nil, closeRowsWithError(promptRows, err) } - raw, err := json.Marshal(syncPromptPayload{SyncID: syncID, SessionID: sessionID, Project: nullableString(rowProject), Deleted: true, DeletedAt: &deletedAt, HardDelete: true}) + raw, err := json.Marshal(syncPromptPayload{SyncID: syncID, SessionID: sessionID, Project: nullableString(rowProject), SourceInboxID: inboxID, Deleted: true, DeletedAt: &deletedAt, HardDelete: true}) if err != nil { return nil, closeRowsWithError(promptRows, err) } @@ -5599,7 +5810,7 @@ func (s *Store) exportWithProjectScope(project string) (_ *ExportData, err error if err != nil { return nil, fmt.Errorf("export sessions: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var sess Session if err := rows.Scan(&sess.ID, &sess.Project, &sess.Directory, &sess.OwnershipMode, &sess.StartedAt, &sess.EndedAt, &sess.Summary); err != nil { @@ -5640,7 +5851,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 = ? @@ -5657,7 +5868,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) @@ -5666,6 +5877,32 @@ func (s *Store) exportWithProjectScope(project string) (_ *ExportData, err error return nil, err } + 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 += ` WHERE ` + tombstoneProject + ` = ?` + tombstoneArgs = append(tombstoneArgs, project) + } + tombstoneQuery += ` ORDER BY t.sync_id` + tombstoneRows, err := s.queryItHook(s.db, tombstoneQuery, tombstoneArgs...) + if err != nil { + return nil, fmt.Errorf("export prompt tombstones: %w", err) + } + for tombstoneRows.Next() { + var tombstone PromptTombstone + if err := tombstoneRows.Scan(&tombstone.SyncID, &tombstone.SessionID, &tombstone.Project, &tombstone.SourceInboxID, &tombstone.DeletedAt); err != nil { + return nil, closeRowsWithError(tombstoneRows, err) + } + data.PromptTombstones = append(data.PromptTombstones, tombstone) + } + if err := tombstoneRows.Close(); err != nil { + return nil, err + } + if err := tombstoneRows.Err(); err != nil { + return nil, err + } + // Relations are direct-backup metadata, not sync payloads. They are selected // separately so every persisted judgment and supersession field is retained. relationQuery := ` @@ -5824,12 +6061,106 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { result.ObservationsImported += int(n) } + // Restore deletion identities before admitting prompts from this backup. + for _, tombstone := range data.PromptTombstones { + if tombstone.SyncID == "" { + return nil, errors.New("import prompt tombstone: sync id is required") + } + if tombstone.SourceInboxID != "" && strings.TrimSpace(tombstone.SessionID) == "" { + return nil, fmt.Errorf("import prompt tombstone %q: session id is required for source inbox id", tombstone.SyncID) + } + // Validate before matching or removing prompts: the entire import must roll back on conflict. + var establishedSession, establishedInbox string + identityErr := tx.QueryRow(`SELECT ifnull(session_id, ''), ifnull(source_inbox_id, '') FROM prompt_tombstones WHERE sync_id = ?`, tombstone.SyncID).Scan(&establishedSession, &establishedInbox) + if identityErr != nil && !errors.Is(identityErr, sql.ErrNoRows) { + return nil, fmt.Errorf("import prompt tombstone %q: check identity: %w", tombstone.SyncID, identityErr) + } + if identityErr == nil && ((tombstone.SessionID != "" && establishedSession != "" && tombstone.SessionID != establishedSession) || (tombstone.SourceInboxID != "" && establishedInbox != "" && tombstone.SourceInboxID != establishedInbox)) { + return nil, fmt.Errorf("import prompt tombstone %q: %w", tombstone.SyncID, ErrPulledPromptIdentityInvalid) + } + rows, err := s.queryItHook(tx, `SELECT p.sync_id, p.session_id, ifnull(p.project, ''), ifnull(p.source_inbox_id, ''), ifnull(s.project, '') FROM user_prompts p LEFT JOIN sessions s ON s.id = p.session_id WHERE p.sync_id = ? OR (? != '' AND p.session_id = ? AND p.source_inbox_id = ?)`, tombstone.SyncID, tombstone.SourceInboxID, tombstone.SessionID, tombstone.SourceInboxID) + if err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: load local prompts: %w", tombstone.SyncID, err) + } + type deletedPrompt struct{ syncID, sessionID, project, inboxID, sessionProject string } + var matched []deletedPrompt + for rows.Next() { + var prompt deletedPrompt + if err := rows.Scan(&prompt.syncID, &prompt.sessionID, &prompt.project, &prompt.inboxID, &prompt.sessionProject); err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: scan local prompt: %w", tombstone.SyncID, closeRowsWithError(rows, err)) + } + matched = append(matched, prompt) + } + if err := closeRowsWithError(rows, rows.Err()); err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: read local prompts: %w", tombstone.SyncID, err) + } + for _, prompt := range matched { + owner := prompt.project + if strings.TrimSpace(owner) == "" { + owner = prompt.sessionProject + } + incoming := derefString(tombstone.Project) + if strings.TrimSpace(incoming) == "" { + incoming = prompt.sessionProject + } + owner, _ = NormalizeProject(owner) + incoming, _ = NormalizeProject(incoming) + if owner != incoming { + return nil, fmt.Errorf("import prompt tombstone %q: %w: project ownership conflict", tombstone.SyncID, ErrPulledPromptIdentityInvalid) + } + project, _ := NormalizeProject(prompt.project) + if project == "" { + project, err = s.resolveSessionProjectTx(tx, prompt.sessionID) + if err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: resolve project: %w", prompt.syncID, err) + } + } + if _, err := s.execHook(tx, `DELETE FROM user_prompts WHERE sync_id = ?`, prompt.syncID); err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: %w", prompt.syncID, err) + } + if err := s.recordPromptTombstoneTx(tx, prompt.syncID, prompt.sessionID, nullableString(project), prompt.inboxID, tombstone.DeletedAt); err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: %w", prompt.syncID, err) + } + enrolled, err := isProjectEnrolledTx(tx, project) + if err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: check enrollment: %w", prompt.syncID, err) + } + if enrolled { + deletedAt := tombstone.DeletedAt + payload := syncPromptPayload{SyncID: prompt.syncID, SessionID: prompt.sessionID, SourceInboxID: prompt.inboxID, Project: nullableString(project), Deleted: true, HardDelete: true, DeletedAt: &deletedAt} + if err := s.enqueueSyncMutationTx(tx, SyncEntityPrompt, prompt.syncID, SyncOpDelete, payload); err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: enqueue delete: %w", prompt.syncID, err) + } + } else { + changed, err := s.supersedeDeletedEntityMutationTx(tx, SyncEntityPrompt, prompt.syncID, project) + if err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: supersede mutation: %w", prompt.syncID, err) + } + if err := s.refreshSupersededProjectLifecycleTx(tx, project, changed); err != nil { + return nil, err + } + } + } + if err := s.recordPromptTombstoneTx(tx, tombstone.SyncID, tombstone.SessionID, tombstone.Project, tombstone.SourceInboxID, tombstone.DeletedAt); err != nil { + return nil, fmt.Errorf("import prompt tombstone %q: %w", tombstone.SyncID, err) + } + } // Import prompts for _, p := range data.Prompts { + if p.SourceInboxID != "" { + deleted, err := promptInboxDeletedTx(tx, p.SessionID, p.SourceInboxID) + if err != nil { + return nil, fmt.Errorf("import prompt %d: %w", p.ID, err) + } + if deleted { + continue + } + } syncID := normalizeExistingSyncID(p.SyncID, "prompt") var tombstoneDeletedAt string - if err := tx.QueryRow(`SELECT deleted_at FROM prompt_tombstones WHERE sync_id = ?`, syncID).Scan(&tombstoneDeletedAt); err == nil { - if isStalePromptUpsert(syncPromptPayload{CreatedAt: p.CreatedAt}, tombstoneDeletedAt) { + var deletedInboxID string + if err := tx.QueryRow(`SELECT deleted_at, ifnull(source_inbox_id, '') FROM prompt_tombstones WHERE sync_id = ?`, syncID).Scan(&tombstoneDeletedAt, &deletedInboxID); err == nil { + if deletedInboxID != "" || isStalePromptUpsert(syncPromptPayload{CreatedAt: p.CreatedAt}, tombstoneDeletedAt) { continue } if _, err := s.execHook(tx, `DELETE FROM prompt_tombstones WHERE sync_id = ?`, syncID); err != nil { @@ -5838,10 +6169,57 @@ 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) + } + var canonical syncPromptPayload + if err := tx.QueryRow(`SELECT sync_id, session_id, content, created_at, source_inbox_id FROM user_prompts WHERE id = ?`, existingID).Scan(&canonical.SyncID, &canonical.SessionID, &canonical.Content, &canonical.CreatedAt, &canonical.SourceInboxID); err != nil { + return nil, fmt.Errorf("import prompt %d: read adopted prompt: %w", p.ID, err) + } + canonical.Project = nullableString(localProject) + enrolled, err := isProjectEnrolledTx(tx, localProject) + if err != nil { + return nil, fmt.Errorf("import prompt %d: check enrollment: %w", p.ID, err) + } + if enrolled { + if err := s.enqueueSyncMutationTx(tx, SyncEntityPrompt, syncID, SyncOpUpsert, canonical); err != nil { + return nil, fmt.Errorf("import prompt %d: enqueue adopted identity: %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) @@ -6069,6 +6447,22 @@ func (s *Store) CloudSyncSummary() (CloudSyncSummary, error) { return summary, nil } +// MaxPendingSyncMutationSeq returns the highest sequence eligible for pending sync +// on targetKey, or zero when none exists. Like ListPendingSyncMutationsAfterSeq, +// only unacknowledged pending mutations for enrolled or global projects qualify; +// sync_state counters do not determine this bound. +func (s *Store) MaxPendingSyncMutationSeq(targetKey string) (int64, error) { + targetKey = normalizeSyncTargetKey(targetKey) + var seq int64 + err := s.db.QueryRow(` + SELECT COALESCE(MAX(sm.seq), 0) + FROM sync_mutations sm + LEFT JOIN sync_enrolled_projects sep ON sm.project = sep.project + WHERE sm.target_key = ? AND sm.acked_at IS NULL AND sm.disposition = 'pending' + AND (sm.project = '' OR sep.project IS NOT NULL)`, targetKey).Scan(&seq) + return seq, err +} + func (s *Store) ListPendingSyncMutationsAfterSeq(targetKey string, afterSeq int64, limit int) ([]SyncMutation, error) { targetKey = normalizeSyncTargetKey(targetKey) if limit <= 0 { @@ -8641,26 +9035,26 @@ func (s *Store) createSessionTx(tx *sql.Tx, id, project, directory, mode string) return err } _, err := s.execHook(tx, - `INSERT INTO sessions (id, project, ownership_mode, directory, started_at) VALUES (?, ?, ?, ?, ?) + `INSERT INTO sessions (id, project, ownership_mode, directory, started_at, local_creation_project) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET project = CASE WHEN ifnull(trim(sessions.project, ?), '') = '' THEN excluded.project ELSE sessions.project END, ownership_mode = CASE WHEN ifnull(trim(sessions.ownership_mode, ?), '') = '' THEN excluded.ownership_mode ELSE sessions.ownership_mode END, directory = CASE WHEN trim(sessions.directory, ?) = '' THEN excluded.directory ELSE sessions.directory END`, - id, project, mode, directory, Now(), sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, + id, project, mode, directory, Now(), project, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, ) return err } func (s *Store) startSessionTx(tx *sql.Tx, id, project, directory, mode string) error { result, err := s.execHook(tx, - `INSERT INTO sessions (id, project, ownership_mode, directory, started_at, runtime_lease_expires_at) VALUES (?, ?, ?, ?, ?, datetime('now', ?)) + `INSERT INTO sessions (id, project, ownership_mode, directory, started_at, runtime_lease_expires_at, local_creation_project) VALUES (?, ?, ?, ?, ?, datetime('now', ?), ?) ON CONFLICT(id) DO UPDATE SET project = CASE WHEN ifnull(trim(sessions.project, ?), '') = '' THEN excluded.project ELSE sessions.project END, ownership_mode = CASE WHEN ifnull(trim(sessions.ownership_mode, ?), '') = '' THEN excluded.ownership_mode ELSE sessions.ownership_mode END, directory = CASE WHEN trim(sessions.directory, ?) = '' THEN excluded.directory ELSE sessions.directory END, runtime_lease_expires_at = excluded.runtime_lease_expires_at WHERE sessions.ended_at IS NULL`, - id, project, mode, directory, Now(), runtimeSessionLeaseDuration, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, + id, project, mode, directory, Now(), runtimeSessionLeaseDuration, project, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, sqlWhitespaceTrimSet, ) if err != nil { return err @@ -9032,16 +9426,62 @@ func (s *Store) recordCloudDeleteTombstoneTx(tx *sql.Tx, targetKey, entity, enti return err } -func (s *Store) recordPromptTombstoneTx(tx *sql.Tx, syncID, sessionID string, project *string, deletedAt string) error { +func promptInboxDeletedTx(tx *sql.Tx, sessionID, inboxID string) (bool, error) { + var exists bool + err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM prompt_tombstones WHERE session_id = ? AND source_inbox_id = ?)`, sessionID, inboxID).Scan(&exists) + return exists, err +} + +// recordLocalPromptTombstoneTx preserves only a verified original live marker. +// Existing tombstones are never promoted, even when their ordinary identity matches. +func (s *Store) recordLocalPromptTombstoneTx(tx *sql.Tx, id int64, syncID, sessionID string, project *string, inboxID, deletedAt string) error { + var existing, count int + if syncID != "" { + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM prompt_tombstones WHERE sync_id=?)`, syncID).Scan(&existing); err != nil { + return err + } + if err := tx.QueryRow(`SELECT COUNT(*) FROM user_prompts WHERE sync_id=?`, syncID).Scan(&count); err != nil { + return err + } + } + var originalSession, originalInbox, originalProject sql.NullString + if err := tx.QueryRow(`SELECT local_creation_session_id, local_creation_inbox_id, local_creation_project FROM user_prompts WHERE id=?`, id). + Scan(&originalSession, &originalInbox, &originalProject); err != nil { + return err + } + if err := s.recordPromptTombstoneTx(tx, syncID, sessionID, project, inboxID, deletedAt); err != nil { + return err + } + if existing != 0 || count != 1 || originalSession.String == "" || originalInbox.String == "" || originalProject.String == "" || + originalSession.String != sessionID || originalInbox.String != inboxID || originalProject.String != derefString(project) { + return nil + } + _, err := s.execHook(tx, `UPDATE prompt_tombstones SET local_creation_session_id=?, local_creation_inbox_id=?, local_creation_project=? + WHERE sync_id=? AND local_creation_session_id IS NULL AND local_creation_inbox_id IS NULL AND local_creation_project IS NULL`, + originalSession.String, originalInbox.String, originalProject.String, syncID) + return err +} + +func (s *Store) recordPromptTombstoneTx(tx *sql.Tx, syncID, sessionID string, project *string, inboxID, deletedAt string) error { if project != nil { normalized, _ := NormalizeProject(strings.TrimSpace(*project)) project = nullableString(normalized) } - _, err := s.execHook(tx, - `INSERT INTO prompt_tombstones (sync_id, session_id, project, deleted_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(sync_id) DO UPDATE SET session_id = excluded.session_id, project = excluded.project, deleted_at = excluded.deleted_at`, - syncID, sessionID, project, deletedAt, + var existingSession, existingInbox, existingProject string + err := tx.QueryRow(`SELECT ifnull(session_id, ''), ifnull(source_inbox_id, ''), ifnull(project, '') FROM prompt_tombstones WHERE sync_id = ?`, syncID).Scan(&existingSession, &existingInbox, &existingProject) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil && ((sessionID != "" && existingSession != "" && sessionID != existingSession) || (inboxID != "" && existingInbox != "" && inboxID != existingInbox) || (project != nil && existingProject != "" && *project != existingProject)) { + return fmt.Errorf("%w: prompt tombstone %q conflicts with established identity", ErrPulledPromptIdentityInvalid, syncID) + } + _, err = s.execHook(tx, + `INSERT INTO prompt_tombstones (sync_id, session_id, project, source_inbox_id, deleted_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(sync_id) DO UPDATE SET session_id = COALESCE(NULLIF(excluded.session_id, ''), prompt_tombstones.session_id), + project = COALESCE(excluded.project, prompt_tombstones.project), source_inbox_id = COALESCE(excluded.source_inbox_id, prompt_tombstones.source_inbox_id), + deleted_at = excluded.deleted_at`, + syncID, sessionID, project, nullableString(inboxID), deletedAt, ) return err } @@ -9101,8 +9541,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 } @@ -9657,7 +10097,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 ( @@ -9684,7 +10124,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) @@ -9705,7 +10145,7 @@ func (s *Store) backfillPromptSyncMutationsTx(tx *sql.Tx, project string, source // ── Tombstoned prompts ──────────────────────────────────────────────────── tombstoneRows, err := s.queryItHook(tx, ` - SELECT prompt_tombstones.sync_id, prompt_tombstones.session_id, prompt_tombstones.project, prompt_tombstones.deleted_at + SELECT prompt_tombstones.sync_id, prompt_tombstones.session_id, prompt_tombstones.project, prompt_tombstones.deleted_at, ifnull(prompt_tombstones.source_inbox_id, '') FROM prompt_tombstones LEFT JOIN sessions s ON s.id = prompt_tombstones.session_id WHERE ( @@ -9733,7 +10173,7 @@ func (s *Store) backfillPromptSyncMutationsTx(tx *sql.Tx, project string, source var tombstonePending []syncPromptPayload for tombstoneRows.Next() { var payload syncPromptPayload - if err := tombstoneRows.Scan(&payload.SyncID, &payload.SessionID, &payload.Project, &payload.DeletedAt); err != nil { + if err := tombstoneRows.Scan(&payload.SyncID, &payload.SessionID, &payload.Project, &payload.DeletedAt, &payload.SourceInboxID); err != nil { return closeRowsWithError(tombstoneRows, err) } payload.Deleted = true @@ -10355,6 +10795,8 @@ func pulledIdentityInvalidReasonCode(applyErr error) (string, bool) { return SyncSessionIdentityInvalidReasonCode, true case errors.Is(applyErr, ErrPulledObservationIdentityInvalid): return SyncObservationIdentityInvalidReasonCode, true + case errors.Is(applyErr, ErrPulledPromptIdentityInvalid): + return SyncPromptIdentityInvalidReasonCode, true default: return "", false } @@ -10961,13 +11403,26 @@ func (s *Store) applyObservationDeleteTx(tx *sql.Tx, payload syncObservationPayl func (s *Store) applyPromptUpsertTx(tx *sql.Tx, payload syncPromptPayload) error { var tombstoneDeletedAt string - err := tx.QueryRow(`SELECT deleted_at FROM prompt_tombstones WHERE sync_id = ?`, payload.SyncID).Scan(&tombstoneDeletedAt) + var deletedInboxID string + err := tx.QueryRow(`SELECT deleted_at, ifnull(source_inbox_id, '') FROM prompt_tombstones WHERE sync_id = ?`, payload.SyncID).Scan(&tombstoneDeletedAt, &deletedInboxID) + if err == nil && deletedInboxID != "" { + return nil + } if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } if err == nil && isStalePromptUpsert(payload, tombstoneDeletedAt) { return nil } + if payload.SourceInboxID != "" { + deleted, lookupErr := promptInboxDeletedTx(tx, payload.SessionID, payload.SourceInboxID) + if lookupErr != nil { + return lookupErr + } + if deleted { + return nil + } + } if err := requirePulledParentSessionTx(tx, payload.SessionID); err != nil { return err } @@ -10978,17 +11433,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 @@ -11001,9 +11470,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 } @@ -11012,6 +11482,43 @@ func (s *Store) applyPromptDeleteTx(tx *sql.Tx, payload syncPromptPayload) error if strings.TrimSpace(payload.SyncID) == "" { return nil } + 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 payload.SourceInboxID != "" && strings.TrimSpace(payload.SessionID) != "" { + var otherID string + lookupErr := tx.QueryRow(`SELECT sync_id FROM user_prompts WHERE session_id = ? AND source_inbox_id = ?`, payload.SessionID, payload.SourceInboxID).Scan(&otherID) + if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) { + return lookupErr + } + if lookupErr == nil && otherID != payload.SyncID { + return fmt.Errorf("%w: delete prompt %q conflicts with inbox identity owned by %q", ErrPulledPromptIdentityInvalid, payload.SyncID, otherID) + } + } + if err == nil { + payload.SessionID = sessionID + payload.SourceInboxID = inboxID + } + if payload.SourceInboxID != "" && strings.TrimSpace(payload.SessionID) == "" { + return fmt.Errorf("%w: delete prompt %q: session id is required for source inbox id", ErrPulledPromptIdentityInvalid, payload.SyncID) + } + if payload.Project == nil || strings.TrimSpace(*payload.Project) == "" { + owner := promptProject + if strings.TrimSpace(owner) == "" { + if err := tx.QueryRow(`SELECT ifnull(project, '') FROM prompt_tombstones WHERE sync_id = ?`, payload.SyncID).Scan(&owner); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + 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 } @@ -11020,7 +11527,7 @@ func (s *Store) applyPromptDeleteTx(tx *sql.Tx, payload syncPromptPayload) error now := Now() deletedAt = &now } - return s.recordPromptTombstoneTx(tx, payload.SyncID, payload.SessionID, payload.Project, *deletedAt) + return s.recordPromptTombstoneTx(tx, payload.SyncID, payload.SessionID, payload.Project, payload.SourceInboxID, *deletedAt) } func isStalePromptUpsert(payload syncPromptPayload, tombstoneDeletedAt string) bool { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ab2850166..571b6058d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -765,6 +765,1004 @@ func TestAddObservationAdoptsUnownedLegacySessionProject(t *testing.T) { } } +func TestPromptInboxIdentityStore(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("inbox-session", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + p := AddPromptParams{SessionID: "inbox-session", Project: "engram", Content: "same", SourceInboxID: "inbox-1"} + first, inserted, err := s.AddPromptWithResult(p) + if err != nil || !inserted { + t.Fatalf("first: %d %v %v", first, inserted, err) + } + var before int + if err := s.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&before); err != nil { + t.Fatal(err) + } + again, inserted, err := s.AddPromptWithResult(p) + if err != nil || inserted || again != first { + t.Fatalf("replay: %d %v %v", again, inserted, err) + } + var after int + if err := s.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&after); err != nil || after != before { + t.Fatalf("mutations: %d -> %d: %v", before, after, err) + } + if err := s.CreateSession("other-session", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + other := p + other.SessionID = "other-session" + otherID, otherInserted, err := s.AddPromptWithResult(other) + if err != nil || !otherInserted || otherID == first { + t.Fatalf("same inbox ID in another session: %d %v %v", otherID, otherInserted, err) + } + p.SourceInboxID = "inbox-2" + second, inserted, err := s.AddPromptWithResult(p) + if err != nil || !inserted || second == first { + t.Fatalf("distinct: %d %v %v", second, inserted, err) + } + p.SourceInboxID = "" + third, err := s.AddPrompt(p) + if err != nil { + t.Fatal(err) + } + fourth, err := s.AddPrompt(p) + if err != nil || fourth == third { + t.Fatalf("legacy: %d %d %v", third, fourth, err) + } +} + +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 TestPromptInboxIdentityDeletedLocal(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("deleted-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + p := AddPromptParams{SessionID: "deleted-inbox", Project: "engram", Content: "same", SourceInboxID: "one"} + id, _, err := s.AddPromptWithResult(p) + if err != nil { + t.Fatal(err) + } + if err := s.DeletePrompt(id); err != nil { + t.Fatal(err) + } + var before, after int + if err := s.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&before); err != nil { + t.Fatal(err) + } + replayID, inserted, err := s.AddPromptWithResult(p) + if !errors.Is(err, ErrPromptInboxDeleted) || replayID != 0 || inserted { + t.Fatalf("deleted replay: %d %v %v", replayID, inserted, err) + } + if err := s.DB().QueryRow(`SELECT count(*) FROM sync_mutations`).Scan(&after); err != nil || after != before { + t.Fatalf("mutations %d -> %d: %v", before, after, err) + } + p.SourceInboxID = "two" + if _, inserted, err := s.AddPromptWithResult(p); err != nil || !inserted { + t.Fatalf("new inbox ID: %v %v", inserted, err) + } +} + +func TestPromptInboxIdentityDeletedSession(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("deleted-session-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + p := AddPromptParams{SessionID: "deleted-session-inbox", Project: "engram", Content: "same", SourceInboxID: "one"} + if _, _, err := s.AddPromptWithResult(p); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession(p.SessionID); err != nil { + t.Fatal(err) + } + if err := s.CreateSession(p.SessionID, "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if id, inserted, err := s.AddPromptWithResult(p); !errors.Is(err, ErrPromptInboxDeleted) || id != 0 || inserted { + t.Fatalf("session replay: %d %v %v", id, inserted, err) + } +} + +func TestPromptInboxIdentityDeletedPulled(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("pulled-deleted-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + deletion := `{"sync_id":"old-sync","session_id":"pulled-deleted-inbox","source_inbox_id":"one","deleted":true}` + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "old-sync", Op: SyncOpDelete, Payload: deletion}); err != nil { + t.Fatal(err) + } + upsert := `{"sync_id":"fresh-sync","session_id":"pulled-deleted-inbox","content":"same","source_inbox_id":"one"}` + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 2, Entity: SyncEntityPrompt, EntityKey: "fresh-sync", Op: SyncOpUpsert, Payload: upsert}); err != nil { + t.Fatalf("pulled replay: %v", err) + } + var count int + if err := s.DB().QueryRow(`SELECT count(*) FROM user_prompts WHERE session_id = ?`, "pulled-deleted-inbox").Scan(&count); err != nil || count != 0 { + t.Fatalf("rows: %d %v", count, err) + } + if err := s.DB().QueryRow(`SELECT count(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityPrompt, "fresh-sync").Scan(&count); err != nil || count != 0 { + t.Fatalf("new mutations: %d %v", count, err) + } +} + +func TestPromptInboxIdentityPulledDeleteUsesLiveIdentity(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("live-delete-session", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + id, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "live-delete-session", Project: "engram", Content: "original", SourceInboxID: "live-key"}) + if err != nil || !inserted { + t.Fatalf("create prompt: id=%d inserted=%v err=%v", id, inserted, 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) + } + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, + Payload: fmt.Sprintf(`{"sync_id":%q,"session_id":"wrong-session","source_inbox_id":"wrong-key","project":"engram","deleted":true}`, syncID)} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + var sessionID, inboxID string + if err := s.DB().QueryRow(`SELECT session_id, source_inbox_id FROM prompt_tombstones WHERE sync_id = ?`, syncID).Scan(&sessionID, &inboxID); err != nil { + t.Fatal(err) + } + if sessionID != "live-delete-session" || inboxID != "live-key" { + t.Fatalf("delete recorded payload identity instead of live identity: session=%q inbox=%q", sessionID, inboxID) + } + if id, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "live-delete-session", Project: "engram", Content: "replay", SourceInboxID: "live-key"}); !errors.Is(err, ErrPromptInboxDeleted) || id != 0 || inserted { + t.Fatalf("deleted identity was reused: id=%d inserted=%v err=%v", id, inserted, err) + } +} + +func TestPromptInboxIdentityDeletedPulledAgainWithoutSession(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("repeated-delete-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + first := `{"sync_id":"old-sync","session_id":"repeated-delete-inbox","source_inbox_id":"one","deleted":true}` + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "old-sync", Op: SyncOpDelete, Payload: first} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + deletion.Seq = 2 + deletion.Payload = `{"sync_id":"old-sync","deleted":true}` + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + var sessionID, inboxID string + if err := s.DB().QueryRow(`SELECT session_id, source_inbox_id FROM prompt_tombstones WHERE sync_id = ?`, "old-sync").Scan(&sessionID, &inboxID); err != nil { + t.Fatal(err) + } + if sessionID != "repeated-delete-inbox" || inboxID != "one" { + t.Fatalf("repeated deletion lost identity: session=%q inbox=%q", sessionID, inboxID) + } + p := AddPromptParams{SessionID: sessionID, Project: "engram", Content: "same", SourceInboxID: inboxID} + if id, inserted, err := s.AddPromptWithResult(p); !errors.Is(err, ErrPromptInboxDeleted) || id != 0 || inserted { + t.Fatalf("local replay: %d %v %v", id, inserted, err) + } +} + +func TestPromptSparseDeleteRetainsProjectAfterSessionRemoval(t *testing.T) { + s := newTestStore(t) + const sessionID = "sparse-project-session" + const syncID = "sparse-project-prompt" + if err := s.CreateSession(sessionID, "engram", "/tmp"); err != nil { + t.Fatal(err) + } + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, + Payload: `{"sync_id":"sparse-project-prompt","session_id":"sparse-project-session","project":"engram","source_inbox_id":"one","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession(sessionID); err != nil { + t.Fatal(err) + } + deletion.Seq = 2 + deletion.Payload = `{"sync_id":"sparse-project-prompt","deleted":true}` + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + exported, err := s.ExportProject("engram") + if err != nil { + t.Fatal(err) + } + if len(exported.PromptTombstones) != 1 || exported.PromptTombstones[0].SyncID != syncID || + exported.PromptTombstones[0].SessionID != sessionID || exported.PromptTombstones[0].SourceInboxID != "one" || + exported.PromptTombstones[0].Project == nil || *exported.PromptTombstones[0].Project != "engram" { + t.Fatalf("project export lost scoped tombstone: %+v", exported.PromptTombstones) + } + fresh := newTestStore(t) + if _, err := fresh.Import(exported); err != nil { + t.Fatal(err) + } + if err := fresh.CreateSession(sessionID, "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if id, inserted, err := fresh.AddPromptWithResult(AddPromptParams{SessionID: sessionID, Project: "engram", Content: "stale", SourceInboxID: "one"}); !errors.Is(err, ErrPromptInboxDeleted) || id != 0 || inserted { + t.Fatalf("stale inbox replay: id=%d inserted=%v err=%v", id, inserted, err) + } +} + +func TestPulledSparsePromptDeleteSurvivesSessionRemoval(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("sparse-owner", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + deletion := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "sparse-key", Op: SyncOpDelete, Payload: `{"sync_id":"sparse-key","session_id":"sparse-owner","source_inbox_id":"inbox","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, deletion); err != nil { + t.Fatal(err) + } + if err := s.DeleteSession("sparse-owner"); err != nil { + t.Fatal(err) + } + exported, err := s.ExportProject("engram") + if err != nil { + t.Fatal(err) + } + if len(exported.PromptTombstones) != 1 || exported.PromptTombstones[0].SyncID != "sparse-key" { + t.Fatalf("missing project delete: %+v", exported.PromptTombstones) + } + other, err := s.ExportProject("other") + if err != nil { + t.Fatal(err) + } + if len(other.PromptTombstones) != 0 { + t.Fatalf("cross-project delete: %+v", other.PromptTombstones) + } + fresh := newTestStore(t) + if _, err := fresh.Import(exported); err != nil { + t.Fatal(err) + } + if err := fresh.CreateSession("sparse-owner", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if _, _, err := fresh.AddPromptWithResult(AddPromptParams{SessionID: "sparse-owner", Project: "engram", SourceInboxID: "inbox", Content: "replay"}); !errors.Is(err, ErrPromptInboxDeleted) { + t.Fatalf("replay: %v", err) + } +} + +func 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 TestPulledSparsePromptDeleteRetainsOwnTombstoneProjectWhileSessionLives(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("cross-owner", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + const syncID = "cross-project-repeat" + for _, mutation := range []SyncMutation{ + {Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpUpsert, Payload: `{"sync_id":"cross-project-repeat","session_id":"cross-owner","project":"beta","content":"cross","source_inbox_id":"inbox"}`}, + {Seq: 2, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, Payload: `{"sync_id":"cross-project-repeat","deleted":true}`}, + {Seq: 3, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, Payload: `{"sync_id":"cross-project-repeat","session_id":"cross-owner","deleted":true}`}, + } { + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, mutation); err != nil { + t.Fatal(err) + } + } + state, err := s.GetSyncState(DefaultSyncTargetKey) + if err != nil || state.LastPulledSeq != 3 { + t.Fatalf("cursor=%+v, err=%v", state, err) + } + dead, err := s.ListDeferred(ListDeferredOptions{Status: "dead"}) + if err != nil || len(dead) != 0 { + t.Fatalf("dead letters=%+v, err=%v", dead, err) + } + if got := scalarString(t, s, `SELECT project FROM prompt_tombstones WHERE sync_id = 'cross-project-repeat'`); got != "beta" { + t.Fatalf("tombstone project=%q", got) + } + beta, err := s.ExportProject("beta") + if err != nil || len(beta.PromptTombstones) != 1 || beta.PromptTombstones[0].SyncID != syncID { + t.Fatalf("beta export=%+v, err=%v", beta, err) + } + alpha, err := s.ExportProject("alpha") + if err != nil || len(alpha.PromptTombstones) != 0 { + t.Fatalf("alpha export=%+v, err=%v", alpha, err) + } +} + +func TestPulledPromptDeleteQuarantinesInboxWithoutSession(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.Fatalf("quarantine invalid inbox identity: %v", err) + } + 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 { + t.Fatal(err) + } + p := AddPromptParams{SessionID: "pulled-backfill-inbox", Project: "engram", Content: "same", SourceInboxID: "one"} + id, _, err := s.AddPromptWithResult(p) + 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) + } + deletion := fmt.Sprintf(`{"sync_id":%q,"session_id":"pulled-backfill-inbox","deleted":true}`, syncID) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: syncID, Op: SyncOpDelete, Payload: deletion}); err != nil { + t.Fatal(err) + } + if _, _, err := s.AddPromptWithResult(p); !errors.Is(err, ErrPromptInboxDeleted) { + t.Fatalf("backfilled deletion: %v", err) + } + mutations, err := s.ExportLocalDeleteTombstones("engram") + if err != nil { + t.Fatal(err) + } + found := false + for _, mutation := range mutations { + if mutation.Entity != SyncEntityPrompt || mutation.EntityKey != syncID { + continue + } + var payload syncPromptPayload + if err := json.Unmarshal([]byte(mutation.Payload), &payload); err != nil { + t.Fatal(err) + } + found = payload.SourceInboxID == "one" + } + if !found { + t.Fatal("backfilled inbox ID missing from delete export") + } +} + +func TestPromptInboxIdentityDeletedEnrollmentBackfill(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("enrollment-deleted-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + id, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "enrollment-deleted-inbox", Project: "engram", Content: "same", SourceInboxID: "one"}) + if err != nil { + t.Fatal(err) + } + if err := s.DeletePrompt(id); err != nil { + t.Fatal(err) + } + if err := s.EnrollProject("engram"); err != nil { + t.Fatal(err) + } + var payloadJSON string + err = s.DB().QueryRow(`SELECT payload FROM sync_mutations WHERE entity = ? AND op = ? ORDER BY seq DESC LIMIT 1`, SyncEntityPrompt, SyncOpDelete).Scan(&payloadJSON) + if err != nil { + t.Fatal(err) + } + var payload syncPromptPayload + if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { + t.Fatal(err) + } + if payload.SourceInboxID != "one" { + t.Fatalf("backfilled delete inbox ID: %q", payload.SourceInboxID) + } +} + +func TestPromptInboxIdentityDeletedLegacyBackupJSON(t *testing.T) { + var backup ExportData + if err := json.Unmarshal([]byte(fmt.Sprintf(`{"version":%q,"sessions":[],"observations":[],"prompts":[]}`, currentExportVersion)), &backup); err != nil { + t.Fatal(err) + } + if len(backup.PromptTombstones) != 0 { + t.Fatalf("unexpected tombstones: %v", backup.PromptTombstones) + } + s := newTestStore(t) + if _, err := s.Import(&backup); err != nil { + t.Fatal(err) + } +} + +func TestPromptInboxIdentityDeletedLegacyBackup(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("legacy-deleted-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + backup := &ExportData{Version: currentExportVersion, PromptTombstones: []PromptTombstone{{SyncID: "legacy-deleted", SessionID: "legacy-deleted-inbox", DeletedAt: Now()}}} + if _, err := s.Import(backup); err != nil { + t.Fatal(err) + } + if _, inserted, err := s.AddPromptWithResult(AddPromptParams{SessionID: "legacy-deleted-inbox", Project: "engram", Content: "new", SourceInboxID: "one"}); err != nil || !inserted { + t.Fatalf("legacy tombstone blocks unrelated ID: %v %v", inserted, err) + } +} + +func TestImportRejectsInboxTombstoneWithoutSessionAtomically(t *testing.T) { + for _, sessionID := range []string{"", " \t "} { + t.Run(fmt.Sprintf("session_%q", sessionID), func(t *testing.T) { + s := newTestStore(t) + backup := &ExportData{ + Version: currentExportVersion, + Sessions: []Session{{ID: "partial-import-session", Project: "engram", Directory: "/tmp", StartedAt: Now()}}, + PromptTombstones: []PromptTombstone{{SyncID: "invalid-inbox-delete", SessionID: sessionID, SourceInboxID: "inbox-1", DeletedAt: Now()}}, + } + if _, err := s.Import(backup); err == nil || !strings.Contains(err.Error(), "invalid-inbox-delete") { + t.Fatalf("import error = %v, want sync ID context", err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM sessions WHERE id = ?`, "partial-import-session"); got != 0 { + t.Fatalf("partial session persisted: %d", got) + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = ?`, "invalid-inbox-delete"); got != 0 { + t.Fatalf("invalid tombstone persisted: %d", got) + } + }) + } +} + +func TestImportLegacyEmptySessionTombstone(t *testing.T) { + s := newTestStore(t) + if _, err := s.Import(&ExportData{Version: currentExportVersion, PromptTombstones: []PromptTombstone{{SyncID: "legacy-empty-session", DeletedAt: Now()}}}); err != nil { + t.Fatal(err) + } + 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) { + source := newTestStore(t) + if err := source.CreateSession("backup-deleted-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + p := AddPromptParams{SessionID: "backup-deleted-inbox", Project: "engram", Content: "same", SourceInboxID: "one"} + id, _, err := source.AddPromptWithResult(p) + if err != nil { + t.Fatal(err) + } + if err := source.DeletePrompt(id); err != nil { + t.Fatal(err) + } + backup, err := source.Export() + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(backup) + if err != nil { + t.Fatal(err) + } + var decoded ExportData + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + restored := newTestStore(t) + if _, err := restored.Import(&decoded); err != nil { + t.Fatal(err) + } + if id, inserted, err := restored.AddPromptWithResult(p); !errors.Is(err, ErrPromptInboxDeleted) || id != 0 || inserted { + t.Fatalf("restored replay: %d %v %v", id, inserted, err) + } +} + +func TestPromptInboxIdentityStoreRetryReplaysCompetingWrite(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("retry-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + p := AddPromptParams{SessionID: "retry-inbox", Project: "engram", Content: "attempt", SourceInboxID: "inbox-1"} + competing, err := sql.Open("sqlite", storeDSN(filepath.Join(s.cfg.DataDir, "engram.db"))) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := competing.Close(); err != nil { + t.Errorf("close competing prompt database: %v", err) + } + }() + + originalCommit := s.hooks.commit + attempts := 0 + var competingID int64 + s.hooks.commit = func(tx *sql.Tx) error { + attempts++ + if attempts != 1 { + return originalCommit(tx) + } + var pending int + if err := tx.QueryRow(`SELECT count(*) FROM user_prompts WHERE session_id = ? AND source_inbox_id = ?`, p.SessionID, p.SourceInboxID).Scan(&pending); err != nil { + return err + } + if pending != 1 { + t.Fatalf("first attempt prompt count = %d, want 1", pending) + } + if err := tx.Rollback(); err != nil { + return err + } + res, err := competing.ExecContext(context.Background(), + `INSERT INTO user_prompts (sync_id, session_id, content, project, source_inbox_id) VALUES (?, ?, ?, ?, ?)`, + "competing-prompt", p.SessionID, "competing", p.Project, p.SourceInboxID) + if err != nil { + return err + } + competingID, err = res.LastInsertId() + if err != nil { + return err + } + return errors.New("database is locked") + } + t.Cleanup(func() { s.hooks.commit = originalCommit }) + + id, inserted, err := s.AddPromptWithResult(p) + if err != nil { + t.Fatal(err) + } + if attempts != 2 || id != competingID || inserted { + t.Fatalf("attempts=%d id=%d competingID=%d inserted=%t; want two attempts, competing ID and false", attempts, id, competingID, inserted) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE session_id='retry-inbox' AND source_inbox_id='inbox-1'`); got != 1 { + t.Fatalf("persisted prompts = %d, want 1", got) + } +} + +func TestPromptInboxIdentityStoreConcurrentReplay(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("concurrent-inbox", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + enrollTestProject(t, s, "engram") + const workers = 12 + start := make(chan struct{}) + type result struct { + id int64 + inserted bool + err error + } + results := make(chan result, workers) + p := AddPromptParams{SessionID: "concurrent-inbox", Project: "engram", Content: "same", SourceInboxID: "shared"} + for i := 0; i < workers; i++ { + go func() { + <-start + id, inserted, err := s.AddPromptWithResult(p) + results <- result{id, inserted, err} + }() + } + close(start) + var first int64 + var inserts int + for i := 0; i < workers; i++ { + r := <-results + if r.err != nil { + t.Fatal(r.err) + } + if first == 0 { + first = r.id + } else if r.id != first { + t.Fatalf("concurrent replay returned %d, want %d", r.id, first) + } + if r.inserted { + inserts++ + } + } + if inserts != 1 { + t.Fatalf("inserted %d times, want one", inserts) + } + var mutations int + if err := s.DB().QueryRow(`SELECT count(*) FROM sync_mutations WHERE entity = 'prompt'`).Scan(&mutations); err != nil { + t.Fatal(err) + } + if mutations != 1 { + t.Fatalf("prompt sync mutations = %d, want one", mutations) + } +} + func TestAddPromptAdoptsUnownedLegacySessionProject(t *testing.T) { type legacySession struct{ id, project string } s := newTestStoreWithNullableLegacySessions(t, legacySession{"null-session", ""}) @@ -2636,15 +3634,17 @@ func TestNewMigratesLegacyUserPromptsSyncIDSchema(t *testing.T) { } t.Cleanup(func() { _ = s.Close() }) - var syncID string - if err := s.db.QueryRow("SELECT sync_id FROM user_prompts WHERE content = ?", "legacy prompt").Scan(&syncID); err != nil { - t.Fatalf("query migrated prompt sync_id: %v", err) + var legacyID int64 + var legacyContent, syncID string + var legacyInboxID sql.NullString + if err := s.db.QueryRow("SELECT id, content, sync_id, source_inbox_id FROM user_prompts WHERE session_id = ?", "s1").Scan(&legacyID, &legacyContent, &syncID, &legacyInboxID); err != nil { + t.Fatalf("query migrated legacy prompt: %v", err) } - if syncID == "" { - t.Fatalf("expected migrated prompt sync_id to be backfilled") + if legacyID != 1 || legacyContent != "legacy prompt" || syncID == "" || legacyInboxID.Valid { + t.Fatalf("legacy prompt not preserved: id=%d content=%q sync_id=%q inbox_id=%v", legacyID, legacyContent, syncID, legacyInboxID) } - var hasSyncIDColumn bool + var hasSyncIDColumn, hasInboxIDColumn bool rows, err := s.db.Query("PRAGMA table_info(user_prompts)") if err != nil { t.Fatalf("query prompt columns: %v", err) @@ -2657,20 +3657,24 @@ func TestNewMigratesLegacyUserPromptsSyncIDSchema(t *testing.T) { if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk); err != nil { t.Fatalf("scan prompt column: %v", err) } - if name == "sync_id" { + switch name { + case "sync_id": hasSyncIDColumn = true - break + case "source_inbox_id": + hasInboxIDColumn = true } } if err := rows.Err(); err != nil { - rows.Close() + if closeErr := rows.Close(); closeErr != nil { + t.Fatalf("iterate prompt columns: %v; close prompt columns: %v", err, closeErr) + } t.Fatalf("iterate prompt columns: %v", err) } if err := rows.Close(); err != nil { t.Fatalf("close prompt columns: %v", err) } - if !hasSyncIDColumn { - t.Fatalf("expected user_prompts.sync_id column after migration") + if !hasSyncIDColumn || !hasInboxIDColumn { + t.Fatalf("expected sync_id and source_inbox_id columns after migration: sync=%v inbox=%v", hasSyncIDColumn, hasInboxIDColumn) } var indexName string @@ -2680,6 +3684,39 @@ func TestNewMigratesLegacyUserPromptsSyncIDSchema(t *testing.T) { if indexName != "idx_prompts_sync_id" { t.Fatalf("expected idx_prompts_sync_id to exist, got %q", indexName) } + + var indexSQL string + if err := s.db.QueryRow("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_prompts_source_inbox' AND tbl_name = 'user_prompts'").Scan(&indexSQL); err != nil { + t.Fatalf("query prompt inbox index: %v", err) + } + if !strings.Contains(indexSQL, "UNIQUE INDEX") || !strings.Contains(indexSQL, "(session_id, source_inbox_id)") || !strings.Contains(indexSQL, "WHERE source_inbox_id IS NOT NULL") { + t.Fatalf("expected unique partial session/inbox index, got %q", indexSQL) + } + + params := AddPromptParams{SessionID: "s1", Content: "new prompt", Project: "engram", SourceInboxID: "inbox-1"} + promptID, inserted, err := s.AddPromptWithResult(params) + if err != nil || !inserted || promptID <= 0 || promptID == legacyID { + t.Fatalf("insert inbox prompt: id=%d inserted=%v err=%v", promptID, inserted, err) + } + params.Content = "replayed prompt must not replace original" + replayID, inserted, err := s.AddPromptWithResult(params) + if err != nil || inserted || replayID != promptID { + t.Fatalf("replay inbox prompt: id=%d inserted=%v err=%v; original id=%d", replayID, inserted, err, promptID) + } + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM user_prompts WHERE session_id = ?", "s1").Scan(&count); err != nil { + t.Fatalf("count prompts after replay: %v", err) + } + if count != 2 { + t.Fatalf("expected legacy and new prompt only, got %d", count) + } + var storedContent, storedSyncID string + if err := s.db.QueryRow("SELECT content, sync_id FROM user_prompts WHERE id = ?", legacyID).Scan(&storedContent, &storedSyncID); err != nil { + t.Fatalf("query legacy prompt after replay: %v", err) + } + if storedContent != legacyContent || storedSyncID != syncID { + t.Fatalf("legacy prompt changed after replay: content=%q sync_id=%q", storedContent, storedSyncID) + } } func TestSuggestTopicKeyNormalizesDeterministically(t *testing.T) { @@ -4371,6 +5408,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 { @@ -5297,6 +6386,83 @@ func TestApplyPulledChunkIsAtomicAndRetrySafe(t *testing.T) { } } +func TestApplyPulledPromptDeleteInvalidInboxIdentityQuarantinesAndContinues(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-prompt","session_id":%q,"source_inbox_id":"inbox","deleted":true}`, session) + invalid := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "bad-prompt", Op: SyncOpDelete, Payload: payload} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, invalid); err != nil { + t.Fatalf("invalid pull: %v", err) + } + if got := scalarInt(t, s, `SELECT COUNT(*) FROM prompt_tombstones WHERE sync_id = 'bad-prompt'`); got != 0 { + t.Fatalf("invalid tombstones=%d", got) + } + rows, err := s.ListDeferred(ListDeferredOptions{Status: "dead"}) + if err != nil || len(rows) != 1 || rows[0].PayloadRaw != payload || rows[0].ReasonCode != SyncPromptIdentityInvalidReasonCode || rows[0].RemoteSeq != 1 || rows[0].EntityKey != invalid.EntityKey || rows[0].Op != invalid.Op { + t.Fatalf("dead evidence=%+v, err=%v", rows, err) + } + state, err := s.GetSyncState(DefaultSyncTargetKey) + if err != nil || state.LastPulledSeq != 1 { + t.Fatalf("invalid cursor=%+v, err=%v", state, err) + } + valid := SyncMutation{Seq: 2, Entity: SyncEntityPrompt, EntityKey: "good-prompt", Op: SyncOpDelete, Payload: `{"sync_id":"good-prompt","session_id":"owner","source_inbox_id":"inbox","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, valid); err != nil { + t.Fatalf("valid pull: %v", err) + } + if got := scalarInt(t, s, `SELECT COUNT(*) FROM prompt_tombstones WHERE sync_id = 'good-prompt' AND session_id = 'owner' AND source_inbox_id = 'inbox'`); got != 1 { + t.Fatalf("valid keyed tombstones=%d", got) + } + state, err = s.GetSyncState(DefaultSyncTargetKey) + if err != nil || state.LastPulledSeq != 2 { + t.Fatalf("valid cursor=%+v, err=%v", state, err) + } + }) + } +} + +func TestPulledPromptDeleteConflictingTombstoneQuarantines(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("owner", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.CreateSession("other", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + first := SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "fixed", Op: SyncOpDelete, Payload: `{"sync_id":"fixed","session_id":"owner","source_inbox_id":"key","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, first); err != nil { + t.Fatal(err) + } + for i, identity := range []string{`"session_id":"other","source_inbox_id":"key"`, `"session_id":"owner","source_inbox_id":"different"`} { + payload := fmt.Sprintf(`{"sync_id":"fixed",%s,"deleted":true}`, identity) + mutation := SyncMutation{Seq: int64(i + 2), Entity: SyncEntityPrompt, EntityKey: "fixed", Op: SyncOpDelete, Payload: payload} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, mutation); err != nil { + t.Fatalf("conflicting pull: %v", err) + } + if got := scalarString(t, s, `SELECT session_id || ':' || source_inbox_id FROM prompt_tombstones WHERE sync_id = 'fixed'`); got != "owner:key" { + t.Fatalf("identity changed: %s", got) + } + rows, err := s.ListDeferred(ListDeferredOptions{Status: "dead"}) + if err != nil || len(rows) != i+1 || rows[i].PayloadRaw != payload || rows[i].ReasonCode != SyncPromptIdentityInvalidReasonCode || rows[i].RemoteSeq != mutation.Seq { + t.Fatalf("dead evidence=%+v, err=%v", rows, err) + } + state, err := s.GetSyncState(DefaultSyncTargetKey) + if err != nil || state.LastPulledSeq != mutation.Seq { + t.Fatalf("cursor=%+v, err=%v", state, err) + } + } + if _, _, err := s.AddPromptWithResult(AddPromptParams{SessionID: "owner", Project: "engram", Content: "replay", SourceInboxID: "key"}); !errors.Is(err, ErrPromptInboxDeleted) { + t.Fatalf("deleted key reused: %v", err) + } + legacy := SyncMutation{Seq: 4, Entity: SyncEntityPrompt, EntityKey: "legacy-idless", Op: SyncOpDelete, Payload: `{"sync_id":"legacy-idless","session_id":"owner","deleted":true}`} + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, legacy); err != nil { + t.Fatalf("legacy delete: %v", err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = 'legacy-idless'`); got != 1 { + t.Fatalf("legacy tombstones=%d", got) + } +} + func TestApplyPulledPromptDeleteCreatesTombstoneAndRemovesPrompt(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("s-prompt", "engram", "/tmp/engram"); err != nil { @@ -5380,6 +6546,66 @@ func TestApplyPulledPromptUpsertUpdatesCreatedAtOnExistingPrompt(t *testing.T) { } } +func TestImportPromptTombstoneJournalsMatchedLocalDelete(t *testing.T) { + for _, sameSyncID := range []bool{false, true} { + for _, repairFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("sameSyncID=%t/repairFirst=%t", sameSyncID, repairFirst), func(t *testing.T) { + s := newTestStore(t) + enrollTestProject(t, s, "engram") + if err := s.CreateSession("import-delete-session", "engram", "/tmp/engram"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`INSERT INTO user_prompts (sync_id, session_id, content, project, source_inbox_id) VALUES (?, ?, ?, ?, ?)`, "local-prompt", "import-delete-session", "hello", "engram", "inbox-1"); err != nil { + t.Fatal(err) + } + if repairFirst { + if err := s.EnsureEnrolledProjectSyncMutations(context.Background()); err != nil { + t.Fatal(err) + } + } + incomingID := "incoming-prompt" + if sameSyncID { + incomingID = "local-prompt" + } + deletedAt := Now() + data := &ExportData{PromptTombstones: []PromptTombstone{{SyncID: incomingID, SessionID: "import-delete-session", Project: nullableString("engram"), SourceInboxID: "inbox-1", DeletedAt: deletedAt}}} + for i := 0; i < 2; i++ { + if _, err := s.Import(data); err != nil { + t.Fatal(err) + } + } + if err := s.EnsureEnrolledProjectSyncMutations(context.Background()); err != nil { + t.Fatal(err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE sync_id = ?`, "local-prompt"); got != 0 { + t.Fatalf("prompt remains: %d", got) + } + wantTombstones := 2 + if sameSyncID { + wantTombstones = 1 + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id IN (?, ?)`, "local-prompt", incomingID); got != wantTombstones { + t.Fatalf("tombstones = %d, want %d", got, wantTombstones) + } + var payloadJSON string + if err := s.db.QueryRow(`SELECT payload FROM sync_mutations WHERE entity = 'prompt' AND entity_key = ? AND op = 'delete' AND project = 'engram' AND disposition = 'pending'`, "local-prompt").Scan(&payloadJSON); err != nil { + t.Fatalf("local pending delete: %v", err) + } + var payload syncPromptPayload + if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { + t.Fatal(err) + } + if payload.SyncID != "local-prompt" || payload.SessionID != "import-delete-session" || payload.SourceInboxID != "inbox-1" || payload.Project == nil || *payload.Project != "engram" || !payload.Deleted || !payload.HardDelete || payload.DeletedAt == nil || *payload.DeletedAt != deletedAt { + t.Fatalf("pending delete payload = %+v", payload) + } + if got := scalarInt(t, s, `SELECT count(*) FROM sync_mutations WHERE entity = 'prompt' AND entity_key = ? AND op = 'delete' AND project = 'engram' AND disposition = 'pending'`, "local-prompt"); got != 1 { + t.Fatalf("local pending deletes = %d, want 1", got) + } + }) + } + } +} + func TestDeletePromptEnqueuesDeleteMutationAndTombstone(t *testing.T) { s := newTestStore(t) enrollTestProject(t, s, "engram") @@ -6254,10 +7480,10 @@ func TestImportRejectsNonOrphanedDanglingAndMissingSupersedingRelations(t *testi destination := newTestStore(t) project := "backup-project" data := &ExportData{ - Version: "0.2.0", - Sessions: []Session{{ID: "invalid-relation-session", Project: "backup-project", Directory: "/tmp/backup", StartedAt: "2026-01-01T00:00:00Z"}}, + Version: "0.2.0", + Sessions: []Session{{ID: "invalid-relation-session", Project: "backup-project", Directory: "/tmp/backup", StartedAt: "2026-01-01T00:00:00Z"}}, Observations: []Observation{{SyncID: "obs-valid-endpoint", SessionID: "invalid-relation-session", Type: "note", Title: "valid", Content: "valid", Project: &project, Scope: "project", CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}}, - Relations: []BackupRelation{{SyncID: "rel-invalid-endpoint", SourceID: "obs-valid-endpoint", TargetID: "obs-missing-endpoint", Relation: RelationRelated, JudgmentStatus: status, CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}}, + Relations: []BackupRelation{{SyncID: "rel-invalid-endpoint", SourceID: "obs-valid-endpoint", TargetID: "obs-missing-endpoint", Relation: RelationRelated, JudgmentStatus: status, CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}}, } if _, err := destination.Import(data); err == nil || !strings.Contains(err.Error(), "relation endpoint") { t.Fatalf("import dangling %s relation error = %v, want missing endpoint error", status, err) @@ -6270,7 +7496,7 @@ func TestImportRejectsNonOrphanedDanglingAndMissingSupersedingRelations(t *testi project := "backup-project" missingSuperseding := "rel-not-in-backup" data := &ExportData{ - Version: "0.2.0", + Version: "0.2.0", Sessions: []Session{{ID: "missing-superseding-session", Project: "backup-project", Directory: "/tmp/backup", StartedAt: "2026-01-01T00:00:00Z"}}, Observations: []Observation{ {SyncID: "obs-superseding-source", SessionID: "missing-superseding-session", Type: "note", Title: "source", Content: "source", Project: &project, Scope: "project", CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}, @@ -6313,10 +7539,10 @@ func TestImportValidatesMissingSupersedingRelationForExistingRelation(t *testing project := "backup-project" missingSuperseding := "rel-missing-superseder" data := &ExportData{ - Version: "0.2.0", - Sessions: []Session{{ID: "rolled-back-session", Project: project, Directory: "/tmp/rollback", StartedAt: "2026-01-01T00:00:00Z"}}, + Version: "0.2.0", + Sessions: []Session{{ID: "rolled-back-session", Project: project, Directory: "/tmp/rollback", StartedAt: "2026-01-01T00:00:00Z"}}, Observations: []Observation{{SyncID: "obs-rolled-back", SessionID: "rolled-back-session", Type: "note", Title: "rollback", Content: "rollback", Project: &project, Scope: "project", CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}}, - Relations: []BackupRelation{{SyncID: "rel-existing-no-superseder", SourceID: source.SyncID, TargetID: target.SyncID, Relation: RelationRelated, JudgmentStatus: JudgmentStatusPending, SupersededByRelationSyncID: &missingSuperseding, CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}}, + Relations: []BackupRelation{{SyncID: "rel-existing-no-superseder", SourceID: source.SyncID, TargetID: target.SyncID, Relation: RelationRelated, JudgmentStatus: JudgmentStatusPending, SupersededByRelationSyncID: &missingSuperseding, CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z"}}, } if _, err := destination.Import(data); err == nil || !strings.Contains(err.Error(), "superseding relation") { t.Fatalf("import existing relation with missing superseder error = %v, want missing superseding relation error", err) @@ -6660,6 +7886,471 @@ func TestImportOlderObservationDoesNotResurrectLocalDeletion(t *testing.T) { } } +func TestImportAdoptionAppendsCanonicalPromptMutation(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("adoption-pending", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.EnrollProject("engram"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "adoption-pending", Project: "engram", Content: "original"}) + if err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + before := scalarInt(t, s, `SELECT count(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityPrompt, syncID) + _, err = s.Import(&ExportData{Prompts: []Prompt{{SyncID: syncID, SessionID: "adoption-pending", Project: "engram", Content: "original", SourceInboxID: "adopted"}}}) + if err != nil { + t.Fatal(err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityPrompt, syncID); got != before+1 { + t.Fatalf("mutation count = %d, want %d", got, before+1) + } + if got := scalarString(t, s, `SELECT ifnull(json_extract(payload, '$.source_inbox_id'), '') FROM sync_mutations WHERE entity = ? AND entity_key = ? ORDER BY seq ASC LIMIT 1`, SyncEntityPrompt, syncID); got != "" { + t.Fatalf("original mutation identity changed to %q", got) + } + if got := scalarString(t, s, `SELECT json_extract(payload, '$.source_inbox_id') FROM sync_mutations WHERE entity = ? AND entity_key = ? AND op = ? AND disposition = 'pending' ORDER BY seq DESC LIMIT 1`, SyncEntityPrompt, syncID, SyncOpUpsert); got != "adopted" { + t.Fatalf("pending identity = %q", got) + } +} + +func TestImportAdoptionNormalizesLegacyProjectInMutation(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("legacy-adopt", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.EnrollProject("alpha"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "legacy-adopt", Project: "alpha", Content: "keep"}) + if err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + if _, err := s.DB().Exec(`UPDATE user_prompts SET project = 'alpha ' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + if _, err := s.Import(&ExportData{Prompts: []Prompt{{SyncID: syncID, SessionID: "legacy-adopt", Project: "alpha", Content: "keep", SourceInboxID: "inbox"}}}); err != nil { + t.Fatal(err) + } + var followUp SyncMutation + if err := s.DB().QueryRow(`SELECT seq, entity, entity_key, op, payload FROM sync_mutations WHERE entity = 'prompt' AND entity_key = ? ORDER BY seq DESC LIMIT 1`, syncID).Scan(&followUp.Seq, &followUp.Entity, &followUp.EntityKey, &followUp.Op, &followUp.Payload); err != nil { + t.Fatal(err) + } + if got := scalarString(t, s, `SELECT json_extract(payload, '$.project') FROM sync_mutations WHERE seq = ?`, followUp.Seq); got != "alpha" { + t.Fatalf("pending project = %q", got) + } + peer := newTestStore(t) + if err := peer.CreateSession("legacy-adopt", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + followUp.Seq = 1 + if err := peer.ApplyPulledMutation(DefaultSyncTargetKey, followUp); err != nil { + t.Fatal(err) + } + exported, err := peer.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(exported.Prompts) != 1 || exported.Prompts[0].SyncID != syncID || exported.Prompts[0].SourceInboxID != "inbox" { + t.Fatalf("peer project prompts = %+v", exported.Prompts) + } +} + +func TestPulledPromptDeleteRejectsPairOwnedByOtherLivePrompt(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("pair-owner", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + a, err := s.AddPrompt(AddPromptParams{SessionID: "pair-owner", Project: "alpha", Content: "A", SourceInboxID: "key-a"}) + if err != nil { + t.Fatal(err) + } + b, err := s.AddPrompt(AddPromptParams{SessionID: "pair-owner", Project: "alpha", Content: "B", SourceInboxID: "key-b"}) + if err != nil { + t.Fatal(err) + } + aid := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, a) + bid := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, b) + payload := fmt.Sprintf(`{"sync_id":%q,"session_id":"pair-owner","source_inbox_id":"key-b","deleted":true}`, aid) + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: aid, Op: SyncOpDelete, Payload: payload}); err != nil { + t.Fatal(err) + } + for _, id := range []string{aid, bid} { + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE sync_id = ?`, id); got != 1 { + t.Fatalf("live %s = %d", id, got) + } + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id IN (?,?)`, aid, bid); got != 0 { + t.Fatalf("tombstones = %d", got) + } + rows, err := s.ListDeferred(ListDeferredOptions{Status: "dead"}) + if err != nil || len(rows) != 1 || rows[0].PayloadRaw != payload || rows[0].ReasonCode != SyncPromptIdentityInvalidReasonCode || rows[0].RemoteSeq != 1 { + t.Fatalf("dead evidence=%+v err=%v", rows, err) + } + state, err := s.GetSyncState(DefaultSyncTargetKey) + if err != nil || state.LastPulledSeq != 1 { + t.Fatalf("cursor=%+v err=%v", state, err) + } +} + +func TestImportAdoptionKeepsFollowUpAfterOriginalAck(t *testing.T) { + for _, ackBefore := range []bool{true, false} { + t.Run(fmt.Sprintf("ack before import=%t", ackBefore), func(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("adoption-ack", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.EnrollProject("engram"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "adoption-ack", Project: "engram", Content: "canonical"}) + if err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + var originalSeq int64 + if err := s.db.QueryRow(`SELECT seq FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityPrompt, syncID).Scan(&originalSeq); err != nil { + t.Fatal(err) + } + // This seq models the immutable outbound request copied before import. + if ackBefore { + if err := s.AckSyncMutationSeqs(DefaultSyncTargetKey, []int64{originalSeq}); err != nil { + t.Fatal(err) + } + } + _, err = s.Import(&ExportData{Prompts: []Prompt{{SyncID: syncID, SessionID: "adoption-ack", Project: "engram", Content: "untrusted incoming", SourceInboxID: "adopted"}}}) + if err != nil { + t.Fatal(err) + } + if !ackBefore { + if err := s.AckSyncMutationSeqs(DefaultSyncTargetKey, []int64{originalSeq}); err != nil { + t.Fatal(err) + } + } + if got := scalarInt(t, s, `SELECT count(*) FROM sync_mutations WHERE entity = ? AND entity_key = ? AND disposition = 'pending' AND seq > ? AND json_extract(payload, '$.source_inbox_id') = 'adopted' AND json_extract(payload, '$.content') = 'canonical'`, SyncEntityPrompt, syncID, originalSeq); got != 1 { + t.Fatalf("canonical follow-up pending = %d, want 1", got) + } + }) + } +} + +func TestImportAdoptionWithoutEnrollmentDoesNotEnqueue(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("adoption-unenrolled", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "adoption-unenrolled", Project: "engram", Content: "canonical"}) + if err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + before := scalarInt(t, s, `SELECT count(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityPrompt, syncID) + if _, err := s.Import(&ExportData{Prompts: []Prompt{{SyncID: syncID, SessionID: "adoption-unenrolled", Project: "engram", SourceInboxID: "adopted"}}}); err != nil { + t.Fatal(err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityPrompt, syncID); got != before { + t.Fatalf("mutation count = %d, want %d", got, before) + } +} + +func TestImportTombstoneRejectsForeignEffectiveProject(t *testing.T) { + for _, project := range []string{"alpha", ""} { + t.Run(fmt.Sprintf("persisted project %q", project), func(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("foreign-project", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "foreign-project", Project: "alpha", Content: "live", SourceInboxID: "pair"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE user_prompts SET project = ? WHERE id = ?`, project, id); err != nil { + t.Fatal(err) + } + if got := scalarString(t, s, `SELECT project FROM user_prompts WHERE id = ?`, id); got != project { + t.Fatalf("persisted project = %q, want %q", got, project) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + beta := "beta" + _, err = s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "other-sync", SessionID: "foreign-project", SourceInboxID: "pair", Project: &beta, DeletedAt: Now()}}}) + if !errors.Is(err, ErrPulledPromptIdentityInvalid) { + t.Fatalf("import error = %v", err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE sync_id = ? AND content = 'live' AND project = ?`, syncID, project); got != 1 { + t.Fatalf("live prompts = %d", got) + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = 'other-sync'`); got != 0 { + t.Fatalf("foreign tombstones = %d", got) + } + }) + } +} + +func TestPulledDeleteRejectsPairOwnedByDifferentPrompt(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("pair-owner", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "pair-owner", Project: "alpha", Content: "live", SourceInboxID: "pair"}) + if err != nil { + t.Fatal(err) + } + syncID := scalarString(t, s, `SELECT sync_id FROM user_prompts WHERE id = ?`, id) + deleted := Now() + payload := `{"sync_id":"unknown-pair","session_id":"pair-owner","source_inbox_id":"pair","deleted":true,"deleted_at":"` + deleted + `"}` + err = s.ApplyPulledMutation(DefaultSyncTargetKey, SyncMutation{Seq: 1, Entity: SyncEntityPrompt, EntityKey: "unknown-pair", Op: SyncOpDelete, Payload: payload}) + if err != nil { + t.Fatal(err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = 'unknown-pair'`); got != 0 { + t.Fatalf("conflicting tombstones = %d", got) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE sync_id = ?`, syncID); got != 1 { + t.Fatalf("live prompts = %d", got) + } + rows, err := s.ListDeferred(ListDeferredOptions{Status: "dead"}) + if err != nil || len(rows) != 1 || rows[0].PayloadRaw != payload || rows[0].ReasonCode != SyncPromptIdentityInvalidReasonCode || rows[0].RemoteSeq != 1 { + t.Fatalf("dead evidence=%+v, err=%v", rows, err) + } + state, err := s.GetSyncState(DefaultSyncTargetKey) + if err != nil || state.LastPulledSeq != 1 { + t.Fatalf("pull cursor=%+v, err=%v", state, err) + } +} + +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 TestPromptTombstoneIdentityCannotRebind(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("original", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.CreateSession("other", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + original := PromptTombstone{SyncID: "fixed", SessionID: "original", SourceInboxID: "key", DeletedAt: Now()} + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{original}}); err != nil { + t.Fatal(err) + } + for _, incoming := range []PromptTombstone{ + {SyncID: "fixed", SessionID: "other", SourceInboxID: "key", DeletedAt: Now()}, + {SyncID: "fixed", SessionID: "original", SourceInboxID: "different", DeletedAt: Now()}, + } { + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{incoming}}); !errors.Is(err, ErrPulledPromptIdentityInvalid) { + t.Fatalf("conflicting import error = %v", err) + } + if got := scalarString(t, s, `SELECT session_id || ':' || source_inbox_id FROM prompt_tombstones WHERE sync_id = 'fixed'`); got != "original:key" { + t.Fatalf("identity changed: %s", got) + } + } + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "fixed", DeletedAt: Now()}}}); err != nil { + t.Fatalf("sparse repeat: %v", err) + } + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "fixed", SessionID: "original", SourceInboxID: "key", DeletedAt: Now()}}}); err != nil { + t.Fatalf("same identity: %v", err) + } + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "legacy", DeletedAt: Now()}, {SyncID: "legacy", SessionID: "original", SourceInboxID: "later", DeletedAt: Now()}}}); err != nil { + t.Fatalf("legacy fill: %v", err) + } + if got := scalarString(t, s, `SELECT session_id || ':' || source_inbox_id FROM prompt_tombstones WHERE sync_id = 'legacy'`); got != "original:later" { + t.Fatalf("legacy fill = %s", got) + } +} + +func TestImportPromptTombstoneProjectCannotRebind(t *testing.T) { + s := newTestStore(t) + original := PromptTombstone{SyncID: "fixed-project", SessionID: "owner", SourceInboxID: "key", Project: nullableString("alpha"), DeletedAt: Now()} + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{original}}); err != nil { + t.Fatal(err) + } + conflict := original + conflict.Project = nullableString("beta") + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "rolled-back", DeletedAt: Now()}, conflict}}); !errors.Is(err, ErrPulledPromptIdentityInvalid) { + t.Fatalf("conflicting project import error = %v", err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM prompt_tombstones WHERE sync_id = 'rolled-back'`); got != 0 { + t.Fatalf("failed import persisted earlier tombstone: %d", got) + } + owner, err := s.ExportProject("alpha") + if err != nil { + t.Fatal(err) + } + if len(owner.PromptTombstones) != 1 || owner.PromptTombstones[0].SyncID != original.SyncID || owner.PromptTombstones[0].Project == nil || *owner.PromptTombstones[0].Project != "alpha" { + t.Fatalf("owner lost tombstone: %+v", owner.PromptTombstones) + } + other, err := s.ExportProject("beta") + if err != nil { + t.Fatal(err) + } + if len(other.PromptTombstones) != 0 { + t.Fatalf("other project leaked tombstone: %+v", other.PromptTombstones) + } + fresh := newTestStore(t) + if _, err := fresh.Import(owner); err != nil { + t.Fatal(err) + } + if err := fresh.CreateSession("owner", "alpha", "/tmp"); err != nil { + t.Fatal(err) + } + if id, inserted, err := fresh.AddPromptWithResult(AddPromptParams{SessionID: "owner", Project: "alpha", Content: "replay", SourceInboxID: "key"}); !errors.Is(err, ErrPromptInboxDeleted) || id != 0 || inserted { + t.Fatalf("replay accepted: id=%d inserted=%v err=%v", id, inserted, err) + } +} + +func TestImportConflictingTombstoneDoesNotDeleteMatchedPrompt(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("owner", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if err := s.CreateSession("other", "engram", "/tmp"); err != nil { + t.Fatal(err) + } + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "fixed", SessionID: "owner", SourceInboxID: "key", DeletedAt: Now()}}}); err != nil { + t.Fatal(err) + } + id, err := s.AddPrompt(AddPromptParams{SessionID: "other", Project: "engram", Content: "keep", SourceInboxID: "other-key"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE user_prompts SET sync_id = 'fixed' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + if _, err := s.Import(&ExportData{PromptTombstones: []PromptTombstone{{SyncID: "fixed", SessionID: "other", SourceInboxID: "other-key", DeletedAt: Now()}}}); !errors.Is(err, ErrPulledPromptIdentityInvalid) { + t.Fatalf("conflicting matched import: %v", err) + } + if got := scalarInt(t, s, `SELECT count(*) FROM user_prompts WHERE id = ? AND content = 'keep'`, id); got != 1 { + t.Fatalf("matched prompt lost: %d", got) + } + if got := scalarString(t, s, `SELECT session_id || ':' || source_inbox_id FROM prompt_tombstones WHERE sync_id = 'fixed'`); got != "owner:key" { + t.Fatalf("identity changed: %s", got) + } +} + func TestImportPromptIdentityAndTombstoneOrdering(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("import-prompt-session", "engram", "/tmp"); err != nil { @@ -10291,6 +11982,61 @@ func TestListPendingSyncMutationsIncludesProject(t *testing.T) { } } +func TestMaxPendingSyncMutationSeq(t *testing.T) { + s := newTestStore(t) + if err := s.EnrollProject("enrolled"); err != nil { + t.Fatalf("enroll: %v", err) + } + const target = "cloud:high-water-test" + for _, key := range []string{target, "cloud:other-high-water"} { + if _, err := s.db.Exec(`INSERT INTO sync_state (target_key, lifecycle, last_enqueued_seq, updated_at) VALUES (?, 'idle', 0, datetime('now'))`, key); err != nil { + t.Fatalf("insert sync state %s: %v", key, err) + } + } + insert := func(key, targetKey, project, ackedAt, disposition string) int64 { + t.Helper() + var acked any + if ackedAt != "" { + acked = ackedAt + } + result, err := s.db.Exec(`INSERT INTO sync_mutations + (target_key, entity, entity_key, op, payload, source, project, acked_at, disposition) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, targetKey, SyncEntityObservation, key, + SyncOpUpsert, `{}`, SyncSourceLocal, project, acked, disposition) + if err != nil { + t.Fatalf("insert %s: %v", key, err) + } + seq, err := result.LastInsertId() + if err != nil { + t.Fatalf("sequence %s: %v", key, err) + } + return seq + } + check := func(targetKey string, want int64) { + t.Helper() + got, err := s.MaxPendingSyncMutationSeq(targetKey) + if err != nil || got != want { + t.Fatalf("MaxPendingSyncMutationSeq(%q) = %d, %v; want %d", targetKey, got, err, want) + } + } + check(target, 0) + insert("other-target", "cloud:other-high-water", "", "", "pending") + global := insert("global", target, "", "", "pending") + insert("un-enrolled", target, "not-enrolled", "", "pending") + insert("acked", target, "enrolled", "2025-01-01T00:00:00Z", "pending") + insert("quarantined", target, "enrolled", "", "quarantined") + eligible := insert("enrolled", target, "enrolled", "", "pending") + if _, err := s.db.Exec(`UPDATE sync_state SET last_enqueued_seq = 0 WHERE target_key = ?`, target); err != nil { + t.Fatalf("stale sync state: %v", err) + } + check(target, eligible) + check("cloud:other-high-water", 1) + if _, err := s.db.Exec(`UPDATE sync_mutations SET acked_at = ? WHERE seq = ?`, "2025-01-01T00:00:00Z", eligible); err != nil { + t.Fatalf("ack eligible: %v", err) + } + check(target, global) +} + func TestCountPendingNonEnrolledSyncMutations(t *testing.T) { s := newTestStore(t)