decision
ADR-0018: Brain Memory Limits and Content Sanitization Policy
ADR-0018 (Accepted (deployed 2026-05-15), 2026-05-15): Brain Memory Limits and Content Sanitization Policy.
Status: Accepted (deployed 2026-05-15)
Date: 2026-05-15
Deciders: Seth (Lead Architect), Blair (CEO)
Context
ADR-0015 establishes the four-document brain. ADR-0016 hands write authority to the LLM via tool calls. Both ADRs deliberately defer two questions:
- How much can accumulate? MEMORY entries grow without bound by default. An overeager LLM, a long-lived account, or a deliberately abusive caller can write thousands of entries. Vector search costs scale with index size, and the always-on retrieval window can’t be infinite. We need a per-tier limit.
- What goes in? Three writers touch brain documents: the LLM via tool calls, the user via the brain-management PUT endpoints, and the auto-generators (
generate_heartbeat,generate_initial_bible). All three are sources of untrusted text — the LLM can hallucinate or be prompt-injected, the user can paste anything, and synced data can contain unexpected control sequences. The persistence layer must not assume any of them is safe input.
The V1 spec (§4) lists “brain editing” as a Pro-tier feature, which gives us a natural place to land the memory limit — Free sees a small fixed quota, Pro gets a larger one. The brain-management UI spec (docs/superpowers/specs/2026-05-15-brain-management-and-profile-design.md, §3.3, §4.6, §8) consolidates the sanitization and limit policies.
Decision
Two policies, applied at every write path:
1. Memory limits per tier
| Tier | Memory entries | Behavior at cap |
|---|---|---|
| Free | 3 | LLM update_customer_brain(target=memory) returns {"success": false, "error": "Memory limit reached (<count>/<limit>). Upgrade to Pro for up to 15 memories."} — error string in _check_memory_limit() includes the current count and limit so the model can surface concrete numbers |
| Pro | 15 | Same error shape if cap is hit |
The cap is enforced server-side in handle_brain_tool_call() (src/memberintel/api/brain/tool_handler.py) and in any future memory-creating endpoint. The LLM receives the error in the tool_result and surfaces it conversationally (“I’d save this but you’ve reached your free-tier memory limit — want to upgrade?”).
Memory deletes are unrestricted at both tiers — transparency and control are not Pro features. Free users can delete to free up slots.
SOUL and BIBLE are not limited by count (one each per user / per site). They are limited by length via sanitization (below).
2. Content sanitization on every write
Every text field destined for user_souls.soul, site_contexts.bible, site_contexts.heartbeat, or a brain_entries.content (where collection='memory') passes through sanitize_brain_content() (in src/memberintel/api/brain/sanitize.py) before storage:
def sanitize_brain_content(text: str, max_length: int = 10_000) -> str:
"""Sanitize brain content before storage.
Strips HTML tags, control characters (except \\n, \\r, \\t),
common prompt injection patterns, and truncates to max_length.
"""
Behavior (matches the current implementation, in this order):
- Strip dangerous HTML blocks including content —
<script>,<style>,<iframe>and their bodies. - Strip all remaining HTML tags (preserves text content between them).
- Strip control characters in
\x00–\x1fand\x7f, preserving\n,\r,\t. - Strip common prompt-injection patterns: ChatML sentinels (
<|im_start|>,<|im_end|>), role-prefix lines (Human:,Assistant:,System:), and fenced-block role headers (```json,```system, etc.). - Truncate to
max_lengthcharacters. text.strip()the result before return. Empty-after-strip is allowed through — there is no hard rejection; downstream code is responsible for refusing to persist an empty document if that matters for the target.
Per-target max_length:
- SOUL and BIBLE writes (via
_update_soul()/_update_bible()) use the default10_000. - MEMORY writes (via
_create_memory()) usemax_length=1000— memory entries are intended to be short insight rows, not essays. generate_heartbeat()/generate_initial_bible()use the default when they sanitize their LLM-emitted output.
Applied at every write entry point:
PUT /api/v1/brain/soul— sanitize thecontentfieldPUT /api/v1/brain/bible— sanitize thecontentfieldhandle_brain_tool_call()— sanitize before writing SOUL, BIBLE, or MEMORYgenerate_heartbeat()andgenerate_initial_bible()incontext.py— sanitize their generated output before storageDELETE /api/v1/brain/memories/{id}— no sanitization (delete-only)
Frontend sanitization is additive, not replacement. The SPA escapes brain content on display (DOMPurify) — that’s defense-in-depth against historical entries written before sanitization shipped, and against future drift. The authoritative sanitization is on the backend write path.
Consequences
Positive:
- The cap is the upsell. Memory is the most visible “the AI remembers me” feature; hitting 3 entries and seeing a Pro nudge is a clean conversion surface — better than gating the feature entirely behind Pro.
- Sanitization is at the trust boundary. The DB never stores raw LLM output, raw sync data, or raw user input. Every read path can trust what it gets.
- Single function, one place to audit.
sanitize_brain_content()is the choke-point. A future change (e.g., add CRLF normalization, change the length clamp) is one edit. - Delete is always free. Users can correct or clear the brain without paying. The Pro gate is creation, which aligns with “Pro = the AI grows with you,” not “Pro = the AI is controllable.”
- The 10,000-char clamp keeps tokens bounded. SOUL/BIBLE/HEARTBEAT each fit comfortably in the always-on context budget; an LLM that tries to write a novel into SOUL gets truncated rather than blowing the system prompt.
Negative / costs:
- Free-tier memory is tight. Three entries fills fast. A user who has a productive first conversation might hit the cap in one session. The Pro upgrade prompt has to land well or it will read as nickel-and-diming.
- No memory eviction. The cap is a hard wall, not a sliding window. We don’t auto-delete the oldest memory to make room for a new one. That’s intentional (eviction would be surprising — “wait, what happened to that thing I told it?”) but it means the user must manually curate. Pro users hit this at 15 and have the same UX problem one tier up.
- Sanitization is lossy by design. HTML stripping and prompt-injection pattern matching can mangle legitimate content (a user who genuinely starts a line with
System:as English prose loses it; legitimate<code>markup in BIBLE gets flattened). Reports of legitimate-content loss will trigger a revisit of the pattern list. Note: Unicode normalization (NFC or otherwise) is not currently performed — visually-identical-but-different code-points compare unequal in storage. - Length clamp is silent (truncates, doesn’t error). A 12,000-char paste becomes a 10,000-char document with no warning. Acceptable for SOUL/BIBLE where the natural length is ~500 chars; less acceptable if a user is intentionally writing a long BIBLE. The UI may need to surface the cap pre-submit.
- No abuse-rate limit beyond the cap. A malicious caller can update SOUL/BIBLE thousands of times per hour, churning the table without growing it. Rate limiting is out of scope for this ADR; if it becomes an attack surface, it lands as a separate decision.
Mitigations:
- The Pro upgrade flow shows “you have N of 15 memories” so Pro users see headroom and the limit feels generous in context.
- Memory eviction is a known feature gap; if user research shows the hard wall is the wrong UX, a “memory archive” pattern (move-to-archive instead of delete) is the natural next step.
- Sanitization edge cases (legitimate content loss) are tracked in
src/memberintel/api/brain/sanitize.pytests; bug reports go straight to the test suite. - Length clamp UX: the brain-management UI shows a live character count and a warning at 9,000 chars. The backend remains authoritative; the UI is courtesy.
- Per-account abuse rate-limiting is in the cross-cutting “abuse” bucket; the entitlement service (ADR-0001) is the natural home for it when it’s needed.
Alternatives considered
- No memory limit; soft-quota on retrieval cost. Rejected. Decouples cost from the user-visible feature. A Free user accumulating 500 memories costs us money for vectors and retrieval that the user doesn’t see, and the Pro upgrade has no narrative hook.
- Unlimited memory for everyone. Rejected for the same reason — and the cost is paid by us. Anthropic + Voyage + Postgres storage is real; the entitlement service exists precisely to keep these economics defensible.
- Tier limits on SOUL/BIBLE word count instead of memory count. Rejected as the primary policy — it’s not what users perceive. “The AI remembers 3 things” is concrete; “your SOUL document can be 500 words” is abstract. The length clamp handles the cost-control role implicitly; the visible limit is on memories.
- Sanitize only on user input, not on LLM output. Rejected. ADR-0016 is explicit that the LLM is treated as untrusted input by the persistence layer. A prompt-injected LLM could otherwise write unescaped HTML or control sequences into BIBLE that would render in the brain-management UI. The sanitization choke-point closes that loop.
- Frontend-only sanitization (rely on DOMPurify in the SPA). Rejected. MCP clients, future agent integrations, and audit exports all bypass the SPA. The backend must be the authoritative trust boundary.
- Move sanitization into the database (CHECK constraints, triggers). Considered. Rejected because Postgres CHECK constraints can’t easily express “strip control chars” — they can reject but not transform. Application-layer sanitization is more expressive and easier to test.
- Higher Free-tier limit (e.g., 10). Considered. Rejected because the conversion-surface argument cuts the other way — if Free users never hit the cap, the cap doesn’t convert. 3 is small enough to be felt in normal use; 15 (Pro) is large enough that a serious user won’t hit it in a quarter.