Skip to content

Code Resolvers

Business logic in Python or Node.

If you have written a GraphQL server, you know this pattern. A code resolver is a function that answers for one object type. Over GraphQL, a function tagged @table is the resolver for its table's type. Provisa calls it with the query's arguments and governs what comes back.

What differs from a resolver you write by hand:

  • Governed. Visibility, masks and row rules apply to the rows your function returns, as they do for any table.
  • Batched. A @loader function gets every key of a join in one call, the DataLoader pattern.
  • Told what the read asks. The read's filters, order, limit and columns reach your function as its read context, for it to use or ignore.
  • Served everywhere. The same function answers GraphQL, SQL (over HTTP, pgwire and Arrow Flight), Cypher (over HTTP and Bolt), gRPC, REST, JSON:API and MCP.

Code resolvers are one of three ways to write business logic in Provisa. See Business Logic in Provisa.

A first resolver

from dataclasses import dataclass


@dataclass
class Region:
    code: str
    name: str


def regions() -> list[Region]:
    """Sales regions.

    @table
    """
    return [
        Region(code="emea", name="Europe, Middle East and Africa"),
        Region(code="amer", name="Americas"),
        Region(code="apac", name="Asia Pacific"),
    ]

To serve it:

  1. Add a python source and point it at the code: a PyPI package, a Git commit, an upload or a folder (see Where the code comes from).
  2. Save it. Provisa installs the code, reads it, and offers regions as a table with the columns code and name.
  3. A steward registers the table in a domain, here sales, and grants it like any other.

Then read it in GraphQL:

{
  regions {
    code
    name
  }
}

Or in SQL:

SELECT code, name
FROM sales.regions

Node works the same way, with JSDoc tags and TypeScript types; see Node.

Build

What a function becomes

Provisa reads your code's own declarations: docstrings and type hints in Python, JSDoc tags and TypeScript types in Node.

Tag Becomes
@table A table. The function returns a list or an iterable, and each element is a row.
@command A command, run on request.
@stream A live table. The function is a generator, and each row is sent as it is yielded.
@columns A column transform: a batch of rows in, one result per row out. See Column transforms.
  • A function without a tag is ignored.
  • A tag Provisa doesn't know is refused, and so is a type it can't map. The refusal lists every problem at once, so you fix them in one pass.

The row type gives the columns:

  • Text, numbers, booleans, dates and timestamps map to their column types. Lists stay lists, and nested objects become named types.
  • In Python a row type is a dataclass, a TypedDict, a NamedTuple, a Pydantic model or any class with annotated fields. A Pydantic model's rows are sent as model_dump(by_alias=True) gives them, so a field with an alias becomes a column named by its alias.
  • In TypeScript a row type is an interface, a type alias or a class.
  • A @table function can also return or yield Arrow instead of rows: a pyarrow Table, RecordBatch or RecordBatchReader in Python, or an apache-arrow Table or RecordBatch in Node. Arrow is sent as it is, its columns matched to the row type's by name, and any column the row type doesn't declare is dropped. Python casts each column to its declared type; in Node each column must already have it.
  • A decimal column holds up to 29 whole digits and 9 decimal places. A value with more is refused by name and never rounded.
  • A field's own documentation becomes its column's description when you register the table: a Pydantic Field(description=...), or a doc comment on a TypeScript property.
  • A loose row type (Any or a dict in Python, any, unknown or a Record in TypeScript) has no columns to read. Provisa calls the function once with no arguments and suggests columns from the rows that come back. When the function has a required parameter it can't be called that way, so you type the columns yourself when you register the table.

Parameters

A @table or @stream function takes up to two parameters, in this order:

  • Its inputs: one object whose fields are the table's parameters. In Python that's a dataclass, a TypedDict, a NamedTuple or a Pydantic model; in TypeScript, an interface or a type alias.
  • Its read context: a ReadContext, what the read asks of the rows (see The read context).

