Prima Get an API key

Module kit / Foundations

Workspace scoping

_shared/workspace-scoping

Kind shared
Applies to backend
State written
Maturity prod
Reusable yes
Stack fastapi-0.118, starlette-0.48, sqlalchemy-2.0-sync, postgres, starlette-middleware
Depends on _shared/auth-dependency-pattern
Reference implementation
  • alchemy_labs/backend/api/dependencies.py
  • alchemy_labs/backend/middleware/workspace_middleware.py
  • alchemy_labs/backend/services/workspace/models.py
  • alchemy_labs/backend/services/workspace/tenant_service.py
  • alchemy_labs/backend/services/workspace/isolation.py
  • alchemy_labs/backend/services/service_desk/access.py
  • alchemy_labs/backend/api/routers/workspace_router.py
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/services/primaapi/identity.py
  • alchemy_labs/backend/services/commerce/checkout_service.py
  • alchemy_labs/backend/migrations/versions/20260504d_workspace_rls_policies.py
  • alchemy_labs/scripts/check_workspace_isolation.py
  • alchemy_labs/frontend/src/services/api.ts
  • prima/prima/auth/claims.py
  • prima/prima/api/auth.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-11
Also measured on
  • prima feature/per-model-pricing cd2c248 2026-09-11
  • alchemy_labs w1-auto-topup 8b5361c 2026-09-11
  • philosophers_stone main d4f5b95 2026-09-11
Version 1

Purpose

A signed-in person sees exactly the rows of the workspace they are working in, and a record id they were not given returns nothing, not somebody else's data. The invariant that makes it trustworthy is that the tenant is never a value the handler chooses: it is resolved once per request by one dependency (WorkspaceID) that has already checked the caller belongs there, and every query on a tenant table carries workspace_id = <that value> in its WHERE clause.

This foundation fixes, for every backend recipe: where the tenant comes from (the X-Workspace-ID header, re-validated; the single workspace a user has; a resource row on a public surface; the key row on the gateway), what a route answers when it cannot resolve one (400, 401, 403 or 409, never a guess), how a foreign id is refused (404 on a child resource, 403 on the workspace itself), and which accounts are confined to one product (desk_requester).

When to use

Include it from every kind: backend recipe whose tables carry a workspace_id column, which is every domain except ui-design-system and the platform-global tables listed under Data model. Read it before adding a route, before writing a service method that takes a user_id, and before deciding what a public embed is allowed to know. Trigger phrases: "which workspace is this", "the header is missing", "409 on first login", "can a member of B read A's form", "filter by created_by", "the API key has no workspace", "the practice account can see the whole platform", "who may change billing".

Not this recipe: who the caller is (_shared/auth-dependency-pattern, S5: get_current_user, the dsk and aem claims, surface grants); machine-to-machine seams that carry no session (_shared/hmac-seam-pattern, S10); the Prima gateway's own tenant table and key rows (the ai-gateway recipes, with prima as the reference per decision D3, 2026-09-11). Where this recipe and the gateway meet, the rule is the same and stated once below: the tenant comes from the credential, never from the body.

Stack assumptions

FastAPI with plain def routes on a synchronous SQLAlchemy Session (api.dependencies.get_db), Postgres, and, on the reference, one Starlette BaseHTTPMiddleware registered in app composition (_shared/fastapi-project-layout, S4). S5 is assumed: get_current_user resolves the account from a Bearer token, the access_token cookie or X-API-Key, in that order (api/dependencies.py lines 187 to 266 at fd8558f).

Versions, measured 2026-09-11 on alchemy_labs fd8558f (backend/requirements.txt lines 32, 109, 110): fastapi==0.118.2, SQLAlchemy==2.0.43, starlette==0.48.0; the test Postgres the checks below run against is postgres:14 (docker-compose.test.yml line 6), and the RLS policies use nothing newer than that. The middleware is middleware/workspace_middleware.py (registered at app_fastapi.py lines 528 to 533 with workspace_header="X-Workspace-ID", enable_subdomain=False, enable_path=True); the dependency is api/dependencies.py lines 680 to 779. Prima (prima cd2c248) reproduces the same resolution table offline from JWT claims in prima/auth/claims.py lines 195 to 252 and reads the header in prima/api/auth.py lines 110 to 141.

Environment: none of this reads a configuration key. The two Postgres GUCs the RLS policies consume are app.workspace_id and app.current_user_id, set per session by get_db_with_rls (api/dependencies.py lines 268 to 336) and cleared in its finally. A route that needs RLS declares DatabaseSessionWithRLS instead of DatabaseSession; nothing else changes. Fail behaviour when the containment query cannot run: DeskAccessUnavailable is raised and the request is answered 503, never waved through (services/service_desk/access.py lines 367 to 391).

What a new app builds from this, as rules rather than as a copy of Labs (all measured 2026-09-11 at fd8558f): the dependency only, reading X-Workspace-ID as a UUID and nothing else (no slug lookup, no path extraction, no required-prefix list, no middleware; those are the Labs quirks in Gotchas). The middleware is Labs history. What it adds on the reference is a refusal before the handler runs, which the dependency gives at the same status codes, and the echoed response header, which proves only that a request header was validated (Application logic step 10) and which S7 records as the layer whose refusals carry no request id (Deploy & ops); the 2026-09-12 rebuild built the dependency alone and every check under Acceptance checks that a new app can run passed. A new app that wants the echo writes the resolved id to request.state.workspace_id from the dependency and lets the request-context middleware (S4 Application logic step 7) copy it to the response. The personal workspace POST /api/workspaces/bootstrap mints is named <first name>'s Workspace, falling back to <username>'s Workspace (api/routers/workspace_router.py lines 125 to 128), and its slug is the lower-cased name with runs of spaces and punctuation collapsed to hyphens plus a four-character random suffix (services/workspace/tenant_service.py lines 392 to 403). The role ladder is viewer 1, member 2, admin 3, owner 4, with desk_requester and guest at 0 (services/workspace/isolation.py lines 146 to 154); a rung is derived from the one owner-or-member predicate, so the owner without a membership row scores as owner. The RLS GUCs are SET LOCAL statements issued on the request's session after the access check passed (core/database.py lines 406 to 416, set_rls_context; the values are regex-checked as UUIDs first because SET LOCAL takes no bind parameters, lines 388 to 404) and RESET in the dependency's finally (clear_rls_context, lines 424 to 441).

Data model

The tenant tables (services/workspace/models.py, measured 2026-09-11):

