Skip to content

19 min read

Thread, user and organisation are three different owners

On this page

A support agent at a logistics software vendor had memory switched to a single scope after a security review, on the reasoning that everything belonging to one person is the conservative choice. Within eleven days two separate escalations landed on the same team. In the first, a network engineer at Northwind Freight opened ticket 44812 about a failing VPN tunnel, worked through it with the agent for forty minutes, and closed it. Three weeks later the same engineer opened ticket 45930 about a printer, and the agent's second message said "since restarting the router did not resolve this last time, let us skip that step" — a fact that had been true about a VPN tunnel in a different building and was now steering a printer diagnosis into a dead end. In the second, Northwind's account manager had recorded during onboarding that any P1 — the vendor's label for its most urgent class of incident — for this customer goes straight to a named on-call engineer and never to the standard queue. That fact was stored against the account manager's user record. When a different Northwind employee filed a P1 at 02:40, the agent had no idea the policy existed and routed the ticket to the overnight queue, where it sat for five hours against a contract that promised thirty minutes.

Both incidents came from the same decision, applied once, in opposite directions. The router fact was scoped too widely: it belonged to one troubleshooting session and should have stopped existing when that session closed. The escalation policy was scoped too narrowly: it belonged to the company and was locked inside one employee. Neither was a bug in retrieval, neither was a conflict of the kind 10.12 deals with, and no amount of decay tuning would have fixed either one. They were ownership mistakes, and ownership is a property you assign when the record is written.

Underneath both of them is scope, which is the label attached to a stored fact recording who owns it and, following from that, who is allowed to read it back. Scope has to exist because the model at the centre of an agent holds nothing between requests: each call sees only the text it is handed, so anything the agent appears to remember from three weeks ago was written into a database by your code at the end of one conversation and read back out by your code during another. The write is the moment ownership gets decided, and the scope is that decision recorded on the row. Every product that remembers anything about its users has already made the decision, usually without discussing it, because the moment a memories table exists somebody has chosen whether a row is keyed by conversation, by person, or by customer account — and at the logistics vendor every row was keyed by person. This chapter is about making that choice on purpose, and about the second thing the choice decides, which is who else can see what was written.

Why the decision cannot be postponed to read time

The claim that ownership is fixed at write time is the one everything below rests on, and it is worth walking rather than asserting, because the alternative belief — that scope is a filter you apply when reading — is both common and the reason teams look for these bugs in the wrong place.

Start from what the model at the centre of an agent actually is. It is a function from text to text, invoked once per turn, holding nothing at all between invocations. There is no process that stays alive between Tuesday's conversation and the one three weeks later. So when the agent appears to recall something from the earlier session, the only mechanism available is that the earlier fact was present, as text, in the input of the later call. Text does not travel between calls on its own. It was placed there by your code, which read it out of a database, which had it because your code wrote it there at the end of the earlier conversation.

That write is the step where ownership gets decided, and it gets decided whether anybody thinks about it or not. A row has to go somewhere. Somewhere means a key, and a key names an entity: this thread, this user, this organisation. Even a schema that has only a user_id column has answered the question — it has answered the person, always — and the logistics vendor's two escalations are what that answer costs when the fact was about a case in one instance and about a company in the other.

Two consequences follow, and both are checkable. The first is that a scope error is invisible to the retrieval layer, because retrieval sees a row with a key on it and has no way to know the key is wrong. The router note was fetched correctly: it carried the engineer's user id, the engineer was in the conversation, and every predicate matched. Tuning similarity thresholds, decay half-lives or reranking cannot repair a row whose key names the wrong owner, since all of those operate on rows that already exist and take the key as given. The second is that the repair is a migration rather than a deploy. Changing the rule changes what happens to writes from now on, and the rows already written keep whatever key they were given.

The two questions that are not the same question

Two questions decide where a memory lives, and they are not the same question. Scope answers both under one name, and they come apart often enough to matter.

The first is who the fact is about — whose behaviour, whose preference, whose contract it describes. Call that the subject. The second is who is permitted to read it, which is the authorisation boundary. Spelled out, an authorisation boundary is the set of principals a read may be served to. Most of the time these line up, and a schema that stores only the subject gets the right answer by accident. The reason scope design is worth a chapter is that when they come apart, the schema that ignored the difference has no way to express the answer.

