Functions — /v1/functions
App-defined server functions that always execute as the invoking user. There is no service role, no admin client, and no app identity to escalate to: a function is trusted code, not widened authority. What invocation adds is provenance — the gateway knows an operation came from inside a named function, and schema access rules can require exactly that.
This page is the maintained execution model and callable contract. Historical design notes were removed once the MVP shipped.
Writing a function#
Every module in your project's functions/ directory is one invokable function; the
filename is its name (and its via identity). Export a default async function taking
(ctx, args):
// functions/submitMove.ts
import type { FunctionContext } from '@tallpond/sdk'
export default async (ctx: FunctionContext, args: { resourceId: string; move: unknown }) => {
// Validate the move server-side, then write it. The insert runs as the
// calling user and passes the moves table's `via: 'submitMove'` gate.
const { rows } = await ctx.db.query({
scope: { kind: 'resource', resourceId: args.resourceId },
table: 'moves',
op: 'insert',
values: { move: args.move },
})
return rows[0]
}
Function names must be identifiers ([a-zA-Z][a-zA-Z0-9_]*). Nested directories are
reserved for later scoping (functions/resource/, functions/app/).
The ctx object#
| Field | What it is |
|---|---|
ctx.userId | The invoking user — every operation runs and bills as them. |
ctx.resourceId | The resource the invoke targeted, or null. |
ctx.fn / ctx.invocationId | This function's name and the invocation's unique id. |
ctx.db.query(request) | One /v1/db/query operation (same request shape as database.md). |
ctx.db.batch(operations) | Atomic multi-op write via /v1/db/batch. |
ctx.gateway(path, body?, opts?) | Generic call to any gateway route under the invocation's identity — the escape hatch until typed ctx.ai / ctx.files helpers land. POST with a JSON body by default; pass { method: 'GET' } for read routes. |
This table is the entire ctx surface. Annotate your handler with
FunctionContext from @tallpond/sdk (a type-only import — nothing is bundled) and the
compiler will hold your code to exactly this contract. There are no higher-level helpers
yet: no per-scope table handles, no membership or rate-limit utilities, no now()/id
generators — build those on top of ctx.db and ctx.gateway, or check the
roadmap. ctx.db speaks the raw /v1/db wire shape
(explicit scope/table/op — see database.md), not the SDK's
client-side query builder.
Read-shaped gateway routes (e.g. resolving a static resource) are plain GETs — reach them by passing the method, with query params in the path:
const feed = await ctx.gateway('/v1/resources/static?type=public_feed&slug=explore', undefined, {
method: 'GET',
})
Every ctx call round-trips the gateway carrying a signed invocation token the
gateway minted for this invoke. Authorization, metering, and via checks all happen
gateway-side per operation. The token is accepted only while the invocation is actively
running, so a function must await its ctx calls; saving the token for detached work does
not extend its authority. The runtime receives no platform dispatch or management secret.
Outbound fetch to arbitrary hosts is not part of the v0 contract.
Deploying#
Both CLI deploy paths handle functions: tallpond dev sends them to the selected
non-production environment, while confirmed tallpond deploy sends them to production.
When a functions/ directory exists, the CLI bundles every function (with its imports
inlined) into a single worker script and uploads it with a manifest of names. Production
scripts run privately inside a Workers for Platforms dispatch namespace; only the gateway
can select and invoke them, and no public workers.dev endpoint is enabled. See
cli.md.
Deploys are validated both ways against your schema's via rules:
- Uploading functions fails (
409 missing_functions) if the active schema references avianame the bundle doesn't provide. - Deploying a schema fails the same way if a functions bundle exists that lacks a referenced name.
- Deploying a schema with
viarules before any functions exist is allowed — the gated verbs simply deny everything until the functions arrive.
Invoking#
// SDK
const result = await tallpond.functions.invoke('submitMove', {
resourceId: room.id,
args: { move: { to: 'e4' } },
})
POST /v1/functions/:name
{ "args": { ... }, "resourceId": "..." } // both optional
Requires a normal app session (see authentication.md).
resourceId, when present, must name a resource of this app's deployment; it is carried
into the invocation context for the function to read. Per-operation access inside the
function resolves against your live membership on every call — invoking a function never
grants access you don't already have.
Response: 200 { "result": <the function's return value> }.
| Error | Status | Meaning |
|---|---|---|
insufficient_balance / spend_cap_exceeded | 402 | The escrow hold could not be placed. |
not_found | 404 | No such function (or resource). |
invalid_request | 400 | Bad name/body — including a function trying to invoke another function (not in v0). |
function_error | 502 | The function threw, or an operation inside it was denied by policy (the inner denial is in detail). |
function_limit | 504 | The function exceeded its wall-clock limit (~30s). |
function_unavailable | 503 | The runtime couldn't be reached; nothing was charged. |
Provenance gating (via)#
Any table access verb may require that the operation arrive from inside a named function (see schema-and-deploy.md for the DSL):
r.owns('moves', (t) => {
t.jsonb('move').notNull()
t.access({
read: 'reader',
// Only writable through submitMove — direct client inserts are denied.
create: { role: 'writer', via: 'submitMove' },
})
})
The check is conjunctive: the caller must hold the role and the op must be executing
inside that function (via also takes an array of names). The caller remains the actor,
payer, and attributed author — via narrows how, never who. Clients cannot forge
provenance: via is only ever set from the gateway-minted invocation token.
Cost#
The invoke itself charges the caller a flat base plus wall-clock time (op type
function_invoke), escrowed before dispatch and captured at actual duration. Everything
the function does — DB ops, AI calls, file ops — meters through its own existing path
with the caller as payer, exactly as if called directly. A failed function still bills
its compute; an unreachable runtime bills nothing.
Limits (v0)#
- ~30s wall clock per invocation; the runtime's CPU limits apply beneath that.
- One level of invocation: functions cannot invoke functions.
- Bundled script ≤ 1MB.
- No entry gate: anyone with a session may invoke any function; the operations inside it enforce access (a role-restricted function's first op denies).