Make native note saves idempotent with a client-chosen id
The Add Note sheet mints one UUID per draft and sends it as client_id on every Save; the server keys the note on it, so a retried save whose response was lost revises the same note instead of creating a duplicate (B10). Non-canonical and nil UUIDs are refused with 400; requests without client_id keep the old server-minted key. Reviewed by Fable 5.1 on discussion #14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from fix/r1-native-note-idempotency into main. Co-Authored-By: Codex <codex~paul@keymail.dev>
5 files changed,
+147
−7
docs/superpowers/specs/2026-09-03-memoria-design.md+6 −1internal/memoria/native.go+31 −3internal/memoria/native_test.go+101 −0native/apple/Sources/MemoriaKit/AddNoteView.swift+4 −1native/apple/Sources/MemoriaKit/MemoriaModel.swift+5 −2
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 0.727s ok amadan.net/oficina/memoria/internal/memoria/chunk 0.004s ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index 0.035s ok amadan.net/oficina/memoria/internal/memoria/notion 0.021s ok amadan.net/oficina/memoria/internal/memoria/query 0.002s ok amadan.net/oficina/memoria/internal/memoria/seal 0.003s ok amadan.net/oficina/memoria/internal/memoria/sibling 3.020s ok amadan.net/oficina/memoria/internal/memoriatest 1.382s ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index 06010b5..5efccc2 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -4059,7 +4059,12 @@ sibling authorization rule is introduced. Only filtered result DTOs are returned |
| with highlight markers removed for native text. Requests/responses are not cached. |
| Native note creation uses POST `/api/native/notes` and the existing Ingest store, |
| -with an opaque unique source key so two same-title notes never replace each other. |
| +keyed by a `client_id` the client mints once per draft (a canonical, non-nil |
| +UUID; the server mints one only for an older client that sends none). Two |
| +same-title notes therefore never replace each other, and a Save retried after |
| +a lost response replays the same draft: Ingest's same-key-same-body path |
| +returns the first id, and an edited retry revises that one note rather than |
| +adding a second (B10, 2026-09-23). |
| This resolves the independent review finding that the web paste endpoint upserts |
| by title. Notes retain the existing team-sharing policy. The local model is an optional, explicit |
| Apple Foundation Models call on a supported device, never an external provider. |
diff --git a/internal/memoria/native.go b/internal/memoria/native.go| index 81c2bf9..0aaffa5 100644 |
| --- a/internal/memoria/native.go |
| +++ b/internal/memoria/native.go |
| @@ -79,11 +79,27 @@ func (a *app) nativeSearch(w http.ResponseWriter, r *http.Request) { |
| } |
| // nativeNote creates a distinct team note, even when its title matches another. |
| +// |
| +// client_id is the note's idempotency key, minted once per draft by the |
| +// client. Without it the server minted a fresh key per REQUEST, so a save |
| +// that committed but whose response was lost (timeout, a phone asleep) |
| +// showed as an error, and pressing Save again made a second copy (B10). |
| +// With it, a replay of the same draft lands on the same (source, key) |
| +// and Store.Ingest's same-key-same-body path answers with the first id |
| +// and changes nothing. A replay whose draft was edited in between revises |
| +// that note rather than adding another beside it, which is the point: it |
| +// is still one draft. |
| +// |
| +// It must be a canonical, non-nil UUID, and it is canonicalised before use, so "A1…" and |
| +// "a1…" are one key and a free-form string cannot choose its own key. |
| +// Absent, the server mints one as before, so older clients keep working |
| +// — and keep the old duplicate-on-retry behaviour. |
| func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) { |
| r.Body = http.MaxBytesReader(w, r.Body, 3<<20) |
| var in struct { |
| - Title string `json:"title"` |
| - Body string `json:"body"` |
| + Title string `json:"title"` |
| + Body string `json:"body"` |
| + ClientID string `json:"client_id"` |
| } |
| d := json.NewDecoder(r.Body) |
| d.DisallowUnknownFields() |
| @@ -91,7 +107,19 @@ func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "Use a title and a note no larger than 2 MiB", 400) |
| return |
| } |
| - id, err := a.store.Ingest(r.Context(), IngestSpec{Source: "paste", Key: "native-" + uuid.NewString(), Title: strings.TrimSpace(in.Title), Body: in.Body}) |
| + key := uuid.NewString() |
| + if in.ClientID != "" { |
| + parsed, err := uuid.Parse(in.ClientID) |
| + // uuid.Nil parses and is 36 characters, but it is the value a |
| + // struct default or a failed decode produces: every such client |
| + // would converge on one note that revises forever. |
| + if err != nil || len(in.ClientID) != 36 || parsed == uuid.Nil { |
| + http.Error(w, "The note's client id is not a UUID", 400) |
| + return |
| + } |
| + key = parsed.String() |
| + } |
| + id, err := a.store.Ingest(r.Context(), IngestSpec{Source: "paste", Key: "native-" + key, Title: strings.TrimSpace(in.Title), Body: in.Body}) |
| if err != nil && !errors.Is(err, ErrIndexLag) { |
| http.Error(w, "The note could not be confirmed", 500) |
| return |
diff --git a/internal/memoria/native_test.go b/internal/memoria/native_test.go| index 2a0a628..49c6111 100644 |
| --- a/internal/memoria/native_test.go |
| +++ b/internal/memoria/native_test.go |
| @@ -208,3 +208,104 @@ func TestNativeNotesSameTitleCreatesDistinctDocuments(t *testing.T) { |
| t.Fatal("distinct note content was lost") |
| } |
| } |
| + |
| +// B10: a save whose response was lost is retried with the same draft's |
| +// client_id, and the retry must be a no-op — same id back, one document |
| +// stored. Mutations verified red 2026-09-23: keying on a server-minted |
| +// uuid again (the old behaviour) -> replay returns a second id; keying on |
| +// the raw client_id -> the uppercase replay makes a second note; dropping |
| +// the 36-character check -> braced and urn: forms are accepted; the |
| +// title folded into the key (Fable's M1) -> the edited replay makes a |
| +// second note; the uuid.Nil refusal removed -> the zero UUID is accepted. |
| +func TestNativeNoteReplayWithSameClientIDIsANoOp(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + post := func(payload map[string]string) (int, int64) { |
| + t.Helper() |
| + raw, _ := json.Marshal(payload) |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/notes", strings.NewReader(string(raw))) |
| + r.Header.Set("Origin", homeTestOrigin) |
| + r.Header.Set("Content-Type", "application/json") |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + var got struct{ ID int64 } |
| + _ = json.Unmarshal(w.Body.Bytes(), &got) |
| + return w.Code, got.ID |
| + } |
| + documents := func() int64 { |
| + t.Helper() |
| + var n int64 |
| + if err := f.app.db.G.Model(&Document{}).Count(&n).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| + } |
| + |
| + const draft = "6f1c2a0e-9a55-4c7e-8d3b-2f4e5a6b7c8d" |
| + note := map[string]string{"title": "Standup", "body": "replayed nebula content", "client_id": draft} |
| + code, first := post(note) |
| + if code != 200 || first == 0 { |
| + t.Fatalf("first save: %d", code) |
| + } |
| + before := documents() |
| + |
| + code, again := post(note) |
| + if code != 200 || again != first { |
| + t.Fatalf("replay: status %d id %d, want 200 and the first id %d", code, again, first) |
| + } |
| + // Swift's UUID().uuidString is uppercase; the key is canonical. |
| + upper := map[string]string{"title": note["title"], "body": note["body"], "client_id": strings.ToUpper(draft)} |
| + if code, id := post(upper); code != 200 || id != first { |
| + t.Fatalf("uppercase replay: status %d id %d, want the first id %d", code, id, first) |
| + } |
| + if after := documents(); after != before { |
| + t.Fatalf("replay stored %d more document(s)", after-before) |
| + } |
| + |
| + // The presence half: a different draft with the same words is a new note, |
| + // and so is a note from an old client that sends no client_id. |
| + other := map[string]string{"title": note["title"], "body": note["body"], "client_id": "0d9e8f7a-6b5c-4d3e-9f1a-2b3c4d5e6f70"} |
| + if code, id := post(other); code != 200 || id == first || id == 0 { |
| + t.Fatalf("a different client_id: status %d id %d, want a new note", code, id) |
| + } |
| + legacy := map[string]string{"title": note["title"], "body": note["body"]} |
| + _, l1 := post(legacy) |
| + _, l2 := post(legacy) |
| + if l1 == 0 || l2 == 0 || l1 == l2 { |
| + t.Fatalf("no client_id: ids %d and %d, want two distinct notes", l1, l2) |
| + } |
| + |
| + // The other half of B10: the phone lost the response AND the person |
| + // then fixed a typo before pressing Save again. Still one draft, so |
| + // still one note — revised, not duplicated. |
| + t.Run("an edited replay revises the same note", func(t *testing.T) { |
| + revision := func(id int64) int { |
| + t.Helper() |
| + var d Document |
| + if err := f.app.db.G.First(&d, id).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + return d.Revision |
| + } |
| + was, count := revision(first), documents() |
| + edited := map[string]string{"title": "Standup (fixed)", "body": "replayed nebula content, typo fixed", "client_id": draft} |
| + if code, id := post(edited); code != 200 || id != first { |
| + t.Fatalf("edited replay: status %d id %d, want the first id %d", code, id, first) |
| + } |
| + if n := documents(); n != count { |
| + t.Fatalf("edited replay stored %d more document(s)", n-count) |
| + } |
| + if now := revision(first); now != was+1 { |
| + t.Fatalf("revision %d -> %d, want a bump of one", was, now) |
| + } |
| + }) |
| + |
| + for _, bad := range []string{"00000000-0000-0000-0000-000000000000", "not-a-uuid", "{" + draft + "}", "urn:uuid:" + draft, strings.ReplaceAll(draft, "-", "")} { |
| + if code, _ := post(map[string]string{"title": "x", "body": "y", "client_id": bad}); code != 400 { |
| + t.Errorf("client_id %q: status %d, want 400", bad, code) |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/MemoriaKit/AddNoteView.swift b/native/apple/Sources/MemoriaKit/AddNoteView.swift| index fec4aad..51f145b 100644 |
| --- a/native/apple/Sources/MemoriaKit/AddNoteView.swift |
| +++ b/native/apple/Sources/MemoriaKit/AddNoteView.swift |
| @@ -8,6 +8,9 @@ struct AddNoteView: View { |
| @State private var bodyText = "" |
| @State private var saving = false |
| @State private var error = "" |
| + // One per sheet, reused by every Save press, so a retry after a lost response |
| + // replays the same draft instead of creating a second note. |
| + @State private var clientID = UUID() |
| @Environment(\.dismiss) private var dismiss |
| var body: some View { |
| NavigationStack { |
| @@ -23,7 +26,7 @@ struct AddNoteView: View { |
| Button(saving ? "Saving…" : "Save") { |
| saving = true |
| Task { |
| - do { let indexed = try await model.storeNote(title: title, body: bodyText, client: client); model.query = title; if indexed { await model.search(client) } else { model.error = "Note saved. Search will find it after the server restarts." }; dismiss() } |
| + do { let indexed = try await model.storeNote(title: title, body: bodyText, clientID: clientID, client: client); model.query = title; if indexed { await model.search(client) } else { model.error = "Note saved. Search will find it after the server restarts." }; dismiss() } |
| catch { self.error = error.localizedDescription; saving = false } |
| } |
| }.disabled(saving || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
diff --git a/native/apple/Sources/MemoriaKit/MemoriaModel.swift b/native/apple/Sources/MemoriaKit/MemoriaModel.swift| index 2113013..bc38962 100644 |
| --- a/native/apple/Sources/MemoriaKit/MemoriaModel.swift |
| +++ b/native/apple/Sources/MemoriaKit/MemoriaModel.swift |
| @@ -70,9 +70,12 @@ struct StoredNote: Decodable { let id: Int64; let indexed: Bool } |
| } |
| #endif |
| } |
| - public func storeNote(title: String, body: String, client: NativeHTTP) async throws -> Bool { |
| + /// `clientID` identifies the draft, not the request: the caller mints it once per |
| + /// Add Note sheet and sends the same value on every retry, so a save whose response |
| + /// was lost cannot be stored twice. The server treats a replay as a no-op. |
| + public func storeNote(title: String, body: String, clientID: UUID, client: NativeHTTP) async throws -> Bool { |
| guard body.utf8.count <= 2 * 1024 * 1024 else { throw NativeClientError.message("Notes must be no larger than 2 MiB.") } |
| - let result: StoredNote = try await client.request("/api/native/notes", method: "POST", body: JSONEncoder().encode(["title": title, "body": body])) |
| + let result: StoredNote = try await client.request("/api/native/notes", method: "POST", body: JSONEncoder().encode(["title": title, "body": body, "client_id": clientID.uuidString.lowercased()])) |
| return result.indexed |
| } |
| } |