Functional Units as a Service


Function as a Service landed one real insight: a handler does not need a permanently running server. Package it. Invoke it on an event. Let the platform manage the runtime.

Now load a logging helper into four handlers that share one conventional Node process. The helper runs inside the same OS-level process as the handlers it assists. If that helper is compromised, it reads the database URL from process.env and sends it over a socket. It inherits the process’s authority. It does not need permission from the handler whose request it is serving. The process already has both the credential and the network.

Functional Units as a Service inverts that default. A unit starts with no ambient OS authority. It acts only through a narrow host API. Every operation through that API is something the platform can authenticate, observe, rate-limit, and revoke.

The unit never sees a database password or a general network API. It asks the host to perform a specific database operation. Raw sockets do not exist in its namespace. Neither does a way to bypass the write-ahead log or the audit trail.

Grouping handlers is the packaging consequence. Authority needs a boundary large enough to carry a real application capability, but small enough that a reviewer understands what the code is allowed to do.

A functional unit

A functional unit packages the related handlers that own one slice of application behavior. The unit is the boundary for loading, deployment, configuration, permissions, isolation, and lifecycle. Its handlers remain independent invocation targets.

One domain responsibility. Named inputs, outputs, handlers, and errors as an explicit contract. A declared set of host capabilities. No ambient credentials. No OS access. The platform owns initialization, deadlines, shutdown, and recovery.

Compare it to what sits on either side. A single cloud function still fits a narrow transformation or event reaction. A microservice fits when a capability needs its own network protocol, datastore, specialized runtime, or operational owner. A functional unit occupies the space between them: several actions belong together under one authority boundary, but they do not justify another general-purpose network service.

Invoicing under one unit

Take invoicing. It creates a draft, calculates totals, issues the invoice, and records a payment. Four Lambdas looks independent, but those four handlers share domain rules, a schema, and a release cadence. The split adds deployment manifests, IAM policies, compatibility work, and often a network call where a function call used to be.

In Kriya the capability lives in one directory:

{
  "name": "invoicing",
  "allowed-apps": ["billing-api"],
  "entrypoint": "index.ts",
  "capabilities": {
    "postgres": {
      "schemas": ["billing"],
      "mode": "read-write"
    },
    "events": {
      "publish": ["invoice-issued"],
      "subscribe": []
    }
  }
}

Registration in the initialization hook is four calls:

import { kriya } from "@iasoft/kriya";
import * as invoice from "./invoice.ts";

const handlerDeadlineMs = 5_000;

kriya.register("create-draft", invoice.createDraft, handlerDeadlineMs);
kriya.register("calculate-totals", invoice.calculateTotals, handlerDeadlineMs);
kriya.register("issue", invoice.issue, handlerDeadlineMs);
kriya.register("record-payment", invoice.recordPayment, handlerDeadlineMs);

The issue handler writes through the host API. It does not open a connection:

import { kriya } from "@iasoft/kriya";

export async function issue(input: { invoiceId: string }) {
  const result = await kriya.sql<{ id: string; status: string }>(
    "update billing.invoices set status = 'issued' where id = $1 returning id, status",
    [input.invoiceId],
  );
  return result.rows[0];
}

Callers address invoicing/issue, not the whole package. The four handlers share validation and internal functions. Those details stay function calls; they do not become remote interfaces.

Implementation status

  • Real today: directory-based units, named handler dispatch, the first three manifest fields, a frozen registration and parameterized SQL API, and confined relative TypeScript imports. Unit code has no environment variables, filesystem, raw network, or general package loading.
  • Designed, not built: the capabilities block, application-level authorization against allowed-apps, and the public and auth edges that establish application identity.

Service identity is already mechanized. Transport peers authenticate with mTLS. Kriya discards the source claimed on the wire and uses the verified certificate identity before routing or deduplication. No self-asserted transport identity reaches either decision.

Suppose record-payment later needs to call a payment processor, follow a stricter release process, and meet a different availability target. Adding a general outbound HTTP capability to all of invoicing would weaken the boundary. The proposed manifest diff makes that expansion visible in review:

     "events": {
       "publish": ["invoice-issued"],
       "subscribe": []
+    },
+    "http": {
+      "origins": ["https://api.payment-provider.example"]
     }

