08Docs

Functions

Declarative policy can’t express “fetch third-party data, then update accordingly.” Functions are the imperative escape hatch, server-side TypeScript you run on demand or on a schedule, without breaking the proof thesis. The runtime is now working end-to-end.

The proof boundary

The honest line: Bounded does not prove a function’s logic, but a function cannot break your invariants, and only authorized callers can invoke it. Two guarantees hold no matter what the code does:

  1. Every write goes through the proven boundary

    A function writes via ctx.bounded (the data plane), so its writes are re-checked by your rules and invariants. A denied rule returns 403; a violated invariant returns 409. Either throws inside the function, so the write cannot bypass a cap, mint value, or cross a tenant.

  2. Invocation is policy-gated

    Who may call a function is its auth expression, a policy rule, evaluated by the same engine as your read/create rules, before the function runs. Authorization stays declarative and analyzable, not buried in imperative code.

What is not proven: the function’s own logic (the third-party call, the transform). That is the deliberate trade, imperative power in exchange for “we prove the boundary, not the body.” Default to runtime-enforced authorization rules and proved invariant obligations, then hooks, and reach for a function only when the logic must leave the boundary.

Declare a function

Functions live in a top-level functions block, a sibling of your collection paths:

policy.json, functions block
{
  "constants": { "FUNCTION_OWNER": "replace-with-your-bounded-user-id" },
  "subs/$userId": {
    "fields": { "userId": "String!", "active": "Bool", "renewsAt": "UInt" },
    "rules": {
      "read": "@user.id == $userId",
      "create": "@user.id == @const.FUNCTION_OWNER && @newData.userId == $userId",
      "update": "@user.id == @const.FUNCTION_OWNER && @newData.userId == @data.userId && @newData.userId == $userId",
      "delete": "false"
    }
  },
  "functions": {
    "syncStripe": {
      "auth": "@user.id == @const.FUNCTION_OWNER",
      "entry": "functions/syncStripe.ts",
      "timeout": 30,
      "secrets": ["STRIPE_KEY"],
      "runtime": "worker"
    }
  }
}

Before verification, replace replace-with-your-bounded-user-id with the universal id printed by bounded whoami --json | jq -r .id. This explicit bootstrap keeps both invocation and the resulting subscription write closed to one known owner; there is no impossible-to-seed admin collection.

KeyMeaning
authRequired. The invocation rule, same language as rules. @user is the verified caller. Evaluated before the function runs; deny → 403.
entryRequired. Relative path to the source file (e.g. functions/syncStripe.ts). No absolute paths, no ...
timeoutOptional. Per-invocation wall-clock seconds, 1300 (default 30).
secretsOptional. UPPER_SNAKE_CASE names exposed as ctx.env.*. Only declared names are surfaced.
runtimeOptional. "worker" (the default and only v1 runtime).

Write a function, the ctx surface

A function is a default-exported async function receiving the caller-supplied args and an injected ctx. You never write the Worker wrapper, the deploy pipeline generates it; you only write the body.

MemberWhat it is
ctx.user{ id, address, email, claims }, the verified caller. ctx.user.id equals @user.id (the universal identity, use for ownership); ctx.user.address equals @user.address (the wallet; email/social callers normally lack it, but auth.wallets can populate it after provisioning); ctx.user.email equals @user.email. The token and the auth rule were already checked, so this is trustworthy and authorized.
ctx.boundedA pre-authed data client: .get(path), .set(path, doc), .delete(path). Writes are re-checked by rules + invariants; policy denials throw 403 and invariant violations throw 409. This is what keeps the proof intact.
ctx.envThe dev-configured secrets, narrowed to the names in functions.<name>.secrets. Nothing undeclared leaks in.
ctx.appIdThe app this function belongs to.
fetchThe standard global, call any third-party API / LLM.
functions/syncStripe.ts, fetch a third-party API, then write
export default async function (args, ctx) {
  // Only the declared owner reaches here; the `auth` rule already gated invocation.
  const { customerId, targetUserId } = args;
  if (!customerId || !targetUserId) throw new Error("customerId and targetUserId are required");

  // 1. Pull from a third-party API using a declared secret.
  const resp = await fetch(
    `https://api.stripe.com/v1/customers/${customerId}/subscriptions`,
    { headers: { Authorization: `Bearer ${ctx.env.STRIPE_KEY}` } }
  );
  if (!resp.ok) throw new Error(`Stripe error ${resp.status}`);
  const sub = (await resp.json()).data?.[0];

  // 2. Transform.
  const active = sub?.status === "active";
  const renewsAt = sub?.current_period_end ?? 0;

  // 3. Write THROUGH the boundary, re-checked by your rules + invariants.
  await ctx.bounded.set(`subs/${targetUserId}`, { userId: targetUserId, active, renewsAt });
  return { ok: true, active, renewsAt };
}

