docs/multi-tenant/implementation-decisions.md
This log records implementation choices made while delivering the Workspace architecture. It is intended to make trade-offs auditable without interrupting implementation for routine decisions.
Architecture decisions, activation gates, and still-open follow-ups are tracked in pending-architecture-decisions.md. Sections marked as decided there are authoritative; this file records the concrete implementation choices and compatibility names used to realize them.
langbot-space.X-Workspace-Id, but the server resolves it against the authenticated Account membership. API keys, public Bot routes, webhooks, jobs, and plugin calls derive Workspace from their trusted owning resource or binding instead of trusting the header.sub; a bounded compatibility path accepts legacy email-subject tokens and rotates them when checked.owner, admin, developer, operator, and viewer map to a fixed permission matrix in LangBot Core. The last owner cannot be removed or demoted, and invitations cannot create an owner directly.instance_uuid, workspace_uuid, execution_generation (stored as the compatibility field placement_generation until the schema rename), installation_uuid, runtime_revision, and verified artifact digest; enabled-resident is the desired semantic. A worker never routes actions for another Workspace or installation, and plugin-supplied scope fields are stripped.data/config.yaml (including native environment overrides), never from a plugin manifest. Cloud requires nsjail and delegated cgroup v2 hard limits or fails closed.dependency_prepare_failed; it prevents that worker launch without blocking recovery of other desired installations, and the same revision can be retried. The installation Supervisor now restores an unexpectedly exited enabled worker through a completion callback with bounded exponential backoff. Jitter, global restart concurrency limits, and a Runtime-wide circuit breaker are still required to prove that an infrastructure-wide failure cannot create a cross-tenant restart storm.data/plugins remain an OSS-only bridge. Shared mode requires complete bindings and rejects incomplete context.sessionStorage until login or acceptance completes; it is never placed in a path, query string, analytics event, or persistent local storage.cloud_projection Workspaces, but only from an explicit Workspace UUID and an active, unfenced WorkspaceExecutionState for the current instance and matching source. OSS-only bootstrap paths additionally require source=local.* scope. The plaintext config key works only for the OSS singleton Workspace and is disabled in multi-Workspace mode.RequestContext, and every tool checks a fixed permission before calling tenant services with that same context.langbot-plugin==0.4.18 and replace the Git pin with the registry pin.SingleWorkspacePolicy, creates or repairs one local Workspace, and permits local membership/invitation workflows. Changing mutable configuration such as system.edition cannot activate multi-Workspace routing. The future closed Cloud bootstrap will install CloudWorkspacePolicy only after verifying a signed InstanceManifest; that policy requires an explicit projected Workspace selector, does not create Workspaces, and rejects invitation or membership mutations with control_plane_required; member reads use the versioned local projection.InstanceManifest verifier and closed bootstrap described in the architecture document before it can inject CloudWorkspacePolicy; edition=cloud, an environment variable, or any unsigned local configuration is never a valid activation credential.useSyncExternalStore Workspace store publishes permission changes to React consumers; direct mutation-only routes and controls are hidden or disabled when the fixed role lacks the required permission.X-Workspace-Id.iss=langbot-core, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.pipeline_uuid or session_type index can collide across Workspaces.resource.view responses recursively redact Bot, Plugin, MCP, and provider credentials. Provider secrets require provider_secret.manage; Bot and Plugin configuration writes require resource.manage. Masked Plugin values can be round-tripped by a manager without overwriting the stored secret. Plugin Runtime debug credentials require resource.manage, not the operator-only runtime.operate permission.resource.manage and store the initiating instance, Workspace, execution generation, and principal. Status and cancellation by any other scope return the same 404 as an unknown session.upload_image owner type and may be resolved only through the opaque public-image route. RAG documents use upload_document and can be read, sized, or deleted only by an exact instance, Workspace, generation, and owner-type match. Legacy upload objects are cleanup-only.api.webui_url or api.webhook_prefix. Request Host and Origin headers never expand this allowlist.account_uuid may refresh the credentials of its already-bound local Account. An unknown provider subject that presents an email belonging to an existing Account is rejected, even when the normalized emails match. The Account owner must authenticate locally and use the one-time, account-bound binding flow.ACCOUNT_TOKEN validates the active Account JWT but intentionally cannot resolve a Workspace, receive RequestContext, or declare Workspace permissions. Its narrow bootstrap endpoint returns only active Workspace memberships belonging to that Account and never chooses the first Workspace when several exist. All tenant resource routes still require the explicit selector in multi-Workspace mode.PRAGMA quick_check, writes a durable manifest, and fsyncs restrictive-permission files and directories. A failed boundary disposes the engine, removes stale journal sidecars, atomically restores the verified source revision, and verifies the restored database before startup continues.placement_generation until a coordinated rename. They revalidate the active execution binding before accessing a provider or transport; long-running calls validate again before accepting results. A stale generation is fenced before it can read, write, or reuse a cached object.SingleWorkspacePolicy. A cloud_projection Workspace cannot observe or migrate the old global collection, even when it carries a legacy marker.internal_error response and request ID, and expose that ID in X-Request-Id; the detailed exception is retained only in server logs correlated by the same ID. Explicit domain and validation errors keep their documented status and code.managed_sandbox capability and managed_sandbox_sessions limit; Core and Runtime never branch on a plan name. An eligible Workspace receives at most one persistent logical global session, while each ordinary command remains a one-shot nsjail process. Managed processes and network are disabled in the first Cloud release.ENABLE plus FORCE ROW LEVEL SECURITY policies are the second. The Cloud runtime role must be non-owner and have neither superuser nor BYPASSRLS.(workspace_uuid, knowledge_base_uuid, vector_id) identity, an untyped vector column with explicit checked dimension, and release-created partial expression indexes for the enabled dimensions. Cloud never falls back to Chroma or performs vector DDL at runtime.SET LOCAL and SQL to one transaction. Long-running pipeline and streaming MCP execution carry a trusted transaction-free tenant scope; each database helper opens a short scoped transaction, avoiding a held pool connection during LLM or network waits. Detached tasks start only after commit and create their own short UoW; rollback cancels them.public. Both migrator and runtime sessions must report current_schema() = 'public' and current_schemas(false) = ARRAY['public']; the runtime role and business database must not carry a search_path override. Runtime startup validates this before using the prepared schema and reruns the complete catalog and privilege validation on every process start; it never runs DDL.session_replication_role = 'origin', row_security = 'on', and lo_compat_privileges = 'off'. Every persistent setting applicable to the runtime role or current business database in pg_db_role_setting is rejected, even if its present value appears safe; tenant context remains transaction-local application state rather than a persistent role/database override.CONNECT on the dedicated business database and USAGE on public; exact SELECT, INSERT, UPDATE, DELETE on every allowlisted business table; SELECT only on alembic_version; and exact USAGE, SELECT on business-owned sequences. It grants neither CREATE, TRUNCATE, REFERENCES, TRIGGER, sequence UPDATE, nor any privilege with WITH GRANT OPTION, and grants nothing on other relations or schemas.LOGIN role with no superuser, BYPASSRLS, CREATEDB, CREATEROLE, or replication attribute; no role membership in any direction, including acting as grantor; no ownership of the business database, public schema, relations, sequences, routines, or extensions; no column ACLs; and no use, create, or ownership in another non-system schema. Neither the runtime role nor PUBLIC may have an explicit routine or parameter ACL, and the runtime role may not effectively execute any SECURITY DEFINER routine, including an extension-owned one. PostgreSQL's default TEMP privilege inherited from PUBLIC is an explicit first-release compatibility decision for this dedicated business database, not a direct runtime-role grant.vector and may contain no extension other than plpgsql and vector; the runtime role owns neither. It contains no foreign data wrapper, foreign server, or user mapping. These checks remove catalog-level escape paths without forbidding the ordinary implicit execution of non-SECURITY DEFINER built-in routines.ENABLE and FORCE ROW LEVEL SECURITY state of each RLS-protected source table, temporarily suspends those source policies as their table owner inside the migration transaction, and restores every table to its recorded state in finally. The migrator does not require superuser or BYPASSRLS for this data move.mcp.stdio.enabled is independent of Box availability and entitlement. OSS defaults it on for compatibility; Cloud requires it off at bootstrap and enforces the same gate on create, update, test, startup loading, and final runtime execution.mcp-shared sandbox for each Workspace and bypass the one-sandbox subscription and cost boundary.TenantScopedAsyncSession and one root transaction. Public commit, rollback, close, connection, bind, nested-transaction, synchronous-Session, live-streaming, raw SQL, public execution options, and public set_config paths fail closed and mark the transaction rollback-only. ORM objects cannot expose a usable synchronous Session, captured methods cannot run in child tasks, an explicit foreign bind is rejected, and a captured Session is permanently retired when its UoW exits rather than being reset for reuse. Tenant scope is installed only through a private UoW capability; pgvector index-plan SET LOCAL/EXPLAIN diagnostics use a test/operator connection rather than the business Session API.TextClause, literal SQL columns, textual labels, prefixes/suffixes/hints, statement execution options, VALUES roots, INSERT FROM SELECT, EXTRACT, literal-execute parameters, unknown/custom AST nodes, forced-unquoted identifiers, named ON CONFLICT constraints, unknown dialect post-values clauses, and untrusted casts/types fail closed. PostgreSQL/SQLite ON CONFLICT DO UPDATE and batch-insert containers are traversed explicitly because SQLAlchemy's standard visitor omits their executable values. Function classes are exactly allowlisted as count, coalesce, sum, now, length, and nullif; the only custom operator/cast admitted is the validated pgvector cosine operator and Vector cast.DATETIME rows and both the historical PostgreSQL TEXT and fresh-schema JSON settings columns while keeping every value bound rather than interpolated.SessionEvents are unsupported on a tenant-scoped Session. If a listener is registered before or during a UoW, the operation fails before the callback executes and cleanup proceeds against an empty dispatch surface. Public get, get_one, refresh, and merge reject caller-supplied loader, bind, lock, shard, and execution options. Flush, implicit autoflush, and commit reject a SQL expression assigned to a mapped attribute before it can reach the compiler. Tenant code uses the async Session directly; relationships use eager loading or explicit await session.refresh(entity, [attribute]). LangBot's persistence base does not expose AsyncAttrs.awaitable_attrs as a supported tenant API.SET LOCAL plus RLS protects a tenant only while every statement stays on the same owned connection and callers cannot end the transaction, replace the GUC, recover the synchronous proxy, or route a statement through another bind.asyncio.gather open their own explicit transaction-free tenant scope before calling persistence-backed Plugin, MCP, or Skill operations. They never inherit the parent's active database Session.resource.view. Monitoring export requires data.export; system/runtime audit logs keep audit.view. Frontend tabs and controls use the same split.data/plugins, and rejects legacy install/restart/delete/upgrade control actions. Worker RPC files use installation-private directories with aggregate size enforcement, and resident nsjail workers explicitly disable the default 600-second wall-time limit.CLOUD_V2_INSTANCE_UUID. A legacy marketplace-only Space deployment with no Cloud v2 instance configured keeps its existing Account registration path; Cloud v2 internal endpoints still fail closed instead of inventing an instance identity.free or pro; plan names, prices, payment providers, and fulfillment stay in Space.REPEATABLE READ transaction. After bootstrap, each event page names affected Workspaces and Core fetches one signed directory.delta for that set. Missing requested Workspaces are tombstones; unrelated Workspaces are untouched.cloud_projection Account whose Space subject UUID and normalized email exactly match. It cannot create an Account, relink by email, mutate directory identity, or choose a Workspace.