Tenant isolation: a leak-proof AI agent surface
Your AI holds a token; tenant isolation means it can only ever reach your data. The architecture that makes a cross-tenant leak fail CI, not slip review.
When you connect your AI to Historis over the MCP interface, it holds a token and acts on its own. The single property the product cannot get wrong is this: that token can only ever reach your organization's data. Here is the architecture that enforces it and, more importantly, that makes a regression fail the build instead of depending on a reviewer remembering to check.
How are reads scoped so an agent only sees your data?
Every read resolves which records you are allowed to see before it touches your data. This happens in a Postgres function, visible_event_ids(user, org) (and its sibling for people), which returns the set of ids in scope:
const { data } = await supabaseAdmin.rpc('visible_event_ids', {
p_user_id: userId, // from the verified token, never the request body
p_org_id: organizationId,
})
query.in('id', data) // the search/read is constrained to this set
visible_event_ids is SECURITY DEFINER and revoked from the authenticated and anon roles. Only the backend service role can call it. That single REVOKE is the load-bearing control: an ordinary database session can't call visible_event_ids(someone_else, some_other_org) to probe what exists across the whole system. Both ids come from the verified token, and nothing the caller sends can override them.
The boundary fails CI, not code review
A REVOKE is one line. Any later migration that drops and recreates the function (a signature change does exactly that) would silently re-grant it to everyone: newly created functions default to EXECUTE for PUBLIC. Relying on a reviewer to notice is exactly the kind of control that eventually fails. So the boundary is asserted at deploy time. If either function ever becomes callable by a normal session, the migration aborts.
DO $$
BEGIN
IF has_function_privilege('authenticated',
'public.visible_event_ids(uuid,uuid)', 'execute')
OR has_function_privilege('anon',
'public.visible_event_ids(uuid,uuid)', 'execute') THEN
RAISE EXCEPTION
'visible_event_ids must be service_role-only';
END IF;
END $$;
That deploy-time check is one of a small family of invariants. Another scans every write policy on visibility-scoped tables and aborts if one no longer references the visibility check, so a policy can't quietly drift back to a weaker "same organization" rule. These run on every release, and a test suite re-checks the same invariants at runtime. Tenant isolation stops depending on anyone's memory; the build refuses to let it break.
Identity comes from the session alone
The functions that RLS policies call, can_see_event and can_see_person, take a single argument: the record. The caller's identity is bound to auth.uid(), the id inside their verified session. There is deliberately no second "on behalf of which user?" parameter. A caller can ask "can I see this record?" but never "can user X see this record?", and that is what closes search and lookups as an oracle for probing other tenants' data.
Are writes scoped too, or only reads?
Read scoping is not enough. Linking a tag or a person to a record you cannot see has to be rejected as well. Otherwise you could blind-write onto a colleague's private record you were never shown. So the write policies on the link tables are gated by the same can_see_* check as reads rather than by a looser "same organization" predicate. Reads and writes share one definition of visibility.
The agent surface gets a tripwire
Autonomous agents holding valid tokens are the surface most worth watching. Every MCP denial routes through a single chokepoint that emits a deliberately PII-free signal. It records the tool, the user, and the org, but never the probed id or the token:
function deniedNotFound(tool, userId, organizationId, message) {
captureAccessDenied('mcp_entity_not_visible', {
userId, organizationId, url: 'mcp:' + tool,
})
return errorContent(message) // the agent just sees 'not found'
}
"Not allowed to see it" and "doesn't exist" are returned identically, so the response leaks nothing. But every denial passes through one place with a rate-based alert on it, so a burst of cross-tenant probing gets noticed without anyone combing the logs.
Why is this the credible version of tenant isolation?
None of these mechanisms is exotic on its own: RLS, SECURITY DEFINER, session-bound identity, write-side policies. What makes the isolation trustworthy is that the boundary is enforced at the database and re-verified by the build on every release. A write policy on a covered table that drifts back to a weaker rule doesn't get a review comment; it fails the deploy.
Adding a new visibility-scoped table means registering it in the audit's coverage list, the one hand-maintained step. Where a self-hosted alternative like Twenty puts that isolation work on you, here it is machine-checked in a managed EU single-region store where your data is never used to train a model.
The controls at a glance:
| Control | What it stops | How it is verified |
|---|---|---|
REVOKE on visible_event_ids | Ordinary sessions probing records across tenants | Deploy-time has_function_privilege check |
| Deploy-time privilege check | The REVOKE silently vanishing after a drop-and-recreate | Runs on every release; re-checked at runtime by tests |
Session-bound identity (auth.uid()) | "Can user X see this?" oracle queries | can_see_* functions take no user parameter |
| Write-policy gating | Blind writes onto records you were never shown | Policy audit aborts the deploy on drift |
| PII-free denial chokepoint | Cross-tenant probing going unnoticed | Rate-based alert on the single denial path |
For an agent acting on its own with your token, that is the difference between "this should be fine" and "this can't regress without turning the pipeline red." The scope model itself keeps evolving (unifying how creator-owned records surface across teams is ongoing work), but the invariant is the floor everything else is built on: your token reaches only your data, and that property is machine-checked.
Related: how search works over the MCP, which runs entirely inside this visibility scope.
Frequently asked questions
- Can one organization's AI ever read another organization's data in Historis?
- No. Every read resolves the caller's visible records through a SECURITY DEFINER Postgres function only the backend can call, scoped to the user and organization taken from the verified token. A deploy-time check fails the build if that function ever becomes callable by an ordinary database session.
- Is tenant isolation enforced for writes too, or only reads?
- For writes too. The write policies on link tables are gated by the same can_see_event / can_see_person visibility check as reads, so you cannot link a tag or a person to a record you were never shown.
- What stops search from being used to probe other tenants' records?
- Identity is bound to the session (auth.uid()), never passed as a parameter, and "not allowed to see it" is returned identically to "does not exist." Every denial also routes through one PII-free chokepoint, so a burst of cross-tenant probing trips a rate-based alert.
- Where is the data stored, and is it used to train AI?
- It lives in a managed EU single-region store and is never used to train any model. There is no on-premise install: what you get is sovereign inference (point your own self-hosted model at it over MCP) with EU-resident storage.