They come apart in both directions, and one instance of each is enough to see what that costs. Running the other way, a thread-scoped record has a subject narrower than its audience: the router note is about one investigation, and everyone working that ticket may legitimately read it, including a second-line engineer who never spoke to the customer. Running the first way, an organisation-scoped record can have an audience narrower than its subject. A fact about the customer's production database — where its credentials live, which host it runs on — is about the company by every test below, and it should not be visible to every employee of that company. The subject says organisation; the authorisation boundary says eleven people in the platform team. A schema with one field for both will store the subject and silently grant the audience, which is how a correctly scoped record becomes a disclosure.

Keeping the two apart costs one extra field and buys the ability to state that combination at all. Chapter 2 works the case where the divergence is about a person rather than a system, and chapter 3 covers the paths that never consult either field.

Three owners, and a test for each

Three owners cover the overwhelming majority of what an agent needs to remember, and it helps to name them by the entity that owns the record rather than by the storage they end up in.

Thread scope belongs to a single conversation or case. Its lifetime is the case's lifetime, and its authorisation boundary is whoever can see the case — which for a support ticket may be the requester plus any agent handling it. The router fact is thread-scoped: it is a statement about the state of one investigation, and it is meaningless outside it. So are the working notes an agent keeps while it reasons, the intermediate results of a multi-step tool sequence, and the user's phrasing of the problem before it was clarified.

User scope belongs to a person and follows them across every conversation they have. Its authorisation boundary is that person, plus whatever internal roles your policy permits — a support engineer debugging their session, for instance. Preferred language belongs here. So does "prefers terse answers", "is on the Linux client", and "has already been shown the migration guide twice". These are facts about a human being, and they should outlive any single case because their usefulness is precisely that they do not have to be re-established every time.

Organisation scope belongs to the account, and the test is whether the fact would still be true if every current employee left. The contracted support tier passes that test: it is a term of a commercial agreement between two companies, it constrains every conversation with anyone at that company, and it does not belong to the person who happened to mention it. The escalation policy passes too. So do the customer's deployment topology, their contracted maintenance window, and the region their data must stay in.

The three tests are one test applied at three radii, and stating it that way makes the assignment mechanical rather than a matter of taste. Ask what has to change for the fact to stop being true. If closing the case is enough, the case owns it. If the person leaving is enough but closing the case is not, the person owns it. If neither is enough — the fact survives the case, survives the person, and only changes when the commercial relationship changes — the account owns it. Applied to the router note, starting from the smallest candidate, closing ticket 44812 ends it, so the ticket owns it. Applied to the P1 routing rule, the account manager could resign tomorrow and a P1 filed the following week would still have to go to the named on-call engineer, so the account owns it.

Why a shared table makes this a security question

The privacy half of the problem comes from a second fact about that table. Business software is normally multi-tenant, meaning that one deployment, one database and one search index hold the records of every customer side by side, with each customer's slice of them called a tenant. Northwind's rows sit alongside the rows of every other company the vendor sells to.

That arrangement changes what a coarse key can do. In a single-customer deployment, a key chosen too widely produces a wrong answer: the agent applies one employee's finding to a colleague who did not want it. In a shared deployment the same mistake can reach past the account entirely, because the storage does not stop at the account boundary — nothing physical separates Northwind's rows from anyone else's, and the only thing that does is the predicate in the query. That makes scope a security question as well as a quality one: a key chosen too coarsely can put one customer's remembered facts in front of another customer's employees, and that is an incident rather than a bad answer. It is the reason the rest of this topic treats scope as a control rather than as a quality setting.

What breaks in each direction

The table below is the artefact worth keeping from this chapter, because it forces the question that gets skipped: for each record, what happens if you are wrong in each direction rather than just the one you were worried about.

