Appearance
Multi-Organizations / Tenant Scoping
Concept layer — frozen. The Multi-Organizations / tenant scoping generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Business Context
One deployment serves several separated populations of data. What separates them varies by product — a legal entity, a tenant, a client, a workspace, a brand, a region — and this concept calls that separating dimension the scope. The commercial motive is always the same: onboard another population without standing up another stack, while guaranteeing that no record from one is ever visible to another.
The concept is therefore not "we have a customers table". It is the rule that every read and every write in the system is answerable to the question whose data is this?, and the machinery that makes the answer impossible to omit.
Business-Level Definition
A user acts inside exactly one scope at a time. Everything they see, create and change belongs to that scope. A user who legitimately belongs to more than one switches between them; switching changes what the whole application shows, not merely a filter on one screen.
Requirements Definition
- Data is separated between scopes within a single deployment, at the storage layer, not by the user interface politely not asking for it.
- A user may hold access in several scopes and switch between them.
- A new scope can be onboarded without new infrastructure.
- The scope a request acts in is derived from the authenticated user, not accepted on trust from the caller.
Technical Context
User Stories / Use Cases
- As a user with access to two scopes, I switch between them and see only the data of the selected one — across every screen, not only the one I switched on.
- As an administrator, I onboard a new scope and its first users without a deployment.
- As an auditor, I need the assurance that a defect in one feature cannot leak another scope's rows.
UI/UX Design
The active scope is shown persistently — name and, where the product has one, logo — because a user who cannot see which scope they are in will eventually act in the wrong one. Where a user belongs to more than one, a switcher sits next to it. Screens that are not scope-specific (platform administration) are visually separated from the ones that are, so the switcher's reach is obvious.
The administrative surface is described on Scope Administration.
Functional Requirements
Classify every entity, once
Each persisted entity is either scope-bound or platform-scope, and the project decides which in one place rather than per feature:
- Scope-bound entities belong to exactly one scope and are guarded by it on every access.
- Platform-scope entities are shared across scopes by design — typically the directory of people and the reference data that would be absurd to duplicate. They carry no scope and therefore get no protection from the scope guard, so each must name what gates it instead. This is the exception that most often goes unwritten, and an unguarded platform entity is indistinguishable from a scoping bug until someone reads the code.
The caller may name a scope; it may never grant itself one
- The caller states the scope it wants to act in — in a header, a path or a payload field, one place per project. Stating it is harmless: a name is not an authority. What the caller must never be able to do is make the backend act in a scope it holds no grant for, or widen the request to "no scope" by leaving the name out.
- The backend decides, on every request, whether this caller may act in the named scope — from the grants and membership as they are now, never from what a token or session says was true when it was issued. A scope carried in a token is a hint the client may use to pre-select; it is not what the guard evaluates. Deriving the scope from the token instead of from the request does not make the boundary stronger — it moves the same check to token issuance, where it goes stale — and it costs the one-token-many-scopes model that a person who belongs to several scopes needs.
- A missing or malformed scope name is rejected identically, before any data is touched. A bad name must never degrade into "no scope" and so widen the caller's reach. Default-deny: if no scope can be resolved and the route is not one that legitimately runs without one, the request fails.
- Where an elevated caller may act on another scope, the named scope is the target of the operation and authorisation is evaluated against the caller's platform-wide grant, not their membership of the target. Those routes are a closed, greppable set.
- A caller who names a scope they hold no grant in is refused, not served an empty result, and the refusal is indistinguishable from "this action is not yours" — so the existence of another scope is not disclosed.
Guard every access, not every query
- Every read, write and delete over a scope-bound entity is constrained by the scope.
- The constraint belongs somewhere it cannot be forgotten. See Where scoping is enforced below — this is the concept's central design decision.
- The route layer has the same problem as the query layer, and needs the same answer. Where the guard is a per-route declaration ("this handler requires a scope"), a handler that declares nothing is not unscoped by decision — it is unscoped by omission, and it is the by-id reads (preview, download, detail) that get omitted, because each looks too small to be a tenant boundary. So every route declares its scope class — scope-bound, platform-scope, or an explicit opt-out with a reason — and a build-time test fails on a route that declares none, exactly as the permission catalogue's test fails on a handler with no permission. Absence of a declaration must be impossible, not merely unusual.
Extend the scope to everything data lives in
- File and object storage is partitioned per scope, with the scope in the key structure, so that a storage path is as scoped as a table row. A bucket per scope and a scope prefix inside one bucket are both this rule; what is not is a key that a caller can compose without the scope.
- Search indexes, caches, queues, exports and report snapshots are all data stores. A cache key without the scope in it is a cross-scope leak with a TTL.
Scope lifecycle
- Scopes can be created, edited, activated and deactivated. Deactivation stops access without destroying data; deletion is a soft delete, since the scope is referenced by everything it owns.
- Deactivating a scope must take effect for sessions already established, which means the check is on the request, not only at sign-in — and where on the request matters. The active check belongs in scope resolution, once per request, before authorisation: a scope that does not exist or is not active fails closed, and every guard downstream sees only live scopes. The two places it drifts to, and why each is wrong: a membership check that only some routes declare, so deactivation closes the front door and leaves every other endpoint answering; and a decided-access cache whose entries are not invalidated on deactivation, which is a delay only if evaluation itself reads the flag — if it does not, a cache miss re-evaluates without the flag and "until the TTL" is really "indefinitely". Deactivation also invalidates that cache for the scope; whichever place is chosen, the project writes it down.
- The scope's own business identifiers — a registration number, a tax id, a name the business knows it by — obey the same rule the concept gives a scope's children on Scope Administration: soft deletion must free them for reuse, so a uniqueness constraint is partial over live rows, or the company that left can never be onboarded again. A plain
UNIQUEover the whole table quietly makes every soft delete permanent for that identifier.
Scope change auditing
- Every change to a scope is recorded in the Audit Log concept: creation, updates to its details and settings, and activation or deactivation.
- The entry is written in the same transaction as the change, so it exists if and only if the change committed.
- The recorded context and the history view are specified on Scope Administration.
Non-Functional Requirements
- The scope predicate is on every query against a scope-bound table, so it must be indexed on every such table. An unindexed scope column turns the safety mechanism into a full scan.
- Scope resolution happens once per request and must be cheap; it sits in front of all other work.
Processes & Related Systems / Components
This concept is not a feature that other features may ignore. It constrains every process, every background job, every export and every integration. That breadth is exactly why it needs a single enforcement point rather than a convention.
Related concepts: User Login & Authentication (who the caller is, before the scope is known), Users & Groups (membership, typically platform-scope), Permission Model (permissions are commonly granted within a scope, with a global variant), Audit Log (scope lifecycle events).
API Analysis (API-A)
Scope administration: Scope Administration — API Analysis (API-A).
Beyond administration, this concept adds no endpoints of its own — it adds a precondition to every other endpoint in the system. The reads that expose it are the ones that tell a client which scopes the user may enter and which one is active; those belong to whichever feature owns the application shell.
Domain Model & Data Attribute Table (DAT)
- A scope entity — identity, display name, the identifiers the business knows it by, an active flag, plus the standard id and audit block.
- A scope key on every scope-bound entity, indexed.
- A membership relation between users and scopes — stored or derived, per the decision Users & Groups records. A project that derives membership from the grants has no table here and should have no class or endpoint implying one; a project that stores it must say how the table and the grants are reconciled.
Indexing Strategy
A per-table index on the scope key, on every scope-bound table. Where a table is almost always queried as scope plus something else, the composite index leading with the scope key is what actually gets used.
Data
Nothing to seed but the scopes themselves and their first memberships. A deployment with exactly one scope is a legitimate configuration, and the code path must be the same one — a special case for "single scope" is how the guard stops being exercised.
Logging & Monitoring
.info— scope created, updated, activated, deactivated. A user switching scope is logged only where switching is a backend act; where it is a client-side context change carried on the next request (the common case with a caller-named scope), there is no event to log and the first scoped request is the record — a project says which, so the absence of a switch line is not read as a gap..warn— a request that resolved no scope; an elevated cross-scope access. Both are rare and both are worth seeing.
Every log line and every error report carries the scope, or an incident becomes an archaeology exercise.
Caching and Performance
The concept itself caches nothing, but it constrains everything that does: the scope is part of every cache key. A cache keyed only by entity id, seeded under one scope and read under another, is a leak that no query-layer guard can catch, because no query runs.
Backward Compatibility and Migration
Introducing scoping into a system that lacks it is the hardest version of this migration: every table gains a key, every row needs a backfilled value, and until the backfill is complete the guard cannot be made mandatory. Expand-contract applies — add the column nullable, backfill, make it required, then turn on enforcement — and the enforcement step is the one to schedule deliberately, because it is the only step that can break a working query.
Legal Context
Data separation between scopes is frequently a contractual commitment and sometimes a regulatory one, particularly where scopes correspond to distinct legal entities or to regions with residency rules. Where the scope is a region, the separation may need to reach the physical storage location, which this concept alone does not provide.
Cybersecurity Considerations
Authorization is incomplete without scope. A permission check that establishes this user may read invoices and not this user may read invoices in this scope authorises reading everyone's invoices. The two checks are separate, and both are mandatory, on every request path and every background job.
The realistic threat is not an attacker crafting a cross-scope request; it is an ordinary feature whose query forgot the predicate. That is why the concept prefers enforcement a developer cannot omit over a convention a developer must remember.
Risk Assessment
- Silent cross-scope leakage. The highest-severity risk in the concept and the least likely to be noticed, because the leaking query returns a plausible result. Mitigation is structural: enforce centrally, and test for it — a test suite in which two scopes' data coexist catches this, and a suite with one scope's data cannot.
- Background work escaping the guard. See below; jobs have no request to inherit from.
- Unclassified new entities. A table added without a decision about its scope defaults to whichever is easier, which is unscoped.
- Scope-blind caches, exports and indexes. Everything that stores a copy of scoped data inherits the obligation.
Auditing, Reporting & Measurement
- Audit trail — scope changes are recorded through the Audit Log concept. A scope's own administrators read their own scope's history; a platform-level administrator reads any. This makes the scope entity unusual: it is one of the few audited entities whose history is visible to the scope it concerns, precisely because it is that scope.
- Entity-level audit — the standard created/updated/deleted metadata on the scope row answers "current state, last touched by whom"; the audit log answers "how it got here".
- Cross-scope reporting is a deliberate, permissioned exception, not a report that happens to omit the predicate. Say which reports have it.
Where scoping is enforced — the decision that defines the implementation
There are two families of answer, and a project must choose one knowingly.
Carry the scope on every entity and guard every access. Each scope-bound table has a scope column; the data-access layer applies the predicate. Simple to read, works on any storage engine, keeps everything in one database, and makes cross-scope administrative reads easy. Its weakness is that correctness is per query: the guarantee holds only as long as every access path applies the predicate, and the number of access paths only grows.
Enforce below the application. Separate schemas or databases per scope, or a row-level security policy in the engine, or a connection whose session variable pins the scope. The guarantee stops depending on individual queries, so a forgotten predicate returns nothing rather than everything. The costs are real: cross-scope reads and migrations become harder, schema-per-scope multiplies migration work by the number of scopes, and engine-level policies are one more thing to get right.
Most projects take the first. If yours does, the mitigation that matters is to make the predicate structurally unforgettable — a repository or query layer that applies it by default and requires an explicit, greppable opt-out for the rare cross-scope read — rather than a rule in a document saying to remember it. A scope column is only as good as the layer that never forgets it.
Whichever is chosen, write down: which entities are scope-bound, what applies the guard, what the opt-out looks like, and how the opt-out is reviewed — and which family was rejected, and why. A project page that states the chosen design without the fork reads as though there was none, and the next person to propose schema-per-scope will not find the decision that already answered them.
Scoping background work is a separate problem
Requests carry an authenticated user, so a scope can be derived. Background work — scheduled jobs, queue consumers, retries, webhook handlers, imports — has no request and no user, and this is where scoping most often silently stops.
For background work to be trustworthy, all of the following must hold:
- The scope travels with the unit of work. It is part of the job payload or the message, recorded when the work was enqueued and in the same transaction as whatever caused it. A job that re-derives its scope at execution time is deriving it from something, and that something is usually a lookup that could have moved. The one legitimate exception is the scoped-record job: the unit of work is a scope-bound record (a document to process, an import row to apply), so the scope travels with the record's id and is read from the row itself. That is sound on one condition — the job never reaches beyond that row's scope: every further read it makes is predicated on the scope it took from the row, and a job that needs another scope's data has been given the wrong unit of work.
- The job runs under the same guard as a request. The enforcement point cannot be a request-scoped filter that a worker process bypasses simply by not being a request. If the guard lives in the web layer, background work is unguarded by construction.
- A job that spans scopes is a set of jobs, one per scope — not one job with the guard turned off. Batch work that legitimately iterates every scope should iterate scopes explicitly, entering each one, rather than issuing a single unscoped query.
- The actor is recorded as a system actor with its scope, so audit entries and logs from background work are as attributable and as scoped as those from requests.
- Failure handling preserves the scope. Dead-letter queues, retry records and error reports carry it, or a retry resumes without it.
The test for all of this is the same as for requests: seed two scopes, run the job, and assert that the other scope's data was neither read nor touched.
Known instances
None recorded in this copy. Add this project's instance here as it adopts the concept — one line naming the feature that implements it. This is the only place a concept may name a project, and the template ships it empty so a fork inherits no other project's entries.