Skip to content

ADR-0036: Functions are sandboxed userland code that composes privileged Actions

ADR-0036: Functions are sandboxed userland code that composes privileged Actions

Status

Accepted. Builds on ADR-0035 (a Normal Action binds to a recipe or a function; business logic is TS/Python) — this fixes what a function is and how it runs. Relates to ADR-0015 (declared derivations — the read-side seed, and the reason IR-2 is not a Function), ADR-0021 (one acquire() at the seam), and ADR-0033 (the /sys/act/<name> surface a Function calls). Open engine choices are flagged in Consequences.

Date

2026-07-05

Context

ADR-0032 drew the privileged/normal line and ruled that a Normal Action’s behaviour is either a declarative recipe or a function, and that all business logic is TypeScript/Python — never Rust. The recipe rung is specified (a pure composition of privileged Actions) and its grammar is being built. The function rung was named but not pinned. Four questions block building it, and each is a decision:

  1. Is a Function code, or a declarative IR? The system already has IR-2 (the query/derivation language, ADR-0015). Making Functions also a declarative tier invites a confusing “which tier?” fork; making them code leaves IR-2’s role unclear.
  2. What runtime, and how sandboxed? The-seam §IV requires “the SDK is the only door” — but that is a confinement requirement, not a runtime ban, and it must be compatible with authors wanting an ordinary language (Node).
  3. May a Function do I/O? Business logic reaches external systems (a mailer, a payments API, an NMS). Forbidding I/O makes Functions useless; allowing ungoverned I/O makes them an unaudited egress that voids the driver/ring model.
  4. How do writes and replay work? A Normal Action is a bounded, replayable Act (ADR-0026/0013); a Function that writes or calls out must not break that.

Decision

A Function is a unit of sandboxed userland code that may reach reality only by composing privileged Actions through the typed SDK. It is authored in TypeScript (Python later), runs with no ambient authority, and computes — it never writes to a store directly.

  1. Code, not a declarative tier. A Function is code. IR-2 stays exactly where ADR-0015 put it — the query/derivation language — and is never branded a Function. A derived property that IR-2 cannot express may be function-backed (code), but that is a derivation choosing a Function, not a second kind of Function. There is one Function concept and it is code.
  2. The sandbox is confinement, not a runtime ban. Functions run in an isolated host (Node-first) with no ambient authority: no store credentials, no ambient network or filesystem. The generated SDK is the only door out (the-seam §IV). “Node is fine” and “the sandbox is real” are compatible — the boundary is what capabilities the host injects, not which language runs.
  3. The ABI is the sdkgen-projected typed SDK. The only surface a Function may call is the privileged Actions (and Query reads), named through the typed bindings sdkgen projects from the installed reality. Capabilities are types (the-seam §VIII): a privileged call takes its capability token as a typed parameter, so a Function that lacks a capability cannot compile a call that needs it — protection rings enforced at authoring time, fine decisions still taken at the seam (ADR-0021).
  4. I/O is capability-gated, recorded as a Call. A Function may do I/O — but only through capability-injected driver clients, never a raw socket. Each outbound crossing is a Call and is recorded as a fact. Call is not a rival to Functions; it is what a Function does when it reaches outward. Replay uses the recorded Call result, not a re-call, so a function-backed Act stays replayable even with I/O in the mix (event-sourcing standard). Determinism is a declared property: a Function that only reads is pure (may back derivations / materialize); one that Calls is impure (its outputs are recorded facts).
  5. A Function computes; the Act applies. A function-backed Action’s Function returns an edit-set / value; the kernel’s Act dispatch applies the writes transactionally at the seam (Palantir’s Ontology-edits model). A Function never writes to a store itself — it names privileged Actions, and ring-0 executes and authorizes them.
  6. Execution is serverless per-invocation. Functions run on-demand, versioned, per call (the model Palantir recommends for Functions). The long-lived worker is the Process substrate, not the Function runtime — serverless-vs-deployed is exactly the Function-vs-Process split.

One primitive, reused: the same Function may back an Action, a derived property, an input validation, or a Process step.

Alternatives Considered

  • A declarative “Function tier” (IR-2 as Functions): rejected — it duplicates the query/derivation language under a second name and creates a “which tier do I write?” fork. Keeping Functions = code and IR-2 = the query language is one concept each.
  • Node with ambient fs/child_process (Palantir TS v2 posture): rejected — ergonomic, but it is exactly the ambient authority the-seam §IV forbids; a meta-semantic kernel whose thesis is “the SDK is the only door” cannot accept that trust surface. Isolation + capability injection give the ergonomics without the hole.
  • Raw, ungoverned I/O in Functions (plain fetch, nothing recorded): rejected — makes Functions an unaudited egress and voids the driver/protection- ring model; the capability-gated Call path is barely more ceremony and keeps the audit + replay properties.
  • Functions write to stores directly: rejected — writes must stay governed at the one Act seam; a Function that writes bypasses authorization and provenance.

Consequences

  • Build order: after the recipe interpreter + SchemaDocument.actions (ADR-0032 slice), stand up the Function runtime — an isolated host + capability injection + sdkgen bindings for privileged Actions + the edits-apply path + Call recording. Then migrate confirm-root-cause (its RCA partition is real compute) from Rust into a TypeScript Function, deleting the Rust.
  • sdkgen grows an ABI target: it already projects kinds/actions; it now also projects the privileged-Action call surface a Function imports, with capabilities in the type signatures, and the typed input of each Function.
  • The derivation evaluator (ADR-0015) is the pure read-side seed — the same “compute over owned reality, deterministically” shape, on the read path.
  • Open (reserved, to decide when building): the sandbox engine — isolated Node container vs microVM (Firecracker/gVisor) vs WASM — and the cold-start / latency budget for serverless invocation. This ADR fixes the contract (code, no ambient authority, capability-gated I/O, computes-edits, serverless); the engine is an implementation choice that must satisfy that contract.
  • Processes are out of scope — the continuous/triggered kinetic member (Schedule/Subscribe, the long-lived worker) is its own decision; this ADR covers only the Function (logic) layer.