Skip to content

Apps: reference ​

The contract between an app and the platform: what a module is, what its manifest declares, the envelopes its exports take and answer, the limits of a call, how workflows and rules call an action through an install, how asynchronous actions complete through webhooks and hand the applicant to the vendor's page, the life of a version and an install, the routes and the error codes. For a walk through writing one, start with Apps: authoring; for the functions a module imports, see Apps: host interface.

Packages and services:

  • @aletheia-dev/app-sdk: what a TypeScript author imports. defineManifest and defineApp, the aletheia-app build command (and @aletheia-dev/app-sdk/build), and the testing helpers (@aletheia-dev/app-sdk/testing). Published on npm; the generated App SDK reference lists every export.
  • @aletheia-dev/app-host: the host. It inspects a module, validates manifests and JSON Schemas, and runs one export with the host functions bound to one tenant's call: it fills the install's secrets into the module's requests, builds their authentication and signatures, holds the install's cached values for the call, checks a model request and its answer, and redacts what leaves the call. The runner and the SDK's testing helpers both use it. Published on npm.
  • @actanomos/aletheia-core (app.ts, app-http.ts, app-secret-placeholders.ts, app-cache.ts, app-ai.ts, app-runner.ts, app-install.ts, app-install-hosts.ts, app-install-change.ts, app-admin.ts, app-callback.ts, app-webhooks.ts, app-handoff.ts, app-output-paths.ts): the contract itself as zod schemas, which both sides validate against, and the plain functions the API, the worker, the console and the SDK share to work out an install's hosts, secrets and documents, to read a vendor's webhook, to check a hand-off's page and to list the paths of an action's output.
  • apps/app-runner: the service that runs calls for the API and the worker, and asks the models of an action's ai requests through the Vercel AI SDK (packages/app-ai).
  • catalog/<name>: the platform apps, one package each (App catalogue). catalog/http-api, the generic connector for any vendor's HTTP API, is an ordinary app: a module built with the SDK, run by the same host under the same contract and limits, which turns an install's configuration and a step's input into one http_request.
  • tests/apps/test-vendor: a vendor double the end-to-end smoke uploads as a tenant app, to cover synchronous calls, asynchronous calls completed by a signed webhook, documents, the hosts and secrets an install's config brings, the hand-off to a page on one of those hosts, a vendor's session token its account action keeps in the install's cache (ctx.cache), and a model server its ask action asks through the host (ctx.ai). It is built like a catalogue app but never indexed or published with a release.

The module ​

An app version is one WebAssembly module, named by the hex SHA-256 of its bytes and kept in object storage (apps/platform/<sha256>.wasm for a platform app, apps/<tenantId>/<sha256>.wasm for a tenant's own). A module the platform runs:

  • is valid WebAssembly of at most 32 MiB;
  • imports only the host functions of extism:host/user it uses (http_request, hmac, hmac_verify, document_read, log, cache and ai), Extism's own kernel (extism:host/env) and WASI preview 1 (wasi_snapshot_preview1) without its socket (sock_*) and path (path_*) functions;
  • defines its own memory: a module that imports its memory is refused, and the platform writes the manifest's memoryMiB into the module as its memory maximum at upload (the build already does), so the hash names the capped module;
  • exports manifest and invoke, and handle_webhook and handoff when its manifest says it handles them.
ExportCalled byRuns with
manifestthe API at upload, the build, the runner's inspectionnothing: no tenant, no network, a 5 s deadline
invokethe worker (steps and rules) and the API (POST /app-installs/:install/test)the install the call goes through: config, secrets, hosts, cached values, documents, and the runner's models
handle_webhookthe API, on POST /webhooks/app-installs/:installIdthe install's config, secrets, hosts and cached values, a 15 s deadline
handoffthe API, on GET /collection-submissions/:id/handoffthe install's config, secrets, hosts and cached values, a 15 s deadline

Every call runs on an instance of the module that no other call has used, in a worker thread of its own, discarded when the export returns, so memory, globals and Extism variables never outlive a call. The runner may prepare that instance before the call comes (APP_RUNNER_WARM_PLUGINS), and it still serves that one call only. A module's start code (a start function, WASI's _initialize) may therefore run before its call comes, with no tenant and every host function refusing, for 5 s at most; a module whose start code fails or runs longer gets no instance made ahead until a call of it answers on one made for it. What a module keeps between its install's calls goes into the install's cached values, which the platform holds (Cached values of an install). manifest runs with every host function refusing.

Manifest fields ​

The manifest is the JSON the manifest export answers (AppManifest in @actanomos/aletheia-core); with the SDK, defineManifest declares it in zod and the build serialises it. The upload and the build read it strictly (AppManifestStrict): a field this platform does not know, at any depth outside the JSON Schemas, refuses the module, named by its path (manifest: actions.screen.retry.jitter: unknown field, then a hint that the app may be built with a newer SDK than the platform runs). The API describes each version with its manifest unfolded (AppVersionSummary: the catalogue's latest, GET /apps/:name and its versions, an install's version), its title among the rest, and each action as an AppActionSummary, with its schemas, its mappable and its outputFromInput.

FieldType and limitsPurpose
interface2The version of this contract, which the SDK writes. The upload and the build refuse any other: rebuild such a module with an SDK for this platform.
name^[a-z0-9][a-z0-9-]{0,99}$, not approvals, health, metering or uploadsThe app's name in the catalogue and its routes (/apps/:name), and the name the console proposes for its first install. Installs have names of their own (Life of an install).
version1.2.0 or 1.2.0-rc.1, 50 characters at mostOne immutable module per name and version, per publisher.
title1 to 60 characters, optionalHow the console names the app where its name would not do, e.g. ChatGPT (OpenAI API).
description500 characters at most, optionalShown in the console's catalogue.
category60 characters at most, optionalWhere the catalogue files the app, e.g. Sanctions & PEP.
vendor100 characters at most, optionalWho runs the service the app calls; omitted by apps that call no vendor.
docsUrlan https URL, optionalThe vendor's documentation, linked from the app's page.
pricingNote280 characters at most, optionalHow the vendor charges, in one line.
capabilitiesup to 20 tags, default []Free-form tags such as sanctions.screen and document.verify.
configSchemaa JSON SchemaThe tenant's non-secret configuration; an install's config is validated against it.
secretsup to 20 { name, description?, requiredWhen? }, names ^[a-zA-Z][a-zA-Z0-9_]{0,63}$, each onceThe secrets an install stores; each is required, or only when one of its requiredWhen conditions holds (below).
actionsat least one, names ^[a-zA-Z][a-zA-Z0-9_]{0,99}$The callable actions (below).
hostsup to 20 hosts, each once: api.vendor.example, *.vendor.example, or localhost and IPv4 for development, with :port when it is not the default (api.vendor.example:8443; never :443, :80 or a port browsers refuse)The hosts every install of the app calls, each at its port; an install approves them, with those its config adds.
hostsFromConfigup to 5 JSON Pointers into the config, each once, default []The config fields that hold URLs: an install also calls the exact host of each URL its config gives there, its port included (below). The field's schema has no default.
secretsFromConfiga JSON Pointer into the config, optionalA config array of variables { key, value?, secret?, stepPlaces? }: the key of each secret variable is a secret of the install (below).
needs[] or ['documents']documents: calls may read the tenant's clean documents; with documentsFromConfig, only while the install's config turns them on.
documentsFromConfiga JSON Pointer into the config, only with needs: ['documents'], optionalThe install's documents switch: calls through an install read documents only while its config holds true there (installReadsDocuments), and get no document token otherwise (below). The field's schema has no default.
memoryMiB16 to 256, default 64The module's memory maximum, written into it at upload.
handles{ webhook, webhookExternalId?, webhookExternalIdFromConfig?, handoff? }Which optional exports the module provides and how its webhooks find their session (below). The SDK fills it in.

Per action:

FieldLimitsPurpose
description500 characters at most, optionalShown with the action in the console and the step pickers.
inputa JSON SchemaValidated before a call from a workflow or a rule reaches the module.
outputa JSON SchemaA synchronous call's output is validated against it before the run sees it.
timeoutMs100 to 120 000, default 15 000One attempt, the whole call included, cut to the time its step or rule has left: a rule's app calls share the evaluation's minute.
retry{ maxAttempts: 1-5, backoffMs: 0-60000 }Attempts including the first; the delay is backoffMs times the attempt number. Optional: one attempt.
idempotentdefault falseRepeating the call has no further effect, so the platform may retry without an idempotency key.
async{ callbackTimeoutSeconds: 1-604800 }The action answers pending and completes through a webhook; the run waits up to this long (seven days), or the shorter wait a pending answer asks for.
mappable1 to 20 JSON Pointers, optionalThe input fields run data may fill (below); absent, any.
outputFromInput1 to 10, optionalThe output fields whose shape a call's input gives, for the paths a step's or a rule's editor offers (below).

