# Scopes Tenant isolation + the path to layered scopes. ## Today: flat, one scope per executor Every multi-tenant row carries a `scope_id` column. Tables whose schema declares `scope_id` are "scoped"; tables without it are shared across scopes by construction. - **SDK** — `createExecutor({ scope: Scope, adapter, blobs, plugins })`. One `Scope` per executor instance. `scopeAdapter(rootAdapter, {...}, schema)` wraps the adapter before it reaches plugin storage or the core-table writers. Every read on a scoped table gets `where scope_id = scope.id` ANDed in; every write gets `scope_id = scope.id` stamped into the payload. Tx handles passed into transaction callbacks are also wrapped, so nested writes stay inside the same scope. - **Plugins** — see a plain `DBAdapter`. They do not know or care about scope. Every plugin schema that wants isolation declares `scope_id: { type: "string", required: true, index: true }`. Forgetting the column means the adapter passes the plugin's reads/writes through unscoped (documented failure mode, not silent) — tests catch the concrete cases we care about. - **Cloud** — the WorkOS organization is the outermost scope. `createScopedExecutor(scopeId, scopeName)` in `apps/cloud/src/services/executor.ts` is called per request with `{ org.id, org.name }` pulled from the session. One scope per request. - **Local** — `apps/local/src/server/executor.ts` derives a single scope id from the working directory (`${basename(cwd)}-${sha256(cwd)[0..8]}`). One scope per executor process. Coverage for the invariant lives in two places: `packages/core/sdk/src/ executor.test.ts` (SDK-level, in-memory adapter, two executors sharing one adapter) and `apps/cloud/src/services/tenant-isolation.node.test.ts` (HTTP-level, real `ProtectedCloudApi`, real PGlite). Both exercise sources, tools, secrets, and plugin-owned source detail lookups. ## The primitive already accepts layering `scopeAdapter` takes a `ScopeContext`: ```ts interface ScopeContext { readonly read: readonly string[]; // precedence-ordered, innermost first readonly write: string; // exactly one scope for writes } ``` Today the read list is always length 1 and equals the write target. For a single scope the wrapper emits `where scope_id = `; for multiple scopes it emits `where scope_id IN (...)`. The shape is a list on purpose so that extending to a stack later touches **one** call site (`createExecutor`'s adapter wrap) — not every plugin or every storage backend. Read-side dedup by id (shadowing on collision) is **not implemented** today; no code path sees rows from more than one scope yet. When layering lands the dedup step is a thin pass on top of `findMany` / `list` results. Write target is always a single scope. Layered writes mean "I decided my write target is workspace, not org, not user" — that's a policy decision the caller makes, not something storage guesses. ## Future: layered scopes The goal is a design like: ``` org → workspace → workspace-of-workspace → user ``` As an ordered list, not a tree. Rows stay owned by exactly one scope. Reads walk the list; on id collision the innermost wins (shadowing). Writes land in exactly one scope chosen by the caller. ### Scenarios we want to support - **Org-provided API key, team inherits.** Org admin adds an OpenAPI source with its auth at the org scope. Every user in every workspace in that org sees the source and the auth. Override at any inner scope (e.g., workspace overrides the URL, user overrides nothing) by writing a row with the same id at the inner scope — the outer row is shadowed on read. - **Workspace Gmail, per-user auth.** Workspace admin adds the Gmail source at workspace scope but declines to store an oauth token there. When a user invokes Gmail, secret resolution walks the scope stack (user → workspace → org) and only finds a token if that user personally oauthed. Policy on the source row — `auth_scope_mandate: "user"` — forces this: secret lookup refuses to return workspace-level tokens even if one existed. - **Local global + per-folder.** `~/.executor/global` holds the outer scope; the current folder's `executor.jsonc` holds the inner. Run `executor` in folder A and you see folder A's sources layered on the global set. Run in folder B, same global base, different inner. ### Data model implications - **No new columns on existing tables.** `scope_id` is enough — each row still belongs to exactly one scope. Layering is a read-time and resolution-time concern, not a storage concern. - **One new column** on `source` (or whatever the public config table becomes): `auth_scope_mandate: string | null`. If set, secret resolution for this source refuses scope levels at or above the mandate. `null` means "resolve normally, inner wins." - **No parent pointer.** Do not add `parent_scope_id`. Parent pointers imply a tree; we want an ordered list held in memory per request. Scope identity is flat (each scope is just a string id); hierarchy is assembled by the host app per request. ### API / SDK surface changes when layering lands - `Scope` becomes `ScopeStack { read: readonly Scope[]; write: Scope }`, or `createExecutor` grows a new shape and the old `Scope` path becomes a 1-element convenience. - `ctx.scope.id` in plugin code is an implicit "the scope" today. The three callers in `executor.ts` that set `SecretRef.scopeId` from `scope.id` need to read the write target instead: `ctx.scope.write.id` or a rename. - `scopeAdapter` signature is already list-shaped. Change the caller, not the wrapper. - Secret resolution (`executor.secrets.get`) grows a shadowing pass: walk the stack, first non-null value wins. If the source has an `auth_scope_mandate`, skip scopes above it. ### Permissions / RBAC Out of scope for the isolation primitive. Who can write to which scope is a host-app concern (cloud: WorkOS role + workspace membership; local: filesystem ownership). The SDK just takes the caller's declared write target on faith — the host is responsible for enforcing that it's legal. ### Cloud glue `createScopedExecutor` takes `(scopeId, scopeName)` today with the cloud passing the org id/name. When workspaces arrive: - The request's `AuthContext` grows `workspaceId?` and `userId`. - Cloud builds a `ScopeStack` from those: `[user, workspace, org]` read, write defaulting to the innermost writable (usually user; admin ops override). - `createScopedExecutor` becomes `createScopedExecutor(scopes: ScopeStack)` or a similar shape. The storage layer and every plugin are untouched by that change. ### Local glue `apps/local` derives a single scope id from `cwd` today. Layering is additive: - Outer scope: `~/.executor/global` (or a named profile). - Inner scope: the current folder, same derivation as today. - `executor.jsonc` sync writes to the inner (folder) scope by default; a `--global` flag or explicit file location targets the outer. ## What NOT to do - Do not collapse `source` and `secret` into one table to simplify layering. Their scope targets are independently chosen (see the Gmail scenario). - Do not add a `parent_scope_id` column. - Do not bake the concept of "organization" into SDK types or storage. It's a cloud concern today that maps onto the generic `scope_id`; the SDK must stay scope-generic so workspace + user scopes are cheap to add. - Do not introduce scope-level defaults in the wrapper. The wrapper stamps and filters; shadowing lives one layer up. ## Related - `packages/core/sdk/src/scoped-adapter.ts` — the wrapper. - `packages/core/sdk/src/core-schema.ts` — `scope_id` on source, tool, definition, secret. - `packages/core/sdk/src/executor.test.ts` — SDK-level tenancy tests. - `apps/cloud/src/services/tenant-isolation.node.test.ts` — HTTP-level tenancy tests. - Per-plugin schemas all carry `scope_id` today: openapi, mcp, graphql, google-discovery, workos-vault.