# Architecture and design decisions The README describes what GoodWorkshop does and how to install it. This is why it is built the way it is on the inside — for everybody working on it, or wanting to understand an installation more deeply. ## In three sentences Next.js 15 with React 19 and PostgreSQL, one application with tenant isolation that is in every case enforced by row level security in the database. The editor works on a CRDT (Yjs); the relational tables remain the authoritative record. What ships is a single Docker image plus Postgres. ## Tenant isolation Every table carries `tenant_id` and is under `FORCE ROW LEVEL SECURITY`; the policies apply `to gw_app` and compare against `app.current_tenant()`. The application sets that value in every transaction through `withTenant()` — an ESLint guardrail forbids importing the raw `db` handle outside `src/server/db`, so that no query can get around it. `FORCE` is the decisive part: the owner of a table bypasses its own policies unless they are forced — and `gw_owner` owns every table here, because that role runs the migrations. `scripts/migrate.mjs` therefore applies `FORCE` on every run to every table with a policy, so that a table added next year does not quietly miss out. The identity tables (`identity`, `webauthn_credential`, `email_token`, `auth_session`, …) are locked for `gw_app`. Whoever needs them explicitly enters through `set local role gw_auth`; because `gw_app` is NOINHERIT, that right ends with the transaction. An ORM mistake outside the auth module therefore cannot read sign-in material. ### Finding the tenant before a credential names one Most requests arrive with their tenant attached: a session, a personal access token, an OAuth access token and a guest cookie each name one. A handful of moments do not — somebody signing in, a guest opening a share link, an OAuth client registering or redeeming a code, a logged-out visitor looking at the login page, first-run setup. Every one of those asks [`edition`](../src/server/edition/index.ts), and nothing else in the application names a tenant id of its own; `src/server/edition/edition.test.ts` fails the build when something does. The Community Edition answers each question with its one fixed tenant and without a database read, which is exactly how self-hosted installations have always behaved. The seam exists so that an edition with many tenants can answer the same questions from the credential in hand — the identity, the hash of a code or token — without a second code path through sign-in, OAuth or sharing. Which edition a build is gets decided when it is built, never by an environment variable an operator could set: `GW_EDITION` is a Docker build argument, the build points `@gw/edition` at `community.ts` or `cloud.ts` and records the choice in `dist/edition.json`, and migrate and provision read that file. Only a cloud build applies `drizzle-cloud/` — one membership per person, and the SECURITY DEFINER functions that find the tenant behind an identity, a share link, an authorization code or an OAuth token. **Suspended and read-only tenants.** `tenant.status = 'suspended'` is part of the community schema and now holds everywhere a credential is checked: web sessions and guest sessions join the tenant row, as the token resolvers always did, so a suspension signs everybody out at their next request. Read-only is cloud-only (`tenant_lifecycle`, for a trial that ran out or a payment that failed). It is enforced twice, on purpose: `assertWorkshopAccess` withholds every write capability, so screens and MCP tools show a read view instead of an editor whose saves fail; and restrictive policies in `drizzle-cloud/sql/951_read_only.sql` refuse writes to library content in the database, whatever path they take. A cloud test fails when a tenant table is neither guarded there nor named with the reason it is not — a table added to the community schema next year does not quietly stay writable. A read-only tenant's workshop is also read without a row lock, because row level security treats `FOR UPDATE` as an update. **The cloud's website and registration** are routes that only a cloud build has: `page.cloud.tsx` files, which `pageExtensions` counts as pages only when `GW_EDITION=cloud`, and a front page chosen through the `@gw/home` alias. A community image contains none of it — CI checks the image for the routes and for `src/cloud`. Registration writes nothing but a `pending_signup` row until the address is confirmed; the confirmation link runs `app.cloud_complete_signup`, which creates the identity, the tenant, the admin membership, the trial and the billing account in one transaction, so a double click creates one workspace. The form answers the same whether or not an address is known, and a known address gets a mail pointing to the sign-in instead. The cloud is sold to businesses only: the form says so before its first field and asks for it to be confirmed, `parseSignup` refuses anything unconfirmed before it looks at anything else, and the database accepts no other customer type. A business in another EU member state needs a VAT number, because without a valid one there is no correct invoice to issue — the billing run holds such a month for an operator. The welcome mail carries the terms as text, as they were agreed, in the language they registered in -- with the German wording after it, because that is the one the contract is made of. The legal texts are Markdown in `src/cloud/legal/`, rendered without passing any HTML through. German has no suffix and the translations do (`agb.md`, `agb.en.md`), which is not only a naming convention: the German file is the binding one, and somebody reading that directory should see it without being told. A missing translation falls back to German rather than to nothing, and a test insists that every translation says, in its own language, that only the German version binds -- a translation that omits that is worse than none, because somebody acts on it and finds out afterwards that what they read was never the contract. A consent always records the German version, whatever language it was read in, so a translation that is a day behind cannot create a version nobody could find in the terms. Prices live in exactly one place, `src/cloud/billing/plans.ts`, which the pricing page and billing both read. **Billing in the cloud** runs in its own process, the billing worker (`dist/billing-worker.mjs`, cloud builds only), as the operations role, one run at a time under an advisory lock. Usage is recorded by triggers on `member` and `workshop`, so every path that creates a workshop or switches a membership counts without knowing billing exists, and a tenant cannot shrink its own usage. A run closes the previous month into one `billing_period` per tenant, invoices it, waits for the announced collection date and collects; every step moves a period one state on with an update that names the state it expects, and the invoice reference is the idempotency key towards accounting and payments, so a repeated or interrupted run neither invoices nor charges twice. **What a plan costs** is read from the accounting system, not kept in the code: the article has to carry the price there for the invoice anyway, and the second copy is the one that goes stale -- the registration page advertised one euro for a while after the price had gone to five. The billing worker reads the article price and appends a row to `plan_price`; the web container reads that table and needs no access to accounting at all. A change takes effect on the first month that is at least six weeks away -- what the terms promise before a price applies, and what the right to end the contract over it needs to be worth anything. The date carries the promise, so nobody has to remember to wait; every workspace on that plan is told once, and `price_change_notice` is where the run remembers that it did. Every price ever in force stays on record. **A new version of a legal text** works the same way from the other end. Publishing a text and binding customers to it are two decisions: the second is an operator action that writes `legal_announcement` with a date at least six weeks out, and the run turns that row into mail and into one `legal_acknowledgement` per workspace -- which is also what stops it telling anybody twice. The console and the worker are different database roles, which is why the decision is a row rather than a function call. When accounting has not answered for a day, the pricing page shows no price and registration refuses -- existing workspaces are unaffected, because a stale sync is our problem, not theirs. What stays in `plans.ts` is what accounting does not know: that a user month is counted by the day and a workshop once, when it is created. **The invoice document** is fetched after the invoice was sent and kept in `invoice_document`, so a workspace can download its own from `/admin/billing/rechnung/`. A link into the accounting system's portal would either open for nobody but us or carry a token that works for anybody who has the link. Which document belongs to whom is decided by row level security, not by the route: the query runs in the tenant's transaction and another workspace's month comes back as nothing. Fetching is its own step -- an invoice that went out but whose PDF could not be fetched is not a failed invoice. **Vouchers** take a whole percentage off the monthly invoice. The console (and the operator MCP, under `ops:vouchers`) makes them: a code, a percentage, optionally a date after which it can no longer be redeemed, a number of billed months it runs for and a number of workspaces that may redeem it -- each left empty means no limit, and a single-use code is a limit of one. A workspace admin redeems one on the billing page, next to the payment method, through `app.cloud_redeem_voucher`; the application role cannot read the `voucher` table at all (blanket table grants meet a policy that matches nothing), so which codes exist is not something a workspace can list or find out by trying -- an unknown and a revoked code get the same answer. A workspace has one voucher at a time, and the redemption copies the terms, so revoking or editing a voucher afterwards changes nothing about what was given. When the run closes a month it applies the redemption from the month it was made in, and `billing_period.net_cents` is the amount **after** the discount: a month that costs nothing is void and never invoiced, and the plausibility check against the invoice is unchanged. A month counts towards the voucher when its period is closed, so a voucher redeemed during the trial loses none. The invoice line carries the discount as `discountPercent` and names the code; accounting rounds quantity × price × (1 − discount) once, and so does `discountedNetCents`. **A private build must not ship vouchers without an accounting adapter that honours `discountPercent`**: `issueInvoice` sends the invoice, so an adapter that ignored the field would send the full amount before the mismatch was noticed. The conformance suite has the discount cases that say so. Accounting and payments are ports (`src/cloud/billing/ports.ts`): the real adapters come from the private build through `@gw/billing-adapters`; every other build gets adapters that refuse, and the worker then only closes months and moves trials. The private build names its adapters with the `GW_BILLING_ADAPTERS` build argument, which points at a file it copied into the build context; left empty, as every public build leaves it, the alias falls back to the adapters that refuse. CI builds the cloud edition against a stub outside `src/` and checks that the stub is what ends up in the bundle, because a build that ignored the argument would silently ship adapters that cannot bill. The module answers two questions, not one: `configured` means invoicing and collection both work and is what the worker asks, while `paymentsConfigured` means only that a payment provider can open a hosted page for a payment method, which is what the billing page asks. The web container is deliberately the second without the first -- it holds a restricted payment key and no accounting key at all -- and while one flag answered both, a correctly configured production container told its customers that payments were not set up. What the adapters have to _do_ is stated once, as a suite (`src/cloud/billing/conformance/`) that runs against any implementation: here against the fake in the ordinary unit suite, and in the private build against Odoo and Stripe staging. So the first check of the promise is not the first invoice a customer receives. `GW_BILLING_MODE` is a dry run unless it is exactly `live`. Anything the run cannot decide -- a VAT number still pending, contradicting evidence, an invoice whose tax is not what was billed, an amount above the limit -- is held for an operator rather than guessed. The payment provider's webhook verifies and stores; the worker acts on stored events, once. Storing a payment method also marks the account as having one, in the same function, because that is not a decision but what the provider just said -- and a customer coming back from the hosted form to "no payment method yet" tries again. **A workspace's state in the cloud** is `tenant_lifecycle`: `trial`, `active`, `read_only` (trial over without a payment method, or payment failed), `payment_blocked` (a reminder that went unanswered), `paused` (an operator's decision; billing continues) and `deleting`. The dunning ladder runs without anybody deciding anything, and comes back the same way: three failed charges make a workspace read-only and send the reminder § 5.4 asks for, two weeks later the access itself closes, and a payment that arrives -- on a retry or through a webhook months later -- reopens it. The blocked state is deliberately NOT `tenant.status = 'suspended'`, which signs everybody out: the contents are the customer's, we hold them as their processor, and an unpaid invoice is not a lien on them. So `app.cloud_tenant_access()` answers `export` there, the capability layer hands out `workshop.export` and nothing else, and the library keeps its rows without keeping the way in. `suspended` stays what it was: the hard bolt for abuse, an operator's decision, nothing to do with money. What an operator can do about money is `app.op_grant_grace` -- the ladder steps over that tenant until the date passes and then carries on where it stood, the period stays owed, and the reason goes into `operator_audit`. Everything but `trial` and `active` is read-only through the same restrictive policies, and the app shell says why in a banner under the header. Blocking is `tenant.status = 'suspended'`, which signs everybody out. A tenant admin deletes their workspace from the billing page: it becomes read-only at once so that it can still be exported, member days stop counting, the request can be taken back for 30 days, and after that the billing worker deletes the tenant, its content and every account no other workspace holds -- but only once the month of the request has been closed, so the last partial month is invoiced like any other. The billing page's writes are SECURITY DEFINER functions that act on the current tenant and refuse anybody who is not its admin; a refused write under row level security reaches the person as "this workspace is read-only", not as a failure. **Planned maintenance** is announced from the console and shows up under the header in every workspace (`maintenance_window`). The terms promise it "nach Möglichkeit im Voraus", so the database refuses a window that has already begun -- an outage under way is an incident, and telling people about that is a different thing than planning one. Deliberately no mail: a window at three in the morning reaches the people it concerns by being on the screen when they work, and an inbox full of night-time notices teaches everybody to ignore the next one. The table has no `tenant_id` because one window applies to everybody, and the console reads it through `app.op_maintenance()` like it reads everything else -- `gw_operator` has no grant on any table. **Signing into the operator console** is a passkey, or a link by mail. The passkey is the door to use; the link is the way back, because a passkey is bound to its origin and to a device, and an operator whose laptop is gone used to need somebody with a shell on the server. The link lives fifteen minutes, is spent by the database in the same statement that reads it, and three unspent ones per hour are all an operator gets. Asking for one always answers the same, whether or not the address belongs to an operator: the console has no sign-up, so a distinguishable answer is a way to ask who the operators are. Every sign-in through it is written to `operator_audit` -- the weaker door is the visible one. **The operator console** is the cloud's own administration, and it is built so that the people using it cannot read a customer's work. It runs in a container started with `GW_OPERATOR_CONSOLE=1` (every other process answers 404 under `/operator`) and connects as `gw_operator`, a role with no grant on any tenant table: what it sees and does is the `app.op_*` functions in `drizzle-cloud/sql/956_operator_functions.sql`, which return names, states, counts and invoices -- never content -- and write `operator_audit` with the operator and the reason for every change. Operators are not identities; they sign in against tables of their own, with a session of eight hours and thirty minutes idle, and the very first passkey is enrolled through a one-time link that `scripts/operator.mjs` prints on the server -- once, because at that point nobody can sign in yet. The passkey is the way in; a link to the address on file is the way back when the device holding it is gone -- fifteen minutes, once, three per hour, and every such sign-in in `operator_audit`, which is what makes the weaker door a visible one. That link is opened the same way as `/verify`: the page only looks, the button spends. Whoever is signed in adds and removes their own passkeys under `/operator/security`, scoped by operator in the statements themselves, so coming back by mail leads back to a passkey instead of replacing it. The proxy should route the operator host to that container alone and restrict it by address. **OAuth clients in the cloud** register into a registry tenant, because registration happens before anybody has signed in. The consent screen copies the client into the tenant of the person consenting (`app.cloud_adopt_oauth_client`, which can only write into the tenant the caller already acts as), with the same public `client_id`, and from there code, tokens and revocation work exactly as in the community edition. ## Database roles | Role | For what | Particularity | | ---------- | -------------------------------- | ---------------------------------- | | `postgres` | creating roles, schema ownership | only in the `migrate` container | | `gw_owner` | migrations | owns the schema, NOINHERIT | | `gw_app` | the application | RLS forced, NOBYPASSRLS, NOINHERIT | | `gw_auth` | the sign-in path only | no login, is entered | | `gw_ops` | maintenance and tests | BYPASSRLS | Creating roles is an operator action and not a migration — which is why `db-bootstrap.mjs` does it as superuser, and `gw_owner` deliberately has no right to. Every login role gets its own random password into a file on first start (`scripts/db-secrets.mjs`), and every service receives only the files it needs: the web container knows `gw_app` and nothing else. The long reasoning, and why the earlier route through peer authentication could not work, is a comment in `compose.yaml` and in `scripts/db-secrets.mjs`. ## Live collaboration Several people edit the same workshop day at once; a CRDT (Yjs) does the merging, so that simultaneous changes to different blocks — and to different fields of the same block — both survive. **Yjs is the editing layer, Postgres remains the record.** Export, print and every read query read the relational tables and know nothing about a CRDT. A materialiser writes the state back; `collab_state.materialized_up_to` says how far behind the tables are. **A block's fields are written one at a time.** Every field of a block is its own key in the block's Y.Map — the type-specific ones as `desc.` beside the stored `desc` object, which stays as the base so no document needed migrating. `desc` used to be written whole, and two people in two fields of one block then wrote two whole objects; the later one silently undid the other. MCP's `desc` still means "replace the description", and clears the field keys with it. **One person per field.** Whoever is in a field claims it in the room's presence (`editing: { blockId, field, since }`); everybody else's editor shows it locked (`domain/collab/locks.ts`, `components/agenda/field-lock.tsx`). The lock lives in presence and nowhere else, because presence goes with the connection: a field left, a tab closed, 30 seconds without a keystroke (the field is saved and blurred) — and a socket that vanished without a close frame, which the server's heartbeat ends within twenty seconds (ping every ten, terminate on no answer). A lock in a table would need somebody to remove it, and the case that matters is the one where nobody is left to. The locks are advisory: browsers hold back, the server does not check updates against claims, and an MCP write does not take part. **A day has exactly one write path.** The materialiser writes the document into the tables and deletes anything there that the document does not know about — otherwise deletions could not be transferred. Which makes every second write path onto the same day a silent data loss: written, deleted again a few seconds later, and only when somebody happens to have the day open. So _everything_ that changes a day — the editor as much as MCP — goes through the room. The room fills its document from the database on opening, so that even a write into a day nobody has ever opened starts from the real state. The collaboration service runs as **its own process in the same image** on port 3001 — Next cannot serve a WebSocket upgrade from a route handler. A second image would have been the more comfortable route and would have broken the promise of "one image plus Postgres". The proxy forwards `/collab` there; it authenticates with the same session cookie that opens the editor -- or, for an invited guest, with theirs. Nothing in the socket decides whether a guest may write: it demands `workshop.content.write` before the upgrade, so a guest invited only to read is refused at the handshake by the capability table alone. **The handshake is not the last word.** A socket stays open for hours, and access is withdrawn in the web process, which has no line to this one. The shared line is Postgres: triggers on every table that decides access (sessions, members, tokens, invitations, collaborators, folders, workshops, days — `drizzle/0009_access_changed_notify.sql`) send an empty `NOTIFY`, and the collaboration server asks every open socket's credential again through the normal access check. The notification carries nothing to trust; it is only a reason to ask. Because a notification can be lost, every socket is also checked on a timer, and a message on a check that has grown old waits for a fresh one before it is applied — so a late notification cannot let a withdrawn guest write into the record. A "no" closes the socket with code 4401. **Being allowed in is not a blank cheque.** The frame limit bounds one message; `limits.ts` bounds the rest: messages and bytes per socket (token buckets, close code 4429), messages waiting to be handled, bytes buffered towards a socket that stopped reading, connections per room, and the encoded size of a day's document (close code 4413). The document limit is checked _before_ an update is applied, because afterwards it has already been broadcast and queued for the log. Tombstones count towards it, so a document churned to its limit stays there — which locks one day, and not the service. ## The library The visibility rule is in the SQL and not a filter afterwards. That is why pages work at all: filter in the application and a page of twenty rows delivers three visible ones, `LIMIT` returns pages that are too short and `OFFSET` skips rows nobody ever saw. Paging is by key, not by `OFFSET`: somebody editing a workshop on the side pushes it upwards, and with `OFFSET` you would get the same row twice or not at all. Search is `ILIKE` over the title, not a tsvector. At a few thousand rows that costs nothing, saves a migration — and "strat" still finds "Strategie", which a stemmed index does not do without a prefix query. Tags appear as you type. A management screen you have to visit first is a step nobody wants and a screen nobody maintains; a tag nothing points at any more disappears by itself. ## OAuth for MCP clients A personal access token is the right credential for a client somebody configures by hand. It is the wrong one for a client that has to onboard a stranger: ChatGPT's developer-mode connectors accept only OAuth or no authentication at all, and the claude.ai connector UI is in the same place. So `/api/mcp` is now a proper OAuth 2.1 resource server, and this installation is its own authorization server. **Both credentials resolve through the same door.** `resolveBearer` reads the prefix — `gwp_` is a personal access token, `gwo_` an OAuth access token, `gwr_` a refresh token and therefore refused here — and everything downstream only ever asks the two questions it always asked: who is this, and what may it do. Four decisions worth knowing, because each has a tempting alternative: - **Registration is open**, as RFC 7591 describes it, because the specification expects a client the operator has never heard of to be able to start. What makes that acceptable is that a registration grants _nothing_: it is a name and a redirect target, and every permission still comes from a person at the consent screen. It is rate limited per address, which is the only defence available to an endpoint that must stay reachable by strangers. - **PKCE is S256 or nothing.** OAuth 2.1 keeps `plain` for clients that cannot compute a hash; a service reachable over HTTPS is not one, and accepting it would mean accepting a challenge that protects nothing. - **Redirect targets match exactly.** Prefix matching is how an open redirector is built by accident — "the registered URI plus anything" includes paths the client never asked for. A broken request is therefore shown ON the consent screen rather than bounced to an address we have not validated, and a refusal is validated the same way an approval is. - **A client is offered reading and writing workshops**, not only reading. The specification's advice is to start small and step up, but a step-up needs a 403 with `insufficient_scope` at the HTTP level, and a refused write here is a tool result inside a 200 — no client ever stepped up, so Claude and ChatGPT were shown every write tool and could run none. Offering is not granting: the consent screen lists the scopes in words and a person decides. Block types stay read-only and the tenant is not offered at all. One list, `OAUTH_SCOPES`, feeds the default, the metadata document and the 401. - **Tokens carry their audience.** The MCP specification requires a server to accept only tokens issued for itself; `resource` is stored on the row and compared on every call. Without it a client could carry a token minted here to somebody else's server, which is where a confused deputy starts. The authorization code is spent inside the `UPDATE` that reads it (`used_at is null` is part of the `WHERE`), so two requests racing on one code cannot both win — the loser of a read-then-write race is somebody replaying a code they intercepted. Refresh tokens rotate for the same reason: a stolen one works at most once, and the theft surfaces as the real client being logged out. There is a second SECURITY DEFINER function, `app.resolve_oauth_token`, beside the one that resolves personal access tokens. Two functions rather than one widened one: a PAT has no audience and an OAuth token does, and folding them together would mean a null that means "skip a security check". (A third, `app.forget_identity_if_orphaned`, answers a different question the application's own role cannot ask — see Access and invitations below.) **What is deliberately not built yet:** Client ID Metadata Documents, which the specification prefers over dynamic registration; and a sign-in that returns to the consent screen afterwards — somebody who is not logged in when a client sends them here signs in, lands in the library, and starts the flow again from the client. ## Access and invitations A membership starts as "invited"; only signing in through the link activates it. An admin cannot activate anybody by decree — otherwise they could invite a foreign address and take over the account that results. **Opening a sign-in link spends nothing.** `/verify` only checks the token and shows a button; the button consumes it. Mail scanners — Microsoft Defender's Safe Links above all — fetch every link before the recipient does, and while the GET consumed the token, those recipients only ever saw "expired". **Disabling and removing are different answers.** Disabling revokes access and keeps the row; removing deletes it, and a `disabled` row still holds a name and an address, which is precisely what an erasure request is about. Removing therefore asks for a successor: `workshop.owner_id` is `RESTRICT`, so the database already refuses to let a member vanish out from under a team's agendas, and `folder.created_by` — which [`folderRoleFromPath`](../src/domain/workshop/folder-access.ts) reads as folder ownership — carries no foreign key at all, so nothing but the application keeps it pointing at somebody who exists. Choosing the successor is a decision for the admin rather than a default, because every default hands somebody else's work to whoever the code guessed. Once the last membership is gone, so is the account — and that answer needs a third SECURITY DEFINER function, [`app.forget_identity_if_orphaned`](../drizzle/sql/902_forget_identity.sql). `gw_app` may read `member` but not `identity`, `gw_auth` the reverse, and the question "does this person still belong to any workspace at all?" spans both. It runs with BYPASSRLS on purpose: an admin of one workspace must not delete an account another workspace still uses, and a membership count scoped by the caller's tenant would answer "none left" and be wrong. Access to a single workshop is granted from inside the tenant, never by e-mail address: an address is an identity, and identities are global; inviting by address would silently grant access across a tenant boundary. **A folder can be shared too, and it reaches the whole subtree.** One sentence — "the Acme team works in Kunden/Acme" — instead of one grant per workshop. Three rules keep that from becoming a way to give away things that are not yours, and all three live in [`domain/workshop/folder-access.ts`](../src/domain/workshop/folder-access.ts) as pure functions with test tables: - **The nearest folder decides.** Walking up from the workshop, the first folder that says anything about this member is the answer — so a subtree shared as editor can still hold one folder that is read-only. A row on the workshop itself is nearer than any folder, which is how a single workshop is pinned back. - **Nobody hands on more than they hold.** A folder editor may make editors and viewers; a folder viewer may only make viewers. The same rule applies backwards, which is the half that is easy to forget: without it a viewer could revoke an editor. - **A folder grant is never ownership.** `workshop.delete` and `workshop.transfer` stay with the owner. Making a folder that other people file things in confers no right to throw their work away. Two consequences worth stating rather than discovering. A workshop **moved into** a shared folder is shared by that move alone. And because delegation is open to everyone who holds something, a viewer can widen the audience of a colleague's workshop — bounded to their own level, but the circle grows without the owner acting. That is the price of folder-level sharing; the alternative was an admin in the loop for every addition. **A folder nobody gave you is not there.** The tree a member reads holds only folders they have a role on — made by them, or under a grant, or every folder for an admin — and it is the same `folderReachSql` the library asks of a workshop's folder, so a folder and what is filed in it come and go together. A grant reaches downwards, so what is left are whole subtrees; the top of each stands at the top level for that reader, with the path above it cut off rather than shown greyed out, because a folder's name already says something about the work. The same cut applies wherever a folder name travels: the export's path, the "via …" line of a sharing screen, the chip on a workshop that was shared on its own. And such a folder is not a target either — `folderReachable` in [`domain/workshop/folders.ts`](../src/domain/workshop/folders.ts) answers "gone" before a workshop is created or filed in it, or a folder made in it, because the move consequence above would otherwise let anybody share their own work with a stranger's team. **A share link for somebody with no account is the exception, and it is not one.** A client, an external co-trainer or a commissioning manager gets one agenda by e-mail — and that does not cross the boundary the rule protects, for four reasons that are each load-bearing rather than reassuring: it creates no `identity` and no `member`, so nothing global comes into existence; the row lives in a tenant-scoped table under RLS and can name nothing outside its own tenant; it grants exactly one workshop, never the library and never a second one; and the guest's session lives in `share_session`, which has no `identity_id` column at all — so a guest session cannot become a login. That last one is the shape of the schema rather than a rule somebody has to remember. The address on such a link is therefore not an identity. It is the **second factor**: the link alone shows a form, and only the address the invitation was sent to opens the agenda. A link that was forwarded, left in an inbox or found in a browser history is not a key. The token is deliberately **not** single-use, unlike a magic link — a guest comes back to it tomorrow from their phone — which is exactly why it needs a second factor at all. Guests get their own roles rather than `viewer` and `editor`, because `editor` carries renaming, tags, the bin and moving between folders. A guest invited to write edits agenda content and nothing else, and a reader gets `workshop.read` alone: no print view and no Markdown download, which would be a second and third surface to get wrong. How long it lasts is **derived, not stored**: access ends after the last dated day of the agenda. A date written into the row at invitation time would lock guests out of a workshop that was moved by a week, and the person who rescheduled it would have no reason to suspect the invitation. A workshop with no dates yet has no deadline, so only a withdrawal ends it. Whoever has no access gets 404 rather than 403: a workshop you may not see should not confirm that it exists. The last active admin can neither be demoted nor switched off. A tenant without an admin can only be repaired through a shell on the server. ## Languages Four of them — German, English, French, Spanish — and no locale in the URL. The application lives entirely behind a session, so there is nothing to index; the language is a property of the person, not of the address. **The key travels, not the sentence.** A domain error reaches three audiences with three languages: the person in the browser in their own, an MCP client in English, and a log that belongs to an operator. So `src/domain` throws keys and exactly two places turn them into sentences. `Error.message` is the key itself, which means anything that logs an error prints something unambiguous rather than half a translation in the wrong language. **The stored row stays the truth; translation happens on the way out.** The fifteen built-in block types are seeded per tenant in German by a plain boot script with no catalog loader. `localiseModuleType` rewrites only what a person reads — never `properties`, `required`, `type`, `enum` — so the schema still validates exactly the same documents. A row the tenant has customised is returned untouched: mixed output, half in the tenant's wording and half translated, is the worst of the three possible answers. The details, and what deliberately stays untranslated, are in [Languages](languages.md). ## Branding The server takes hue and chroma from **one** hex value and derives the light and dark steps itself; the lightness steps stay fixed. That is exactly the trick: OKLCH lightness is perceptual, so the brand colour can be swapped without re-checking the contrast of every surface. A colour that would not produce a readable ramp is refused on saving, not discovered later as an unreadable page. Branding touches `--brand-*` and **never** `.cat-*`. Block type colours are semantics ("this is a break"), not decoration; a corporate blue across every block type makes the agenda unreadable. An uploaded SVG is **refused rather than cleaned** when it contains scripts, `