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

  1. Include at least one of narrative, text, title, subtitle, facts, or concepts in the payload
  2. If the record genuinely has no text yet, skip or buffer the write until text exists
  3. 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

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


AI-assisted analysis of thedotmack/claude-mem@e2d1df569a (2026-08-20). Data as JSON: /api/errors/ede3de5b217a520a. Report an issue: GitHub.