Either can be left out. Any other signature is refused, naming the function. This one scores one account:

from dataclasses import dataclass


@dataclass
class Risk:
    score: float
    reasons: list[str]


@dataclass
class RiskInputs:
    account_id: str


def churn_risk(inputs: RiskInputs) -> list[Risk]:
    """Churn risk of one account.

    @table
    """
    return [Risk(score=0.82, reasons=["no logins in 30 days"])]

Each field of the inputs object is a parameter. In GraphQL a parameter is a field argument:

{
  churnRisk(account_id: "A-1042") {
    score
    reasons
  }
}

In SQL it is an _nf_ condition:

SELECT score, reasons
FROM accounts.churn_risk
WHERE _nf_account_id = 'A-1042'
  • A field without a default is required. A read that leaves it out is refused before your code runs.
  • A field with a default is optional. Leave it out and your default applies.
  • A field's own documentation (a Pydantic Field(description=...), a doc comment on a TypeScript property) becomes its parameter's description.
  • A steward can still make an optional one required.
  • A parameter is an input, never a filter. A field named like a return column, such as region, is the _nf_region input. A condition on the region column reaches your function through its read context.

Commands and @columns functions keep their own parameters, each its own argument.

What reaches your function

A call gets the read's inputs and its read context, and nothing else of the request. Your function never sees who is asking. The caller's identity and roles stay with Provisa, which governs the rows your function returns.

The read context

A function that declares a ReadContext parameter is told what the read asks of its rows:

Field Holds
filters Each condition the read puts on a return column: column, op (eq, ne, gt, gte, lt, lte, in, like) and value
order_by The read's sort keys: column, descending, nulls_first
limit The most rows the read keeps, its offset added in; none when every row counts
columns The return columns the read names
keys A @loader's join keys (see Batched keys); none otherwise
from collections.abc import Iterator
from dataclasses import dataclass

from provisa_services import ReadContext


@dataclass
class OrderInputs:
    region: str = "EU"


def orders(inputs: OrderInputs, context: ReadContext) -> Iterator[Order]:
    """Orders of one region.

    @table
    """
    query = api.orders(region=inputs.region)
    for f in context.filters:
        if f.column == "status" and f.op == "eq":
            query = query.where(status=f.value)  # fetch only what the read keeps
    yield from query

In TypeScript, import type { ReadContext } from "@provisa/services".

Everything in the context is a hint. Provisa applies the whole statement to the rows your function returns, so a function can ignore any part of it, or loosen a filter, and still return the right answer. One rule holds for the limit:

  • If you apply the limit, apply every filter and the order first. Otherwise ignore it. A function that cuts to the limit before a filter would leave Provisa too few rows.

Provisa gives a limit only when the read limits the table's own rows: a read of the table alone, with no grouping, aggregate, window or DISTINCT, where every condition is one of the filters and every sort key is a column. A filter is given when it compares a return column with a value, in a condition joined to the others by AND. A condition under OR or NOT, or on an expression, isn't given, and neither is a limit after it.

The context holds for a read that goes straight to your function, and for one an engine sends to a built source's host. A read that lands your table gives an empty context, and your function returns the whole table for its inputs (see Reading a table).

Batched keys: @loader

A join can reach a code table, with the rows of another table supplying its parameters: customers joined to their churn score, say. By default Provisa calls the function once for each distinct set of inputs the joining rows give, and sends all those calls to the worker in one request.

