08Docs
Functions
Declarative policy can’t express “fetch third-party data, then update accordingly.” Functions are the imperative escape hatch: server-side TypeScript for API calls, AI, managed services, background work, governed builds, and scheduled jobs. Every data write still returns through the same policy boundary.
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:
Every write goes through the proven boundary
A function writes via
ctx.bounded(the data plane), so its writes are re-checked by yourrulesandinvariants. A denied rule returns403; a violated invariant returns409. Either throws inside the function, so the write cannot bypass a cap, mint value, or cross a tenant.Invocation is policy-gated
Who may call a function is its
authexpression, 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:
{
"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.
| Key | Meaning |
|---|---|
auth | Required. The invocation rule, same language as rules. @user is the verified caller. Evaluated before the function runs; deny → 403. |
entry | Required. Relative path to the source file (e.g. functions/syncStripe.ts). No absolute paths, no ... |
timeout | Optional. Per-invocation wall-clock seconds, 1–300 (default 30). |
secrets | Optional. UPPER_SNAKE_CASE names exposed as ctx.env.*. Only declared names are surfaced. |
runtime | Optional. "worker" (the default and only v1 runtime). |
queueCallable | Optional. Set true to allow this function to run as a queued background target of ctx.enqueue. A queued replay runs as the system principal (no enqueuer identity, every @user.* is null), so the human auth rule can only deny it; this opt-in is the authorization instead. A queued run that descends from public (browser/webhook) ingress is refused fail-closed even with the opt-in. |
sandbox | Optional. Opts a trusted backend job into isolated command and file operations through ctx.sandbox. |
build | Optional. Grants only the declared create, edit, fork, view, or cancel operations through ctx.build. The capability is the authority and cannot be combined with public webhook or browser ingress. |
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.
| Member | What 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.bounded | A pre-authed data client: .get(path), .set(path, doc), .setMany(items), .delete(path), and .runQuery(path, queryName, args?). Writes are re-checked by rules + invariants; policy denials throw 403 and invariant violations throw 409. This is what keeps the proof intact. |
ctx.env | The dev-configured secrets, narrowed to the names in functions.<name>.secrets. Nothing undeclared leaks in. |
ctx.auth | The authorization Bounded already performed: { enforced, rule, system }. Do not repeat the declared auth decision in function code. |
ctx.secrets | The documented async secret accessor: await ctx.secrets.get("NAME"). It reads the same declared secret map as ctx.env. |
ctx.ai | Managed chat, image, and video generation without a provider key in app code. Paid managed routes are beta; verify the exact model and accounting path before production use. |
ctx.services | Search, describe, and invoke enabled managed third-party API tools with explicit idempotency and usage limits. |
ctx.enqueue | Queue a deployed function for immediate or delayed background work. The target must declare queueCallable: true; a queued replay runs as the system principal (not the enqueuer) and is refused when it descends from public ingress. Without the opt-in the dispatcher poison-drops the job, so a normally declared function silently loses every enqueued run. |
ctx.build | Create, edit, fork, inspect, or cancel governed app builds when the policy grants that capability. |
ctx.appId | The app this function belongs to. |
fetch | The standard global for direct provider integrations. Prefer ctx.ai for models and ctx.services for Bounded-managed API tools. |
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 };
}AI, managed services, and background work
Functions are also the server-side home for model inference, generated media, managed API tools, and delayed jobs. Paid calls require stable replay provenance; production use also requires live provider and fault/recovery proof for the exact managed route.
See AI & managed services for ctx.ai, ctx.services, BYOK integrations, idempotency, and the precise proof and billing boundary.
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.
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 or with await ctx.secrets.get("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.
{
"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:
1–300s wall-clock per invocation. - Not for: multi-minute jobs or native-binding npm, use your own server as a
@bounded-sh/serverclient for those.