Connect by JBRH Open Connect

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 What this means
Audience
both, developer
In the app
#/account
Last verified
Product version
6.3.2

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.

ThingWhere it runsWhat it decidesHow its failure looks
Workspace kernelIn the application, over SQLAlchemyWhich rows a query may see, and what a written row is stamped withData from another workspace appears, or a written row lands unstamped
Row-level securityInside PostgreSQL, per policy, forcedThe same question, answered again by the database and not by trusted codeA write is refused by a policy's WITH CHECK and surfaces as a server error
customer_safeIn the HTTP middlewareWhich /api/* paths a customer session may call at allA 403 on a route that works perfectly for the Owner
tenantAdaptIn the browser, before the request leavesWhich customer path an Owner path is rewritten toA 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#

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.