That permission exists for one handler. That is evidence the unit is losing a coherent authority boundary. Create a payments unit instead. Let invoicing consume a payment-recorded event. The invoicing manifest changes in the other direction:

-      "subscribe": []
+      "subscribe": ["payment-recorded"]

This is not a clean deployment-only extraction. Payments now owns the processor interaction and payment ledger. Invoicing derives its balance and status from the event. If the team cannot accept that data-ownership change, the proposed unit boundary is not real yet. When it can, the new network boundary corresponds to an actual difference in authority and operations.

When to split

“Split when concerns diverge” helps only if a team can see the divergence. The unit declaration, review history, and runtime telemetry provide signals:

  • A manifest change grants a capability used by only one handler.
  • One handler dominates CPU, memory, or invocation volume and forces unrelated code onto every loaded instance.
  • A handler repeatedly exhausts deadlines or fails under conditions its siblings do not share.
  • Releases keep changing one handler while the rest of the unit stays untouched.
  • Ownership or change controls differ across the handlers.

Kriya cannot lint the first signal yet because capability declarations remain design work. That review behavior is what the manifest is meant to enable.

These are review prompts. They are not automatic rules. A platform can lint capability diffs and report per-handler resource use. A reviewer decides whether the evidence justifies a new boundary. The goal is not the fewest units. The goal is making each boundary explainable by its responsibility, authority, and operations.

Cloudflare Workers: service bindings and capability sandboxing

Grouping functions is not new. An Azure Function App is the deployment and scaling boundary for multiple functions. The Lambdalith pattern routes several actions through one Lambda.

Cloudflare Workers is the strongest precedent. A Worker can export multiple named entrypoints whose public methods are called over RPC. A caller receives access by declaring a Service binding to a Worker and entrypoint at deployment. Cloudflare describes a binding as a permission and an API in one piece: the code receives the operation, not the underlying secret.

Dynamic Workers custom bindings make the overlap explicit. A loader gives sandboxed code a narrow object. The object’s methods add authentication, logging, and customer-specific scoping. Code that does not receive the object cannot forge it or access the resource. That is the architecture FUaaS argues for.

FUaaS does not claim novelty for the mechanism. Cloudflare built this model as a managed edge platform. The claim is that the model deserves a name and belongs in general application architecture: related named actions share a domain boundary, authority arrives through mediated capabilities, and the platform owns invocation and failure handling.

Kriya

We are applying these ideas in Kriya, an internal IASoft project. Kriya is an event-driven action platform built around TypeScript functional units, named handler registration, persistent JavaScript Realms, deadline enforcement, durable event processing, and mediated PostgreSQL access.

We need to run the system on infrastructure we control, integrate it with our own PostgreSQL and durability model, and change the runtime while testing the architecture. The Workers product keeps its control plane, account trust boundary, scheduling, and binding implementations inside Cloudflare’s platform. Those are not the constraints we are working under.

Kriya is also a research vehicle. Building the loader, capability surface, transport, and failure path reveals which guarantees are architectural and which are conveniences of one provider. We did not build it because Workers lacks the model. Workers is the strongest evidence that the model is viable.

We do not yet have a publishable measurement for warm dispatch, Realm creation, or the PostgreSQL mediation tax. Those are the relevant benchmarks. Until we have them, mediation is overhead traded for enforceable authority, durability, and observability. It is not a performance claim.

Kriya remains internal research and engineering work. Its interfaces and architecture will change. FUaaS is the architectural idea. Kriya is one implementation we use to find where the idea holds and which guarantees justify their complexity.

Persistent Realms

Kriya creates one persistent JavaScript Realm for each loaded unit. Loading and initialization do not repeat for every warm invocation. Handlers reuse in-memory caches.

Module state survives. That creates bugs a fresh-process mental model hides.

Correctness cannot depend on that state. The platform can replace a Realm after a deployment, failure, or resource decision. A Kriya deadline terminates the current execution and restores the isolate for later invocations. Code must assume that sensitive input left in a module-level variable could still exist during the next invocation. Durable state belongs behind a host capability, not in the Realm.

