Resolving which workspace a request belongs to
Resolution happens once, in the middleware, before any router sees the request: the session names one workspace and the request is answered inside it or not at all. Enforcement then happens three more times — an allowlist in front of the application, a filter on every query it builds, and a policy inside the database on the statement it receives.
The flow#
- Trigger — any request arrives: a screen loading, an action, a background tick doing work for one workspace.
- User or external event — the browser presents its session cookie, or an integration presents its key. Neither carries a workspace of its own choosing.
- Authentication and workspace resolution — the session record is read and the workspace is fixed. This is the only moment scope is decided, and nothing later can change it.
- Ingest — the path is checked against what this audience may call. An unrecognised or unpermitted path is refused here, before any handler runs.
- Canonical record — the handler asks for records. Every scoped table carries a workspace column, and the filter is applied whether or not the handler remembered.
- Reasoning — the engine reasons inside the resolved scope. It cannot ask a question of another workspace's data, because there is no query it can build that reaches one.
- Knowledge, memory and rules — all scoped records. Memory at every tier, knowledge sources, autonomy rules: each belongs to one workspace.
- Autonomy and approval — resolved within the workspace, at the narrowest applicable scope.
- Action through a provider — the credentials used are the workspace's own, sealed, and resolved through the same scope.
- Result — the database applies row-level security to the statement it actually receives, as the last line rather than the first.
- Relationship, timeline and memory — everything written carries the workspace stamp, so tomorrow's reads resolve the same way today's writes did.
- Audit, usage and Needs You — the decision record, the usage ledger and the queue are all scoped records too. There is no cross-workspace view of any of them.
Deciding, then enforcing#
It is worth separating the two halves, because they answer different questions. Deciding happens once and produces a single answer: this request belongs to this workspace. Enforcing happens repeatedly and answers a narrower question each time: does this specific read or write stay inside that answer.
| Where | What it checks | What it cannot see |
|---|---|---|
| In front of the application | Whether this audience may call this capability at all | Anything about rows — it has no opinion inside a permitted call |
| In every query the application builds | That the workspace condition is present, without the handler adding it | A read that never becomes a query, or a table nobody registered as scoped |
| In the database | The statement as received, whatever produced it | A table whose policy was never applied, or a row with an empty stamp |
Proving a workspace is isolated, end to end is how to check the three rather than take them on trust, and Three independent layers of tenant isolation is the argument for why there are three.
What is deliberately not scoped#
A small set of tables sits outside every workspace on purpose, and knowing which is part of understanding the model rather than an exception to apologise for: platform identity — the accounts people sign in with — sessions, billing records, and enquiries addressed to the operator through the public site.
The reasoning is that each of them exists *before* a workspace does, or *across* several. An identity that belonged to one workspace could not be the same person in two; an enquiry from a stranger belongs to nobody's workspace by definition. What matters is that the list is short, written down, and does not grow casually — a table that quietly joins it stops being protected by any of the three enforcement points.
Where it goes wrong#
- A new table with a workspace column and no policy
- Scoped by the application only. This is the failure the third layer exists for, and it is a release-time omission rather than a design fault.
- A read by identifier that never becomes a query
- Bypasses the second layer entirely, and therefore the third as well. The reason every by-identifier route goes through a scoped lookup.
- A capability with no route for one audience
- Not a scoping fault at all — a routing gap, which shows as a refusal. See Routing a request for the Owner or a customer.
- A row whose stamp is empty
- Matches no scope and no policy, so it is invisible rather than protected. See Data that should be here is not.
What this means for a background task#
Work that nobody is watching — a tick draining follow-ups, a fetch, a scheduled brief — resolves a workspace the same way and then stays inside it. That is why a task cannot process two workspaces in one pass: each pass belongs to one, which is slower to describe and much easier to reason about when something has gone wrong at three in the morning.
Questions#
Can a request change workspace part-way through?
No. Scope is fixed in the middleware before any handler runs, and there is no supported way to widen it afterwards. A person who belongs to two workspaces changes by changing session.
Is the platform operator's workspace special?
It resolves through the same mechanism and is subject to the same three enforcement points. What is genuinely operator-only is the small set of surfaces for running the platform rather than using Connect — payments, pricing and public enquiries — and those are separate surfaces, not a wider scope.
What happens if no workspace can be resolved?
The request does not proceed. There is no default workspace and no fallback, because a fallback is a way of answering a question you could not answer, which is precisely the mistake this design is arranged to avoid.