Files
kandev/docs/specs/multi-tenancy/spec.md
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

499 lines
30 KiB
Markdown

---
status: draft
created: 2026-08-22
owner: tbd
---
# Multi-Tenancy (Organizations)
## Why
Kandev's opt-in authentication gives an instance users, roles, and per-user
workspace privacy, but no boundary above the user: every account on an instance
shares one filesystem tree, one set of agent CLI credentials, one executor pool,
and one instance-wide configuration surface. That is acceptable for colleagues
who already trust each other and unacceptable for anyone who wants to run one
Kandev deployment for two teams that must not reach each other's code, or to
host Kandev for customers at all. Users need to belong to an **organization**
that is a real isolation boundary, not just a label.
**Sequencing.** Self-hosted is the near-term priority and a hosted deployment is
the reason the boundary must be real rather than cosmetic. Concretely: the
design is built to be strong enough for untrusted co-tenants, but it ships and
is validated on self-hosted instances first. What a self-hosted team actually
asks for today ([#2824](https://github.com/kdlbs/kandev/issues/2824)) is
[org roles and scopes](../auth/requirements/roles-and-scopes.md) plus
[organization units](../workspaces/requirements/org-units.md) — neither needs an org, and
both ship ahead of this spec. Organizations are what make that team board safe
to put on a box shared with another team, and later on a box shared with
another customer.
## What
- **An organization ("org") is the tenant.** It owns users, filesystem roots,
agent credentials, executors, and configuration. Cross-org reach is a bug,
not a permission level.
- **Every user belongs to exactly one org.** `users.org_id` is non-empty and
immutable after creation. There is no org switcher, no "acting as org" request
parameter, and no way for a session or token to name an org. The tenant is a
total function of the authenticated identity, so no request payload can move
a caller between tenants.
- **The org is the outer edge of sharing, not a sharing mechanism of its own.**
Who sees what inside an org is decided by
[org roles and scopes](../auth/requirements/roles-and-scopes.md) and
[organization units](../workspaces/requirements/org-units.md), exactly as on a
single-org instance. This spec adds no reach rule; it bounds every
existing one.
- **The unit tree is confined to one org and nothing wider.** A
workspace marked visible to the organization is reachable by members of *its*
org only. Adding a member from another org is refused with 404, so org
membership is not enumerable, and the tenancy migration drops any pre-existing
cross-org membership row it finds, logging each one without aborting.
- **Org scopes are per-org.** A user holding `org.members.manage` or
`org.config.manage` holds it in their own org and nowhere else. The
instance-level `operator` tier is separate and holds no org scopes at all.
Membership and roles are the boundary below the user; tenancy is the boundary
above; neither blocks the other.
- **Multi-tenancy is a runtime feature toggle.** `features.multiTenancy`
("Organizations", `KANDEV_FEATURES_MULTI_TENANCY`) is restart-required and
OFF in every shipped profile. It requires `features.auth`; enabling it with
auth disabled is refused at startup with a named error. With the flag off, an
instance behaves byte-identically to today — no org UI, no org routes, no
filesystem layout change.
- **A third role tier above the org.** `operator` is instance-level and manages
orgs, feature toggles, instance configuration templates, backups, and storage
maintenance. The org roles (`owner`, `admin`, `member`, `guest`) and their
scopes are defined by [roles and scopes](../auth/requirements/roles-and-scopes.md) and are
re-scoped to one org. An operator holds **no org scopes** and MUST NOT gain
read access to any org's workspaces, tasks, sessions, transcripts, or
secrets; operator is an administration role, not a visibility role, exactly
as org `admin` is.
- **Configuration is two-tier: instance template → org row.** Executors,
executor profiles, environments, agents, agent profiles, editors, custom
prompts, and notification providers each exist as instance-level templates
(`org_id = ''`, managed by an operator) and org-level rows (managed by an org
admin). An org sees the union of its own rows plus every template it has not
shadowed. An org admin may shadow, override, or ignore a template; an
operator cannot see or edit an org's rows.
- **An instance template MUST NOT carry a credential.** Templates hold shape
only: executor type, image reference, agent binary, model, flags, env-var
*names*. Every value that authenticates (secret, token, API key, env-var
value marked sensitive, integration config) is org-owned. This is a hard
invariant with a pinning test, because a template is the one row two orgs
both read.
- **Runtime isolation is tenant-pinned, not just row-scoped.** An agent
launched for org A SHALL NOT run in any process, container, or host that
holds org B's credentials or working tree:
- **Filesystem.** Each org gets a private root (`<home>/orgs/<org_id>/`)
containing its worktrees, clones, attachments, and agent credential home.
Directories are created mode `0700`. The pre-existing flat tree becomes the
default org's root in place, so upgrading an instance moves no files.
- **Agent credentials.** Every ACP subprocess and agentctl child for org A
receives `HOME`/`XDG_CONFIG_HOME`/`XDG_DATA_HOME` pointed at org A's
credential home, so `gh auth`, `claude login`, and provider API keys are
per-org rather than per-OS-user.
- **Containers.** Docker executions are labelled `kandev.org=<org_id>`,
named and volume-namespaced by org, and never reused across orgs. A
container whose label does not match the launching org is not adopted on
recovery; it is treated as foreign and left alone.
- **Remote/SSH executors.** An executor row is org-owned and reachable only
by its org. Two orgs cannot both target one remote host through Kandev.
- **Standalone (`local_pc`) executors fail closed.** A standalone execution
shares the backend's OS user, so `HOME` redirection is a convention an
agent can walk out of. When more than one org is active, standalone
executions are refused with a named error unless the instance declares a
per-org OS user for the launching org, or an operator has set the
`features.multiTenancyTrustedStandalone` escape hatch for a
mutually-trusting self-hosted deployment.
- **Secrets are sealed under a per-org key.** Row scoping decides who may
*read* a secret; it does nothing about the ciphertext. Today one AES-256
master key at `<data-dir>/master.key` seals every row on the instance, so at
rest every tenant collapses to one key that sits beside the database. Under
organizations each org gets its own data encryption key (DEK), wrapped by
that same master key:
- The master key and `MasterKeyProvider` are unchanged, so a self-hosted
upgrade changes nothing operationally and no KMS is required.
- A secret is sealed under its own org's DEK, so compromising one org's
plaintext does not yield another's.
- Deleting an org destroys its DEK, so the org's secrets are unreadable **in
the live database**. This is deliberately NOT a crypto-shred against
backups: a backup is `VACUUM INTO` of the database, `org_keys` is a table,
so the wrapped DEK travels inside the same snapshot as the ciphertext it
protects, and `master.key` is untouched by restore. Restoring a
pre-deletion backup on the same host unwraps it again. See Out of scope
for the change that would make deletion a real shred and what it costs.
- The tenancy migration re-wraps existing secrets under the default org's DEK
in the same first-boot pass that assigns `org_id`.
- **Background work runs per-org, never identity-free.** Today an absent
identity means "internal caller, unscoped". Under tenancy that is a
cross-tenant read. Pollers, office schedulers, the orchestrator, watchers,
automation runners, and storage maintenance SHALL iterate orgs and carry a
**system identity bearing an `OrgID`**. An identity-free context reaching a
tenant-scoped service entry point is a denial, not a grant.
- **Migration is automatic and lossless.** On first boot with the flag enabled,
every existing user, workspace, secret, executor, profile, environment,
editor, prompt, notification provider, and integration config is assigned to
a single **default org** named after the instance. No row is orphaned and no
file moves.
- **Deleting an org is explicit and destructive.** An operator deletes an org
only through a type-to-confirm flow naming the org slug. Deletion revokes
sessions and tokens, stops executions, removes org rows, and removes the org
filesystem root. The default org cannot be deleted while it is the only org.
## Data model
New and changed tables. Timestamp types follow the existing SQLite/Postgres
substitution (`internal/db/dialect`); all DDL is replay-safe (ADR 0027) and new
columns arrive via idempotent `ADD COLUMN` migrations, never by editing a
`CREATE TABLE` alone.
```
orgs
id string PK
name string display name, non-empty
slug string UNIQUE, lowercase [a-z0-9-], used in confirm flows
status enum active | suspended
is_default bool exactly one row true; the migration target
storage_root string abs path; '' means the legacy flat tree
created_at timestamp
updated_at timestamp
```
```
org_keys -- per-org data encryption key, wrapped
org_id string PK, FK -> orgs.id (cascade delete)
wrapped_dek bytes the org's DEK sealed under the instance master key
nonce bytes AEAD nonce for the wrap
created_at timestamp
```
Deleting the row destroys the only copy of the wrapped DEK. The plaintext DEK
is never persisted and is held in memory only for the life of a request.
```
org_os_users -- optional per-org OS identity, operator-managed
org_id string PK, FK -> orgs.id (cascade delete)
os_user string POSIX user name that standalone executions run as
created_at timestamp
```
Changed existing tables:
| Table | Change | Meaning |
|---|---|---|
| `users` | `+ org_id TEXT NOT NULL DEFAULT ''` | the user's tenant; immutable after create |
| `users` | `email` unique constraint becomes `UNIQUE (org_id, email)` | the same address may exist in two orgs |
| `auth_invites` | `+ org_id TEXT NOT NULL DEFAULT ''` | an invite mints a member of one org |
| `workspaces` | `+ org_id TEXT NOT NULL DEFAULT ''` | the tenant root for all workspace-descended data |
| `secrets` | `+ org_id TEXT NOT NULL DEFAULT ''` | org-owned; no instance tier. Sealed under that org's DEK, not the instance master key |
| `executors`, `executor_profiles`, `environments`, `agents`, `agent_profiles`, `editors`, `custom_prompts`, `notification_providers` | `+ org_id TEXT NOT NULL DEFAULT ''`, `+ template_id TEXT NOT NULL DEFAULT ''` | `org_id = ''` is an instance template; `template_id` names the template this org row shadows |
| `executors_running` | `+ org_id TEXT NOT NULL DEFAULT ''` | recovery must not adopt another org's execution |
| `task_message_attachments` | `+ org_id TEXT NOT NULL DEFAULT ''` | reached by `owner_id` without a workspace join |
| `plugin_settings`, `plugin_state` | `+ org_id TEXT NOT NULL DEFAULT ''` | plugin config is per-org; `plugin_user_state` inherits from its user |
| `settings`, `runtime_flag_overrides`, `kandev_meta`, `storage_*`, `telemetry_activations`, `notification_migrations` | unchanged | instance-global by design |
**Tenancy classification is exhaustive and pinned.** Every table is exactly one
of:
- `instance` — one row set for the whole deployment, operator-managed.
- `tenant-root` — carries `org_id` directly.
- `descendant` — reaches its org through a documented FK path (for example
`task_sessions -> tasks -> workspaces.org_id`).
The classification lives in one registry (`internal/tenancy/registry.go`) and a
completeness test enumerates the live schema and fails when a table is absent
from it. Adding a table without classifying it is a build failure, not a
runtime leak.
**Denormalization rule.** A descendant table gains its own `org_id` column only
when at least one production query reaches it without joining its root — that
is the query that would otherwise leak. The registry records the reason.
## API surface
```
GET /api/v1/orgs/current -> current caller's org (any member)
PATCH /api/v1/orgs/current -> rename own org (org admin)
GET /api/v1/instance/orgs -> list orgs (operator)
POST /api/v1/instance/orgs -> create org + first admin invite (operator)
PATCH /api/v1/instance/orgs/{id} -> name, status (operator)
DELETE /api/v1/instance/orgs/{id} -> destructive, requires {"slug": "<slug>"} (operator)
PUT /api/v1/instance/orgs/{id}/os-user -> set/clear the per-org OS user (operator)
GET /api/v1/instance/templates/{kind} -> list instance templates (operator)
POST /api/v1/instance/templates/{kind} -> create template (operator)
PATCH /api/v1/instance/templates/{kind}/{id} -> update template (operator)
DELETE /api/v1/instance/templates/{kind}/{id} -> delete template (operator)
```
`{kind}` is one of `executors`, `executor-profiles`, `environments`, `agents`,
`agent-profiles`, `editors`, `prompts`, `notification-providers`.
Existing collection routes for those kinds are unchanged in shape and return
the org's effective view: own rows first, then every template not shadowed by a
`template_id` match. Each item carries `"source": "instance" | "org"` and
templates are returned with `"editable": false`.
`POST /api/v1/instance/templates/{kind}` and its `PATCH` reject a body carrying
a secret value, a sensitive env-var value, or an integration credential with
`400 template_may_not_carry_credentials`.
**Changed contracts**
- `authn.Identity` gains `OrgID string` and `Instance bool` (the operator tier).
`IdentityFromContext` returning an identity with an empty `OrgID` while the
flag is on is a denial at every tenant-scoped entry point.
- A new `authn.SystemIdentity(orgID)` constructor is the only way background
work obtains a context; it is not reachable from any HTTP or WS path.
- `/api/v1/app-state` gains `org: {id, name, slug}` for authenticated callers
and continues to return only `{features, auth}` anonymously. It never
enumerates other orgs.
- WS: `Hub.BroadcastToWorkspace` is unchanged. `Hub.Broadcast` (global) is
replaced by `Hub.BroadcastToOrg(orgID, …)` for everything tenant-derived. The
`//ws:global` justification comment survives only for genuinely
instance-global events (release notification, feature-toggle change,
restart), and a lint-style test pins that allowlist.
- The GitHub credential broker lease, the port-proxy capability HMAC, automation
webhook secrets, office channel HMACs, and plugin webhook secrets each bind
the issuing org into their payload and refuse redemption under another org.
## Permissions
This spec adds only the instance tier. Org-level authority is the scope set in
[roles and scopes](../auth/requirements/roles-and-scopes.md).
| Action | operator | org owner | org admin | member / guest |
|---|---|---|---|---|
| Create / suspend / delete an org | yes | no | no | no |
| Feature toggles, backups, storage maintenance, restart | yes | no | no | no |
| Manage instance templates | yes | no | no | no |
| Set a per-org OS user | yes | no | no | no |
| Rename own org (`org.settings.manage`) | no | yes | yes | no |
| Manage org config: executors, profiles, environments, agents, editors, prompts, notification providers (`org.config.manage`) | no | yes | yes | no |
| Manage org users, invites, roles (`org.members.manage`) | no | yes | yes | no |
| Delete own org (`org.delete`) | no | yes | no | no |
| Read a workspace they cannot reach | no | no | no | no |
An operator is a user like any other: they belong to an org, they reach
workspaces there by the ordinary rules, and the instance tier grants them
nothing inside any org. On a self-hosted single-org instance the first admin
created by the auth setup wizard holds `operator` and org `owner`.
## State machine
An org has three states:
| State | Entered by | Effect |
|---|---|---|
| `active` | creation, or an operator resuming a suspended org | normal operation |
| `suspended` | operator | sessions and tokens fail closed with `org_suspended`; running executions are stopped; background work skips the org; rows and files are retained |
| *(deleted)* | operator, type-to-confirm | rows removed, filesystem root removed, sessions and tokens revoked; not reversible |
Suspension is reversible and is the intended lever for a cloud billing lapse.
Deletion is the only destructive path and is never reached implicitly.
## Failure modes
- **`features.multiTenancy` on with `features.auth` off** — startup aborts with
a named configuration error rather than serving an instance whose tenant
boundary has no identity behind it.
- **A tenant-scoped service entry point receives a context with no identity**
(flag on) — denied with the existing `*NotFound` sentinels. This inverts
today's "no identity means internal caller" rule and is the single most
important behavioral change; every background caller must be migrated to
`authn.SystemIdentity` before the flag can be enabled.
- **An identity's `OrgID` names a suspended or missing org** — the session and
any token fail closed on the next request; the browser is sent to a "your
organization is unavailable" page, not to `/login`, so the user is not told
their credentials are wrong.
- **A standalone execution is requested with more than one active org and no
per-org OS user** — refused before the process starts, with an error naming
the missing `org_os_users` row and the escape-hatch flag. No partial launch.
- **A Docker container or `executors_running` row carries a different
`kandev.org` label than the recovering backend expects** — it is not adopted,
not stopped, and not deleted; it is logged and left for its owning org's
recovery pass.
- **An org's storage root is missing or not writable** — task creation and
session launch in that org fail with a storage error; other orgs are
unaffected. The backend does not fall back to the shared tree.
- **The org migration cannot assign a row** (an orphan whose FK path is broken)
— migration aborts and the boot fails rather than leaving a row reachable by
every tenant. The pre-migration backup taken by `persistence.Provide` is the
recovery path.
- **An org's DEK cannot be unwrapped** (missing `org_keys` row, or a master key
that no longer decrypts it) — every secret read and write in that org fails
with a named error. It does NOT fall back to the master key: a silent
fallback would re-create exactly the shared-key property this removes.
The error names the recovery route, because a rule with no recovery story is
a rule the next person deletes under pressure: `org_keys` is an ordinary
table, so a lost row is restored from a database backup like any other row,
and secrets stay readable afterwards because the master key that unwraps it
is not part of the snapshot and did not change.
- **A template edit would introduce a credential** — rejected at the API
boundary; no partial write.
- **An org is deleted while an execution is running** — executions are stopped
first; if a stop fails the deletion aborts and reports the execution, so no
filesystem root is removed under a live agent.
## Persistence guarantees
- Org rows, memberships, per-org OS users, and org filesystem roots survive
restart.
- Org storage roots are only removed by explicit org deletion. Storage
maintenance and GC operate per-org and never traverse another org's root.
- The default org's `storage_root` stays `''` (the legacy flat tree) for an
upgraded instance, permanently. A fresh install writes
`<home>/orgs/<org_id>/` for every org including the default. Both shapes are
supported forever; the empty value is not a migration to finish later.
- Backups remain instance-wide (one database), so a backup contains every org.
Per-org export is out of scope.
- **Deleting an org does not erase it from backups already taken**, including
its secrets. The wrapped DEK lives in `org_keys`, which is inside the
snapshot, and the master key that unwraps it is not part of the snapshot and
survives restore. Deletion is irreversible on the live instance; it is not
retroactive across retained backups.
- Suspending an org does not delete anything; deletion is the only data loss.
- Turning `features.multiTenancy` off retains every `org_id`, exactly as
disabling auth retains `owner_id`. Re-enabling restores the previous
boundary without a re-migration.
## Scenarios
- **GIVEN** an instance with `features.multiTenancy` off, **WHEN** a user loads
any page or calls any API, **THEN** no org route exists, no `org` key appears
in the boot payload, and every response is byte-identical to the same build
with the tenancy code absent.
- **GIVEN** an instance with `features.auth` off, **WHEN** the operator sets
`features.multiTenancy` on and restarts, **THEN** startup aborts with a named
configuration error and the previous binary state is untouched.
- **GIVEN** an existing auth-enabled instance with three users and eight
workspaces, **WHEN** multi-tenancy is enabled and the backend restarts,
**THEN** one default org exists, all three users and all eight workspaces
carry its `org_id`, no file under `<home>` has moved, and every user's
visible workspace list is unchanged.
- **GIVEN** users A (org 1) and B (org 2) and a workspace owned by B, **WHEN**
A requests that workspace, task, session, transcript, secret, or attachment
by ID over HTTP, WS, or MCP, **THEN** the response is 404 and no row is
serialized.
- **GIVEN** org 1 and org 2, **WHEN** an agent runs for org 1 and reads
`$HOME`, **THEN** it sees org 1's credential home, and no path under org 2's
storage root is readable from that process.
- **GIVEN** an org-1 agent that runs `gh auth status`, **WHEN** only org 2 has
authenticated `gh`, **THEN** the agent reports no credential rather than
authenticating as org 2.
- **GIVEN** two active orgs and no `org_os_users` row, **WHEN** a user launches
a session on a `local_pc` executor, **THEN** the launch is refused before any
process starts and the error names the missing per-org OS user.
- **GIVEN** two active orgs and the `features.multiTenancyTrustedStandalone`
override set, **WHEN** the same launch is attempted, **THEN** it proceeds with
the org's redirected `HOME` and a warning is logged once per org per boot.
- **GIVEN** a Docker container labelled `kandev.org=org-2`, **WHEN** the backend
recovers executions for org 1, **THEN** the container is not adopted, not
stopped, and not removed.
- **GIVEN** an org-scoped poller, scheduler, or automation runner, **WHEN** it
executes a cycle, **THEN** every service call it makes carries a system
identity bearing that org's ID, and a call made with no identity is denied.
- **GIVEN** a workspace-carrying event in org 1, **WHEN** the WS gateway
broadcasts it, **THEN** no client authenticated to org 2 receives a frame.
- **GIVEN** an instance template for an executor profile, **WHEN** an org admin
lists executor profiles, **THEN** the template appears with
`"source": "instance"` and `"editable": false`, and after the org creates a
row with that `template_id` the template no longer appears.
- **GIVEN** an operator, **WHEN** they attempt to save an instance template
whose body contains a secret or sensitive env-var value, **THEN** the request
is rejected with `template_may_not_carry_credentials` and nothing is written.
- **GIVEN** an operator who belongs to org 1, **WHEN** they request any
workspace, task, session, or secret belonging to org 2, **THEN** the response
is 404 — the instance tier grants no org scopes and no reach.
- **GIVEN** a workspace under org 1's root unit, **WHEN** a member of org 2
requests it, **THEN** the response is 404 — a unit tree spans one org only.
- **GIVEN** a workspace owner in org 1, **WHEN** they try to add a user from
org 2 as a workspace member, **THEN** the response is 404 and no membership
row is written.
- **GIVEN** an instance already using org-visible workspaces and workspace
members, **WHEN** multi-tenancy is enabled and every user lands in the default
org, **THEN** every unit, membership row and placement is retained and
every shared board keeps working exactly as before.
- **GIVEN** a membership row whose member and workspace would land in different
orgs, **WHEN** the tenancy migration runs, **THEN** the row is dropped and
logged, and the migration does not abort.
- **GIVEN** a GitHub credential-broker lease minted for an org-1 task, **WHEN**
it is redeemed against an org-2 task, **THEN** redemption fails and no token
is returned. The same holds for a port-proxy capability, an automation webhook
secret, an office channel HMAC, and a plugin webhook secret.
- **GIVEN** an active user in org 1, **WHEN** an operator suspends org 1,
**THEN** the user's next request fails closed with `org_suspended`, running
executions are stopped, and the browser shows the organization-unavailable
page rather than the sign-in page.
- **GIVEN** two orgs each holding a secret, **WHEN** org A's DEK is used to
decrypt org B's ciphertext, **THEN** the decryption fails: the rows are not
sealed under a shared key.
- **GIVEN** org B deleted from the live instance, **WHEN** any of its secrets
is read, **THEN** the read fails: its DEK row is gone.
- **GIVEN** a backup taken while org B existed, **WHEN** it is restored onto the
same host after B was deleted, **THEN** B's secrets ARE readable again. This
is the documented consequence of `org_keys` living inside the snapshot while
the key that unwraps it lives outside; a test asserts it so nobody mistakes
deletion for a backup-spanning shred.
- **GIVEN** an org whose `org_keys` row is missing, **WHEN** any secret in it is
read, **THEN** the read fails with a named error rather than falling back to
the instance master key.
- **GIVEN** an org with a running execution, **WHEN** an operator confirms
deletion by typing the slug, **THEN** executions are stopped first, all org
rows are removed, the org storage root is removed, and every session and
token for its users is revoked.
- **GIVEN** an org whose execution cannot be stopped, **WHEN** deletion is
confirmed, **THEN** the deletion aborts, the storage root still exists, and
the error names the execution.
- **GIVEN** a new table added to the schema, **WHEN** the tenancy completeness
test runs, **THEN** it fails until the table is classified as `instance`,
`tenant-root`, or `descendant` in the tenancy registry.
## Out of scope
- **Workspace sharing, roles, and scopes.** Those are
[roles and scopes](../auth/requirements/roles-and-scopes.md) and
[organization units](../workspaces/requirements/org-units.md), which ship
first and do not require an org. This spec only bounds them to one org.
- **Multiple orgs per user.** One org per user is the design, not a stopgap: it
makes the tenant a total function of the identity and removes an entire class
of "acting as" confusion. Someone who needs two orgs creates two accounts. If
multi-org is ever added, `users.org_id` becomes the primary-org pointer and a
membership table joins it; no data migration is required.
- **Schema-per-tenant or database-per-tenant.** Row-level scoping plus
filesystem and runtime pinning is the boundary. Revisit only if a hosted
deployment demands physical separation.
- **Billing, plans, quotas, and usage metering.** The org is the anchor these
will hang from; none of them ship here.
- **Per-org SSO / IdP pinning and domain-based auto-join.** Plugin-provided
external login (ADR 0050) stays instance-level for now.
- **Per-org data export or per-org backup/restore.** Backups stay instance-wide.
- **Cross-org anything** — no shared workspaces, no transfer of a workspace or
user between orgs, no cross-org search, no cross-org analytics.
- **Crypto-shredding an org out of existing backups.** Per-org DEKs buy two
things: a partial compromise (a leaked key, a bad export, memory disclosure
in one org's context) exposes that org rather than all of them, and the
envelope structure is the prerequisite for a real shred. They do not deliver
the shred, because the wrapping key is in the same snapshot boundary as the
ciphertext.
The change that would deliver it is specific: wrap each org's DEK with a key
that lives **outside** the database snapshot and dies with the org. The
per-org filesystem root is the natural home, since deletion already removes
it and backups already do not capture it. It is out of scope here because the
cost is not small and lands on every self-hosted operator: a database backup
alone stops being sufficient for recovery, and an org root lost to disk
failure or a partial restore takes that org's secrets with it, permanently.
That tradeoff deserves its own decision rather than riding in on this one.
- **Protecting the root key itself.** `master.key` remains a mode-0600 file in
the data directory beside `kandev.db`, so anyone who can read that directory
can unwrap every org's DEK. `MasterKeyProvider` is the seam a KMS, HSM or
env-supplied root key plugs into. Envelope encryption stops one org reaching
another's plaintext; it does not defend the instance against someone who
already has its disk.
- **Sandboxing an agent from the OS user it runs as.** Tenant pinning stops
org A's agent from reading org B's tree; it does not stop an agent from
escaping its own org's tree if the OS permits it. That remains the executor's
job.