diff --git a/internal/store/store.go b/internal/store/store.go
index 67738d9df..4b9a5c066 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -69,11 +69,13 @@ var (
// ErrProjectOwnershipAmbiguous is returned when an unowned session cannot
// adopt a write's project because it already parents records owned by a
// different one. Guessing there would split a record from its session.
- ErrProjectOwnershipAmbiguous = errors.New("session project ownership is ambiguous")
- ErrObservationProjectImmutable = errors.New("observation project cannot be reassigned")
- ErrObservationTitleRequired = errors.New("observation title is required")
- ErrObservationContentRequired = errors.New("observation content is required")
- ErrPromptContentRequired = errors.New("prompt content is required")
+ ErrProjectOwnershipAmbiguous = errors.New("session project ownership is ambiguous")
+ ErrObservationProjectImmutable = errors.New("observation project cannot be reassigned")
+ ErrObservationTitleRequired = errors.New("observation title is required")
+ ErrObservationContentRequired = errors.New("observation content is required")
+ ErrObservationFindReplaceInvalid = errors.New("find and replace must be provided together and cannot be combined with content")
+ ErrObservationFindReplaceTooLarge = errors.New("find or replace exceeds the observation content limit")
+ ErrPromptContentRequired = errors.New("prompt content is required")
)
// Sentinel errors for relation sync apply path (Phase 2).
@@ -279,6 +281,8 @@ type UpdateObservationParams struct {
Type *string `json:"type,omitempty"`
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
+ Find *string `json:"find,omitempty"`
+ Replace *string `json:"replace,omitempty"`
Project *string `json:"project,omitempty"`
Scope *string `json:"scope,omitempty"`
TopicKey *string `json:"topic_key,omitempty"`
@@ -3721,6 +3725,46 @@ func truncateContent(content string, max int) string {
return content[:end] + "... [truncated]"
}
+// boundedLiteralReplace applies literal global replacement without allocating an
+// unbounded expanded result. It retains one byte beyond the storage limit so
+// truncateContent can preserve its existing UTF-8 boundary behavior.
+func boundedLiteralReplace(content, find, replace string, max int) string {
+ if find == "" {
+ return content
+ }
+
+ limit := max + 1
+ var result strings.Builder
+ if len(content) < limit {
+ result.Grow(len(content))
+ } else {
+ result.Grow(limit)
+ }
+ write := func(part string) bool {
+ remaining := limit - result.Len()
+ if len(part) <= remaining {
+ result.WriteString(part)
+ return false
+ }
+ result.WriteString(part[:remaining])
+ return true
+ }
+
+ for {
+ index := strings.Index(content, find)
+ if index < 0 {
+ if write(content) {
+ return truncateContent(result.String(), max)
+ }
+ return result.String()
+ }
+ if write(content[:index]) || write(replace) {
+ return truncateContent(result.String(), max)
+ }
+ content = content[index+len(find):]
+ }
+}
+
func (s *Store) RecentPrompts(project string, limit int) ([]Prompt, error) {
// Normalize project filter for case-insensitive matching
project, _ = NormalizeProject(project)
@@ -4029,6 +4073,12 @@ func (s *Store) UpdateObservation(id int64, p UpdateObservationParams) (*Observa
// Admission runs before the transaction so a rejected update opens no
// transaction, touches no row and enqueues no sync mutation. The title is
// checked post-strip so redaction cannot smuggle an empty one through.
+ if (p.Find == nil) != (p.Replace == nil) || (p.Content != nil && p.Find != nil) {
+ return nil, ErrObservationFindReplaceInvalid
+ }
+ if p.Find != nil && (len(*p.Find) > s.cfg.MaxObservationLength || len(*p.Replace) > s.cfg.MaxObservationLength) {
+ return nil, ErrObservationFindReplaceTooLarge
+ }
if p.Title != nil {
if err := ValidateObservationTitle(stripPrivateTags(*p.Title)); err != nil {
return nil, err
@@ -4064,6 +4114,17 @@ func (s *Store) UpdateObservation(id int64, p UpdateObservationParams) (*Observa
if p.Content != nil {
content, _ = s.prepareStoredContent(*p.Content)
}
+ if p.Find != nil {
+ // Redact a complete private tag in the bounded replacement input before
+ // expansion. Otherwise a bounded result could cut its closing tag and
+ // prevent prepareStoredContent from recognizing the private content.
+ replace := privateTagRegex.ReplaceAllString(*p.Replace, "[REDACTED]")
+ replaced := boundedLiteralReplace(content, *p.Find, replace, s.cfg.MaxObservationLength)
+ content, _ = s.prepareStoredContent(replaced)
+ if content == "" {
+ return ErrObservationContentRequired
+ }
+ }
if p.Project != nil {
requestedProject, _ := NormalizeProject(*p.Project)
if requestedProject != project {
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 9a62e5cc1..cdfe645d3 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -15837,6 +15837,174 @@ func TestUpdateObservationRejectsBlankTitleWithoutSideEffects(t *testing.T) {
}
}
+func TestUpdateObservationFindReplace(t *testing.T) {
+ s := newTestStore(t)
+ enrollTestProject(t, s, "engram")
+ if err := s.CreateSession("s-update-find-replace", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ id, err := s.AddObservation(AddObservationParams{
+ SessionID: "s-update-find-replace",
+ Type: "note",
+ Title: "Original title",
+ Content: "a.b a.b",
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+
+ load := func() *Observation {
+ t.Helper()
+ obs, err := s.GetObservation(id)
+ if err != nil {
+ t.Fatalf("get observation: %v", err)
+ }
+ return obs
+ }
+ mutationCount := func(syncID string) int {
+ t.Helper()
+ var count int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM sync_mutations WHERE entity = ? AND entity_key = ?`, SyncEntityObservation, syncID).Scan(&count); err != nil {
+ t.Fatalf("count mutations: %v", err)
+ }
+ return count
+ }
+ normalizedHash := func() string {
+ t.Helper()
+ var hash string
+ if err := s.db.QueryRow(`SELECT normalized_hash FROM observations WHERE id = ?`, id).Scan(&hash); err != nil {
+ t.Fatalf("load normalized hash: %v", err)
+ }
+ return hash
+ }
+ assertUnchanged := func(t *testing.T, before *Observation, mutations int) {
+ t.Helper()
+ after := load()
+ if after.Content != before.Content || after.RevisionCount != before.RevisionCount {
+ t.Fatalf("rejected update changed observation: before=%#v after=%#v", before, after)
+ }
+ if got := mutationCount(before.SyncID); got != mutations {
+ t.Fatalf("rejected update enqueued a mutation: got %d, want %d", got, mutations)
+ }
+ }
+
+ t.Run("requires paired parameters and forbids direct content", func(t *testing.T) {
+ for _, update := range []UpdateObservationParams{
+ {Find: ptr("a")},
+ {Replace: ptr("b")},
+ {Content: ptr("direct"), Find: ptr("a"), Replace: ptr("b")},
+ } {
+ before := load()
+ mutations := mutationCount(before.SyncID)
+ if _, err := s.UpdateObservation(id, update); !errors.Is(err, ErrObservationFindReplaceInvalid) {
+ t.Fatalf("expected ErrObservationFindReplaceInvalid, got %v", err)
+ }
+ assertUnchanged(t, before, mutations)
+ }
+ })
+
+ t.Run("replaces literal matches globally and redacts private replacement", func(t *testing.T) {
+ updated, err := s.UpdateObservation(id, UpdateObservationParams{Find: ptr("a.b"), Replace: ptr("secret")})
+ if err != nil {
+ t.Fatalf("replace observation: %v", err)
+ }
+ if updated.Content != "[REDACTED] [REDACTED]" {
+ t.Fatalf("content = %q, want literal global replacement with redaction", updated.Content)
+ }
+ if got := normalizedHash(); got != hashNormalized(updated.Content) {
+ t.Fatalf("normalized hash = %q, want hash of replacement content", got)
+ }
+ })
+
+ t.Run("empty and non-matching find preserve bytes but still revise and sync", func(t *testing.T) {
+ for _, find := range []string{"", "missing"} {
+ before := load()
+ beforeHash := normalizedHash()
+ mutations := mutationCount(before.SyncID)
+ updated, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: ptr("replacement")})
+ if err != nil {
+ t.Fatalf("replace with find %q: %v", find, err)
+ }
+ if updated.Content != before.Content || normalizedHash() != beforeHash {
+ t.Fatalf("no-match content or hash changed: before=%q after=%q", before.Content, updated.Content)
+ }
+ if updated.RevisionCount != before.RevisionCount+1 {
+ t.Fatalf("revision = %d, want %d", updated.RevisionCount, before.RevisionCount+1)
+ }
+ if got := mutationCount(before.SyncID); got != mutations+1 {
+ t.Fatalf("mutations = %d, want %d", got, mutations+1)
+ }
+ }
+ })
+
+ t.Run("rejects replacement that becomes empty without side effects", func(t *testing.T) {
+ find, replace := "[REDACTED] [REDACTED]", ""
+ before := load()
+ mutations := mutationCount(before.SyncID)
+ if _, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: &replace}); !errors.Is(err, ErrObservationContentRequired) {
+ t.Fatalf("expected ErrObservationContentRequired, got %v", err)
+ }
+ assertUnchanged(t, before, mutations)
+ })
+
+ t.Run("truncates expanded output at UTF-8 boundaries", func(t *testing.T) {
+ s.cfg.MaxObservationLength = 10
+ find, replace := "[REDACTED]", "界界"
+ updated, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: &replace})
+ if err != nil {
+ t.Fatalf("replace observation: %v", err)
+ }
+ if !utf8.ValidString(updated.Content) || updated.Content != "界界 界... [truncated]" {
+ t.Fatalf("content = %q, want UTF-8-safe truncated replacement", updated.Content)
+ }
+ })
+
+ t.Run("bounds oversized replacement input before mutation", func(t *testing.T) {
+ find, replace := "界", strings.Repeat("x", s.cfg.MaxObservationLength+1)
+ before := load()
+ mutations := mutationCount(before.SyncID)
+ if _, err := s.UpdateObservation(id, UpdateObservationParams{Find: &find, Replace: &replace}); !errors.Is(err, ErrObservationFindReplaceTooLarge) {
+ t.Fatalf("expected ErrObservationFindReplaceTooLarge, got %v", err)
+ }
+ assertUnchanged(t, before, mutations)
+ })
+}
+
+func TestUpdateObservationFindReplaceRedactsBeforeTruncatingPrivateTag(t *testing.T) {
+ s := newTestStore(t)
+ s.cfg.MaxObservationLength = 25
+ enrollTestProject(t, s, "engram")
+ if err := s.CreateSession("s-update-find-replace-private-truncation", "engram", t.TempDir()); err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ id, err := s.AddObservation(AddObservationParams{
+ SessionID: "s-update-find-replace-private-truncation",
+ Type: "note",
+ Title: "Original title",
+ Content: "12345replace me",
+ Project: "engram",
+ Scope: "project",
+ })
+ if err != nil {
+ t.Fatalf("add observation: %v", err)
+ }
+
+ updated, err := s.UpdateObservation(id, UpdateObservationParams{
+ Find: ptr("replace me"),
+ Replace: ptr("secret"),
+ })
+ if err != nil {
+ t.Fatalf("replace observation: %v", err)
+ }
+ if updated.Content != "12345[REDACTED]" {
+ t.Fatalf("content = %q, want redacted replacement before truncation", updated.Content)
+ }
+}
+
+func ptr(s string) *string { return &s }
+
func TestUpdateObservationAcceptsPrivateTagOnlyTitle(t *testing.T) {
s := newTestStore(t)
enrollTestProject(t, s, "engram")
diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go
index 83b725b3b..abcaa3d98 100644
--- a/internal/sync/sync_test.go
+++ b/internal/sync/sync_test.go
@@ -3641,7 +3641,7 @@ func TestCloudImportAppliesMutationReconciliationForUpdatesAndDeletes(t *testing
SessionID: "sess-a",
Type: "decision",
Title: "v1",
- Content: "original",
+ Content: "original old",
Project: "proj-a",
Scope: "project",
})
@@ -3661,9 +3661,9 @@ func TestCloudImportAppliesMutationReconciliationForUpdatesAndDeletes(t *testing
t.Fatal("expected first cloud export to write initial snapshot")
}
- updatedTitle := "v2"
- if _, err := src.UpdateObservation(obsID, store.UpdateObservationParams{Title: &updatedTitle}); err != nil {
- t.Fatalf("update observation: %v", err)
+ updatedTitle, find, replace := "v2", "old", "new"
+ if _, err := src.UpdateObservation(obsID, store.UpdateObservationParams{Title: &updatedTitle, Find: &find, Replace: &replace}); err != nil {
+ t.Fatalf("find-and-replace update observation: %v", err)
}
if err := src.DeletePrompt(promptID); err != nil {
t.Fatalf("delete prompt: %v", err)
@@ -3692,8 +3692,8 @@ func TestCloudImportAppliesMutationReconciliationForUpdatesAndDeletes(t *testing
if err != nil {
t.Fatalf("search updated observation: %v", err)
}
- if len(found) == 0 || found[0].Title != "v2" {
- t.Fatalf("expected updated observation title after pull reconciliation, got %+v", found)
+ if len(found) == 0 || found[0].Title != "v2" || found[0].Content != "original new" {
+ t.Fatalf("expected find-and-replace update after pull reconciliation, got %+v", found)
}
prompts, err := dst.RecentPrompts("proj-a", 10)