-- workspaces (lines 17 to 46)
id           uuid PRIMARY KEY
name         varchar(255) NOT NULL
slug         varchar(255) NOT NULL UNIQUE      -- idx_workspace_slug
owner_id     uuid NOT NULL REFERENCES users(id) -- idx_workspace_owner
is_active    boolean DEFAULT true               -- idx_workspace_active
created_at, updated_at timestamp

-- workspace_users (lines 49 to 79)
id           uuid PRIMARY KEY
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE
user_id      uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE
role         varchar(50) NOT NULL DEFAULT 'member'  -- owner | admin | member | viewer | desk_requester
is_active    boolean DEFAULT true
joined_at, updated_at timestamp; added_by uuid REFERENCES users(id)
UNIQUE (workspace_id, user_id)                  -- idx_workspace_user
INDEX (workspace_id, is_active), INDEX (user_id, is_active)

-- workspace_settings (lines 82 to 103): one JSON document per workspace
workspace_id uuid NOT NULL UNIQUE REFERENCES workspaces(id) ON DELETE CASCADE
settings     json NOT NULL DEFAULT '{}'

-- workspace_invitations (lines 106 to 139): email, role, token UNIQUE, expires_at, accepted

role is a string with no CHECK constraint; the values the code reads are the four in the column comment (line 59), desk_requester, the one value in DESK_ONLY_ROLES (services/service_desk/access.py line 58), and guest, which the SPA types (frontend/src/contexts/WorkspaceContext.tsx line 30) and services/auth/permissions.py line 279 maps to a permission set; the backend accepts any string. The model comment predates the desk; the tuple and the permission map are the fact.

Membership is two facts, not one. A user has access to a workspace when workspaces.owner_id = user OR an active workspace_users row exists; every fence in this recipe asks both (_user_has_workspace_access, api/dependencies.py lines 621 to 642; the middleware's outer join, workspace_middleware.py lines 314 to 331; the gateway's copy, services/primaapi/identity.py lines 82 to 95). Owners without a membership row exist on production, which is why TenantService.get_user_workspaces was changed to count ownership (services/workspace/tenant_service.py lines 146 to 173; the comment there explains the duplicate workspaces the inner join used to mint).

Every tenant table carries the same column: workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, indexed (models/commerce_models.py lines 25, 115, 159 and 201 are the commerce examples; models/forms.py, models/crm_model.py and models/tracking_model.py follow the same shape). scripts/check_workspace_isolation.py is the ratchet: it walks every SQLAlchemy model and fails on a table that lacks the column unless the table is in GLOBAL_TABLES (lines 41 to 56: users, workspaces, workspace_users, workspace_settings, workspace_invitations, the template catalogues, alembic_version, session_merge_logs, webauthn_challenges, subscription_plans, tag_categories, brand_packages), in CHILD_TABLES (scoped through a parent FK, tracking_events via tracking_sessions), or in the tolerated baseline (scripts/workspace_isolation_baseline.txt, 126 table names on 2026-09-11). At fd8558f the ratchet is red: 19 tables added after the baseline was written are in none of the three lists (Gotchas, 2026-09-11).

Row-level security, opt-in (migrations/versions/20260504d_workspace_rls_policies.py, revision workspace_rls_001, 2026-05-04): 13 tables (leads, form_templates, client_tags, tracking_sessions, tracking_events, tracking_contacts, content_reviews, content_review_tokens, content_review_events, emdash_sites, emdash_deployments, retargeting_funnels, outreach_campaigns, lines 36 to 50) get ENABLE and FORCE ROW LEVEL SECURITY (lines 54 to 59, FORCE because the application connects as the table owner) and one policy:

CREATE POLICY workspace_isolation ON <table> FOR ALL
  USING      (current_setting('app.workspace_id', true) IS NULL
           OR current_setting('app.workspace_id', true) = ''
           OR workspace_id = current_setting('app.workspace_id', true)::uuid)
  WITH CHECK (the same three clauses)

The IS NULL clause is what makes it opt-in: a session that never set the GUC (a background job, a webhook, a route on plain DatabaseSession) passes through. The policy is the second fence, never the first.

What is NOT stored: the header value. A tenant the middleware validated lives on request.state.workspace_id for the life of one request and is echoed back as a response header (workspace_middleware.py lines 92 and 134 to 135); a tenant the dependency resolved on its own (the single-workspace fallback, or an API-key request on a path the middleware skipped) lives only in the handler's argument, because _get_workspace_id never writes request.state (api/dependencies.py lines 680 to 777, measured 2026-09-11). No table records "which workspace the client said".

API surface

The contract on every tenant route, in the order a request meets it:

SignalWhere it is readAnswer when it is wrong
X-Workspace-ID: <uuid>middleware (workspace_middleware.py lines 150 to 161), then re-read by the dependency when state is empty (api/dependencies.py lines 712 to 733); on a new app the dependency alonenot a UUID: 400 Invalid X-Workspace-ID header (dependency, line 719); a UUID the caller does not own or belong to: 403 You don't have access to this workspace (middleware line 355, dependency line 732)
no header, one workspacedependency, the LIMIT 2 query (lines 741 to 758)resolved silently
no header, several workspacesdependency (lines 761 to 765)400 Workspace context required (user has multiple workspaces; pass X-Workspace-ID header)
no header, no workspacedependency (lines 770 to 777)409 {"error": "no_workspace", "message": "User has no workspace. Call POST /api/workspaces/bootstrap to create one."}
header, no credentialmiddleware ignores it (lines 94 to 104); dependency answers 401 Authentication required for workspace context (line 724) when it runs before the auth dependency (parameter order, Security & tenancy)the header never sets state for an anonymous request
a desk-only account outside /api/desk/, /api/auth/, GET /api/workspaces and GET /api/workspaces/{id}get_current_user (lines 160 to 184), the middleware (lines 334 to 347), the dependency (lines 645 to 677)403 {"error": "desk_only_role"} from the dependency and get_current_user; from the middleware the same token as a bare string detail, which its own handler wraps as {"error": "desk_only_role"}

