Skip to main content
Molecule AI
Blog

Engineering

Hierarchical agent memory: LOCAL, TEAM and GLOBAL scopes that follow the org tree

How we gave agent teams a memory model shaped by the org tree — workspace, team and org scopes, pgvector recall, and the isolation boundary we hardened.

By Molecules AI Engineering 7 min read

An agent that forgets everything between turns is a chatbot. An agent that remembers everything it has ever seen, and shares it with every other agent in the building, is a liability. The interesting design space is in the middle: an agent should remember the right things, and those things should be visible to exactly the right set of peers — no more.

When you run a single agent, “the right set of peers” is trivial: it’s just that agent. But Molecule runs org trees — a hierarchy of workspaces where a control plane sits above teams, teams sit above individual agents, and agents talk to each other over A2A. In that world, “who should see this memory” is a real question with a real answer, and the answer is almost always “the subtree this fact is true for.” A debugging note an SEO agent writes about one client’s Search Console quirk should not leak to a different tenant. A team convention (“we deploy via staging-<sha>, never :latest”) should be visible to every agent on that team. A platform-wide fact should be visible to all of them.

So we built the memory model to follow the org tree. This is the write-up of what we tried, what shipped, and the boundary we had to harden twice before we trusted it.

Org tree on the left with org, team and workspace nodes mapping to org, team and workspace memory scopes on the right, with commit and recall arrows through pgvector

Three scopes, derived from one address

The model has three scopes, and they map one-to-one onto levels of the org tree:

  • workspace: — LOCAL. Private to a single agent’s workspace. The default.
  • team: — TEAM. Shared across siblings under a common parent.
  • org: — GLOBAL. Shared across the whole tenant org.

The key decision — and the one that kept the implementation honest — was to make the scope derivable from the namespace string itself rather than carrying it as a separate flag the caller could get wrong. A namespace is an address like workspace:<id> or team:<id>, and the “kind” of a memory is read straight off that prefix, with a custom fallback for anything that doesn’t match. The agent doesn’t assert “this is a team memory” in one field while writing to a workspace namespace in another; the prefix is the assertion. We tightened this in core #1925, where the namespace kind is derived from the workspace: / team: / org: prefix and the writable namespace is upserted immediately before the commit so a write can’t land against a namespace that doesn’t exist yet.

Agents touch this through two MCP tools: commit_memory to write and recall_memory to read. That’s the entire surface an agent sees. Everything about where a memory lives and who can read it is policy underneath those two verbs, not knobs the agent has to reason about turn by turn.

Recall has to be semantic, not keyword

Storage is the easy half. The hard half is recall: an agent asking “have I dealt with this before?” rarely phrases the question the same way it phrased the original note. Keyword search misses the match and the agent re-derives a fact it already knew — which, with LLM agents, means it re-derives it differently this time, and now you have drift.

So recall is semantic. Memory v2 stores embeddings in Postgres via pgvector and recalls by vector similarity within the scopes the caller is allowed to see. The “v2” matters: it replaced an earlier design we’d plumbed but never actually wired. We’d carried an “Awareness namespace” surface — AWARENESS_URL and AWARENESS_NAMESPACE env vars threaded across the workspace container — that turned out to be dead in every environment. Rather than leave a half-built routing layer to confuse the next person, we ripped it out in core #1737 and swept the narrative docs to match in core #1758. Deleting the thing you didn’t ship is part of shipping the thing you did.

We also gave the v2 plugin its own home in the database. The plugin’s tables live under a dedicated memory_plugin schema rather than scattered across the workspace-server’s main tables — isolated in core #1742. The motivation was boring and correct: a memory subsystem that owns its own schema can evolve its storage without fighting the rest of the workspace-server’s migrations, and the blast radius of a bad memory migration stops at the schema boundary.

The boundary is the product

Here’s the part that took the most care. A memory model that follows the org tree is only trustworthy if the tree boundaries are real boundaries. The whole value proposition — “your tenant’s memories never reach another tenant” — collapses the instant one query reads across the wrong edge.

We learned this the expensive way. Several workspace-server paths computed an agent’s set of org-root siblings as WHERE parent_id IS NULL. That predicate looks like “everything at the top of my org.” It is actually “every tenant’s org root in the entire table,” because the workspaces table had no org_id column to scope by. Three paths — peer discovery and A2A routing among them — were reading across tenant lines. We scoped them to the caller’s own org in core #1954. The lesson there is general and worth stating plainly: in a multi-tenant system, the absence of a scoping column is itself the bug. A WHERE clause that can’t name the tenant will always, eventually, match all of them.

The GLOBAL (org:) scope is where this gets sharpest, because GLOBAL is the scope with the widest reach and therefore the one an attacker would most want to reach. The control plane can legitimately operate at global scope; a tenant agent generally cannot write or read arbitrary global memory through the MCP tools. When that boundary refuses an operation, it refuses cleanly — it returns a scrubbed error rather than leaking the internal reason for the denial. We turned that scrub into an explicit, tested contract (OFFSEC-001) on both the write path and the read path: core #680 for commit_memory, and core #693 and core #725 for recall_memory. Before, the tests only asserted “an error came back.” Now they assert “the error is scrubbed” — which is the thing that actually matters, because an unscrubbed denial reason is a side channel.

The honesty cost of building this boundary in Postgres is bookkeeping that has to be exactly right. pgvector queries are built with positional placeholders, and the scoping logic shifts those positions around. We shipped a regression — OFFSEC-004, core #832 — where a linter flagged a placeholder-index increment as an “unused variable” and a well-meaning cleanup removed it. The variable was used: it was interpolated into the SQL string itself, somewhere the linter couldn’t see. Restoring the idx++ fixed the dual-field namespace patch. The takeaway we wrote down: a linter that can’t see into your query strings should not be trusted to tell you what’s dead in code that builds query strings.

What this buys an agent team

With the model in place, the day-to-day behavior is the kind of thing that’s invisible when it works. An agent finishes a task, writes a one-line note to its workspace: scope, and that note surfaces semantically the next time a similar task comes up — without polluting anyone else. A team lead agent records a convention at team: scope and every sibling recalls it. The control plane seeds an org: fact and the whole tenant inherits it. Nobody passes context around by hand, and nobody sees a sibling tenant’s secrets.

The thing we’d emphasize to anyone building in this space: scope is not a feature you bolt on after the memory works. It’s the shape of the thing. We got the most leverage by making scope fall out of the address (the prefix is the kind), by making recall semantic so agents actually find what they stored, and by treating every cross-scope edge as a boundary that has to fail closed and fail quietly. The features were a few hundred lines. The boundary was the work.

If you’re modeling memory for a fleet of agents, start from the question “who is allowed to see this?” — and make sure your storage layer can actually name that set. If it can’t, that’s the first thing to fix, before anything reads or writes a single byte.