A function tagged @loader is called once per batch instead, the pattern GraphQL's DataLoader made familiar.

  • What it gets. The batch's keys arrive in its read context, context.keys: a hash, a dict (an object in TypeScript) from a stable hash of each key to the key itself. A key is a JSON object holding the joining rows' values for the function's inputs. The inputs object itself is left at its defaults.
  • De-duplicated. Each distinct combination of values appears once.
  • Sparse. A field with no value is left out of the key, so the function's own default applies, as it does on a single call.
  • What it returns. A hash from each key it was given to that key's rows: ordinary rows of the table, none or many. Provisa gives each key's rows to every joining row that shared the key.
  • Matched by hash. The function fills a sparse key's missing fields with defaults, so the values it sees differ from the joining row's. Provisa computes the hash, and the function only echoes back the keys it was given.
  • Typed as usual. The function still declares its row type alone, a list of rows.
  • Batch size. Cap it, as DataLoader's maxBatchSize does, with @loader 100 in place of @loader. Provisa then splits the keys into batches of that size and merges the answers by hash. Without a size, one batch holds every key.
  • Refused. A @loader with no ReadContext parameter, a size that isn't a positive integer, two sizes on one function, and @loader on anything but a @table.

Use @loader when one call over many keys does better than many calls over one:

  • Scoring a model over a whole batch in one vectorized pass.
  • Batching requests to an API or a language model that accepts many inputs at once.
  • Setting up once per batch, such as loading a model or opening a connection.
from dataclasses import dataclass

from provisa_services import ReadContext


@dataclass
class ScoreInputs:
    customer_id: str | None = None
    plan: str = "basic"
    region: str = "US"


@dataclass
class Score:
    customer_id: str
    score: float


def churn_score(inputs: ScoreInputs, context: ReadContext) -> list[Score]:
    """Churn score of each customer in the batch.

    @table
    @loader
    """
    given = {"customer_id": inputs.customer_id, "plan": inputs.plan, "region": inputs.region}
    keys = context.keys or {"": given}
    rows = {h: {"plan": "basic", "region": "US", **key} for h, key in keys.items()}
    scores = MODEL.predict(features(list(rows.values())))  # one vectorized call for the batch
    out = {h: [Score(r["customer_id"], s)] for (h, r), s in zip(rows.items(), scores)}
    return out if context.keys else out[""]  # a loader call returns the hash of key to rows

Joined to a table of customers, its context holds keys such as {"9f2c...": {"customer_id": "637", "plan": "pro", "region": "EU"}, "41ab...": {"customer_id": "638"}, ...}. The second key carries only customer_id, so its plan and region take the defaults. The function returns {"9f2c...": [...], "41ab...": [...], ...}.

Column transforms: @columns

A function tagged @columns computes new columns from the columns of rows you already have, such as splitting an address into its parts, classifying a description or enriching a name. It takes a batch of rows and returns one result per row, in the same order.

Each parameter is one input column, given as a list of that batch's values in row order. The function returns a list with one object per input row, and the object's fields are the output columns. In Python:

@dataclass
class Address:
    street: str
    city: str
    country: str


def clean_address(raw_address: list[str]) -> list[Address]:
    """Each raw address of the batch, as its parts.

    @columns
    """
    ...

In TypeScript, declare it as clean_address(raw_address: string[]): Promise<Address[]> with @columns in its JSDoc.

You register it as a command, one at a time like any other. On the Commands page, pick the source, and its @columns functions are offered to pick from. The function's declaration supplies the input and output columns. Register it into a domain with the roles that may use it.

Apply it in a select list, and its output columns take the place of the call:

SELECT id, clean_address(raw_address) FROM suppliers

This answers id, street, city, country. Rename the outputs in an outer query if you need other names. The call has to be an item of the statement's own select list or of a CTE's select list (see Column transforms in CTEs below). A call in a WHERE, a JOIN, a GROUP BY or inside another expression is refused, and so is an alias on the call. Each output column is a named step of the projection's lineage.

Streaming. Column transforms stream. The statement reads its input as Arrow record batches; each batch is cut to 1,000 rows before being sent to a worker, and the worker's output columns are passed on as soon as that batch is answered. The result is never materialized whole, so memory stays flat whatever the row count, and the first rows reach the caller before the last rows are read. Order is kept.