Deploy & invoke

Deploy uploads the function’s source to the R2 code registry and replaces its complete entry in your policy (validated by the same validator as bounded deploy), owner/admin only. No per-function worker is deployed: the dispatcher loads your source into a fresh isolate on the Cloudflare Worker Loader at invoke time. A new upload just replaces the registered source; nothing is redeployed.

CLI, deploy, list, invoke, logs
bounded functions deploy syncStripe \
  --entry functions/syncStripe.ts \
  --app-id <id> \
  --auth '@user.id == @const.FUNCTION_OWNER' \
  --secret STRIPE_KEY \
  --timeout 30

printf '%s' "$STRIPE_KEY" | bounded secret put STRIPE_KEY --value-stdin --app-id <id>
bounded functions list   --app-id <id>
bounded functions invoke syncStripe --app-id <id> \
  --data '{"customerId":"cus_123","targetUserId":"<user-id>"}'
bounded functions logs   syncStripe --app-id <id>

functions deploy writes the function’s complete policy entry, so repeat its auth, timeout, and secret declaration on every redeploy. A bare --secret STRIPE_KEY safely declares the name without putting its value in shell history or the process list;secret put --value-stdin supplies or rotates the value separately.

invoke attaches your session token automatically (the same token bounded data uses), so the dispatcher verifies your identity and evaluates the auth rule before running the function, then prints its JSON result, or the dispatcher error: 401 not logged in, 403 the auth rule denied you, 404 unknown function, 503 Functions not configured, or any error the function threw.

Secrets

Declare secret names in functions.<name>.secrets; supply their values with bounded secret put NAME --value-stdin --app-id <id>. The function reads them as ctx.env.K. Values are stored encrypted in Bounded’s secret store (never written into the policy, never returned by functions list or secret list) and injected into the isolate’s env at invoke time, narrowed to the declared names. An undeclared key never reaches the function.

Scheduled functions

A function can run on a schedule as well as on demand. A collection’s schedule { every, run } whose run names a function registers it to run on the cadence, fired by the Bounded heartbeat as the system principal.

schedule.run → a function
{
  "constants": {
    "FUNCTION_OWNER": "replace-with-your-bounded-user-id",
    "ROLLUP_BOT": "4ir4cbTYFGaqbAwvczrBeYi9NN6q2PSfkNodgaqznAXW"
  },
  "rollups/$day": {
    "fields": { "total": "UInt" },
    "rules": {
      "read": "true",
      "create": "@user.address == @const.ROLLUP_BOT",
      "update": "@user.address == @const.ROLLUP_BOT",
      "delete": "false"
    },
    "schedule": { "every": "1d", "run": "rollupDaily" }
  },
  "admins/$adminId": {
    "fields": { "active": "Bool" },
    "rules": {
      "read": "@user.id != null && ($adminId == @user.id || @user.id == @const.FUNCTION_OWNER)",
      "create": "@user.id == @const.FUNCTION_OWNER && $adminId == @const.FUNCTION_OWNER && @newData.active == true",
      "update": "@user.id == @const.FUNCTION_OWNER && $adminId == @const.FUNCTION_OWNER && @newData.active == true",
      "delete": "false"
    }
  },
  "functions": {
    "rollupDaily": {
      "auth": "@user.id != null && get(/admins/@user.id).active == true",
      "entry": "functions/rollupDaily.ts",
      "timeout": 120,
      "actAs": "@const.ROLLUP_BOT"
    }
  }
}

A user invocation is gated by the auth rule; a system run (the schedule) is authorized by the owner-deployed schedule in your signed policy, so the heartbeat fires it as the system principal (skipping the user-facing auth rule). Because actAs is privileged, the example’s user-facing auth requires an active record in the explicit admins/$adminId role scope. Replace the owner constant, then let that owner seed its own active admin record before manual invocation; no one else can create or modify it. The example uses actAs to pin the scheduled function to a declared service wallet, and the rollup collection authorizes only that wallet. Without actAs, a scheduled function has the all-null system principal and cannot satisfy ordinary owner rules. Either way, every write still goes through your rules + invariants via ctx.bounded. every accepts <n>s|m|h|d (1s–366d); schedules are offchain-only.

Limits

  • Runtime: Cloudflare Workers (V8 isolate). Great for API calls, transforms, and SDK writes; fast cold start; global.
  • Timeout: 1300 s wall-clock per invocation.
  • Not for: multi-minute jobs or native-binding npm, use your own server as a @bounded-sh/server client for those.