@alma-harness/runtime
Official storage composition, a deployment that derives governed runners, and a
simple text conversation facade. The root export has types only. Use
/deployment to configure once, /agent for query and resume, /memory or
/postgres for storage, and the lower-level factories for advanced routing.
Configure once: /deployment
import { createRuntimeDeployment } from '@alma-harness/runtime/deployment';
const deployment = createRuntimeDeployment({
runtime, clients: { anthropic, openai }, prices, priceVersion,
consumers: ['product'], caps, retentionMs: 60_000,
limits: { deadlineMs: 8_000, runTimeoutMs: 120_000, stepTimeoutMs: 60_000, maxCalls: 24,
maxInputChars: 64_000, maxRequestChars: 1_000_000, maxOutputChars: 100_000 },
onBackgroundError: reportAccountingIssue,
batch: { clients: { openai: openaiBatch }, retentionMs: 7 * 86_400_000 }, // optional
});
const titles = deployment.singleCall({ policy, policyVersion, outputContractVersion, system: [], maxTokens: 64 });
const pick = deployment.structuredCall({ policy, policyVersion, outputContractVersion, system, maxTokens, tools, toolChoice });
const chat = deployment.conversation({ policy, policyVersion, configRevision, resultContractVersion, system, tools,
caps: perTurnCaps, limits: { maxCalls: 8 } });
const extract = deployment.batchCall({ policy, policyVersion, outputContractVersion, configRevision, system, tools, toolChoice,
maxTokens, validate, retries, retryDelayMs, submitWindowMs });
Each factory returns the runner that createSingleCallRunner,
createStructuredCallRunner, createConversationRunner or
createBatchCallRunner builds from the same values, with their validation,
bindings and replay (specs: runtime-deployment, deployment-batch-call).
-
Owned by the deployment: the runtime's stores and audit log,
clients,prices,priceVersion,consumers, the result retention and a conversation'sstep. An operation that passes any of them, even asundefined, throwsTypeError. -
Overridable per operation:
capsreplaces whole,limitsmerge field by field, andonBackgroundErrorreplaces. -
Required limits, no defaults: single and structured calls need
deadlineMs,maxInputCharsandmaxOutputChars. A conversation needsrunTimeoutMs,stepTimeoutMs,maxCalls,maxInputChars,maxRequestCharsandmaxOutputChars. -
Snapshot: prices, caps, limits, consumers, the clients and the store map are copied at construction. The deployment owns no pools.
-
Batch calls:
- they need
runtime.stores.batchand the deployment'sbatch.clients; - they take
runTimeoutMs,maxRequestCharsandmaxOutputChars, within the batch runner's own maxima; - their results are kept for
batch.retentionMs, which defaults to the deployment's retention; - the observer receives the attempt's batch key as
operationKey; /deploymentneeds@alma-harness/batchinstalled.
A host moving pending batches from its own composition keeps them only with an unchanged schema, provider account and effective configuration (see the spec).
- they need
-
Resolved values:
deployment.resolvedand each runner'sresolvedhold the retention and the limits it was built with, frozen (spec: deployment-resolved-values). -
Versioning stays yours: bump
policyVersion,priceVersion,outputContractVersion,configRevisionorinputRevisionwhen what they name changes. A configuration revision covers all execution behavior, including models, policy, prompts, tools, hooks, context sources, generation controls, prices, caps, limits, retries, contracts and provider accounts. It leaves out only what changes neither behavior, resource identity nor authorization, such as a database login or a key rotated within one account. Retrying an existing execution under a changed revision conflicts, so binding those would block pending work.
Start and resume a conversation: /agent
import { createConversationAgent } from '@alma-harness/runtime/agent';
const agent = createConversationAgent({
id: 'assistant', revision: 'v1', runtime,
model: { ref: model, client, prices },
instructions: 'You are a helpful assistant.',
intent: { tier: 'standard', sensitivity: 'personal' },
limits: { caps: { perTurnUsd: 0.25 }, retentionMs: 60_000 },
onBackgroundError: reportAccountingIssue, // audit defaults to runtime.stores.audit
});
const user = agent.forUser({ org, uid }); // verified by the host
const first = await user.query({ prompt: 'Hello', messageId: 'delivery-1' });
if (first.view.status === 'completed')
await user.query({ resume: first.sessionId, prompt: 'Continue', messageId: 'delivery-2' });
const observed = await user.read({ sessionId: first.sessionId, messageId: 'delivery-1' });
See the quickstart.
- One immutable revision covers the whole configuration, trusted capabilities included.
- Versioned defaults: 120s timeout, 24 calls, 64,000 input characters, 1,000,000 request characters and 100,000 output characters.
resumeselects existing history in the bound scope. An unknown or foreign session throwsConversationResumeError. The host authorizes the session: an id is not a credential.- The first session id derives from agent, scope and
messageId(conversationAgentSessionId). To retry a lost response, repeat the same request. - Every query returns
{ sessionId, messageId, rootKey, view }. Inspectview.statusbefore using its result.readnever dispatches. - What the facade does not do: it adds no input queue, retry, scheduler or erasure barrier.
Explicit runner composition
import { createMemoryRuntime } from '@alma-harness/runtime/memory';
import { createConversationRunner } from '@alma-harness/conversation';
const runtime = createMemoryRuntime({ stepResultPolicy: policy, rootResultPolicy: policy });
const runner = createConversationRunner({ ...hostConfig, ...runtime.stores, step: { ...hostConfig.step, ...runtime.stores.step } });
Memory is ephemeral: a restart loses history, admission and replay protection.
close() never erases stores or reconciles uncertain work.
PostgreSQL: /postgres
import { createPostgresRuntime, migratePostgresRuntime } from '@alma-harness/runtime/postgres';
const namespace = { schema: 'agent_execution', rootSchema: 'agent_results', onPoolError: report };
await migratePostgresRuntime({ ...namespace, connectionString: migrationURL }); // migration principal, outside requests
const runtime = createPostgresRuntime({ ...namespace, connectionString: runtimeURL,
poolMax: { execution: 2, roots: 1 }, stepResultPolicy: policy, rootResultPolicy: policy });
- Two dedicated schemas with different lowercase names. Connection URLs
with startup
optionsare rejected. - The migration installs the adapters, grants schema USAGE to
alma_appandalma_retention, and revokes CREATE. It never grants login membership: the operator gives the runtime loginalma_app, without superuser, BYPASSRLS or ownership. It is repeatable, not atomic across schemas: after a failure, fix the cause and rerun. - Upgrade window: a database last migrated by one of the two previous minor releases migrates in place. An older one upgrades in steps (spec: upgrade-window).
- Pools: construction opens no connection. Two bounded pools, five
connections each by default, set by
poolMax: { execution, roots }. Budgetinstances × (execution + roots + batch)plus migrations, maintenance and a reserve against the database's capacity. - Batch stores:
batch: { schema, resultPolicy, poolMax? }opens a third pool, five connections by default, and buildsruntime.stores.batchon a store schema that installsbatchandresults. Pass that schema instoresto migration and verification. - Connections:
connection: { applicationName, idleTimeoutMs }(spec: pool-connection-options).applicationNameis a label of 1 to 40 characters of[A-Za-z0-9_.:-]. Each pool connects as<label>:execution,:rootsand:batch, sopg_stat_activitytells them apart. Without a label Alma sets no name, and the URL's orPGAPPNAMEapplies. A label beside a URL that carriesapplication_nameis refused.idleTimeoutMssets how long an idle connection stays open: 1,000 to 600,000, 10,000 by default.- Keep-alive: every pool enables TCP keep-alive. When the first probe goes out is the operating system's setting.
onPoolError(error, schema)is required and synchronous. It reports idle-client failures and must not throw.runtime.stores.auditwrites to the execution namespace, on the execution pool.- Retention and erasure: register sessions, both result stores and
stores.batch.resultsin the host's audited retention and erasure. Areconciliation_requiredturn's session is freed before it returns; the host decides whether to send again (spec: failed-turn-release).
Verifying the database
import { checkPostgresRuntime, verifyPostgresRuntime } from '@alma-harness/runtime/postgres';
const logins = [{ name: 'api', role: 'alma_app' }, { name: 'maintenance_discovery', role: 'alma_maintenance' }];
const report = await verifyPostgresRuntime({ connectionString: migrationURL, schema, rootSchema, logins }); // deploy job
if (!report.ok) throw new Error(JSON.stringify(report.differences));
await checkPostgresRuntime({ connectionString: runtimeURL, schema, rootSchema, logins }); // startup, read-only
verifyPostgresRuntime compares the deployed pair with a shadow that this
version migrates inside a transaction that always rolls back. It needs no
manifest or snapshot (specs: runtime-verification, verification-digest-order,
store-verification).
- What it enforces:
- Alma's roles are NOLOGIN and members of no role;
- each listed login reaches exactly its role;
- no default grants, global or in a covered schema, to the actors or PUBLIC; none to other roles in a covered schema, or global from the migration principal;
- no untrusted event trigger, publication or external
SECURITY DEFINERroutine; - the discovery roles' column ceilings;
- no grant the migration does not make, to anyone;
- no rewrite rules or inheritance edges;
- no SET on
session_replication_role.
- Trust is given by fingerprint (
trustedRoutines,trustedPublications,trustedEventTriggers). - The report carries hashes (
diagnostics: trueshows bounded text), and is capped at 500 differences. - A clean run certifies a digest from one snapshot into
alma_runtime_verification.
checkPostgresRuntime re-enforces every invariant as the runtime login.
It then compares the live catalog with that digest, and throws
RuntimeVerificationError (invariant, unverified, version or drift).
Roles are cluster-wide: another product adding a member to an Alma role is
drift until you verify again. Requires PostgreSQL 16 or later.
Store schemas. Pass the same stores list, for example
[{ schema: 'agent_memory', install: ['memory', 'audit'] }], to migration,
verification and the check.
- Kinds:
memory,audit,routines,routineRuns,batchandresults. Up to eight dedicated schemas. - Verified whole: a store schema holds only what its kinds' migrations create.
- Routines:
routinesaddsalma_schedulerto the checked roles.
Fencing a scope
import { createPostgresScopeFence } from '@alma-harness/runtime/postgres';
const fences = createPostgresScopeFence({ ...namespace, connectionString: runtimeURL, stores });
const { status, record } = await fences.fence(scope, { actionId: 'erase-42', actor: 'privacy-desk' });
await fences.close();
A fence is permanent: no API lifts it (spec: scope-fence). Once fence
resolves, Alma grants the scope no new authority to dispatch a model call or
to reserve a place for content, and no writer inserts the scope's content
anywhere (spec: scope-erasure).
- Refused with
ScopeFencedError(@alma-harness/execution), which carries no content:- a new turn, and a new single, structured or batch call key;
- every new result reservation. An enrichment run reports
reconciliation_requiredand writes no plan; - a session append, a memory episode or fact, a routine registration, and a routine run record.
- Still served: reads, replays of stored keys, maintenance recovery and usage acknowledgement.
- In flight: work that already held its claim and reservations may
dispatch and store its result. A turn admitted before the fence returns
reconciliation_requiredat its next step or append. On PostgreSQL, a new key in a session that turn still occupies answersbusy. A fence erases nothing: erase the scope next. - Where: the pair's execution namespace holds the canonical record,
copied into the roots namespace and every store schema installing
batch,results,memory,routinesorroutineRuns. The first record wins; a laterfencereturns it asexisting. Only a resolvedfenceproves every schema is fenced, so retry a failed one, under anyactionId. A schema fenced on its own with another record makesfencereject and name it, for an operator to reconcile.getreads the pair only. - Not a guard against the host: the gates refuse inserts. A writer that edits Alma's rows by hand as the app login acts on the host's authority.
- Connections: connect as the application login. At most one connection, none at construction.
- Memory:
createMemoryRuntimeexposesruntime.stores.fences, which gates its own stores. - Upgrading: the fence tables and gates change the verified catalog, so
the startup check reports drift until you verify again. Fence only after
migrating, verifying with the complete
storeslist, starting every process on this version, and retiring every older writer.
Erasing a scope
import { createPostgresScopeErasure } from '@alma-harness/runtime/postgres';
const erasure = createPostgresScopeErasure({ ...namespace, connectionString: runtimeURL, stores,
batchClients: (configRevision, provider) => batchClientFor(configRevision, provider) });
const report = await erasure.erase(scope, { actionId: 'erase-42', actor: 'privacy-desk' });
if (report.status === 'pending') retryAfter(report.waitingUntil); // then call erase again
await erasure.close();
One call erases an owner from the pair and every declared store schema
(spec: scope-erasure). Pass the same stores list as migration and
verification. Each call:
- fences the scope, and keeps an existing fence and its record;
- erases content: sessions, labels (sealed), step, root, batch and store results (tombstones), memory episodes and facts, and routines (deleted);
- closes pending work:
runningandsubmittedroutine runs becomefailedwith the reasonscope erased, and every run loses its handle and any other reason;- uncertain admissions are released with the fence's
actionIdas their resolution, at most 100 a call. No operator login is needed: the fence authorizes it;
- discards the scope's batches at the provider (spec:
provider-batch-retention).
batchClientsreturns the client of the account a configuration revision used, orundefined. A running batch is cancelled and stays open until a later call deletes it. A submission still in flight without a handle stays open until its submit deadline, which setswaitingUntil; - proves it:
report.tableslists every scope table with itsrows, theopenrows that still hold content or unclosed work, and why the rest iskept(tombstone,closed,journal,accounting,audit,fence).report.providergives the scope's batches and those not confirmed deleted.statusiserasedonly when everyopenis 0.
-
Pending: read
tables[].openfor the cause. A turn admitted before the fence and still inside its deadline setswaitingUntil; call again after it. More than 100 uncertain admissions take another call. A content row still open after a call is an inconsistency to investigate, not a wait. -
Retries: each step commits on its own and is idempotent. A failed call throws after writing
scope:failedto the pair's audit trail; the next call completes it. Every call writesscope:erasedorscope:pending. -
In flight: neither kind of call stores a result.
- One that reads its reservation after the erasure stops without
dispatching: it rejects, and its journal records it
not_dispatched. - One already past that read may still dispatch under the claim it
held before the fence. It completes with an
unavailableresult, and its cost is recorded.
- One that reads its reservation after the erasure stops without
dispatching: it rejects, and its journal records it
-
What stays: identifiers, amounts, usage, times and hashes, never message text Alma wrote. Two host-authored strings stay on the append-only audit trail: a routing policy's
rationaleand an access event'sresource. Choose pseudonymous identifiers, and keep user text out of both. -
Not counted: the maintenance and projection cursors, which hold only a scope's key, and the tenant-day spend, which has no
uid. -
Connections: connect as the application login. During a call, at most one connection per schema it touches; none between calls.
-
Provider batches: each call asks the provider again, one retrieval and up to three deletions per OpenAI batch, so a retry confirms nothing remains. A throttled or failed request leaves its batch open. What the provider keeps regardless:
- an OpenAI batch object, with identifiers, counts and error messages;
- a batch whose acceptance was lost, until its expiry (OpenAI) or 29 days (Anthropic);
- prompt caches and logs.
A batch cancelled before collection may be billed and never recorded.
-
Out of scope: the host's own data, and retained turn claims of older deployments, which a host erases with
createMemoryErasure({ turns }).
Maintenance: /maintenance
import { createPostgresMaintenance } from '@alma-harness/runtime/maintenance';
const maintenance = createPostgresMaintenance({ schema, rootSchema, stepResultPolicy, rootResultPolicy, onPoolError,
discovery: { connectionString: discoveryURL }, worker: { connectionString: workerURL } });
const report = await maintenance.maintain({ limit: 50, signal }); // schedule it
maintain finds scopes with due work and sweeps them, least recently visited
first (spec: runtime-maintenance):
- it expires admissions, roots and executions;
- it completes executions from retained results;
- it acknowledges usage.
It never dispatches, and its report holds counts only. A settled execution is due
once recovery could take it: past its recovery lease, or else its deadline. A
completed call missing its root receipt is found through its pending usage row
(spec: maintenance-scan). The scan's index and the discovery role's
recovery_until grant change the verified catalog: verify again after
migrating.
Logins:
- a discovery login in
alma_maintenanceonly, which sees identifiers, states and times across scopes; - a worker login in
alma_apponly, which is not the request path's.
Neither may be in alma_retention. Every call checks this.
Connections: at most three per instance: two on the discovery login and
one worker connection, which also serves each call's login check (specs:
upkeep-connection-budget, login-check-connection). connection takes the
same options as the runtime's. The worker's pools and the discovery pool
connect as <label>:maintenance:execution, :roots and :discovery.
Receipt projections: /projections
import { createPostgresProjections } from '@alma-harness/runtime/projections';
const projections = createPostgresProjections({ schema, rootSchema, stepResultPolicy, rootResultPolicy, onPoolError,
discovery: { connectionString: projectionURL }, worker: { connectionString: workerURL } });
await projections.drain('cost-dashboard', async delivery => { /* dedupe on (scope, consumer, settlementId) */ }, { limit: 50 });
await projections.quiesce(scope, () => erasure.erase(scope, request), { timeoutMs: 30_000 });
drain delivers each pending governed receipt of one consumer, with its
session attribution: available, erased or none (spec:
receipt-projections).
- Acknowledgement: a receipt is acknowledged only after the handler resolves, so commit the deduplication and the effect together.
- Delivery: fair, and never loses a receipt.
- Logins: a discovery login in
alma_projectiononly, and a worker login inalma_apponly, as for maintenance. - Connections:
connectionas for maintenance, with names under<label>:projections:. - Erasure:
quiescewaits for a drain working the scope, then runs your erasure. A destination that stores labels must serialize with erasure through gate rows, as the spec shows.
Projections drain only the pair's execution namespace.
Conversation operations: /operations
createConversationOperations inspects, repairs and resolves one admission,
with capabilities the host supplies. The alma-operations CLI runs them from
bounded JSON on stdin with a local --host module. See the
operator runbook.
A call whose journal is not_dispatched never left, and counts as accounted at
no cost, so it no longer blocks resolution (spec: never-sent-calls).
A turn its runner released needs no resolution: inspect shows
turn-failed-v1:, and resolve answers binding_conflict (spec:
failed-turn-release).
An operation_failed caused by a database error carries its SQLSTATE in
error.sqlstate, and the CLI prints it: {"error":"operation_failed","sqlstate":"42501"}.
No message, detail or query of that error leaves (spec: uncertain-turn-causes).
runtime.stores.labels is the session label store (spec: session-labels). Wire
it into erasure as createMemoryErasure({ sessionLabels }).