19 KiB
Memory Bank — Architecture (v0)
Status: Draft for scoping. Pre-spec architecture doc that feeds
/officeHours→/specToProvenPR. Decisions captured in an interview on 2026-07-02 and revised after a 5-lens/autoplanreview the same day. [VERIFY] = confirm at impl; [DECISION] = locked; [FIX] = correction applied after the autoplan review (see §13 for the review trail).
1. Goal & principles
A per-user memory bank inside OpenWork. The user opts in by chatting (e.g. "save this memory to the memory bank"); the agent drafts a memory + relevant context, a human verifies it, and it's persisted server-side. Later the user can explicitly search their memories with natural-language phrasing, and view/delete them from a lightweight desktop panel.
Principles (priority order):
- Durable interface, evolvable implementation. The long-lived contract is the REST/MCP tool shape; storage + search behind it are swappable (lexical today, vector/entity-graph later) with no contract change.
- No new infrastructure dependencies. MySQL (PlanetScale) + Drizzle only.
- Harness-agnostic. Memory is a shared knowledge layer keyed to the principal.
- Server-side storage, thin client.
- Human-verified writes. Persist human-confirmed content, never raw agent output.
Note on recall (revised): v0 is explicit, lexical search — the user must ask. We do not claim semantic/"understands meaning" recall, and we do not auto-recall. The honest value bar is "saved facts are retrievable across sessions on a natural-language query," not "the agent never makes you re-explain."
2. Surface & the durable contract
Memory is exposed as den-api REST routes, auto-surfaced as capabilities through the
existing meta-MCP (/mcp/agent). That rail exposes exactly two tools —
search_capabilities and execute_capability — so memory is reached by executing a
discovered capability, not by a bespoke memory_save tool. [FIX B1]
harness ── search_capabilities("save a memory") → finds capability "postMemory"
└─ execute_capability({ name: "postMemory", body: {...} })
harness ── search_capabilities("search my memories") → finds "getMemorySearch"
└─ execute_capability({ name: "getMemorySearch", query: { q, limit } })
Routes & operationIds [FIX — corrected]
buildOperationId (ee/apps/den-api/src/openapi.ts:16-38) strips v1 and PascalCases
path segments. The actual operationIds / MCP tool names are:
| Route | operationId / tool name | scope |
|---|---|---|
POST /v1/memory |
postMemory |
mcp:write |
GET /v1/memory/search |
getMemorySearch |
mcp:read |
GET /v1/memory (list) |
getMemory |
mcp:read |
DELETE /v1/memory/:id |
deleteMemoryById |
mcp:write |
Verified: none collide with BLOCKED_OPERATION_IDS, all stay ≤49 chars after
structuralShorten, and the two GETs are distinct paths (no collision). Never refer
to memory_save/memory_search in the prompt or docs — those names do not exist.
v0 CRUD scope [DECISION]
save + search + list + delete (list + delete back the desktop management panel).
Owner-scoped everywhere (§8). PATCH (edit) and a bankScope param are future.
Catalog inclusion [DECISION: new "Memory" tag]
Add "Memory" to SAFE_INCLUDED_TAGS (ee/apps/den-api/src/mcp/policy.ts:2-23) and
tag the routes tags: ["Memory"]. This is the only mechanism in active use (x-mcp:true
is dormant). Route pattern to mirror for structure: routes/workers/core.ts — but
its authorization is org-scoped; memory must be user-scoped (see §8, [FIX B4]).
3. Data model (MySQL / Drizzle)
memory
id typeid("memory") PK
user_id typeid("user") -- owner (v0 always set)
org_id typeid("organization")
scope enum('user','org') default 'user' -- two-bank model; v0 forces 'user'
content text -- HUMAN-VERIFIED memory (plaintext in v0, see §8)
source varchar -- server-set: 'chat' | 'modal'
tags json -- optional
created_at, updated_at (shared `timestamps`)
indexes: (user_id)
FULLTEXT index on (content) -- raw SQL; created idempotently, see below
memory_context
id typeid("memctx") PK
memory_id typeid("memory") FK → memory.id (ON DELETE CASCADE)
citation json -- { conversation_id?, message_id? } (all optional)
snippet text -- plaintext in v0
origin enum('active_conversation','searched_conversation')
created_at
indexes: (memory_id)
[FIX B5] Register the typeid prefixes
memory and memctx must be added to idTypesMapNameToPrefix
(packages/utils/.../typeid.ts) or denTypeIdColumn/denTypeIdSchema won't compile.
This is a cross-package edit and is part of the schema task, not an afterthought.
[DECISION] Two-bank model, personal-only in v0
scope present from day one, hard-enforced to 'user' server-side — not merely a
column default. A client can POST scope:'org'; the handler must overwrite it. Every
read filters user_id = principal.userId and (defensively) scope='user' so a
future org row can never retroactively leak into personal reads. [FIX]
On-device provenance
memory_context captures agent-gathered citations + snippets. All citation fields are
optional (floor: a snippet with no ids). [VERIFY] which ids the agent can read
on-device.
Search backend [DECISION: MySQL FULLTEXT] + [FIX B2]
PlanetScale/Vitess supports FULLTEXT + MATCH … AGAINST. v0 uses NATURAL LANGUAGE
MODE, owner-scoped, relevance-ranked.
- Fresh-install hazard: new DBs bootstrap from the build-time current-schema
SQL snapshot (which cannot see a raw
FULLTEXTindex — Drizzle mysql-core has no FULLTEXT DSL, drizzle-orm#1495) and baseline marks migrations applied-without-executing — so a plain migration-only index is silently absent in production while working on incrementally-migrated dev DBs. [FIX B2] Create the index idempotently on a path both bootstrap and migrate hit — e.g. a startup/bootstrap step that checksinformation_schema.STATISTICSfor the index and runsCREATE FULLTEXT INDEXif missing. Add a CI/startup assertion that the index exists. - Query safety [FIX]: bind the query (
MATCH(content) AGAINST(${q} IN NATURAL LANGUAGE MODE)via Drizzle'ssqltemplate) — neversql.raw; combine the MATCH with theuser_idpredicate in a singleand(...). (Precedent:admin-tools.tsbinds correctly.) - PlanetScale caveat [VERIFY]:
innodb_ft_min_token_size(default 3) and stopwords are not tunable on PlanetScale — short/common query terms silently won't match. The v0 mitigation is honest scoping (§1), not "tune it." If recall is weak, consider also indexingsnippetor a denormalized column (future).
4. Save flow (human-verified)
Trigger: "save this memory to the memory bank."
- Draft (standalone turn). Agent composes candidate
content+ optional context. - Human verification. Human confirms/edits before persisting. Baseline = chat-driven confirm (harness-agnostic); optional desktop modal (deferred, §9).
- Persist —
POST /v1/memory. [FIX B3] Flattened, mostly-optional payload so the agent (which only seeshasBody:true, not the schema) can construct it. The shape is also encoded in the route's OpenAPIsummary(the one body hint the agent gets):{ "content": "User deploys into a Daytona sandbox", // required "tags": ["deploy", "infra"], // optional "contexts": [ // optional { "snippet": "…excerpt…", // required if present "conversation_id": "…", "message_id": "…", // optional "origin": "active_conversation" } // optional ] }- Server sets
sourceand ignores/overwrites clientscope(forces'user'). - Server wraps the
memory+ Nmemory_contextinserts in one transaction (no orphaned context rows). [FIX] - Input bounds [FIX]: cap
contentlength andcontextscount (cheap abuse guard; full rate-limiting/quota deferred, §9).
- Server sets
- Feedback. The save must surface an explicit success/failure signal (see §6 states).
5. Retrieval flow (explicit, lexical)
[DECISION] Explicit search only — no auto-recall.
- User asks in natural language: "what was that deal with Acme?"
- Agent calls
getMemorySearch(GET /v1/memory/search?q=…&limit=…).limitdefaults to 20, capped; results relevance-ranked viaMATCH … AGAINST, owner-scoped. - No match → empty result set, HTTP 200 (not an error) so the agent reports "nothing found" gracefully. [FIX]
- Agent does best-effort set reduction and presents results.
Response (stable): { results: [{ id, content, tags, created_at, score, contexts? }] }.
6. Desktop client (thin) + gating
[DECISION] Client self-serve toggle, UI-gate only; v0 metrics are qualitative
- New Preferences-tab toggle via
useLocal()featureFlags.memory(mirrorfeature-flags-preferences.ts). Client-only, per-device, never synced. - Gates UI visibility only (soft gate): reveals the management panel, copy-prompt button, and optional modal. Routes stay callable (owner-scoped + authz'd).
- [DECISION] Metrics are qualitative in v0. The client-only flag is not server-observable, so we do not commit to server-computed adoption/recall metrics; we measure via manual eval + raw save/search counts (see product.md). A server enable event + hard gate are deferred (§7, §9).
Management panel (list + delete) — states are the product [FIX design]
The panel and save flow must specify non-happy-path states, reusing the desktop's
existing accessible Base UI primitives (Switch, Dialog/AlertDialog, ConfirmModal,
Empty, Sonner toast) rather than building from scratch:
- Save: draft-loading, success (toast), failure/retry, offline, cancel/discard.
- Panel: loading, enabled-but-empty (first-run payoff — use
Empty), error, offline. - Delete: optimistic delete + undo toast (resolves the "reversible-feeling vs permanent" tension); focus moves sensibly after removal.
- Recall: the agent renders results (prompt-owned), but the panel must not imply a search UI it doesn't have.
- Accessibility: icon-button names, destructive-action focus,
aria-live/toast announcements — mandated via the primitives. - Content is rendered escaped in the panel (stored-XSS guard; §8). [FIX]
Agent priming [DECISION: static ## Memory Bank, search-first]
Append a static, distinct ## Memory Bank section to OPENWORK_AGENT_PROMPT
(apps/server/src/openwork-runtime-config.ts:34-66) — separate from the existing
## Memory (credential-hygiene) section. It must [FIX B1]:
- Be search-first: "to save a memory, search for a capability to save a memory, then
execute it" — never name
memory_save. - Cover save (draft → human-confirm → execute
postMemory) and retrieval (search → reduce → present). - [DECISION, plaintext risk] Instruct the agent to not persist secrets, credentials, tokens, or sensitive PII — this prompt guidance is the only v0 mitigation for plaintext-at-rest (§8).
- The copy-prompt button primes non-server harnesses (Claude Code, local opencode); on desktop server workspaces the prompt is already injected, so the button is a secondary cross-tool utility, not the first-run path.
7. Hard gate (deferred fast-follow) [DECISION: deferred]
v0 gate is client-only + UI-only. A hard, server-enforced gate (server per-user pref or
org desktop-policies, in-handler 403, optional conditional prompt injection, and a
kill switch) is scoped but not built. Additive — no contract change.
8. Auth, scoping & security (v0)
- Principal always carries
userId+organizationId(mcp/auth.ts:19-24); routes readc.get("user").id/c.get("activeOrganizationId"). Live session + membership checks already gate every request (revocation is immediate — verified). - [FIX B4] Owner-scoping (the #1 risk). Do not copy the org-scoped worker
accessor. Memory reads/list/delete filter on
user_id = principal.userIdas the primary predicate. Add a dedicatedgetMemoryByIdForUser; return 404 (non-leaking) for ids the caller doesn't own; add a cross-user access regression test as a merge gate. DELETEhard-deletes the FK'dmemory_contextrows (ON DELETE CASCADE / explicit) for right-to-be-forgotten.- [DECISION — plaintext at rest, risk accepted]
content/snippetare plaintext in v0. The repo shipsencryptedTextColumnbut v0 does not use it. Mitigation is prompt guidance (§6) telling the agent not to store secrets. This is a documented, accepted v0 risk; encryption at rest is a pre-GA requirement (§9). - Query is parameterized + owner-filtered (§3). Panel escapes content (§6).
- Deferred (§9): per-user quota / rate-limiting (storage-exhaustion DoS). v0 keeps only cheap input bounds (§4).
9. Out of scope for v0 (explicitly deferred)
- Vector DB / embeddings / semantic recall.
- Encryption at rest for content/snippet — pre-GA requirement, accepted risk in v0.
- Auto-recall / passive session-start recall (considered, cut).
- Cross-tool entity/relationship synthesis.
- Active org-shared bank (schema-ready, not activated).
- Hard server-enforced gate + server-side metrics + kill switch.
- Per-user quota / rate-limiting (only cheap input bounds in v0).
- Memory reaping on account/org deletion — there is no DB FK or offboarding hook, so deleting
a user/org does not remove their
memory/memory_contextrows. A cleanup hook is a pre-GA requirement alongside encryption at rest (§8); v0 relies on the explicitdeleteMemoryById. PATCH(edit a saved memory) — v0 is view + delete only.- Optional desktop save/verify modal (chat-driven is the v0 baseline).
10. Durability / evolution path
| Concern | v0 | Future (no contract change) |
|---|---|---|
| Search | MySQL FULLTEXT (NL mode), owner-scoped |
vector/embedding or hybrid ranker |
| Privacy | plaintext + prompt guidance | encryption at rest (pre-GA) |
| Context | memory_context citations + snippets |
entity/relationship synthesis |
| Banks | scope='user' enforced |
activate scope='org' |
| Gate | client toggle, UI-only, qualitative metrics | server pref / 403 / events / kill switch |
| Recall | explicit lexical | passive + semantic |
11. Decisions resolved + items to verify
Resolved (this doc):
- Tool access → search-first via
execute_capability; real namespostMemory/getMemorySearch/getMemory/deleteMemoryById(§2). [FIX B1] - FULLTEXT bootstrap → idempotent index creation on bootstrap+migrate path (§3). [FIX B2]
- Save payload → flattened, mostly-optional, shape in OpenAPI summary (§4). [FIX B3]
- Owner-scoping →
user_id-primary, 404 non-owned, regression-test gate (§8). [FIX B4] - typeids → register
memory/memctxinpackages/utils(§3). [FIX B5] - Encrypt-at-rest → plaintext v0, prompt-guidance mitigation, pre-GA requirement (§8).
- Gate/metrics → client-only, qualitative metrics; hard gate deferred (§6, §7).
- Recall → explicit lexical only; honest value bar; no
≥60%metric (§1, §5). - Search backend / tag / two-bank / citation / CRUD → as prior (§2, §3).
Verify at impl (non-blocking):
- [VERIFY] On-device citation id availability (§3).
- [VERIFY] PlanetScale
innodb_ft_min_token_size/stopword impact on recall (§3).
12. Handoff
- Next:
/specToProvenPRfor staged, proven PRs. - Suggested PR staging:
- den-db:
memory+memory_contexttables + migration; register typeids (packages/utils); idempotentFULLTEXTindex on the bootstrap+migrate path;ON DELETE CASCADE. - den-api: routes
postMemory,getMemorySearch(boundMATCH…AGAINST, NL mode, empty-set on no match),getMemory(list),deleteMemoryById— alluser_id-owner-scoped, 404 non-owned, forcescope='user', one-tx save, input bounds; structured save/search/delete events; new"Memory"tag; OpenAPIsummaryencodes the save body shape; cross-user regression test (merge gate). - eval:
memory-save-recall.flow.mjs(mirrormcp-search-capabilities.flow.mjs) proving save→recall end-to-end. - apps/server: static search-first
## Memory Bankprompt incl. no-secrets guidance. - Desktop:
featureFlags.memorytoggle + management panel (list + delete, all states via Base UI, optimistic-delete + undo, escaped content) + copy-prompt button. - Fast-follow (deferred): hard gate, encryption at rest, rate-limit/quota, modal.
- den-db:
- Per-PR verification gate (
AGENTS.md) — applies to every stage above. Each stage's PR must: (a) run tests with pnpm and report the exact commands + results in the PR body; (b) produce test evidence for every experience-affecting change —evals/results/test-runs/<test-run-id>/test-run.jsonfrom an@openwork/testkittest, each frame binding claim → user action → observable assertion → validated screenshot, and reportPassedonly when the test run exists with observable assertions (elseIncomplete/Failedwith repro); backend-only/types-only stages may skip but must say so and prove the core flow is unchanged; (c) attach end-to-end evidence (short video preferred, screenshots otherwise) of save → recall → view/delete; (d) meet coding guidelines (noany/as, pnpm,@/components/shadcn Base UI, smallest diff). SeeTASKS.mdTASK-11. - Reference files:
ee/packages/den-db/src/schema/workers.ts,ee/apps/den-api/src/routes/workers/core.ts(structure only — re-scope authz to user),ee/apps/den-api/src/openapi.ts(operationId gen),ee/apps/den-api/src/mcp/policy.ts:2-23,search.ts/agent.ts(rail + error shape),apps/server/src/openwork-runtime-config.ts:34-66,apps/app/src/react-app/domains/settings/state/feature-flags-preferences.ts,apps/app/src/react-app/kernel/local-provider.tsx,evals/flows/*cloud-mcp*.flow.mjs.
13. Review trail
Revised after a 5-lens /autoplan review (product · arch · design · devex · security),
2026-07-02. Full findings + consolidated report:
~/.agentic-workflow/joi-fairshare-openwork/plans/20260702-112912-memory-bank/consolidated-review.md.
Blockers B1–B5 and the operationId correction are folded in above; the three product/
security tensions were decided as recorded in §11.