# Warmbly Agent Notes ## Purpose Warmbly is an email warmup and cold outreach platform. At a product level, the app does four main things: - manages sender accounts and their assignment to workers - sends campaign and warmup mail through distributed workers - syncs mailbox state back into the platform - tracks opens, clicks, replies, suppression, and deliverability signals The backend API is the control plane. Workers are the execution plane. It ships as a hosted service and as a self-host, and the two are the same code. The front door for the self-host is one command, `curl -fsSL https://warmbly.com/install.sh | sh`, which pulls the published release images and needs no clone and no compiler. `--wizard` turns it into an interactive install that asks the data-control questions up front: where each store lives, what is kept and for how long, how it is backed up. The script is `site/public/install.sh` and it has its own rules below; the docs are `docs/content/docs/development/install.mdx` and `data-control.mdx`. ## Working In This Repo CI is strict. `go build ./...` succeeding is not enough — `golangci-lint` runs `gofmt` as part of its checks, and a single unformatted import block or mis-indented doc comment will fail the PR even when the code compiles cleanly. Before declaring any Go change done: - run `gofmt -w` on every Go file you touched (or `gofmt -w internal/ cmd/` to be safe) - run `make lint` locally when the toolchain is installed, or at minimum `gofmt -l ./...` should print nothing - do not rely on `go build` as the "ship signal" — it ignores formatting and stylistic lint rules that CI enforces Other CI-touching rules: - the frontend trees (`admin/`, `web/`, `site/`) each have their own CI jobs; run `pnpm typecheck` in any tree you touched and `pnpm lint` when the rules are non-trivial - never push without first re-running the relevant `*build*` / `*typecheck*` / `*lint*` step on the affected tree - a `make lint` (or `gofmt -l`) failure is always a real CI failure; do not push hoping it will pass Migrations are numbered against `main`, not against your branch: - a new migration takes the next six-digit version after the highest one on `main`, with a matching `.up.sql` and `.down.sql` - two branches that each pick "the next number" independently are both green alone and collide once both merge; golang-migrate then refuses to build its source driver and the backend restart-loops at boot, so nothing deploys - run `make check-migrations` (also a prerequisite of `make lint`, and its own CI job) before pushing anything that adds a migration - if a duplicate does reach `main`, renumber the migration that has NOT been released yet. The other one is already recorded in deployments' `schema_migrations`, and renumbering it makes them re-apply it Docs stay in sync: - the customer docs site lives in `docs/` (Fumadocs, served at docs.warmbly.com); content is MDX under `docs/content/docs/` in three sections: `guides/` (product behavior), `learn/` (fundamentals), `api/` (API reference) - any change that alters user-visible behavior must update the matching docs page in the same change: a new or changed endpoint updates `api/endpoints.mdx` (scope map) and, where relevant, `api/authentication.mdx`; a new or changed API permission updates `api/permissions.mdx` including the permission table, presets, and all three language tabs in the constants section; a new or changed error code updates `api/error-codes.mdx`; a new or changed product feature, default, limit, or setting updates the relevant `guides/` page (or adds one, registered in `guides/meta.json` under the right section group) - removing or renaming a feature, endpoint, or permission means removing or updating its docs too; do not leave stale docs behind - self-hosting behavior has its own pages under `docs/content/docs/development/`: a change to the installer or to what it asks updates `install.mdx`; a change to where a store lives, how long something is kept, or how an instance is backed up or moved updates `data-control.mdx`; a new environment variable updates `configuration.mdx`, and a new database-backed setting updates its table there as well as the admin panel - follow the docs conventions: frontmatter `title` is the H1 (no `#` heading in the body), no decorative sidebar icons (pages and `meta.json` sections carry no `icon`; the source loader has the lucide icon plugin disabled, and code-sample tabs use the real language logo instead), sentence-case headings, no em dashes in prose, internal links use trailing slashes (`/guides/mailboxes/`) - verify with `pnpm types:check` and `pnpm lint` in `docs/` (the site is a fully static export; `pnpm build` writes `out/`) Commit hygiene: - when instructed to make a commit, use the subject format `feat: ` - one line, no body. Make the line long and specific (what changed and where), not a stub like `feat: fix docs` - no `Co-Authored-By:` or other AI/agent attribution footers; rewrite any commit that has one before opening or updating a PR Copy / writing style: - do not lean on em dashes (`—`). Use them sparingly, only when one is genuinely the clearest option; prefer a period, comma, colon, or parentheses instead. This applies to user-facing copy and microcopy in `site/` and `web/`, and to docs. Overusing em dashes reads as machine-written. Code comments: - keep them short: one line stating the non-obvious constraint or intent. No multi-line essays; if a comment needs a paragraph, the explanation belongs in docs or the PR description Data modeling / representation: - we are happiest with the most **type-safe** option, but the rule is: pick the **most effective option for the actual use case**, not type-safety for its own sake. - prefer real typed columns / enums when the data is fixed-shape, queried or filtered in SQL, or benefits from FK integrity. - a `jsonb` column is the right call when the data is a free-form, evolving, read-then-execute blob that isn't filtered in SQL (e.g. the `sequences.conditions` branching tree and `sequences.action` node config) — keep it type-safe at the app boundary with a Go struct + validation on write, and a DB `CHECK` on any discriminator column. ### Workspace data stays portable A customer can export their whole organization to an archive and import it on another instance (`internal/app/orgtransfer`, Settings > Data, `warmblyctl org export|import`). That only keeps working if every new piece of org-owned data is added to it deliberately. Data that isn't in the registry is silently absent from every archive, and nobody finds out until a migration lands on the other side missing a feature's data. So: **a migration that adds an organization-scoped table is not done until that table is in `internal/app/orgtransfer/spec.go`.** Add it to `Tables` with its data group and scope, or to `ExcludedTables` with the reason it must not travel. There is no third option; leaving it out is the bug. When you add one, work through: - **Scope.** The `WHERE` fragment selecting that table's rows for one organization, with `$1` as the org id. Use a subquery against a parent when the table has no `organization_id` of its own. - **Order.** `Tables` is applied top to bottom on import, so a table must sit below everything it references. - **Group.** Which `models.OrgDataGroup` it belongs to. If a NOT NULL foreign key crosses a group boundary, add the dependency to `Requires` in `models.OrgDataGroupCatalog` — otherwise a user who deselects the target group gets an import that aborts on a constraint. Nullable crossings need nothing; the importer blanks them. - **Secrets.** Any column holding ciphertext needs a `SecretColumn` with the right `KeyDomain`. Warmbly has two and they are not interchangeable: `KeyDomainInstance` is `CREDENTIALS_ENCRYPTION_KEY` (mailbox credentials, which the worker reads without an org context), `KeyDomainOrgDEK` is the per-organization DEK (everything else). Getting this wrong produces mailboxes that authenticate against nothing. - **Instance-local columns.** Anything naming a worker, a queue handle, a Stripe object, or a sync checkpoint belongs in `ResetOnImport`, or the whole table in `ImportSkip` when it only means something on the instance that wrote it. - **Blobs.** A column holding an object-storage key needs a `BlobColumn` so the bytes travel with the rows. Rows move as `jsonb` in both directions, so adding a *column* to an existing table needs no code change: the exporter emits it and the importer intersects against the destination catalog. Only new tables need registering. The same applies to the customer-facing side of a feature: if it stores org data, its docs page and `docs/content/docs/guides/workspace-export-import.mdx` should agree about whether that data moves. ### The installer is a published artifact `site/public/install.sh` is the one-command self-host installer, served verbatim from the static site at `https://warmbly.com/install.sh`. What is in the repo is byte for byte what a stranger pipes into their shell, which makes it the highest-consequence file here that is not Go. It is a wizard: an animated stepper, arrow-key and vim menus, live pull and health screens, a review pass, and a `--demo` mode that plays the whole thing while installing nothing. `docs/content/docs/development/install.mdx` documents it and `data-control.mdx` documents what its questions decide; the `warmbly-install` skill is the agent-facing version. Rules, all of them learned from breaking them: - **POSIX sh, not bash.** It runs under whatever `/bin/sh` the host has, which on Debian and Ubuntu is dash. A `sh -n` that passes under your own shell proves nothing about that; `make installer-check` runs `dash -n` and `shellcheck -s sh` - **`set -eu`, everything in a function, `main "$@"` on the last line**, so a truncated download executes nothing. Watch for `[ x ] && y` as a function's LAST command: it returns non-zero when the test fails, and under `set -e` that ends the run. Use an `if`, or end with `return 0` - **Nothing drawn inside a redraw loop may be wider than the terminal.** A wrapped line is two physical rows while every `ESC[nA` counts logical ones, so one long option hint makes the menu draw over itself and over whatever was on screen before it. Everything in a loop goes through `fit` - **The screen is not ours.** It appends by default, `--clear` is opt-in, and `ESC[3J` (erase scrollback) is never sent - **Regenerate the checksum.** `site/public/install.sh.sha256` is what makes "download, verify, read, run" a real alternative to piping into a shell. `make installer-sha`, and CI fails when the two disagree - **Every answer is a flag and a `WARMBLY_*` variable.** An install that can only be driven by keyboard cannot be driven by Ansible, cloud-init or an agent, and the wizard exists to be optional - **Idempotent.** A second run adopts the existing `.env`, never regenerates a secret (a new `CREDENTIALS_ENCRYPTION_KEY` is permanent data loss) and never moves an existing data root Run `make installer-check` before pushing a change to it (POSIX parse, shellcheck, `--help`, `--demo`, `--print-env`, a compose file per answer shape, a pty width regression test, and the checksum). `make installer-demo` is how you see a UI change without installing anything. `site/public/cli.sh` is the second published script, served at `https://warmbly.com/cli.sh`, and it installs the `warmbly` CLI rather than an instance. Every rule above applies to it, plus two of its own: - **It verifies what it downloads.** The release publishes `checksums.txt` next to the archives, and a mismatch installs nothing rather than warning. Never weaken that to a warning - **Release assets are named without the version**, so `releases/latest/download/warmbly__.tar.gz` resolves with no GitHub API call. The unauthenticated API is rate limited per IP, which is what breaks a curl installer on a shared runner. `scripts/build-cli.sh` and the platform list in `cli.sh` have to agree; `make cli-check` fails when they do not `make cli-check` runs the whole thing (POSIX parse, shellcheck, `--help`, `--dry-run`, a real install from a local mirror, checksum tampering, uninstall, the PowerShell parse and the checksum), and `make cli-sha` regenerates the checksum after any edit. `site/public/cli.ps1` is the Windows half. ### Verification: what to run, what to skip Keep the loop fast. The signals that matter are formatting, lint, and typecheck — not local builds or browser automation. Always, before calling a Go change done: - run `make fmt` (or `gofmt -w cmd internal`); `gofmt -l ./...` must print nothing - run `make lint` (golangci-lint, which first runs `make check-migrations`) For frontend changes, run `pnpm typecheck` and `pnpm lint` in any tree you touched. For a change to `site/public/install.sh`, run `make installer-check`; it is the same script CI runs and it regenerates nothing, so a stale checksum fails there exactly as it will in CI. For `site/public/cli.sh` or `cli.ps1`, the equivalent is `make cli-check` (and `make cli-sha` after any edit). Do not: - do not run `go build ./...`, `pnpm build`, or docker image builds as a "did it work" check. They are slow and are not what CI gates on. `go run` (via the make dev targets) already compiles; `make fmt` + `make lint` + `pnpm typecheck` are the real signals. - do not write or run Python/Playwright (or any browser-automation) scripts to test the app. Manual, in-browser verification is the user's job against the native dev stack (`make infra` + `make backend` + `make web`). Do not add screenshot/e2e test harnesses to this repo. - do not run the Go test suite as a default gate unless the task is specifically about those tests. - do not push hoping CI passes; a `gofmt -l` / `make lint` / `pnpm typecheck` failure is always a real CI failure. ## Security And Compliance Invariants Warmbly's Google OAuth client is assessed against **ADA CASA v2.1.1 at Assurance Level 1**, which maps to OWASP ASVS 4.0.3. The evidence pack is a claim about the code on `main`: a change that breaks one of the invariants below does not just introduce a bug, it makes a submitted statement untrue and puts the OAuth client's verification at risk. Treat them as constraints on every change, not as a checklist run before an audit. **The pack is not in this repository and must not be added to it.** It maps every control to the file that implements it and lists the advisories still open with the exact conditions under which each is reachable. That is a reconnaissance document for anyone attacking a self-hosted instance that has not updated, which is the same reason the disclosure rule below exists. It lives outside the tree, at `CASA_EVIDENCE_DIR` (default `~/warmbly-casa-private/casa`), and `make casa-evidence` refuses to write anywhere inside the repository. What stays here is this section: the invariants themselves, stated as what the code does rather than as what it would otherwise allow. When a change alters a control, update the pack in the same sitting, because nothing in CI can tell you the pack has gone stale. ### Disclosure: this repository is public and the product self-hosts Every instance that has not updated yet runs the code an attacker can read here. So: - **describe the invariant, never the gap.** A comment, commit subject, PR body or doc that says what used to be possible is a working exploit for every unpatched instance. Write "every read of an organization's data is scoped by `organization_id`", not "before this, X could read Y" - do not add a before-and-after account of a security fix to the repository. Keep that out of tree - a security fix ships like any other change: a normal subject line naming what the code now does ### Authentication - passwords are hashed with **Argon2id** and nothing else. No change may introduce a second scheme, weaken the parameters, or store a password in any reversible form - `crypt.CheckPassword` (`internal/pkg/crypt/validation.go`) is the only gate on a new or changed password, and it refuses anything on the embedded NCSC breached list (`internal/pkg/crypt/passwords/breached.txt`). Every path that accepts a password must call it: registration, reset, change, invitation acceptance, and any future one - every auth-sensitive entry point is behind CAPTCHA (`internal/pkg/captcha/turnstile.go`): login, registration, password reset, confirmation - TOTP verification records the step it consumed (`user_totp_settings.last_used_step`) and refuses a replay of it. Any new second factor needs equivalent single-use enforcement - **admin routes require a session that verified a second factor.** `middleware.RequireAdminPermission` refuses `!session.MFAVerified` with `admin_mfa_required`. Never add an admin route that bypasses it - an operation that changes who can get in, or moves money or ownership, requires a fresh authentication (`middleware.RequireFreshAuth`, `POST /v1/auth/reauth`). API-key and OAuth callers pass through, because they present a credential on every call and have no session to refresh - a federated identity (Google, Apple, OIDC) is bound to an account by `(issuer, subject)`. The email fallback that finds an existing account on a first sign-in attaches the identity to a password account only after that password is presented (`resolveFederatedUser` parks it as `link_required`, `SSOLinkConfirm` completes it through `finishLoginAs`). Only an account with no password links on the address alone ### Sessions and tokens - **every token carries a purpose** and is verified against the one purpose its consumer accepts (`internal/app/token/config.go`: `access`, `refresh`, `ws`, `login`, `registration`, `reset`, `2fa`). A token minted for one flow must never verify in another. A new token type gets a new purpose constant, not a reused one - `VerifyToken` pins the algorithm to HS256 and requires an expiry. Do not relax either, and do not add a verification path that skips `token.VerifyToken` - `AUTH_SECRET` has a hard floor of `config.MinAuthSecretLength` (32 bytes) and the backend refuses to boot below it. The realtime service applies the same floor to `JWT_SECRET`, which is the same value. Neither check may become a warning - banning a user, changing a password and revoking a session all terminate the sessions they invalidate. A new "lock this account" path must revoke too, or it locks nothing ### Access control: the rule that is easiest to get wrong **The route's permission gate and the service's data scope must agree, and both must be the organization.** Mailboxes, contacts, campaigns, tokens and message content are organization assets; they are not owned by the member who created them. A route gated on an organization permission whose service then filters by `user_id` produces the worst kind of failure: the resource is listed, the caller passes the gate, and the write returns "not found". It reads as data corruption and it strands resources permanently when the member who created them leaves. Going the other way, a user-scoped gate with an organization-scoped query is a tenant leak. So, for anything organization-owned: - the SQL predicate is `organization_id = $1`. A helper that takes a "scope" fragment gets the organization one - the handler resolves the tenant with `middleware.GetOrganizationID(c)` and refuses when it is absent - ownership is checked against the caller's organization before any side effect is published, not after - `user_id` stays on the row as a record of who connected it, and is used for attribution and for addressing worker events. It is not an authorization key Everything else in section 3 of the evidence pack rests on this: no identifier from the request body may select a row without a tenant predicate, and a reference to another entity (a campaign, a contact, a task) is verified to belong to the same organization before it is accepted. ### Communications - `middleware.SecurityHeaders` sets HSTS, `X-Content-Type-Options`, `X-Frame-Options`, a referrer policy and a default-deny CSP on every API response. Do not remove a header to make a page work; scope the exception - the realtime websocket checks the browser's `Origin` against `CHECK_ORIGIN_HOSTS`. Non-browser clients send no origin and are unaffected. Adding a first-party origin means adding it to that list in every environment - webhook targets stay HTTPS and HMAC-signed, and SSRF-prone destinations are refused. Only a self-hosted or development instance may opt out ### Input that other people see Anything one person types that Warmbly later shows to someone else is content injection waiting to happen, and platform email is the worst case: a mail client turns anything shaped like an address into a live link, sent under Warmbly's own domain. `html/template` escaping stops markup, not that. So: - **every name a person chooses goes through `internal/pkg/displayname`**: first and last names, workspace names, and any new name-like field that can reach another person. It refuses links, web addresses, email addresses, hostnames and IPs (after folding full-width and ideographic dots), control, invisible and bidi characters, markup characters and stacked combining marks, and it bounds length by `Kind`. The refusal is `400 invalid_name`, documented in `api/error-codes.mdx` - **the server is the authority and the check sits at every write**, not only the one the dashboard uses: the handler or service behind registration, setup, onboarding, profile, org create and rename, the admin panel, `warmblyctl` and an org-transfer import. A new path that writes one of these fields calls the same package. `web/src/lib/displayName.ts` mirrors the rules so a form can explain a refusal before the request, and it is never the only check - **a value nobody can be asked to correct is cleaned, not refused**: a name from an identity provider or an email local part goes through `displayname.Clean`/`FromEmail`, which drops what fails, so a hostile IdP claim costs the user a name, not a sign-in - **a stored value is untrusted at render time too.** Rows written before a rule existed are still in the database, so anything interpolated into an email body or subject goes through `displayname.Displayable` (or `FullName`) with a neutral fallback ("A team member", "Your workspace") - **tighten a rule in both places and in the docs together**: Go package, `displayName.ts`, their tests, and the `invalid_name` section of `api/error-codes.mdx` ### Errors, logging and data exposure - a server-class (`Internal`) error answers the caller with one fixed sentence and a request id. The real message is logged against that id. `errx.NewPublic` is the narrow exception, for a message an operator can act on, and never for one built from an underlying error - no secret, credential, token or full DSN may reach a log line, an error message or an analytics event. Errors sent to PostHog go through `internal/observability/errs`, and the database wrapper strips parameter values - ciphertext columns carry the right key domain. `KeyDomainInstance` is `CREDENTIALS_ENCRYPTION_KEY`, `KeyDomainOrgDEK` is the per-organization DEK. They are not interchangeable ### Dependencies and configuration - `make casa-evidence` runs `govulncheck`, the Node, Rust and Elixir audits and a Trivy scan, writing outside the repository. A reachable vulnerability with an upstream fix is fixed; one without gets a written justification in the pack, not silence. Do not paste scanner output, an advisory id or a reachability note into this repository - no credential of any kind is committed. A node in the fleet holds no cloud credential: the privileged operations are brokered through the internal API - a new environment variable is documented in `docs/content/docs/development/configuration.mdx` in the same change ## Local Development Event codec: `json` is the default the Makefile and docker-compose set, because it needs nothing. `avro` works too: the worker command and result envelopes carry an `any` body, and a schema is derived for each from the declared registry in `internal/models/event_variants.go` (see `event_schema.go`), so a new event type is not carried until it is added there. It is only compiled into the `-kafka` images and resolves every event against `SCHEMA_REGISTRY_URL`. `tracking-events` reads the same setting: the consumer decodes both of its topics with one codec, so the Rust publisher honours `CODEC_PROVIDER` on Kafka as well as on NATS. Avro there needs a Schema Registry and is refused at boot without one; JSON needs nothing. Infra runs in docker; the Go services and frontends run natively on the host for fast iteration — no docker image rebuilds when you change app code. Targets live in the `Makefile`. - `make dev` — the one-command stack: brings up the docker infra and waits for postgres, applies migrations, loads seed fixtures (skip with `SEED=false`), installs web + admin deps on first run, starts realtime and tracking as containers, then runs backend + forms + consumer + worker + dashboard + admin in one terminal. Login: dev@warmbly.com / password123, with the emailed login code in Mailpit at http://localhost:18025. Ctrl-C stops the app; infra stays up. - `make infra` — start the backing services in docker (postgres, redis, nats, mailpit). Run once; leave running. Kafka, Schema Registry, localstack, cloud-tasks, and stripe-mock are gone; the stack is no-cloud by default (NATS, local KMS, filesystem blobs, in-process tasks). - `make backend` — run the API natively on `:8080` (applies the embedded migrations on boot against the docker postgres). - `make consumer` / `make worker` — run those Go services natively, each in its own terminal. Both register themselves as fleet nodes on their first heartbeat, so they show up in `warmblyctl fleet list` without any enrolment step in dev. Workers are interchangeable, so one is enough; run a second `make worker WORKER_ID=` in another terminal when you want to watch placement spread mailboxes across a fleet. The workers read encrypted DEKs through the backend's `/internal/dek` endpoint (the prod `http` provider, no worker DB), so `make backend` must be running and their `INTERNAL_API_TOKEN` must match (the targets are pre-wired to match). - `make run` — backend + forms + consumer + worker together in one terminal (Ctrl-C stops all). - `make forms` — the public forms service natively on `:8090` (`cmd/forms`): builds the `forms/` TanStack app, then serves it plus the embed loader and public submissions. No database; it resolves forms and forwards submissions through the backend's internal API, so `make backend` must be running and `INTERNAL_API_TOKEN` must match (pre-wired). The backend's `FORMS_DOMAIN=localhost:8090` makes dashboard share links point at it; `make forms FORMS_PORT=8091` (matched on `make backend`) moves it when worktrees share the machine. `make forms-web` runs the Vite dev server (:5175) for iterating on the app itself. - `make sandbox` — fully working demo environment: seeds the "Sunrise Labs" showcase org (live mailboxes: SMTP -> mailpit, IMAP -> dovecot, credentials sealed with `CREDENTIALS_ENCRYPTION_KEY`) and runs the simulator that plays the internet (delivers mail into dovecot inboxes, opens pixels, clicks tracked links, replies as contacts). Needs `make run` + `make tracking` alongside. Docs: `docs/content/docs/development/sandbox.mdx`. - `make tracking` / `make realtime` — the Rust tracking pixel service (:3000) and Elixir/Phoenix websocket fanout (:4000). Deliberately kept out of `make run`; start them only when needed, and only if you have the cargo / elixir toolchains on the host. - `make web` / `make admin` / `make site` — frontend dev servers (5173 / 5174 / 4321), pointed at the native backend. - `make seed` — load fixtures (after the backend has applied migrations). - `make fmt` / `make lint` — format and lint Go. - `make installer-demo` — walk the self-host installer's wizard with nothing installed: the real questions and review, a played pull and start. No Docker, no network, no file written. `WARMBLY_DEMO_FAST=1` collapses the animations while iterating on them. - `make installer-check` / `make installer-sha` — everything CI runs against `site/public/install.sh`, and the checksum regeneration that has to follow any edit to it. Prefer native `make backend` over rebuilding the docker backend image: docker rebuilds are slow because the image bakes in the migrations and the compiled binary, so a one-line change means a full image build + container recreate. The native targets skip all of that. The dockerized hot-reload flow (`make app`) and prod-image smoke test (`make up`) remain available when you specifically need containers. Dashboard realtime: - dashboard experiences should be realtime by default. When emails arrive, contacts are added, records change, or any dashboard-visible feature updates, the dashboard should reflect it live without requiring a manual refresh - aim for a responsive, Discord-like product feel: presence, counts, lists, detail panes, notifications, and workflow state should stay current across every dashboard feature where live updates are meaningful - when changing dashboard behavior, it is acceptable to safely change the API structure if a better solution exists. Before making an API shape change, ask the user how they want to handle it, especially when the current API may already be published or backwards compatibility might require a new API version Public API quality bar: - treat customer-facing API changes as contract changes. Prefer additive changes inside a version, and use a new API version for incompatible behavior once an endpoint is published - every API-key-capable route must have an explicit API permission gate and, for JWT callers, the matching organization permission gate - side-effectful POST/PATCH/PUT/DELETE endpoints should support `Idempotency-Key` or have a documented reason why retries are naturally safe - error responses should include stable machine-readable `code` and `request_id` fields in addition to human-readable text - list endpoints should use consistent `data` plus `pagination` shapes with opaque cursors; invalid cursors or limits should return `400` instead of being ignored - webhook endpoints must stay HMAC-signed, HTTPS by default, and protected against obvious SSRF targets. Only development/self-hosted environments should opt into unsafe webhook URLs ## Dashboard UI Conventions (`web/`) Everything in the dashboard must use our own theme, not browser/library defaults. - Number fields: never ship a raw `` with the native spinner. Use the shared `NumberInput` from `@/components/ui/field` — it strips the native up/down arrows (`appearance:none`) and renders our own themed chevron steppers. Do not re-add the default stepper anywhere. - Inputs/labels: reuse `TextInput`, `SearchInput`, `Label`, `NumberInput` from `@/components/ui/field`; don't hand-roll raw ``/`