Platform updates and new features
Machine-readable: /updates.txt
GET /agent/v1/operator/status now reads the current organization lifecycle model instead of retired project columns, so authenticated run402 doctor checks no longer hit a production 500.apply kind before resetting migration rows, avoiding an applied_migrations foreign-key failure.database.expose now commits cleanly instead of failing with a masked EXPOSE_FAILED "transaction is aborted" error.edge block with public-edge convergence state, durable pointer-update status for managed subdomain KVS, CloudFront invalidation, and custom-domain Worker KV propagation, plus a link to GET /apply/v1/operations/:operation_id/edge-coherence.GET /_run402/release.json with no-store caching, and user static files/routes under /_run402/ are reserved.rehearsal metadata, and POST /apply/v1/plans/:plan_id/rehearse snapshots the source project, creates a contained branch, applies the candidate plan there, runs checks, and returns a report with commit/discard/keep next actions. The source project and plan stay untouched.restore_point before DDL when possible, while large or explicitly skipped cases report snapshot_skipped_reason.RUN402_BRANCH=1.all_mailboxes:true with custom mode after email receive is domain-wide. Every current and future mailbox local part can send from local@your-domain.com without enumerating slugs, while managed addresses remain receive-compatible fallbacks.v1_111 restores ProjectDomain state for projects that already had DKIM-verified, inbound-enabled custom domains before the ProjectDomain consolidation.name for idempotent generated or seed SQL. Clients compile it to a content-derived wire id, so unchanged SQL noops and changed SQL applies as a new migration. Hand-authored schema migrations can keep using immutable id values.MIGRATION_CHECKSUM_MISMATCH responses now list every conflicting migration with checksums and applied_at, then return next actions for switching generated SQL to name, minting a new versioned id, or requesting operator adoption for legacy migrations.POST /auth/v1/test-sessions. Labels derive <label>@<project_id>.test.invalid, and responses include a 2-hour browser-session cookie value, a 2-hour bearer token with is_test:true, app origins, curl examples, and test_mode status./auth/v1/test-users and /auth/v1/test-emails routes let agents list non-secret summaries, clean up watermarked users, and inspect messages captured from auth, platform, or mailbox sends to .test.invalid. Captured test email skips SES and outbound quota counters.actor.isTest:true, x-run402-test-session: 1, and routed invocation log metadata. Normal signup paths reject .test.invalid with R402_AUTH_RESERVED_EMAIL_DOMAIN./projects/v1/:project_id/domains/:domain routes. Responses separate desired, observed, and effective state; include stable checks like email.receive.route; return DNS records with safety metadata and BIND lines; expose authority options; and provide one canonical next_action.needs_repair, and agents get repair/test-receive actions without losing mailbox ids, history, or webhooks.reply_only, allowlist, or open.Run402-Client, such as their surface and package version. The gateway parses it request-locally, never treats it as auth or trust, and does not enable browser custom headers yet. When a configured advisory policy matches a Run402-originated error, the original error remains and the envelope may add details.client_compatibility plus a semantic upgrade_client next action.CLIENT_VERSION_UNSUPPORTED, mutation_state:"none", and safe_to_retry:true, but no broad stale-client block is enabled by default.reply_received mailbox webhook payloads and ReleaseSpec email-trigger function events now include sender_trust with the SES receipt-time spam, virus, SPF, DKIM, DMARC, processing-time, and recipient verdict metadata that Run402 already stores. Signing and audit apps can now persist those SMTP-time attestations directly from the event payload. Existing consumers can ignore the additive field./domains/v1 routes now accept SIWX wallet, control-plane session, or scoped delegate auth with an explicit project_id. Service-key auth remains compatible, but a supplied mismatched project_id now returns PROJECT_CREDENTIAL_PROJECT_MISMATCH.PROJECT_CREDENTIAL_NOT_FOUND with source:"local_cache" when absent.X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and Content-Security-Policy: frame-ancestors 'none'. Run402-owned hosts also receive HSTS with includeSubDomains; customer custom domains keep the non-HSTS baseline so Run402 does not impose an HSTS policy on domains it does not own.GET /apply/v1/operations/:operation_id/events now returns durable server-side events instead of a single synthesized status snapshot. Agents can see commit, stage, gating, migrate, expose, schema_settle, role_gate, activate, and ready transitions with phase details and duration_ms when available. Activation now breaks down further into activate.asset_copy_wait, activate.functions, activate.snapshot, activate.asset_variants, activate.transaction, and activate.side_effects. The route supports limit/cursor pagination and still returns one synthetic event for older operations.run402 up can record per-project/per-app install state through POST /apply/v1/app-installs and read it back with GET /apply/v1/app-installs. The state records status, manifest/graph digests, resource summaries, bindings, and the last deploy operation id, but never secret values.notifications@kysigned5.mail.run402.com, after a previous same-name test project is deleted or archived.notifications and forward-to-sign on every cloned project without global collisions. New managed addresses use local@<project-mail-host>.mail.run402.com, and the project mail host is allocated once so web-subdomain renames do not move email addresses.kysigned3 now gets addresses like notifications@kysigned3.mail.run402.com when that label is available, with deterministic collision fallbacks only when needed.managed_address. The primary address and outbound from_address switch to local@your-domain.com only after the custom sender domain is both DKIM-verified and inbound-enabled; replies to the managed address continue to route to the same mailbox.*.mail.run402.com through the existing SES/S3/Lambda pipeline by resolving project mail host, then local part. The legacy local@mail.run402.com receive path remains for compatibility.functions.replace.<name>.triggers[] now supports type:"email" entries for mailbox reply_received, delivery, bounced, and complained events. Matching events create idempotent durable function runs with the canonical email payload under payload.event, so apps can react to inbound replies and delivery outcomes without polling or external webhook glue.POST /functions/v1/:function_name/runs, inspect/log/cancel/redrive them under /functions/v1/runs/:run_id, and correlate logs by fnrun_... / fnatt_.... The target function receives a normal POST with X-Run402-Trigger: function_run and a typed envelope. The function helper, SDK, CLI, and MCP docs now expose the same surface, and prototype-tier projects can try it with smaller caps.createRequire imports — the Lambda wrapper now aliases its internal CommonJS bridge instead of declaring createRequire at top level, so user code can import createRequire from node:module without failing at bundle load.created_at, next_attempt_at, and delivered_at as ISO-8601 strings, matching the public timestamp contract.run402.deploy.ts with SDK helpers such as defineConfig, dir, file, sqlFile, and nodeFunction. The output normalizes to the same ReleaseSpec used by JSON manifests and deploy apply. JSON data manifests still auto-discover; executable configs are trusted local code and require explicit --manifest run402.deploy.ts.run402 up --manifest run402.deploy.ts --check and --print-spec are local-only. They import/normalize config and validate local files without gateway calls, uploads, project creation, tier changes, or workspace-link writes. --plan asks the gateway for a reviewed, non-deploying plan with plan_id, plan_fingerprint, expiration, diff, warnings, and a single next action.--require-plan <plan_id> sends required_plan before upload and again at commit. If the normalized spec, reviewed fingerprint, planner semantics, concrete base release, project scope, or reviewed-plan lifetime changed, the gateway rejects before release mutation with stable REVIEWED_PLAN_* errors and recovery guidance to re-plan.type — reviewed-plan responses now use the platform-wide next_actions[] shape, for example {"type":"retry"}..r402ar archive for a Cloud project: POST /projects/v1/:project_id/archives, GET /projects/v1/:project_id/archives/:archive_id, and GET /projects/v1/:project_id/archives/:archive_id/download. The export uses a write-pause consistency model and records the pinned release, database/storage/runtime metadata, export report, and portability report.run402 cloud archives create ... --wait --output ./project.r402ar --json, run402 archives inspect, run402 archives verify, and run402 core projects import ... --env-file ./required.env. Public client coordination is tracked in kychee-com/run402#476.AUTH_REQUIRED, INVALID_AUTH, VALIDATION_FAILED, and RATE_LIMITED used to return an empty next_actions: [] on some routes (for example an unauthenticated POST /apply/v1/plans or a bad-address POST /faucet/v1). The error envelope now fills a typed default action: authenticate for the auth codes, edit_request for validation, and retry for rate limits. Routes that already returned their own next step are unchanged. Additive — clients reading next_actions just get a populated array where they previously got [].footer_policy, effective_footer_policy, and footer_policy_locked_reason. PATCH /mailboxes/v1/:mailbox_id accepts {"footer_policy":"run402_transparency"} or {"footer_policy":"none"}; hobby and team projects may set none per mailbox for trust-sensitive signing/login mail, while prototype projects stay locked to the default Run402 transparency footer and return FOOTER_POLICY_TIER_REQUIRED if they try to disable it. Template, raw, attachment, delivered, and stored outbound bodies all use the same effective policy.sent to delivered, bounced, or complained. Hard bounces feed the existing suppression-list and auto-suspend paths, and mailbox webhooks subscribed to delivery, bounced, or complained can receive those events. No mailbox API shape changed.ci_bindings_suspended plus a re-link prompt (run402 ci link github) instead of breaking auto-deploy silently. Token-exchange returns a distinct binding_revoked error so a transferred project’s failing CI run is self-diagnosing. Additive — existing clients are unaffected.POST /orgs/v1 now writes the prototype tier, a fresh 7-day lease, and active lifecycle state to the organization row instead of only returning tier:"prototype". Legacy direct-created null-tier orgs are repaired on boot when they have active owners and no linked wallet rows; wallet cold-start placeholders stay null-tier until a tier purchase. The console now shows tier/lease state on org cards and adds a Billing tab for tier checkout.POST /projects/v1/:project_id/transfers now accepts to_org_id. When your principal owns both the current org and the destination org, the move completes immediately, revokes old grants/delegates, marks inherited secrets for rotation, records an audit-grade transfer row, and returns fresh project keys. It does not merge wallet and human/email identities; owning only the source org still returns 403 FORBIDDEN until the future interactive org-transfer flow ships./orgs/:org_id/projects/:project_id/move page lets you choose another org you own, add an optional audit note, approve with passkey step-up if needed, and copy the returned project keys once the synchronous move completes. Admins still use the email handoff flow; direct org moves are owner-only.default_outbound_mailbox_id for app/function email and auth_sender_mailbox_id for magic-link and invite email. Mailbox list/create responses show default roles, send readiness, and next_actions[]; PATCH /mailboxes/v1/settings repairs missing defaults.email.send() use the configured mailbox or fail with actionable errors such as AMBIGUOUS_MAILBOX, DEFAULT_MAILBOX_REQUIRED, or DEFAULT_MAILBOX_INVALID. Successful sends now report the actual mailbox_id and from_address.since values while continuing to accept legacy epoch milliseconds. Other public since filters reject non-ISO date strings instead of letting JavaScript’s permissive date parser guess. OpenAPI marks absolute timestamps as date-time, matching the SDK rule that public timestamps are strings, not Date objects or numeric epochs.tier, lease_started_at, and lease_expires_at. Direct-created orgs show their prototype lease immediately; null tier/lease fields are reserved for true pre-subscription placeholders.LIMIT 1, rows[0], PriceList[0], rules[0], and recipients[0] patterns so this bug class does not quietly return.source field — an error the gateway returns before your function runs (unparseable JSON, a missing or invalid apikey, a rate-limit) is now stamped "source": "gateway", so a caller of your function can tell a run402-boundary error (resolve against run402.com/errors) from your app's own error (which carries no source). The contract is two-layered: run402 owns transport/parse/auth/rate-limit; your function owns its domain errors. New reference at run402.com/errors/#boundary-errors lists the boundary codes and a recommended (not required) canonical vocabulary for app errors. Additive and backward-compatible./contracts/v1/wallets* routes are now /contracts/v1/signers* and the wallet_id field is now signer_id; the contract call/deploy/read operations keep their names. SDK provisionSigner()/getSigner()/listSigners(), CLI contracts provision-signer/get-signer/list-signers, and MCP provision_signer/get_signer/… replace the old *-wallet names. Pre-launch, so update clients in lockstep. Your SIWX signing wallet (run402 wallets) is unrelated and unchanged.DELETE /jobs/v1/runs, and project deletion uses the same cleanup path.run402 operator login --loopback, and when you provision or deploy, a browser opens for the passkey tap and the approval returns to the CLI over a local loopback. No wallet, no copy-pasting tokens.Org-ID, and unnamed orgs consistently render as “Unnamed org.”/orgs/:org_id/new-project route instead of native prompt() / alert() dialogs. The page has a recoverable waiting state, a stop-waiting button, and a copyable URL for retrying in Chrome or Safari when an embedded passkey popup gets stuck. The key screen includes copy-all, per-key copy buttons, and a ready-to-paste .env block.window.close() failures, lets Cancel abort pending WebAuthn where supported, and points the user to Chrome or Safari instead of waiting forever.GET /projects/v1/:project_id — an authoritative single-project read: identity, owning org, tier, lifecycle, site_url + custom domains, last deploy, mailbox address(es), and usage versus tier limits. Authorized for the owner (a SIWx wallet or control-plane session with viewer access to the owning org) or platform admin; it is never an existence oracle and never returns a secret value. Completes project CRUD and replaces the stale local snapshot that tools used to show.DELETE /jobs/v1/runs, and project deletion uses the same cascade so old job records do not linger.GET /mailboxes/v1/:id/messages/:id/raw) now returns up to 30 MB, so a 15 MB attachment forwarded back in a reply stays retrievable.POST /projects/v1 echoes org_id — the create response now includes the owning org, so multi-org clients can immediately manage members or follow-up org-scoped calls without guessing from membership order. Existing fields are unchanged.<Run402Image> can render their placeholder and strict schema filters correctly.Idempotency-Key with an identical request against such a row re-runs it in place — same call_id, no on-chain duplication possible. Previously the stored failure replayed as 502 with a misleading retryable: true forever.retryable: false plus a next_actions pointer to mint a new key, instead of a dead-end retry loop.idempotency_key_conflict now includes the stored row’s status, tx_hash, and call_error, so clients can tell “in-flight — poll it” apart from “terminally failed — rotate the key”.POST /agent/v1/control-plane/merge folds the duplicate into your real account: you prove control of both sides (a fresh sign-in to each, plus your passkey), and its sign-in methods, emails, and recovery codes move over atomically with a full audit trail. The duplicate is disabled, never deleted.POST /orgs/v1 makes an empty org on the prototype tier with you as owner — no project required first. Give it an optional label and rename it any time. There is no tier input at create; org create/read/rename responses report the tier and lease timestamps. (Paid tiers stay a separate step.)deploy flow is unaffected), and the wallet credential never moves onto your human account.POST /projects/v1 takes an optional org_id so a developer can create a project under an existing org. Omit it and nothing changes for solo cold-start./orgs/v1 API now speaks org_id everywhere (org lists and whoami carry org_id + display_name). The SDK/CLI wrappers follow shortly; wallet sign-in is unchanged.400 instead of a 500.console.run402.com for org management (members, invites, audit log, project handoffs). Each path mints a control-plane session signed with a dedicated secret asserted distinct from the tenant-app, CI, and read-only operator secrets. This is the owner’s front door — not the app-level /auth/sign-in your project’s own end-users use (different account, different data).localStorage). run402 operator login from your terminal now completes end-to-end: the browser opens an approve page, you confirm with a passkey, and your terminal is signed in.503) until the OAuth apps are registered and their client id/secret are provisioned in Secrets Manager and wired into the gateway. Passkey and email-link sign-in work without that step.409 WRONG_TRANSFER_KIND — email→org handoffs share the transfer id-space with wallet-to-wallet transfers (and appear in GET /agent/v1/transfers/outgoing), so addressing a handoff id on a wallet-transfer route (cancel / accept / preview) used to return a 500. It now returns a clean 409 WRONG_TRANSFER_KIND whose details.next_actions[] points at the correct completion action. Handoff behavior now lives under the same /agent/v1/transfers/:transfer_id noun: email rows use POST .../claim, wallet rows use POST .../accept, and cancel/preview are kind-agnostic.POST /projects/v1/:id/delegates issues one (the run402_agent_key bearer is returned once), with list / DELETE revoke / POST .../rotate. The agent can use the bearer to deploy, but the delegate only ever narrows its grant (authority = grant ∩ scope ∩ expiry ∩ owning-org snapshot): it can’t escalate via the spec, can never delete or transfer the project, fails closed if the project moves to another org, and dies instantly on revoke. The token and key material are never exposed by the list.ci_oidc delegate. The only visible change is that a binding’s id is now an opaque UUID (was bnd_…). Existing deploys keep working (a binding is matched by issuer + subject, not id); if you manage bindings by a saved id, list them to get the current one.GET /agent/v1/whoami (your principal + memberships), GET /orgs/v1 (your orgs + roles), GET|POST /orgs/v1/:id/members + PATCH|DELETE /orgs/v1/:id/members/:principal_id (add / list / role-change / revoke), and POST|DELETE /projects/v1/:id/grants (issue / revoke per-project capability grants for agents/CI).409 LAST_OWNER). Revoking a member is a single row (no key rotation, ownership unchanged) and takes effect immediately. Co-ownership and scoped agent delegation are now real: add a teammate by wallet with a role (a new wallet becomes a human collaborator; an existing wallet is added by its principal — type is informational, so an owner’s explicit add always works). For non-member automation, use a per-project grant.faucet → tier → create → deploy works exactly as before with no email, passkey, or human step. The org/membership concepts stay invisible until a second person joins.wallet_address replaced by organization_id + created_by — GET /projects/v1 and GET /tiers/v1/status now return the owning org and the provisioning principal instead of a wallet. /tiers/v1/status lists every project owned by an org you’re an active member of, plus projects you hold a grant on. SIWX auth is unchanged, so existing clients keep working; the CLI/MCP/SDK update follows shortly.NOT_AUTHORIZED (403) — control-plane routes return 403 (never 404, so project existence isn’t leaked) when your principal lacks the required role/grant. Delete, transfer, and membership changes require an active owner membership.astro/app/entrypoint, an Astro-6-only export; Astro 5 exposes only ./app and ./app/node). On Astro 5 the build fails with Missing "./app/entrypoint" specifier in "astro" package, and Vite resolves the specifier at bundle time so the runtime guard can’t soften it. The error catalog at run402.com/errors/#R402_ASTRO_VERSION_UNSUPPORTED previously gave the range as >=5.0.0 <7.0.0 and advised “Pin Astro to ^5 or ^6” — the ^5 half sent you back into the crash. It now states >=6.0.0 <7.0.0 and advises “Pin Astro to ^6”, and the adapter’s assertAstroVersionSupported() guard rejects Astro 5 up front. Use Astro 6 with @run402/astro.<SignIn methods={["google"]} /> returned session_mint_failed on every custom domain (apex like kychon.com, custom subdomains, and managed *.run402.app): the OAuth flow completed, then the cookie mint failed. Google’s single registered callback lands on the API origin (api.run402.com), which can neither set the tenant __Host- session cookie (host-locked, cross-origin) nor emit it under /auth/v1 (the machine-route cookie guard). Sign-in now mints a single-use, host-bound handoff ticket and 303s to a new tenant-origin completion route (GET /auth/sign-in/oauth/complete) that sets the cookie on the right host. Password, magic-link, and passkey sign-in were unaffected. No SDK change — <SignIn> already drives the OAuth start route.R402_AUTH_OAUTH_HANDOFF_INVALID — a clear 400 when an OAuth sign-in handoff ticket is missing, expired (~120s), already used, or bound to a different host/project, replacing the misleading session_mint_failed for that path (the no-JS <SignIn> error channel shows oauth_handoff_invalid).GET /jobs/v1/runs/:job_id/artifacts/:filename streams a completed job’s artifact (proof.json, public.json, and the *.log files) with its content-type, authenticated with the same Authorization: Bearer <service_key> as the rest of /jobs/v1. Previously a completed job listed its outputs but nothing could fetch them. Returns 404 if the job isn’t completed or the filename wasn’t recorded.run402:// strings — GET /jobs/v1/runs/:job_id and the terminal-completion webhook now return artifacts as { "<filename>": { url, content_type, sha256?, size_bytes? } }, where url is the fetchable download endpoint above. The old run402://storage/… URI scheme was never resolvable and has been retired — parse the url field. The runner-computed sha256/size_bytes let you verify integrity before use; they’re omitted for jobs created before this change (the url still serves).typ: run402.operator-session+jwt, scope: ["operator.read"]) signed with a secret asserted distinct from both the tenant-JWT and CI-session secrets; 30-min TTL, 12h absolute re-auth cap, revocable. GET /agent/v1/operator/overview returns a multi-organization summary (counts only — functions/secrets/domains/mailboxes — never inventory names) and also accepts a wallet SIGN-IN-WITH-X header. Inventory names (secret names, never values) come from a Tier-B drill-down GET /agent/v1/operator/projects/:id/contents behind a stronger, fresher proof. The read-only ceiling is enforced by a route auth/effect manifest + CI gate — the operator session is rejected at every mutating route. Support reads go through an admin-only, audited /admin/v1/operator/overview. Phase 0 is strictly read-only; email-safe mutations, a non-custodial browser signing wallet, and the reference web console at console.run402.com are upcoming phases. API-first: the reference console is just a consumer of these public endpoints..astro child component (not the page) that called auth.user(), auth.account.getSecurity(), db(), cache.*, assets.*, email.*, or ai.* in its frontmatter silently degraded to the anonymous / no-context path during streaming SSR — so <AccountSecurity> / <SignedIn> rendered blank (and <SignedOut> showed signed-out UI) for a signed-in user, even though the page frontmatter and API routes resolved the actor correctly. The function runtime wrapped only App.render() in the per-request context and drained the streamed body after that context exited, where child components render lazily. The runtime now materializes the response body inside the request context, so components resolve the same actor/project/locale as the page; streaming stays enabled. Redeploy your function/site to pick up the fix — already-deployed bundles keep the old behavior until their next deploy.app_origin=https://undefined. auth.account.getSecurity() (which backs <AccountSecurity> and account-security UIs), auth.fetch(), and the session-response helpers each got a 400 from /auth/v1/account/* and returned null — leaving the component empty for a signed-in user. auth.user() was unaffected (it resolves the actor from the request URL), which is why whoami worked while getSecurity() didn’t. The runtime now sets the context host to the public tenant host (custom domain or managed subdomain). Redeploy your function/site to pick up the fix — already-deployed bundles keep the old behavior until their next deploy.kysigned.fflonk_prove.v0_17_0) could stay queued indefinitely because nothing triggered the dispatch step — runners were only ever launched by hand. The gateway now claims and launches queued jobs on its 60-second worker tick, so a job goes queued → running within ~60s of submission with no manual step. No API change; any previously-stuck job dispatches on the next tick without re-submitting.auth.* fixes) only reaches a deployed function when it’s re-bundled, and there was no way to see the gap. Functions now record a build fingerprint: GET /agent/v1/operator/status gains a runtime block (stale count + list) and GET /functions/v1 gains a per-function runtime_stale flag. To pick up a platform runtime fix without editing your source, call POST /projects/v1/:id/functions/:name/rebuild (one) or POST /projects/v1/:id/functions/rebuild (all) — it re-bundles from your stored source with dependencies pinned to their exact recorded versions, so only the wrapper/runtime changes (code_hash unchanged, no new release). Strictly opt-in — functions are never auto-rebuilt; allowed during billing grace. Functions deployed before dependency locking return CANNOT_REBUILD_UNLOCKED_DEPS.GET /mailboxes/v1/:id/messages/:messageId and GET /mailboxes/v1/:id/messages/:messageId/raw validated messageId as a UUID, but Run402 message ids are msg_<epoch>_<rand> (e.g. msg_1780076428285_dlnfqq) — the exact ids returned in message listings and reply_received webhook payloads. Every fetch by a real message id returned 400 Invalid messageId: must be a valid UUID, so no consumer could read a stored message or pull its raw DKIM-signed bytes. The routes now validate the actual msg_ id format; a malformed id still fails closed at 400. Unblocks reply-to-sign flows that fetch raw MIME for DKIM / zk-email verification.@run402/functions v3.0, @run402/astro v2.0. Browser sessions are no longer JWT-in-cookie. The new __Host-Http-r402_session=v1.<session_id>.<secret> cookie is an opaque handle into internal.sessions; the DB stores only an HMAC-SHA256 verifier (peppered with a gateway-only secret). No positive validity cache, so revocation is instant across every gateway task on the next request. Sliding 12h / 30d hard cap.auth.* is the sole server-side auth namespace — auth.user() / requireUser() / requireRole(role) / requireMembership(m) / requireFresh({maxAge, amr?}) / fetch(input, init?) / csrfToken() / csrfField() / sessions.createResponseFromIdentity({provider, subject, proof, amr, createUser?}) / sessions.endResponse() / identities.link({...}). The Actor type uses id (matching Supabase / Clerk / Auth.js convention). Hallucinated names (getUser, getSession, currentUser, getServerSession, auth.protect, auth.signIn, …) throw R402_AUTH_UNKNOWN_EXPORT at runtime AND fail the run402 doctor deploy scan. Raw-userId session minting is no longer exposed — the createResponseFromIdentity bridge verifies the proof end-to-end so agent code never holds a userId./auth/sign-in, /auth/sign-up, /auth/sign-out (GET redirect-safe; POST revokes), /auth/re-auth, /auth/sign-in/oauth/google/start. @run402/astro 2.0 ships <SignIn />, <SignUp />, <UserButton />, <SignedIn>, <SignedOut> components. All hosted routes return HTML + Set-Cookie — never JWT JSON.auth.requireFresh({amr: ["passkey"]}). Sessions track per-method amr_times so step-up flows actually check the named method.Origin: null always fails. Referer fallback uses full-origin equality. Both absent fails closed. Hosted forms additionally verify a double-submit token from auth.csrfField().*.run402.com unless the project is in the staging allowlist (SameSite scope is eTLD+1, so sibling subdomains are structurally same-site). PSL-registered *.run402.app and verified custom domains are always allowed.db() mints a 60s actor JWT → PostgREST pre_request (v1.61) populates run402.* via set_config(..., true) → RLS policies call run402.current_user_id(). Zero plumbing in consumer code; .eq("user_id", user.id) against an RLS-bound table is a deploy-fail.R402_AUTH_* error codes at /errors/ covering identity, session, CSRF, hosted-UI, SDK, and RLS surfaces. Every envelope's next_actions[].docs deep-links into the page anchor.run402 doctor source scanner detects 33 hallucinated-name patterns and wires into run402 deploy apply pre-flight. Bypass via RUN402_DEPLOY_SKIP_SCAN=1.tools/agent-eval/ measuring whether Claude / GPT-class models generate the canonical auth.* patterns on first try. Fixture mode for CI; hermetic live mode for release-gate.GET /tiers/v1/status and GET /wallets/v1/:address/projects no longer duplicate organization state onto each projects[] entry. Removed per-project: tier, status, pinned, effective_status, organization_lifecycle_state, lease_perpetual. These are organization-scoped (v1.46 tier + v1.57 lifecycle), so emitting them N times was a denormalized view. Read them once from the top level of /tiers/v1/status, which gains organization_lifecycle_state and lease_perpetual (both null only for orphan wallets). SDK / CLI / MCP consumers of the dropped fields need updating in kychee-com/run402.mail.run402.com namespace — POST /mailboxes/v1 previously rejected the reserved slug list (info, support, help, hello, contact, team, noreply, admin, …) globally — even on a project that owned a fully-verified custom email domain. The blocklist now applies only on the shared mail.run402.com namespace; on a project whose internal.email_domains row has status='verified' AND inbound_enabled=TRUE, the customer owns the namespace and may use any (format-valid) slug. Mailbox responses (create / list / get / delete) now return address against the project's verified custom domain instead of mail.run402.com, matching the operational reality. Format rules (3-63 chars, lowercase alnum + hyphens) still apply everywhere. Unblocks kysigned's info@kysigned.com.POST /mailboxes/v1 now allows up to 5 mailboxes per project, flat across all tiers. Previously each project was capped at a single mailbox; a project at its cap now returns 409 Project mailbox limit reached (5). No pricing change and no schema change — the per-organization daily email quota, inbound routing, and the mailbox-id-keyed API were already built for multiple mailboxes; only the create guard enforced the old limit. This lets one project run distinct sign@ / notifications@ / support@ addresses.POST /projects/v1/:project_id/transfers); B reviews the project's footprint via GET /agent/v1/transfers/:transfer_id (safe preview: custom domains, subdomains, function names, secret names — never values, mailboxes, CI bindings to be revoked, billing implications); B accepts with their own SIWX (POST /agent/v1/transfers/:transfer_id/accept). Either party can cancel; otherwise auto-expire at 72 hours. The accept is atomic: wallet flip + CI binding revoke + notification enqueue + secrets-rotation advisory all commit in one transaction. Stale-offer safe: routes scope by transfer_id, not project_id — B accepting a cancelled offer returns 409 TRANSFER_ALREADY_PROCESSED instead of silently accepting the replacement. Project state freeze while pending: /apply/v1/plans returns 409 PROJECT_HAS_PENDING_TRANSFER with a cancel URL. Five distinct pre-condition envelope codes (OPERATOR_EMAIL_NOT_VERIFIED, RECIPIENT_ORGANIZATION_NOT_ACTIVE, PROJECT_NOT_TRANSFERABLE, PROJECT_OWNER_CHANGED, TRANSFER_EXPIRED). Four mandatory notification events to BOTH parties. Phase 1A ships with KySigned attestation stubbed; Phase 1B (add-project-transfer-kysigned-attestation) activates real on-chain verification. Migrate-only billing policy in Phase 1A. SDK / CLI / MCP cascade tracked at kychee-com/run402#404.POST /email/v1/domains/inbound no longer 500s with RuleDoesNotExist — The SES receipt-rule reconciler was hardcoded against RuleName: "InboundMailRule" while the live CDK-generated rule is named EmailReceiptRuleSetInboundMailRuleC0040FE4-<suffix>, so every enableInbound / disableInbound threw 500. The rule name is now resolved lazily from DescribeActiveReceiptRuleSet and cached. As a follow-on, enableInbound / disableInbound now call SES BEFORE flipping internal.email_domains.inbound_enabled — a SES failure no longer orphans the DB flag and locks retries out via the idempotency short-circuit. Closes #416.POST /apply/v1/releases/:release_id/promote — operator pointer-swap recovery — New SIWX or CI-session endpoint (capability deploy) that points a project's live_release_id at a prior release without re-running the apply pipeline. Refuses non-ready targets and no-op self-promotes. Returns structured warnings (MIGRATIONS_NOT_REVERSIBLE, FUNCTION_VERSION_MISMATCH, ASSET_GC) that the caller opts into via allow_warning_codes. Flushes ssr_cache for the project's bound hosts and logs to operation history as operation_kind: "promote". SDK + CLI in kychee-com/run402.active → past_due → frozen → dormant → purged) now lives on internal.organizations.lifecycle_state, not per-project. One UPDATE organizations per cliff event replaces the previous N-row update across all projects in an organization. Per-project pinned is replaced by per-organization lease_perpetual (operator-only, toggled via POST /orgs/v1/admin/:org_id/lease-perpetual). Per-project state surface narrows to deleted_at (user-driven delete) and archived_at (operator moderation). Notification event types rename project_* → organization_* with new payload carrying affected_project_ids[] (cap 50). Multi-wallet organizations fan out one notification per distinct verified operator email, deduplicated. SDK / CLI / MCP cascade tracked at kychee-com/run402#402. HTTP envelope codes PROJECT_PAST_DUE / _FROZEN / _DORMANT preserved for one release while clients migrate. Destructive cutover gated by ALLOW_DESTRUCTIVE_CUTOVER=true./apply/v1/plans validates assets.put[].content_type at plan time (closes #394) — validateAssetSlice was missing the typeof/format check that all sibling fields already had, so non-string values (arrays, numbers, null literals) flowed verbatim into internal.blobs.content_type and surfaced as malformed Content-Type headers when the blob was served. Now rejects with HTTP 400 INVALID_SPEC against an RFC 6838 regex. Also closes a downstream Bugsnag-noise variant where undefined content_type crashed a .toLowerCase() call further down the plan pipeline./apply/v1/plans maps StableHostCanonicalizationError to 400 instead of 500 — Errors from canonicalizeStablePath (e.g. path targets an internal Run402 namespace) were becoming 500
INTERNAL_ERROR. The mapper now returns 400 INVALID_SPEC with canonicalization_code populated so SDK callers can branch on the specific reason./projects/v1/admin/:id/publish preserves user 4xx when demo-project setup fails — Demo creation/update was wrapping every caught error as 500, including tier-validation 4xx (e.g. FunctionError(403, "Timeout must be 1-10s for your tier.")). Now instanceof checks for FunctionError, BundleError, PublishError, ForkError, and HttpError with 400 ≤ statusCode < 500 preserve the original status; only truly unexpected errors stay 500. Soft-rollback to visibility=private still runs in both branches./projects/v1/admin/:id/secrets retries Lambda ResourceConflictException with exponential backoff — refreshFunctionEnvVars now wraps both the Lambda GetFunctionCommand and UpdateFunctionConfigurationCommand in a retry helper (500ms / 1500ms / 4500ms; max 3 retries). Closes a noisy FUNCTION_ENV_REFRESH_FAILED 503 path on projects with multiple functions when two secrets-rotation calls land in quick succession and AWS rejects the second with An update is in progress for resource.
pg_default_acl — resetSchemaSlot now takes a fixed-key global advisory lock before the per-slot lock so two concurrent POST /projects/v1 calls hitting different schema slots no longer race on shared catalog tuples from ALTER DEFAULT PRIVILEGES. Eliminates the long-tail tuple concurrently updated500s during burst project provisioning.
BadRequestError: request aborted suppressed from Bugsnag — Client disconnect mid-upload throws BadRequestError with code: "ECONNABORTED"; the filter only matched name === "HttpError" so this leaked. Added a targeted suppression for that exact shape; other BadRequestErrors (e.g. ENTITY_TOO_LARGE) still report./apply/v1/plans asset_ref now carries v1.50 + v1.54 fields (fixes #415) — The plan-path enrichment was SELECTing only the v1.49 intrinsics (width_px, height_px, blurhash) from internal.blobs, so blurhash_data_url, asset_schema, image_format, image_info, image_exif, image_exif_policy, and metadata were silently dropped from every asset_entries[].asset_ref even when the row had them populated. The @run402/astro consumer's dist/_assets-manifest.json carried empties on every image; <Run402Image> placeholder rendering + strict-mode schema filtering degraded with no error. The plan-path SELECT + threading is now widened to surface all v1.50 + v1.54 fields. /apply/v1/service-asset-put extended to thread the same v1.50 fields so both write paths stay symmetric. Strictly additive on the wire; consumer fix lands without a redeploy on the next planning roundtrip.operator-health-notifications, gateway migration v1.55) — BREAKING — Unified operator-facing notification feature: weekly digest + immediate lifecycle events share one queue, one audit log, one delivery worker, one preferences object. Threshold state is computed in v1 and surfaced in digest + status; immediate threshold-alert delivery ships in v1.5. BREAKING: internal.organizations.primary_contact_email is dropped — six callsites rewritten to resolve the verified operator email via organization_wallets → agent_contacts JOIN. Pre-revenue; no parallel-write, no migration buffer. Recipient resolution refuses to send to unverified addresses; instead persists a missing_verified_recipient audit row visible via GET /agent/v1/notifications and run402 doctor. Seven new HTTP endpoints under /agent/v1/notifications/*, /agent/v1/webhook-secret/rotate, /agent/v1/operator/status. Assurance ladder: SIWX reads own + organization-scoped; email_verified unlocks cross-wallet rollup + multi-wallet preference mutation; operator_passkey for webhook URL changes + secret rotation + recovery-adjacent settings. Mandatory classes (security, recovery, billing_critical, destructive_lifecycle) bypass every preference toggle — you cannot silence them. Fixed thresholds with hysteresis. Deterministic 5-tier digest lede ranking. Notification delivery decoupled from state transitions — lifecycle/quota/billing/recovery code emits events; a dedicated worker delivers; delivery failures never block state.Run402-Signature: t=<unix>,v1=<hex>. Receivers reject |now - t| > 5 minutes for replay protection. Dual-secret 24h grace window after rotation. Delivery-time DNS re-resolution defeats DNS rebinding — blocks loopback, RFC 1918, link-local (incl. cloud metadata 169.254.169.254), multicast, IPv6 ULA, CGNAT. 5s timeout, 1 MiB cap, 3 max redirects (each re-SSRF'd). Webhook auto-disables after 10 consecutive failures and emits a webhook_disabled security-class notification. Payload envelope includes idempotency_key equal to the audit-row UUID, so receivers dedupe across worker retries with zero coordination.@run402/functions@2.9.0 — verifyWebhook(headers, rawBody, secret, options) SDK helper — Constant-time HMAC verification. Accepts Web Headers, Node IncomingHttpHeaders, or plain case-insensitive header objects. Configurable replay tolerance (default 5 min); previousSecret option enables the 24h rotation grace. Returns {valid, timestamp, secret_used} or {valid: false, reason} with one of six documented reasons.run402 doctor extended with operator-health check; new run402 notifications + run402 webhook-secret rotate CLI commands — doctor calls GET /agent/v1/operator/status and surfaces gaps (missing verified email, missing passkey, skipped notifications, critical items) as warnings without failing the doctor; degrades gracefully against pre-v1.55 gateways. Six matching MCP tools registered: get_operator_status, get_notification_preferences, set_notification_preferences, list_notifications, test_notification, rotate_webhook_secret.<Run402Image> component (capabilities asset-image-variants-v1-51 + run402-image-component) — Two paired changes that close the silent-degradation failure mode for image rendering. Gateway side: internal.blobs gains blurhash_data_url (pre-decoded ~800-byte PNG data URL, computed once at upload time) and asset_schema (stamps which shape contract the row satisfies: v1.49 / v1.50 / v1.54 / null). Pinned vendored BlurHash decoder (16×16 pixels) with a SHA-256-locked pixel-buffer reference so render output is byte-deterministic across Node versions.scripts/asset-schema-backfill.ts stamps pre-v1.54 rows via 8 new admin endpoints (no DB tunnel needed). Cost preview with --dry-run; checkpoint + heartbeat for resumable runs after Ctrl-C. --regenerate-heic-transcodes flag generates missing display_jpeg variants for legacy HEIC uploads. scripts/asset-schema-remediate-drift.ts is the operator-discretion drift-remediation subcommand for the byte-drift-escaped-to-production scenario; writes a structurally-flagged audit-trail row.Run402-AssetBackfill — 5 panels covering row throughput, skip + error categorization, batch p50/p95 latency, HEIC throughput + storage delta, HEIC latency, and 24h summary tiles. 11 metrics under Run402/AssetBackfill namespace. Per-tag project_id filtering for per-tenant rollups.@run402/functions@2.7.0 — additive AssetRef.blurhash_data_url + asset_schema — SDK type extended; r.assets.put response and r.assets.fromRef rehydration both deliver the new fields. Backwards-compatible.@run402/astro@1.0.0 — new <Run402Image> with byte-identical Astro + React entries — Three additions over the v1.0 <Run402Picture>: pre-decoded blurhash placeholder as <img> background-image (zero render-time decode), strict-mode (binary strict={true} or schema-filter strict={{ onSchema: ">=v1.49" }}), and a React entry at @run402/astro/react. Shared core; both adapters produce byte-identical HTML, locked in by an 18-fixture byte-identity sweep. 12 new R402_ASTRO_IMAGE_* error codes documented at https://run402.com/errors/. Build-time degradation manifest at <outDir>/run402/image-degradations.json for CI regression-gating against silent degradation.display_jpeg variant renders zero pixels in Firefox + Chrome (~75% of global browser share). The component hard-fails unconditionally with R402_ASTRO_IMAGE_HEIC_NO_TRANSCODE in that case — regardless of strict mode, regardless of schema-filter. Projects with legacy HEIC AssetRefs MUST run the backfill with --regenerate-heic-transcodes BEFORE enabling schema-filtered strict mode; the runbook covers all three remediation paths.docs/reference/run402-image-component-adoption.md in the run402-private repo: prerequisite checklist with bash commands, Astro + React import-path migration with diff blocks, three imageDefaults recipes (greenfield strict / mixed-vintage / lenient), CI integration with both minimal-gate and golden-file-gate patterns, and a four-tier rollback path including swap-to-<Run402Picture> emergency option.astro-ssr-runtime, v1.52) — First-class server-rendered Astro deploys with sub-second admin-edit visibility. New @run402/astro v1.0.0-alpha.1 default-export preset: export default run402(); returns a complete AstroUserConfig composing image integration + SSR adapter + build-time detectors. <Run402Picture asset={AssetRef}> runtime component renders <picture> with WebP srcset and HEIC fallback from a stored AssetRef JSONB row. v0.2.x named run402 aliased to run402Image for backwards-compat.POST /cache/v1/invalidate (single URL / prefix / all / many — atomic DELETE + per-(project, host) generation increment, project-host scoped) and GET /cache/v1/inspect (HIT/MISS row state — never BYPASS, since inspect doesn't issue a request). CAS-backed storage (internal.ssr_cache + internal.ssr_cache_generations), has_any_content_ref() extended to keep cached bytes alive across GC. Storage-bytes accounting EXCLUDES the cache (platform-generated, not user-uploaded). Hourly cleanup tick reaps expired + stale-release rows.x-run402-cache (HIT / MISS / BYPASS) + x-run402-cache-reason + x-run402-cache-age + x-run402-request-id + x-run402-release-id + x-run402-function + x-run402-locale. Read-time bypass on method ≠ GET/HEAD, ANY Cookie, ANY Authorization. Write-time bypass on Set-Cookie, Cache-Control: private | no-store, unsupported Vary, oversized body, non-cacheable status. getUser() / payment-primitive taint surfaces via the Lambda metadata envelope. In-process single-flight dedup for concurrent MISS renders on the same task. Cache writes are generation-guarded — in-flight MISS renders won't overwrite after a concurrent invalidation.functions.[name].class: 'ssr' | 'standard'. SSR-class functions enable AWS Lambda SnapStart for Node.js automatically (cold start ~600 ms → ~120 ms). enableSnapStartForSsrFunction in apply-v1 activation publishes a version, waits for waitUntilPublishedVersionActive as pre-activation validation, and surfaces failures as R402_SNAPSTART_INIT_IO warnings (deploy still succeeds; function runs without the cold-start optimization).@run402/functions ALS + cache.* SDK — New runtime-context primitives (AsyncLocalStorage, runWithContext, requireActiveContext, payment-primitives registry, Run402OutsideRequestContextError). db() / getUser() / getUserId() / getRole() widened to (req?) — when no req is passed they read from the active ALS context (populated by the SSR Lambda runtime in @run402/astro). getUser() always taints cache bypass. New cache.invalidate / invalidatePrefix / invalidateAll / invalidateMany SDK methods; structured R402_CACHE_INVALIDATION_HOST_REQUIRED / R402_CACHE_INVALIDATION_HOST_FORBIDDEN errors.R402_* error codes — Stable codes for Astro build / bundle / SnapStart / SDK / SSR runtime / cache / deploy failures, each carrying code + message + suggestedFix + docs + (when statically determinable) file + line. Build-time detectors hard-fail <Image src={dbValue}> (R402_ASTRO_DYNAMIC_IMAGE_UNSUPPORTED), server islands (R402_ASTRO_SERVER_ISLAND_UNSUPPORTED), and sessions API (R402_ASTRO_SESSIONS_UNSUPPORTED). Native dependency scan rejects bundles with .node files (R402_BUNDLE_NATIVE_DEP_UNSUPPORTED).run402 cache inspect/invalidate, run402 init astro (project scaffolder), run402 dev (Astro dev with Run402 env loaded), run402 doctor (health + config diagnostics), run402 logs --request-id <req> (top-level log fetcher for the request id you see in x-run402-request-id response headers). Every command supports --json for agent automation.@run402/functions ships with zero runtime dependencies — A supply-chain reduction pass removed jsonwebtoken, bs58, blurhash, and npm-package-arg from the gateway, dropping ~28 transitive packages. The published @run402/functions SDK went from one runtime dep to zero. Replacements are vendored inline (HS256 JWT on node:crypto; spec-fixed blurhash encode; strict registry-only --deps parser that rejects git+ssh://, file:, workspace:, npm: aliases at parse time). No public API change — routes, error envelopes, JWT wire format, and blurhash output are byte-identical; getUser(req) stays synchronous. Already-deployed user functions keep their bundled OLD helpers until redeploy.viem + a crypto or PDF lib + a small UI bundle easily cross 1.5 MB; the prior universal cap was an oversight that no tier upgrade could clear. Apply's validate phase now pre-checks source.size against the tier limit so over-budget specs fail at POST /apply/v1/plans (HTTP 402 TIER_LIMIT_EXCEEDED) instead of after the full CAS upload + commit. Activate phase enforces the same cap as defense-in-depth.spec.i18n gains unknownLocalePolicy ('reject' default | 'pass-through'). Under 'pass-through', a cookie or Accept-Language tag that doesn't match locales[] is returned verbatim (lowercased + trimmed) as ctx.locale instead of falling through to defaultLocale. Unblocks admin-driven "Add Language" flows where a portal enables a new locale at the app DB layer between deploys. Default behavior unchanged; canonical-return invariant preserved for locales[] hits. Routed-invocation logs gain locale_in_locales_list so operators can distinguish pass-through hits.assets.put accepts metadata (flat per-key JSON, 4 KB cap) and an exif_policy ('keep' default | 'strip'). assets.list and GET /storage/v1/blobs gain sort (key / createdAt asc/desc) and indexed filters (filter.uploaded_by, filter.tag, filter.format, filter.is_image, dimension ranges). Apps that previously maintained a shadow media_assets table for filename / uploader / tags / dimensions can drop it — internal.blobs is now the single source of truth. Every filter hits a partial index.exif_policy: 'strip' keeps only camera_make / camera_model / lens_model / exposure / iso / focal_length / datetime tags in image_exif; GPS, serial numbers, owner identifiers, maker-notes are dropped. The CAS bytes (with full EXIF) stay verbatim — apps that need full EXIF can parse the served bytes themselves. Default 'keep' stores the full EXIF, because the platform shouldn't silently lose user data.image_format (magic-byte detected), image_info (alpha, color_space, animated, frame_count, bit_depth, orientation), and image_exif are populated for any image MIME, ride alongside the v1.49 width_px / height_px / blurhash on the same sharp.metadata() pass — no extra latency.assets.list — @run402/functions gains an assets.list({ prefix?, sort?, filter? }) method backed by the same indexed query path. Client-side validators reject invalid metadata / exifPolicy / filter keys before any HTTP call.assets.put() on an image now returns the source URL plus three responsive WebP variants (320w / 800w / 1920w), display-oriented width_px/height_px, and a blurhash placeholder. AssetRef gets a new variants map keyed by thumb/medium/large. MediaPicker-style admin grids no longer have to download 5 MB originals for thumbnails. Encoder is sync, bounded by per-task concurrency/pixel/dim/timeout caps with friendly 413/422/429/504 responses. IMAGE_VARIANTS_ENABLED=false on the gateway task is a zero-redeploy kill switch.display_jpeg variant is generated alongside the three WebP variants. The new AssetRef.display_url field points at the JPEG for HEIC sources; for already-displayable formats it equals cdn_url. SDK helpers will default <img src> to display_url in the upcoming SDK release.Retry-After: 2. Four CloudWatch alarms on the Run402/Assets namespace fire to Telegram on encoder latency, failure rate, queue saturation, or timeouts.POST /assets/v1/admin/backfill-variants + the scripts/backfill-image-variants.ts wrapper let operators populate variants for image blobs that existed before v1.49 (e.g. an existing media library) without requiring re-upload.blob_url_refs.content_sha256 when checking whether a content_objects row was still referenced, meaning bytes referenced only by immutable URLs could be reaped during the grace window. v1.49 extends the ref union and the new helper internal.has_any_content_ref() covers every callable check. Variants benefit; so do every other immutable URL the platform serves.r.assets.put(projectId, key, newBytes) against an already-populated key returned HTTP 500 ACTIVATION_FAILED with a NOT NULL violation on blobs.size_bytes. The activation INSERT pulled size_bytes via a correlated subquery against internal.content_objects; on a second-upload sha that subquery sometimes resolved to NULL and tripped the constraint. The shared applyOneAssetPut primitive now reads size_bytes once up front and passes it as a literal parameter to both INSERTs — no snapshot race, and a clear error if CAS is genuinely missing the row. Affects both the wallet apply hero and the in-function service-key path; design D16 immutable-URL retention is unchanged.deployFunction threw HTTP 404 Project not found or has no wallet address when internal.projects.wallet_address was null, because the AWS resource-tag builder requires a non-empty run402:signer_id. Demo projects are intentionally wallet-less, so the path that runs after apps publish crashed at the very first Lambda create. The path now uses a "platform" sentinel for the wallet tag when the column is null. The 404 still fires when the project row genuinely doesn't exist.apps publish no longer crashes for team-tier publishers — The demo project that gets auto-created after a public publish is always forked at hardcoded prototype tier (platform-owned, free), but it was copying the publisher's function timeout_seconds and memory_mb verbatim. A team-tier function defaulting to 60s tripped the demo fork's tier validator with "Timeout must be 1-10s for your tier.", returned HTTP 500, and the just-published version got soft-rolled-back to private. The demo path now clamps function timeout/memory down to prototype's ceiling before deploy. Paid forks still fail-fast on tier mismatch — only demos clamp.kysigned.fflonk_prove.v0_17_0 proof jobs that run on run402-owned EC2 capacity instead of request-time functions. Jobs support idempotent submit, compact polling, cancellation, CloudWatch-backed logs, durable project-storage artifacts, spot-first retry with capped on-demand fallback, and a single managed_job billing ledger entry keyed by job_id.GET /orgs/v1/:org_id/billing, GET /orgs/v1/:org_id/billing/history, POST /orgs/v1/:org_id/wallets, POST /orgs/v1/:org_id/checkouts, and PATCH /orgs/v1/:org_id/billing/auto-recharge require x-admin-key or a signed org credential. Billing reads require an org member or linked wallet; checkout creation and auto-recharge require an org role of billing or higher. Email-identifier reads currently require admin key; magic-link self-service for email organizations will land separately.POST /orgs/v1/admin/:org_id/set-tier (admin-key only) now invalidates the wallet-tier and SIWX caches like the x402 path, accepts an optional idempotency_key body field so operator retries are safe (was: each retry double-extended the lease), and rejects downgrades whose organization-pooled functions, scheduled functions, or secrets exceed the new tier limit (was: only storage_bytes was checked, so 800 functions could keep running on a prototype tier)./health minimized — GET /health now returns only {"status":"healthy"} (200) or {"status":"unhealthy"} (503) to unauthenticated callers. The previous response exposed gateway version and per-dependency check state to anyone. The detailed view moved to GET /admin/api/health-detail, gated by X-Admin-Key.domain field against the attacker-controllable req.hostname. The allowlist is built from PUBLIC_API_URL plus localhost/127.0.0.1 for dev; other values reject with 401. Closes the Host-header SIWX replay vector.apiBase sourced from PUBLIC_API_URL — Six routes that previously interpolated req.get("host") into apiBase (and passed it into deployed Lambdas as RUN402_API_BASE) now use the trusted constant. A spoofed Host header can no longer redirect the deployed function's callbacks to an attacker.X-Run402-Trace-Id now echoes either a client-supplied x-run402-trace-id or a fresh trc_<32-hex> value. The X-Amzn-Trace-Id fallback was removed, so the response no longer leaks ALB request timestamps and per-request identifiers.POST /apply/v1/plans now rejects out-of-tier timeoutSeconds, memoryMb, or scheduled intervalMinutes with HTTP 402 TIER_LIMIT_EXCEEDED before any CAS upload or function build runs. Was: full plan ran and failed at the AWS Lambda call.value_wei, webhooks, secret-file deploys — parseOptionalNonNegativeWei rejects above 2**256-1. Mailbox webhook creation caps at 20 per mailbox with HTTP 429. Apply v1 manifests reject paths matching .env, .env.*, node_modules/, .git/, .pem/.key/.pfx/.crt, SSH keys, .netrc/.npmrc/.aws/, or DB dumps with HTTP 400 BLOCKED_FILENAME.X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', Referrer-Policy, and X-Content-Type-Options are now set on every admin HTML route. The JSON /admin/api/* surface is unchanged.subdomainMiddleware no longer short-circuits OPTIONS requests, so browser preflights from custom subdomains reach corsMiddleware and receive proper Access-Control-Allow-Origin headers.pg_try_advisory_xact_lock; the loser returns HTTP 202 resume_already_in_progress. The idempotency middleware awaits the cache INSERT before returning, so retries always see the cached row. The cron scheduler tags each registration with a generation counter so stale ticks from replaced expressions short-circuit before running side effects.UpdateFunctionCode succeeds but UpdateFunctionConfiguration fails mid-deploy, the gateway throws LAMBDA_DEPLOY_PARTIAL_FAILURE with stage: "config_after_code" instead of returning silent success. Retrying the same deploy converges.tierCache now enforces a hard cap of 10,000 entries with insertion-order LRU; cache hits re-bump to most-recent. Closes the slow memory-pinning vector under wallet churn.value_wei — The pre-fingerprint match path used to compare (contract_address, function_name, args) only. It now extracts and compares value_wei too, so a request stored with value_wei=100 can no longer short-circuit a new request with value_wei=0.DELETE /email/v1/domains/inbound/:domain and ?domain= now work alongside the body form, surviving ALB and SDK layers that strip DELETE request bodies.POST /contracts/v1/signers now accepts an optional low_balance_threshold_wei on provisioning instead of forcing the 0.001 ETH default; the value goes through the same uint256-capped parser.createDemoProject or updateDemoVersion fails after a publish, the just-published version is soft-rolled-back to private + fork_allowed=false and the route returns 500. No more orphan public versions with no demo.JSON.stringify. Content-Disposition filenames are sanitized to [A-Za-z0-9._-] only, closing the header-injection vector.https://run402.com/schemas/release-spec.v1.json now resolves so deploy manifests with $schema get editor autocomplete.client_state, and OAuth signup/linking now rolls user and identity writes back together on failure.RUN402_SERVICE_KEY after a failed config read, and function PATCH/DELETE ordering now avoids schedule or DB/Lambda drift on downstream failures.X-Powered-By response header is no longer emitted.TREASURY_LOW refunds it. A single transient failure can no longer become a sustained outbound retry flood.Unexpected token errors from BOM-prefixed code copy-pasted from Windows editors or docs.POST /projects/v1/admin/:id/expose requires an object body, PATCH /v1/versions/:id type-checks fork_allowed as a boolean and validates visibility against the allowed enum, and POST /tiers/v1/:tier validates the payment-header wallet via viem.isAddress instead of startsWith("0x").provisionWallet with a self-reference recovery address fails before any rent debit, wallet-row insert, or allowance ledger entry, and that the orphaned KMS key is scheduled for deletion.extractMppWallet in the x402 middleware now uses viem.isAddress instead of a startsWith("0x") check, blocking malformed addresses from reaching req.walletAddress on the MPP payment path.ttl_seconds now rejects non-number types up-front; POST /content/v1/plans validates every entry size as a non-negative integer up to 10 GiB; POST /attribution/v1 adds a per-IP rate limit (30/min) and truncates User-Agent to 512 chars before the SQL VALUES; the admin auth middleware now rejects invalid SIWX outright instead of silently falling through to cookie auth; the OAuth validateRedirectUrl helper is now an explicit allowlist with a defensive isLoopback check; CI session tier-missing returns HTTP 403 with TIER_REQUIRED instead of 402 (no more infinite x402 retry loops); and issueAuthSession now wraps the last_sign_in_at bump and the refresh-token insert in a single transaction so token-mint failures no longer leave an orphan timestamp.X-Forwarded-For spoofing draining the testnet treasury). POST /mailboxes/v1/:id/messages enforces a per-mailbox sliding-window rate limit (60/min) before quota and SES. services/email-send.ts wraps the SES call in a bounded retry that distinguishes transient AWS errors from user errors so transient hiccups no longer waste user quota. POST /agent/v1/contact now DNS-resolves the webhook host and rejects URLs pointing at private addresses (loopback, RFC1918, AWS metadata, IPv6 ULA, etc.), closing an SSRF vector that could pull AWS task-role credentials.advanceLifecycleForWallet wraps its work in a transaction with a per-wallet advisory lock so the hourly scheduler and inline tier-payment hook cannot race. The billing module exports two distinct numeric helpers (positive vs non-negative) with distinguishable error messages, and PATCH /orgs/v1/:org_id/billing/auto-recharge rejects updates against non-active organizations with HTTP 410 ORGANIZATION_ARCHIVED so cancelled organizations cannot trigger phantom Stripe charges.pinned state plus was_pinned, so repeated agent calls are safe. Project lists and wallet tier status expose pin state, and deploy operation listing now supports cursor pagination, status/since filters, matching project_id, has_more, next_cursor, and opt-in exact totals.site_limit now rejects trailing junk, decimals, and exponent notation; direct blob reads validate byte ranges before S3 sees them and return clean 400/416 responses; blob list prefixes now treat SQL wildcard characters as literal key-prefix characters.POST /auth/v1/passkeys/register/verify now returns HTTP 429 when project/user/RP/IP limits are exceeded before WebAuthn verification runs.POST /apply/v1/plans + POST /apply/v1/plans/:id/commit, direct storage uploads plus GET /storage/v1/blob/:key or project CDN /_blob/:key, @run402/functions with db(req) / adminDb(), and POST /projects/v1/admin/:id/expose. A repo guard now fails if current guidance reintroduces removed route/helper surfaces outside explicit negative tests or historical archives.ReleaseSpec.site.public_paths can now publish clean public static URLs without exposing the backing asset filename. mode: "explicit" requires a complete table such as "/events": { "asset": "events.html" }, so /events serves events.html while /events.html stays private on stable hosts. Explicit mode carries forward across later site.patch deploys, mode: "implicit" restores filename-derived reachability with a widening warning, static aliases materialize as route-only manifest entries, and authenticated release/resolve diagnostics explain the public-path authority.405 ROUTE_METHOD_NOT_ALLOWED whenever a path matches a route but the HTTP method is not allowed, including safe GET and HEAD requests. Previously, a GET to a POST-only route could serve the static SPA fallback and hide route mistakes.functions.replace and functions.patch.set as declarative desired state. If a function already matches the planning base by source hash, runtime, timeout, memory, and schedule, Run402 preserves the release snapshot but skips Lambda staging and activation, so static-only redeploys no longer spend most of their time updating unchanged functions.route_manifest_sha256: null as an empty route table, so cleared routes no longer leave custom domains serving stale route-manifest failures while managed Run402 subdomains are healthy.service_key or a project_admin JWT in the apikey header. Browser-safe anon_key tokens can still read public blobs and complete internal CAS sessions opened by content plans, but they can no longer create normal blob upload sessions, list blobs, sign downloads, delete blobs, diagnose CDN state, or read private blobs.TooManyRequestsException responses during routed function invocation now return the structured routed-failure envelope with HTTP 503 instead of escaping as an unhandled gateway error.site.paths[*].size_bytes, static manifest sizes and hashes, and static_manifest_metadata.total_bytes consistent, including same-SHA static replacements whose base snapshot did not know the file size.ReleaseSpec.routes now accepts static targets shaped as { "type": "static", "file": "events.html" }, so clean public paths like /events can serve materialized static files without function compute. Static aliases are exact-only, require explicit GET or GET+HEAD methods, validate the target file against the final materialized site, preserve the browser URL, and fail closed with STATIC_ROUTE_TARGET_NOT_FOUND instead of falling through to SPA HTML. Same-path mixed-method route tables are supported only when effective methods are disjoint, enabling static GET /login plus dynamic POST /login. Plan warnings call these aliases, not rewrites, and cover shadowing, relative asset risk, duplicate canonical URLs, extensionless non-HTML targets, and the current temporary combined route-table limit.run402.static_manifest.v1 with cache classes, response metadata policy, SPA fallback target, CAS object sizes, and a stable manifest SHA. Deploy plans report a new static_assets diff bucket with changed/reused byte counts and immutable-cache diagnostics. Activation verifies the static manifest and every referenced CAS object before the active release pointer flips, and CAS GC preserves static manifests. Agents can call GET /apply/v1/resolve?host=...&path=... for authenticated host/path diagnostics without exposing internal CAS URLs. Stable-host serving is gated by rollout flags and custom-domain serving modes during rollout.ROUTE_METHOD_NOT_ALLOWED response is preserved with request correlation.X-Run402-Request-Id: req_... on success, redirects, user-controlled errors, platform errors, and wrapper-generated errors. Function logs support real since filtering, request_id lookup, latest-tail semantics after filters, chronological results, and additive metadata (event_id, log_stream_name, ingestion_time, best-effort request_id). Operators can copy the browser request_id from a routed 500 and run run402 functions logs <project> <function> --request-id req_... to find the stack and route/deployment context. Browser error bodies remain sanitized; gateway trace_id remains the control-plane support handle.Request cookie header before invoking route-targeted Node functions. This restores the documented req.headers.get("cookie") contract for OAuth state checks and cookie-backed routed app sessions. Existing deployed functions keep their generated wrapper until redeployed; new deploys and redeploys pick up the fix.POST /projects/v1/expose/validate validates against an empty schema plus optional parsed migration_sql; POST /projects/v1/admin/:id/expose/validate merges the current project schema with optional parsed migration_sql. Both return the same { hasErrors, errors, warnings } envelope used by the bundle manifest CI gate. The gateway never executes the SQL, applies the manifest, writes deploy plans, updates internal.project_manifest, or reloads PostgREST on these validation routes.Request on both managed subdomains and local routed invocation. Exact dynamic routes such as /admin and /admin/ are resolved before static asset or SPA fallback, matched route failures continue to fail closed, route diffs ignore JSON key-order noise in identical targets, and custom-domain routed invoke secret wiring is synced from CDK with a distinct missing-Worker-secret error.route_scopes such as ["/admin","/admin/*"]. Bindings still have no route authority by default. When CI sends non-null spec.routes, Run402 compares the proposed replace-mode route table against the current base table and rejects HTTP 403 CI_ROUTE_SCOPE_DENIED if any added, removed, or changed route is outside the delegated scopes. Unchanged out-of-scope routes may be re-applied, so full route manifests can stay in GitOps workflows without allowing CI to widen public browser ingress by surprise.ReleaseSpec.routes table that maps same-origin browser paths to serverless functions. Exact paths such as /admin and final prefix wildcards such as /api/* route through a new run402.routed_http.v1 envelope that preserves public URL, query string, cookies, duplicate-safe headers, and base64 request bodies up to 6 MiB. Dynamic routes run before static assets and SPA fallback; unsafe method misses return 405; matched dynamic failures fail closed. Responses preserve redirects and multiple cookies, omit bodies on HEAD, default to private no-store only when the function sets no cache header, and do not get wildcard CORS. The route table appears in release snapshots, plan/release diffs, release inventory, warnings, managed-subdomain host lookup, and custom-domain Worker manifests. This phase intentionally does not add ReleaseSpec.web, framework adapters, edge runtime, streaming, WebSockets, ISR, image optimization, or route-level x402 policy.POST /agent/v1/contact returns email_verification_status, passkey_binding_status, and assurance_level; new or changed emails start a reply challenge and stay email_pending until the mailbox owner replies. After email_verified, POST /agent/v1/contact/passkey/enroll sends a short-lived browser enrollment link to the verified email, where the operator can bind a Run402-origin passkey. Labels are intentionally limited to wallet_only, email_pending, email_verified, passkey_pending, and operator_passkey: this records reachable operator mailbox control and passkey continuity, not a humanhood or uniqueness claim./auth/v1/passkeys/*. Passkey sessions issue normal Run402 auth tokens with assurance metadata (amr, auth_time, aal, passkey_id), and refresh tokens preserve that metadata. WebAuthn origins are pinned to exact project app origins: claimed Run402 subdomains, the project public-id host, active custom domains, or localhost when allowed. Auth settings now include preferred_sign_in_method, public_signup, and require_passkey_for_project_admin. When enforcement is on, password/OAuth/magic-link admin sessions are downgraded until the user signs in with an eligible passkey. New service-key POST /auth/v1/admin/users creates or updates users and can send trusted invite links for first admin passkey bootstrap./apply/v1/plans rejects secrets.set and secrets.replace_all (HTTP 400 INVALID_SPEC); the spec only carries secrets.require[] (declare keys that must already exist) and secrets.delete[] (atomic with activate). Plans with missing required keys still return HTTP 201 but emit a MISSING_REQUIRED_SECRET entry in the new warnings field; commit-time gating hard-errors with HTTP 422. POST /projects/v1/admin/{id}/secrets with body { key, value } now encrypts every value via AWS KMS (one CMK per platform, EncryptionContext bound to { project_id, key }). Hard cap: 4 KiB UTF-8 per value (HTTP 413 SECRET_VALUE_TOO_LARGE over). Secret keys must match ^[A-Z_][A-Z0-9_]{0,127}$. GET .../secrets no longer returns value_hash. Bugsnag scrubber redacts body.value AND nested secrets.set.*.value paths defensively. Two paired migrations: v1.37 adds the new ciphertext columns and runs TS-level batched-commit redaction loops over historical internal.releases manifests (inline AND CAS-spilled, recomputing manifest_digest byte-for-byte against the runtime). v1.38 (gated by ALLOW_DROP_VALUE_ENCRYPTED=true AND a drain check) drops the legacy value_encrypted column. A background backfill worker re-encrypts existing rows out-of-band so the gateway boot path never blocks on KMS throttling. Caveat: secret values are still visible to AWS Lambda as environment variables — the residual exposure is the function's CloudWatch log group. The platform does NOT encrypt them inside Lambda's environment block./apply/v1. GET /apply/v1/releases/{id} returns the activation-time materialized state of a specific release (snapshot-backed, O(1)); GET /apply/v1/releases/active returns the active release's CURRENT LIVE state (reads live tables, so a setSecret call between activation and now appears here); GET /apply/v1/releases/diff?from=<id|empty|active>&to=<id|active> diffs two releases with the same envelope shape as the plan response. All three are always available and return JSON envelopes for errors — NEVER HTML 404 (that was the bug #106/#107 closed). New internal.release_state_snapshots table (v1.39) stores activation-time state; snapshot bytes are built from the planned post-activation state BEFORE the activate transaction begins, so the stale-read race against in-transaction mutations is impossible. CAS-GC's orphan-detection union extended to cover snapshot refs (without it, CAS bodies of spilled snapshots would be silently reaped). Patch-conflict validation now rejects ambiguous specs (same path in site.patch.put+site.patch.delete, same name in functions.patch.set+functions.patch.delete, same name in subdomains.add+subdomains.remove) as 400 INVALID_SPEC./ci/v1/bindings; the workflow exchanges its GitHub OIDC JWT at /ci/v1/token-exchange for a short-lived Run402 CI session and then calls the existing /apply/v1 plan/commit routes. V1 CI sessions are deploy-scoped and limited to site, function, and database deploy specs with base: { release: "current" }; secrets, subdomains, routes, checks, and oversized manifest refs are rejected. Bindings can be listed and revoked, and revocation is checked on every CI gateway request... segments rejected at upload (closes #157) — r.blobs.put(projectId, "../../../etc/passwd", source) used to be accepted and stored verbatim. The blob then listed by r.blobs.ls but unfetchable (URL host normalized .. segments and walked UP to the site root) and undeletable through the SDK (the same normalization made r.blobs.rm 404). Result: orphaned row counting against the project's storage_bytes quota. The write-side validateKey regex ^[a-zA-Z0-9._/\-]+$ allowed . as a literal, so .. segments slipped past — while the read-side normalizeBlobKey was already rejecting them. The two validators now agree: write-side splits on / and rejects any segment equal to ... Sanity-positive cases (foo.bar.txt, path/with/dots.txt, ..foo, foo..) keep passing.Object.freeze(project) hardening on projectCache.set() (the run402#170 fix) caught a latent bug in POST /projects/v1/admin/:id/pin and POST /projects/v1/admin/:id/unpin — both handlers mutated project.pinned directly on the cached frozen object after the DB UPDATE, throwing TypeError: Cannot assign to read only property 'pinned' at runtime. Replaced the in-place mutation with projectCache.set(project.id, { ...project, pinned }) so the cache holds a fresh frozen replacement. Two regression tests pin/unpin a frozen project fixture and assert no TypeError.@x402/express payment challenges AND app-level denials like QUOTA_EXCEEDED, PROJECT_FROZEN, NO_ACTIVE_TIER, insufficient_balance_for_30_day_prepay, wallet_suspended_unpaid_rent, AI translation quota, and email-pack quota. The SDK's @x402/fetch wrapper intercepted ALL 402s and tried to parse them as x402 payment requirements; Run402 envelopes don't match that shape, so the parser threw "Failed to parse payment requirements" and masked the real error. Following the GPT-5.5 Pro consultation in docs/archive/consultations/project-cache-mutable-fields-fix.md, all app-level 402 sites now return HTTP 403 with the same envelope (same code, same category, same next_actions). Real x402 challenges from @x402/express — on POST /tiers/v1/:tier, POST /generate-image/v1, POST /contracts/v1/signers, POST /contracts/v1/call, POST /faucet/v1 — keep returning 402. Agents that branch on the structured code field keep working; agents that branched on status === 402 for application errors break (and were already broken because of the x402-fetch parse failure). Supersedes the band-aid in commit 6fa2c0d8.projectCache mutable counters removed (closes #170 properly) — Yesterday's band-aid (6fa2c0d8) refreshed the cache after retention paths but left two structural problems. Other write paths (blob delete, deployment_files cascade from non-retention sources) still drift the cache, and meteringMiddleware was reading project.apiCalls / project.storageBytes from the cached ProjectInfo object — which DB triggers and async work could change without app-side knowledge. The fields are now removed from ProjectInfo outright. A new getProjectUsage(projectId, { allowMemo }) helper reads internal.projects.api_calls / storage_bytes authoritatively from the DB, with a 1-second process-local memo for the metering hot path. The metering middleware uses a read-then-re-read pattern: memoized read first; if it shows over-quota, a fresh re-read with allowMemo: false runs immediately before denial — so a prune that just dropped storage_bytes microseconds ago can't produce a stale 403. Cached ProjectInfo objects are Object.freeze'd on insert so any future regression like project.apiCalls++ throws a TypeError at runtime. Quota checks at upload-session init / content-plan creation / deploy-plan creation now also use getProjectUsage({ allowMemo: false }) instead of reading from cache. The original >= quota comparison was off-by-one and is corrected to >.prune-superseded decremented storage_bytes via the AFTER DELETE trigger, the in-memory project cache held the pre-prune (inflated) value. The next deploy hit meteringMiddleware, saw cached storageBytes >= tier.storageBytes, and 402'd. The SDK's x402-wrapped fetch couldn't parse the Run402 error envelope as x402 payment requirements and surfaced "Failed to parse payment requirements" — masking the false-positive quota check. Both prune paths now collect RETURNING project_id and refresh the cache post-commit. Same drift exists on blob/deployment_file deletes from other paths; those land in a follow-up.GET /apply/v1/operations/:id returns operation_id — The snapshot route emitted id, but the SDK's OperationSnapshot type and openapi.json have always specified operation_id. Harmless before today — successful commits returned ready synchronously and the SDK never polled past the commit response. The earlier #153 reorder made activation_pending a real intermediate state on Lambda failure, which kicked the SDK into its poll loop, which read snapshot.operation_id = undefined, which POSTed /operations/undefined → 404 — masking the actual FUNCTION_ACTIVATE_FAILED envelope. Both the snapshot route and the list route now emit operation_id plus the release_id, last_activate_attempt_at, and urls fields the spec already declared.activateStagedFunctions ran AFTER the release flip, so a Lambda failure left the release active while Lambda still served old code — and subsequent same-spec deploys then hit the noop short-circuit and skipped activation entirely. commitDeploy re-ran the full state machine on retry, minting a duplicate release row and overwriting the original error. Reorder: function activation FIRST (out-of-tx, idempotent), release flip SECOND. On Lambda failure the release stays staged, so the next deploy actually retries. commitDeploy now gates on operation status: terminal returns the cached snapshot, resumable redirects to resumeOperation, only initial states run the state machine.r.project(id).apply now actually replaces function code — The /apply/v1 activate phase was a TODO stub for spec.functions — staged_function_versions rows were inserted but never promoted to Lambda, so a successful redeploy via r.project(id).apply would silently keep running the old code. The activate phase now fetches each staged source from CAS, calls UpdateFunctionCode on the underlying Lambda, upserts internal.functions, and applies the schedule tri-state (cron string registers, null cancels, undefined no-ops). spec.functions.patch.delete is honored. On Lambda failure the operation stays in activation_pending so the existing hourly auto-resume worker retries up to 10 attempts with 5-minute cooldown. Becomes critical now that the v1 admin functions route is gone — v2 is the only path.internal.deployment_files meant every redeploy added rows that each charged against tier quota, even though CAS dedup keeps S3 to one physical copy. Three demo projects on the prototype tier crossed quota this week despite a ~60 MB single-deploy footprint — storage tracked deploy count to within 2%. A new hourly retention sweep deletes superseded deployments older than RELEASE_RETENTION_DAYS (default 7) that aren't bound to a subdomain or referenced by an in-flight operation; CASCADE through deployment_files fires the existing trigger and decrements the project's storage_bytes. CAS GC reclaims the orphaned S3 bytes on its existing 30-day grace. New admin route POST /apply/v1/admin/projects/:id/prune-superseded (admin-key, with ?dry_run) for incident response. Composite alarm fires if the sweep produces zero deletes for 24h while eligible rows exist.GET /projects/v1/admin/:id/usage returns lease_expires_at — The endpoint already documented the field in openapi.json, but the handler omitted it — SDK callers were forced into a second roundtrip to /tiers/v1/status just to read the lease expiry. Response now includes lease_expires_at: string | null (ISO timestamp when a lease is active; null when the project has no wallet or no organization). Schema in openapi.json updated to nullable so codegen reflects reality.POST /deploy/v1, GET /deploy/v1, POST /deploy/v1/plan, POST /deploy/v1/commit, POST /deployments/v1, GET /deployments/v1, GET /deployments/v1/:id, and POST /projects/v1/admin/:id/rls now return 404 Not Found. Successors: POST /apply/v1/plans + POST /apply/v1/plans/:id/commit for deploys, POST /projects/v1/admin/:id/expose for the declarative auth manifest, and GET /apply/v1/operations/:id for status polling. The SDK has been routing through v2 since the unified apply ship in v1.34, so SDK and CLI users see no change; pre-revenue, no paying users were affected.https://run402.com/llms.txt is now a short wayfinder that points an agent at the right reference for its integration surface. Three new files land alongside it: llms-sdk.txt (canonical @run402/sdk reference), llms-mcp.txt (canonical run402-mcp tool reference), and a rewritten llms-full.txt (canonical HTTP API reference, modern-only — removed /deploy/v1, /deployments/v1 POST, and /projects/v1/admin/:id/rls routes are omitted from the agent docs). The wayfinder + SDK/CLI/MCP files are owned by the public repo kychee-com/run402 and pulled at deploy time; llms-full.txt is owned here. Coding agents should prefer the SDK over raw HTTP.PATCH /auth/v1/settings accepts the CLI/MCP header combo — Handler now accepts service-role authorization from either apikey: <service_key> or Authorization: Bearer <service_key> (with apikey: <anon_key> alongside, the convention CLI and MCP ship). Previously the documented combo returned 403. The Bearer fallback enforces project-id match against the apikey's project so cross-project escalation is rejected; tampered Bearers fall through to 403.code, category, retryable, safe_to_retry, mutation_state, trace_id, details, and next_actions. Agents can branch on codes like PROJECT_FROZEN, RATE_LIMITED, MIGRATE_GATE_ACTIVE, and MIGRATION_FAILED without parsing English. PostgREST-native errors, user function invocation responses, and presigned upload target responses remain passthrough boundaries.GET /storage/v1/uploads/:id now enriches its response with the live S3 ListParts view. Agents can see exactly which parts have been received and resume a stalled multipart upload without re-uploading completed parts. Server is now authoritative./apply/v1 hardening — Spec validator rejects unsupported routes/checks and multi-file functions at validate-time (clearer errors than letting them blow up mid-commit). Activate phase claims new subdomains from spec.subdomains.set/add so a single commit can ship code and claim a brand. CDN publish path wired up for sites under unified apply. Concurrent deploys to the same project share CAS bytes correctly. is_noop commits short-circuit to the active release_id instead of creating duplicate release rows. The manifest_ref escape hatch (when an inline manifest exceeds the 5 MB body cap) is now documented and tested.POST /apply/v1/plans now accepts the full v1 expose manifest with policy-bearing tables (objects with name, policy, owner_column, custom_sql), matching what the imperative /expose route already accepts. The string[] shorthand still works (default policy public_read_authenticated_write). The commit phase routes through the same applyManifest + NOTIFY pgrst sequence the imperative route uses. Unblocks r.project(id).apply and r.apps.bundleDeploy with policy-bearing manifests.resetSchemaSlot (the drop/recreate during project create and purge) now takes a per-slot pg_advisory_xact_lock so concurrent operations on the same recycled slot can't race on pg_catalog tuples. Eliminates the intermittent tuple concurrently deleted / tuple concurrently updated errors. No behavior change visible to callers — only error rates drop.POST /projects/v1/admin/:id/functions now returns the actual esbuild / dep-resolution error with the right status code (400 / 413 / 503) instead of a content-free 500. Unsupported package imports now surface as bundler diagnostics instead of being masked by Express./apply/v1, v1.34) — Three deploy transports — apps.bundleDeploy, sites.deployDir, blobs.put — collapse onto a single primitive. Two-phase wire protocol: POST /apply/v1/plans negotiates the diff and lists missing content, then POST /apply/v1/plans/:id/commit drives a state machine (validate → stage → migrate-gate → migrate → schema-settle → activate → ready). Bytes always travel through CAS via the new generic POST /content/v1/plans route — no more inline base64. New internal.releases table is the immutable source of truth (one active per project, parent_id links the chain). Schema-settle gate fires a canary SELECT 1 retrying up to 12×500ms before activation, so PostgREST forward retry can never serve a stale schema after DDL. Migration registry keyed by (project_id, migration_id) with checksum: idempotent re-deploys are noops; checksum mismatches are hard errors. Auto-resume worker recovers stuck deploys after 5 minutes. Removed v1 deploy endpoints were superseded by POST /apply/v1/plans and POST /apply/v1/plans/:id/commit. The atomic-multi-resource shape (DB + RLS + secrets + functions + site + subdomain in one commit) is preserved end-to-end.--deps, no more Lambda layer — The shared run402-functions-runtime Lambda layer is gone. @run402/functions (the in-function helper) is now bundled into every function zip at deploy time, alongside any user-declared --deps. The previous --deps array was decorative; it actually works now. Resolved versions land in deps_resolved; the bundled @run402/functions version lands in runtime_version. Existing functions on the legacy layer keep running until redeployed; on the next redeploy they migrate to bundling. Hardened npm install (lifecycle scripts off, scrubbed env, public-registry-only with lockfile audit, native binaries rejected) plus an esbuild resolver plugin that enforces user-source isolation.<sub>.run402.com/_blob/<key>) moves from CloudFront → OAC → S3 to CloudFront → ALB → gateway origin reading the v1.32 CAS substrate. Upload completion responses now include both cdn_url (key-stable, mutable, auto-invalidates on mutation) and cdn_immutable_url (sha-stable, immutable, Cache-Control: public, max-age=31536000, immutable safe forever). New diagnose endpoint GET /storage/v1/blobs/diagnose?url=<blob-url> returns expected vs observed SHA, cache state, and any in-flight invalidation, so agents can debug a stale-CDN ticket from one call. Tracks a new Run402/BlobCDN CloudWatch namespace._cas/{sha[0:2]}/{rest}. Bytes upload direct-to-S3 via presigned URLs into a per-session staging key, then promote to CAS only after SHA-256 verification — never a partial commit. Per-reference billing: storage_bytes is now driven entirely by triggers on the four ref tables; same SHA referenced N times in a project contributes N × size. Three-phase CAS GC runs hourly with a 30-day grace window before any S3 delete; DB row delete only AFTER S3 delete succeeds. Durable copy-resume worker handles cases where the synchronous Stage 2 doesn't finish in 30 seconds. Cutover was DESTRUCTIVE; rollback path is forward-fix or RDS snapshot restore.manifest.json — what code, deps, and migrations are deployed) so agents and CI can verify deploys from outside without trusting their local copy. Newly created tables in user schemas are now dark-by-default — invisible to PostgREST until explicitly granted via the deploy spec — closing the gap where a forgotten table grant could leak internal data through /rest/v1/* just because it existed. Pairs with the v1.30 PUBLIC EXECUTE revoke the previous day.db(req) + adminDb()) — The @run402/functions runtime helper now exposes two distinct DB clients: db(req) runs in the caller's user context (RLS enforced, JWT forwarded) and adminDb() runs as service_role (RLS bypassed). Calling service_role directly via /rest/v1/* from outside a function is now rejected — service_role is function-only. Closes the footgun where a function might accidentally elevate to admin while serving an end-user request.PUBLIC EXECUTE on user functions (v1.30) — A Postgres event trigger fires on every CREATE FUNCTION in user schemas and revokes PUBLIC EXECUTE immediately. The anon role can no longer invoke arbitrary SQL functions just because they exist; callers must be granted explicitly via the deploy spec.GET /status availability report — New unauthenticated endpoint returns rolling availability data (uptime %, last incident, current state) for the gateway and all major substrate dependencies. Powers status.run402.com; safe to embed in shields/badges and dashboards.GET /projects/v1 is now SIWX-only; pricing moved to GET /tiers/v1 — Listing projects requires Sign-In-With-X (wallet auth) instead of being publicly readable — closes the enumeration leak where any wallet's project list was queryable. Tier pricing data moved to the new GET /tiers/v1 (no auth, public, designed for landing pages and CLI quotes). BREAKING for clients fetching projects unauthenticated.run402:project_id cost-allocation tag. Cost Explorer rollups by tag let the admin Finance dashboard show real per-project AWS spend, so unprofitable tenants are visible instead of averaged into the platform total.public_id + blob CDN routing — Storage uploads now use presigned PUT URLs — clients send bytes straight to S3 instead of through the gateway, removing the gateway request-body cap as an upload size ceiling. Each project gets a stable public_id used as the S3 prefix and CDN routing key. Blob CDN routing reads project from a CloudFront KeyValueStore at the edge instead of round-tripping to the gateway for every asset request — orders-of-magnitude lower edge latency.Cache-Control per blob kind — Mutable blobs ship a short Cache-Control with auto-invalidate-on-mutation (re-uploading new bytes for the same key triggers CloudFront CreateInvalidation automatically). Immutable blobs ship Cache-Control: public, max-age=31536000, immutable and never invalidate — the URL encodes the SHA, so mutation produces a new URL.Accept: text/markdown is sent — clean prose without HTML scaffolding, in roughly 1/4 the bytes. API catalog at /.well-known/api-catalog (RFC 9727) lets agents discover the gateway's full endpoint surface in one fetch. Content Signals in robots.txt publish machine-readable AI-training opt-in/out signals for well-behaved crawlers.status.run402.com) — Status page moved from gateway-served to S3 + CloudFront so it stays up even when the gateway is what's down. Adds a daily incident rollup, atom feed, shields/badges endpoint, embedded incident calendar, and a methodology page documenting how uptime is measured.POST /mailboxes/v1/:id/webhooks (already shipped) gains the rest: GET to list, GET /:webhookId to read one, PATCH to update URL/secret/active flag, DELETE to remove. Lets agents reconfigure inbound-email handlers (rotate the webhook secret, point at a new Lambda, disable temporarily) without dropping the mailbox and re-creating it.active → past_due → frozen → dormant → purged. The live site keeps serving end users throughout — only the project owner's control plane (deploys, secret rotation, subdomain claims, function upload) is gated. Three warning emails across grace: past_due at day 0, frozen at day 14, final warning 24 hours before deletion. Subdomain reservation: the name is held for the original owner's wallet throughout grace, so a missed renewal can't lose the brand to a squatter. Any tier renewal during grace instantly reactivates the project and clears the countdown. Scheduled (cron) functions pause at day 44 to stop charging absent owners for compute. Operator rescue endpoints for admin-initiated reactivation and subdomain release. Motivated by saas-factory products: one missed renewal used to silently destroy a live brand's data and subdomain with no recourse — now it takes ~104 days, three emails, and the name is held until the tail expires.service_key token format aligned with anon_key (no exp) — The gateway-issued service_key JWT no longer carries an exp claim — aligns with anon_key and removes "my function broke after 30 days" bugs. Existing functions on legacy Lambdas with stale env-var copies self-heal on next cold start. Rotate immediately if you've embedded a service_key outside run402 in a library that hard-requires an exp.Cache-Control: public, max-age=... on non-HTML assets (CSS, JS, images, fonts) so CF edges cache them. HTML still passes through uncached so fork badges, auth state, and dynamic redirects keep working. Cuts time-to-first-asset for cold-cache visitors.POST /email/v1/domains/inbound, add the MX record to your DNS, and replies to <slug>@yourdomain.com route through the same pipeline as @mail.run402.com. Opt-in, requires DKIM-verified domain. Removing the sender domain cascades to disable inbound. Enables kysigned reply-to-sign at branded addresses.GET /mailboxes/v1/:id/messages/:messageId/raw endpoint returns the exact RFC-822 bytes of an inbound message, fetched verbatim from S3 with no parsing or normalization. Inbound messages only, Content-Type: message/rfc822, 10MB cap. Use this for cryptographic verification — DKIM signature checks, zk-email proofs, archival-grade email storage. The existing JSON message endpoint still returns parsed body_text for display and threading. Apps doing signature verification over inbound mail (kysigned reply-to-sign, etc.) should always read raw bytes — any post-processing breaks the cryptographic chain./contracts/v1/* endpoints for AWS KMS-backed Ethereum wallets per project. Private keys never leave KMS. $0.04/day rental ($1.20/month, billed daily as kms_wallet_rental) plus $0.000005 per contract call (KMS sign fee, billed alongside chain gas at-cost). 30-day prepay required at creation. Provision via POST /contracts/v1/signers; submit calls via POST /contracts/v1/call; non-custodial with optional drain endpoint and recovery address as safety nets. Wallets that stay suspended for 90 days are permanently deleted. Base mainnet first; chain registry config-only for adding more chains.POST /orgs/v1/:org_id/checkouts with product set to tier, email_pack, or balance_topup. Email packs are $5 for 10,000 emails, never expire, and require a verified custom sender domain to protect mail.run402.com reputation. Auto-recharge is configured at PATCH /orgs/v1/:org_id/billing/auto-recharge.POST /email/v1/domains. DKIM verification via SES, DNS records provided. Once verified, email sends from <slug>@<your-domain>. Wallet-scoped ownership for multi-project reuse.POST /auth/v1/magic-link, verify via grant_type=magic_link. Auto-creates users on first use. Includes password change/reset/set endpoint and project-level allow_password_set setting. Multi-method identity: users can have any combination of password, OAuth, and magic link.@run402/functions runtime helper into standalone TypeScript npm package with full type definitions, replacing the inlined heredoc that previously shipped via the function-runtime layer (see April 28 entry for the layer-drop follow-up)inherit: true flag on deploy requests to carry forward unchanged files from previous deployment via S3 server-side copyproject_admin Postgres role with BYPASSRLS, is_admin flag on users, admin JWT issuance, and promote/demote endpointson-* lifecycle hook convention; gateway auto-invokes on-signup function fire-and-forget after first user signupfrom_name) support, bumped team tier daily send limit to 500<slug>@mail.run402.com with template-based outbound, reply-only inbound, and SES integrationSQL type with sql() helper and libpg-query pre-flight validationbootstrap function auto-invoked after fork/deploy with caller-provided variablesgetUser(req) in functions runtime to retrieve authenticated user inside edge functions via JWT