The workers run batches in parallel when you set more than one on the source's form (workers in its mapping, see Dev mode and workers); each added worker adds throughput. A function that keeps no state between calls benefits most. A batch answered with a different number of results than rows it was given is refused, naming the function.

The function can call Provisa's services, the LLM service among them, so one model call can clean, classify or enrich a whole batch. Each call is metered and capped against the source like any of its other calls (Calling a model).

A view that applies a column transform is landed, whether or not you declare it a materialized view. Provisa builds it as it builds a materialized view, calling the function on the view's rows in batches and storing the result, and the view is read from that stored table. A statement can then filter, join or group on the output columns like any other. The outputs are computed again each time the view is refreshed, as its freshness declares. They have no schedule of their own.

Column transforms in CTEs

A column transform can also appear in a common table expression (CTE). The CTE becomes a staged step. Provisa runs it on its own first, reading its input, sending the rows to the function in 1,000-row batches and landing the enriched rows in a session temporary table. The rest of the statement then runs against that table.

WITH enriched AS (
    SELECT id, region, clean_address(raw_address) FROM suppliers
)
SELECT region, COUNT(*) AS n
FROM enriched
WHERE country = 'DE'
GROUP BY region

The WHERE country = 'DE' filter cannot push past the transform (the column is computed), so it runs after the CTE is staged. But conditions on input columns can push down into the CTE's read, and a join key from another part of the statement can restrict the CTE's rows before the function is called, so only the rows the statement actually uses are enriched.

Two pushdown rules apply:

  • A condition on an input column whose value is known before the statement runs (a literal, a bound parameter, a session variable) is pushed into the CTE's read at once.
  • When the CTE is inner-joined to another relation on its input columns, that side runs first and its keys restrict the CTE's read.

Each rule is applied only where it leaves the answer unchanged. A condition on a computed output column, a condition under OR, and an outer join do not trigger a pushdown.

Statements with no column transform are compiled and run exactly as they always were; the staging logic is entered only when a transform is called.

Writing to a table: commands

SQL and GraphQL writes to a code table go to its source's commands. A @command is bound to a table's operation by its input type:

Operation The command takes
INSERT rows: a list of the table's row type
UPDATE keys and changes: a list of the table's primary-key values, then an object with some of the row type's fields, each optional
DELETE keys

A key value is an object of the table's key columns, or the value itself when the key is one column. The table needs a primary key for an update or a delete.

from dataclasses import dataclass


@dataclass
class OrderChanges:
    status: str | None = None
    total: float | None = None


def add_orders(rows: list[Order]) -> int:
    """@command"""
    return api.create(rows)


def set_orders(keys: list[int], changes: OrderChanges) -> int:
    """@command"""
    return api.update(keys, changes)


def remove_orders(keys: list[int]) -> int:
    """@command"""
    return api.delete(keys)

Writing works the way it does for any table:

  • A write is governed as any write is: the role's write grant names the columns it may set, and its row rules are added to an update's or a delete's WHERE.
  • An INSERT's rows reach the insert command in batches.
  • An UPDATE or a DELETE first reads the rows it touches: an ordinary read of the table's key columns over the statement's own FROM and WHERE, joins included, routed as any read is. The command is then called with those keys. An update whose SET is computed per row (SET total = total * 2) is called once for each distinct set of changes.
  • The affected count is the number of rows written or keys given.
  • A GraphQL mutation on the table goes the same way.
  • The read and the call are not one transaction, so a row can change between the two.

When two commands fit one operation, tag the one it is with @insert <table>, @update <table> or @delete <table>, where <table> is the table's @table function. Two that fit with no tag are refused when you register the table, and so is a tag on a command that doesn't fit. A write to a table with no command bound for it is refused, naming the operation.

