Personal Memory Engine: Supabase RLS + FastAPI Implementation
UI는 한국어입니다. 글 본문은 아직 영어 또는 중국어만 있습니다.
Engineering System ADR-001 defined the architecture. Invest AI ADR-031 defined the API surface. Here's how we actually built it.
The Schema
One table in Supabase, extending the existing user_settings pattern:
create table if not exists public.user_memory (
id bigint generated by default as identity primary key,
user_id uuid not null unique references auth.users (id) on delete cascade,
age integer,
goals text[] not null default '{}',
constraints text[] not null default '{}',
health_conditions text[] not null default '{}',
financial_profile jsonb not null default '{}',
family_members jsonb[] not null default '{}',
preferences jsonb not null default '{}',
decision_style text check (decision_style in ('analytical', 'intuitive', 'collaborative')),
language text not null default 'en' check (language in ('en', 'zh', 'ko')),
version integer not null default 1,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now())
);
UNIQUE on user_id means upsert is a simple on_conflict=user_id — no secondary lookup needed.
Row Level Security
Four policies, one per operation. The pattern is identical to user_settings:
alter table public.user_memory enable row level security;
create policy "users can read own memory"
on public.user_memory for select
using (auth.uid() = user_id);
create policy "users can insert own memory"
on public.user_memory for insert
with check (auth.uid() = user_id);
create policy "users can update own memory"
on public.user_memory for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "users can delete own memory"
on public.user_memory for delete
using (auth.uid() = user_id);
Why separate policies instead of one ALL policy? Explicit. Each operation's intent is readable. Adding a future admin-read policy (for support tooling) doesn't accidentally grant admin write.
MemoryService — The httpx Pattern
The service follows the exact same pattern as SettingsService — no new abstractions:
class MemoryService:
async def get_memory(self, user_id: str) -> dict[str, Any]:
supabase = get_supabase_config(self._config)
headers = build_rest_headers(service_role=True, config=supabase)
url = build_rest_url(
f"rest/v1/user_memory?select=*&user_id=eq.{user_id}&limit=1",
config=supabase,
)
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
rows = response.json()
return _parse_row(rows[0]) if rows else _empty_memory(user_id)
service_role=True bypasses RLS on the backend — the JWT auth is enforced at the FastAPI layer (get_current_user), not repeated at the Supabase level. This matches how every other Supabase service in this project works.
The LLM Context Block
The key method is build_llm_context_block:
async def build_llm_context_block(self, user_id: str) -> str:
memory = await self.get_memory(user_id)
lines = ["## User Context (Personal Memory)"]
if memory.get("age"):
lines.append(f"- Age: {memory['age']}")
if memory.get("goals"):
lines.append(f"- Goals: {', '.join(memory['goals'])}")
# ... health_conditions, financial_profile, family_members ...
return "\n".join(lines)
This string gets prepended to the worker's LLM system prompt. The daily brief worker now knows the user is 55, conservative, planning for a daughter's medical school — without any UI change.
The API Routes
Three routes in app.py, all behind get_current_user:
@api_v1.get("/memory")
async def get_memory(current_user=Depends(get_current_user), ...):
return await memory_service.get_memory(current_user["user_id"])
@api_v1.put("/memory")
async def update_memory(payload: dict, current_user=Depends(get_current_user), ...):
return await memory_service.upsert_memory(current_user["user_id"], payload)
@api_v1.delete("/memory", status_code=204)
async def delete_memory(current_user=Depends(get_current_user), ...):
await memory_service.delete_memory(current_user["user_id"])
DELETE returns 204 (no content). It's the GDPR erasure endpoint — hard delete, no soft-delete toggle.
Privacy: No Sensitive Fields in Logs
health_conditions and financial_profile never appear in application logs. The _strip_sensitive_logging function enforces this — it's called before any dict is passed to a logger. The rule is enforced structurally, not by convention.
What's Next
- Wire
build_llm_context_blockinto the daily brief worker (ADR-008 worker path) - Add "My Profile" page in the frontend to read/edit memory
- Meal AI's worker reads memory for health condition context (ADR-002 already specifies this)
Related ADRs: Engineering System ADR-001 · Invest AI ADR-031