@alma-harness/runtime
v0.12.0
Published
Official store composition for governed Alma conversations.
Readme
@alma-harness/runtime
Official storage composition, a deployment that derives governed runners, and a
simple text conversation facade. The root export contains types only. Use
/deployment to configure once, /agent for query/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,
});
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 } });Each factory returns the runner that createSingleCallRunner,
createStructuredCallRunner or createConversationRunner would build from the same
values. They keep their validation, bindings and replay (spec: runtime-deployment).
- Owned by the deployment: the runtime's stores and audit log,
clients,prices,priceVersion,consumers, the result retention and a conversation'sstep. An operation passing any of them, even asundefined, throwsTypeError. A product that needs a different store, audit sink or price table for one operation composes that runner explicitly. - Overridable per operation:
capsreplaces the deployment's caps whole (no merging),limitsmerge field by field,onBackgroundErrorreplaces. - Required limits: single-call and structured need
deadlineMs,maxInputCharsandmaxOutputChars. A conversation needsrunTimeoutMs(root),stepTimeoutMs(each step),maxCalls,maxInputChars,maxRequestCharsandmaxOutputChars. There are no defaults: text replay binds versions, not limits, so a default that moved in a release would change admitted work silently. - Snapshot: prices, caps, limits, consumers, the client registry and both levels of the store map are copied at construction. The deployment owns no pools and never closes the runtime.
- Your versioning duty is unchanged: bump
policyVersion,priceVersion,outputContractVersion,configRevisionorinputRevisionwhen what they name changes.
Start and resume a conversation
Configure once, then bind the authenticated user on the server:
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') {
const next = await user.query({
resume: first.sessionId, prompt: 'Continue', messageId: 'delivery-2',
});
// Inspect next.view.status before using its result.
}
const observed = await user.read({ sessionId: first.sessionId, messageId: 'delivery-1' });The complete quickstart shows storage,
tools and configuration. The facade wires all stores, one model's routing and
internal versions; it does not own/close the supplied runtime. audit is optional and
defaults to runtime.stores.audit; omitting it never disables auditing. Optional tools,
hooks and volatile configure trusted behavior. Model/client/prices, audit,
intent, budget and retention remain explicit deployment choices. Keep one
immutable revision for the entire configuration, including trusted capabilities.
Changing behavior requires a new revision; admitted retries still conflict.
Technical defaults are versioned: 120s timeout, 24 calls, 64,000 input characters,
1,000,000 request characters and 100,000 output characters. Override through
limits.runTimeoutMs/maxCalls/maxInputChars/maxRequestChars/maxOutputChars.
Runtime result policies must accommodate the chosen retention, sensitivity and
output envelopes. The facade never widens those policies or guesses model prices.
resume selects existing history within the bound scope; it is not automatic
execution recovery. Unknown/foreign sessions throw ConversationResumeError
(code: conversation_resume_not_found) without creating a replacement. The host
must authorize the selected session; an ID is not an access credential. Different
agents can explicitly share a session within that authorized scope.
For the initial message, session ID derives from agent ID, scope and messageId.
Exported conversationAgentSessionId(id,scope,messageId) obtains it before I/O.
Repeat the original no-resume request if its response was lost. For subsequent
messages, repeat the same resume/messageId/prompt. IDs are scoped to a conversation;
there is no cross-session membership deduplication. Namespace/hash raw transport
IDs into the official bounded identifiers when needed.
Every query returns {sessionId,messageId,rootKey,view}. view is the unchanged
canonical result, including busy, not_admitted, in_progress, unavailable and
reconciliation_required. read can return a null view and never dispatches.
Input that was not admitted is still the host's responsibility; this facade adds
no durable input queue, retry, ACK, scheduler or erasure barrier. Existing operator
and multi-surface erasure procedures remain required. Execution signal is not a
socket-close signal. Advanced media/multi-provider/streaming callers retain the
canonical runner API below; query currently returns a buffered final view.
Explicit runner composition
import { createMemoryRuntime } from '@alma-harness/runtime/memory';
import { createConversationRunner } from '@alma-harness/conversation';
const resultPolicy = { maxSensitivity: 'personal' as const, maxRetentionMs: 60_000, maxChars: 100_000 };
const runtime = createMemoryRuntime({ stepResultPolicy: resultPolicy, rootResultPolicy: resultPolicy });
// hostConfig contains the non-storage conversation configuration.
const runner = createConversationRunner({ ...hostConfig, ...runtime.stores,
step: { ...hostConfig.step, ...runtime.stores.step } });
// Reuse the original idempotencyKey and input when retrying a delivery.
const result = await runner.runTurn(request);
await runtime.close();See the complete quickstart for executable
configuration. Memory is ephemeral: restarting loses notes, history, admission and
replay protection. close() does not erase stores or reconcile uncertain work.
PostgreSQL
Install the PostgreSQL peers and pg when using this subpath. Provision two trusted,
dedicated schemas with different lowercase ASCII names (at most 63 characters).
Public/system namespaces and connection URLs with startup options are rejected.
import { createPostgresRuntime, migratePostgresRuntime } from '@alma-harness/runtime/postgres';
const namespace = { schema: 'agent_execution', rootSchema: 'agent_results',
onPoolError: (error: Error, schema: string) => console.error('Database pool error', schema, error) };
// Run explicitly under a migration principal, outside request handling.
await migratePostgresRuntime({ ...namespace, connectionString: migrationURL });
const runtime = createPostgresRuntime({ ...namespace, connectionString: runtimeURL,
poolMax: { execution: 2, roots: 1 }, // Example connection budget, not a production recommendation.
stepResultPolicy: resultPolicy, rootResultPolicy: resultPolicy });The migration principal needs schema/table/role provisioning rights. Migration
installs official adapters, grants schema USAGE to alma_app/alma_retention and
revokes their and PUBLIC's schema CREATE. It never grants login membership. The
operator separately provisions the runtime login with alma_app membership,
without superuser/BYPASSRLS/owner privileges. alma_retention membership belongs
to a separately authorized operator login. Existing role attributes are unchanged.
Migration is repeatable but not atomic across schemas: after a failure, correct the
cause and rerun explicitly. Do not drop populated schemas to retry installation.
The required synchronous onPoolError(error, schema) listener reports idle-client
failures from both pools, including during migration. The host must not throw from
this listener. Reporting does not retry work or release admission.
Construction performs no connection, migration or grants. It owns two bounded pools (five connections each by default), pins their namespace and float settings, and uses the official scoped transactions. The result tables are physically separate for steps and conversation roots. Runtime startup fails on missing tables or memberships.
poolMax: { execution, roots } sets the two independent limits. Both must be
positive safe integers with a safe sum; null, partial objects and extra fields are
rejected before either pool is allocated. The values are copied at construction;
mutating the configuration cannot resize live pools. Checkout/connect timeout
remains five seconds and idle timeout ten seconds. Generic driver overrides and
public pool handles are not provided. Migrations have their own two max1 pools,
always closed on completion/failure; application sizing does not tune migrations.
runtime.stores.audit writes to the audit tables the migration already installs in
the execution namespace, on the execution pool, so audit writes share that budget.
Audit history a host kept in another schema stays there; retention must cover the
execution namespace, as it already must for its settlement rows.
Budget sum(runtime instances × (execution + roots)) plus migrations, retention,
backup, other pools and an administrative reserve against database capacity.
Count every replica/worker class and reuse a runtime across its turns/observers.
Connection limits do not bound worker concurrency or driver waiter queues. Host
queue limits remain necessary; pressure cannot authorize retries or release
uncertain admission. The deterministic max1 tests prove correctness with minimal
capacity, not production throughput or a recommended server configuration.
Register sessions and both result stores in the host's audited retention and
erasure workflows. Erasing one result store does not erase the other. Quiesce workers
before close(); it attempts both pools and is idempotent. Closing neither aborts
nor releases uncertain admissions. Resolve reconciliation_required through the
existing operator procedure, never by issuing a new delivery key.
Maintenance: one entry point for interrupted work
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; the host names no scope
await maintenance.close();maintain finds the scopes with due work and sweeps them, taking the least
recently visited first (spec: runtime-maintenance). It can find:
- expired admissions, which become uncertain and are never released;
- expired roots and executions, which are fenced;
- executions that can complete from their retained results;
- pending usage.
The report holds counts only. Nothing is logged, and the sweep never dispatches: it has no model client.
Provision two new logins, both without superuser, BYPASSRLS or ownership:
- Discovery: a member of
alma_maintenanceonly. That role is created bymigratePostgresRuntime. It sees identifiers, states and times across scopes, never content. - Worker: a member of
alma_apponly, and not the login of your request path. It works one scope at a time with the application's authority.
Neither login may be a member of alma_retention, or of the other's role.
Every call checks this and throws before doing any work. Change these
memberships with maintenance stopped.
Scopes are claimed with session advisory locks, so concurrent callers skip each
other's scopes. Each scope gets a budget of 50 items per call for each kind of
work, and its own cursors. A scope that never clears comes back on the next lap
without starving the others. Retention and erasure stay with the alma_retention
operator. The memory runtime has no maintenance.
Receipt projections
import { createPostgresProjections } from '@alma-harness/runtime/projections';
const projections = createPostgresProjections({ schema, rootSchema, stepResultPolicy, rootResultPolicy, onPoolError,
discovery: { connectionString: projectionURL }, worker: { connectionString: workerURL } });
const report = await projections.drain('cost-dashboard', async delivery => {
// { scope, consumer, settlementId, receipt, attribution, signal }; deduplicate on (scope, consumer, settlementId)
}, { limit: 50 });
await projections.quiesce(scope, () => erasure.erase(scope, request), { timeoutMs: 30_000 });drain finds the scopes with pending governed receipts for one consumer and
delivers each receipt with its session attribution (spec: receipt-projections).
Attribution is one of available (with labels), erased or none; erased is
never reported as none, so never fall back to labels you kept. A receipt is
acknowledged only after the handler resolves. A crash in between redelivers the
same key, so commit the deduplication and the effect in one transaction.
Delivery is fair and never loses a receipt:
- a receipt that always fails is passed over and retried on the next lap;
- laps are finite, and a late settlement is reached on a later lap.
The report holds counts only.
Logins. Provision two, as for maintenance:
- a discovery login that is a member of
alma_projectiononly. It sees pending receipt identities and times, never payloads; - a worker login that is a member of
alma_apponly, and not your request path's login.
Every call checks both. The database does not separate consumers from each other. Give each consuming service its own pair of logins, and drain only the consumer names that service owns.
Erasure: quiesce, and fence any destination that stores labels.
quiesce waits for a drain working the scope, holds new ones off, and runs
your erasure. If the erasure reports complete: false, quiesce rejects with
IncompleteErasureError. A drain whose claim is lost aborts delivery.signal
and starts no further receipt.
No Alma lock can stop a write a handler already began. So a destination that
keeps label values must serialize with erasure through gate rows. The
empty session '' is the scope gate.
create table gates (org text, uid text, session text, erased boolean not null default false, primary key (org, uid, session));
-- publication, one transaction:
insert into gates (org, uid, session) values ($org, $uid, ''), ($org, $uid, $session) on conflict do nothing;
select erased from gates where org = $org and uid = $uid and session in ('', $session) for share; -- held until commit
-- write the labels only if no row is erased; otherwise write the receipt without them. Then commit.
-- erasure, inside quiesce, one transaction, two statements:
insert into gates (org, uid, session, erased) values ($org, $uid, $session, true)
on conflict (org, uid, session) do update set erased = true; -- waits for a publication holding the gate
update your_rows set labels = null where org = $org and uid = $uid and session = $session; -- a new snapshot, after the waitKeep the clearing in its own statement. In READ COMMITTED, a single statement
keeps the snapshot it took before waiting on the gate, so it would miss the row
the waiting publication just committed. For a scope-wide erasure, do the same
with the '' gate and every row of the scope. A consumer that stores receipts
without labels needs no fence.
Conversation operations
@alma-harness/runtime/operations exports createConversationOperations. Supply
runtime stores for metadata-only inspect, an existing step recovery capability for
accounting-only repair, and separately privileged admission resolution plus a
trusted maintained host quiescence capability for resolve. No default operator,
provider clients, effect replay or automatic takeover is supplied.
The installed alma-operations CLI accepts inspect/repair/resolve, an explicit
local --host module and bounded JSON stdin. The host owns credentials and cleanup.
See the operator runbook and its real
PostgreSQL installed-consumer drill. Unknown/unpriced or late-only evidence stays
blocked; release never marks an incomplete root successful. Expiry sweeps are
explicitly scope-wide. A stale digest requires reinspection, while lost resolution
ACK retries retain the same resolutionId. This is operator infrastructure, not a
request-path fallback or a remote-worker fencing implementation.
runtime.stores.labels is the session label store (spec: session-labels), in
memory or in the execution namespace on PostgreSQL; migratePostgresRuntime
installs it. Wire it into erasure as createMemoryErasure({ sessionLabels }).