Where the code comes from

  • A package. An npm package for Node or a PyPI package for Python, at an exact version.
  • A Git repository. A full 40-character commit, with a subdirectory if the code isn't at the top.
  • An upload. A .zip, .tgz or .tar.gz of the code.
  • A folder on your machine. Choose folder zips the folder in your browser, leaving out node_modules, .venv, __pycache__ and .git, and uploads it as the source's archive. In a browser that can keep the folder (Chrome, Edge), Save zips and sends it again; another browser asks you for the folder again.
  • A directory on the server. On a single-organization install, a directory under the ones the operator allows. It is read where it is, so an edit is read by the next query. A server of many organizations refuses it.

Every origin but the server directory is pinned, so the source runs exactly what was saved.

  • Entry module. When Python code has more than one top-level module, name the one to enter by.
  • Open in VS Code. A server directory opens in your installed VS Code; a Git origin opens at its commit on vscode.dev. A package or an upload has no such link.

Installing dependencies

Code with dependencies needs a lockfile, and every install follows it exactly. Without one, a package that declares dependencies is refused, and the refusal tells you to add one.

Runtime Lockfile
Node.js package-lock.json or npm-shrinkwrap.json
Bun bun.lock or bun.lockb
Deno deno.lock
Python uv.lock (with its pyproject.toml), pylock.toml, or a requirements.txt with --hash on every line
  • Carried or given. Code that carries its own lockfile installs from it: a Git repository, upload or folder with one, or an npm package whose author published npm-shrinkwrap.json (npm never publishes package-lock.json). Most npm packages, and Python packages from PyPI, carry none, so give a lockfile beside them.
  • Why it matters. Without one, two installs of the same code can differ, and a test that passes today can fail tomorrow on a new dependency version.
  • A built image is fixed already. Once you build the source's image, its dependencies are inside it. A lockfile matters for dev mode and for each new build.
  • No install scripts. npm installs with --ignore-scripts, and Python installs wheels only, never building from a source distribution.
  • Your registries. Packages come from your organization's own npm registries, PyPI indexes and Git hosts, entered once under Admin → Package registries. The public npm registry and PyPI need no entry. See Package Registries.
  • Cached. An install is cached by the code's content hash, so the same code installs once.

Node

  • Node code runs on Node.js, Bun or Deno, whichever you pick.
  • Tags go in JSDoc, and types come from TypeScript.

The scored account in TypeScript:

export interface Risk {
  score: number;
  reasons: string[];
}

export interface RiskInputs {
  account_id: string;
}

/** Churn risk of one account.
 * @table
 */
export async function churnRisk(inputs: RiskInputs): Promise<Risk[]> {
  return [{ score: 0.82, reasons: ["no logins in 30 days"] }];
}

The batched churnScore in TypeScript:

import type { ReadContext } from "@provisa/services";

export interface ScoreInputs {
  customer_id?: string;
  plan?: string;
  region?: string;
}

export interface Score {
  customer_id: string;
  score: number;
}

/** Churn score of each customer in the batch.
 * @table
 * @loader
 */
export async function churnScore(
  { customer_id, plan = "basic", region = "US" }: ScoreInputs,
  context: ReadContext,
): Promise<Score[]> {
  const keys = context.keys ?? { "": { customer_id, plan, region } };
  const rows = Object.entries(keys).map(([h, key]) => [h, { plan: "basic", region: "US", ...key }] as const);
  const scores = await model.predict(rows.map(([, row]) => row)); // one call for the whole batch
  const out = Object.fromEntries(
    rows.map(([h, row], i) => [h, [{ customer_id: row.customer_id, score: scores[i] }]]),
  );
  return context.keys ? out : out[""]; // a loader call returns the hash of key to rows
}

Deployment

Dev mode and workers

A source runs on the Provisa server by default; this is dev mode.

  • Your code runs in a worker process of its own, one per source and version of its code in each Provisa process, started on its first call.
  • A worker that crashes fails the calls in flight, naming the source and the function. The next call starts a new one.
  • A call is cancelled when its request's deadline passes. The worker is told to stop it, and the waiting request is released at once.