handles:

  • webhook: the module exports handle_webhook (the SDK sets it when defineApp has a handleWebhook).
  • webhookExternalId: where the API reads a webhook's external id when the URL has no ?externalId=: { json: '/data/applicantId' } (a JSON Pointer into a JSON body), { header: 'x-job-id' } (a lower-case header name) or { query: 'job' }.
  • webhookExternalIdFromConfig: for an app whose vendors put the id in different places, such as http-api, the config field that holds that source instead, in the same shape (a header's name in any case), e.g. /webhook/externalId. The API reads it from the install's config as saved, so the field's schema has no default; only with webhook: true, never with webhookExternalId. An install whose config holds no source there receives no webhooks: its webhookUrl is null, and a webhook without ?externalId= answers 400 missing_external_id.
  • handoff: the module exports handoff, which sends the applicant of a pending session to the vendor's hosted page (Hand-off to the applicant); absent is false. The SDK sets it when defineApp has a handoff.

JSON Schema at the boundary ​

The host validates with Ajv in draft 2020-12 with the common formats, strictly: an unknown keyword or format makes the schema invalid, and the upload refuses a manifest whose schemas do not compile. It never fills defaults: a module applies its own when it parses its input and configuration. The SDK serialises configSchema and each action's input with z.toJSONSchema(schema, { io: 'input' }), so a field with a default is optional, and each output with { io: 'output' }; what JSON Schema cannot express (a transform, a refinement) becomes {} and is checked by the module's zod parse only.

Output paths ​

A later step reads a call's output under the step's output key (sanctions.hit), and an app rule the value at its resultPath. The paths a step's editor, a rule's result picker and POST /workflow-definitions/context-paths offer there are the output schema's properties, nested as far as the schema says, 6 levels deep and 300 paths at most (an array or a record is one path), and the fields a call's input shapes, which an action declares in outputFromInput:

  • { "kind": "schema", "output": "/output", "input": "/output/schema" }: the output at output is what the JSON Schema the input holds at input describes, such as the schema a step writes for a model's answer (summary.output.verdict);
  • { "kind": "keys", "output": "/answers", "input": "/questions", "key": "id" }: the output at output has a member for each item of the input's array at input, named by the item's key field and shaped as the output schema's additionalProperties there, such as a decision's answer to each question it asks (ai.answers.fraud.probability).

output and input are JSON Pointers, and key a field name; a name a dot path cannot hold (one with a dot, or a name every object inherits) is left out. The catalogue's model apps declare both, jev, which only decides, the second (AI models). appOutputPaths in @actanomos/aletheia-core works the paths out from an action and a step's input, as the API and the console do. A run reads whatever the action answered, offered or not.

Envelopes ​

Every export takes one JSON document and answers one. call names the call: its id, the tenant, when the deployment has a public webhook address the URL the webhooks of the install the call goes through are sent to, and the call's secret tag.

call.secretTag is 16 random bytes in base64url (22 characters) that the host draws for every call of an export, each attempt included, whatever the caller sent. A secret placeholder fills only when it carries the tag, {{secret:NAME:TAG}}, and the host never sends the tag to a vendor: a request with it anywhere the host fills no placeholder is refused. Text a module did not write with the tag (its input, a webhook's body, a vendor's answer) therefore cannot name a secret (Secrets). The module reads the install's config with each {{secret:NAME}} of its text written as the call's placeholder, so config text it sends fills. With the SDK the tag stays inside ctx.secret(name).

invoke:

json
{
  "call": {
    "id": "0e6d…",
    "tenantId": "3f2a…",
    "callbackUrl": "https://api.example/webhooks/app-installs/9b2d…",
    "secretTag": "q8Zl3vX0aT9_bR2-cY7wKd"
  },
  "action": "screen",
  "idempotencyKey": "8c41…",
  "config": { "baseUrl": "https://api.acme-screening.example", "threshold": 0.9 },
  "input": { "name": "Evil Corp" }
}

answers { "status": "ok", "output": … }, { "status": "pending", "externalId": "…" } (an asynchronous action; 200 characters at most) or { "status": "error", "message": "…", "retryable": true } (retryable optional: a failure another attempt may fix, and with it retryAfterMs, how long the vendor asked to wait, in ms and an hour at most). A pending answer may add callbackTimeoutSeconds (1 to 604800): the run then waits that long for the webhook, never longer than the action declares (pendingWaitSeconds in @actanomos/aletheia-core).

handle_webhook takes { call, config, request }, where request is the vendor's request as the API received it: { method, headers, query, bodyBase64 } with lower-cased header names. It answers { "status": "event", "event": { externalId, eventId, status, output?, error? } } with status completed, failed or pending; { "status": "ignored" }; { "status": "rejected", "message": "…" } (a bad signature or payload); or an error answer. The event's externalId is the session's id as the module read it, and must be the one the API found the session by; a module reads it, like its eventId and status, where the vendor's signature covers them (below).

handoff takes { call, config, externalId, returnUrl }: the external id of the pending session the applicant continues, and the address the vendor sends them back to when they are done there (http or https, 2,000 characters at most). It answers the vendor's hosted page for that session and when its link stops working (ISO 8601 with an offset):

json
{
  "status": "ok",
  "handoff": {
    "url": "https://verify.vendor.example/s/7d1c…?return=https%3A%2F%2Fcollect.example%2Fflow%2F…",
    "expiresAt": "2026-10-09T12:30:00Z"
  }
}

{ "status": "ok", "handoff": null } when the vendor needs nothing more from the applicant, or an error answer. url is http or https and 4,000 characters at most; the API sends the applicant there only when it is https on one of the install's hosts (below).

An answer must be JSON and at most 1 MiB; anything else fails the call as bad_output. Before it is parsed, the host redacts it with the call's list: each of the call's secret values becomes [secret:NAME], each value the host derived from one during the call (a Basic credential, a signed token, a signature, a model request's credential header) [auth:TYPE], each cached value the call read or set [cache:NAME], and the call's tag [tag] (Secrets).

With the SDK the envelopes stay out of sight: a handler gets the parsed input and a context, and pending(externalId) (or pending(externalId, { callbackTimeoutSeconds }), which throws for a wait that is not a whole number of seconds from 1 to 604800), rejected(message), ignored, the page or null a handoff handler returns and a thrown AppError (an HttpNetworkError or an AiError among them) become the answers above.

Limits of a call ​

WhatLimit
Deadlinethe action's timeoutMs for invoke, cut to the time its step or rule has left; 15 s for handle_webhook and handoff; 5 s for manifest
Memorythe manifest's memoryMiB
HTTP requests25 per call (a digest auth's two or three included), 10 s each by default and 30 s at most
HTTP response8 MiB
Document reads10 per call, document bodies included
Log lines200 per call, 2,000 characters each
Cached values64 set per call; a value of 8 characters to 8 KiB, kept a day at most; per install, APP_CACHE_ENTRIES values (16) and APP_CACHE_TOTAL_BYTES bytes (64 KiB) (below)
Model requests20 ai requests per call, refused ones included, which send 10 HTTP requests to model vendors at most; each 1 s to 2 min, within the call's deadline (ai)
Model request1 MiB as asked and as answered, a vendor's response 8 MiB; an output schema 32 KiB, 10 levels deep and 500 properties; a decision's state 256 KiB, 32 questions, 2 to 255 options, 2 to 10 levels, and a question's texts 4,000 characters each; 100 messages; 32,768 output tokens
Answer1 MiB
Module32 MiB
Calls in flightAPP_RUNNER_CONCURRENCY per runner process (8), APP_RUNNER_TENANT_CONCURRENCY per tenant (4)

A call past its deadline is ended by terminating the thread it runs in (timeout); the deadline also covers making the call's instance when none was prepared for it. A runner at either concurrency limit refuses the call with busy, which the worker retries after a moment. What a call used comes back with its answer, and with a timeout, a trap or an answer outside the contract, and is kept on the invocation record (usage): its HTTP requests; reusedConnections, the requests sent on a connection the tenant's earlier requests left open; its document reads and log lines; and, for a call that asked models, ai, the HTTP requests sent to their vendors, which httpRequests does not count, and the input and output tokens the vendors counted, a count a vendor did not give as none: { "requests": 1, "inputTokens": 120, "outputTokens": 16 }. A model's tokens are spent whatever the call answered, and its record keeps them. Over time, a tenant's calls are capped by its quotas and its installs' budgets (Quotas and metering).

Documents of a call ​

A call reads the tenant's documents (document_read, or a document as a request body or a multipart part) only with a document token, which the API or the worker mints when it hands the call to the runner, and only while the install's calls read documents (installReadsDocuments in @actanomos/aletheia-core): its version declares needs: ['documents'] and, when it puts them behind the install's switch (documentsFromConfig), the install's config holds true there. Without a token the host refuses those reads. The token binds the tenant, the call and, for a call that has a subject (a run's call_app step, an app rule), that subject: such a call reads only its subject's clean documents, and another subject's answers as one that does not exist. A test call (POST /app-installs/:install/test) has no subject, so it gets a token only for a caller who also holds documents:read; a webhook's or a hand-off's module gets none.

Turning the switch on or off is a save like any other: it applies with the save, never waits for an approval, and is audited as app.install.documents.enabled or .disabled, with the hosts the documents may go to. An automatic upgrade to a version that reads documents under the install's config waits for an approval (needsAdded: ['documents']); one behind a switch the config leaves off adds nothing.

Cached values of an install ​

A module keeps values between its install's calls with the cache host function (ctx.cache in the SDK; Host interface), such as a vendor's token or session, so that each call does not fetch one again: get reads a value, set keeps one for ttlSeconds (a day at most, a longer time cut to a day), delete removes one.

  • Where they live. The platform keeps them in its database, per install, sealed like the install's secrets (AES-256-GCM with SECRET_STORE_KEY, bound to the tenant, the install and the name). Every call through the install gets the live ones, a run's, a test call's, a webhook's and a hand-off's alike: the worker or the API opens them for the call, and stores what the call changed once the runner answers, whatever the module answered (a token fetched before an error is kept), but nothing when the runner failed. The runner keeps none: they come with the call and leave with its answer. Another install, even of the same app, never sees them. On a deployment without SECRET_STORE_KEY no call keeps any, and the module's cache operations are refused. A webhook or a hand-off runs on the version that started its session, with the install's values as they are.
  • The install's, not a subject's. Every call of the install reads the same values, whatever its run subject, while a run's step or rule reads only its subject's documents (Documents of a call). A value a module kept from one subject's call reaches the calls for every other subject, their vendor requests and their answers, and the redaction below hides the value and its usual encodings, not text the module derives from it. Keep subject data (a document's text, personal data) out of the cache: it is for what the vendor gives the install, such as a token or a session.
  • Never shown. Every value a call reads or sets joins the call's redactions: its answer, its log lines, its errors and its records show [cache:NAME] where the value would be. GET /app-installs/:install/cache lists each live value's name, when it expiresAt, when a call last wrote it (updatedAt) and its sizeBytes, never the value, with the deployment's budget (limits).
  • Calls at once. What a call sets, its own later operations see at once and the install's other calls once it has ended. Of two calls that set one name, the one stored last stays. A call's delete removes a value only while it is still the one that call read: a call that drops a token its vendor refused never removes the fresh one another call stored meanwhile.
  • Limits. A value is 8 characters (a shorter one is too common to redact) to 8 KiB as UTF-8. A call sets at most 64. An install keeps APP_CACHE_ENTRIES values (16 by default, 1 to 64) and APP_CACHE_TOTAL_BYTES bytes together (64 KiB by default, 8 KiB to 512 KiB): past either, the values written longest ago go. Set both alike for the API and the worker.
  • What clears them. Storing or revoking a secret of the install, a save or an approved change that changes its config or moves it to another version, an automatic upgrade (saving the same config again, renaming the install or switching it on or off keeps them), removing the install, and clearing them by hand (DELETE /app-installs/:install/cache, apps:write). A clear also holds for the calls already running: one that read the values before it stores nothing of what it changed, so a token minted with a replaced secret is never stored. A call reads the values with its install and before its secrets, so a clear between those reads counts too: the call then starts with no values. A retry of a step's call sees what the attempt before it stored, and nothing once the install's cache was cleared since the call's first attempt. Audited as app.cache.cleared with why (reason: secret, config, version, or manual for a clear by hand) and how many live values went (entries): a clear by hand whatever it removed, the others when a live value went. A value that no longer opens with the deployment's key is left out of the call and removed.

Quotas and metering ​

Every call of an action is metered, and weighed before it goes out against two layers of limits:

  • The operator's quotas of a tenant: at most so many calls (calls), or so many HTTP requests those calls make (http_requests), per UTC day or month, over all the tenant's installs. The operator sets them with pnpm ops tenants quota (Operations); the tenant reads them and never changes them.
  • An install's budgets, set by the tenant's administrators with its prices (PUT /app-installs/:install/limits): at most so many calls, HTTP requests or spend per UTC day or month, the spend at the price per call the tenant enters for each action, in the install's currency. An action without a price costs nothing. Spend is kept in the currency it was charged in, a removed install's too, so an install's currency changes only from a UTC month it has spent nothing in.

What counts. Every attempt the module ran on, or may have, is a call of its install: a run's steps and app rules (each retry an attempt of its own, a fallback's under the fallback's install) and an install's test calls. It counts the HTTP requests the runner reports for it (after a timeout, a trap or an answer outside the contract, those its error reports; none when the runner never answered) and, when it succeeded or started its asynchronous work, its action's price. The requests ai sends to model vendors are not among those HTTP requests, and no quota or budget counts tokens: a call that asks a model counts once, as any call, its record keeps what it asked (usage.ai, above), and the vendor bills the tokens (AI models). An attempt the platform refused before the runner (invalid input, a disabled install, a blocked module, an open circuit, a quota) or that ran nothing (a busy runner, a call no runner took, a module the runner could not load) counts for nothing. The worker meters an attempt in the same transaction as its invocation record, and on its own should the record fail. The vendor's webhooks the install's module handled and the applicants it handed off are counted apart and never limited, so a vendor's retries or a replayed webhook cannot use up a tenant's calls. Use is kept per install, UTC day and month, a removed install's under the name its last call used: GET /apps/metering?month=YYYY-MM reports a month, with the quotas and the budgets against what their current periods used, each budget reached once it refuses calls (a spend budget once what is left is below the price of one of the install's actions); spend, its currency and spend budgets go only to apps:write, who reads the prices from the install's limits.

What a refusal does. Before an attempt goes out, the worker reads the tenant's quotas, the install's budgets and prices and, when any limit applies, what their periods used: a call is refused when it would be one call too many, when its period's HTTP requests are used up, or when its price would pass a spend budget; the operator's quotas are weighed first. The check is soft: calls in flight on other workers count once they are recorded, so a quota can be passed by as many calls as run at once. A refused call fails at once with 429 app_quota_exceeded, with the limit in its details (scope tenant or install, metric, period, max, used, currency, periodStart, resetsAt, install): it is recorded nowhere, metered nowhere, never retried, never counted against the install's circuit and never handed to a fallback. A step fails without a retry, an app rule ends in error, a test call answers 429. The first refusal of each quota and period, and of each install's budget and period, appends app.quota.exceeded against the install, which raises the webhook event of that name and the app_quota_exceeded notification to holders of apps:write.

Input mapping from workflows and rules ​

A call_app step and an app rule name an install in their app field (an install's own name, not the catalogue app's) and an action, and build the action's input the same way: the literal input object first, then inputMapping (input field -> dot path into the run context, or the rule data), which overrides literals of the same name. Mapped paths that do not resolve leave the literal, or nothing, in place. Field names containing dots set nested paths:

json
{
  "input": { "applicant": { "country": "GB" } },
  "inputMapping": {
    "documentId": "submission.identityDocument.fileId",
    "applicant.fullName": "submission.uboName"
  }
}

produces { documentId, applicant: { country: 'GB', fullName } }. Intermediate objects are created as needed and literal objects are merged into, never changed. An intermediate that already holds a non-object is left alone and the value lands under the literal key "applicant.fullName", where the action's input schema reports it.

An action whose input is a template it fills names the fields run data may fill (mappable, JSON Pointers into the input), so run data never writes the template. A mapping key sets the field its dot path names (variables.customerId is /variables/customerId), and may set a declared field or one under it. A key that sets another field is an unmappable_input error when the workflow or rule is validated (Admin console), and every call fails with a ValidationError before the module runs. The literal input may set any field: the author of the step or rule writes it. The catalogue's http-api declares mappable: ['/variables']: run data reaches its request through variables only, "variables.customerId": "subject.customerId" filling the <<customerId>> its path uses (Variables).

Input is data, whatever it holds. The host fills only the secret placeholders that carry the call's secret tag (Envelopes), which the input cannot know: it is drawn after the input is fixed and changes with every call. An applicant named {{secret:apiKey}} reaches the vendor under that name, from a step, a rule or a test call alike, and never pulls the install's secret into the request.

The step stores the output under its outputKey; an asynchronous action also writes <outputKey>ExternalId. The app rule compares the output's resultPath with failWhen (Rules) and needs a synchronous action: it cannot wait for a webhook, so an asynchronous one is refused before anything is sent. Dry runs and backtests do not call apps: their app rules come back skipped.

A step and a rule may name a fallback: another install, with its own action, input and inputMapping, built the same way from the same run data and checked against its own action's mappable (Fallbacks). The step stores whichever answered under its outputKey; a rule's fallback reads its output at its own resultPath and failWhen, the rule's when it gives none.

Retries, idempotency, circuits and records ​

  • Idempotency key. Every call from a step carries a key derived from the run and the step (and the round, when a request for information repeats the step); a rule's call adds the rule's key. The key is the same across retries and reaches the module as idempotencyKey, so a vendor that takes one never sees the same logical call twice.
  • The action's retries. The worker retries a failed attempt by the action's retry when the call is safe to repeat: the action is idempotent or the call carries a key, which calls from steps and rules always do. Only failures another attempt may fix are retried: an error answer with retryable: true, a timeout, or a runner that was busy, unreachable or failing on its own, or that no runner took. A retry waits the policy's backoff or the wait the error asked for (retryAfterMs, ten minutes at most), whichever is longer. A timeout, a runner that did not answer once the call was on its way and the runner's own failure may have let the module's requests out, so after one only an idempotent action is sent again; for any other action the failure is final. A call no runner took (the connection was refused or never made) ran nothing and is sent again. An attempt that could not end before the step's own deadline is not started.
  • A vendor request that failed in transit. http_request answers { error, network } when sending failed after the host handed the request to the network (http_request): network is not_sent when the connection was never made (the name did not resolve, the connection was refused or timed out, the TLS handshake failed), so the vendor cannot have the request, and unknown when it may have arrived (the connection dropped, or no answer came in time). The SDK's ctx.http, and so fetch, throws it as HttpNetworkError: an AppError with network, not retryable, so an app that does not catch it fails the call for good, as for any other host error. The app decides otherwise by catching it and throwing an AppError with retryable: true: always safe after not_sent, and after unknown only for a request the vendor treats as idempotent (a GET, or one that carries the call's idempotencyKey). A request the host refuses (a host the install did not approve, a secret the call does not carry, a placeholder where the host fills none, a field the host does not know, a limit reached) throws a plain AppError. The catalogue's http-api decides this way for its own requests (Responses and retries).
  • The step's retries. Around that, a call_app step runs as a Temporal activity on the app task queue (TEMPORAL_APP_TASK_QUEUE, aletheia-apps), with a deadline of two and a half minutes and the step's retry (3 attempts, 2 s apart and doubling, by default); after a retryable error whose wait (retryAfterMs) is longer than the step's policy waits, the step's next attempt waits that long, ten minutes at most. Invalid input, an input mapping the action refuses (mappable), an install that is missing (unknown_install), disabled or not configured, a blocked module, apps being off, a quota (app_quota_exceeded), an error the app did not call retryable, and a timeout, an unreachable runner or the runner's own failure for an action that is not idempotent end the step at once.
  • The order of an attempt. Each attempt is admitted first: the install enabled, its module not blocked (read at every attempt), the action, the input mapping and the input valid, and the secrets its config requires opened; a refusal here is recorded and ends the call. Then the install's circuit, then the quotas and budgets (Quotas and metering), then, when the circuit's trial is due, its claim: a refusal there is recorded nowhere, so an invalid or refused call never uses up a trial. Then the install's cached values are read and the call goes to the runner, and what it changed of them is stored as soon as the runner answers, so a retry starts from what the attempt before it kept; then the record with the metering, then the circuit's move, then, when the call failed, its fallback.
  • Circuits. Each install has one circuit, shared by every worker: its row in the database, read with the install on every call and on each retry, and moved in the database's time, so every worker's outages count toward it and it opens for all of them at once. After the vendor's outages in a row reach its threshold it opens, and calls through that install fail at once with 503 app_circuit_open, without reaching the runner, for its open time; then one trial call goes out, which exactly one worker claims, while the others' calls are still refused; the trial's success closes the circuit and its failure opens it again, and a trial that never reports back frees the slot after another open time. A success on a circuit that counted no outage writes nothing. Another install of the same app keeps its own circuit, and so does an install created later under a removed one's name. Only outages count: an error answer the app calls retryable (as an app does for a vendor's 5xx, a 429 or a failed connection) and a timeout, retried or not, unless the step's time cut the attempt short (an attempt run for less than its action's time that timed out, or failed retryably in its last two seconds). An error the app calls final (a misconfigured step's 404), a module that traps or answers outside its contract, a busy or unreachable runner and a refusal of the platform (a blocked module, invalid input, a quota) say nothing about the vendor, so one caller's mistakes cannot open the circuit for everyone. The threshold is five outages and the open time 30 seconds, unless the deployment sets others (APP_CIRCUIT_FAILURE_THRESHOLD, 1 to 100, and APP_CIRCUIT_OPEN_SECONDS, 5 to 3600) or the install its own (PUT /app-installs/:install/circuit); an administrator closes an open circuit by hand (POST /app-installs/:install/circuit/reset). Both apply from the next call, on every worker. Opening and closing are audited (app.circuit.opened, app.circuit.closed, as the install), opening raises the app.circuit.opened webhook event and the app_degraded notification, and GET /apps/health lists open and half-open circuits with their threshold.
  • Fallbacks. A call_app step or an app rule may name a fallback: another install of the tenant, with an action of the version it runs (synchronous for a rule) and its own input, reviewed and published with the definition, so no call reaches another vendor that nobody approved. The fallback takes the call when the install the step or rule names refused it with an open circuit, or after the vendor's outage outlasted the action's retries (an error the app called retryable, or a timeout of an idempotent action, in an attempt the step's time did not cut short); never after a timeout, an unreachable runner or the runner's own failure of an action that is not idempotent, whose requests may have reached the vendor, nor after a refusal of the platform (an unknown, disabled or blocked install, invalid input, a quota) or an error the app calls final. It runs only while an attempt through it can still end before the step's deadline, under the same idempotency key, through its own install as any call does (its admission, circuit, quotas, retries, secrets and hosts); its own fallback is not followed. An asynchronous action it starts is the fallback's session: its callback, webhook URL and timeout are its install's. When the fallback fails too, the step or rule ends with the first install's error, which decides whether the step is retried, with the fallback's in details.fallback (install, action, code, message). aletheia.app.fallback.count counts the calls a fallback took.
  • Records. Every attempt that reaches an install is recorded in app_invocations: the install (its id and its name at the time), the version it ran on (which names the app), the action, the run and subject, the idempotency key, the attempt number, the input and output, the status (success, pending, error, timeout), the error, the duration and what it used at the runner (usage, its models' requests and tokens included); a fallback's attempt also names the install it stood in for (fallbackOf, its id and the name the call gave it). Records outlive their install. Secrets and cached values are never recorded. Callers without runs:context read the input, the output and the error text as [redacted].

Asynchronous actions and webhooks ​

An action declared async answers pending with the vendor's external id. The worker then:

  1. records the invocation as pending and an app_callbacks row (the install, the version, the action, the external id, the run and the step), which is what maps the vendor's id back to the run. The install's webhooks find a session by that id alone, so an id another session of the install already holds (another step's, run's or round's) fails the step at once with validation_error, naming the install and the action, the id only in its details: each call needs an id of its own;
  2. stores <outputKey>ExternalId in the run context and sets the run to waiting_callback;
  3. waits for the appCallback signal for the install and the external id, up to the action's callbackTimeoutSeconds or the shorter wait its pending answer asked for, so two calls of one run through different installs whose vendors choose the same id never take each other's callback. A completed event's output becomes the step's output. A failed event fails the run (AppCallbackFailed); no event in time marks the session timed_out and fails the run (CallbackTimeout). The run's error names the install and the action only: the vendor's reply can quote the subject, so it stays on the callback record.

The vendor sends its webhook to the install's own URL, built on the deployment's public webhook address (WEBHOOK_PUBLIC_URL, the API's own address without it): POST /webhooks/app-installs/:installId. An install's view carries it as webhookUrl (null when it receives none: its version handles no webhooks, or leaves where they name their session to the install's config, which says nothing yet), and every call through the install hands it to the module as call.callbackUrl. The id pins the tenant and the install, so a webhook only ever reaches the sessions of the install that called the vendor: two installs of one app, like two tenants, never share a URL, whatever external ids their modules choose. A removed install's URL answers 404 unknown_install, and an install created again under the same name has a new id and a new URL. A call_app step that calls an asynchronous action through an install that receives no webhooks would wait until its timeout: validation warns install_no_webhook.

The route is public (no token), takes bodies up to 256 KiB and hands the module the raw bytes, so a signature is checked over exactly what the vendor sent. The API:

  1. finds the install by the id in the URL (404 unknown_install without one);
  2. reads the external id from ?externalId=, or else where the version the install runs declares it (handles.webhookExternalId) or, for a version that leaves it to each install (handles.webhookExternalIdFromConfig), where the install's config says: text of 1 to 200 characters, or an integer, read as its decimal text. No module code runs before the session is found; without an id the answer is 400 missing_external_id;
  3. finds the install's session for that id, and the version the session started on;
  4. runs that version's handle_webhook with the install's configuration, secrets and hosts, and refuses an event that names another session than the one it found (401 webhook_rejected, nothing recorded). For a module that reads the id from what its vendor signed, a query or a header that points a signed body at another session completes none. A module that reads the id from the query or an unsigned header, as an http-api install's webhook.externalId may say only under token, which signs nothing, answers the value the API read: a replayed request pointed at another pending session of the install passes this check (Event ids and what a signature covers);
  5. records the event and, unless it is a repeat, completes the session and signals the run.
SituationAnswerEffect
The module answered ignored202 { received: false, ignored: true }nothing
The install received the event id before200 { received: false, duplicate: true }nothing
The event is pending202 { received: true, accepted: true, callbackId, status }event recorded, the session stays pending
The session was already settled or timed out200 { received: false, stale: true, callbackId, status }event recorded, the session unchanged
Otherwise202 { received: true, callbackId, status }session completed or failed, run signalled
The module answered rejected401 webhook_rejectednothing
The event names another session than the request found401 webhook_rejectednothing
The module failed or the runner did502 app_webhook_failednothing
No session of the install has the external id404nothing
No install has the id in the URL404 unknown_installnothing
The module is blocked by the operator409 app_blocked, without the block's reason or the hashnothing

Completion is first-wins at the database, so a replay racing the timeout cannot change the outcome, and only the delivery whose completion won signals the run: one that lost to another delivery, the timeout or a forced removal answers stale. Every completion is audited as app.callback.received with the install, its app, the external id, the status and the event id, never the payload. GET /app-callbacks lists the sessions; the install's page in the console and the run's page show them.

A run cancelled through the platform fails its sessions still waiting (run_cancelled), so they no longer hold their install. The worker keeps sessions and their webhook events RETENTION_APP_CALLBACK_DAYS (90 by default) after they can last settle, then deletes them (Operations); a webhook for a deleted session answers 404 like one for an unknown id.

Event ids and what a signature covers ​

An install receives each event id once: a repeat answers duplicate. A module reads the vendor's event id, and the event's status, from the JSON body, which every scheme but a shared token signs, or from a header the scheme signs, such as Standard Webhooks' webhook-id; never from the query, which no scheme signs, or from a header the scheme does not sign. Whoever replays a signed body can change the rest of the request: an unsigned event id would make the replay a new event, an unsigned status a failure a success (webhookSourceSigned and webhookEventSourceProblem in @actanomos/aletheia-core say what each scheme covers). A vendor that signs no event id gets the default one (webhookEventIdRequest): HMAC-SHA256, under the fixed key aletheia-webhook-event-id, of the external id, a NUL byte and the body, in hex, computed through the host's hmac (with the SDK, webhookEventId(ctx, externalId, body)). A re-delivery of one body for one session is then one event, while the same body sent for two sessions, { "status": "approved" } say, is two.

Read the external id where the signature covers it too. One read from the query or an unsigned header can be changed the same way, to name another pending session of the install, and the API's refusal of an event that names another session cannot tell: the API read that same value to find the session. Under a scheme that signs no time, a captured request could be pointed at another session at any time; under the timestamped ones, within their tolerance. The catalogue's http-api closes this for the schemes that sign: under hmac, hmac-timestamped and standard-webhooks it takes the external id only from the body or a header the scheme signs, and the platform refuses to save an install that says otherwise; under token, which signs nothing, the token authenticates the sender only (The install's webhook). An app of a tenant's own keeps the responsibility: it reads the id from what the vendor signs whenever the vendor puts it there, and its configuration allows no other source. A vendor whose signed webhook names the session only in its URL leaves nothing signed to read the id from: an app for it reads the id from the URL, and a captured request can then be pointed at another pending session of the install as above.

Hand-off to the applicant ​

Some checks need the applicant at the vendor after the submit: a liveness check, or documents the vendor's own capture collects. Most identity vendors offer a hosted page per session for this. An app whose manifest declares handles.handoff exports handoff (Envelopes): given the external id of a pending session and the address to send the applicant back to, it answers the vendor's page for that session, or null once the vendor needs nothing more from the applicant.

While a submission's run waits for such a session, GET /collection-submissions/:id/status answers handoff: true, and the collection terminal asks GET /collection-submissions/:id/handoff for the page and sends the applicant there (Embedding). The route is for the submission's applicant only, with the token of their link. The API:

  1. takes the newest pending session of the submission's run (of its 20 newest) whose version (the one that started it, which also handles its webhook) declares handles.handoff and is not blocked, and whose install still exists and is enabled. The status's handoff: true means this step finds one; it does not look at the install's secrets;
  2. runs that version's handoff with the install's configuration, the secrets it requires and its approved hosts, the session's external id, and as returnUrl the applicant's own page of the submission in the terminal: the tenant's collection.flowUrl, else COLLECTION_FLOW_URL, then /flow/<id>, without a token. The address never comes from the request;
  3. sends the applicant only to an https page on one of the install's approved hosts at its port (the default for a host approved without one), named rather than an IP address, without credentials or a port browsers refuse (handoffUrlProblem in @actanomos/aletheia-core), so an app cannot send applicants anywhere else. With APP_RUNNER_ALLOW_PRIVATE_NETWORKS http, any port and IP addresses pass, on the install's hostnames still;
  4. answers the page, audited as collection.submission.handoff_started with its host (and its port when it is not the default), never its URL, which can carry the vendor's session token.
SituationAnswer
The module answered a page the API sends applicants to200 { handoff: { url, expiresAt } }
The module answered null: the vendor needs nothing more, the run waits for its webhook200 { handoff: null }
The submission is not sent, its run waits for no session whose version hands off and is not blocked, the session's install is disabled or removed, or apps are off409 handoff_unavailable
The runner or the module failed, a secret the install requires has no value, or the page is not https on one of the install's hosts502 handoff_failed
The caller is not the submission's applicant403

The applicant asks again for a link that expired. The vendor's webhook completes the session like any other (above), and the run goes on.

Life of a version ​

StatusMeaning
uploadedA tenant's module, checked and stored; seen by that tenant's administrators only, not installable.
publishedInstallable: every tenant sees a platform version, the publishing tenant sees its own.
withdrawnNot newly installable; installs that run it keep running it.
  • Upload (POST /apps/uploads, the module as application/wasm). The API checks the bytes, asks the runner for the manifest (the manifest export, run with nothing), writes the memory maximum into the module unless it carries the manifest's already (an SDK build is stored byte for byte, so a signature over the built file holds) and stores it under its hash. A tenant has one version per name and version number, and none under the name of a platform app (409 conflict). A module or an app the operator blocked is refused before anything is stored (409 app_blocked). Audited as app.uploaded.
  • Provenance (PUT /apps/:name/versions/:id/provenance, the Sigstore bundle as JSON, 64 KiB at most: 413 beyond). A signature over the module or an in-toto attestation naming its SHA-256, keyless or signed with a key the operator pins (Apps: authoring). The API verifies it against the stored module and keeps only what it proves: the signer, the source, the transparency log index and when it was signed. Each version summary's provenance adds whether the operator's policy trusts the signer as it is now (trusted, trustedSigner; null for an unsigned version, and a platform version from a release carries the release's commit, release, trusted). A bundle attaches to, or replaces the one of, an uploaded version with no publish request or operator review open: 409 provenance_locked otherwise (details.reason published, withdrawn, approval or review). Audited as app.provenance.recorded. Under APP_SIGNATURES=required a tenant's version publishes, is approved, installed or upgraded to only once a signer the policy trusts has signed it: 409 provenance_required (details.reason unsigned or untrusted) before any request or review opens (Operations).
  • Publish (POST /apps/:name/versions/:id/publish). Publishes at once, or, when the tenant's approval settings require approvals for apps (approvals.required, with requiredFor unset or including app), opens a request that another administrator approves (POST …/versions/:id/approve) or rejects (…/reject, the version stays uploaded and can be requested again). With four-eyes on, the requester cannot approve their own request (403 four_eyes). Only an uploaded version publishes, and approving a request needs the version still uploaded (409 conflict); a module the operator blocked is refused (409 app_blocked, the request stays open). Audited as app.published, app.approval.requested and app.approval.decided.
  • The operator's review. A tenant under review (appReview required on GET /tenants/me: APP_REVIEW_DEFAULT when the tenant was created, then as the operator sets it) publishes none of its own versions directly. Once its own approval is given, when it requires one, the publish opens the operator's review instead: 202 with the pending review, the version stays uploaded, audited as app.review.requested. The operator approves it, which publishes it (app.published with via: review), or rejects it with a comment, after which the tenant can publish it again. The decision arrives as app.review.decided, a webhook event and a notification to holders of apps:publish. Each version summary carries the latest review of one of the tenant's own versions (pending, approved, rejected or withdrawn, with the operator's comment), null when it was never reviewed; while one is pending, publish and approve answer 409 conflict. The operator turning a tenant's review off closes its pending reviews as withdrawn.
  • Withdraw (DELETE /apps/:name/versions/:id). Closes the version's open publish request and its pending review as withdrawn. Audited as app.withdrawn, with the approvalId and reviewId it closed. The operator can withdraw a tenant's version too, with a reason (app.withdrawn with via: operator).
  • Platform versions come from the release's catalogue, published by the API at start-up, and are withdrawn when a release no longer carries them (App catalogue). The operator can also publish, withdraw and restore one outside a release (Operations). platformSource says where a platform version comes from (release or operator; null for a tenant's version). No tenant can change one.
  • Order. Versions are listed by number, highest first (SemVer precedence: 1.10.0 above 1.9.0, a release above its prereleases; compareAppVersions in @actanomos/aletheia-core), not by when they were uploaded. An app's latest is its highest published release, a prerelease only when no release is published, and its highest version whatever the status while none is published. An install's updateAvailable says whether its publisher has published a higher version of the app, counting a prerelease only for an install that runs one.
  • Blocks. The operator blocks a module by its hash (that build, whoever uploaded it) or every version of a publisher's app, with a reason (Operations). From the block's commit, with no restart, the module's uploads, publishes and approvals, installs and upgrades onto it, the approval of an install change onto it, its calls (each attempt) and test calls answer 409 app_blocked with details: { sha256, reason }, vendor webhooks 409 app_blocked without them (nothing has checked who sent the request yet), a hand-off finds none of its sessions (409 handoff_unavailable), and the runner can no longer fetch it. Each install it stops gets app.module.blocked in its tenant's trail, which raises the webhook event of that name and the app_blocked notification, and GET /apps/health lists it under blocked with the reason and blockedAt.

Hosts and secrets of an install ​

An install's configuration decides which hosts its calls may reach and which secrets they need. The API works both out whenever an install is saved, with the functions of app-install-hosts.ts in @actanomos/aletheia-core (installHosts, installSecrets, requiredSecrets), which the console uses for its live preview and the SDK's loadApp for its hosts.

Hosts ​

An install's hosts are the manifest's hosts, then the exact host of each URL its config holds at a hostsFromConfig pointer, unless one of the manifest's hosts already covers it (its hostname and its port alike); a pointer whose value is absent or null adds none. A host is the URL's hostname, lower case, and :port after it when the URL names a port that is not the scheme's default. With hostsFromConfig: ['/baseUrl'] and a config { "baseUrl": "https://eu.api.vendor.example/v2" }, the install calls eu.api.vendor.example; with https://eu.api.vendor.example:8443/v2, it calls eu.api.vendor.example:8443. An install calls 20 hosts at most. A config URL must:

  • use https and carry no user name or password;
  • name no port browsers refuse, the Fetch standard's bad ports such as 22, 25 or 6667 (must not use port 22, which browsers refuse (a bad port in the Fetch standard)), nor the other scheme's default (:80 on https); the scheme's own default written out is the URL without it;
  • name one host, not a wildcard, an IP address, localhost or a .localhost name;
  • name none of the deployment's own hosts, at any port: those of WEBHOOK_PUBLIC_URL, ZITADEL_ISSUER, COLLECTION_FLOW_URL, SIGNUP_CONSOLE_URL, STORAGE_CORS_ORIGINS, STORAGE_ENDPOINT and STORAGE_PUBLIC_ENDPOINT.

With APP_RUNNER_ALLOW_PRIVATE_NETWORKS (development stacks only; the API and the runner read the same value) http, localhost and IPv4 addresses pass; an IPv6 address never does. A URL that breaks a rule fails the save with 400 validation_error and the issue at its pointer: details.issues [{ "path": "/baseUrl", "message": "must use https" }].

The runner reaches an approved host at its own port only: api.vendor.example at the scheme's default, api.vendor.example:8443 at 8443. On a stack that allows private networks it admits any port of an approved hostname (Host interface).

A field hostsFromConfig names may not have a default in the config schema: the platform reads the config as saved, so a URL the module would fill in itself is a host nobody approved. The upload and the build refuse such a manifest. Steps and rules never choose a host: whatever their input, the runner reaches the install's approved hosts only.

Secrets ​

The names a value can be stored under are the manifest's secrets and the config's secret variables (below). A call needs, and carries, only the required ones (requiredSecrets):

  • each secret of the manifest without requiredWhen, or with a condition that holds. A condition { "config": "/mode", "in": ["live"] } holds when the config's value at the pointer is one of in (compared strictly); without in, when the config has a value there (not absent, null or empty text). Any one condition is enough;
  • each secret variable the config uses as <<key>>, or whose stepPlaces lists a place;
  • each secret a {{secret:NAME}} in the config's text names. A name that neither the manifest nor a secret variable declares fails the save with 400 validation_error, the issue at the text's pointer. The module reads such text with the call's placeholder in its place (Envelopes), which the host fills when the module sends it.

Conditions read the config as saved, without the schema's defaults: a condition on a field with a default sees a value only when the install sets one. An install's view lists requiredSecrets, storedSecrets and missingSecrets (required and not stored). The worker, the webhook route and the test route open and send the required secrets and no others, and refuse the call with install_not_configured while one is missing.

Secret variables ​

With secretsFromConfig: '/variables', the config array at that pointer holds variables { key, value?, secret?, stepPlaces? }, an API client's environment. The key of each one with secret: true is a secret of the install: its value is stored like any secret (POST /app-installs/:install/secrets/:key), never in the config, and required once the config uses it. A secret variable's key follows the rules of a secret's name; one that does not, a value in the config and more than 20 secret variables fail the save with 400 validation_error, at /variables/<i>/key, /variables/<i>/value or the array.

stepPlaces grants the steps that call the install the places of a request where they may use the secret: some of path, query, headers and body (SECRET_STEP_PLACES), none when absent. A secret variable that lists any is required, since a step may use it whatever the config holds. The platform checks the list, and the module decides what each place means: the catalogue's http-api lets a step use the secret in its path, its query values, its header values or its body (Secrets in a step). A stepPlaces that is not a list of those places, or that lists any on a variable that is not secret, fails the save with 400 validation_error at /variables/<i>/stepPlaces. Granting places needs no approval of its own: the save applies at once unless it adds a host (Saves that wait for an approval), and its audit event records the places of each secret variable that has some (secretPlaces, names and places only).

Life of an install ​

An install is one use of an app by a tenant, under a name of its own: vendor-a and vendor-b can be the same app installed twice, once per vendor account, each with its own version, configuration, hosts, secrets, webhook URL, circuit, limits, records and metering. call_app steps and app rules name an install, never the catalogue app, by its name or a former name it keeps (renames). A tenant has at most 50 installs, of every app together. A policy pack declares every install its steps and rules call, with the app it expects: its import reports the ones the tenant lacks and refuses one that runs another app (Example policies).

  • Install (POST /app-installs with name, versionId, enabled, config, approveHosts, comment and autoUpgrade; enabled defaults to true, config and approveHosts to empty, autoUpgrade to off). name follows the rules of an app's name (^[a-z0-9][a-z0-9-]{0,99}$, not approvals, health, metering or uploads) and is unique in the tenant among its installs' names and the former names they keep (409 install_name_taken, with details.aliasOf naming the install a former name belongs to); a rename changes it. The console proposes the app's name for an app's first install. autoUpgrade: 'compatible' opts an install of a platform app in to automatic upgrades (400 validation_error for a tenant's own app). The version picks the app: it must be published and its module not blocked; config is validated against its configSchema (400 validation_error with the issues), then its URLs and secret references (above). Every host the install will call is new to it, so approveHosts lists them all, the version's and the config's, each spelled as the install's hosts spell it, its port included (409 hosts_not_approved otherwise, details.added naming them). At 50 installs the answer is 409 install_limit_reached. A new install has no secrets yet, so enabled: true is refused with 409 install_not_configured when its config requires any: such an install is created disabled, given its secrets, then enabled. The answer is 201 with the install, or 202 when the save waits for an approval (below). Under APP_SIGNATURES=required a tenant's version is installed only with provenance the operator's policy trusts (409 provenance_required). Audited as app.installed (with autoUpgrade), or app.install.change.requested when it waits.
  • Configure (PUT /app-installs/:install with the same body less name). The version must be a published version of the install's own app (400 validation_error for another app's); the app never changes, and the name only through a rename. autoUpgrade left out keeps the install's. The hosts the save adds to the install's, the new version's or a new URL's, are listed in approveHosts (409 hosts_not_approved otherwise); hosts the save drops simply go. The answer, like every install's view, lists the install's approved hosts, requiredSecrets, storedSecrets and missingSecrets, says whether its publisher has published a higher version of the app (updateAvailable, by number), and carries the webhookUrl, the change waiting for an approval (pendingChange, null without one), the former names it answers to (aliases) and, for an install that upgrades automatically, where it moves next (upgrade). enabled: true is refused with 409 install_not_configured while a required secret is missing. The answer is 200, or 202 when the save waits for an approval. Audited as app.configured, or app.install.change.requested. A save, or an approved change, after which the install's calls read the tenant's documents, or no longer do, is also audited as app.install.documents.enabled or .disabled (Documents of a call). One that moves the install to another version or changes its config clears its cached values.
  • Secrets (POST /app-installs/:install/secrets/:secret with { value }) are sealed in the tenant's secret store with the deployment's SECRET_STORE_KEY (AES-256-GCM, bound to the tenant, the install's id and the secret's name) and never returned or logged; storing again replaces the value. They belong to the install: another install of the same app has its own, and a sealed value copied to it does not open. A secret is one the installed version declares under the install's config (its secrets and secret variables), one a published version of the app declares, one the version its last automatic upgrade left declares, or one the change waiting for an approval declares, so a secret that a newer version or a held config adds is stored first and the install stays enabled throughout; any other name is a 400. An automatic upgrade waiting for the secret moves, or opens its change, before the answer. DELETE on the same path removes it, after which the calls that require it fail until a value is stored again. Storing or removing one clears the install's cached values, so nothing a call kept from the former value is read again. Audited as app.secret.stored and app.secret.revoked, without the value. Without the key both answer 503 secret_store_unavailable, and storing a secret while its install is being removed answers 404 unknown_install.
  • Upgrade is the same PUT with another versionId of the app, or the platform's own move of an install that upgrades automatically (below). Under APP_SIGNATURES=required a tenant's version is moved to only with provenance the operator's policy trusts (409 provenance_required). Sessions that are still pending finish on the version that started them.
  • Rename (POST /app-installs/:install/rename) gives the install another name and keeps the former one as an alias (below); its id, secrets, cached values and webhook URL stay.
  • Disable (enabled: false) stops new calls: the steps and rules that name the install fail, and validation warns (install_disabled). Switching an install off never waits for an approval, and a save that only switches an enabled install off (the same version and config) is never refused: it keeps the approved hosts without checking the version, its module or the config's URLs again, so a module the operator blocked or a URL the deployment now refuses does not keep it on.
  • Circuit (PUT /app-installs/:install/circuit with { failureThreshold, openSeconds }, both given, null for the deployment's) sets the install's own threshold (1 to 100 outages) and open time (5 to 3600 seconds), and POST /app-installs/:install/circuit/reset closes its circuit and clears its count, both at once for every worker (Circuits). The install's view carries its circuit: its state, the outages counted, since when, its own settings and the deployment's. Audited as app.circuit.configured (the settings before and after) and app.circuit.reset (the state and count it cleared).
  • Limits (GET and PUT /app-installs/:install/limits with { currency, prices, budgets }) are the install's prices, in micros of its currency per call of each action, and its budgets, at most six and one per metric and period (Quotas and metering). A save replaces them all; a price or a spend budget needs a currency (ISO 4217), and a price names an action of the version the install runs (400 validation_error otherwise). A currency other than the one this UTC month's spend through the install is in is refused (409 conflict). Reading and saving them need apps:write. Audited as app.limits.updated.
  • Remove (DELETE /app-installs/:install) deletes the install with its secrets, its cached values, its circuit, its limits and its changes, and keeps its invocations, callbacks, webhook events and metering, which record the install's id and its name at the time (the callbacks and webhook events until the retention deletes them). The app stays in the catalogue and its other installs are untouched. The name and the former names it kept are free again: an install created under one later is a new one, with no health, circuit, cached values or webhook events of the old one. While runs wait on the install's callbacks it answers 409 install_in_use with details.pendingCallbacks; ?force=true fails those sessions with install_removed, which ends each run's wait at once. Audited as app.uninstalled. Definitions that still name the install then fail with unknown_install; GET /app-installs/:install/usage lists the published ones.
  • Test (POST /app-installs/:install/test with { action, input }) calls one action of the installed version once with the install's configuration, required secrets, approved hosts and cached values, enabled or not: no retries, no circuit, nothing recorded but the app.tested audit event; what it changes of the cached values is kept, as for any call. It is metered as a call and weighed against the quotas and budgets first (429 app_quota_exceeded). A change waiting for an approval plays no part. It answers success with the output, pending with the external id (whose webhook will find no run), error or timeout, with the duration and what the call used. A required secret without a value is 409 install_not_configured. Without runs:context the output values and the error text are [redacted].

Saves that wait for an approval ​

When the tenant requires approvals for apps (approvals.required, with requiredFor unset or including app), a save that adds a host waits for another administrator instead of applying: the answer is 202 { install, change }. Saves that add no host apply at once, approvals or not: dropping hosts narrows what the install reaches, and changes of the version, the config or the secrets that add none need no approval. Without approvals required every save applies at once; either way the audit records the hosts it asks for, adds and drops.

  • The change holds the whole state the save asks for: the version, the config, enabled and the hosts, with hostsAdded (what the approval is for), newInstall, the requester, the save's comment, and approvalSettings { required, fourEyes }, the settings in force when it was requested, which its decision follows. origin is save for an administrator's save, or auto_upgrade for an automatic upgrade that adds hosts or capabilities: requested by system, approval required and four-eyes off whatever the tenant's settings, with needsAdded and handlesAdded naming the capabilities it adds.
  • Meanwhile the install serves as it is: runs, test calls and webhooks use its approved hosts, version and config only. A new install waits stored disabled and without hosts, with the version and config it asks for, so its secrets can be stored on it before the approval; the steps and rules that name it fail until then, and validation warns (install_pending). A held save that switches the install off applies that at once, and so does one that stops its documents: the config it serves with gets its documents switch off, so no call reads one while the change waits.
  • One open change per install. A newer save of the install withdraws it (decisionwithdrawn, decisionComment superseded), whether that save waits or applies.
  • Approve (POST /app-installs/:install/changes/:id/approve, apps:publish, with an optional { comment }). Under four-eyes, as it was when the change was requested, the requester never approves it (403 four_eyes), even once four-eyes is turned off. The version is checked again (published, or withdrawn if it is the one the install's last automatic upgrade left; not blocked; the install's app), the config validated again, and its URLs must still give the hosts asked for (409 conflict otherwise). 409 install_changed when the install changed since the request. The version, config and hosts then apply in one transaction, and enabled only when every secret the config requires is stored by then: otherwise the install has its new hosts but stays off, and its missingSecrets says why; store them, then save it with enabled: true, which adds no host and applies at once.
  • Reject (…/reject, apps:publish) or withdraw (…/withdraw, apps:write, by the requester or an admin with tenants:settings, 403 not_requester for anyone else) leaves the install as it is. A new install's change takes the waiting install with it, and the secrets stored on it: the answer's install is null then.
  • Where changes show: GET /app-install-changes?status=open|decided&install= lists them (open ones oldest first, decided ones newest first; a rejected or withdrawn new install's change goes with its install, and the audit trail keeps the decision), an install's view carries its open one, Home's attention list counts them for callers with apps:publish (install_change_pending), and the console's Operate › Approvals lists them beside the definitions' requests. A request raises the app.install.change.requested webhook event and an approval_requested notification to everyone with apps:publish; decisions are audited as app.install.change.decided.

Automatic upgrades ​

An install of a platform app can follow the platform's releases on its own: autoUpgrade: 'compatible' (set with apps:write on POST or PUT; off by default) lets the API move it within its version's compatible range, the same major from 1.0.0 on and the same minor on 0.x (1.2.0 moves within 1.x, 0.1.0 within 0.1.x), never to a prerelease. The target is the highest published release of that range no block stops, read again as the install moves, so the operator's withdrawal or block holds from its commit. The API sweeps the opted-in installs at start-up, after the catalogue is reconciled, and every five minutes, and moves one install at once after a save of it, a decision on its change and a secret written for it. Each install's upgrade says where it moves next (target, rollback) and what that waits for (waitingFor, null when the next sweep moves it):

  • Applied at once when the move adds nothing: its hosts are among the install's approved ones, it adds no capability, the install's config holds under the target, every secret the target requires is stored, and the target still answers every action the tenant's published workflows and app rules call through the install (by any of its names), synchronously where a rule calls it. Hosts the target no longer calls are dropped. Audited as app.install.upgraded (automatic true) under the system actor, which raises the webhook event of that name and an app_upgraded notification to holders of apps:write.
  • Held for an approval (approval) when the target adds hosts, or capabilities as the install's config would use them: needs: ['documents'] (a token to read the tenant's documents on every call; behind a switch, only when the config turns it on), handling webhooks (with the source in the config, only once the config names one) or the hand-off: a change of origin auto_upgrade opens, even when the tenant requires no approvals, naming what is added (above). Approving it moves the install (app.install.upgraded with automatic false); rejecting it leaves the install where it is and is never asked again for that version (declined), while a newer target opens its own change. A save of the install waiting for an approval holds the move too.
  • Waiting for what an administrator fixes: a secret the target requires (secrets, with missingSecrets), a config the target refuses (config, with the issues), or an action the target lacks or answers through a webhook where a rule calls it (actions, with who calls it). Home's attention list shows these as install_upgrade_waiting to holders of apps:write.
  • Rollback. When the version the install runs is withdrawn or blocked, the sweep moves it to the highest published, unblocked release of the same range, lower if need be: rollback true on the view and the audit event.
  • Moving back. upgradedFromVersionId is the version the last automatic move left. A save may move the install back there, and later saves keep it there, even once it is withdrawn (never once it is blocked), whether the save applies at once or waits for an approval because moving back adds a host. Moving down takes autoUpgrade: 'off' in the same save: while it is on, a save that moves the install to a lower version is refused with 400 validation_error, so the next sweep never undoes a move back.

Renames and aliases ​

POST /app-installs/:install/rename with { name, keepAlias, rewrite } gives an install another name. It keeps its id, so its secrets, cached values, circuit, callbacks, changes and webhook URL stay, and an open change of it still applies.

  • Aliases. With keepAlias (the default) the former name stays an alias of the install: runs in flight, definitions that still name it and old links keep reaching it. An install keeps at most 5 (409 alias_limit_reached), and names and aliases share the tenant's namespace (409 install_name_taken with details.aliasOf). Renaming an install to one of its own aliases takes that alias back. The routes under /app-installs/:install take the current name only: a former name answers 404 unknown_install with details.renamedTo, the name to use. Validation warns install_renamed where a step or an app rule names a former name.
  • Without an alias the former name is free again; that is refused with 409 install_in_use (details.definitions, details.runs) while published definitions or runs in flight still call it.
  • Rewrite. rewrite updates the published workflows and app rules that call the install by any of its names: drafts (with definitions:write) writes a new draft of each calling the new name, publish (also definitions:publish) publishes each, or requests its approval where the tenant requires approvals for that kind. A definition with a version newer than its published one is skipped (skipped, draft_pending), and one whose rules or rule sets are no longer published stays a draft. The answer is { install, alias, definitions, skipped }, audited as app.install.renamed. A rewrite that fails part way answers its error and leaves the rename done, with the versions it wrote, which the audit event lists.
  • Removing an alias (DELETE /app-installs/:install/aliases/:alias) frees the name; 409 alias_in_use while published definitions or runs in flight whose workflow version calls it still name it, unless ?force=true: their steps and rules then fail with unknown_install, and so does replaying a run pinned to such a version. Audited as app.install.alias.removed.
  • Records keep the name a call was made by (installName of invocations and callbacks), and installId ties them to the install; GET /app-installs/:install/usage gives each definition's calledAs, and GET /apps/health counts an install once, under its current name.

Routes ​

RoutePermissionDoes
GET /appsapps:readThe catalogue (items): platform apps and the tenant's own, one entry per name, each with the tenant's installs of it (installs, by name), and the deployment's signature mode (signatures).
GET /apps/:nameapps:readOne entry, with its installs and every version the tenant sees, and the deployment's signature mode (signatures).
GET /apps/:name/versionsapps:readThe versions the tenant sees, the highest number first, each with its manifest unfolded, its platformSource, its provenance and, for the tenant's own, the operator's latest review.
GET /apps/health?windowMinutes=apps:readPer install: calls, failures, timeouts, p95 and the last call and failure over 5 to 1,440 minutes (60 by default), with the app it runs; open circuits, with thresholds; installs on a blocked module, with reason and blockedAt.
GET /apps/approvals?open=apps:readPublish requests of the tenant's own versions: open ones oldest first, or decided ones.
GET /apps/metering?month=apps:readA UTC month's use (this one by default), in total, per day and per install, with the operator's quotas and the installs' budgets against their current periods; spend, its currency and spend budgets only with apps:write.
POST /apps/uploadsapps:publishUpload a module as an uploaded version; 409 app_blocked for a module or an app the operator blocked, conflict for a platform app's name.
POST /apps/:name/versions/:id/publishapps:publishPublish (200), or open an approval request or, for a tenant under review, the operator's review (202).
POST /apps/:name/versions/:id/approve, /rejectapps:publishDecide an open request, with an optional { comment }; approving publishes (200), or opens the operator's review for a tenant under review (202).
DELETE /apps/:name/versions/:idapps:publishWithdraw one of the tenant's own versions, closing its open request and its pending review.
PUT /apps/:name/versions/:id/provenanceapps:publishAttach a Sigstore bundle to an uploaded version of the tenant's own, or replace it; answers the version's summary with its provenance.
GET /app-installs?app=apps:readThe tenant's installs by name, or the installs of one app.
GET /app-installs/:installapps:readOne install with its version, hosts, required, stored and missing secrets, webhook URL, pending change, aliases, the automatic upgrade it waits for and its circuit.
GET /app-installs/:install/usageapps:readThe published workflows and rules that name the install, by its name or an alias (calledAs), with the actions they call.
POST /app-installs/:install/renameapps:writeRename the install, keeping the former name as an alias, and optionally rewrite the definitions that call it.
DELETE /app-installs/:install/aliases/:alias?force=apps:writeFree a former name the install keeps.
POST /app-installsapps:writeInstall a version of an app under a name of its own (201), or request the install when it waits for an approval (202).
PUT /app-installs/:installapps:writeConfigure, upgrade, enable or disable (200), or request a change that waits for an approval (202).
DELETE /app-installs/:install?force=apps:writeRemove the install.
POST /app-installs/:install/secrets/:secret, DELETE on the sameapps:writeStore or remove a secret of the install.
POST /app-installs/:install/testapps:writeOne test call of an action.
PUT /app-installs/:install/circuitapps:writeSet the install's circuit settings, null each for the deployment's.
GET /app-installs/:install/cacheapps:readThe install's live cached values: name, expiry, last write and size, never a value, with the deployment's budget.
DELETE /app-installs/:install/cacheapps:writeClear the install's cached values.
POST /app-installs/:install/circuit/resetapps:writeClose the install's circuit and clear its count.
GET /app-installs/:install/limits, PUT on the sameapps:writeThe install's budgets and the prices of its actions; a save replaces them.
GET /app-install-changes?status=&install=apps:readChanges waiting for an approval, oldest first, or with status=decided the decided ones, newest first; of every install or one.
POST /app-installs/:install/changes/:id/approve, /rejectapps:publishDecide a change, with an optional { comment }.
POST /app-installs/:install/changes/:id/withdrawapps:writeWithdraw a change: its requester, or an admin.
GET /app-invocations, GET /app-invocations/:idapps:readInvocation records, newest first, by install (the name the call used; earlier installs of that name too) or installId, workflowRunId, status (repeated) and from/to; a fallback's names the install it stood in for.
GET /app-callbacksapps:readVendor sessions, newest first, by workflowRunId, status, install and installId.
POST /webhooks/app-installs/:installIdpublicVendor webhooks, one URL per install (above).
GET /collection-submissions/:id/handoffthe applicantThe vendor's hosted page for the session the submission's run waits for (above).

Admins hold every apps:* permission; analysts hold apps:read. The /apps and /app-installs routes answer 503 apps_unavailable on a deployment without an app runner or object storage; the invocation and callback routes answer either way.

The runner serves POST /v1/inspect, /v1/invoke, /v1/webhooks/handle and /v1/handoff to the API and the worker under the bearer APP_RUNNER_TOKEN, with GET /health and /health/ready open (calls in flight, compiled modules and the instances waiting for a call, warmPlugins). It fetches modules from GET /internal/app-modules/:sha256 and documents from GET /internal/app-calls/:callId/documents/:id on the API's internal listener (API_INTERNAL_PORT), which no ingress reaches: modules under the same bearer, documents under the call's token, which binds the tenant, the call and the call's subject when it has one, expires after five minutes and is signed with APP_CALL_TOKEN_SECRET, a secret the runner never holds. Only calls through an install whose calls read documents get a token (Documents of a call).

Errors ​

The codes the API answers with, and that a step's or a rule's failed call carries:

CodeStatusWhen
apps_unavailable503The deployment has no app runner or no object storage.
validation_error400An upload that is not a module the platform runs (details.code below), invalid config, input or output, a version of another app for an install; a config URL the platform would not call, a secret reference nothing declares or a secret variable's stepPlaces the platform refuses (details.issues, at their pointers); autoUpgrade on for a tenant's app, or a lower version while it stays on.
runner_<code>502The runner failed while inspecting an upload.
conflict409A name and version the tenant already has, an upload under a platform app's name, a version not in the status the call needs, an open request, a version waiting for the operator's review, a decided change, a change whose config gives other hosts than it asked for.
four_eyes403The requester tried to approve their own publish request, or their own install change requested under four-eyes.
unknown_install404No install of that name in the tenant (a route, a step or a rule names it), or of that id (a webhook URL); a former name an install keeps, given to a route under /app-installs/:install (details.renamedTo).
install_name_taken409A new install's name, or a rename's, is another install's or a former name another install keeps (details.name, details.aliasOf).
alias_limit_reached409A rename that keeps the former name, of an install that keeps 5 already (details.max).
alias_in_use409Removing a former name that published definitions or runs in flight still call, without force (details.definitions, details.runs).
install_limit_reached409The tenant has 50 installs already (details.max).
provenance_invalid400A bundle that does not prove the module (details.reason): malformed, unsupported (another content or key type), digest_mismatch (it signs other bytes than the stored module) or signature.
provenance_required409APP_SIGNATURES=required and a tenant's version no signer the policy trusts has signed (details.reason unsigned or untrusted): at publish, approval, install, upgrade and install-change approval.
provenance_locked409Attaching a bundle to a version that is published or withdrawn, or whose publish request or operator review is open (details.reason).
provenance_unavailable503A Sigstore trusted root could not be loaded: the TUF repository out of reach with no unexpired cache, or a trusted_root.json the API cannot read.
signatures_off404Attaching a bundle when the deployment takes none (APP_SIGNATURES=off).
install_not_configured409enabled: true while a secret the config requires is missing (details.missingSecrets); a call of a disabled install, or of one whose required secret has no value.
hosts_not_approved409A save adds hosts that approveHosts does not list (details.added names every host it adds).
install_changed409Approving a change of an install that changed since the change was requested (details.changeId).
not_requester403Withdrawing another person's install change without tenants:settings.
install_in_use409Removing an install while runs wait on its callbacks (details.pendingCallbacks); renaming one without keeping its name while published definitions or runs in flight call it (details.definitions, details.runs).
app_blocked409The operator blocked the module, by its hash or every version of its publisher's app (details: { sha256, reason }): at upload, publish and approval, install, upgrade and install-change approval, a call (each attempt, recorded as an error, not retried, never counted against the circuit), a test call and a vendor's webhook (without details, since nothing has checked its sender).
secret_store_unavailable503No SECRET_STORE_KEY to seal or open a secret.
app_call_failed502The module answered an error, or the runner failed the call (details.reason).
app_circuit_open503The install's circuit is open, or half-open with its trial out: shared by every worker, it refuses the call before the runner, recorded nowhere (details.install).
app_quota_exceeded429One of the operator's quotas of the tenant or one of the install's budgets is used up for its period (a spend budget has less left than the call's price): a call, a step's or a rule's or a test call, refused before the runner (details: scope, metric, period, max, used, currency, periodStart, resetsAt, install).
missing_external_id, webhook_rejected, app_webhook_failed400, 401, 502Webhooks (above).
handoff_unavailable, handoff_failed409, 502Hand-offs (above).

A refused upload carries one of these in details.code: not_wasm, invalid_module (not valid WebAssembly, a bad memory section, or a manifest the platform refuses, such as one of another interface or with a field it does not know, which the message names), forbidden_import, imported_memory and missing_export (no manifest or invoke). A body over 32 MiB is 413 payload_too_large.

The runner's own failures (reason of app_call_failed), with the status the runner answers:

CodeStatusMeaning
unauthorized401The bearer token is missing or wrong.
bad_request400The request does not match the contract.
module_not_found404The API has no published module of that hash, or the operator blocked it.
invalid_module422The bytes are not a module the platform runs, or its manifest is bad.
missing_export422The export is not declared by the manifest or not in the module.
timeout504The call ran past its deadline and its thread was terminated.
trapped502The module trapped or threw.
bad_output502The module answered something the contract does not allow.
busy429The runner, or this tenant on it, has too many calls in flight; retried.
module_unavailable503The module could not be fetched from the API; retried.
internal500The runner failed; retried.

The API and the worker add not_sent for a call no runner took (the connection was refused or never made, so nothing ran), retried, and unreachable for a runner that did not answer once the call was on its way. After unreachable, timeout or internal the module's requests may have gone out, so the worker retries those for an idempotent action only. Of these failures only timeout counts against the install's circuit, as an error answer the app calls retryable does, unless the step's time cut the attempt short; the others are the module's own or the platform's (Retries, idempotency, circuits and records).

Testing helpers (@aletheia-dev/app-sdk/testing) ​

The testing subpath runs a built module through the real host in the author's own tests, with any test runner.

loadApp(source, options?) ​

source is the module's path or bytes. Answers a TestApp with the manifest the module declares, invoke(action, input, { idempotencyKey?, subjectId? }), handleWebhook(request) and handoff(externalId, returnUrl?) (each resolving to the export's answer, as in Envelopes), logs (the lines the module logged with ctx.log, { level, message, fields }, redacted as the host redacts them; the host's own lines go to the logger option only), cache (the install's cached values between the calls: values, the live ones in clear, which the module's answers and lines never show, and writes, what the last call changed, each name's value or null for one it deleted) and hosts, the hosts its calls may reach now. The calls keep the cache as the platform does: each starts with the live values, and what it changed is stored once it has answered, whatever it answered, but not after a trap, a timeout or an answer outside the contract, within the deployment's default budget, the values written longest ago leaving first. invoke's subjectId is the run's subject, which keeps the call to that subject's documents, as a run's step or a rule is kept. Reading hosts, like a call, throws the platform would refuse this config: <path>: <message> when a URL at hostsFromConfig breaks a rule (an http URL is refused unless a vendor stub runs).

handoff sends the applicant back to returnUrl, TEST_RETURN_URL (https://collect.example/flow/test-submission) unless given, and throws the platform would refuse this hand-off: its page <problem> when the page it answers is not one the API sends applicants to (Hand-off to the applicant): https on one of hosts, or, while a vendor stub runs, the stub's own http://127.0.0.1:<port>.

OptionDefaultNotes
config{}The install's configuration, passed as is (the module applies defaults).
secrets{}Values by secret name; a placeholder for a missing one fails the request.
hoststhe install's hostsThe approved hosts; by default those the platform approves for config (installHosts): the manifest's, and each URL's host at hostsFromConfig, its port included. A running vendor stub adds 127.0.0.1.
documentsnone{ [id]: { fileName, contentType, bytes, subjectId? } }, which invoke calls read while the install reads documents (installReadsDocuments: the manifest needs them and its switch, when it has one, is true in config); webhook and hand-off calls read none. An unknown id fails the read, and so does another subject's document for a call that names a subjectId.
tenantIdTEST_TENANT_IDThe tenant the calls run for.
callbackUrlnonectx.callbackUrl in the module.
cache{}The install's cached values the first call starts with, by name: text, kept for a day, or { value, expiresAt }. A name or a value the host would refuse to set throws.
ainoneThe models an action's ai requests reach: aiStub() (below) or an AiBackend of the test's own (the type is exported here too). The host checks each request, fills its keys from secrets and approves its base URL among the hosts as on the platform, then asks it in place of a vendor; webhook and hand-off calls ask none. Without it, every request answers models are not available to this call.
loggernoneReceives the module's lines and the host's own: app http_request for each request the module sends, app ai for each model request, and Extism's.

Calls run with the platform's limits: the action's timeoutMs for invoke, 15 s for handleWebhook and handoff. They run through the real host, so each call has a secret tag of its own, a request's secrets, auth and sign are filled and built, and what leaves the call is redacted ([secret:NAME], [auth:TYPE], [cache:NAME]), exactly as on the platform. Of what the API and the worker do around a call, loadApp plays the install's hosts, its documents switch, its cached values, the call's subject and the check of a hand-off's page only: it does not retry, keep a circuit or validate input and output against the manifest's JSON Schemas, which conformance does.

vendorStub(handler) ​

Starts an HTTP server on 127.0.0.1 that answers every request with handler(request) ({ method, path, headers, body } in, { status?, headers?, body?, drop? } out, an object body sent as JSON) and keeps them in requests. While one runs, loaded apps may reach http://127.0.0.1, which the platform otherwise refuses. origin is its base URL; close() stops it. Requests reach it with the auth and sign the host built, so the handler sees the credentials and signatures the vendor would; it checks none of them itself. A test verifies what it needs in the handler, and plays a Digest challenge there (a 401 with www-authenticate: Digest realm="…", nonce="…", qop="auth") when the app uses digest, or a vendor failing mid-request with drop: true: the stub closes the connection without an answer once the request has arrived, and the app's request fails in transit with network: 'unknown', an HttpNetworkError in the SDK.

aiStub(options?) ​

Models for loadApp's ai option that answer without a network. The host checks each request as on the platform (its fields and output schema, its keys, its base URL among the hosts) before the stub sees it, and the stub's answer against the request as it checks a vendor's. requests keeps what the stub was asked, in order, as { op, call }, the call as the host made it: the connection with its keys filled, the baseUrl, the model, the timeoutMs and the rest of the request. By default stubGenerated(call) answers a generate request: STUB_AI_TEXT (A stub answer.) for text, a value of an object's or an array's schema (each field's const or first enum value, else stub, 0, true or null; an array output of minItems elements, one at least), the first option, or {}. stubDecided(call) answers a decide request: P(true) 0.5, the first option, the first level, with their distributions on a native engine. Both answer the model asked and 10 input and 5 output tokens. { generate, decide } answer with functions of the test's own: one that throws an AiCallFailure (exported here too) plays a vendor's failure, with its kind, retryable, status and retryAfterMs, and any other throw is a final vendor failure.

signedWebhook(options) ​

Builds the request the API would hand handle_webhook: POST with a JSON-encoded body, lower-cased headers and the query; with secret, the hex HMAC-SHA256 of the body goes into header (default x-signature).

conformance(app, { samples }) and assertConformance ​

Runs the checks the platform applies and answers { ok, checks, failures }, each check { name, ok, message? }:

  • the manifest parses, and configSchema and every action's input and output are JSON Schemas the host accepts;
  • invoke refuses an unknown action;
  • for each sample { action, input, webhook? }: the action is in the manifest, the input validates, and a synchronous action answers ok with an output that validates;
  • an asynchronous action's sample answers pending with an id and, when the sample supplies webhook(externalId) (the vendor's request for that id), handle_webhook answers a completed event naming the same id, whose output validates.

conformance never throws for a failing app; assertConformance throws one error listing every failed check.

Released under the Apache-2.0 License.