# Workspace kernel

The workspace kernel is the object-relational layer in Connect that adds a workspace filter to every query before it reaches PostgreSQL, and stamps the owning workspace on every row it writes. It is the middle of three isolation layers: a route allowlist above it, row-level security in the database below it. It lives in `workspace_kernel.py`.

- **Status:** Available
- **Audience:** both, developer
- **In the app:** #/account
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/glossary/workspace-kernel/

## The definition, and the two lists that give it meaning

Almost every table in Connect carries a `workspace_id`. The kernel is what makes that column mean something without every query in the application having to remember it: a read is narrowed to the active workspace on the way out, and a write is stamped with it on the way in. Application code asks for *threads*; the kernel is the reason the answer is *this workspace's threads*.

Two lists decide what it governs. `SCOPED_TABLES` names the tables it filters. `CONTROL_PLANE_TABLES` is the deliberate exception — platform identity, sessions, billing, and the `connect_inquiries` table that holds enquiries sent to JBRH itself, which belong to no workspace by design.

> **Careful** A table that has a `workspace_id` column but is absent from `SCOPED_TABLES` is not partly scoped. It is scoped by nothing at all, and its rows are visible across the whole installation until the list is corrected.

## What it is confused with

This is where the term earns its keep, because three neighbouring things are routinely called the same name in a bug report, and each one fails differently.

| Thing | Where it runs | What it decides | How its failure looks |
|---|---|---|---|
| Workspace kernel | In the application, over SQLAlchemy | Which rows a query may see, and what a written row is stamped with | Data from another workspace appears, or a written row lands unstamped |
| Row-level security | Inside PostgreSQL, per policy, forced | The same question, answered again by the database and not by trusted code | A write is refused by a policy's `WITH CHECK` and surfaces as a server error |
| `customer_safe` | In the HTTP middleware | Which `/api/*` paths a customer session may call at all | A 403 on a route that works perfectly for the Owner |
| `tenantAdapt` | In the browser, before the request leaves | Which customer path an Owner path is rewritten to | A 403 from `/api/customer/ui/blocked`, because no rewrite matched |

Two of these are about **rows** and two are about **routes**. If a screen loads and shows too little, suspect the row layers. If a screen loads and one panel is empty with a 403 behind it, suspect the route layers. The quickest separator is to compare the same action as the Owner and as a customer: a route problem changes between the two audiences, a row problem usually does not.

> **Note** "Workspace resolution" is a fourth term and a different moment. Resolution happens once per request, during authentication, and works out *which* workspace is active. The kernel then applies that answer to every query. Resolution picking the wrong workspace and the kernel filtering by the wrong workspace look identical on screen and are fixed in different files.

## The empty stamp

The failure worth knowing about is not a missing filter but an empty one. A row written with `workspace_id = ''` matches no workspace scope and no row-level security policy, so it is **invisible rather than missing**: every list omits it silently, while any action that already holds its id still works.

Connected mailboxes have vanished this way. Two live Gmail identities were absent from the Mailboxes screen with no duplicate warning and no error, and the uniqueness check could not see them either — so reconnecting one would have written a second row for a single real inbox.

The repair is deliberately not done from a web request. Stamping such a row from a request is refused by the policy's `WITH CHECK`, which turns an invisible record into a server error. Correction belongs to the schema owner: `migrate.backfill_canonical_workspace` runs every boot. The listing and the uniqueness check ask one read-only query that can see the row, so the fault is at least visible while it waits.

## Where to read more

- [Workspace isolation](/docs/security/workspace-isolation/) — the whole boundary, not just this layer.
- [Row-level security](/docs/security/rls/) and [the technology behind it](/docs/technology/row-level-security/) — the layer underneath.
- [Three layers of isolation](/research/three-layers-of-isolation/) — why one layer was judged insufficient.
- [Customer-safe](/docs/glossary/customer-safe/) — the route allowlist above it.
- [The workspace stamp](/research/workspace-stamp/) — what a stamp is for, and what an empty one costs.

## Questions

### Is the workspace kernel the same as row-level security?

No, and the difference matters when you are debugging. The kernel is application code that adds a filter to a query; row-level security is a PostgreSQL policy that filters again regardless of what the application asked for. The database layer is the one that still holds if the application layer is bypassed, which is exactly why both exist.

### Does a developer using the public API have to think about it?

Not directly. An integration authenticates into one workspace and every response is already narrowed to it. The term is useful to you as an explanation rather than a control: it is why an entity id from one workspace returns nothing in another rather than returning somebody else's record.

### What happens to a new table that nobody adds to the scoped list?

It is created at boot by `create_all` and left unprotected, because row-level security is applied by an operational script rather than by the model definition. Three prospecting tables once shipped in that state and were enabled and forced afterwards; a test now fails on any new table that repeats it.

## Related

- [Workspace isolation](https://connectbyjbrh.com/docs/security/workspace-isolation/)
- [Row-level security](https://connectbyjbrh.com/docs/security/rls/)
- [Customer-safe](https://connectbyjbrh.com/docs/glossary/customer-safe/)
- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)
- [Row-level security](https://connectbyjbrh.com/docs/technology/row-level-security/)
- [Data that is invisible rather than missing](https://connectbyjbrh.com/research/workspace-stamp/)

## What this page is based on

- `docs-source/sources/GENERAL.md` §2–3 — the request path, the data model, and where isolation is enforced three times
- `docs-source/sources/CHANNELS.md` §1 — the empty workspace stamp and the repair that belongs to migration
- Connect capability registry (`docs-source/facts.py`) — `rls_isolation`, `workspace_resolution`