Reading a table

How a read reaches your function depends on what else it reads:

  • A read of one code source's tables alone goes straight to your function. Nothing is landed. Provisa calls it with the read's inputs and its read context, and applies the whole statement to the rows it returns.
  • A read that joins other tables is the federation engine's. A dev-mode source's table is landed for it. Your function gets the read's inputs and an empty context, and returns the whole table for them, which is kept under those inputs for the table's cache TTL.
  • A built source's table in such a join is read live from its remote host, which the engine attaches as a Postgres catalog. The host gives your function the read context of the part of the statement the engine sends it. It is landed instead when its source is replicated or floored, when the table is kept for good, when it declares a landing TTL above 0, or on an engine that can't attach a host.

Either way, visibility, masks and row rules apply to the rows, as they do for any table. You pass arguments the way you pass any table parameter: _nf_ conditions in SQL, field arguments in GraphQL.

Running on a remote host

When your code needs a machine the server isn't, such as a GPU box or a host inside another network, build its image and run it there. The source then runs in built mode, and its tables, grants and readers stay the same.

Build the image.

  • Save the source in dev mode first, so its code is read and pinned.
  • Under Remote host on its form, choose the architecture (Linux x86-64 or ARM64) and where the image goes: one of your organization's container registries (Admin → Container registries), or a download.
  • Provisa assembles the image itself, on a minimal base, with no Docker daemon: the code at its pinned hash, its dependencies installed for that platform from its lockfile through your package registries, the pinned runtime, and the remote host program. Nothing in the image runs while it is built.
  • The image's tag carries the code hash and Provisa's version, and its labels name the runtime, the code hash and the exports.

Run it. The form shows a compose service for the image. Load a downloaded image first with docker load -i <file>. The container listens on 7443 and keeps its identity on its volume. You choose the host port with PROVISA_HOST_PORT; the service won't start without it.

services:
  provisa-code-rates:
    image: ghcr.io/example/provisa-code-rates:3f9a1c2e7b40-0.1.0
    restart: unless-stopped
    ports:
      - "${PROVISA_HOST_PORT:?set PROVISA_HOST_PORT to a free port on this host}:7443"
    volumes:
      - provisa-code-rates-state:/var/lib/provisa
      - provisa-code-rates-spill:/var/lib/provisa-spill
volumes:
  provisa-code-rates-state: {}
  # Size this volume to codesource.duckdb_spill_cap_mb with the platform's quota
  # (Kubernetes ephemeral-storage or an emptyDir sizeLimit, an xfs project quota, or a
  # volume driver's size option). Without a quota the disk the image spills to is not
  # bounded.
  provisa-code-rates-spill: {}

On its first start the container prints a line beginning pairing code: in its log.

Its memory and its disk. The container answers each statement with an embedded DuckDB, bounded by three settings: codesource.duckdb_memory_mb, codesource.duckdb_threads and codesource.duckdb_spill_cap_mb. Provisa gives them to the container when it pairs and with every call, and the container keeps the last ones on its state volume, so it's bounded again after a restart. A statement that needs more memory spills to the spill volume. The cap holds only when you size that volume to it with your platform's quota. A statement that fills the volume is refused, and the refusal names codesource.duckdb_spill_cap_mb. A source in dev mode runs its statements in Provisa's own DuckDB, under the same memory and threads, and spills into the data directory with no bound.

Connect it.

  • On the form, enter the host Provisa reaches the container at, the port you published, and the pairing code. On a shared Docker network or in Kubernetes, enter the container or Service name and 7443, with nothing published.
  • Provisa pairs with the container over mutual TLS. The code is used once; from then on each side accepts only the other.
  • Provisa checks that the container runs the source's code and offers its exports, then switches the source to built mode.
  • Every Provisa process keeps its own 8 connections to the host, one for each call it runs at once, and dials again if the host stops answering. While it doesn't answer, reads of the source's tables fail and name the host.