RecordCorrect ownerScoped too narrowlyScoped too widely
Preferred language: GermanUserRe-asked every ticket; the agent replies in English on the second conversation because the preference died with the firstThread's German preference applied to a colleague who reads only Polish; at org scope, one German speaker makes the agent German-only for the whole account
P1s route to named on-call engineerOrganisationInvisible to every colleague; the 02:40 P1 waits five hours against a 30-minute service-level agreement (SLA)At a global scope, another customer's tickets route to Northwind's on-call engineer, who now sees ticket titles from a company he has no relationship with
"Restarting the router did not fix it"ThreadNothing breaks; the fact was only ever needed inside the case, and losing it early costs at most one repeated questionResurfaces three weeks later on a printer ticket and steers the diagnosis wrong; at org scope, one employee's failed step suppresses that step for everyone
Contracted support tier: PremiumOrganisationOnly the person who mentioned it gets premium behaviour; everyone else at the same customer gets standardApplied to a different customer on the standard tier, which is both a billing error and a disclosure of the first customer's commercial terms

The way to read that table is across each row rather than down each column, because what the design needs from it is not how bad a row is but which of its two failures is worse and by how much. The gap is the output. On the third row the gap is enormous and points one way: too narrow costs one repeated question, recoverable inside the same conversation, while too wide costs a misdiagnosis three weeks later on an unrelated case. On the second and fourth rows the gap points the other way, since a policy nobody can see and a support tier honoured for one employee are both failures of the feature's whole purpose. On the first row the gap is small in both directions, which is why preferred language is the record teams argue about least.

That reading also explains why the instinct that produced the incident at Northwind is so common. Over-scoping fails loudly and embarrassingly, so engineers who have seen it once develop a reflex to narrow everything, and narrowing looks free because its failure mode is quiet. What actually disappears when you over-narrow is the class of fact that has to be shared to be worth anything at all. An escalation policy known to one person is not a policy. A support tier stored per-user is a support tier the agent will honour for one employee and deny to their colleague, and the customer will experience that as the vendor arbitrarily changing the rules.

It is worth naming precisely what the reflex is getting wrong, because "narrow is conservative" is a defensible-sounding rule that fails on a specific class of record rather than at random. Narrowing holds one quantity constant: the number of principals who can read any given row goes to one, and one reader is the safest count there is. It is the safest count for every record, which is exactly the problem. The property that actually varies from record to record is how many readers the fact needs in order to be worth storing, and that number is one for an observation about a case, one for a personal preference, and every employee of the account for a term of the contract. A control that fixes the reader count at one is measuring nothing about the records it is protecting; it returns the same verdict whether the fact was a note about a router or a thirty-minute response commitment. The escalation policy stored at user scope was not partially useful. Its value was zero, and the store looked healthy the whole time, because a row that is never read the way it needed to be read produces no error.

The row that is worth staring at is the third one, because the asymmetry in it is the one that generalises. Thread scope is the only one of the three where the too-narrow failure is genuinely mild and the too-wide failure is severe and delayed. That means thread-scoped facts should be the default for anything an agent learns during problem-solving, and promotion to user or organisation scope should be a deliberate act with a reason attached, rather than the natural end state of everything a conversation produced. The rule has a stated limit, which the second and fourth rows supply: it holds for observations and stops holding for terms. A fact that arrived as a term of the relationship — a tier, a routing policy, a data-residency requirement — was never thread-scoped in the first place, and defaulting it to thread scope destroys it on close.

Making the scope a key, not a column you remember to filter on

A scope that exists only as an argument some function is supposed to pass will be forgotten. The layout that survives contact with a real codebase makes the scope part of the record's identity, so that a read without a scope is not a read that returns too much — it is a read that does not typecheck.

python
from dataclasses import dataclass
from enum import Enum

class Scope(str, Enum):
    THREAD = "thread"
    USER = "user"
    ORG = "org"

@dataclass(frozen=True)
class Namespace:
    """Fully qualified owner of a memory record. Always includes org_id,
    even at thread and user scope, so that no read can be constructed
    that is not already constrained to one tenant."""
    org_id: str
    scope: Scope
    owner_id: str  # thread_id, user_id, or org_id depending on scope

    def as_key(self) -> tuple[str, ...]:
        return (self.org_id, self.scope.value, self.owner_id)

def thread_ns(org_id: str, thread_id: str) -> Namespace:
    return Namespace(org_id, Scope.THREAD, thread_id)

def user_ns(org_id: str, user_id: str) -> Namespace:
    return Namespace(org_id, Scope.USER, user_id)

