# Proving a workspace is isolated, end to end

Isolation is enforced three times over, by three unrelated mechanisms: an allowlist in front of the application, a workspace filter inside every query the application builds, and row-level security inside the database. Verifying it means checking each layer on its own terms, because each one catches a class of mistake the other two are blind to.

- **Status:** Available
- **Audience:** both, developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/workflows/isolation-verification/

## The flow being verified

1. Trigger — somebody asks whether a customer workspace can see or change another's records.
2. User or external event — a request leaves a browser, signed in as one audience, aimed at one capability.
3. Authentication and workspace resolution — the session is resolved to exactly one workspace before any handler runs. No workspace, no further.
4. Ingest — the path is checked against the allowlist for that audience. An unrecognised path is refused rather than interpreted.
5. Canonical record — the handler asks for records; every scoped table carries a workspace stamp that the query filter applies without the handler asking.
6. Reasoning — none in this flow. Isolation is a property of the path, not a judgement.
7. Knowledge, memory and rules — subject to the same scoping; memory is a scoped record like any other.
8. Autonomy and approval — not involved. A permission decides what a person may do inside a workspace; isolation decides which workspace that is.
9. Action through a provider — no provider takes part in an isolation check.
10. Result — the database applies row-level security to the statement it actually receives, whatever the application believed it was asking for.
11. Relationship, timeline and memory — unchanged; nothing is written by a verification pass.
12. Audit, usage and Needs You — a refusal at any layer is a recorded event rather than a silent nothing.

| Stage | What you see | What changes | What can fail |
|---|---|---|---|
| Path check | A refusal on the screen, not a partial page | Nothing | A new capability added to one audience with no equivalent for the other |
| Query filter | An empty list, or no record found | Nothing | A read that never becomes a query, or a table missing from the scoped list |
| Database policy | The same emptiness, from one layer deeper | Nothing | A new table whose policy was not applied in the same release |

## The three layers, and what only each one catches

The layers are worth understanding as three different kinds of mistake being caught, rather than as three copies of one check. If they were copies, one error in the shared idea would clear all three at once.

**The allowlist, in front of the application** — Decides whether this audience may call this capability at all. It is the only layer that can refuse a request before any code that understands the request has run — and the only one that catches a capability being exposed to an audience that was never meant to have it. It knows nothing about rows: inside a permitted capability it has no opinion.
**The workspace kernel, inside the application** — Adds the workspace condition to every query the mapper builds, so a handler that forgets to filter is filtered anyway. It is the only layer that catches an ordinary human omission in a new handler. It cannot see a read that never becomes a query, and it cannot help a table nobody told it was scoped.
**Row-level security, inside the database** — Applies to the statement the database actually receives, so it holds for hand-written SQL, for a tool nobody thought about, and for a handler with a bug in it. It is the last layer and the only one an application mistake cannot talk its way past. It cannot protect a table whose policy was never applied, and it cannot match a row whose workspace stamp is empty.

> **Note** Independence is the property being relied on: three mechanisms, three authors, three failure modes. A single misunderstanding does not disable all three, which is exactly the reason not to collapse them into one cleverer check.

## Doing the check

1. Establish the two sessions you are comparing, one as the operator and one as a customer, and note which workspace each resolves to.
   - Result: Everything after this is a comparison between two known scopes rather than an argument about what should happen.
2. Take a capability that exists for both and exercise it as each audience.
   - Result: Both should answer, and each should answer with its own records only. One answering and the other refusing is a path question, not a data one.
3. Take an identifier that exists in one workspace and name it from the other, on a by-identifier action rather than a list.
   - Result: Lists are the easy case. A by-identifier action is where a missing check hides, and where the mailbox defect of this corpus was found.
4. Ask whether every scoped table is on the scoped list and carries a policy, for anything added since the last check.
   - Result: A table added with a workspace column but no policy is scoped by nothing at all, and that is the one failure the other layers cannot cover.

The audience half of the same question has its own enumerated test: the parity suite reads the route table out of the running application and compares every operator capability against its customer equivalent, so a capability that exists for one side only fails the build rather than being noticed by somebody later.

## Limits of this method

- It verifies the boundary between workspaces. It says nothing about what a person inside a workspace may do, which is a permission question.
- A pass on the day is a statement about the code that day. The value comes from the layers being in the build, not from any single pass.
- An empty result is ambiguous on its own: no rows, no permission and no stamp look identical from a screen. [Data that should be here is not](/docs/troubleshooting/data-not-visible/) separates the three.
- None of the layers addresses a credential that has left the system. Isolation protects records from other tenants, not from somebody holding a valid session.

## Questions

### Why three layers rather than one good one?

Because each catches something the others cannot see. The allowlist stops a capability reaching the wrong audience, the kernel covers a forgotten filter in new code, and the database policy holds for a statement the application never meant to send. Removing any one of them removes a distinct class of protection.

### What is the single most likely way isolation would actually fail?

A new scoped table whose policy was not applied in the same release as the table, or a by-identifier read that never becomes a query. Both are omissions rather than design faults, which is precisely why layers that apply automatically are worth more than layers a developer must remember.

### Can the platform operator read a customer's records through these paths?

The operator's session resolves to the operator's own workspace, and the same three layers apply to it. Running the platform — payments, pricing, enquiries addressed to the operator — is a separate surface with its own records rather than a way into a customer's.

## Related

- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)
- [Workspace isolation](https://connectbyjbrh.com/docs/security/workspace-isolation/)
- [Row-level security](https://connectbyjbrh.com/docs/security/rls/)
- [Data that should be here is not](https://connectbyjbrh.com/docs/troubleshooting/data-not-visible/)
- [Resolving which workspace a request belongs to](https://connectbyjbrh.com/docs/workflows/workspace-resolution-flow/)

## What this page is based on

- Connect source pack section 2 — isolation is enforced three times: allowlist, kernel, row-level security (`docs-source/sources/GENERAL.md`)
- Connect source pack section 3 — a scoped table needs its policy applied in the same release (`docs-source/sources/GENERAL.md`)
- Connect source pack section 1 — the enumerated parity suite over the running route table (`docs-source/sources/GENERAL.md`)
