RELEASE NOTES · 2.0

Run402 2.0

One apply call. One atomic commit. Full stack.

Published 2026-05-18 · Gateway live at api.run402.com

Run402 2.0 collapses every write to a hosted application onto a single primitive:

await r.project(id).apply({
  database:   { migrations: [...], rls: {...} },
  secrets:    { OPENAI_API_KEY: "..." },
  functions:  { handlers: [...] },
  site:       { dir: "./dist" },
  assets:     { put: { "logo.svg": "...", "banner.png": "..." } },
  subdomains: { primary: "demo" },
  routes:     [...],
  checks:     [...],
});

That call provisions schema, sets row-level security, deploys serverless handlers, ships a static site, uploads CDN assets, claims a subdomain, and points custom routes as one transaction. If anything fails, nothing flips. If the same spec is re-submitted, the platform recognizes the manifest_digest and returns the prior result. There is no orchestration script around this. It is the API.

The rest of this post is what makes that primitive work, and why building on it is structurally different from gluing a frontend host to a managed database.

/apply/v1/plans manifest_digest content-addressed atomic activation x402 native

The plan/commit protocol

apply(spec) is not a single round-trip. It is a two-phase protocol the SDK runs for you:

Plan-only is a first-class entry point. The SDK exposes it directly:

const plan = await r.project(id).apply.plan({
  site:   { dir: "./dist" },
  assets: { put: dir("./public") },
});

// Structured cost preview, before you commit a byte.
console.log(plan.cost);
// {
//   storage_bytes_added:    1_842_113,
//   storage_bytes_freed:      512_000,
//   storage_bytes_net:      1_330_113,
//   asset_keys_added:              17,
//   asset_keys_removed:             3,
//   billable_delta_usd_micros:  4_200,
//   payment_required:           false,
//   billing_subject:            "account:0xabc...",
//   quota_before: { storage_bytes:  84_220_113, asset_keys: 142 },
//   quota_after:  { storage_bytes:  85_550_226, asset_keys: 156 }
// }

The plan response classifies issues into three severities:

This kills a class of footguns where "warnings" get silently bypassed by an allowWarnings: true flag. Destructive operations require a hash-bound token; warnings are free.

The whole protocol is idempotent. Re-submitting the same spec returns the prior operation:

// First call: runs the deploy.
const a = await r.project(id).apply(spec);

// Second call with the same spec: short-circuits on manifest_digest.
// Returns the prior ApplyResult. No re-upload. No re-migrate. No re-bill.
const b = await r.project(id).apply(spec);

console.log(a.operation_id === b.operation_id); // true

The same code path covers "deploy" and "asset upload" and "schema migration" and "site rewrite" because they are all just slices of one ApplySpec.

The atomic activation transaction

The hard part of full-stack deploy is not provisioning. It is the flip.

