# Building and shipping on Whisk Conventions version 1. This is the document a coding agent reads before touching a Whisk app. Every file it refers to is published beside it at `https://skill.whisk.run/`: `whisk.schema.json`, `graph.schema.json`, `headers.md`, `environment.md`, `webhook-presets.yaml`, `errors.md`, `doctor-rules.md`. Working starter apps come with the CLI (`whisk init --template`). ## 1. What Whisk is Whisk hosts business apps written by coding agents. An app is a container that speaks HTTP; the platform adds login, a Postgres database, a queue with durable workflows, secrets, webhooks, storage, email and backups, all by convention rather than by SDK. Tenant apps live at `..whisk.page` on a paid plan, `--.whisk.page` on the free plan; the platform is at `whisk.run`. Use the hostname the API returns rather than building one. What you will do: `whisk login`, `whisk init`, `whisk doctor`, `whisk deploy`, then tell the human which secrets to set and where. The human's part is a few screens in the dashboard. Yours is everything else, and one more thing: whenever Whisk gets in your way, tell us with `whisk feedback` (section 12). You are the user Whisk is built for, so your feedback is how it gets better. ### Whisk already does this Use the built-in. Do not add an outside service, SDK account or library for any of these unless the human asks for that service by name; if a built-in falls short, say so with `whisk feedback` (§12) rather than working around it. | Need | Whisk's built-in | Instead of | |---|---|---| | Sign-in, users, roles | the platform's login and `X-Whisk-*` headers (§4) | Auth0, Clerk, NextAuth, Supabase Auth, a password page | | Database and backups | Postgres per app, `DATABASE_URL`, backups and restore points (§5) | Supabase, Neon, Firebase, SQLite files | | Scheduled and background work | functions, steps, retries, approvals (§7) | node-cron, Celery, BullMQ, an outside cron, Zapier | | Secrets | declared names, set in the dashboard (§6) | `.env` files, Doppler, Vault | | Incoming webhooks | `webhooks:` with verification, retries, replay (§8) | a relay service, your own signature code | | Files and uploads | `storage: true` (§9) | an S3 or R2 account, Cloudinary, UploadThing | | Video and audio | `` (§9) | Mux, Vimeo, YouTube embeds | | Email | `email: true` (§9) | SendGrid, Resend, Mailgun, SMTP | | Cache, counters, locks | `kv: true` (§9) | Upstash, Redis Cloud | | Errors | `SENTRY_DSN`, Whisk's own error store, `whisk errors` (§3) | a Sentry account, Bugsnag, Rollbar | | Logs and request tracing | stdout, `X-Whisk-Request-Id`, `whisk logs`, OpenTelemetry traces to `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `whisk traces` (§2, §11) | Datadog, Logtail, LogRocket, Honeycomb, an APM agent | | Bot protection | `routes.challenge` (§4) | reCAPTCHA, hCaptcha, Turnstile | | Hosting, HTTPS, domains, caching | the platform (§3) | Vercel, Cloudflare, a CDN | ### One app or two To the people you work for, an app is one tool their team uses in a web browser, with its own web address, its own data and its own list of who can use it. Size is not a reason to split: a whole CRM with many screens is one app. When asked for something new, decide whether it belongs in an existing app or is a new one: - Same people, same information: add it to the existing app. A report on the CRM's customers belongs in the CRM. - Different people should see it (a client portal beside the staff tools): a new app, because access is set per app. - Something they would switch off, roll back or hand to someone else on its own: a new app. - Someone else maintains it: a new app, so their changes do not collide with yours. - Needing the other app's data is not a reason to merge: use a shared database (§5). Say which you chose and why in one sentence. When the rules do not settle it, ask the human. ## 2. The rules An app on Whisk is a stateless HTTP server in a container. 1. Listen on `PORT` (always 8080) on all interfaces. 2. Answer `GET ` with 200 when ready to serve. Default `/health`. 3. Log to stdout and stderr. JSON lines are indexed by field; plain lines are indexed as text. 4. Write files only under `/tmp`. Everything else is read-only. `/tmp` is emptied on every restart; files that must last go to storage (§9, files a job keeps). `/tmp` is memory: what you keep there and what the process uses share `WHISK_MEMORY_BYTES` (256 MiB on Free), and a run that goes over is killed without SIGTERM. 5. Exit cleanly on SIGTERM within the grace period (default 28 seconds). 6. Read configuration and secrets from the environment. Never from files in the repository. 7. Trust identity only from the `X-Whisk-*` headers, which only the platform can set. 8. Treat every queue delivery and webhook as possibly repeated; use the provided IDs to dedupe. 9. Start fast. Under five seconds from process start to health 200 is the target, because sleeping apps wake on demand. **Keep it light.** Memory is what an app costs to run, and a small app wakes fast and is never killed for running out. Stream large files and query results in chunks (storage reads, CSV and spreadsheet parsing, database cursors) instead of loading them whole into memory or `/tmp`. Process a big job as many small steps, each handling one batch. Let the database filter, sort and total rather than fetching every row to do it in code. React to an event or webhook when something changes instead of a schedule that checks every few minutes, and choose the longest schedule the business can live with. Load heavy libraries only in the code path that needs them. Pages and API reads that can be a little old should send `Cache-Control: private, max-age=` (`public` for a page anyone may see): the edge answers repeat requests from its cache, per person and for up to an hour, without waking the app. A response that sets a cookie is never cached. ## 3. Getting an app live If `whisk` is not on the PATH, install it first; neither script needs root: ``` curl -fsSL https://whisk.run/install.sh | sh # linux, macOS irm https://whisk.run/install.ps1 | iex # windows PowerShell powershell -NoProfile -Command "irm https://whisk.run/install.ps1 | iex" # windows Git Bash or cmd ``` If `whisk` is already installed, run `whisk update` once at the start of a session: this document describes the CLI the platform serves, and an older one lacks commands it names. Later, any `--json` output that carries `cli_update`, or an `UNKNOWN_COMMAND` error, means the same: run `whisk update` and retry. ``` whisk login --agent "" name yourself as people know you ("Claude Code", "Codex", "Cursor"): the approval page, deploys and token lists show it. Exits 2 at once with a URL and a code in the NEEDS_HUMAN block (also under --json): show both to the human, then run the resume command it names (whisk login --resume ), which waits for approval. The token works only from this computer: use it through whisk, never copy it elsewhere whisk init --template ts|py|go writes whisk.yaml, .whisk/app.json, and the template whisk doctor checks the repository; exit 3 means fix the listed problems whisk deploy -m "…" commits, pushes, builds, migrates, health-checks, switches traffic whisk logs -f tails the app whisk clone / copies an existing app's code here; git pull and push then work ``` If `whisk login --resume` is cut off by your shell's time limit, run it again: the code stays good until it expires and the waiting key is kept. **Following and recovering.** These are the commands to reach for when something takes a while or goes wrong: ``` whisk deploy --no-wait -m "…" prints the deploy id at once; a deploy can take up to 25 minutes whisk deploys info its status and phase; deploys cancel stops a stuck one whisk deploys log its whole build log and, for a failed start, the app's last lines whisk status running, sleeping or broken, and why it stopped answering whisk whoami · whisk orgs who you are and which businesses this token reaches whisk use / binds this folder to an app (the NOT_FOUND fix names it) whisk secrets list declared names, set or unset; never values whisk functions list declared functions, their triggers and drift from the code whisk events send --data @event.json sends an event to test an event function whisk runs show · whisk runs replay · whisk runs cancel whisk restore show --wait follows a restore (whisk restore list lists them) ``` Every command takes `--org ` and `--app ` instead of `.whisk/app.json`, and `--quiet` to drop progress lines. In CI, where nobody can approve a login, a person runs `whisk agent-token create --label "ci"` and puts the token in `WHISK_TOKEN`. Run `whisk login` in the app's folder, or with `--org` (and `--app`): the approval page then starts on that app or business, the narrowest login that fits. The person can choose all their businesses instead; that login acts as them in the businesses they belong to when they approve it, never one they join later: `whisk orgs` lists them and `--org ` picks one when the folder names none. When a login meets a business it does not cover, the command exits 2 with a NEEDS_HUMAN block whose link (`details.url`, `details.reason` `BUSINESS_NOT_IN_LOGIN`) is where the person, signed in, adds that business to this login with one tap: show them the link, then run the command again. Do not run `whisk login` again for it. A person adds a new business themselves at `https://whisk.run/new`. An agency builds for its clients with the login it already has: `whisk clients` lists its client businesses, `whisk clients add --contact ` adds one, `whisk init --org ` starts an app in it, and `whisk clients handover ` prints the link its contact accepts it with. The agency's own business needs a paid plan first (`PLAN_FEATURE`); each client business is on Team. If you speak MCP rather than a shell, run `whisk mcp` as a stdio server: this document and every error code are its resources, every command is a tool. In a chat with no shell, such as ChatGPT or Claude, the person connects Whisk's assistant connector (`https://api.whisk.run/mcp`) and signs in once. Its tools cover deploying (`deploy_app` takes the files themselves), rolling back, logs, runs (and replaying one), the database's tables and queries, access and feedback; `whisk_build_guide` names which command each one stands for and what a chat cannot do (cron runs on demand, webhook deliveries, errors and traces, restores, previews, domains, export), which needs the dashboard or a developer with the CLI. Every command takes `--json`. Exit codes: 0 success; 1 an error, including a refused request such as a bad slug or uncommitted files (`INVALID_REQUEST`), read `code` and `fix`; 2 needs a human, show them the printed URL and wait; 3 doctor or the manifest found problems, fix the listed ones and retry; 4 not signed in or not allowed: read `code` (for `AUTH_REQUIRED` or `TOKEN_EXPIRED` run `whisk login`; for `AGENT_CATEGORY`, `AGENT_PAUSED`, `FORBIDDEN_ROLE` or `TOKEN_SCOPE` logging in again does nothing, so tell the human what to grant or resume); 5 network or Whisk's side, retry with backoff and do not change the code; 6 deploy failed in the app, read the log excerpt. Start from a template unless the human already has code: `whisk init --template ts` (Hono, Drizzle), `--template py` (FastAPI, SQLAlchemy, Alembic), `--template go` (chi, pgx, goose). Each is a plain starter (a notes page, a health route, one event function; a few hundred lines, the Go one about 900) that passes doctor with zero warnings, deploys with nothing for a person to set, and shows the conventions below working. `whisk init --template` works in a folder holding only dot-files and `CLAUDE.md` or `AGENTS.md`. Existing code deploys too: add a `whisk.yaml`, make it follow the rules, run doctor. **Everything `whisk.yaml` takes.** Unknown keys are errors; `whisk schema` prints the JSON schema. Settings marked paid are refused with `PLAN_FEATURE` on the free app, except `kv`, which deploys without a cache and a `W080` line instead (§9). Storage, email and customer identity are on every plan, the free app included, within its allowances. ```yaml whisk: 1 # required name: job-tracker # required; the app's address derives from it routes: public: ["/", "/health"] # everything else needs a signed-in person (§4) challenge: ["/contact"] # public POSTs here need a solved bot challenge (§4) csrf_off: [] # rare: routes exempt from the CSRF check, for embeds headers: { X-Frame-Options: DENY } # add or override response headers health: { path: /health, timeout: 30 } # seconds from start to the first 200; default 90 database: app # app | shared: | none (§5) migrate: "node dist/migrate.js" # runs before traffic switches, at most 600 seconds (§5) build: dockerfile: Dockerfile # optional; the stack is detected without one secrets: [NPM_TOKEN] # available only while building, never at run time secrets: [XERO_CLIENT_SECRET] # names only (§6) env: { LOG_LEVEL: info } # plain configuration, visible in logs queue: { endpoint: /.whisk/inngest } functions: [] # cron and event functions (§7) webhooks: [] # incoming webhooks (§8) static: # committed folders served by the edge, cached for a year - { dir: public/assets, path: /assets } storage: false # files, uploads, images, video (§9) · every plan kv: false # a Redis of its own (§9) · paid email: false # sending email (§9) · every plan always_on: false # never sleeps · paid calls: [] # other apps of the business this one calls (§8) customer_identity: none # none | app | org: the app's own users (§4) · every plan network: internal # Whisk On-Premise only: internal (default) | public; not on whisk.run previews: { database: empty, ttl_days: 3 } # previews start empty; days a preview lasts ``` `static` folders are read from the commit, not from the built image: a folder the commit does not hold, such as git-ignored build output like `dist/`, fails the deploy with `MANIFEST_INVALID` naming it (`whisk doctor` warns first, `W004`). Serve build output from the app itself, or commit the folder. Static files never wake the app and are cached for a year, so give them content-hashed names. `always_on` is for apps that must answer at once or hold WebSockets open; everything else should sleep. A sleeping app has 60 seconds to answer its health check when a visitor wakes it, whatever `health.timeout` allows a deploy, so an app that needs longer sets `always_on` (`W005`). A sleeping app starts again on its next request, so start fast: the TypeScript template bundles each entry point into one file with esbuild (`build.mjs`), so its image holds no `node_modules` and Node reads a few files instead of thousands. A package that must stay a file on disk (a native addon, or one that reads its own files at run time) goes in esbuild's `external` list, and the Dockerfile copies it into the image. A preview starts with an empty database of its own; seed what testing needs from a migration or a script, never from production. **Say what changed.** Every deploy shows the subject line of its commit to the business, on the app's Deploys page and in `whisk deploys list`. Write it for the people who use the app, in one plain sentence about what they will notice: `whisk deploy -m "Add a CSV export to the jobs page"`, not "fix bug", "wip" or "whisk deploy". `-m` commits uncommitted changes with that message; when you commit yourself, the first line of your commit message is what shows. Keep it under 72 characters and leave file names and internals out. `whisk deploy` from `main` or `master` deploys production, and nothing else does. From any other branch it starts a preview at its own URL, says so, and leaves production alone; to put a branch live, merge it into `main` and run `whisk deploy` from `main` (`PRODUCTION_NEEDS_MAIN` otherwise). Work on `main` unless the human asks to try something out first. Check `environment` in the `--json` summary before telling the human something is live. A plain `git push` of a branch only stores it: start its preview with `whisk envs start ` when someone needs to see it, and from then on every push to that branch updates it. Each app may have one preview on the free app and three on a paid plan (`PLAN_LIMIT_PREVIEWS`); remove one you are done with using `whisk envs delete preview:`. Previews sleep when idle and go a few days after their last push or visit. A preview runs no scheduled functions, workflows or events (`PREVIEW_NO_WORKFLOWS`); skip sending events when `WHISK_ENV` starts with `preview:`, and test functions with `whisk dev`. `whisk apps delete --yes` needs a human's say first; a deleted app keeps its data for 7 days, and `whisk apps deleted` then `whisk apps restore ` bring it back stopped. `whisk deploy` ends with the URL, the address of each declared webhook, and any `warnings`: a deploy that went live with something wrong, such as `FUNCTIONS_NOT_REGISTERED` (a function name in `whisk.yaml` the code does not use), whose fix says what to change. If declared secrets (`build.secrets` included) have no value yet it also prints a `NEEDS_HUMAN` block naming them and the dashboard URL. Relay that block to the human verbatim. Do not ask them for the values; the app restarts on its own when they are set. **Their own domain.** On a paid plan, `whisk domains add jobs.acme.com` prints two DNS records for the human to create: a TXT that proves they own the name and a CNAME to the app's address. A bare domain (`acme.com`) cannot have a CNAME, so it takes A (and AAAA) records to the addresses printed instead. Relay the records, then `whisk domains verify jobs.acme.com` once they are in place; until then it answers `DOMAIN_UNVERIFIED` naming what is missing. The certificate is issued on verification and the app answers on the name with no deploy. Prefer relative links; `WHISK_PUBLIC_URL` stays the platform address, and the `Host` header is the name the visitor used. To put every app on the business's domain at once (crm.acme.com, billing.acme.com, and each new app with no further DNS), an owner or admin's session runs `whisk domains business add acme.com` instead: a TXT and one wildcard CNAME (`*.acme.com`), then `whisk domains business verify`. The human's existing records (www, mail) keep working. **Serving from near the visitors.** On Team and Business, `whisk cdn on` puts the app's address and its CNAME'd domains behind a CDN (`whisk cdn` shows the status and each hostname's route). It helps public pages, sites and downloads. A public page is cached only as long as the app's `Cache-Control` says, so set it on public pages and assets you want cached; a response that sets a cookie is never cached. **Looking after a live app.** `whisk status` says whether it is running, sleeping or broken, which deploy is live and which secrets are unset, and, when it stopped answering, why: the exit code and last lines of a crash, or a wake that failed. `whisk rollback` puts the previous deploy back (or a named one from `whisk deploys list`). `whisk errors` lists the app's grouped exceptions, reported through `SENTRY_DSN`, which every template already wires. It points at Whisk's own error store, not sentry.io: initialise any Sentry SDK with it and only the DSN, without Sentry's tracing, profiling or session replay, which cost memory and start time and which Whisk already covers. **Traces** show where a request's time went, step by step: the platform sets the `OTEL_*` variables (`environment.md`), so any OpenTelemetry SDK's OTLP/HTTP trace exporter sends them with no configuration, and the edge's `traceparent` makes each request's trace id its request id, so a log line's `request_id` opens its trace. The templates already start tracing when `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is set; in another app, start the SDK at that check, continue the incoming context, and instrument incoming HTTP, database queries and outgoing calls. Traces are kept 7 days, 100,000 spans per app per day. `whisk open ` prints the dashboard link for the human (`logs`, `runs`, `secrets`, `domains`, `access`, `errors`). `whisk github` shows the app's optional two-way copy on the business's GitHub; a person links it in the app's settings. **Known vulnerabilities in packages (Business plan).** Whisk checks the live app's packages after every production deploy and daily: the image (base image packages and everything installed in it) and every lockfile in the commit. `whisk status` says how many findings need fixing now; `whisk doctor` lists them as `W063`, one line per package: it checks the local lockfiles as they are, before you deploy, so run it after each change and the list shrinks as you fix; `whisk scan` lists each one with its severity, the installed version, the version that fixes it (`fixed`) and where it was found (a lockfile, or a path in the image). Fix the critical and high ones that have a fix: move the package to that version or newer in the lockfile (a base image package by moving to a newer base image), deploy, then `whisk scan --now` to check again. A finding with no `fixed` has no fix yet; leave it. Most image findings come from the operating system in a full base image: the TypeScript and Go templates run on a distroless image (`gcr.io/distroless/nodejs24-debian13:nonroot` for Node, the Python one on `python:3.13-slim`), which has no shell or package manager, so keep `migrate` a plain command such as `node dist/migrate.js`, not `npm run` or shell syntax (`whisk doctor` warns, `W062`). An app that needs a system program (ffmpeg, ImageMagick, a browser) moves the Dockerfile's last stage to `FROM node:24-trixie-slim`, installs it with `RUN apt-get update && apt-get install -y --no-install-recommends && rm -rf /var/lib/apt/lists/*`, and sets `USER node` and `CMD ["node", "dist/index.js"]`, since only the distroless image starts node itself. Deploys are never blocked by findings. On another plan `whisk scan` says scanning is on the Business plan. ## 4. Identity The platform signs people in. Your app never sees a password, a token or a login form. On every request, read who it is from headers (full table in `headers.md`): | Header | Meaning | |---|---| | `X-Whisk-Audience` | `anonymous`, `team`, `customer` or `service` | | `X-Whisk-User-Id` | stable ULID; store this, never the email, as the key | | `X-Whisk-Email`, `X-Whisk-Name` | verified email; display name (may be empty) | | `X-Whisk-Org` | org ULID | | `X-Whisk-Groups` | comma-separated group names (team only) | | `X-Whisk-Roles` | comma-separated org roles: `owner`, `admin`, `developer`, `billing`, `member`, plus `guest` (team only) | | `X-Whisk-Request-Id` | trace ID; put it in every log line | Routes are private unless listed under `routes.public` in the manifest. A private route only ever receives requests with a `team` or `customer` identity (or `service`, for platform deliveries and app-to-app calls); the platform has already redirected browsers to login and answered API clients 401. A public route receives `anonymous` when nobody is signed in and the full identity when someone is. Authorise inside the app by role or group: ```ts const roles = (c.req.header("X-Whisk-Roles") ?? "").split(",").filter(Boolean); if (!roles.includes("admin") && !roles.includes("owner")) return c.text("Forbidden", 403); ``` Never read identity from a cookie, a query parameter, a body, or an `Authorization` header a browser sent; the headers are the only source and the platform strips any copy arriving from the internet. To sign someone out, redirect to `/.whisk/logout?return=/`. To force login from a public page, redirect to `/.whisk/login?return=`. Both are handled in front of the app. `customer` identities are the app's own users (portals, public apps), enabled with `customer_identity: app | org`. They arrive in the same headers without roles or groups, and `X-Whisk-User-Id` names the customer. The way in is an invitation until an owner opens registration; onboard your own with `POST /v1/orgs//apps//customers {email, name}` and your service token, which answers the link to send them. `whisk customers list`, `invite`, `block` and `unblock` do the same from the CLI. An owner can remove a customer: they are signed out and can no longer sign in. Invited again (or signing up again) within 30 days they come back under the same `X-Whisk-User-Id`, so keep their records rather than deleting them when a request stops naming them; after 30 days the platform deletes the person, and a later invitation makes a new id. Who may open the app (groups, people, everyone in the business) is an owner's or admin's choice in the dashboard: `whisk access show` reads it and `whisk open access` gives the link to relay. **Public pages.** The edge rejects a cross-site POST to the app (`CSRF_REJECTED`), so forms work from the app's own pages with no token. Put a public form that strangers fill in (contact, quote request, sign-up) under `routes.challenge`: a POST without a solved proof-of-work challenge gets `CHALLENGE_REQUIRED` carrying one; solve it with the Altcha widget and resend with the solution in the `altcha` form field. `routes.headers` adds or replaces response headers on every route, such as a `Content-Security-Policy`. The edge sends `Cross-Origin-Opener-Policy: same-origin`, so a link to the app from inside a sandboxed iframe (a CRM tile, a widget on another site) opens to Chrome's `ERR_BLOCKED_BY_RESPONSE`; when the app is meant to be opened that way, set `Cross-Origin-Opener-Policy: unsafe-none` in `routes.headers`. ### Who may see and change what The platform decides who may open the app. The app decides which records each person may see and change, and that is where most real security holes in business apps are: one customer opening another's invoice by changing the number in the address. Do it the same way every time. 1. **Store the owner on every record a person creates**: their `X-Whisk-User-Id`, in a column such as `owner_id` or `author_id`. Never take the owner from the body, the query string or a hidden form field. 2. **Filter in the query, not afterwards.** The template's `whisk` module has `scopeFor` (`scope_for`, `ScopeFor` in Go): the business's team sees every record, a customer only their own, anyone else nothing. Every list, search, count, export and report goes through it. 3. **Check every record fetched by id** with `canSee`, then `canChange` before changing or deleting it (`can_see` and `can_change`; `CanSee` and `CanChange`). A record the person may not see answers 404, as if it did not exist; one they may see but not change answers 403. 4. **Change the rules in one place.** When a business wants something different (members see only their own records, a group sees a department's), change the helpers, not each route. The template's access tests (`access.test.ts`, `test_access.py`, `access_test.go`) check the rules over thousands of generated people and records; keep them passing and add a case for the new rule. 5. **Test each kind of record as the wrong person.** For every route that reads or changes one, add a test that calls it as a second customer, as a team member who is not an owner or admin, and with no identity at all, and expects 404, 403 or an empty list, never the record. Ids in the address and in the body both count. The same habits close the other common holes. Send SQL with parameters (the ORM, or `$1`), never by joining strings. Let the framework escape HTML; never insert text a person typed as raw HTML. Keep keys on the server, never in code sent to the browser. Fetch only addresses the app chose, never a URL a person typed without checking its host first. Keep uploads in storage (§9) and serve them from there. Before pushing, run the tests and `whisk doctor`: W094 warns when an app with customer sign-in never limits anything to the signed-in person, and W096 to W099 flag SQL built from text, raw HTML, a list of people's records with no owner filter, and public routes that write. ## 5. Database `DATABASE_URL` is a pooled Postgres connection in transaction mode. Use it as-is. No session-level state: no `SET` outside a transaction, no advisory locks held across statements, no `LISTEN`. Prepared statements are fine inside a transaction. Migrations run from the manifest's `migrate` command before traffic switches, with a direct connection the platform provides for that step and a snapshot taken first. The previous deploy stays live and keeps writing while the migration runs. A migration that runs longer than 600 seconds is stopped; split a long backfill into a function instead. A failed migration that changed no tables, columns, indexes or functions leaves the database as it is, so nothing users wrote is lost; one that did change them is rolled back to the snapshot, and the error names the replaced database, kept for 7 days, where anything users wrote meanwhile can be read back with `whisk db query --database`. So run each migration in one transaction (most tools do on Postgres), and keep migrations forward-only and idempotent where the tool allows. `whisk dev` also runs the `migrate` command on your own machine before it starts the app. `database: app` (default) gives the app its own database. `database: shared:` gives the app its own schema inside a database shared across the org, for apps that must join across each other. `database: none` for apps without one. In a shared database each app connects as its own role and owns one schema of the same name, `app_`, which is first on its `search_path`, so unqualified tables land there. Every schema is private to its app until that app grants access; nothing in `whisk.yaml` opens it. The owning app grants read access in one of its migrations, naming the reader's role (`whisk apps info --json` prints the reader's id as `app.id`; inside an app, `select current_user` names its own role): ```sql GRANT USAGE ON SCHEMA app_ TO app_; GRANT SELECT ON ALL TABLES IN SCHEMA app_ TO app_; ALTER DEFAULT PRIVILEGES IN SCHEMA app_ GRANT SELECT ON TABLES TO app_; ``` The first two cover the tables that exist; the third covers tables the owner creates later. The reader then queries `app_.` and joins it with its own tables. Grant `INSERT`, `UPDATE` or `DELETE` the same way only when the reader must write; `REVOKE` takes access back. **Row-level security: one customer never sees another's rows.** When the app serves customers (`customer_identity`), or people who must not see each other's records, every table holding rows that belong to someone gets a policy, so a query that forgets its `where` still answers only the caller's rows. Add it in a migration, naming the column that holds the owner's `X-Whisk-User-Id`: ```sql create index orders_owner_id on orders (owner_id); alter table orders enable row level security; alter table orders force row level security; create policy orders_by_audience on orders using ( current_setting('whisk.audience', true) in ('team', 'system') or owner_id = nullif(current_setting('whisk.user_id', true), '') ); ``` `force` matters: the app's role owns the table, and an owner skips policies without it. The policy lets the team and the app's own work see every row and a customer only their own. Then every query on such a table goes through the template's helper, which tells Postgres who is asking at the start of one short transaction: `dbFor(who(c), (tx) => …)` in TypeScript, `with db_for(identity(request.headers)) as session:` in Python, `db.For(ctx, callerOf(identityFrom(r.Header)), func(tx pgx.Tx) error {…})` in Go. Functions and webhook deliveries, which run for nobody in particular, use `asSystem` (`as_system`, `db.AsSystem`); never use it to answer a person's request. A query that skips the helper sees none of the rows and cannot write them, which is the safe way to fail. Keep slow work (calls to other services) outside the helper, since the transaction holds a pooled connection until it ends. To tailor it: a table every signed-in customer may read but only the team may change (a price list) needs no policy; a team member who should see only their own rows drops `'team'` from the list; rows shared by a customer's company use a column holding that company's id instead of the person's. A migration that reads or changes rows of a policy table starts with `select set_config('whisk.audience', 'system', true);` (DDL needs nothing). `whisk db query` runs as `system` and sees every row. Another app reading a shared schema is filtered by the owner's policies too: it sets `system` the same way when it should see every row. `whisk dev` connects as a superuser, which policies never filter, so prove isolation on a deploy. **Looking at the data from your machine.** The database's own address is inside the platform, so connecting to it from outside does not work (`whisk db url` says so). Use these instead; they run on the platform, need nothing installed, and are audited: - `whisk db schema --json`: every table with its columns, keys and estimated row count. Read it first when planning an import or a migration. - `whisk db query "select … limit 20" --json`: the rows as JSON, values as PostgreSQL prints them. `--file script.sql` runs a script. Statements stop after 30 seconds; at most `--limit` rows come back (1000 by default). - Reads and additions run at once. SQL that would change or remove existing data (`update`, `delete`, upserts, `drop`, `alter`, `truncate`) does not run: it comes back as `CONFIRM_REQUIRED` saying what it would do and carrying a code. Check that the effect is what you meant, then run `whisk db query --confirm `. A restore point is taken first, and the result names the `whisk restore --at` that undoes it, on a plan with self-service restore. On Free nothing undoes it (`details.restorable` is `false`): copy the rows it changes first with `whisk db query "select …" --json`. - Do not put `begin` or `commit` in the SQL: each call is already one transaction. **Undoing a mistake.** Backups are continuous, and `whisk restore` returns the database to any minute in the plan's window: the last 7 days on Starter, Team and Agency, 30 on Business; Free has no self-service restore. `whisk db snapshot` marks a restore point before risky work and prints the command that returns to it, or says the plan cannot. `whisk restore --at