thedotmack/claude-mem · error
ValidationError
ValidationError
Error message
memory_items requires at least one searchable text field (narrative, text, title, subtitle, facts, or concepts) so the FTS index is populated; refusing to persist an empty record
What it means
POST /v1/memories enforces a cross-field rule beyond Zod: at least one searchable text field (narrative, text, title, subtitle, facts, concepts) must be populated, because the FTS insert trigger (trg_memory_items_fts_insert) copies exactly those columns into the search index. A row with none would be invisible to /v1/search and look 'frozen', so the route rejects it loudly with 400 ValidationError (issues #2684/#2533).
Source
Thrown at src/server/routes/v1/ServerV1Routes.ts:191
const event = new AgentEventsRepository(this.options.getDatabase()).getById(id);
if (!event) {
res.status(404).json({ error: 'NotFound', message: 'Event not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, event.projectId)) return;
this.audit(req, 'event.read', event.id, event.projectId);
res.json({ event });
});
app.post('/v1/memories', writeAuth, this.handleCreate(CreateMemoryItemSchema, (req, res, body) => {
if (!this.ensureProjectAllowed(req, res, body.projectId)) return;
// Write-path contract (#2684/#2533): the FTS trigger (trg_memory_items_fts_insert)
// copies title/subtitle/text/narrative/facts/concepts into the search index.
// A row with NONE of the searchable text columns populated is invisible to
// search — it looks "frozen". Reject it LOUDLY here instead of silently
// persisting an unsearchable record.
if (!hasSearchableContent(body)) {
res.status(400).json({
error: 'ValidationError',
message: 'memory_items requires at least one searchable text field (narrative, text, title, subtitle, facts, or concepts) so the FTS index is populated; refusing to persist an empty record',
});
return;
}
const memory = new MemoryItemsRepository(this.options.getDatabase()).create(body);
this.audit(req, 'memory.write', memory.id, memory.projectId);
res.status(201).json({ memory });
}));
app.get('/v1/memories/:id', readAuth, (req, res) => {
const id = this.routeParam(req.params.id);
const memory = new MemoryItemsRepository(this.options.getDatabase()).getById(id);
if (!memory) {
res.status(404).json({ error: 'NotFound', message: 'Memory not found' });
return;
}
if (!this.ensureProjectAllowed(req, res, memory.projectId)) return;View on GitHub (pinned to e2d1df569a)
Solutions
- Include at least one of narrative, text, title, subtitle, facts, or concepts in the payload
- If the record genuinely has no text yet, skip or buffer the write until text exists
- Replicate the hasSearchableContent check client-side so the failure never reaches the server
Example fix
// before
POST /v1/memories { "projectId": "p1", "score": 0.9 } // 400: no searchable field
// after
POST /v1/memories { "projectId": "p1", "score": 0.9, "title": "deploy incident", "text": "summary of incident" } // 201 Defensive patterns
Strategy: validation
Validate before calling
const SEARCHABLE_FIELDS = ['narrative', 'text', 'title', 'subtitle', 'facts', 'concepts'] as const;
function hasSearchableContent(body: Record<string, unknown>): boolean {
return SEARCHABLE_FIELDS.some(f => {
const v = body[f];
if (v == null) return false;
return typeof v === 'string' ? v.trim().length > 0 : Array.isArray(v) ? v.length > 0 : Boolean(v);
});
}
if (!hasSearchableContent(payload)) throw new Error('memory needs at least one of: ' + SEARCHABLE_FIELDS.join(', ')); Type guard
function isSearchableMemory(body: unknown): body is Record<string, string | string[] | number | undefined> {
if (typeof body !== 'object' || body === null) return false;
const b = body as Record<string, unknown>;
return ['narrative', 'text', 'title', 'subtitle', 'facts', 'concepts'].some(k =>
(typeof b[k] === 'string' && (b[k] as string).trim() !== '') || (Array.isArray(b[k]) && (b[k] as unknown[]).length > 0)
);
} Try / catch
const res = await fetch(`${base}/v1/memories`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload) });
if (res.status === 400) {
const body = await res.json().catch(() => ({}));
if (typeof body.message === 'string' && body.message.includes('searchable text field')) {
payload.title = payload.title ?? fallbackSummary; // enrich and retry once
return retry(payload);
}
throw new Error(`validation failed: ${JSON.stringify(body.issues ?? body.message)}`);
} Prevention
- Mirror hasSearchableContent client-side and fail before the HTTP call
- Map content keys carefully — 'body' or 'content' are not searchable fields
- Never write metrics-only memory records; give each one a title or narrative
When it happens
Trigger: POST /v1/memories with a body that passes CreateMemoryItemSchema but carries only non-searchable metadata (projectId, timestamps, scores, tags) with every text field absent or empty.
Common situations: Pipelines writing metrics-only or relation-only records; clients mapping content to the wrong key name (e.g. 'body' instead of 'text'); upstream trimming that leaves empty strings.
Understand the failure class
Background: "Missing required field" and "field is required" errors: why libraries reject payloads that omit mandatory fields — this error's family across 20 libraries.
Related errors
- observation_search: "query" is required
- INVALID_CORPUS_NAME
- ValidationError
- ValidationError
- auth_invalid
AI-assisted analysis of thedotmack/claude-mem@e2d1df569a (2026-08-20).
Data as JSON: /api/errors/ede3de5b217a520a.
Report an issue: GitHub.