diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f6002b1a..f2077d1b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,10 @@ Breaking changes are always marked with a `type:breaking-change` label and docum
+### Memory core
+
+- **fix(store):** reject empty or whitespace-only observation titles at write time (`engram save`, `mem_save`, `POST /observations`, `store.AddObservation`). Persisting a titleless observation also enqueued a cloud upsert that sync validators reject, which blocked every later mutation for the project.
+
### Cloud sync
- **fix(cloud):** make chunk and mutation push payload limits configurable with `ENGRAM_CLOUD_MAX_PUSH_BYTES` while preserving the 8 MiB default.
diff --git a/DOCS.md b/DOCS.md
index a0971d245..de941119c 100644
--- a/DOCS.md
+++ b/DOCS.md
@@ -134,6 +134,7 @@ Engram is local-first: local SQLite is authoritative; cloud features are optiona
### Observations
- `POST /observations` — Add observation. Body: `{session_id, type, title, content, tool_name?, project?, scope?, topic_key?}`
+ - `400` when `title` is missing, empty, or whitespace-only. The same rule applies to the observation-create paths (`engram save`, `mem_save`, `POST /observations`), not to updates via `PATCH /observations/{id}`: cloud sync rejects observation upserts without a title, and one rejected mutation blocks every later mutation for the project
- `GET /observations` — Recent observations compatibility endpoint. Query: `?project=X&scope=project|personal|global&limit=N&sort=created_at:desc`
- `GET /observations/recent` — Recent observations. Query: `?project=X&scope=project|personal|global&limit=N`
- `GET /observations/{id}` — Get single observation by ID
diff --git a/cmd/engram/main.go b/cmd/engram/main.go
index 730d783f9..9276aeb3c 100644
--- a/cmd/engram/main.go
+++ b/cmd/engram/main.go
@@ -1032,6 +1032,13 @@ func cmdSave(cfg store.Config) {
}
}
+ // Reject titleless saves before opening the store or creating a session
+ // (#459). The store applies the same rule as a backstop.
+ if err := store.ValidateObservationTitle(title); err != nil {
+ fatal(err)
+ return
+ }
+
s, err := storeNew(cfg)
if err != nil {
fatal(err)
diff --git a/cmd/engram/main_extra_test.go b/cmd/engram/main_extra_test.go
index f16d06b4c..f42581f8e 100644
--- a/cmd/engram/main_extra_test.go
+++ b/cmd/engram/main_extra_test.go
@@ -4325,3 +4325,24 @@ func TestCmdMCPAutosyncPollTickerPullsDuringServe(t *testing.T) {
t.Fatalf("expected MCP autosync poll ticker proof to complete cleanly, panic=%v stderr=%q", recovered, stderr)
}
}
+
+// TestCmdSaveRejectsEmptyTitle pins that `engram save` exits non-zero with an
+// actionable message instead of persisting a titleless observation (#459).
+func TestCmdSaveRejectsEmptyTitle(t *testing.T) {
+ cfg := testConfig(t)
+ stubExitWithPanic(t)
+
+ for _, title := range []string{"", " "} {
+ withArgs(t, "engram", "save", title, "content body")
+ _, stderr, recovered := captureOutputAndRecover(t, func() { cmdSave(cfg) })
+ if _, ok := recovered.(exitCode); !ok {
+ t.Fatalf("title %q: expected exit panic, got %v", title, recovered)
+ }
+ if !strings.Contains(stderr, "observation title is required") {
+ t.Fatalf("title %q: stderr missing title guard message: %q", title, stderr)
+ }
+ if !strings.Contains(stderr, "cloud sync") {
+ t.Fatalf("title %q: stderr should explain the cloud sync impact: %q", title, stderr)
+ }
+ }
+}
diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go
index e1fb4d161..5f257fd88 100644
--- a/internal/mcp/mcp.go
+++ b/internal/mcp/mcp.go
@@ -1195,6 +1195,11 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
if strings.TrimSpace(content) == "" {
return mcp.NewToolResultError("content is required for mem_save (use content, or observation for backward-compatible clients)"), nil
}
+ // Reject titleless saves before any project resolution or session
+ // creation (#459). The store applies the same rule as a backstop.
+ if err := store.ValidateObservationTitle(title); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
typ, _ := req.GetArguments()["type"].(string)
sessionID, _ := req.GetArguments()["session_id"].(string)
scope, _ := req.GetArguments()["scope"].(string)
diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go
index 89fc21ed8..8fd2b8f69 100644
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -7452,3 +7452,126 @@ func TestHandleSearch_MatchModeInvalidError(t *testing.T) {
t.Fatalf("parameter-validation error must not contain query-advice suffix \"Try simpler keywords\", got: %s", text)
}
}
+
+// TestHandleSaveRejectsEmptyTitle pins that mem_save refuses a titleless save
+// (#459) instead of persisting an observation whose cloud upsert would block
+// the project's mutation queue.
+func TestHandleSaveRejectsEmptyTitle(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ title any
+ }{
+ {"missing title", nil},
+ {"empty title", ""},
+ {"whitespace only title", " "},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ s := newMCPTestStore(t)
+ h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
+
+ args := map[string]any{
+ "content": "Body that would otherwise be saved",
+ "type": "note",
+ "project": "engram",
+ }
+ if tc.title != nil {
+ args["title"] = tc.title
+ }
+
+ res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: args}})
+ if err != nil {
+ t.Fatalf("handler error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatalf("expected tool error, got %q", callResultText(t, res))
+ }
+ if !strings.Contains(callResultText(t, res), "observation title is required") {
+ t.Fatalf("unexpected error text: %q", callResultText(t, res))
+ }
+
+ obs, err := s.RecentObservations("engram", "project", 5)
+ if err != nil {
+ t.Fatalf("recent observations: %v", err)
+ }
+ if len(obs) != 0 {
+ t.Fatalf("expected no observation persisted, got %#v", obs)
+ }
+
+ mutations, err := s.ListPendingSyncMutations(store.DefaultSyncTargetKey, 100)
+ if err != nil {
+ t.Fatalf("list pending sync mutations: %v", err)
+ }
+ for _, mutation := range mutations {
+ if mutation.Entity == store.SyncEntityObservation {
+ t.Fatalf("expected no observation mutation enqueued, got %#v", mutation)
+ }
+ }
+ })
+ }
+}
+
+func TestHandleUpdateRejectsBlankTitleWithoutSideEffects(t *testing.T) {
+ s := newMCPTestStore(t)
+ if err := s.CreateSession("s-update-title-guard", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ id, err := s.AddObservation(store.AddObservationParams{
+ SessionID: "s-update-title-guard",
+ Type: "note",
+ Title: "Original title",
+ Content: "Original content",
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+ before, err := s.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get original observation: %v", err)
+ }
+ countMutations := func() int {
+ t.Helper()
+ mutations, err := s.ListPendingSyncMutations(store.DefaultSyncTargetKey, 10)
+ if err != nil {
+ t.Fatalf("list pending mutations: %v", err)
+ }
+ count := 0
+ for _, mutation := range mutations {
+ if mutation.Entity == store.SyncEntityObservation && mutation.EntityKey == before.SyncID {
+ count++
+ }
+ }
+ return count
+ }
+ mutationsBefore := countMutations()
+
+ for _, title := range []string{"", " \t\n "} {
+ title := title
+ t.Run("blank title", func(t *testing.T) {
+ res, err := handleUpdate(s)(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
+ "id": float64(id),
+ "title": title,
+ }}})
+ if err != nil {
+ t.Fatalf("handler error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatalf("expected tool error, got %q", callResultText(t, res))
+ }
+ if !strings.Contains(callResultText(t, res), "observation title is required") {
+ t.Fatalf("unexpected error text: %q", callResultText(t, res))
+ }
+ after, err := s.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get observation after rejected update: %v", err)
+ }
+ if after.Title != before.Title || after.Content != before.Content || after.RevisionCount != before.RevisionCount {
+ t.Fatalf("rejected update changed observation: before=%#v after=%#v", before, after)
+ }
+ if got := countMutations(); got != mutationsBefore {
+ t.Fatalf("rejected update enqueued a mutation: got %d, want %d", got, mutationsBefore)
+ }
+ })
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index c30f66a11..aa27e74a4 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -327,8 +327,15 @@ func (s *Server) handleAddObservation(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
- if body.SessionID == "" || body.Title == "" || body.Content == "" {
- jsonError(w, http.StatusBadRequest, "session_id, title, and content are required")
+ // Validate the title before the session lookup so a bad session or project
+ // cannot mask the documented title-validation 400 (#459). A whitespace-only
+ // title survives a raw `== ""` check, so it needs the shared predicate.
+ if err := store.ValidateObservationTitle(body.Title); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if body.SessionID == "" || body.Content == "" {
+ jsonError(w, http.StatusBadRequest, "session_id and content are required")
return
}
if !s.validateSessionProject(w, body.SessionID, body.Project) {
@@ -337,6 +344,11 @@ func (s *Server) handleAddObservation(w http.ResponseWriter, r *http.Request) {
id, err := s.store.AddObservation(body)
if err != nil {
+ // A titleless observation is a client mistake, not a server failure.
+ if errors.Is(err, store.ErrObservationTitleRequired) {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -465,7 +477,11 @@ func (s *Server) handleUpdateObservation(w http.ResponseWriter, r *http.Request)
obs, err := s.store.UpdateObservation(id, body)
if err != nil {
- jsonError(w, http.StatusNotFound, err.Error())
+ if errors.Is(err, store.ErrObservationTitleRequired) {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ } else {
+ jsonError(w, http.StatusNotFound, err.Error())
+ }
return
}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index dd6d97362..a0e04d074 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -2168,3 +2168,167 @@ func TestMigrateProjectCaseOnlySkipped(t *testing.T) {
t.Fatalf("expected status=skipped for case-only difference, got %v (full response: %#v)", resp["status"], resp)
}
}
+
+// TestHandleAddObservationRejectsBlankTitle pins that POST /observations answers
+// 400 (client mistake) rather than 500 or 201 when the title is blank (#459).
+func TestHandleAddObservationRejectsBlankTitle(t *testing.T) {
+ st := newServerTestStore(t)
+ srv := New(st, 0)
+ h := srv.Handler()
+
+ var writeCount atomic.Int32
+ srv.SetOnWrite(func() { writeCount.Add(1) })
+
+ if err := st.CreateSession("s-blank-title", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+
+ for _, body := range []string{
+ `{"session_id":"s-blank-title","type":"note","title":"","content":"body","project":"engram"}`,
+ `{"session_id":"s-blank-title","type":"note","title":" ","content":"body","project":"engram"}`,
+ } {
+ req := httptest.NewRequest(http.MethodPost, "/observations", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("body %s: expected 400, got %d (%s)", body, rec.Code, rec.Body.String())
+ }
+ }
+
+ if writeCount.Load() != 0 {
+ t.Fatalf("expected 0 onWrite calls for rejected writes, got %d", writeCount.Load())
+ }
+}
+
+// TestHandleAddObservationBlankTitleNotMaskedBySessionError pins that the title
+// check runs before the session/project lookup. A whitespace-only title passes
+// the raw required-fields check, so before #459's follow-up the request was
+// answered with the session error instead of the documented title 400.
+func TestHandleAddObservationBlankTitleNotMaskedBySessionError(t *testing.T) {
+ st := newServerTestStore(t)
+ srv := New(st, 0)
+ h := srv.Handler()
+
+ var writeCount atomic.Int32
+ srv.SetOnWrite(func() { writeCount.Add(1) })
+
+ // Exists, but bound to a different project than the request claims.
+ if err := st.CreateSession("s-mismatched", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+
+ for _, tc := range []struct {
+ name string
+ body string
+ }{
+ {
+ name: "nonexistent session",
+ body: `{"session_id":"s-does-not-exist","type":"note","title":" ","content":"body","project":"engram"}`,
+ },
+ {
+ name: "mismatched project",
+ body: `{"session_id":"s-mismatched","type":"note","title":" ","content":"body","project":"other"}`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/observations", strings.NewReader(tc.body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d (%s)", rec.Code, rec.Body.String())
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v (body %s)", err, rec.Body.String())
+ }
+ msg, _ := resp["error"].(string)
+ if msg != store.ErrObservationTitleRequired.Error() {
+ t.Fatalf("expected the title error, got %q — the session lookup masked it", msg)
+ }
+ })
+ }
+
+ if writeCount.Load() != 0 {
+ t.Fatalf("expected 0 onWrite calls for rejected writes, got %d", writeCount.Load())
+ }
+}
+
+func TestHandleUpdateObservationRejectsBlankTitleWithoutSideEffects(t *testing.T) {
+ st := newServerTestStore(t)
+ srv := New(st, 0)
+ h := srv.Handler()
+ var writeCount atomic.Int32
+ srv.SetOnWrite(func() { writeCount.Add(1) })
+
+ if err := st.CreateSession("s-update-title-guard", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ id, err := st.AddObservation(store.AddObservationParams{
+ SessionID: "s-update-title-guard",
+ Type: "note",
+ Title: "Original title",
+ Content: "Original content",
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+ before, err := st.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get original observation: %v", err)
+ }
+ countMutations := func() int {
+ t.Helper()
+ mutations, err := st.ListPendingSyncMutations(store.DefaultSyncTargetKey, 10)
+ if err != nil {
+ t.Fatalf("list pending mutations: %v", err)
+ }
+ count := 0
+ for _, mutation := range mutations {
+ if mutation.Entity == store.SyncEntityObservation && mutation.EntityKey == before.SyncID {
+ count++
+ }
+ }
+ return count
+ }
+ mutationsBefore := countMutations()
+
+ for _, title := range []string{"", " \t\n "} {
+ title := title
+ t.Run("blank title", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/observations/%d", id), strings.NewReader(fmt.Sprintf(`{"title":%q}`, title)))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
+ }
+ after, err := st.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get observation after rejected update: %v", err)
+ }
+ if after.Title != before.Title || after.Content != before.Content || after.RevisionCount != before.RevisionCount {
+ t.Fatalf("rejected update changed observation: before=%#v after=%#v", before, after)
+ }
+ if got := countMutations(); got != mutationsBefore {
+ t.Fatalf("rejected update enqueued a mutation: got %d, want %d", got, mutationsBefore)
+ }
+ })
+ }
+ if writeCount.Load() != 0 {
+ t.Fatalf("expected no onWrite calls for rejected updates, got %d", writeCount.Load())
+ }
+
+ req := httptest.NewRequest(http.MethodPatch, "/observations/999999", strings.NewReader(`{"title":"updated"}`))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404 for missing observation, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
diff --git a/internal/store/diagnostic.go b/internal/store/diagnostic.go
index 9392485e9..d22276834 100644
--- a/internal/store/diagnostic.go
+++ b/internal/store/diagnostic.go
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -129,6 +130,31 @@ func (s *Store) listPendingProjectMutationsTxLike(q rowQuerier, project string)
return mutations, rows.Err()
}
+// ErrObservationTitleRequired is returned by write paths that would otherwise
+// persist an observation without a usable title. Cloud sync rejects observation
+// upserts whose payload carries an empty title, and because the mutation queue
+// is an ordered log, a single rejected row blocks every later mutation for the
+// same project.
+var ErrObservationTitleRequired = errors.New("observation title is required: an empty or whitespace-only title is rejected by cloud sync and would block every later mutation for the project")
+
+// observationTitleIsPresent is the single definition of "an observation upsert
+// has a title". Both ValidateSyncMutationPayload (pull/doctor side) and
+// ValidateObservationTitle (write side) call it so the rule lives in one place.
+func observationTitleIsPresent(title string) bool {
+ return strings.TrimSpace(title) != ""
+}
+
+// ValidateObservationTitle enforces, before persistence, the same non-empty
+// title rule that ValidateSyncMutationPayload enforces for observation upserts.
+// It returns ErrObservationTitleRequired when the title is empty or
+// whitespace-only.
+func ValidateObservationTitle(title string) error {
+ if !observationTitleIsPresent(title) {
+ return ErrObservationTitleRequired
+ }
+ return nil
+}
+
// ValidateSyncMutationPayload performs pure required-field validation for sync
// payloads. It is intentionally conservative: malformed/empty/unsupported
// payloads are reported as manual blocks, while complete payloads return an
@@ -184,7 +210,9 @@ func ValidateSyncMutationPayload(entity, op, payload, entityKey string) SyncMuta
if op == SyncOpUpsert {
require("session_id")
require("type")
- require("title")
+ if !observationTitleIsPresent(field("title")) {
+ missing = append(missing, "title")
+ }
require("content")
require("scope")
}
diff --git a/internal/store/store.go b/internal/store/store.go
index 9c6537b98..d2d4a317e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -2256,6 +2256,15 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) {
title := stripPrivateTags(p.Title)
content := stripPrivateTags(p.Content)
+ // Reject titleless observations before any persistence. The check runs on
+ // the post-strip title so redaction cannot turn a valid title into an empty
+ // one behind our back. Persisting an empty title also enqueues a cloud
+ // observation upsert that the sync validators reject, which blocks every
+ // later mutation for the project.
+ if err := ValidateObservationTitle(title); err != nil {
+ return 0, err
+ }
+
if len(content) > s.cfg.MaxObservationLength {
content = content[:s.cfg.MaxObservationLength] + "... [truncated]"
}
@@ -2881,6 +2890,9 @@ func (s *Store) UpdateObservation(id int64, p UpdateObservationParams) (*Observa
}
if p.Title != nil {
title = stripPrivateTags(*p.Title)
+ if err := ValidateObservationTitle(title); err != nil {
+ return err
+ }
}
if p.Content != nil {
content = stripPrivateTags(*p.Content)
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 5ed55ca63..794d21c34 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -8830,3 +8830,275 @@ func TestSanitizeFTS(t *testing.T) {
})
}
}
+
+// TestAddObservationRejectsEmptyTitle pins the write-time guard for #459: an
+// observation without a usable title must never reach the observations table,
+// because it also enqueues a cloud upsert the sync validators reject, which
+// blocks every later mutation for the project.
+func TestAddObservationRejectsEmptyTitle(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateSession("s-title-guard", "engram", "/tmp/engram"); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+
+ countMutations := func() int {
+ t.Helper()
+ var n int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM sync_mutations WHERE entity = ?`, SyncEntityObservation).Scan(&n); err != nil {
+ t.Fatalf("count sync mutations: %v", err)
+ }
+ return n
+ }
+ countObservations := func() int {
+ t.Helper()
+ var n int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM observations`).Scan(&n); err != nil {
+ t.Fatalf("count observations: %v", err)
+ }
+ return n
+ }
+
+ cases := []struct {
+ name string
+ title string
+ }{
+ {"empty title", ""},
+ {"whitespace only title", " "},
+ // stripPrivateTags trims the value, so a title made only of blank
+ // characters is still empty after stripping. The guard runs on the
+ // post-strip title precisely so redaction cannot smuggle one through.
+ {"title empty after stripping private tags", " \t\n "},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mutationsBefore := countMutations()
+ observationsBefore := countObservations()
+
+ id, err := s.AddObservation(AddObservationParams{
+ SessionID: "s-title-guard",
+ Type: "note",
+ Title: tc.title,
+ Content: "content that is perfectly valid",
+ Project: "engram",
+ Scope: "project",
+ })
+ if !errors.Is(err, ErrObservationTitleRequired) {
+ t.Fatalf("expected ErrObservationTitleRequired, got id=%d err=%v", id, err)
+ }
+ if id != 0 {
+ t.Fatalf("expected no observation id, got %d", id)
+ }
+ if got := countObservations(); got != observationsBefore {
+ t.Fatalf("expected no observation persisted, count went %d → %d", observationsBefore, got)
+ }
+ if got := countMutations(); got != mutationsBefore {
+ t.Fatalf("expected no sync mutation enqueued, count went %d → %d", mutationsBefore, got)
+ }
+ })
+ }
+}
+
+// TestAddObservationAcceptsValidTitle pins that the #459 guard does not change
+// behaviour for observations that already carry a usable title, including a
+// title whose private tags collapse into the redaction marker.
+func TestAddObservationAcceptsValidTitle(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateSession("s-title-ok", "engram", "/tmp/engram"); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+
+ cases := []struct {
+ name string
+ title string
+ wantTitle string
+ }{
+ {"plain title", " Reject empty titles ", "Reject empty titles"},
+ {"title reduced to redaction marker", "secret", "[REDACTED]"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ id, err := s.AddObservation(AddObservationParams{
+ SessionID: "s-title-ok",
+ Type: "note",
+ Title: tc.title,
+ Content: "content for " + tc.name,
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+ obs, err := s.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get observation: %v", err)
+ }
+ if obs.Title != tc.wantTitle {
+ t.Fatalf("expected title %q, got %q", tc.wantTitle, obs.Title)
+ }
+
+ var mutations int
+ if err := s.db.QueryRow(
+ `SELECT COUNT(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`,
+ SyncEntityObservation, obs.SyncID,
+ ).Scan(&mutations); err != nil {
+ t.Fatalf("count sync mutations: %v", err)
+ }
+ if mutations != 1 {
+ t.Fatalf("expected 1 sync mutation for %s, got %d", obs.SyncID, mutations)
+ }
+ })
+ }
+}
+
+// TestValidateObservationTitleMatchesSyncPayloadRule pins that the write-time
+// guard and the sync payload validator agree on what a missing title is, so the
+// rule keeps living in exactly one place.
+func TestValidateObservationTitleMatchesSyncPayloadRule(t *testing.T) {
+ cases := []struct {
+ name string
+ title string
+ valid bool
+ }{
+ {"empty", "", false},
+ {"whitespace", " ", false},
+ {"present", "Real title", true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidateObservationTitle(tc.title)
+ if tc.valid && err != nil {
+ t.Fatalf("expected title %q to be accepted, got %v", tc.title, err)
+ }
+ if !tc.valid && !errors.Is(err, ErrObservationTitleRequired) {
+ t.Fatalf("expected ErrObservationTitleRequired for %q, got %v", tc.title, err)
+ }
+
+ payload, marshalErr := json.Marshal(map[string]string{
+ "sync_id": "obs-1",
+ "session_id": "s-1",
+ "type": "note",
+ "title": tc.title,
+ "content": "content",
+ "scope": "project",
+ })
+ if marshalErr != nil {
+ t.Fatalf("marshal payload: %v", marshalErr)
+ }
+ result := ValidateSyncMutationPayload(SyncEntityObservation, SyncOpUpsert, string(payload), "obs-1")
+ missingTitle := false
+ for _, field := range result.MissingFields {
+ if field == "title" {
+ missingTitle = true
+ }
+ }
+ if missingTitle == tc.valid {
+ t.Fatalf("validator disagrees with write guard for %q: missing_fields=%v", tc.title, result.MissingFields)
+ }
+ })
+ }
+}
+
+func TestUpdateObservationRejectsBlankTitleWithoutSideEffects(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateSession("s-update-title-guard", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ id, err := s.AddObservation(AddObservationParams{
+ SessionID: "s-update-title-guard",
+ Type: "note",
+ Title: "Original title",
+ Content: "Original content",
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+ before, err := s.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get original observation: %v", err)
+ }
+ countMutations := func() int {
+ t.Helper()
+ var count int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityObservation, before.SyncID).Scan(&count); err != nil {
+ t.Fatalf("count observation mutations: %v", err)
+ }
+ return count
+ }
+ mutationsBefore := countMutations()
+
+ for _, title := range []string{"", " \t\n "} {
+ title := title
+ t.Run(fmt.Sprintf("title %q", title), func(t *testing.T) {
+ _, err := s.UpdateObservation(id, UpdateObservationParams{Title: &title})
+ if !errors.Is(err, ErrObservationTitleRequired) {
+ t.Fatalf("expected ErrObservationTitleRequired, got %v", err)
+ }
+ after, err := s.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get observation after rejected update: %v", err)
+ }
+ if after.Title != before.Title || after.Content != before.Content || after.RevisionCount != before.RevisionCount {
+ t.Fatalf("rejected update changed observation: before=%#v after=%#v", before, after)
+ }
+ if got := countMutations(); got != mutationsBefore {
+ t.Fatalf("rejected update enqueued a mutation: got %d, want %d", got, mutationsBefore)
+ }
+ })
+ }
+}
+
+func TestUpdateObservationAcceptsPrivateTagOnlyTitle(t *testing.T) {
+ s := newTestStore(t)
+ if err := s.CreateSession("s-update-redaction", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ id, err := s.AddObservation(AddObservationParams{
+ SessionID: "s-update-redaction",
+ Type: "note",
+ Title: "Original title",
+ Content: "Original content",
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+ title := "secret"
+ updated, err := s.UpdateObservation(id, UpdateObservationParams{Title: &title})
+ if err != nil {
+ t.Fatalf("update observation: %v", err)
+ }
+ if updated.Title != "[REDACTED]" {
+ t.Fatalf("expected redacted title, got %q", updated.Title)
+ }
+
+ var mutationCount int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityObservation, updated.SyncID).Scan(&mutationCount); err != nil {
+ t.Fatalf("count observation mutations: %v", err)
+ }
+ if mutationCount != 2 {
+ t.Fatalf("expected exactly two observation mutations (create and update), got %d", mutationCount)
+ }
+ var mutation SyncMutation
+ if err := s.db.QueryRow(`SELECT op, payload FROM sync_mutations WHERE entity = ? AND entity_key = ? ORDER BY seq DESC LIMIT 1`, SyncEntityObservation, updated.SyncID).Scan(&mutation.Op, &mutation.Payload); err != nil {
+ t.Fatalf("load update mutation: %v", err)
+ }
+ if mutation.Op != SyncOpUpsert {
+ t.Fatalf("expected update mutation op %q, got %q", SyncOpUpsert, mutation.Op)
+ }
+ if validation := ValidateSyncMutationPayload(SyncEntityObservation, mutation.Op, mutation.Payload, updated.SyncID); len(validation.MissingFields) != 0 || validation.ReasonCode != "" {
+ t.Fatalf("expected valid update mutation, got %#v", validation)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal([]byte(mutation.Payload), &payload); err != nil {
+ t.Fatalf("decode update mutation payload: %v", err)
+ }
+ if payload["title"] != "[REDACTED]" {
+ t.Fatalf("expected redacted title in update payload, got %#v", payload["title"])
+ }
+}