Ready for twist

What a project must satisfy before wbsp deploy --destination twist succeeds and the application signs people in at https://<app>.twist-hosting.com. The build applies this list on entry to the production phase.

How to read an item. Each begins with a six-character identifier — quote that, never a position in the list, because items are reordered, regrouped and retired and the identifier is the only thing that stays put. Each ends with where it comes from: G is the client guidelines index (with the section), R the manifest reference, D the credential-less deploy page, H HAP's own guidelines (named), X a behaviour the platform verified in its own code, Phil a direct instruction (dated), P a catalogue project by number, and C the conductor's field record (list, item, date) from which the item was vetted.

Owner. The platform writes this page. Regenerating the guidelines digest never touches it. A retired item stays on the page under Retired so a quoted identifier always resolves.

A. The destination block

  • gbpppu wbsp.yaml declares destination.twist with type: aws, mode: normal, tenant: twist, cluster: main, enclave: twist. tenant: is per destination; a root-level tenant: is rejected. (R; C twist 1; P 41, 42, 126, 169)
  • exhadz access: public at top level and a routes.twist entry naming the public domain in the form <app>.twist-hosting.com. routes: is required whenever access: public. (R; C twist 2)
  • agy3ba port: 8080 at top level. On aws the container is routed to 8080 whatever port: says unless use_declared_port: true, which also injects PORT. A project listening elsewhere declares that, or fixes its listener. (R The port contract; C twist 3)
  • 8ez2ya The reserved universal: {} destination is declared, and the twist block is an ordinary destination carrying its identity keys; demo, sandbox and universal carry none of them. (R Reserved destinations; C twist 4)
  • cvn8pd variant: <uuid> sits immediately before name:; projectNo: is declared; source: and dockerfile: name a Dockerfile that builds linux/amd64. (G §1; R; C twist 5)

B. Data stores

  • j2tr63 database.enabled: true, and all three store keys stated explicitly — redis.enabled, redis.durable, memorydb.enabled — each with its one-line comment, with redis.durable and memorydb.enabled each matching a user-decided redis / memorydb line in DECISIONS.md; a manifest that declares either without that decision fails here. Nothing under env: or secrets: names a DATABASE_*, REDIS_* or MEMORYDB_* variable — the platform injects them and a declaration fights it. (R; G §4 Requirement — warn your user and get permission before declaring either; C twist 6)
  • 3fs25x On the twist tenant both redis.durable and memorydb.enabled are false unless Phil has agreed otherwise for the project. The application treats Redis as a cache that can be emptied at any time: every job idempotent, nothing held only in Redis, the source of truth in PostgreSQL. (Phil 2026-09-08, 2026-09-14; G §4 Treat cache-mode Redis as ephemeral; C twist 6)
  • u33ht5 Every PostgreSQL extension the schema needs is under database.extensions; the platform creates them before the release runs. (R; C twist 7)
  • 5khe6m The application reads the discrete DATABASE_HOST/PORT/NAME/USER/PASSWORD and honours DATABASE_SSL=true, which the platform sets on RDS. Redis clients honour REDIS_NAMESPACE and REDIS_TLS. No source file reads a DATABASE_URL. (G §4; R; C twist 8)
  • 2znuum REDIS_* always describes plain Redis and never needs a cluster client: an application declaring durable: true is attached to a separate durable Redis instance with the same protocol, and no other application's declaration changes what this one receives. The application builds an ordinary (non-cluster) Redis client from REDIS_* and declares no REDIS_CLUSTER flag. MEMORYDB_*, injected only when the application itself declares memorydb.enabled: true, is the only cluster-mode store; that client is cluster-aware, with credentials in the node options since nodes are discovered rather than listed. (G §4 MemoryDB is a different product; R; C twist 44; P 123)
  • 8f6q6v The application reads REDIS_USERNAME and passes it. The platform provisions a named ACL user; a password-only AUTH against it fails with WRONGPASS, which reads like a wrong password, and never shows on a laptop where the default user authenticates by password alone. Omit the username when absent rather than sending an empty one. (C twist 45; P 123)
  • ch7xmn Hash tags apply to MEMORYDB_NAMESPACE keys and are never required for REDIS_*. An application on MemoryDB puts MEMORYDB_NAMESPACE inside {…} in its key prefix so every key of the deployment shares a slot and multi-key commands work, keeps the namespace leading the key literally so a prefix-matching ACL still sees it, and tags the local stand-in too so a laptop behaves like the cloud. (G §4 MemoryDB is a different product; C twist 46; P 123)
  • 7kzcax If the application uses S3, s3.enabled: true and no access keys anywhere: on aws the pod reaches the bucket through the role on its service account. (C twist 9; P 126)
  • qp4pyd If the application relies on row-level security: the cloud role is a plain user with no bypass and, under FORCE ROW LEVEL SECURITY, no owner exemption, so policies are live on twist though inert on dev, whose role is a superuser. Every connection path sets the tenant context the policies read — request middleware, workers, health checks, migration-time data fixes, pooled connections — or a query that works on dev returns zero rows on twist. Enforcement is tested against a non-superuser role before the deploy. (X measured 2026-09-08; C twist 37; P 61)

C. Release and migrations

  • kc53m2 A release: command exists, is idempotent, runs inside the application's own image with the discrete DATABASE_* only, and brings an empty database to the checked-in schema plus whatever row a first user needs, before the new version serves. (G §4.4; R; C twist 10)

  • aegvkq The image carries whatever release: needs: the migration toolchain and its dependency closure (a copied Prisma directory without its engines is the known failure), psql if the command uses it, and a script that runs under sh without an exec bit. Verified by building the image locally and running the release command inside it against a local database. (R Release; C twist 11; P 123)

  • 3db2ma Migrations are expand/contract: the previous version may still be serving while release: runs. (C twist 12; P 42)

  • wyugth release_timeout is set when migrations can exceed the default 600 seconds. (R; C twist 13)

  • pkzsac health_check.path is declared when the application 404s at /, so wbsp list --health and info probe a real endpoint; the path answers 200 with a body. (R; C twist 14)

  • 3mexnw The emptiness guard on any script that can wipe data must be able to see everything. Under row-level security a plain count runs through the policies, and with no tenant context it returns zero for a fully populated production database — so the guard reports "empty, safe to wipe" about a live system. Count with a path that bypasses the filter (a role with BYPASSRLS, or SET LOCAL row_security = off where the role may); if the check cannot be made to see past the policies it must fail closed and refuse, never report empty. An emptiness test that can be filtered is not a guard. Two minutes to check an existing script: run its own count as the application role, with no tenant context, against a database you know has rows. (G pipeline instruction corrected 2026-09-13; C twist 54–55; P 61)

  • 69kpfm wbsp db dump cannot back up a database whose tables FORCE ROW LEVEL SECURITY. pg_dump sets row_security = off so it can never write a silently partial dump, and with that set any query a policy would filter raises instead of returning less. Where the application's own role owns the tables (it ran the migrations) FORCE removes the owner's exemption and the dump fails outright — so the backup you were told to take before a destructive step is not available to you. That is pg_dump being careful. A workable backup lifts FORCE, copies every table, restores FORCE and commits only at the end, inside one transaction, so no other session sees the tables unforced and any failure rolls back. (C twist 54; P 61)

D. Environment and secrets

  • q2eh8v Per-destination destination.twist.env carries the placement configuration, not the top-level env:: the issuer https://hap.wbsp.ai/t/twist, the public https:// application URL under the app's own name, any "trust the proxy host" flag the framework needs (AUTH_TRUST_HOST=true for Auth.js), and a destination marker so the login bypass is refused. (G §5, §6; C twist 15; P 126, 169)

  • 8uqf6h Only env: and secrets: reach the container. A key that sits in .env.twist and is named by neither is read and dropped; a value already carried by env: is not demanded again as a secret. (R; C twist 16)

  • 694ktr Secrets are declared by reference, and per destination where a value exists only for twist. Per-destination secrets: fold into the top-level map (destination wins) but never subtract from it, so a secret declared under dev does not reach twist unless repeated, and a secret declared at top level also binds demo, which fails closed on values the platform supplies itself. (R; C twist 17; P 42)

  • hfxvkg Every declared secret has a value the platform will hold for this installation: in the git-ignored .env.twist beside wbsp.yaml, or set once with wbsp config set --destination twist. wbsp config status --destination twist reports the verdict the deploy will use. A declared secret with no value fails the deploy closed; a value_specs entry with required: false is omitted instead — entirely absent, never present-and-empty. (R; G §5; C twist 18)

  • f9pwnv A deploy records a placeholder as a real value, and every status command calls it complete: wbsp config status reports a secret "set" whether the value is real or the template's REPLACE_WITH_… string. The failure surfaces only at the token exchange, as 401 invalid_client, and only when somebody signs in. Where a secret is transcribed by hand, check the file before the deploy. (C twist 49; P 123)

  • 2ag7nu A committed .env.twist.example exists with placeholders only — no shared test-HAP values, since that client accepts localhost redirects only — and says which values are placement configuration living in wbsp.yaml rather than in this file. (G §5; C twist 19)

  • 52r4ng No WBSP_RDS_*, WBSP_REDIS_*, WBSP_ACME_* or AWS_PROFILE is needed in .env.twist for the credential-less flow; they belong to the operator's --backdoor path. Present ones are ignored, not harmful. (D; C twist 20)

  • enrrzu No login-bypass flag is on for twist — absent, or stated off — and the code refuses it on a cloud destination regardless. (G §6; C twist 21; see the ready-to-test page)

  • u7c5ef DATABASE_SSL is injected on cloud destinations only, so the branch that reads it has never run when you first deploy — and the spelling is per driver. The platform injects a boolean; drivers want a libpq word under different parameter names (sslmode=require for psycopg2, ssl=require for asyncpg, which rejects ssl=true outright). A project that builds one connection string for both drivers and interpolates the boolean cannot open a single connection on its first cloud destination, which presents as a deployment that comes up and serves nothing. Test the value against the driver's own parser, not against a literal — assert "?ssl=true" in dsn holds precisely while the application cannot connect. Use require, not verify-full: the provider CA is not in the default trust store and a tunnelled connection presents a certificate naming the real database host. (R DATABASE_SSL on aws; C twist 51; P 61)

  • qfsq66 A value arriving is not the same as the mechanism that consumes it working — test the consumer, not the file. A test asserting the file holds the right value passes throughout. Two shapes worth checking by name: an env file folded into the environment after the argument parser was constructed can never reach a default, so a required value reads as missing and an optional one silently falls back to a synthesised default; and a "this is a cloud destination" guard derived from a list of cloud names lets an unrecognised destination fall through to the local path — derive such a guard from the list of local names so anything unrecognised fails safe as cloud. (C twist 52; P 61)

  • abdxev A committed .env.<destination>.example is an instruction, not a placeholder list, and whatever it leaves unsaid is copied literally into the one file nobody else reviews. Wherever a value is not free-form it states: what each sentinel means and when it stops applying (a "set this to none" that is correct for one configuration is a defect in every other); the shape of the value where shape matters (a host root with no path, when the code appends one); and whether a value is used verbatim — an identifier that falls back to a default only when empty passes a non-empty typo straight through to the provider. A declared secret with no value at least fails the deploy closed; a plausible wrong value does not. (G §5; C twist 53; P 61)

  • 5vgpqq If the application calls an AI model, it reads exactly AI_PROVIDER (anthropic | openai | ollama | none), AI_API_KEY, AI_BASE_URL and AI_MODEL, with AI_MODEL_<ROLE> for per-role overrides and AI_EMBEDDING_* only where embeddings use a different provider — not an LLM_* spelling, and not a key per provider beside AI_PROVIDER. One factory selects the client; nothing else branches on the provider. Availability is the selected provider's own requirements, never "a key is present": ollama needs AI_BASE_URL and no key. With AI_PROVIDER absent or none the model features are off and say so, and the health endpoint reports it; the application does not crash. (G §5 The AI provider contract; Phil 2026-09-15; C note 2026-09-15; P 1, 61, 169, 301, 439)

  • 9avsyc AI_API_KEY is declared with a value spec source: operator, required: false, and is in no group. Without the spec a deployment that correctly has no key — ollama, or none — is refused, because a declared secret with no spec is required; inside a group with the other AI_* values, a keyless ollama configuration reads as half-supplied and is refused the same way. AI_PROVIDER and AI_MODEL sit in destination.twist.env:, not the top-level env:, which demo also reads. (G §5 The AI provider contract; X value resolution: required-without-spec, all-or-nothing groups; C note 2026-09-15)

E. Identity

  • 22amdb A HAP client exists in tenant twist for this application, registered before the first sign-in, carrying this destination's callback verbatim — https://<app>.twist-hosting.com/<framework-standard path> — with scopes openid profile email plus offline_access if the library asks for a refresh token. A repository deploy registers nothing; HAP matches redirect_uri as an exact string; the shared test client is localhost-only; another project's twist client is a different host and fails at the callback. (G §6; H hap-registering-an-application; C twist 22)
  • 4m6t37 The project cannot register its own client: registration needs a tenant administrator of twist or an admin key bound to that tenant, and an ordinary account in the tenant confers nothing. The shape is: the project asks, the tenant administrator registers, the credentials come back. Afterwards the project may be made application administrator of that one client — able to edit, rotate, disable and delete it — without gaining the power to create another. (H HAP's answer of 2026-09-08; C twist 38)
  • cwhbmr The registration request the project sends, one line each so it can be registered verbatim: name (permanent, shown in audit rows); application_type: web; confidential: true (this, not the type, is what mints a secret); redirect_uris exact; scopes (requested must be a subset of registered or authorize refuses with invalid_scope); home_url https://<app>.twist-hosting.com/ (without it the app is an unopenable row on everyone's menu); access_mode open unless a per-person allow-list is wanted. Scopes cannot be changed through the API afterwards, only in the console; post-logout redirect URIs cannot be set at creation, only by a later edit. (H; C twist 39)
  • 5spzja The secret is shown once, at creation, and can never be re-shown. A lost secret is replaced by rotation, never recovered. Replacing a working secret: rotate with an overlap so both work while the project redeploys. Replacing a lost one: rotate with no overlap, which also revokes that application's tokens and sessions. Local token validation checks the issuer https://hap.wbsp.ai/t/twist, not the audience alone, because a client_id is unique only within a tenant. (H; C twist 40)
  • j6shsa Nothing reads name or email off the verified ACCESS token. It carries sub, aud, iss, exp and scope and no profile claims, on every destination and for every user, whatever scopes were requested — so an API holding no ID token reads them from {AUTH_ISSUER}/oauth2/userinfo, where a row is about to be written rather than on every authenticated request, and treats a failure there as a missing name rather than a failed sign-in. Checked on two live deployments that were storing an invented name and a null respectively. (G §6; C twist 63)
  • fdfpvt If the application provisions a user row on first sight, names already written are checked, not assumed: that path writes the display name once and then looks the user up by subject, so every row created before an identity fix keeps whatever was wrong — an invented name, an empty string, a null. Refresh name and email when they differ from what the provider reports; never refresh the role that way. (G §6; C twist 64)
  • x5pzxx Changing a redirect URI later is an edit on the existing client, never delete-and-recreate; an edit revokes nothing. Register the new URI before the application starts sending it, keep both during a path change, then drop the old. (H; C twist 41)
  • ead6f7 The client id and issuer live with the secret in the twist configuration — per-destination env: or .env.twist — never inherited from a top-level env: that another destination also reads. (C twist 23; P 42)
  • ww7beb The callback is built as https://, not http://: the platform terminates TLS and forwards plain HTTP, the ingress answers plain http with a 301, and an http callback never matches. Frameworks that derive the callback from the request host pin the public URL and trust the proxy. (G §6 Build the callback as https; C twist 24)
  • h9wkvb auth.callbackPath (and auth.scopes when non-default) is declared in the manifest, so the redirect URI to register is stated rather than guessed, and wbsp deploy --dry-run prints the scope set that will be registered. (G §5 The identity contract, §6; C twist 25)
  • abea94 Outside demo and sandbox, the user menu's Exit forwards to https://hap.wbsp.ai/t/twist/exit, built from the configured issuer with /exit appended, after the application has ended its own session. (G §6 Show who is signed in; C twist 26)
  • 7wztq8 Leaving never returns to the application. A post-logout return to the application's own root — RP-initiated logout as the OIDC specification describes it — delivers the person, unauthenticated, to an application that correctly starts a sign-in, and presents as a sign-in fault (invalid-state on the next attempt) rather than a sign-out one. Checked from outside with no session, by following every hop of the Exit route: the chain reaches https://hap.wbsp.ai/t/twist/exit and no hop names <app>.twist-hosting.com, including inside an encoded post_logout_redirect_uri. A test that greps the source for the logout call proves nothing about where the person ends up. (G §6 Leaving never returns to your application; C note 2026-09-15; P 439)
  • wgmpr9 The redirect_uri is sent twice — on the authorize request and on the code exchange — and HAP compares the two as exact strings. Both are built from the same pinned value. A client library handed the incoming callback URL derives the exchange's value from that URL instead of from configuration, and inside a Next.js standalone container that URL has been observed as the server's bind address (http://0.0.0.0:8080/…), so every sign-in is refused with invalid_grant while the readiness endpoint reports the right callback. Rebuild the URL from configuration and take only the query string from the request. (G §6 The redirect_uri is sent twice; C note 2026-09-15; P 5)
  • ps8hch A callback whose pending sign-in state is missing restarts the sign-in once, carrying a short-lived marker, and renders a page on a second miss in a row — never a problem page at the end of a provider redirect, where nothing starts a sign-in and the back button is the only way on. (G §6 Make identity resolution resilient; C note 2026-09-15; P 439)
  • bz52ys Know what omitting consent=granted does; sending it is a choice, not a requirement. It asserts first-party consent — correct for your own application in your own tenant, wrong for a broker. Omitting it on a client with no consent on record asks the person rather than looping (the loop was fixed in 2026-08), which on a first sign-in is a screen the visitor did not expect and on an established client is invisible. Send it if a visible consent prompt on a newly registered client would be unacceptable; do not send it because a checklist said so. (H hap-hosted-login-guidelines, hap-sso-client-guidelines; C twist 48 as corrected by P 45, 2026-09-12)

F. Account and authority

  • t8c869 The deployer holds a signed-in session for tenant twist: wbsp login --tenant twist creates it, cached per tenant, and a deploy to a destination declaring tenant: twist presents that session rather than the website's. wbsp whoami reports one session — the tenant the project's destinations agree on, else the website tenant — so inside a project with several tenants its tenants: line says nothing about twist; sessions: lists which tenants have a cached login. The deployer role in tenant twist is granted by that tenant's administrator. (D; X token cache per tenant; C twist 27, corrected 2026-09-08)
  • jafxu5 The variant is owned by, or shared with, the signed-in account: the one-writer check refuses a deploy it cannot verify, and --allow-unverified-owner is the operator override, not the fix. (D Troubleshooting; C twist 28)
  • ddubz2 The installation is not operator-managed by accident: nothing has been stored with wbsp config set for this installation unless that was the intent. If it was, the deploy path is refused and wbsp config remove-installation is the only way back. (C twist 29; digest, production phase)
  • ejche6 wbsp is current (wbsp --version). wbsp deploy no longer takes --sandbox, --universal or --publish-only; the destination name says what is published. (D; C twist 30)
  • w3reft Before spending a deploy on a rotated secret, test it: a token request carrying a deliberately invalid code answers 401 invalid_client while the secret is wrong and 400 invalid_grant once it is right, because HAP rejects the client before it looks at the code. A successful deploy, a healthy pod and a "complete" config status are together still consistent with nobody being able to sign in. (C twist 50; P 123)

G. Runtime shape on one container

  • t2eueq The application runs as one container: a worker, scheduler or queue consumer runs inside the same process or is declared as a component. A "dedicated worker" mode with no second container queues forever. (R; C twist 31; P 169)
  • 282gv8 Logs go to stdout and stderr as structured lines; the platform collects them. (G §7; C twist 32)
  • 2uymvp Behind the path-prefix router Location headers are not rewritten: redirects are relative, or built from the pinned public URL. (G §2 via path-prefixes.md; C twist 33)
  • vkeeze A Next.js standalone image sets HOSTNAME=0.0.0.0 in its runtime stage, for the reason given on the ready-for-sandbox page (item as7xfn): the fault hides on aws, where the pod is probed by address, and surfaces on every local type. (C twist 47; P 45)

H. Publishing and after the deploy

  • yx6hdb wbsp deploy builds from the working tree. Before any publish the project reads git status and records it, and publishes from a clean tree or a checkout of the intended commit, stating the commit. The -dirty suffix is honest from wbsp v0.2.45; on v0.2.44 and earlier it appears on every demo or sandbox publish regardless (see the ready-for-sandbox page, c4a442). (X; C twist 43; P 123)

  • 86c5d9 wbsp deploy --destination twist ends with the placement reported; wbsp list --provider aws --tenant twist shows the app running with its route; https://<app>.twist-hosting.com answers 200 (or a redirect to sign-in that answers 200) and the health path answers. (D; C twist 34)

  • yvegw3 One real sign-in through HAP on tenant twist completes — nothing before the deploy can prove the redirect URI is registered, and the only proof that a client secret is right is one completed sign-in. The tester is a member of tenant twist; the first arrival is provisioned with the ordinary default role; admin follows HAP's claims. (G §6; C twist 35, 49; P 42)

  • pjs7et The project has told nobody outside its repository to do anything: the HAP client registration, tenant membership and deployer role are requests to the tenant's administrator, carried by the operator, never instructions to a core project. (G §9; C twist 36)

  • pjdnma A deploy can report success and change nothing that is running, and a failed deploy can already have changed it. Where the workload references a moving tag, moving the tag does not tell the cluster to pull it again — the command completes, prints a normal status and the pods go on serving what they were serving. The inverse also happens: a deploy that fails at the route step has already rolled the new image out. The exit status describes the command, not what is serving. Verify by looking at what is served, through something that changes between builds: a versioned asset's hash, a version string the build stamps in, a value the health endpoint reports. Give it long enough that you are not reporting a race, then believe what you see. (C twist 56; P 45, 61)

Retired

None yet.