# Run402 CLI -- Agent Reference — Deploying apps > Slice `deploy` of the Run402 CLI reference. Index (start here): https://docs.run402.com/llms-cli.txt · Whole reference: https://docs.run402.com/llms-cli-full.txt Use `run402 up` for normal deployments. The commands below expose advanced planning, apply, inspection and recovery primitives. They share the same release model; none promises to reverse committed database migrations. ## Deploying Apps ### Unified Apply Canonical deploy primitive: CAS bytes (no inline-body cap), per-resource `replace`/`patch`, atomic multi-resource activation, resumable failures. SDK: `r.project(id).apply(...)`. Runtime config tokens are opaque credentials: equivalent claims can have different ES256 signatures. Do not compare token strings to determine project identity; use `project_id` and `api_base`. Your HTML never needs a pasted key: load `/_run402/config.js` and read `window.RUN402.anon_key` (see "Runtime config on every host"). Wire fields — manifests, plan and commit bodies — are `snake_case` (`content_type`, never `contentType`; `contentType` is only the in-function `assets.put` JS option). Manifest format mirrors a v2 `ReleaseSpec`. For editor autocomplete, use top-level `"$schema": "https://run402.com/schemas/release-spec.v1.json"`; the CLI accepts that metadata and strips it before planning. The example uses prototype-compatible function limits: 10 seconds and 128 MB. Larger values require a tier that supports them. `--check` validates locally; it does not certify the target organization's tier limits. ```json { "$schema": "https://run402.com/schemas/release-spec.v1.json", "project_id": "prj_1741340000_42", "database": { "migrations": [ { "id": "001_init", "sql": "CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL); INSERT INTO items (title) VALUES ('Buy groceries');" }, { "name": "seed_items", "sql": "INSERT INTO items (title) SELECT 'Welcome' WHERE NOT EXISTS (SELECT 1 FROM items WHERE title = 'Welcome');" } ], "expose": { "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "public_read_authenticated_write" } ] } }, "secrets": { "require": ["OPENAI_API_KEY"], "delete": ["OLD_KEY"] }, "functions": { "replace": { "api": { "runtime": "node22", "source": { "data": "export default async (req) => new Response('ok')" }, "config": { "timeout_seconds": 10, "memory_mb": 128 }, "triggers": [{ "id": "api_every_15m", "type": "schedule", "cron": "*/15 * * * *", "run": { "event_type": "api.tick", "payload": {} } }] } } }, "site": { "replace": { "index.html": { "data": "..." }, "assets/logo.png": { "data": "iVBORw0KGgo...", "encoding": "base64" } } }, "subdomains": { "set": ["my-app"] }, "routes": { "replace": [ { "pattern": "/api/*", "methods": ["GET", "POST"], "target": { "type": "function", "name": "api" } } ] }, "i18n": { "default_locale": "en", "locales": ["en", "es", "fr"], "detect": ["cookie:wl_locale", "accept-language"] } } ``` File entries: bare UTF-8 string; `{ "data": "...", "encoding": "utf-8" | "base64", "content_type": "..." }`; or `{ "path": "dist/index.html", "content_type": "text/html" }`. `site.replace` / `site.patch.put` may also be `{ "__source": "local-dir", "path": "dist/client" }` for a static-site directory. Function `source` may be `{ "path": "dist/run402/functions/api.js" }`. `--manifest` relative paths resolve from manifest dir; `--spec`/stdin paths resolve from cwd. Authoring-only local paths and `__source` markers are stripped/staged before the apply request. Migrations may use `"sql_path"` / `"sql_file"` instead of `"sql"`. Each migration declares exactly one of `"id"` or `"name"`: use `id` for immutable versioned migrations, and `name` for generated/idempotent SQL whose compiled id should track content changes. CLI/MCP share SDK `normalizeDeployManifest`; JSON can become SDK-native `ReleaseSpec`. Strict adapter: only top-level `$schema` and app-kit evidence `x-run402-omitted_features` are ignored before planning; unknown fields/no-op specs fail (`"subdomain"`, `"site.replcae"`, `"functions.replace.api.deps"`, `"functions.replace.api.config.schedule"`). Binary files are safe in every form. A `{ "path": ... }` entry is read from disk as bytes and uploaded as-is, never decoded as text, with the content type inferred from the extension (`.webp` → `image/webp`, `.png`, `.woff2`, `.pdf`, ...) unless `content_type` says otherwise; only a bare string is treated as UTF-8 text, and a string paired with a binary path or type is refused locally as `BINARY_CONTENT_REQUIRES_BYTES` before any request. So an image ships as `"sigil.webp": { "path": "assets/sigil.webp" }` next to `"index.html": { "path": "site/index.html" }`. To see what a manifest will ship before it ships, `run402 up --manifest run402.json --check` prints the preflight with `summary.site` (path count and a per-content-type tally) and makes no gateway call. Function specs: `runtime: "node22"`, exactly one code source (`source` or `files`+`entrypoint`), `config.timeout_seconds`, `config.memory_mb`, and optional `triggers[]`. Schedule triggers require a stable `id`, `type: "schedule"`, 5-field `cron`, and nested `run: { event_type, payload?, retry?, expires_after_seconds? }`; each tick creates a durable function run. Email triggers use `{ id, type: "email", mailbox, events, run }`, where `mailbox` is a mailbox slug/id and `events` is any of `reply_received`, `delivery`, `bounced`, `complained`, `mailbox_suspended`; each matching email event creates a durable function run with the canonical event payload under `payload.event`. A `mailbox_suspended` trigger fires when the mailbox is abuse-suspended (payload event carries `suspended_reason`, `suspended_at`, `evidence`, `recovery_actions`) — the run executes independently of the suspended mailbox's send capability, so an app can observe its own outage without polling or a public webhook URL. `deps: string[]` works under `apply-v1-function-deps`; gateway installs/bundles. `run402 functions deploy --deps` builds one `functions.patch.set` and uses unified apply; legacy standalone deploy route removed. Deploy preflights literal function caps after normalization before CAS upload/plan: timeout, memory, schedule-trigger cron interval, scheduled-trigger count. Local failures: `code: "BAD_FIELD"` with `details.field/value/tier`, limit (`tier_max` or `min_interval_minutes`), `details.limit_source` (`tier_status` or `local_static_fallback`). Current caps: prototype 10s/128 MB/1 scheduled trigger/15 min; hobby 30s/256 MB/3/5 min; team 60s/512 MB/10/1 min. `tier status` shows live caps/usage when returned. Subdomains: one mode per deploy. `"set"` replaces release managed subdomains, `"add"` appends, `"remove"` deletes. Current gateway supports at most one `subdomains.set`; multi-set fails locally with `SUBDOMAIN_MULTI_NOT_SUPPORTED`. Complete static site + function + route manifest: ```json { "project_id": "prj_...", "site": { "replace": { "index.html": { "data": "" }, "events.html": { "data": "
`. |
| `ROUTE_TARGET_CARRIED_FORWARD` | Carried-forward route still targets a base-release function. | Inspect `run402 deploy release active` and deploy a replacement route table if needed. |
| `ROUTE_SHADOWS_STATIC_PATH` / `WILDCARD_ROUTE_SHADOWS_STATIC_PATHS` | Dynamic route shadows direct public static content. | Inspect warning details, active routes, `static_public_paths`, and resolve diagnostics; confirm only when intentional. |
| `METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK` | Unmatched methods can serve static content. | Confirm fallback is intended or add method coverage. |
| `WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS` | Wildcard function route only allows `GET`/`HEAD`. | Add mutation methods e.g. `POST`, omit methods for an API prefix, or set `acknowledge_readonly: true` on an intentionally read-only GET/HEAD final-wildcard function route. `--allow-warning WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS` is a reviewed CLI escape hatch; broad `--allow-warnings` is last resort. |
| `ROUTE_TABLE_NEAR_LIMIT` | Route table is near a limit. | Consolidate or remove routes. |
| `ROUTES_NOT_ENABLED` | Routes are disabled for the project/environment. | Deploy without `routes` or request enablement; direct function invoke is not a browser-route substitute. |
| `STATIC_ALIAS_SHADOWS_STATIC_PATH` / `STATIC_ALIAS_RELATIVE_ASSET_RISK` | Route-only static alias conflicts with a direct public static path or has relative-asset risk. | Inspect active routes, `static_public_paths`, and the backing `asset_path`; prefer `site.public_paths` for ordinary clean URLs and confirm only when intentional. |
| `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` / `STATIC_ALIAS_EXTENSIONLESS_NON_HTML` | Route-only static alias may duplicate another direct public path or expose extensionless non-HTML. | Use one canonical public path per page and reserve exact static route targets for method-aware aliases. |
| `STATIC_ALIAS_TABLE_NEAR_LIMIT` | Static route targets are near route-table limits. | Avoid one-static-route-target-per-page tables; consolidate. |
Runtime route failure codes: `ROUTE_MANIFEST_LOAD_FAILED` (manifest/propagation), `ROUTED_INVOKE_WORKER_SECRET_MISSING` (custom-domain Worker secret), `ROUTED_INVOKE_AUTH_FAILED` (internal invoke signature), `ROUTED_ROUTE_STALE` (release revalidation failed), `ROUTE_METHOD_NOT_ALLOWED`, `ROUTED_RESPONSE_TOO_LARGE` (>6 MiB).
**Routed functions: locale awareness.** `spec.i18n` negotiates locale per routed-function request and exposes `x-run402-locale` / `x-run402-default-locale` headers (omitted when active release lacks `i18n`). Carry-forward rules are simpler than routes; no `{ replace }` envelope:
```json
{
"i18n": {
"default_locale": "en",
"locales": ["en", "es", "fr", "zh-Hant"],
"detect": ["cookie:wl_locale", "accept-language"]
}
}
```
- Omit `i18n` to carry forward from the base release; pass `"i18n": null` to clear the slice on the new release; pass `{ default_locale, locales, detect? }` to replace.
- `default_locale` must byte-match one `locales[]` entry; no silent canonicalization; CLI/SDK validate before planning.
- Locale tags must match `/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/` and RFC 5646 canonical casing: primary lowercase, script Titlecase, 2-alpha region uppercase, 3-digit region preserved, variants/extensions lowercase. Examples: `pt-BR`, `zh-Hant`, `zh-Hant-TW`, `de-1996`. Non-canonical deploy error: `R402_LOCALE_NOT_CANONICAL` (400) with `fix: { input, canonical }`. `locales[]` non-empty, max 50. No silent canonicalization because DB translation keys often use literal locale strings.
- Negotiation returns canonical casing from `locales[]`, NOT the request's casing.
- `detect[]` default `["accept-language"]`, max 10, `[]` = always default; first match wins. Sources: `"accept-language"` (RFC 9110 + RFC 4647 lookup truncation `zh-Hant-TW` -> `zh-Hant` -> `zh`; generic request tag does not match more specific configured tag, e.g. `es` not `es-MX`) and `"cookie:"` (RFC 6265 name regex `/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/`, raw value matched case-insensitively).
- Static-route hits do NOT receive locale negotiation; only routed HTTP function invocations do.
- Run402 does NOT inject `Vary` headers — apps that return public-cacheable responses varying by locale must set their own `Vary` until per-locale edge caching ships.
Routed-function read pattern: single-arg `(req)`, not `(req, ctx)`; bundled runtime translates envelope to Web `Request`, so `context.locale` is not visible.
```ts
export default async (req) => {
const locale = req.headers.get('x-run402-locale');
const defaultLocale = req.headers.get('x-run402-default-locale');
if (locale && locale !== defaultLocale) {
return renderWithTranslations({ locale });
}
return renderBase({ locale: defaultLocale ?? 'en' });
};
```
Language switchers must write a cookie; `localStorage` only is invisible to server-side negotiation. Mirror locale to cookie and declare cookie source in `spec.i18n.detect`:
```js
function setLanguage(lang) {
localStorage.setItem('wl_locale', lang);
document.cookie =
`wl_locale=${encodeURIComponent(lang)}; path=/; max-age=31536000; samesite=lax`;
}
```
Deploy with `"detect": ["cookie:wl_locale", "accept-language"]`.
Migration registry: key = `(id, checksum)`. There are two authoring kinds. Versioned migrations use `id`: same id+SQL = noop; same id+different SQL = `MIGRATION_CHECKSUM_MISMATCH`; if you revise one, ship a new id. Content-tracked migrations use `name`: the SDK compiles `_`, so changed generated SQL applies once under a new id and unchanged re-ups noop. SQL declared with `name` MUST be idempotent (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE`, upserts, `ADD COLUMN IF NOT EXISTS` in a `DO` block) because changed content re-runs against a database where prior versions may already exist. If generated SQL is trapped behind a static id mismatch, replace `"id": "seed"` with `"name": "seed"` as the primary recovery path; admin checksum adoption is only for legacy/out-of-band cases.
---
### GitHub Actions OIDC Deploys
Use this when the same repo should deploy itself from GitHub Actions without storing Run402 service keys, allowance files, or API keys in GitHub secrets. KISS rule: link once locally, then CI runs the same `run402 deploy apply` command agents already know.
Local setup:
```bash
run402 ci link github --project prj_... --manifest run402.deploy.json
run402 ci link github --project prj_... --manifest run402.deploy.json --route-scope /admin --route-scope /api/*
```
Full link syntax:
```bash
run402 ci link github \
[--project ] \
[--manifest ] \
[--repo ] \
[--branch | --environment ] \
[--repository-id ] \
[--workflow ] \
[--expires-at ] \
[--route-scope ...] \
[--force]
```
Defaults:
- `--project`: active project
- `--manifest`: `run402.deploy.json`
- `--repo`: inferred from `git remote get-url origin`
- `--branch`: current branch from `git branch --show-current`
- `--workflow`: `.github/workflows/run402-deploy.yml`
- `--route-scope`: omitted by default, which means no CI route-declaration authority; repeat for exact paths like `/admin` or final wildcard prefixes like `/api/*`
- allowed events: fixed to `push` and `workflow_dispatch`
- allowed action: fixed to `deploy`
The command fetches GitHub's numeric repository id using `GITHUB_TOKEN` or `GH_TOKEN` when available. If lookup fails, pass `--repository-id ` explicitly. The subject is generated from `--branch` as `repo::ref:refs/heads/`, or from `--environment` as `repo::environment:`.
Generated workflow shape:
```yaml
name: Run402 Deploy
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to run402
run: npx --yes run402@3.7.5 deploy apply --manifest 'run402.deploy.json' --project 'prj_...' < /dev/null
```
Output on success:
```json
{
"binding_id": "cib_...",
"project_id": "prj_...",
"provider": "github-actions",
"subject_match": "repo:owner/name:ref:refs/heads/main",
"allowed_events": ["push", "workflow_dispatch"],
"allowed_actions": ["deploy"],
"route_scopes": ["/admin", "/api/*"],
"github_repository_id": "123456789",
"github_repository_id_status": "verified",
"workflow_path": ".github/workflows/run402-deploy.yml",
"manifest_path": "run402.deploy.json",
"run402_version": "3.7.5",
"delegation_chain_id": "eip155:84532",
"bootstrap_caveat": "Commit the generated workflow and manifest before expecting GitHub Actions deploys.",
"consent_summary": ["..."],
"revocation_residuals": ["..."]
}
```
Management:
```bash
run402 ci list [--project ]
run402 ci revoke
```
`list` prints `{ "project_id": "...", "bindings": [...] }`.
`revoke` prints `{ "binding": {...}, "revoked": true, "revocation_residuals": [...] }`.
Intentional omissions in v1: no raw `--subject`, no wildcard flag, no `--allow-event`, no PR deploy flags, and no `--no-repository-id`. Use `--branch` or `--environment`; create a follow-up design before broadening trust.
CI deploy restrictions: when `run402 deploy apply` runs inside GitHub Actions with OIDC env vars present, it uses the GitHub subject token, exchanges it for a Run402 CI session, and skips the local allowance preflight. CI manifests may include only `project_id`, `database`, `functions`, `site`, absent/current `base`, and route declarations covered by the binding's `route_scopes`. Without `--route-scope`, CI cannot ship `routes`. CI cannot ship `secrets`, `subdomains`, `checks`, unknown future top-level fields, non-current base, or oversized manifests that require `manifest_ref`.
Common CI error codes:
- `invalid_token`: check `permissions: id-token: write` and the workflow's OIDC environment
- `access_denied`: no active binding matched this repo/branch/environment
- `binding_revoked`: a matching binding existed but was revoked (most often the project was transferred/handed off, which suspends the prior org's CI bindings) — re-run `run402 ci link github` to re-create it; do NOT run `set-asset-scopes` (it 409s on a revoked binding)
- `event_not_allowed`: v1 allows only `push` and `workflow_dispatch`
- `repository_id_mismatch`: re-link from the current repo or pass the correct numeric `--repository-id`
- `forbidden_spec_field` / `forbidden_plan`: remove disallowed CI manifest fields or run the deploy locally
- `CI_ROUTE_SCOPE_DENIED`: re-link with covering `--route-scope` patterns e.g. `/admin` or `/api/*`, or run the route-changing deploy locally
- `payment_required`: renew/upgrade/fund the project tier outside CI, then rerun the workflow
---
### Unified Deploy Details
Use `run402 deploy apply --manifest app.json` for full-stack releases; see the Unified Apply example above. Use `project_id`, `--project`, `RUN402_PROJECT_ID`, or an app-local `.run402/project.json` link. Global active state is never a deployment selector; conflicting selectors fail before mutations. Omitted top-level sections carry forward. Strict adapter: only top-level `$schema` ignored; typo/no-op fields fail before planning.
Function specs add auth gates:
- `require_auth: true`: valid project user JWT required; 401 on anonymous; no DB lookup; independent from `require_role`.
- `require_role: { table, id_column, role_column, allowed[], cache_ttl? } | null`: implies auth; gateway reads project-schema table with RLS bypass; 403 if role not in `allowed`; `null` removes gate in patch mode; `cache_ttl` default 60, max 600, 0 disables cache.
- Passing gate injects `x-run402-user-id` (any gate) and `x-run402-user-role` (`require_role`) into request; read headers directly or use `auth.*`.
- Validation: all `require_role` blocks in one release share `(table,id_column,role_column)`; schema-qualified identifiers rejected; `0 <= cache_ttl <= 600`; empty `allowed` rejected; missing table/column fails activation with `DEPLOY_INVALID_ROLE_GATE` (422) before live flip.
Auth-gate fragment:
```json
{
"functions": {
"patch": {
"set": {
"list-my-items": {
"source": { "path": "functions/list.ts" },
"require_auth": true
},
"delete-content": {
"source": { "path": "functions/delete.ts" },
"require_role": {
"table": "members",
"id_column": "user_id",
"role_column": "role",
"allowed": ["admin"],
"cache_ttl": 60
}
},
"moderate-content": {
"source": { "path": "functions/moderate.ts" },
"require_role": {
"table": "members",
"id_column": "user_id",
"role_column": "role",
"allowed": ["admin", "moderator"]
}
}
}
}
}
}
```
Role reads (`@run402/functions` 3.4.0+, `{ from }` needs 3.5.0+): edge gate authenticates Bearer JWT and cookie-session SSR browsers (`ssr-aware-role-gate`). Choose by topology:
- Dedicated function/route: prefer deploy-spec `require_role`; per-function, pre-dispatch, TTL-cached. `await auth.requireRole("operator")` returns `{ user, role }`; throws distinct `RoleGateNotConfiguredError` (500) vs `InsufficientRoleError` (403). Multi-role: `await auth.role()` and branch. Browser console can set gate `on_deny: "redirect"` + same-origin `sign_in_path` for anonymous HTML 303 to sign-in; authenticated wrong-role remains 403 JSON.
- Catch-all SSR/finer per-path control: use in-function `{ from }` guard; edge gate would also gate public catch-all/404 and `/admin/login`.
```ts
const { user } = await auth.requireRole("operator", { from: { table: "staff", idColumn: "user_id", roleColumn: "role" } });
// or, for an .astro page (a throw in frontmatter renders a 500, not a redirect) use the non-throwing read:
const role = await auth.role({ from: { table: "staff", idColumn: "user_id", roleColumn: "role" } });
if (role !== "operator") return Astro.redirect("/admin/login", 303);
```
`run402 auth scaffold-roles --roles operator` emits conventional `app_roles(user_id uuid, role text)` migration, matching `requireRole` snippet, and service-role `INSERT` for FIRST role (table starts empty; first grant bypasses RLS). Gate keys on tenant user id (`internal.users.id` / JWT `sub`), not wallet. Applies to routed and direct (`POST /functions/v1/:name` with API key + user JWT); direct still requires API key before gate.
Binary files (images, fonts, PDFs): Set `"encoding": "base64"` and provide base64-encoded data. MIME types are auto-detected from the file extension (`.png` → `image/png`, `.woff2` → `font/woff2`, etc.). Text files use `"encoding": "utf-8"` (the default — can be omitted).
Assets slice: top-level `ReleaseSpec.assets` stages content-addressed asset entries with the release. Activation changes the active release pointer; applied migrations and external side effects are not undone.
```json
"assets": {
"put": [
{ "key": "static/app.css", "sha256": "<64-hex>", "size_bytes": 1234, "content_type": "text/css", "visibility": "public", "immutable": true }
]
}
```
Additive batch: locally computed `sha256`; gateway dedupes CAS; only new shas upload through same S3 presign flow as `assets put`. Defaults `visibility: "public"`, `immutable: true`; other keys untouched.
```json
"assets": {
"put": [...],
"sync": {
"prefix": "static/",
"prune": true,
"confirm": { "base_revision": "", "delete_set_digest": "", "expected_delete_count": 42 }
}
}
```
Declarative sync: `prune: true` deletes keys under explicit `prefix` absent from new `put`; no implicit project-root prune. First apply without `confirm` returns `asset_sync` (`base_revision`, `delete_set_digest`, `expected_delete_count`, `sample_keys`); re-run with `confirm`. Activation rechecks and fails `ASSET_SYNC_DRIFT` if inventory mutates between commit/activation. No `run402 assets sync`; use manifest + `deploy apply` or SDK helpers (`uploadDir`, `syncDir`, `prepareDir`, `putMany`).
Verify block (authoring-only): deploy manifests accept a top-level `verify` with post-apply HTTP checks — the same `verify.http[]` shape app manifests use. It is stripped before the wire `ReleaseSpec` (like `$schema`); `run402 up` runs the checks after a successful apply (propagation-tolerant, results in `result.verification.http[]` + a `result.verify` rollup) and `run402 up verify` reruns them on demand. Verification-only output reports `mode: "verify"`, `read_only: true`, and `dry_run: false` because it performs real probes without applying a deployment. Each check: `id` (unique, required), `path` (resolved against the project public origin) or `url`, `expect: { status }` (snake alias `expected_status`), optional `retries`. Each executed HTTP check includes `observed_release` with nullable `release_id` and `generation`, response `url`, `observed_at`, `source: "response_headers"`, and `unavailable_reason`. These are observations from that response, not proof that all routes agree or that a release stayed unchanged throughout the run. Missing or malformed headers remain unknown and do not fail an otherwise successful HTTP check.
```json
"verify": {
"http": [
{ "id": "home", "path": "/", "expect": { "status": 200 } },
{ "id": "api", "path": "/v1/health", "expected_status": 204 }
]
}
```
Migrations: inline `sql` or per-entry `sql_path` / `sql_file`. Make re-runnable: `CREATE TABLE/INDEX IF NOT EXISTS`; new columns need `ALTER TABLE ... ADD COLUMN` in an idempotent `DO` block:
```sql
CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL);
DO $$ BEGIN
ALTER TABLE items ADD COLUMN priority int DEFAULT 0;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
```
Authorization manifest (`https://run402.com/schemas/manifest.v1.json`): new tables are dark until declared with `expose: true`. Prefer `database.expose` or a `manifest.json` in bundle `files[]`; platform validates against migration SQL, applies it, and strips `manifest.json` before site deploy so it is never public. Success includes `manifest_applied: true`; missing migration table -> HTTP 400 with structured `errors[]`. Dry feedback: `run402 projects validate-expose [project_id] --file manifest.json [--migration-file setup.sql]`; validates auth/expose manifest only, does not execute SQL, exits 0 even with `has_errors: true`.
Built-in table policies:
- `user_owns_rows` — owner column matches `auth.uid()`; requires `owner_column`. With `force_owner_on_insert: true`, per-table trigger fills owner only when `NEW.` is `NULL`; explicit different owners still fail `WITH CHECK`. `service_key` bypasses RLS but trigger still runs; admin writes should set owner if no JWT. Best for user-scoped data. `uuid` owner columns get index-friendly policies; other types use `::text` cast with warning; btree index auto-created.
- `public_read_authenticated_write` — anyone reads; any authenticated user can INSERT/UPDATE/DELETE any row (not just their own). For collaborative content (shared boards, announcements).
- `public_read_write_UNRESTRICTED` — ⚠ fully open; `anon_key` can read AND write any row. For intentionally public tables only (guestbooks, waitlists, feedback forms). Requires `"i_understand_this_is_unrestricted": true` on the table entry.
- `custom` — escape hatch. Provide `custom_sql` containing `CREATE POLICY` statements; they run inside the apply transaction after RLS is enabled + forced.
| Policy | anon SELECT | anon writes | auth SELECT | auth writes |
|---|:---:|:---:|:---:|:---:|
| (omitted from manifest) | — | — | — | — |
| `user_owns_rows` | — | — | own rows | own rows |
| `public_read_authenticated_write` | all | — | all | all rows |
| `public_read_write_UNRESTRICTED` | all | yes | all | yes |
`—` = denied. `service_key` bypasses all policies. Views are always created with `security_invoker=true` — they inherit the underlying table's RLS. RPCs require an entry in `rpcs[*]` with `grant_to` to be callable as `/rest/v1/rpc/` — `CREATE FUNCTION` revokes PUBLIC EXECUTE automatically.
Worked example covering all three slices (drop in as `manifest.json` or under `database.expose` in a deploy manifest):
```json
{
"$schema": "https://run402.com/schemas/manifest.v1.json",
"version": "1",
"tables": [
{ "name": "posts", "expose": true, "policy": "public_read_authenticated_write" },
{ "name": "notes", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true }
],
"views": [
{ "name": "posts_public", "base": "posts", "select": ["id", "title", "published_at"], "expose": true }
],
"rpcs": [
{ "name": "increment_counter", "signature": "(counter_name text)", "grant_to": ["authenticated"] },
{ "name": "now_utc", "signature": "()", "grant_to": ["anon", "authenticated"] }
]
}
```
`rpcs[*].signature`: parenthesized args (`"()"` for none), regex `^\([^;]*\)$`; `grant_to` non-empty roles (`anon`, `authenticated`, `service_role`, `project_admin`). Function must exist in migration SQL; manifest grants EXECUTE only. Views require `base` + non-empty `select`, are `security_invoker=true`, inherit base RLS. Ad-hoc: `projects apply-expose --file manifest.json`; inspect with `projects get-expose ` (`source: "applied" | "introspected"`). Manifest is convergent; removed items revoke policies/grants/triggers/views, so include full desired exposed surface each apply.
Deploy:
```bash
run402 deploy apply --manifest app.json
```
Deploy stages migrations, `database.expose`, functions/site/assets, subdomains and routes before activation. Inspect each stage and any resumable operation; activation is not a transaction that rolls back every prior effect. Set secret values first with `run402 secrets set`; deploy manifests only declare value-free `secrets.require` / `secrets.delete`.
### Step-by-Step Deploy
If you want more control:
```bash
# 1. Provision a database
run402 projects provision --name my-app
# 2. Create tables
run402 projects sql "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)"
# 3. Insert seed data
run402 projects sql "INSERT INTO items (title) VALUES ('Buy groceries'), ('Read a book')"
# 4. Declare authorization. Write manifest.json first:
# {"version":"1","tables":[{"name":"items","expose":true,"policy":"public_read_authenticated_write"}]}
run402 projects validate-expose --file manifest.json
run402 projects apply-expose --file manifest.json
# 5. Deploy a static site (uses active project automatically)
run402 sites deploy --manifest site.json
# 6. Claim a subdomain (uses active project + its live release automatically)
run402 subdomains claim my-app
```
---
### Interpreting deploy evidence
`up --verify` replaces the current `deploy.edge` summary with the latest probe result and retains the earlier summary in `activation_snapshot`, whose timestamp can be null for an older gateway. Reports disclose path sampling, vantage, check time, and `verification_basis`: release identity, content hash, or inconclusive weak metadata. Identity checks may legitimately have `observed_sha256: null`; a hash is never inferred from HTTP 200. `no_mutable_paths` is explicit.
A successful first vault push reports `repo.first_push.snapshot` (ID, kind, backup status) separately from `repo.local_git` (branch, HEAD, unborn, dirty). A synthetic backup does not advance your branch or stage files. An unborn branch with untracked files after backup is expected.
Function HTTP 400/429 responses can carry `x-cache: Error from cloudfront`. Judge them against the application’s expected status and body; preserve `Retry-After` for rate limits. That header alone does not establish edge failure.
Source-resolution steps report `source_available: true` after resolving an existing directory. `git_state` distinguishes `has_commit`, `unborn`, `not_repository`, and `unavailable`. A null `commit` does not mean the source is missing; an unborn repository has local files but no local commit. Resolution does not create a commit.
## Deployment output
`run402 up` returns JSON with the destination, release, verification and backup outcomes, warnings and next actions. Repetitive progress is opt-in with `--json-stream`. Applied runs can include a local `result_ref` pointing to redacted detail in `.run402/diagnostics/`; read the returned file to inspect that same execution. Diagnostics are private, bounded and excluded from source snapshots. Check, plan-only and read-only output does not create diagnostic files. MCP uses its existing `expand_result` handle instead of a local path. SDK scripts retain the full typed result.
An initial connection failure before payment dispatch is classified as `network`, preserving `X402_INITIAL_REQUEST_FAILED` and safe retry details. A failure after possible payment dispatch still requires reconciliation.
An explicitly declared append-only policy on a new table deploys without a second acknowledgment. Public-write widening on existing tables, changed custom policies and legacy custom-policy convergence still require review; the CLI reports the precise `--allow-warning` flag. An unchanged compiler-applied custom policy does not prompt again. Default results contain one verification summary, release URLs and backup outcome; repeated inventories remain in `result_ref`.