“The platform controls lifecycle” therefore means a Realm may outlive one invocation, but application code cannot require it to outlive any invocation. Per-call identity, authenticated caller, delivery attempt, and enqueue time arrive in a fresh, frozen invocation context. The platform enforces the registered handler deadline. This resembles a warm FaaS container, but the persistence is an explicit term of the programming contract rather than an optimization authors discover by accident.

The Realm sandbox

Kriya’s current Realm sandbox contains buggy or careless first-party unit code and prevents accidental authority escalation. Canonical import confinement and the absence of OS bindings make those guarantees stronger than a convention enforced in code review.

A V8 Realm is not a sufficient boundary for mutually distrusting tenants or deliberately hostile code. That threat model needs defense in depth: an outer process or kernel sandbox, trust-tier separation, side-channel mitigations, resource controls, and prompt runtime patching. Cloudflare’s Workers security model documents why isolates, API design, process sandboxing, and scheduling policy all matter. FUaaS names the authority boundary. It does not remove the need to choose a stronger containment boundary when the threat model demands one.

Durable invocation

Kriya accepts a service-mode invocation only after its verified peer identity, 128-bit request ID, and event body are committed to a bounded local SQLite queue. A worker holds a renewable lease while it runs. Completion stores the correlated response before network delivery. After reconnecting, a caller can resend the identical request or query the result by ID. Lease loss or shutdown terminates active JavaScript and releases unfinished work for another worker using the same local store.

At the queue or database-size bound, Kriya reports overload and does not accept the excess work. The queue is not replicated. It survives a process restart while its disk remains intact. Loss of that local storage loses accepted, unfinished work. “Durable” here covers process failure and reconnects on one host. It does not cover failover after host-storage loss.

Delivery is at-least-once. Every handler receives the stable invocation ID and delivery count. PostgreSQL calls derive their own stable call IDs. The database edge executes the statement and records its result in one transaction. A retry can return the cached result. Other side effects must use the invocation ID in the system that owns them. Deadlines cover asynchronous waits and CPU-bound JavaScript. They do not turn arbitrary side effects into exactly-once work.

Mediation is therefore more than a security tax. Because every supported SQL call crosses the database edge, the platform can pair the statement, idempotency record, and cached result atomically. A handler with a raw connection could implement the same pattern, but the platform could not require it or prevent another code path from bypassing it. The overhead buys a durability property the runtime enforces.

Costs

The grouped boundary has real costs:

  • Scaling is less granular. Scheduling can still react to each handler, but a hot action loads its cold siblings into every unit instance.
  • The blast radius is wider. A leak, corrupt cache, or wedged Realm can affect a capability rather than one action.
  • Attribution gets harder. Per-handler metering must be built into the runtime because process-level cost belongs to the whole unit.
  • Deployment is coupled. Changing one handler redeploys the package. Compatibility within the unit and rollback discipline still matter.
  • The runtime is shared. A unit cannot use a different language or resource profile for each action.
  • Mediation adds cost and removes escape hatches. Every host call can add serialization, queueing, policy checks, and durable writes. Kriya’s SQL API returns complete result sets and exposes no transaction control, cursors, streaming, connection pooling, or maintenance commands. The return is the enforceable idempotency path described above.
  • Kriya excludes the npm ecosystem. A unit can import relative TypeScript files confined to its directory and the virtual Kriya API. It cannot import an npm package. That is an implementation choice, not a requirement of FUaaS. It is a major adoption cost.
  • Owning the runtime is operational work. A self-hosted platform must patch V8 and Node, maintain the sandbox and scheduler, rotate transport identities, operate durable queues, and build the observability a managed provider supplies.

These costs are acceptable only while the handlers genuinely share authority and operations. FUaaS removes accidental distribution. It does not make coupling free.

Closing thoughts

Software architecture oscillates between two defaults: split everything into the smallest deployable handler, or gather related behavior into a conventional service. FUaaS is a third option.

Put related actions behind one explicit contract. Remove ambient credentials and sockets. Mediate every side effect. Attach a stable identity to every invocation. Make capability changes visible in review. Split only when the evidence shows the resulting unit no longer has one coherent security and operational story.

The useful boundary is not the smallest executable function. It is the smallest unit of behavior whose authority a team can still understand and defend.