Skip to content

Reject empty observation titles before persistence #459

Description

@andrzejk87

📋 Pre-flight Checks

  • I have searched existing issues and this is not a duplicate.
  • I understand this issue needs status:approved before a PR can be opened.

📝 Bug Description

Engram v1.16.1 allows first-party save paths to persist an observation with an empty or whitespace-only title.

This creates a local observation row with observations.title = '' and enqueues a cloud observation upsert mutation with sync_mutations.payload.title = ''.

Later, engram doctor / cloud sync correctly treats the same mutation as invalid because observation upserts require a non-empty title. The project then becomes blocked until the local database and frozen mutation payload are manually repaired.

This is a write-side validation / invariant gap:

  • CLI engram save accepts title="".
  • MCP mem_save accepts title="".
  • store.AddObservation accepts Title="".
  • cloud/doctor/chunk validators reject empty title for observation upserts.

Related but not duplicate:

This issue is about current v1.16.1 allowing new malformed empty-title observation upserts.

🔄 Steps to Reproduce

Use an isolated data directory so the real database is not touched:

tmp="$(mktemp -d)"
export ENGRAM_DATA_DIR="$tmp/.engram"

engram save "" "Content for empty-title reproduction" \
  --type discovery \
  --project empty-title-test

engram doctor --json --project empty-title-test

Observed CLI output:

Memory saved: #1 "" (discovery)

Inspecting the isolated SQLite database shows:

SELECT id, quote(title), length(title), project
FROM observations;

-- id=1, title='', length(title)=0, project='empty-title-test'

And the queued cloud mutation contains:

{
  "sync_id": "obs-...",
  "session_id": "manual-save-empty-title-test",
  "type": "discovery",
  "title": "",
  "content": "Content for empty-title reproduction",
  "project": "empty-title-test",
  "scope": "project"
}

Doctor then reports a blocking malformed mutation:

sync_mutation_required_fields
sync_mutation_payload_missing_required_fields
observation payload missing required fields: title

The same behavior is reproducible through MCP mem_save:

{
  "name": "mem_save",
  "arguments": {
    "title": "",
    "content": "Content via MCP empty-title reproduction",
    "type": "discovery",
    "capture_prompt": false
  }
}

The tool returns success:

Memory saved: "" (discovery)

and persists observations.title = '' plus sync_mutations.payload.title = ''.

✅ Expected Behavior

Engram should reject empty or whitespace-only titles before persistence.

Expected behavior:

  • CLI engram save "" "content" fails loudly.
  • CLI engram save " " "content" fails loudly.
  • MCP mem_save(title="", content="...") returns a tool error.
  • MCP mem_save(title=" ", content="...") returns a tool error.
  • store.AddObservation rejects empty/whitespace-only Title, so no caller can create an invalid observation or cloud mutation.

No observation row and no cloud mutation should be created for invalid titles.

❌ Actual Behavior

Engram accepts the write, creates a local observation, and queues a cloud mutation that later blocks doctor/cloud sync.

🧪 Verified On

engram 1.16.1
repo: Gentleman-Programming/engram
commit: 07445ba99eac95083d7c6d56c5d795bb68e90834
tag: v1.16.1

This was also reproduced by running from a clean upstream checkout with:

go run ./cmd/engram save "" "Content from clean source repro" \
  --type discovery \
  --project clean-source-empty-title

using an isolated ENGRAM_DATA_DIR=/tmp/....

The source checkout was clean and matched origin/main / v1.16.1.

🔍 Suspected Root Cause

internal/mcp/mcp.go validates content, but not title:

title, _ := req.GetArguments()["title"].(string)
content, _ := req.GetArguments()["content"].(string)

if strings.TrimSpace(content) == "" {
    ...
}

savedID, err := s.AddObservation(store.AddObservationParams{
    Title: title,
    ...
})

cmd/engram/main.go also forwards the CLI title unchanged:

title := os.Args[2]
content := os.Args[3]
...
Title: title,

internal/store/store.go strips private tags but does not enforce a non-empty title:

title := stripPrivateTags(p.Title)
content := stripPrivateTags(p.Content)

But cloud/chunk validation requires non-empty title:

body.Title = strings.TrimSpace(body.Title)
if body.Title == "" {
    return "", "", fmt.Errorf("observation payload title is required for upsert")
}

and cloud server validation also rejects it:

if strings.TrimSpace(observation.Title) == "" {
    return fmt.Errorf("observations[%d].title is required", i)
}

🛠 Suggested Fix

Enforce the same invariant on the write side:

  1. MCP mem_save:

    • trim title
    • reject empty/whitespace-only title
  2. CLI engram save:

    • trim title
    • reject empty/whitespace-only title
  3. store.AddObservation:

    • enforce non-empty title after stripPrivateTags and trim
    • return an error before any row or sync mutation is created

Suggested tests:

  • mem_save(title="") fails and creates no observation/mutation.
  • mem_save(title=" ") fails and creates no observation/mutation.
  • engram save "" "content" fails and creates no observation/mutation.
  • engram save " " "content" fails and creates no observation/mutation.
  • store.AddObservation(Title:"") fails.
  • store.AddObservation(Title:" ") fails.
  • valid non-empty titles still work.

📌 Impact

This can block project cloud sync with sync_mutation_payload_missing_required_fields and requires manual SQLite repair of both observations.title and frozen sync_mutations.payload.title.

A required JSON schema field is not sufficient here because required:["title"] only requires the key to exist; it does not reject an empty string.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions