Three isolation models, in ascending order of both strength and setup cost. Choose deliberately: the difference between them is where the guarantee lives.

Mode A — application-scoped

%Foresight.Context{tenant_id: "default", mode: :mode_a, bank: "notes"}

One schema. Every query is scoped by bank in application code. Fine for a single tenant, or where "tenants" are your own subsystems and a bug leaking between them is embarrassing rather than serious.

The guarantee lives entirely in Foresight's query construction. If that is not enough for your data, it is not enough.

Mode B — row-level

Rows carry a tenant column and queries are filtered on it. Still application code, but the boundary is explicit and auditable in the data.

Mode C — schema-per-tenant with enforced RLS

%Foresight.Context{tenant_id: "acme", mode: :mode_c, bank: "notes"}

Each tenant gets its own PostgreSQL schema. Connections SET ROLE to a tenant-specific role for the duration of the work and RESET ROLE afterwards, re-entrantly. Tables carry ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY.

Here the guarantee lives in the database. A bug in Foresight's query building does not breach it, because the database refuses to return rows the connection's role cannot see.

Provisioning from an in-BEAM consumer

Use the public role-neutral provisioning seam:

{:ok, %{tenant_id: tenant_id, prefix: prefix, migrated_versions: versions}} =
  Foresight.Tenancy.provision_mode_c_schema(
    MyApp.Repo,
    "acme",
    "tenant_acme",
    rls_enabled: true
  )

The call first installs or upgrades Foresight's shared control plane, then creates or rechecks the tenant schema from packaged tenant migrations. It is idempotent. :rls_enabled is mandatory and must be true or false; there is no application-environment fallback at this boundary.

Historical public-schema data repairs are classified separately and are not replayed into newly provisioned tenant schemas. The public schema is reserved for the host application and cannot be used as a Mode C tenant prefix.

This seam is deliberately authority-neutral: it starts no tenant runtime, creates no PostgreSQL role, and writes no tenant API key. A host that owns role or credential lifecycle must do that itself. Foresight's HTTP tenant lifecycle remains available for deployments that intentionally want those side effects.

The same explicit rls_enabled: option may be passed to public memory operations. A per-call false overrides an application default of true, including the parallel recall strategies and nested reflect calls. Async storage, migration, and webhook jobs persist only this boolean and restore it in the worker; no Repo credentials or other caller options are serialized.

The part that is easy to get wrong

RLS does not apply to superusers, and it does not apply to a table's owner. If your application connects as postgres, Mode C is decorative — you have configured a guarantee that is not in force, and every test will still pass.

The application role must be non-superuser and must not own the tenant tables. Getting there requires five distinct things, documented in MODE_C_LEAST_PRIVILEGE_RUNBOOK.md in the order they actually fail, with the real error strings. Setting it up is genuinely fiddly and the runbook exists because we did it wrong five times first.

Verifying rather than assuming

VICTIM_API_KEY=... elixir scripts/mode_c_isolation_proof.exs

Five forged-header cross-tenant attacks against a running instance over real HTTP. It checks the attacker's responses for the victim's canary string rather than trusting status codes — a 200 with empty results and a 403 both look like "blocked" from the outside, but only one of them means the data stayed put.

  • exit 0 — attacks blocked, and the script confirmed it could have detected a breach
  • exit 2 — INCONCLUSIVE. Not a pass. The self-check did not confirm, so the run proves nothing.

Last verified run: rolsuper = false, RLS forced, 5/5 blocked.

A continuous check runs in mix ci too: mix foresight.isolation_coverage fails the build if the isolation tests are tagged out of the default lane or the suite is empty. A silently-skipped isolation probe reads as coverage while providing none, which is worse than having no probe at all.

Choosing

Mode AMode BMode C
Guarantee enforced byapp codeapp codePostgreSQL
Setup costnonelowreal
Survives a Foresight bugnonoyes
Suitable for others' personal datanodependsyes