Routes this recipe owns (api/routers/workspace_router.py, mounted at /api/workspaces, surfaces LABS_SURFACES, app_fastapi.py line 823; the whole prefix is on the middleware's skip list because these routes name their workspace in the path and check it themselves):

  • POST /api/workspaces/bootstrap (lines 102 to 136), get_current_user, idempotent: returns the first workspace the user owns or belongs to, else creates a personal one through TenantService.create_workspace (workspace, owner membership, settings row in one transaction). 200 with the workspace. This is the only answer to a 409 no_workspace.
  • POST /api/workspaces/ (139 to 175), creates; the caller becomes owner_id. 201; 400 on a taken slug.
  • GET /api/workspaces/ (177), the caller's workspaces, ownership counted.
  • GET /api/workspaces/{workspace_id} (204 to 245): IsolationService.verify_workspace_access, then the row. A non-member gets 403 here, not 404: the workspace is the thing the caller named, so "you may not" is the honest answer and reveals nothing the UUID did not already say.
  • PATCH, DELETE /{workspace_id}; GET, POST, PATCH, DELETE /{workspace_id}/members[/{user_id}]; GET, PATCH /{workspace_id}/settings; the integration documents (/smtp, /imap, /payrexx, /quentn, /email-design, lines 670 to 1104). The PUTs on /smtp, /imap and /payrexx require required_role="admin" (lines 699, 812, 919); the PUTs on /quentn (line 1014, an API key encrypted at rest) and /email-design (line 1078) go through _assert_ws_access (lines 996 to 999), which checks membership and no role, so any active member may write them (Gotchas, 2026-09-11). The recipe's rule is the first shape: a credential-bearing or money-bearing document is written by an admin or the owner.

Child resources everywhere else: GET /api/forms/templates/{template_id}/submissions is the pattern (api/routers/forms_router.py lines 503 to 527): workspace_id: WorkspaceID in the signature, the parent loaded with id = ? AND workspace_id = ?, 404 Template not found or unauthorized when the row is absent. Cross-tenant ids are indistinguishable from nonexistent ones on purpose.

The gateway (/api/prima/v1; Prima cd2c248 is the reference, the in-Labs copy at services/primaapi/identity.py is legacy per D3): the tenant is prima_api_keys.tenant_id for a key (prima/auth/keys.py line 122), and for a session it is resolved from the tid and tids_n claims plus the header by resolve_tenant (prima/auth/claims.py lines 195 to 252) with codes a client can switch on: 400 workspace_invalid (malformed header, param: X-Workspace-ID), 409 no_workspace (tids_n == 0), 403 workspace_forbidden (the header names a workspace the subject is not in, or does not equal tid when tids_n == 1), 400 workspace_ambiguous (tids_n > 1 and no header). The status codes are the ones Labs returns for the same situations; the machine codes are Prima's addition, and the envelope is _shared/error-envelope's OpenAI-shaped one.

Application logic

One request against a tenant route on Labs, in order (all anchors fd8558f):

  1. WorkspaceMiddleware.dispatch (workspace_middleware.py lines 67 to 137). If the path starts with one of EXCLUDED_PATHS (/api/auth/, /api/public/, /docs, /redoc, /openapi.json, /health, /api/workspaces/, lines 36 to 44) the middleware does nothing. Otherwise it reads the header; a value that is not a UUID is tried as a slug (lines 157 to 161); with enable_path=True it also reads /workspace/{id}/... (lines 172 to 181), a shape no mounted router uses.
  2. With a workspace id in hand it identifies the caller itself (lines 221 to 282): request.state.user_id if an earlier middleware set it, else the Bearer or cookie token decoded through auth_service.decode_token_with_surface, else the X-API-Key hash looked up in api_keys. This duplicates the auth dependency because the dependency runs after the middleware and the middleware must refuse before the handler sees the request.
  3. Identified: _validate_workspace_access (lines 284 to 367) runs the owner-or-member query; a membership whose role is in DESK_ONLY_ROLES must also pass desk_only_path_allowed(path, method) (access.py lines 405 to 423: /api/desk/**, /api/auth/**, GET /api/workspaces, GET /api/workspaces/{uuid} and nothing under it). Pass: request.state.workspace_id is set. Fail: 403. Anonymous with a header: the header is logged and dropped (lines 94 to 104); a public endpoint derives its tenant from its resource, never from this header.
  4. No header at all and the path starts with one of workspace_required_prefixes (lines 381 to 390): 400 {"error": "workspace_required", "message": ...} before any handler. See Gotchas for which of the six prefixes match a mounted router.
  5. get_current_user (api/dependencies.py lines 187 to 266) resolves the account and, for a token carrying dsk: true or an API key whose owner is_desk_only_user, applies the containment allow-list again (_enforce_desk_only_containment, lines 160 to 184). This is the one fence that closes on every authenticated route, including routes the middleware skipped; the middleware fence closes only when a workspace was named.
  6. WorkspaceID (_get_workspace_id, lines 680 to 777): return request.state.workspace_id if the middleware set it; else resolve the user id again (_resolve_request_user_id, lines 576 to 618, needed because the middleware writes request.state.workspace_id and never request.state.user_id, line 92 being its only state write, so the dependency re-derives the caller on every path it handles itself), refuse desk-only context (lines 645 to 677), re-validate a header if present, fall back to the single workspace, refuse ambiguity with 400, refuse none with 409. The handler receives a UUID that has been access-checked on every path.
  7. The handler filters: Model.workspace_id == str(workspace_id) on every read (forms_router.py lines 518 to 521, 654, 806, 847, 1142, 1204, 1250, 1295, 1339), stamps the same value on every insert, and answers 404 when a parent row is absent under that filter. user_id and created_by are attribution columns, never the tenant filter; the owner-or-member rule is the only thing that decides tenancy.
  8. A public surface (a hosted form, a checkout, a booking page, a tracking beacon) loads its resource first and takes workspace_id from that row: checkout_service.start_checkout (services/commerce/checkout_service.py lines 46 to 47, 60 to 66, 110) resolves the product, refuses a missing one with 404, and writes Order.workspace_id = product.workspace_id, so orders.workspace_id is always the selling merchant and never a value in the request body. Any child id in the body is re-loaded filtered by that same workspace and refused with 404 on mismatch (the comment at lines 88 to 93 states the pattern for later phases).
  9. A billing write on the gateway (the six at 8b5361c: POST /credits/topup, PUT and DELETE /credits/auto-topup, and the legacy POST /sparks/checkout, /sparks/change and /sparks/cancel; _require_billing_role(db, ctx) at api/routers/primaapi_router.py lines 1011, 1345, 1513, 1794, 2028 and 2220, the last three added by the head commit itself) additionally requires that the caller is the workspace owner (workspaces.owner_id, the column) or holds an active membership with role owner or admin: user_may_change_billing (services/primaapi/identity.py lines 107 to 143 on branch w1-auto-topup at 8b5361c, commit 3d21356, 2026-09-11; founder decision A-1 the same day). Anyone else is 403 {"error": "owner_required"}. Reads stay open to any active member. The rule composes the two predicates the platform already has, the ownership half of _user_has_workspace_access and the role rung of IsolationService.verify_workspace_access(required_role="admin"), in one text-SQL query. This branch is unmerged; see Gotchas.
  10. The response carries X-Workspace-ID: <uuid> only when the middleware itself set state at step 3 (workspace_middleware.py line 92, echoed at lines 134 to 135), which is to say only when the request already carried a valid header the caller belongs to; the value is then the one the client sent. A tenant resolved by the dependency at step 6 (the single-workspace fallback, an API-key request on a skipped path) is never written to state and never echoed, so the header is not a way to learn what the dependency resolved (measured 2026-09-11 at fd8558f; the deviation is listed under Reference implementation).

On Prima (cd2c248) the same request costs no Labs call in the common case: tids_n == 1 means tid is in the token and is the answer; tids_n > 1 requires the header and checks it against the introspected workspaces set; tids_n == 0 is 409 (prima/seams/identity.py lines 123 to 137, prima/auth/claims.py lines 222 to 252). Prima deliberately does not pick a default for a multi-workspace subject: "it silently reattributes a multi-workspace customer's spend" (prima/seams/identity.py lines 135 to 136).

Invariants

  • Every route on a tenant table declares workspace_id: WorkspaceID and filters on it; a route that filters by user_id or created_by alone is a cross-tenant leak (ADR-001, decided 2026-05-27; the sweep that enforced it is commits 37c7157 and 88370e3, 2026-05-28, which the ADR describes as "~100+ vulnerable endpoints across forms, outreach, design, CRM, tracking, and SEO routers"). At fd8558f grep -rc "workspace_id: WorkspaceID" api/routers/ finds 24 routers, the largest counts being accounting_router.py (39), commerce_router.py (25), forms_router.py (24).
  • The tenant is never guessed: one workspace resolves, several are a 400, none is a 409, and the 409 names the one endpoint that fixes it (ebb0e3c, 2026-05-04, "Remove workspace-guess fallbacks; require explicit context").
  • The header is re-validated wherever it is read; a header on an anonymous request sets nothing.
  • Cross-tenant child ids are 404, the named workspace itself is 403 (tests/test_desk_lifecycle.py lines 957 to 1000 pin both: workspace B gets 404 on every ticket route of A, a stranger gets 403).
  • Ownership counts: every fence is owner_id = user OR active membership, never membership alone (the one exception, IsolationService.verify_workspace_access, is in Gotchas).
  • A desk-only account never owns a workspace (_ensure_workspace skips it, auth_router.py lines 193 to 210; is_desk_only_user returns false for any owner, access.py lines 389 to 391) and never leaves the allow-list; the question "is this account desk-only" fails closed with 503, never open with False.
  • Every account that can sign in has a workspace after sign-in: _ensure_workspace runs on the login paths (auth_router.py lines 605 and 1218) and create_api_only_account writes the workspace in the same transaction as the user row (identity.py lines 401 to 420).
  • A public surface derives its tenant from the resource row and accepts a signal (an allowlisted scalar), never a stored field; orders.workspace_id is the selling merchant.
  • A billing instruction is changed by an owner or an admin, never by a plain member (A-1, w1-auto-topup, unmerged).
  • RLS is defence in depth: a route on DatabaseSessionWithRLS sets the GUC only after _user_has_workspace_access passed (api/dependencies.py lines 302 to 316); no route relies on the policy as its only filter.

Frontend

The modules that render this, by id (recipes in identity-auth/, to be written; ids from the inventory of 2026-09-11):

identity-auth/
  ui/workspace-switcher-and-bootstrap   SPA: WorkspaceContext + the switcher in AppShell, currentWorkspaceId in localStorage,
                                        hard reload on switch, the role badge (owner/admin/member/viewer/guest/desk_requester),
                                        desk_requester confinement rendered as "no role", not as an error
  ui/chat-signin-and-workspace-picker   chat: one workspace = silent, several = searchable picker, zero = its own screen;
                                        nothing auto-selects a tenant
  ui/developer-portal-keys-page         portal: the tenant id shown is read back from /usage, X-Workspace-ID sent from it
  ui/admin-users-console                SPA: /admin/users, memberships per account, the impersonation banner

The HTTP client is _shared/react-app-skeleton (S2): the axios singleton adds X-Workspace-ID: <currentWorkspaceId> to every request when one is stored (frontend/src/services/api.ts lines 39 to 42) and its response interceptor is meant to answer a 409 no_workspace by calling POST /api/workspaces/bootstrap once, storing the id and retrying (lines 82 to 103, _wsBootstrap flag). Which key that interceptor reads is a Gotcha.

Minimum admin and debug surface: the X-Workspace-ID response header, present only when the request named a workspace and the middleware validated it (it confirms the header was accepted; it does not reveal what the dependency resolved on a header-less call, Application logic step 10); GET /api/workspaces/{id}/members (the role the switcher shows); python scripts/check_workspace_isolation.py (the ratchet's diff against the baseline, red at fd8558f with 19 offenders); and for a desk-only account, the desk_only_role 403 rendered as the two copy lines in frontend/src/services/serviceDeskService.ts lines 123 and 180 ("Dein Zugang gilt nur für den Service Desk." and "This session is confined to the service desk."), chosen by the surface prop.

Security & tenancy

Auth dependency: get_current_user (S5) first; WorkspaceID after it. WorkspaceID never authenticates on its own: with no credential it answers 401 when a header is present and 400 when none is (api/dependencies.py lines 720 to 724, 738), and it consults the same three credentials S5 does. Which dependency answers a missing credential is decided by parameter order: FastAPI resolves a handler's dependencies in the order they are declared and the first refusal wins (measured 2026-09-12 on fastapi 0.118.2 with two raising dependencies in both orders). The Labs pattern route declares workspace_id: WorkspaceID before current_user: Depends(get_current_user) (forms_router.py lines 504 to 510), so an anonymous caller there gets this recipe's 401 or 400; a route that declares CurrentUser first never reaches either and answers S5's uniform 401 with WWW-Authenticate: Bearer. A new app declares CurrentUser before WorkspaceID on every tenant route, so a missing credential is one answer everywhere, S5's, and the 400 and 401 in the API surface table are what WorkspaceID says when it is declared alone or first.

Workspace derivation, by surface:

SurfaceTenant comes fromNever from
SPA and API-key calls to LabsX-Workspace-ID re-validated, else the single workspacethe body, created_by, a guess
public embeds and pages (/api/f/, /api/public/, checkout, booking, beacon)the resource row (template, product, slug, site)the header (dropped for anonymous requests)
the gateway with a keythe key row (api_keys.workspace_id on Labs, prima_api_keys.tenant_id on Prima; a key with no workspace is refused 400 API key has no bound workspace (tenant), identity.py lines 167 to 168)the header
the gateway with a sessiontid when tids_n == 1; the header, checked against membership, when tids_n > 1a default picked by the server
desk agency views (/api/desk/agency/*)the agent's own membership list across workspaces, no header (service_desk_router.py line 761 onward; tests/test_desk_agency.py lines 64 to 161)the header

Fail direction, stated: access control fails CLOSED. A containment query that errors is 503 (DeskAccessUnavailable); a middleware exception is 500 {"error": "internal_server_error", "message": "An internal error occurred."} with the real exception logged server-side (workspace_middleware.py lines 121 to 129); an unreadable token in the middleware yields no user id, so the header sets nothing and the auth dependency raises the 401.

Attacker-controlled inputs and what stops them: the header (both fences); a path id (the AND workspace_id = ? filter, 404); a body id (re-load under the resolved workspace, 404; the checkout pattern); a slug in the header (resolved by the middleware only, see Gotchas); the Host header (subdomain resolution is off, enable_subdomain=False). The shape the code itself warns against (the docstring of _enforce_desk_only_containment, api/dependencies.py lines 160 to 168): a route outside the middleware's prefixes that takes a workspace id from its body and performs no check of its own is fenced only by the desk allow-list, and only for desk-only accounts. One such route exists at fd8558f and is on the wiki's fix list; a new route never reads a workspace id from a body.

Credentials at rest: none in this recipe. workspace_settings.settings holds per-workspace integration documents whose values are encrypted by their own recipes; reading the whole document is why a desk-only account may fetch a workspace object but nothing under it (access.py lines 398 to 403).

Rate limits: none specific, and the platform limiter runs after this middleware: UnifiedMiddleware is registered at app_fastapi.py line 523 and WorkspaceMiddleware at line 528, the last add_middleware call, so on the inbound path the workspace fence runs first. A request it refuses is neither rate-limited nor request-logged by UnifiedMiddleware (measured 2026-09-11; the same ordering is why the refusal carries no request id, Deploy & ops).

Tests

On the test Postgres of _shared/test-layout (S9), all at fd8558f. Hermetic where the bullet says so; the first file is not:

  • backend/tests/integration/test_workspace_isolation.py: two workspaces, one row each, a query scoped to A returns exactly the A row, per model (test_form_templates_isolated, test_leads_isolated, test_client_tags_isolated, test_tracking_sessions_isolated, test_tracking_contacts_isolated). Order-dependent, not hermetic: each test borrows db.query(User).first() for the owner FK and calls pytest.skip("test DB has no users") when there is none (lines 64 to 67 and 91 to 94), so on a fresh database all five skip unless a test that creates a user ran first in the same session. Measured 2026-09-11: 5 skipped when run on their own.
  • backend/tests/test_desk_lifecycle.py::test_tenant_isolation_other_workspace_404_non_member_403 (lines 957 to 1000): the 404-versus-403 vector, hermetic. Workspace B gets 404 on ten ticket routes (lines 971 to 980), 400 on creating a ticket in a workspace it is not in (line 983) and 403 on GET /api/workspaces/{A}/config (line 986); a stranger gets 403 on six routes including the agency queue (lines 991 to 996). Ten 404s, one 400, seven 403s.
  • backend/tests/test_desk_agency.py: test_agency_filters_and_out_of_scope_workspace_is_403, test_platform_admin_sees_every_workspace.
  • backend/tests/test_desk_acting_email.py: test_the_allow_list_is_narrow (parametrised over method and path), test_is_desk_only_user_refuses_rather_than_defaulting, test_owning_a_workspace_disqualifies_a_desk_only_account, test_an_ordinary_account_carries_no_containment_claim (lines 391, 395, 411 and 505; 27 test functions in the file). Select these four by node id: the whole file does not pass at fd8558f (Gotchas, 2026-09-11, test_requester_owns_is_decided_by_the_acting_address).
  • backend/tests/test_contact_cross_tenant.py: the contact resolver partitions the same email by tenant and the linking job never links across (test_resolver_partitions_same_email, test_linking_job_never_links_cross_tenant).
  • backend/tests/v2/conftest.py::cross_tenant_client (lines 83 to 120): a second user in a second workspace with X-Workspace-ID preset, used by four v2 test files (test_scaffold.py, test_f1_scribe_leak.py, test_f1_scribe_leak_iter2.py and one under coverage/); tests/v2/test_scaffold.py line 21 asserts the header is on the client.
  • backend/tests/test_primaapi_identity.py: test_signup_creates_user_workspace_and_key (line 135) and test_minted_key_is_ai_scope_only_never_labs (line 181), both methods of a class.
  • backend/tests/test_surface_scoped_access.py::test_api_only_account_is_still_a_workspace_owner_and_that_is_now_harmless (line 316).
  • Prima cd2c248, tests/auth/test_claims_and_surfaces.py lines 116 to 198: the whole resolution table as known answers (test_single_workspace_no_header_costs_zero_labs_calls, test_single_workspace_with_a_mismatched_header_is_403, test_multiple_workspaces_without_a_header_is_400, test_multiple_workspaces_with_a_non_member_header_is_403, test_zero_workspaces_is_409_no_workspace, test_a_malformed_workspace_header_is_400, test_tid_present_with_tids_n_greater_than_one_is_refused). Run with PYTHONPATH=. from the repo root: the conftest imports prima and the repo's venv carries no editable install, so a bare pytest stops at collection with ModuleNotFoundError: No module named 'prima'. Ran 2026-09-11 that way: 11 selected, 11 passed.
  • The lint: python scripts/check_workspace_isolation.py from the repo root. At fd8558f it exits 1 and lists 19 new offenders against the committed baseline (Gotchas, 2026-09-11); it exits 0 once those tables are fixed or classified, never by refreshing the baseline.
  • Playwright gate: none dedicated at fd8558f; the workspace switcher is exercised by the S2 shell spec.

Deploy & ops

Config keys: none. The middleware is registered once in app_fastapi.py (lines 528 to 533) after UnifiedMiddleware, which mints request.state.request_id; because Starlette runs the last-added middleware outermost, a refusal from this middleware carries no request id and no X-Request-ID header (measured 2026-09-11; the envelope consequences are in _shared/error-envelope).

Migrations, in order: the tenant tables predate the migration chain; 20260504d_workspace_rls_policies.py (workspace_rls_001, revises tracking_sites_001) enables and forces RLS on the 13 tables and is reversible (_drop_policies, _disable_rls); 20260903_0002_projects_workspace_id.py adds the nullable projects.workspace_id. A new tenant table ships with its workspace_id NOT NULL FK in the same migration as the table, and python scripts/check_workspace_isolation.py --write-baseline is NOT run for it: the ratchet is meant to tighten, and a new table outside GLOBAL_TABLES and CHILD_TABLES that lacks the column fails it.

Scheduler jobs: none. Background jobs run on plain sessions and pass the RLS policies through; they filter by workspace_id in their own SQL.

Argus checks: none specific; the 403 and 409 counts are visible in the platform's request log by status.

Rollback: the RLS migration downgrades cleanly; nothing else is deployable state. The ratchet script is not wired into .github/workflows/ci-cd.yml at fd8558f (measured 2026-09-11), so it runs when somebody runs it.

Gotchas

  • 2026-05-04 (66be4f7, "Enforce workspace access on JWT and API-key requests"): the middleware started identifying the caller itself and validating membership. The wiki's ADR-001 (operations/decisions/0001-workspace-id-dependency-for-tenant-isolation.md, updated 2026-05-28) and platform/architecture.md line 268 still open with "WorkspaceMiddleware skips its access check for JWT-authenticated requests". At fd8558f it does not (workspace_middleware.py lines 221 to 282); the code wins, and the ADR sentence describes the state before that commit.
  • 2026-05-28 (37c7157, 88370e3): the isolation sweep that added WorkspaceID to the routers ADR-001 lists. The ADR dates the decision 2026-05-27; the commits are dated 2026-05-28.
  • 2026-09-11 (measured at fd8558f): api/routers/prima_design_router.py lines 270 to 276 still return uuid4() from _get_user_workspace ("Placeholder"), used at lines 45, 119 and 173, and the router is mounted (app_fastapi.py line 970). Its workspace filter matches nothing by construction. The inventory records the editor as retired by the founder on 2026-08-28 and still deployed; a new app never copies this file.
  • 2026-09-11 (measured at fd8558f): the middleware's workspace_required_prefixes (workspace_middleware.py lines 381 to 388) name six prefixes; only /api/connections and /api/dashboards are mounted (app_fastapi.py lines 828 and 830). /api/queries misses the real mount /api/query (line 829), and /api/chat, /api/pipelines and /api/database are not mounted at all. The 400 workspace_required short-circuit therefore fires on two prefixes; every other route gets its 400 from the dependency instead. Same outcome, different envelope ({error, message} from the middleware versus the Labs envelope from the handler).
  • 2026-09-11 (measured at fd8558f): the SPA's 409 bootstrap reads error.response.data.detail (frontend/src/services/api.ts lines 84 to 88, written 2026-05-04 in eb9716f), but the Labs HTTPException handler has emitted {error, code, request_id, timestamp, details} since the initial commit (ffe7ebf, 2025-11-12) and never a detail key: at fd8558f the 409 arrives as error: "no_workspace" with the dict in details (a360b2c, 2026-09-04). The interceptor's condition is false on every such response, so the automatic bootstrap does not run. It is not observed in practice because _ensure_workspace runs on every login path and create_api_only_account writes the workspace at signup, so the 409 is reached only by an account created outside those paths. Fix on the client: read data.error === 'no_workspace' || data.details?.error === 'no_workspace'.
  • 2026-09-11 (measured at fd8558f, wiki fix-list item B87): GET /api/prima/conversation/history (api/routers/prima_router.py line 1184) declares no WorkspaceID and is not tenant-scoped; the sibling /conversation/latest (line 1200) is user-scoped. On the wiki's D8 list to be fixed before its wave; this entry is the reason for that fix, not a description of what the route returns.
  • 2026-09-11 (measured at fd8558f, wiki fix-list item B127): GET /api/content-review/requests/{review_id}/active-token (api/routers/content_review_router.py line 174) takes get_current_user and no WorkspaceID. In the same service, _resolve_workspace_id (services/content_review_service.py lines 140 to 174) derives the workspace from the project owner's single active workspace and never reads the header, while its own 409 text says "Specify workspace via X-Workspace-ID". On the D8 list, same rule as B87.
  • 2026-09-11 (measured at fd8558f): IsolationService.verify_workspace_access (services/workspace/isolation.py lines 109 to 163) checks the workspace_users row only, so an owner without a membership row is refused by it while _user_has_workspace_access admits the same person; and its role ladder viewer 1, member 2, admin 3, owner 4 (lines 146 to 154) scores any other string (desk_requester, guest, agency) as 0, below viewer. It is called from workspace_router.py (15 sites) and commerce_router.py (1, line 1299). A new app has one predicate, the owner-or-member SQL, and derives the role rung from it.
  • 2026-09-11 (measured at fd8558f): PUT /api/workspaces/{id}/quentn (workspace_router.py line 1014) stores an API key encrypted at rest and is gated by _assert_ws_access (lines 996 to 999), membership with no role, as is PUT .../email-design (line 1078); the /smtp, /imap and /payrexx PUTs require admin (lines 699, 812, 919). A plain member can rewrite the Quentn credential. The recipe's rule (API surface) is the admin gate on all five.
  • 2026-09-11 (measured at fd8558f): projects.workspace_id is nullable (models/project_models.py lines 51 to 57, migration 20260903_0002); pre-migration rows carry no value and read paths treat that as "legacy, ownership unverified" rather than refusing. ADR-001 recorded projects as user-scoped by design; the column arrived on 2026-09-03 and the backfill has not.
  • 2026-09-11 (measured at fd8558f): the inventory's B15 row says get_db_with_rls is "used by no router". Five routers declare DatabaseSessionWithRLS at 22 parameter sites (27 occurrences counting the five import lines: dashboard_router.py, query_router.py, connections_router.py, accounting_google_router.py, accounting_gmail_router.py). The code wins; RLS is live on those routes and opt-in everywhere else.
  • 2026-09-11 (measured at fd8558f): the middleware accepts a workspace slug in X-Workspace-ID (lines 157 to 161) and resolves it through TenantService.get_workspace_by_slug; the dependency refuses the same value with 400 when the middleware did not run (an excluded path, or an API-key request on a path the middleware skipped), and Prima refuses it with 400 workspace_invalid. The contract is UUID only; the slug path is a convenience nobody should rely on.
  • 2026-09-11 (measured at fd8558f): _ensure_workspace (auth_router.py lines 176 to 225) asks "does this user own a workspace", not "does this user have one": an account that is only a member of somebody else's workspace gets a personal workspace minted at its first login and is a multi-workspace user from then on, so every call without the header is a 400 until the SPA has stored currentWorkspaceId. Deliberate per ADR-001's corollary ("every user must have a workspace"); worth knowing when a member reports "workspace context required".
  • 2026-09-11 (measured): user_may_change_billing and the owner_required refusal exist only on alchemy_labs branch w1-auto-topup (commit 3d21356, head 8b5361c, forked from feat/service-desk at 1379a38), not on d1a-token-source or the deployed line. Decision D2 (2026-09-11) makes the prepaid ledger in Prima the single truth, and the inventory records the W1 branch as failed in wave 1. The rule stands as the recipe's rule; the reference for it is that branch until it lands.
  • 2026-09-11 (measured at fd8558f): scripts/check_workspace_isolation.py tolerates 126 tables through its baseline, is not invoked by CI, and is red: it exits 1 and names 19 new offenders that are in neither the baseline nor GLOBAL_TABLES nor CHILD_TABLES. Sixteen lack the column (prima_credit_grants, prima_key_syncs, prima_signup_intents, primaapi_quota, primaapi_usage_events, primalayered_job_budgets, primalayered_spend_policy, spark_media, spark_progress, spark_work_items, spark_work_orders, spark_workers, sparks_grants, sparks_low_balance_alerts, subscription_cancel_otps, user_surface_grants), scheduled_messages has it without the FK to workspaces.id, and two carry it nullable (spark_brand_kits, user_login_emails). They are the Prima, Sparks and login tables added after the baseline was written and never classified; the script reads the models of the checkout it sits in (lines 27 to 30), so the list is this tree's. The fix is per table (add the column, or classify it as global or child in the script); a recipe that adds a tenant table runs the script by hand and does not refresh the baseline.
  • 2026-09-11 (measured at fd8558f): tests/test_desk_acting_email.py::test_requester_owns_is_decided_by_the_acting_address (line 276) fails deterministically with TypeError: DeskSettings.__init__() missing 1 required positional argument: 'client_brief': DeskSettings gained the field in dd38691 (2026-09-06, services/service_desk/config_service.py line 83) and the test's literal was last touched in c923b8a (2026-09-05). The four containment tests in the same file pass; select them by node id.
  • 2026-09-11 (measured at fd8558f): the X-Workspace-ID response header is absent on the single-workspace fallback and on every other path the dependency resolves alone, because _get_workspace_id returns the UUID without writing request.state (api/dependencies.py lines 741 to 758) and the middleware echoes only the state it set itself (workspace_middleware.py lines 92 and 134 to 135). The header is a receipt for a validated request header, nothing more.

Reference implementation

Paths are relative to each repo's root; commits as in measured_on and also_measured_on.

  • alchemy_labs (d1a-token-source at fd8558f, whose backend tree is feat/service-desk at b1089b6 unchanged; the two D1a commits touch frontend/, .gitignore and CLAUDE.md, and git diff --name-only b1089b6 fd8558f -- backend is empty):
    • backend/middleware/workspace_middleware.py: EXCLUDED_PATHS (36 to 44), dispatch (67 to 137, the state write at 92, the echo at 134 to 135), _extract_workspace_id (139 to 183), _extract_user_id (221 to 282), _validate_workspace_access (284 to 365), _should_exclude_path (367), _requires_workspace (371 to 390), get_workspace_id, require_workspace, WorkspaceContextDependency (393 to 449).
    • backend/api/dependencies.py: _enforce_desk_only_containment (160 to 184), get_current_user (187 to 266), get_db_with_rls (268 to 336), _resolve_request_user_id (576 to 618), _user_has_workspace_access (621 to 642), _refuse_desk_only_workspace_context (645 to 677), _get_workspace_id (680 to 777), WorkspaceID (779).
    • backend/services/workspace/models.py (the four tables), tenant_service.py lines 146 to 173 (get_user_workspaces, ownership counted), isolation.py lines 109 to 163 (verify_workspace_access).
    • backend/services/service_desk/access.py: DESK_ONLY_ROLES (58), is_desk_only_user (367 to 391), desk_only_path_allowed (405 to 423).
    • backend/api/routers/workspace_router.py: bootstrap_workspace (102 to 136), get_workspace (204 to 245), the admin-gated PUTs (699, 812, 919), _assert_ws_access (996 to 999) and the two membership-gated PUTs (1014, 1078).
    • backend/core/database.py: set_rls_context (363 to 421), clear_rls_context (424 to 441).
    • backend/api/routers/auth_router.py: _ensure_workspace (176 to 225).
    • backend/api/routers/forms_router.py lines 503 to 527: the child-resource pattern.
    • backend/services/commerce/checkout_service.py lines 40 to 115: the public-surface pattern.
    • backend/services/primaapi/identity.py: _user_has_workspace_access (82 to 95), _resolve_tenant_for_jwt_user (98 to 136), _authenticate_ai_scoped_key (139 to 190), resolve_gateway_identity (193 to 233), _create_personal_workspace (401 to 420). Legacy per D3; kept because it is the code that answers on the Labs hosts today.
    • backend/migrations/versions/20260504d_workspace_rls_policies.py; scripts/check_workspace_isolation.py; scripts/workspace_isolation_baseline.txt.
    • frontend/src/services/api.ts lines 32 to 45 and 82 to 103; frontend/src/contexts/WorkspaceContext.tsx lines 197 to 209 (the desk_only_role read) and 215 to 231 (the switch).
  • alchemy_labs (w1-auto-topup at 8b5361c, unmerged): backend/services/primaapi/identity.py lines 107 to 143 (BILLING_ROLES, user_may_change_billing); backend/api/routers/primaapi_router.py lines 845 to 869 (the owner_required refusal) and its six call sites (1011, 1345, 1513, 1794, 2028, 2220).
  • prima (feature/per-model-pricing at cd2c248): prima/auth/claims.py lines 195 to 252 (resolve_tenant); prima/seams/identity.py lines 110 to 137 (the claim set and the resolution table); prima/api/auth.py lines 110 to 144 (current_identity, the header as a typed Header()); prima/auth/keys.py lines 110 to 130 (ApiKeyRecord.tenant_id).
  • Wiki philosophers_stone (d4f5b95): operations/decisions/0001-workspace-id-dependency-for-tenant-isolation.md; platform/architecture.md "Multi-Tenancy"; operations/prima-modules-inventory.md §1.1 row S6, §2 "Public surfaces derive the tenant from the resource row", §6 decisions D2, D3, D8.

Deviations of the references from this recipe, all dated 2026-09-11: the uuid4() placeholder in prima_design_router.py; the two D8 routes that take no WorkspaceID (conversation/history, active-token); the content-review resolver that ignores the header; IsolationService's membership-only check and its role ladder; the two integration PUTs gated by membership rather than by role; the SPA's 409 parser reading a key the server does not send; the middleware's four dead required-prefixes and its slug acceptance; the response header that echoes only a validated request header and never the fallback; projects.workspace_id nullable; the billing role rule living on an unmerged branch; the ratchet outside CI and red with 19 unclassified tables; one desk test failing on a stale constructor.

Acceptance checks

Run without the author at the commits named above. Expected observations are exact.

  1. Labs, backend/: grep -rc "workspace_id: WorkspaceID" api/routers/ | grep -v ":0" | wc -l prints 24; grep -c "workspace_id: WorkspaceID" api/routers/forms_router.py prints 24.
  2. Labs, backend/: sed -n 36,44p middleware/workspace_middleware.py lists exactly seven excluded prefixes, the last being /api/workspaces/; sed -n 381,388p middleware/workspace_middleware.py lists six required prefixes; grep -n 'prefix="/api/query"' app_fastapi.py prints line 829 (the mismatch in Gotchas).
  3. Labs, backend/: grep -n "LIMIT 2" api/dependencies.py services/primaapi/identity.py prints one line in each file (the no-guess rule: lines 752 and 120); grep -c '"error": "no_workspace"' api/dependencies.py services/primaapi/identity.py prints 1 for each.
  4. Labs, backend/: grep -c "owner_id = :uid OR wu.id IS NOT NULL" api/dependencies.py services/primaapi/identity.py prints 2 for each file (the access predicate and the single-workspace fallback); grep -n "Workspace.owner_id == user_id" middleware/workspace_middleware.py prints lines 296 (the docstring) and 325 (the query).
  5. Labs, backend/, the test Postgres on localhost:5434 up: pytest tests/test_desk_lifecycle.py::test_tenant_isolation_other_workspace_404_non_member_403 tests/test_desk_acting_email.py::test_the_allow_list_is_narrow tests/test_desk_acting_email.py::test_is_desk_only_user_refuses_rather_than_defaulting tests/test_desk_acting_email.py::test_owning_a_workspace_disqualifies_a_desk_only_account tests/test_desk_acting_email.py::test_an_ordinary_account_carries_no_containment_claim -q passes with no failure; sed -n 957,1000p tests/test_desk_lifecycle.py | grep -c "status_code == 404" prints 10, the same with 403 prints 7, with 400 prints 1. Running tests/test_desk_acting_email.py whole instead fails exactly one test (test_requester_owns_is_decided_by_the_acting_address, the client_brief TypeError in Gotchas), and pytest tests/integration/test_workspace_isolation.py -q on a fresh test database reports 5 skipped with reason test DB has no users; both are the measured state at fd8558f, not a pass.
  6. Labs, repo root: python scripts/check_workspace_isolation.py; echo $? prints a first line beginning Workspace isolation check FAILED, then 19 offender lines from prima_credit_grants: missing workspace_id column to user_surface_grants: missing workspace_id column (the list in Gotchas), then 1; grep -vc "^#\|^$" scripts/workspace_isolation_baseline.txt prints 126 and grep -c prima_credit_grants scripts/workspace_isolation_baseline.txt prints 0. The check turns green by fixing or classifying those tables, and the baseline file is not rewritten to get there.
  7. Labs, backend/: grep -n "RLS_TABLES = \[" -A14 migrations/versions/20260504d_workspace_rls_policies.py | grep -c '"' prints 13; grep -rl "DatabaseSessionWithRLS" api/routers | wc -l prints 5.
  8. Labs, with the harness backend running (tooling/render/labs-local.sh up, AUTHORING.md "Renderings", API on 127.0.0.1:8020) and the seeded owner's access token from tooling/render/.labs-local/session.json: curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8020/api/forms/templates | grep -ic "^x-workspace-id:" prints 0 on a 200 response (the single-workspace fallback resolves in the dependency and is never echoed; x-request-id and x-process-time are present, x-workspace-id is not); the same call with -H "X-Workspace-ID: $WS", where $WS is the seeded workspace's id from GET /api/workspaces/, prints one x-workspace-id: line equal to $WS (the middleware validated the header and echoed it); the same call with -H "X-Workspace-ID: 00000000-0000-0000-0000-000000000000" returns 403 with body {"error": "You don't have access to this workspace"} (the middleware's own envelope, no code, no request_id); with -H "X-Workspace-ID: not-a-uuid" it returns 400 with error equal to Invalid X-Workspace-ID header in the Labs envelope (the middleware's slug lookup found nothing and set no state, the dependency refused the value).
  9. Labs, frontend/: grep -n "detail?.error === 'no_workspace'" src/services/api.ts prints line 87, and grep -c '"detail"' ../backend/schemas/error_schemas.py prints 0: the mismatch in Gotchas is reproducible by reading. It passes once the interceptor reads error or details.error and this recipe's version is bumped with the date.
  10. Prima, repo root: PYTHONPATH=. pytest tests/auth/test_claims_and_surfaces.py -q -k "workspace or tid or header" selects 11 tests and passes all (without PYTHONPATH=. the run stops at conftest import with ModuleNotFoundError: No module named 'prima'); grep -c "workspace_forbidden\|workspace_ambiguous\|workspace_invalid\|no_workspace" prima/auth/claims.py prints 5.
  11. Labs, w1-auto-topup checkout: grep -n "BILLING_ROLES = " backend/services/primaapi/identity.py prints line 107 with ("owner", "admin"); on d1a-token-source the same grep prints nothing (the branch state in Gotchas).
  12. npm run check in this repo prints 0 problems.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.