Back

Four Layers of What an Agent Knows

By Pablo Fernandez14 min read

Ask Suma, the assistant inside Sumalos, to draft an invoice for Talleres García, and the answer leans on four people who have never met. Someone at Sumalos wrote down the VAT rates in force, with the article of the law they come from. A colleague at the company once mentioned that García always pays at 60 days. The person typing likes short answers. And the company's past invoices sit in the database, readable by some members and not by others.

Each of those facts has a different author and a different audience. The VAT rate is true for every customer of the product. The payment term is true for one company, and only for the people there who handle customers. The preference for short answers belongs to one person, whichever company they happen to be working in. Yet Sumalos runs one agent, one model and one Postgres database for all of them.

So the question this post answers: how do you stack what an agent knows, so that one reply can draw on every layer, while no layer leaks past the people it belongs to?

There are two obvious designs. One is a store per layer: a knowledge base, a memory service per tenant, a vector index per person. The other is one big index with metadata filters. Sumalos uses both, and the line between them turned out to be a single question: who writes the layer?

The four layers

Suma's knowledge splits into four layers. Three of them are memory, facts that people told it. The fourth is what the platform itself knows and backs with sources.

LayerBelongs toWritten byRead by
Personal memoryOne person, in any organizationSuma as it learns, and the personOnly that person
Private memory in an organizationOne person, in one organizationThe sameOnly that person, there
Shared memoryThe organizationWhoever may edit its area (or as a proposal)Members who may read that area
System knowledgeThe platform, globalStaff onlyEveryone, published entries only

The organization's own records (invoices, contacts, expenses, uploaded documents) sit next to shared memory: the same audience, the same area rules. I'll come back to them.

┌───────────────────────────────────┬──────────────────────────┐│ personal memory │ memories ││ one person, every organization │ no organization_id │├───────────────────────────────────┼──────────────────────────┤│ private memory in an organization │ memories ││ one person, one organization │ organization + owner │├───────────────────────────────────┼──────────────────────────┤│ shared memory and records │ memories, search_*, ││ one organization, by area │ invoices, contacts ... │├───────────────────────────────────┼──────────────────────────┤│ system knowledge │ ai_knowledge_* ││ everyone, written by staff │ no organization at all │└───────────────────────────────────┴──────────────────────────┘
The layers from the widest audience at the bottom to the narrowest at the top, with the table each one lives in.

The right-hand column is the whole design in one glance. The bottom layer has tables of its own. The top three share one table, and what keeps them apart is a predicate.

Its own tables, because staff write it