If the code changes.

  • A container that comes back running other code is refused until a steward reviews the code and re-pins it from the form.
  • To change the code itself, switch the source back to dev, save the new code, and build again.

Provisa's services

Your code can import one module that Provisa always provides on the server: provisa_services in Python, @provisa/services in Node.js, Bun and Deno. Nothing needs installing, and it isn't on any package registry. Outside Provisa it doesn't exist, so your tests mock it.

import provisa_services as ps

region = ps.settings.get("region")
rates = ps.cache.get("rates") or fetch_rates()
ps.cache.set("rates", rates, ttl=3600)
import { settings, cache } from "@provisa/services";

const region = await settings.get("region");
const rates = (await cache.get("rates")) ?? (await fetchRates());
await cache.set("rates", rates, 3600);
Call What it does
secrets.get(name) One of the source's declared secrets, read from the vault now. See Secrets.
settings.get(name, default), settings.all() The source's settings, from Settings on its form, which a steward can change without changing the code. A missing one with no default raises.
cache.get(key, default), cache.set(key, value, ttl), cache.delete(key) The source's own cache, shared by every worker of it. A value is anything JSON can hold, up to 1 MB, kept for ttl seconds or until replaced.
vectors.upsert(items, model), vectors.search(query, k, model), vectors.embed(texts, model), vectors.delete(ids) The source's own vector collection, for its own reference data. An item has an id and a text or a vector; a text is embedded by one of your organization's vector models. It can't search your governed tables, whose data reaches code only as parameters.
http.get(url), http.post(url, ...) A fetch Provisa makes for the code, through the deployment's outbound proxy and certificate authorities. The host must be on the egress allow-list, and each fetch is recorded in the audit trail.
llm.chat(model, messages), llm.embeddings(model, input), llm.base_url() (llm.baseUrl() in JavaScript) Provisa's LLM service. See Calling a model.
dq.dataset(name), dq.product(id) The data quality of a dataset (domain.table or its table id) or a data product, as the meta domain shows it to the role of the request your code is serving. For a dataset: the checker tables scanning it, each with its description, contract, assigned product and latest run (its outcome, time and failing checks), and its profiles with each one's latest run. For a product: its promises, expectations, partner checks and orphans.
log(message, level, fields), metric(name, value, kind, attributes) A log line, and a metric point named code_source.<name>, attached to the request your code is serving.
  • A call the service refuses raises a ServiceError that names the service and says why.
  • The services answer code running on the Provisa server. On a remote host every call raises.

Secrets

A key for a paid API belongs in your organization's vault, not in the code.

  • Under Secrets on the source's form, pick the organization secrets the code may read. In a config file, list them in the source's mapping:

    mapping:
      secrets: [openrouter]
    
  • A name on its own is the organization secret of that name. To read a deployment environment variable instead, give the reference: secrets: {openrouter: "${env:OPENROUTER_API_KEY}"}.

  • The list refers to the vault and never copies a value, so a config naming a secret nobody has stored yet still loads.

Your code reads a secret with secrets.get(name), or straight from $PROVISA_SERVICES/secrets/<name>:

import os, urllib.request
key = urllib.request.urlopen(f"{os.environ['PROVISA_SERVICES']}/secrets/openrouter").read().decode()
const key = await (await fetch(`${process.env.PROVISA_SERVICES}/secrets/openrouter`)).text();
  • PROVISA_SERVICES is the one environment variable your code gets. The request goes through the same egress proxy as the rest of its traffic, and the proxy answers it itself; nothing else of Provisa is reachable that way.
  • The address carries a token only this source's worker holds. It reads only the names the source lists, and stops working when the worker stops.
  • Each read resolves the secret in the vault at that moment, so a rotated secret takes effect on the next read.
  • A name the source doesn't list is refused, and so is a secret that can't be resolved. The refusal names the secret, never its value.
  • Every read is recorded in the organization's audit trail with the source, the name and the time. No value is ever written to a log.
  • The host the API is on still has to be on the egress allow-list.

