Notes

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:

ControlWhat it stopsHow it is verified
REVOKE on visible_event_idsOrdinary sessions probing records across tenantsDeploy-time has_function_privilege check
Deploy-time privilege checkThe REVOKE silently vanishing after a drop-and-recreateRuns on every release; re-checked at runtime by tests
Session-bound identity (auth.uid())"Can user X see this?" oracle queriescan_see_* functions take no user parameter
Write-policy gatingBlind writes onto records you were never shownPolicy audit aborts the deploy on drift
PII-free denial chokepointCross-tenant probing going unnoticedRate-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.

Related posts