def org_ns(org_id: str) -> Namespace:
    return Namespace(org_id, Scope.ORG, org_id)

The tuple those constructors return is the record's namespace: the full name of its owner, made of the tenant, the scope and the identifier of whoever within that tenant the record belongs to, which every read and every write has to supply. org_id is the customer account, scope is one of the three values above, and owner_id is a thread id, a user id or the account id again, depending on which scope was chosen — which is why org_ns passes the same string twice.

The detail that earns its place here is org_id appearing in all three constructors, including the thread and user ones where it is technically redundant given that a thread belongs to exactly one organisation. Redundant is the right word for the information and the wrong word for the mechanism. If the tenant is only implied by the thread id, then every query that wants to be tenant-safe has to recover it, which means a join or a lookup, which means a code path that can be skipped and a code path that can fail. Carrying it means the tenant identifier is present in every key the store ever sees, so the partition boundary is structural rather than derived.

The order inside the tuple is doing work too, and it is easy to write down backwards. Stores of this shape support listing, searching and deleting by prefix — supply the first element and you get everything beneath it. With the tenant first, every prefix that names anything at all has already named a tenant, so the shortest possible over-broad query is "all of one customer's memories", which is a query you might legitimately want. Put scope first instead and the shortest prefix becomes "every thread-scoped record in the system", which spans every customer on the platform and is a query nobody should be able to express by omission. The ordering makes the dangerous query unwritable rather than merely discouraged. Stores designed for this — LangGraph's BaseStore namespaces are the clearest example, and the Long-term memory in LangChain documentation shows the same tuple-prefix idea in a working store — accept exactly this kind of hierarchical prefix, which is what makes the later chapters on isolation testing and deletion tractable.

Retrieval then assembles from all three namespaces and labels what it found, because the agent's prompt needs to know that "P1s go to the named on-call engineer" is an account-level rule and "restarting the router failed" is a note from the current case. Mixing them into one undifferentiated block of remembered facts throws away the distinction you just spent the schema on, and it makes the conflict resolution from 10.12 harder, since a thread-scoped observation and an org-scoped policy that appear to disagree usually are not disagreeing at all.

Scope is not sensitivity, and it is not lifetime

Three neighbouring ideas get folded into scope and should not be.

Scope is not a sensitivity classification. A record's owner tells you who it is about; it says nothing about how damaging disclosure would be. An organisation-scoped fact can be entirely public — the customer's product tier may be on their own website — while a thread-scoped fact can be the most sensitive thing in the system, as when a user pastes a production database password into a troubleshooting session. Chapter 2 takes up the case where org scope and disclosure risk pull in opposite directions, which is where the account boundary stops being sufficient.

Scope is not lifetime. It correlates with lifetime, since thread-scoped records usually die with the thread, but the two are independent axes and 10.12 already established that decay is its own mechanism. A user-scoped preference may be permanent while a user-scoped observation about their current project should expire in ninety days, and both live in the same namespace.

Scope is also not the document-level permission filtering from 9.15. That machinery answers whether this principal may retrieve this source document, and it operates over content the organisation already owned before the agent existed. Memory scope governs records the agent itself wrote, derived from conversations, with no pre-existing ACL to inherit. The two systems have to agree — memory derived from a document the user could read may still be readable, memory derived from one they could not should never have been written — but they are separate enforcement points, and assuming that permission-aware retrieval covers your memory store is how memory becomes the unguarded copy of everything the guarded store was protecting.

Assigning owners is the design step, and the test that does it is a counterfactual you can evaluate at the keyboard: name the smallest thing whose ending would make the fact stop being true, and let that thing own the record. What the test hands you is a subject and nothing else. It is silent on how sensitive the fact is, silent on when it should expire, and silent on who among the subject's own people may read it — three residuals that the rest of this topic has to supply separately, because none of them can be recovered from a key.

The first of those residuals is the one that bites soonest. An owner and an authorisation boundary do not always coincide at organisation scope, where the account is a commercial entity containing people who did not consent to each other reading their conversations. That is the case where a correct scope assignment can still produce a disclosure incident, and it needs its own chapter.

Memory — scope and privacy — read the current version at https://daviddominguez.dev/learn/en/memory-scope-and-privacy/thread-user-and-organisation-are-three-different-owners

Back to top