System knowledge is curated by the Sumalos team: tax rules for Spain first, in a domain called impuestos_es. Each entry is a markdown article with sources (the consolidated text in the BOE, the tax agency's site), a draft or published status, a version history, and validity dates. Next to the prose sit typed facts, the values the model must get exactly right: iva.tipo.general, irpf.retencion.profesional, the filing deadline of a quarterly return.

None of that looks like memory. A memory is a fact someone mentioned in a chat, saved in the background, with a confidence. A knowledge entry is written on purpose, reviewed, cannot be published without a source, and changes when the law does. A different writer and a different lifecycle, so it gets different tables (ai_knowledge_domains, _entries, _facts, _chunks, _versions), and none of them has an organization_id column. Isolation between tenants is trivial here because there's nothing tenant-shaped in them.

What does need protecting is the gap between staff and everyone else. Drafts exist (three of the 27 seeded entries are drafts waiting on a source), and the assistant must never read one. The agent reads its data through a restricted Postgres role with row-level security (how that role works is its own post), and these are the policies the knowledge tables get:

packages/db/drizzle/0054_system_knowledge.sql
SELECT ai_rls_protect('ai_knowledge_domains', 'true');
SELECT ai_rls_protect('ai_knowledge_entries', 'status = ''published''');
SELECT ai_rls_protect('ai_knowledge_facts', 'EXISTS (SELECT 1 FROM ai_knowledge_entries e WHERE e.id = entry_id)');
SELECT ai_rls_protect('ai_knowledge_chunks', 'EXISTS (SELECT 1 FROM ai_knowledge_entries e WHERE e.id = entry_id)');
SELECT ai_rls_protect('ai_knowledge_versions', 'false');

The facts and chunks policies look like they let everything through, and that's the trick: the subquery on ai_knowledge_entries runs under that table's own policy, so a fact is visible only when its entry is published. Versions are never visible. The role gets SELECT and nothing else, so even a bug that tried to write would get "permission denied". The test for this layer reads the published entries as a principal with no organization at all, asks for the draft by id and gets nothing, and tries an update, an insert, a delete and a read of the versions, all four refused.

A layer that knows what day it is

Tax law changes every year, so every entry, chunk and fact carries valid_from and valid_to, and the assistant asks for the rules in force on the document's date, not today's. The same key has one row per period: the general withholding for professionals was 19% until 11 July 2015 and 15% from the next day, so a question about an invoice from March 2015 gets 19%, and one about today gets 15%. Saving two values for one key over the same period is refused.

Retrieval is the same hybrid search the organization's data uses: BM25 through pg_search, trigrams, and quantized vectors, fused with reciprocal rank fusion, with the validity filter inside each scan. One wrinkle: pg_search can't score a scan filtered by row-level security, so the BM25 ranker runs as the app user and returns chunk ids only; the text is then read under the restricted role, so a chunk the policies hide never reaches the model. On the 32 test questions in the repo (colloquial Spanish, typos, past dates), measured on 19 October 2026, the fused retrieval found the expected entry in the top five for all 32, with an MRR of 0.938, and returned the exact fact in all 23 questions that have one.

Some facts aren't owned by this layer at all. VAT rates, withholdings and the equivalence surcharge are facts of the rules engine, and the knowledge reads them through a view, knowledge_facts, that unions its own facts with the engine's published ones. Saving one of those keys in the knowledge is refused. A rate lives in one place, whichever layer asks.

One table, because only the reader changes

The other three layers have the same writer: a person in a chat, through Suma. What changes is who may read the result. So they share one table, memories, and a row says which layer it belongs to with four columns: visibility, organization_id, its owner (owner_user_id, or owner_phone for a WhatsApp number with no account), and area.

The area is the organization's permission vocabulary (contacts, invoices, expenses, and general for everything else). A memory about a customer is contacts; one about a supplier is expenses; what the company does is general. The person asking arrives as an access object the server builds from the session or the WhatsApp number: who they are, which organizations this request may touch, and in each one the areas they may read and write. Neither the model nor the browser picks any of it, and the tools follow the same object.

From that object, every query on these tables gets the same filter:

packages/server/src/ai/data/access.ts
export function scopeSql(t: string, access: AiAccess): SQL {
  const col = (name: string) => sql.raw(`${t}.${name}`);
  const own = ownerSql(t, access);
  const branches: SQL[] = [sql`(${col("visibility")} = 'personal' AND ${own})`];
  if (access.organizationIds.length) {
    branches.push(sql`(${col("visibility")} = 'private_in_org' AND ${col("organization_id")} IN (${list(access.organizationIds)}) AND ${own})`);
    for (const id of access.organizationIds) {
      const areas = ["general", ...(access.permissions[id]?.read ?? [])];
      branches.push(sql`(${col("visibility")} = 'shared' AND ${col("organization_id")} = ${id}::uuid AND ${col("area")} IN (${list(areas)}))`);
    }
  }
  return sql`(${sql.join(branches, sql` OR `)})`;
}
row (visibility, organization_id, owner, area) │ ├── personal ───────▶ owner is the principal │ ├── private_in_org ─▶ owner is the principal │ AND organization is in this request │ └── shared ─────────▶ organization is in this request AND area is general, or readable there
A row is visible when one of the three branches matches; each branch pins either the owner, the organization, or both.

Two properties fall out of those branches. A personal row has no organization, so it follows the person into every company they work in, and that is the one branch that can match outside the request's organizations; it can only ever match the principal's own rows. And no branch ever reaches another organization's private or shared rows, because each one either fixes the organization to one in the request or demands the row be the principal's.

The same predicate is written a second time, in SQL, as the row-level security policy on the table:

packages/db/drizzle/0041_ai_rls.sql
CREATE OR REPLACE FUNCTION ai_row_visible(org uuid, vis ai_visibility, area text, owner_user uuid, owner_phone text) RETURNS boolean LANGUAGE sql STABLE AS $$
	SELECT CASE vis
		WHEN 'personal' THEN ai_is_owner(owner_user, owner_phone)
		WHEN 'private_in_org' THEN org = ANY(ai_org_ids()) AND ai_is_owner(owner_user, owner_phone)
		ELSE org = ANY(ai_org_ids()) AND (area = 'general' OR ai_can(org, area, 'read'))
	END
$$;

The assistant's reads run as the restricted role with the request's access set as transaction settings, and the query carries scopeSql too, so a row has to pass both. A query that forgot its filter still sees only what this person may see; if the role is missing, the TypeScript filter still holds, and the process says so loudly at boot.

This is also where the organization's records and documents join the stack without a design of their own. The search index (search_documents, search_chunks) and the conversation log (ai_messages) carry the same four columns and the same policy. An uploaded file is indexed as a shared row of its organization with the file's area, so a supplier's invoice dropped into a chat is found by the members who may read expenses. A conversation is indexed against the person who had it. Four kinds of content, one rule.

Where a new memory lands

Reading is the easy half. The harder question is writing: when Suma learns something in a chat, which layer does it go to? Get it wrong in one direction and a person's private remark becomes company record. Get it wrong in the other and the company forgets what one member told it.

How Suma notices a fact worth keeping belongs to the post on memory across channels. Once it has one, writeMemory decides the layer, and its rule is in a comment: never wider than asked, never shared without the rules.

packages/server/src/ai/data/memory.ts
if (!organizationId) visibility = "personal";
else if (input.scope === "personal") visibility = personalCategory ? "personal" : "private_in_org";
else if (input.scope === "private_in_org") visibility = "private_in_org";
else if (personalCategory) visibility = "private_in_org";
else if (settings.sharedMode === "off") {
  visibility = "private_in_org";
} else if (!canWriteShared(access, organizationId, area)) {
  visibility = "private_in_org";
  status = "proposed";
  proposedVisibility = "shared";
} else if (settings.sharedMode === "confirm" && !input.explicit) {
  visibility = "private_in_org";
  status = "proposed";
  proposedVisibility = "shared";
} else visibility = "shared";
new fact │ ├── no organization in the request ───▶ personal ├── about the person (profile, taste) ▶ personal or private_in_org ├── organization shares nothing ──────▶ private_in_org ├── can't edit that area ─────────────▶ private_in_org, a proposal ├── organization asks to confirm ─────▶ private_in_org, a proposal └── otherwise ────────────────────────▶ shared
The first condition that matches decides where a fact is saved; only the last line reaches the whole organization.

A few things in there carry the segmentation:

  • Facts about the person never become company facts: a profile detail or a preference stays personal, or private inside the organization, whatever the model guessed.
  • The organization chooses how it shares: owners and admins set shared memory to automatic (the default), confirm (business facts wait as proposals until their author confirms them) or off.
  • Permission to read an area is not permission to write it: a member without edit rights on customers can still tell Suma that García pays at 60 days. It's kept as a private proposal, visible to them, until someone with that right shares it.
  • A third party's bank details are their own case: an IBAN for a supplier is saved as a shared proposal marked unverified, so the team sees it exists, and Suma says it is unverified whenever it uses it, until an owner or admin confirms it.

Promotion goes through the same checks. "Only for me" moves a shared memory back to private (its author or a manager can do that). Sharing a private one requires the right to edit its area and an organization that shares at all, and it hands the row over: the owner columns are cleared, so from then on it belongs to the organization, not to the person. Every change to a shared memory goes to the audit log. When two members say different things about the same subject and attribute, the newer value from someone who may write wins, and the old row stays as a superseded version that an owner or admin can restore. When someone leaves an organization, their private memories in it are deleted; what they shared stays, attributed to "a former member".

The isolation test writes five facts as one member and reads them as three people. The author sees all five. A colleague who may read invoices but not expenses sees the company fact and the customer's payment term, but not the supplier fact, nor the author's personal preference, nor their private note. A member of another organization sees nothing.

Stacking them in one answer

Keeping layers apart is half the job; the other half is using them together. The layers reach the model in two ways: some are pushed into every prompt, others are pulled by a tool when the question needs them.

prompt, most stable first pulled by tools when needed┌──────────────────────────────┐│ instructions │ saber ─────▶ system knowledge,│ memory profile │ on the document's date│ summary of older turns │ buscar ────▶ memory first, then│ recent messages │ records, documents, chats│ this turn: context + message │ consultar ─▶ sums over records└──────────────────────────────┘
Memory is pushed into the prompt on every turn; knowledge and records are pulled by tools, each through the same access filter.

The memory profile is pushed because it's small and almost always relevant: up to 20 memories, in at most 2,000 characters, of what Suma should keep in mind about this person and this company, fenced as data. It's the one place the layers literally stack, in one query that reads all three memory layers through the same scope and picks which ones make the cut:

packages/server/src/ai/data/memory.ts
order by m.pinned desc,
  case when m.category in ('perfil_usuario', 'preferencia') then 0 when m.category = 'empresa' then 1 else 2 end,
  case m.visibility when 'personal' then 0 when 'shared' then 1 else 2 end,
  m.confidence * ${decaySql("m")} desc,
  m.updated_at desc

Pinned first, then who the person is and their preferences, then the company, then everything else; inside each group, personal before shared before private, and confidence discounted by age, at a rate that depends on the category (who someone is never fades, an event fades fastest). The 20 that make it are then written into the prompt grouped by layer: personal, shared, private. The profile sits second in the prompt, right after the instructions, so the provider's prompt cache can reuse it across turns.

Next to the profile, each turn adds up to eight memories related to the message, from the same three layers and through the same scope, only those that clear a minimum relevance. How that is changing (a smaller fixed core, and a subject's memories arriving with the record that names it) is in the memory post.

System knowledge is pulled, never pushed. It's large, and most turns don't need a tax rule. The saber tool takes a query and an optional date, and returns passages with their sources and the typed facts verbatim, with their validity.

When the layers disagree

A stack needs an order for conflicts. Suma's is mostly written into its instructions:

  1. Knowledge facts are exact. Use them as they come and never contradict them with anything remembered. If the knowledge doesn't cover the question, say so instead of filling the gap from the model's own training.
  2. The system's data beats memory. If a memory clashes with the organization's records, the records win and Suma says so.
  3. Within memory, the newest allowed value wins. This one is code, not instruction: the older row is marked superseded and stays as history.
  4. The web comes last. Web search is off by default (staff and the organization both have to turn it on), and even then the instructions say to use knowledge and the organization's data first.

Here's the seam. The isolation between layers is enforced by the database, twice. The precedence between them, apart from the conflict rules inside memory, is enforced by a paragraph of instructions, and a model can ignore a paragraph. For the case that matters most there's a deterministic check underneath: when Suma prepares the lines of a draft invoice or quote, taxRateWarnings compares each VAT rate and withholding against the facts in force on the document's date, without a model, and puts a warning in the preview (in Spanish, in the app: "9% is not a VAT rate in force in Spain on this date: the ones in force are 21%, 10% and 4%"). It never blocks; the person reads it before confirming. For everything else, the order is a matter of instruction.

The rule underneath

Looking back, the question that sorted every layer was who writes it. System knowledge has a different writer (staff), a different lifecycle (drafts, versions, sources, validity) and no tenant at all, so it got tables of its own and a policy as simple as status = 'published'. The three memory layers, the organization's records and its documents all have the same kind of writer, so they share one shape of row, and the boundary between them is a predicate written once in TypeScript and once in SQL, tested against a real database in a transaction that rolls back.

The price is that predicate. Every new kind of content has to carry the four columns and the policy, and the two copies of the rule have to stay identical. In exchange, adding a layer is adding a branch, not a service; a fifth layer of content would arrive already isolated by the rule every other row follows. And the knowledge core imports nothing from Sumalos: another project brings its own domain and seed, and gets the same retrieval and the same policies.

Related articles

A Model That Decides for You

Most model calls in a product are multiple-choice questions. A decision model answers in one pass, with a probability. I ran one on a four-core VPS.

Designing a Payments API

Taking a payment is four lines. Making a retry never double-charge and a lost webhook never lose an order is the real work. How I shaped Paybit's API.

Building a Temporary Email Service

A temporary inbox takes mail from anyone, for anyone, with no account. How Buzontemporal runs on Mailingest: no attachments, a light path, hard limits.