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.
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.
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.
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 — the whole boundary, not just this layer.
- Row-level security and the technology behind it — the layer underneath.
- Three layers of isolation — why one layer was judged insufficient.
- Customer-safe — the route allowlist above it.
- The 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.