cpatpa ships per-user memory and finally wires the layered system prompt
Phase 9 is two features: a `user_memories` table with model-driven capture, LRU eviction, and a pin cap enforced by a Postgres trigger; and the four-layer system-prompt assembly that the fork's architecture doc had always described but never implemented. Both are behind opposite-default flags.
The most revealing commit in this batch is the design revision (84aafcd6) that ran before any code was written. A codebase audit found that chatTools.ts hardcodes SYSTEM_PROMPT as a string constant, users.custom_instructions is stored but never injected into a prompt, and workspaces.instructions is similarly inert. The four-layer assembly described in 01-architecture.md was aspirational. The design was rewritten to actually implement it, behind a use_layered_prompt org setting that defaults true. The same audit pass also found: res.locals.userId not req.user; audit events emitted inline, no lib/audit.ts; admin page is /admin/policy not /admin/ai-policy. These corrections fed back into the implementation before a line of code shipped.
The backend (085a9d78) adds migration 0022_user_memories.sql: the user_memories table (cascade on users, SET NULL on chats, content bounded BETWEEN 1 AND 2000 chars), the enforce_user_memory_pin_cap trigger that raises errcode 23514 when pinned count would exceed 10, RLS enabled and forced, and four new org_settings columns (allow_memory default false, memory_max_per_user 1-500 default 50, memory_max_chars 1-2000 default 500, use_layered_prompt default true). lib/memories.ts provides full CRUD plus clear/export. Concurrent add_memory calls are protected by SELECT ... FOR UPDATE inside a transaction; the pin cap trigger's errcode 23514 is caught and translated to a typed PinCapExceededError, then returned as 409. Memory content is rendered inside a fenced code block with PINNED: and OTHER: subheadings so hostile content can't escape the block's markdown structure. stampLastUsed runs one batched UPDATE for all injected memory ids at the end of the request. lib/promptAssembly.ts assembles: org system prompt → workspace instructions → user custom instructions → memory block → the existing systemPromptExtra. The SYSTEM_PROMPT constant is preserved intact as the fallback path when use_layered_prompt is false.
The add_memory tool is only added to the active tool set when policy.allow_memory is true. When a memory saves, the backend emits an SSE memory_saved frame with a skippedReason taxonomy - memory_disabled, invalid_content, empty_content, too_long, cap_reached, pin_cap, internal_error. The frontend renders it as a small amber pill in the chat linking to /account.
The memories API lives at /me/memories with a per-user write-only rate limiter (30/min, env-configurable). Clear-all is POST /clear { confirm: true } rather than DELETE ?confirm=1, a deliberate CSRF-safe choice noted in the design revision. Export returns chat_id as a bare uuid without hydrating chat metadata, to avoid surfacing titles of chats the caller no longer has access to.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?