Back to blog
EngineeringSep 29, 2026· min read

When the Resolver Fails Closed: Owner-Scoped Memory CRUD

We shipped complete entity CRUD on the /memory page today. This also demonstrates what 'fail closed' means when you're building a resolver that gates entire endpoints.

We shipped complete entity CRUD on the /memory page today. Create, edit, delete — the operations you'd expect from any data table. Except this one holds memory entities across multiple autonomous brains, and until today, the scoping that keeps one brain's entities invisible to another didn't exist on write paths.

PR #284 closes that gap. It also demonstrates what "fail closed" means when you're building a resolver that gates entire endpoints.

The Problem: Two PRs That Were Only Correct Together

This started as PRs #281 and #282, split as a stack. #281 added read scoping (GET /entities filtered by brain_id). #282 added create and edit. Both switched reads onto brain_id while the column was still NULL on every row in production, so both returned zero entities. Both also shared a fragile resolver pattern copied from prior art elsewhere in the codebase: a predicate on brains.name.

The name predicate made sense in its original context — a one-off task resolver where degrading to brain_id=None on failure was acceptable. It doesn't make sense when gating the entire /memory page. A single UPDATE brains SET name='...' on a cosmetic display column would 403 every memory read. #281 kept the predicate and replaced the degradation with a hard 403. #282 inherited the same resolver. The stack would have shipped a fragile production gate.

We caught it in review and rebuilt as one PR on a populated column after PR #283 (Phase 0) backfilled brain_id on all existing rows.

The Resolver: Identity Only, No Cosmetics

resolve_brain_id() in shared.py is the new canonical resolver. It resolves user_id to the brain_id that owns a caller's memory. The predicate is identity only — user_id + is_active. It deliberately does not filter on brains.name.

Here's what that means in practice:

  • You can rename a brain without breaking every memory endpoint
  • The resolver never degrades to None — it fails closed with a 403 if it can't resolve
  • If a user owns multiple active brains (currently impossible but nothing prevents it schema-wise), it resolves to the oldest and logs a warning — arbitrary tie-breaks must be loud, not silent

The fail-closed contract is the important part. The write path uses a separate resolve_brain_id_for_write() that validates the UUID before handing it to Pydantic schemas (avoiding ValidationError-kills-request). The read path uses the strict version. A request without a resolvable identity must never reach an unscoped query.

Write Operations: Stamping, Embedding, and the 409 Leak Guard

Three new endpoints:

POST /entities stamps brain_id, embeds the entity name via the existing Voyage path (so it's reachable by semantic retrieval), and returns 409 on a near-duplicate within the caller's brain. That last bit is subtle.

The duplicate-detection function match_entities_by_embedding() has no owner predicate (see the TODO in migration 20260323000001). If we surfaced a match raw, a create attempt could leak an entity name across the brain boundary this PR builds. The 409 check re-filters candidates against the caller's brain first. A cross-brain duplicate is invisible — the create proceeds as if it's unique, and the two brains maintain isolation.

Embedding and dedup failures both degrade gracefully. If Voyage is down or the semantic match fails, the entity is created anyway. The user presses "New Entity" and gets an entity. They don't get blocked by infrastructure outside the critical path.

PATCH /entities/{id} is brain-scoped, partial-update only, 404 on unowned. Standard guarded update.

DELETE /entities/{id} now takes user_id and is brain-scoped. This closes SCE-2436, deferred twice. The prior implementation filtered on id + status alone, so any shared-key holder could soft-delete any brain's entity by UUID. The new implementation requires ownership.

Frontend: Modal Salvage and Readable Errors

EntityFormModal.tsx was salvaged from #282. It's the create/edit modal wired to "New Entity" and per-card "Edit" buttons. Dark surface hsl(234,71%,5%) matching the AddFeedModal pattern from the Feedtumi work.

The other piece is readableApiError() in client.ts. A 409 from the backend now renders "…already exists" instead of the raw {"detail":{...}} JSON body. SCE-2411 was the ticket for that class of error surfacing. This is the first place we handle it.

Tests: Written First, Real Schema Guard

1,471 lines of tests written test-first, red before green, at every step:

  • Resolver suite (196 lines): 14 tests including an explicit guard that no name predicate is used
  • Owner scoping (189 lines): behavioral two-brain isolation through the real resolver; fixture brains are deliberately not named "Sabine" so a reintroduced name predicate fails the suite
  • Write operations (493 lines): 18 tests including embedding, 409-on-owned-duplicate, no-409-and-no-leak on a cross-brain duplicate, degradation on embedding/dedup failure
  • Real schema integration (187 lines): the anti-#281 gate. Runs the real predicates against a real schema and asserts non-empty results. Opt-in via SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / SABINE_INTEGRATION_USER_ID. If you switch a read onto a NULL column or introduce a predicate that matches zero rows, this suite catches it.

The real-schema guard is the thing I'd want other teams to copy. We've shipped at least two PRs that returned zero rows in prod because we tested against empty fixtures. This suite runs against the production schema with production data and asserts that the results are not empty. It's opt-in because it requires service role credentials, but it runs in CI and it would have caught both #281 and #282 before merge.

The SCE-2403 suites (the original entity soft-delete tests) were updated to the new contract, not weakened. The coverage went up.

Verified in Production

3,899 tests passed (was 3,872 on main), 235/235 Vitest, lint clean, build green.

Round-trip also executed against production: create survives the scoped list, scoped PATCH matches, a wrong brain_id matches nothing, scoped DELETE matches. Probe row removed, prod left at 117 entities.

What's Next

Two things:

  1. Dedupe predicate migration. match_entities_by_embedding() has no brain_id predicate. The TODO in migration 20260323000001 calls it out. The current approach — re-filter matches client-side before surfacing a 409 — works and prevents leaks, but it's a band-aid. The correct fix is a migration that adds the predicate to the stored function itself. That's scheduled for the Phase 2 memory consolidation work.
  2. Multi-brain selector. The resolver currently picks the oldest active brain when a user owns more than one and logs a warning. The schema doesn't prevent multi-brain ownership (and there's at least one legitimate use case: a user controlling multiple autonomous agent identities). An explicit brain selector in the UI would make the tie-break unnecessary. Not critical — zero users are in this state today — but the warning is loud on purpose. If we see it in logs, we build the selector.

Closed: SCE-2402, SCE-2405, SCE-2436 | Commit: 120100e | PR: #284