Calling a model

Your code can call a language model through Provisa instead of holding a vendor key. $PROVISA_SERVICES/llm/v1 is an OpenAI-compatible endpoint, so any OpenAI SDK works with its base URL pointed there. The address is the credential, so the key the SDK asks for can be any text.

import os
from openai import OpenAI

client = OpenAI(base_url=f"{os.environ['PROVISA_SERVICES']}/llm/v1", api_key="unused")
reply = client.chat.completions.create(
    model="provisa:sql_generation",
    messages=[{"role": "user", "content": "Summarise this account's last quarter."}],
)
import OpenAI from "openai";

const client = new OpenAI({ baseURL: `${process.env.PROVISA_SERVICES}/llm/v1`, apiKey: "unused" });
const reply = await client.chat.completions.create({
  model: "provisa:sql_generation",
  messages: [{ role: "user", content: "Summarise this account's last quarter." }],
});
  • By role. Name a model as provisa:<role> and you get whichever model your organization assigns that role under Admin → AI Models; it follows that choice when an administrator changes it. You can also name a model directly when a role is assigned it.
  • Embeddings. $PROVISA_SERVICES/llm/v1/embeddings takes any of your organization's vector models.
  • Any other model is refused.
  • The call goes out through your organization's own vendors, endpoints and keys, and what it sends passes through Provisa's redaction rules first.
  • Streaming isn't offered. Send stream: false, which is the default.

Safeguards

The sandbox

  • Your code runs in its own worker process, in a sandbox. It can read its own installed code and nothing else on disk.
  • It can reach only the hosts on the deployment's egress allow-list, through a proxy Provisa runs, and never Provisa's own ports.
  • A host that can't provide the sandbox refuses to start the worker and names what is missing; code never runs unconfined. On Windows, code sources are refused.
  • On a remote host, the container's network policy is the code's egress boundary. The server's egress allow-list applies to dev mode.

Refusals

Each of these is refused, before your code runs where it can:

  • An unknown tag or a type Provisa can't map, every problem of a function at once.
  • A read that leaves out a required parameter.
  • A @table or @stream function whose parameters aren't (inputs, context), and a @loader declaration it can't take.
  • A write to a code table with no command bound for it, an update or a delete of one with no primary key, and two commands that fit one operation with no tag between them.
  • A package with dependencies and no lockfile.
  • A server directory outside the allowed ones, or on a server of many organizations.
  • A remote host running other code than the source is pinned to.
  • A secret the source doesn't list, a model that isn't allowed, a service call the service won't answer.

Metering and caps

  • Every model call is recorded for the source, with the model, its tokens and its cost at the model's entry in Polly model prices, and written to the organization's audit trail.
  • To limit what a source spends, set LLM monthly cap on its form, in USD. In a config file it goes in the mapping:

    mapping:
      llm: {cap_usd: 20}
    
  • A source with a cap can call only models that have a price there.

  • Once its spending for the month (UTC) reaches the cap, its calls are refused until the month turns. Calls already under way when the cap is reached still finish, so the month can end slightly over the cap.

Governance on results

  • Your function's rows are checked against the table's columns.
  • Visibility, masks and row rules then apply to them for each reader, as they do for any table.
  • Your code sees a governed table's data only as the parameters a read gives it.

Tracing

  • Each call to your code is traced as part of the request that triggered it: the function, its kind, how many arguments it got (never their values), the rows and batches it returned, its duration and the worker's CPU.
  • Each service call is traced the same way.
  • At the normal trace detail these facts are on the request's span; at debug detail each call is a span of its own.
  • If your code uses OpenTelemetry, its spans join the same trace.