Run402 2.0 stages every kind of write into a typed staging table — staged_function_versions, staged_asset_writes, staged_asset_prune, staged_secret_sets, staged_deployments — all keyed to the same operation_id. The operation walks the state machine, applies migrations under a short-lived migrate-gate that returns 503 on /rest/v1/* for the window during which the schema is mid-flight (median sub-second; bounded to about 60s). After a canary SELECT 1 FROM information_schema.tables WHERE table_schema = $1 confirms PostgREST has reloaded, the platform runs a single final transaction:

BEGIN;
  -- promote staged function versions
  -- bulk-insert staged asset writes into internal.blobs + internal.asset_versions
  -- materialize staged deployments
  -- apply staged_asset_prune deletions
  UPDATE internal.projects
     SET live_release_id   = $target,
         migrate_gate_until = NULL
   WHERE id = $project_id;
COMMIT;

At the instant that row updates, the new schema is queryable, new functions are callable at their URLs, new site files are served by the CDN, new assets resolve to their content-addressed CDN URLs, and the subdomain points at the new release. If the COMMIT fails, the gate clears and nothing flipped.

Two consequences. First: there is no inconsistency window where the site references an asset URL whose SHA has not yet promoted. Second: a half-applied deploy is not a state the system can sit in. It either ran or it did not.

A real-world cross-slice deploy looks like this:

await r.project(id).apply({
  database: {
    migrations: [
      {
        id: "2026_05_18_add_org_id",
        sql: `ALTER TABLE app.posts ADD COLUMN org_id uuid NOT NULL DEFAULT gen_random_uuid();
              CREATE INDEX posts_org_id_idx ON app.posts(org_id);`,
      },
    ],
    rls: {
      "app.posts": {
        select: "org_id = auth.jwt() ->> 'org_id'",
        insert: "org_id = auth.jwt() ->> 'org_id'",
      },
    },
  },
  secrets: { STRIPE_SECRET_KEY: "sk_live_..." },
  functions: {
    handlers: [{ name: "checkout", file: "./functions/checkout.ts" }],
  },
  site:   { dir: "./dist" },
  assets: { put: dir("./public") },
});

The migration runs. RLS gets rewritten. The new secret is wired. The function is deployed. The site rebuild lands. The asset bundle uploads with byte-level dedup against what is already in CAS. They all become visible at the same instant.

The auto-resume worker (runActivationResume, hourly) picks up operations stuck in schema_settling or activation_pending with last_activate_attempt_at older than 5 minutes, FOR UPDATE SKIP LOCKED, and retries up to 10 times before marking the operation failed and clearing the gate. The deploy survives a gateway task restart mid-commit.

Content-addressed everything

Bytes are SHA-256-keyed in a single content-addressed substrate (internal.content_objects, S3 at _cas/{sha[0:2]}/{rest}). Site files, function source, asset uploads, migration SQL, release manifests — same store, same dedup, same garbage collector.

What that buys you:

The pre-commit determinism gives you a flow that is not possible without it: compute CDN URLs (with SRI) before the bytes are even committed, inject them into the HTML, then commit the HTML and the assets atomically.

// 1. Plan the asset directory. Returns deterministic AssetRefs.
const { manifest, applySlice } =
  await r.project(id).assets.prepareDir("./public", { prefix: "static/" });

// 2. The manifest carries every asset's final CDN URL + SRI hash,
//    BEFORE anything has been promoted to live.
const bundle = manifest.byKey["static/app.js"];
// {
//   key:               "static/app.js",
//   sha256:            "3f8c1e...",
//   cdn_immutable_url: "https://demo.run402.com/_blob/static/app.js?v=3f8c1e",
//   sri:               "sha384-Ee9...",
//   etag:              "\"3f8c1e\"",
//   content_digest:    "sha-256=:P4we...",
//   canHtml:           true,
//   html: (opts?) => string | null,
// }

// 3. Render HTML that references those URLs + SRI tags directly.
const html = `<!doctype html>
<html>
  <head>
    ${manifest.byKey["static/app.css"].html()}
    ${manifest.byKey["static/app.js"].html({ defer: true })}
  </head>
  <body><div id="root"></div></body>
</html>`;

// 4. Atomic commit: HTML, JS, CSS, and every asset flip live together.
await r.project(id).apply({
  site:   { put: { "index.html": html } },
  assets: applySlice,
});

The output: <script src="..." integrity="sha384-..." crossorigin="anonymous"></script> is rendered with the SRI hash of the exact bytes that will be served, and the HTML referencing those bytes flips visible at the same instant the bytes do. There is no window where a browser fetches the HTML, follows the script URL, and gets a 404 because the asset has not promoted yet.

Drift detection that actually works

assets.syncDir(dir, { prune: true }) is the only way to delete asset keys you did not explicitly list. The platform refuses to make prune implicit because filesystem state is not a safe source for unconditional deletions on a hosted system. With prune: true, the plan computes:

At commit time the platform re-reads the inventory under the prefix and recomputes both digests. If base_revision mismatches — meaning someone else changed the inventory between plan and commit — the commit returns HTTP 409 ASSET_SYNC_DRIFT. The SDK exposes this so you can handle it:

try {
  await r.project(id).assets.syncDir("./public", {
    prefix: "static/",
    prune:  true,
  });
} catch (e) {
  if (e.code === "ASSET_SYNC_DRIFT") {
    // Inventory under "static/" changed since the plan was issued.
    // Re-plan and re-confirm the destructive set. The SDK does the retry
    // for you when you pass autoRetryDrift: true.
    await r.project(id).assets.syncDir("./public", {
      prefix:          "static/",
      prune:           true,
      autoRetryDrift:  true,
      onDestructive: (planned) => {
        console.log("Will delete:", planned.keys_to_remove);
        return "confirm"; // or "abort"
      },
    });
  } else throw e;
}

Prefix matching is an indexed key-range predicate (key >= $prefix AND key < $prefix_upper_bound), not LIKE $prefix || '%'. Literal % and _ in keys are not a footgun, and the prefix static does not match static-old.

The same retry pattern covers the release slice. If two engineers commit conflicting deploys, the second gets BASE_RELEASE_CONFLICT with the diverged release id, and the SDK can auto-replan against the new base.

Asset batches, in memory or from disk

The assets namespace has one shape for everything, isomorphic across Node and browser:

// In-memory batch (browser-safe). Returns AssetManifest.
const m1 = await r.project(id).assets.putMany([
  { key: "avatars/alice.png", source: aliceBytes,  contentType: "image/png" },
  { key: "avatars/bob.png",   source: bobBytes,    contentType: "image/png" },
  { key: "reports/q1.pdf",    source: pdfBytes,    visibility:  "private" },
]);

// Iterate by manifest order:
for (const ref of m1.list) {
  console.log(ref.key, ref.sha256, ref.cdn_immutable_url ?? "(private)");
}

// Persist the manifest for downstream tools:
await m1.write("./asset-manifest.json");

// Node directory walk, additive:
await r.project(id).assets.uploadDir("./public", { prefix: "static/" });

// Single asset (sugar over a 1-item apply):
await r.project(id).assets.put("hero.jpg", heroBytes, { immutable: true });

// Private asset, signed URL on demand:
const { url } = await r.project(id).assets.sign("reports/q1.pdf", {
  expiresIn: "15m",
});

The AssetManifest result is JSON-serializable. Its byKey and manifest records are Object.create(null)-constructed, so attacker-controlled keys cannot collide with __proto__ or constructor. The manifest is what you commit into git alongside your code if you want to render against a frozen asset version.

Private assets behave consistently across the API surface: their cdn_url, cdn_immutable_url, and url are null in plan and commit responses, AssetRef.html() returns null, and sign() is the only way to mint a public URL. There is no out-of-band path that leaks a private byte stream.

Push-to-deploy without long-lived secrets

The same /apply/v1/* endpoints accept GitHub Actions OIDC tokens via RFC 8693 token exchange. A wallet-signed delegation links a GitHub OIDC subject pattern to a project; CI workflows exchange their OIDC JWT for a 15-minute Run402 session JWT and use it on the apply endpoints. The wallet stays on the developer's laptop. Nothing long-lived lives in GitHub Secrets.

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
permissions:
  id-token: write   # lets GH issue the OIDC token
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: run402/deploy-action@v1
        with:
          project: ${{ vars.RUN402_PROJECT_ID }}
          binding: ${{ vars.RUN402_BINDING_ID }}
          spec:    ./run402.config.json

The action exchanges the OIDC JWT for a session JWT scoped to one binding, one audience (https://api.run402.com), one action (deploy), and submits the inline spec to /apply/v1/plans. CI-submitted ApplySpecs are restricted by property presence: secrets, subdomains, routes, and checks are forbidden top-level fields; asset keys are validated against the binding's asset_key_scopes. Violations are hard 4xx errors with explicit codes (CI_RELEASE_SLICE_FORBIDDEN, CI_ASSET_SCOPE_DENIED). The session expiry is the smaller of the OIDC TTL and the binding's expires_at_unix.

Universal HTTP payment

Run402 does not have signup. It has billing.

Every priced action — provision a project, subscribe to a tier, top up a wallet — speaks x402, the HTTP 402 Payment Required standard. The client makes a request, the server returns 402 with a payment quote, the client signs USDC on Base and re-submits, the server settles. Same handshake for every paid surface.

// Project provisioning, in one paid call.
const { project } = await r.tiers.subscribe("hobby", {
  durationDays: 30,
});

// The same client can now apply against it. No console step in between.
await r.project(project.id).apply(spec);

Tier limits are pooled per organization (internal.organizations.tier), not per project. One account can hold multiple wallets via POST /orgs/v1/:org_id/wallets; storage, API calls, function counts, secret counts, and daily email sends sum across every non-terminal project the account owns. A multi-wallet user sees one combined budget. Per-instance bounds (function memory, timeout, schedule interval) stay per-row. The pool boundary is in the SQL: the trigger that owns storage_bytes computes the organization-pooled SUM inside the write transaction and rejects writes that would overflow.

Budgets are hard-capped. There are no overages, no surprise bills, no "we will charge your card for the spike." Hit the limit, get 402. Renew, get capacity back.

How this stacks up

There is a category of frontend-first platforms that excel at static hosting plus serverless functions on a global edge. The story there is: write your app, push to git, get a URL. The database is your problem. Auth is your problem. Storage is your problem. The "atomic deploy" is the static bundle plus the functions; everything else lives in a different vendor's dashboard with a different identity model, a different billing line, a different rollback story.

The day you need a migration and a frontend change to ship together, you orchestrate two systems and hope nothing references the new column before the migration lands. The day a deploy half-fails, you have a frontend pointing at a backend it does not match.

There is a category of managed-Postgres-with-auth-and-storage backends that solve the other half. The story there is: get a Postgres URL, auto-generated REST and realtime APIs, file storage, an auth service. The database, RLS, and storage are coherent. The hosting of your actual application — frontend, edge functions, CDN, custom domains — is somebody else's problem. You wire your frontend host to call this backend's URLs, and again, every deploy is two systems with two rollback stories and one consistency window in the middle.

Run402 2.0 does not pick a half. The same apply(spec) call that ships the React build also runs the schema migration, rewrites RLS, uploads the assets the new HTML references, swaps the secret the new function needs, and points the subdomain — atomically.

The platform's storage substrate, deploy substrate, asset substrate, and release-history substrate are the same substrate. The platform's billing model is the same model for every priced action. The platform's identity model — wallet-signed delegations, optionally exchanged into a 15-minute CI session via OIDC token exchange — is the same model for every write.

What you get is composition: a single commit boundary that spans the whole stack, idempotent on a content hash, with structured cost preview, drift detection, and atomic activation. The agent that builds the app does not need to learn two control planes, reconcile two organizations, or coordinate a four-step release across two vendors. It calls r.project(id).apply(spec). The result is the new live state, or the prior state.

The architectural bet

The bet underneath 2.0 is that the unit of deployment for an AI-built application is not "the frontend" or "the database" or "the function." It is the release — the atomic bundle that the application as a coherent whole depends on. Treating that release as a content-addressed manifest with a single commit transaction is the difference between "ten ergonomic primitives that do not quite compose" and "one primitive that always does."

Everything else in 2.0 — the CAS substrate, the migrate-gate, the auto-resume worker, the prepareDir flow, the asset-version retention table, the sync-prune drift detection, the per-unique-hash storage accounting, the OIDC token exchange, the soft-delete grace state machine that keeps the data plane serving for 104 days while the control plane gates — is consequences of that bet, made consistent.

The hero is the apply call. The rest is what makes it safe to mean it.

Run402 2.0 ships live on api.run402.com. The SDK, CLI, and MCP server are all on the unified apply primitive.

READ THE DOCS  →