usertrust
API Reference

Errors

Twelve domain error classes with structured hints and documentation URLs.

usertrust defines twelve domain-specific error classes. All extend Error and include structured hint and docsUrl properties for actionable diagnostics. A thirteenth exported class, UnheldSettlementRateLimitedError, is thrown only when recordUnheldSettlement() refuses a record because too many arrived in a window; it carries retryAfterMs instead of a hint.

Catching Errors

import {
  InsufficientBalanceError,
  PolicyDeniedError,
  LedgerUnavailableError,
} from "usertrust";

try {
  const { response, receipt } = await client.messages.create({
    model: "claude-fable-5",
    max_tokens: 1024,
    messages: [{ role: "user", content: "Hello" }],
  });
} catch (err) {
  if (err instanceof InsufficientBalanceError) {
    console.log(`Need ${err.required} UT, have ${err.available} UT`);
    console.log(err.hint);
  }
  if (err instanceof PolicyDeniedError) {
    console.log(`Blocked: ${err.reason}`);
  }
  if (err instanceof LedgerUnavailableError) {
    console.log(`TigerBeetle down: ${err.cause_message}`);
  }
}

Each of the twelve follows the same pattern: a descriptive message, a hint with a suggested fix, and a docsUrl pointing to detailed documentation.


InsufficientBalanceError

Thrown when the requested LLM call would exceed the remaining budget.

class InsufficientBalanceError extends Error {
  readonly userId: string;
  readonly required: number;    // usertokens needed
  readonly available: number;   // usertokens remaining
  readonly hint: string;
  readonly docsUrl: string;
  readonly auditEventHash?: string;   // the `ledger_rejected` event's hash
  readonly auditDegraded?: boolean;   // the append failed (and was dead-lettered, when that write succeeded)
}

When it fires: During the PENDING hold phase, before the LLM call is forwarded to the provider. No tokens are consumed.

Hint: "Increase the budget in trust() options or add funds via the ledger." for an unattributed call. For a call attributed to a cost center, the controller substitutes "Fund this cost center with allocateBudget(parent, costCenter, amount) — or reclaim from another envelope. Raising the trust()/createGovernor() budget funds the session wallet, which an attributed call never debits.", because the default advice does not fund the wallet an attributed call debits.


PolicyDeniedError

Thrown when a policy rule (including the built-in budget rules), the PII detector, the injection detector, or unknownModelPolicy: "deny" blocks the request.

class PolicyDeniedError extends Error {
  readonly reason: string;
  readonly hint: string;
  readonly docsUrl: string;
  readonly auditEventHash?: string;   // the `policy_denied` event's hash
  readonly auditDegraded?: boolean;   // the append failed (and was dead-lettered, when that write succeeded)
}

When it fires: During policy evaluation, before the LLM call. The reason field describes which rule, PII type, injection pattern, or unknown model triggered the denial. On any client (Anthropic, OpenAI or Google) it also fires when you call an SDK method usertrust does not govern and does not list as free: the reason starts ungoverned_method: <path> (for example ungoverned_method: embeddings.create), the promise rejects without calling the SDK, and trust({ allowUngoverned: ["<path>"] }) lets that method through, audited as ungoverned_call.

Hint: depends on what denied the call. The class default is 'Check your policy rules in .usertrust/policies/default.yml, or set "pii": "warn" in .usertrust/usertrust.config.json (or pass { pii: "warn" } to trust()) to downgrade PII enforcement.', but the controller overrides it at its throw sites. A budget-family rule points at the trust() budget and max_tokens (or at allocateBudget for an attributed call), PII at { pii: "warn" } or { pii: "redact" }, injection at { injection: "warn" }, and an unknown model at pricing: "custom" with a customRates entry.

To change PII enforcement, set pii in the config file trust() reads: .usertrust/usertrust.config.json by default, or the file you pass as configPath (which is read instead, so edit that one), or pass pii to trust() itself (e.g. { pii: "warn" }), which overrides the file.


The denial correlation handle

Both denial errors above carry auditEventHash — the hash of the chain event the controller wrote when it refused the call. It is the join between the exception your code logged and the record an auditor reads: policy_denied for a controller decision (a policy rule, a budget-family rule, PII, injection, an unpriceable model, or a refused ungoverned SDK method on any provider), ledger_rejected for the ledger's own atomic refusal of the hold.

try {
  await client.messages.create({ /* ... */ });
} catch (err) {
  if (err instanceof PolicyDeniedError) {
    // Quote this in your incident ticket: it is the hash of the denial's event on the audit chain.
    console.log(`denied, chain event ${err.auditEventHash ?? "(none)"}`);
  }
}

The two fields answer different questions, and you need both:

auditEventHashauditDegradedWhat happened
setabsentThe denial is on the chain, at that hash.
settrueThe event is on the chain at that hash, but the write reported failure — the log was fsync'd and the .meta sidecar was not. The handle is usable; run usertrust verify on the vault.
absenttrueThe append was attempted, nothing durable landed, and the payload was dead-lettered to .usertrust/dlq/ when that write succeeded. There is no chain record.
absentabsentNo append was attempted — an error built by hand, or thrown by a version before denial events existed.

A failed denial-append never changes the error you receive. audit.failClosed exists to stop an unaudited spend from settling; a denial already refused the call and moved no money, so escalating here would only replace an actionable, typed denial with an AuditDegradedError and hide why the call was refused.

Indirect callers do not receive the handle yet. usertrust-server and the ACS adapter convert the error to a wire response and do not forward auditEventHash today; the field is on the in-process error object only.


AccountNotFoundError

Reserved for a missing TigerBeetle account for the given user. Exported, but never thrown.

class AccountNotFoundError extends Error {
  readonly userId: string;
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: Never. The class is defined and exported, but no code path throws it: a missing cost-center envelope account is reported as InsufficientBalanceError with available: 0, and any other failure of the hold, including a missing session wallet, surfaces as LedgerUnavailableError. Keep the instanceof branch only for forward compatibility.

Hint: 'Run "npx usertrust init" to create accounts, or verify the userId matches your config.'


IdempotencyConflictError

Reserved for a transfer ID that has already been submitted to TigerBeetle. Exported, but never thrown.

class IdempotencyConflictError extends Error {
  readonly key: string;
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: Never. The class is defined and exported, but no code path throws it. A transfer id the ledger already holds is answered by the ledger client with subclasses of TBTransferError instead: TransferReplayMismatchError when the stored transfer differs from the replay, PendingReplayError when a PENDING id is replayed (it is not read as a live hold), and TransferIdRetiredError when the first attempt under that id failed.

Hint: "This transfer was already submitted. Use a unique transferId for retries."


LedgerUnavailableError

Thrown when TigerBeetle is unreachable. The LLM call is not forwarded to the provider.

class LedgerUnavailableError extends Error {
  readonly cause_message: string;
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: Before any LLM call, during the connection check or PENDING hold attempt. This is failure mode 15.4 -- no tokens are at risk because no hold was placed. trust(), createGovernor() and usertrust budget first open a plain TCP connection to each configured address; when every one refuses (within 1 s for a loopback address, 3 s for a remote one), they throw with No ledger is running at <address>. Fix: .... When something is listening but no usertrust ledger answers (another service on the port, or a cluster formatted with another id), the first request is bounded by tigerbeetle.connectTimeoutMs (default 5000) and the error says so. When the cluster evicts the client, the error says the evicted client cannot be released and the process must exit on its own.

Hint: the hint depends on what the controller found. It says to:

  • Nothing listening at the default ledger (127.0.0.1:3001, cluster 0): run npx usertrust tb start, or pass { dryRun: true } to skip the ledger
  • Nothing listening at any other address or cluster: start TigerBeetle at the named address and cluster, or pass { dryRun: true } to skip the ledger
  • Something listens, but no usertrust ledger answers: run npx usertrust tb status, or pass { dryRun: true } to skip the ledger
  • The cluster evicts the client: run a TigerBeetle server of the same release as the bundled client (0.17.9)
  • Any other ledger failure, with no hint of its own: Start TigerBeetle 0.17.x (tigerbeetle format … then tigerbeetle start --addresses=<address>) or use { dryRun: true } to skip the ledger., where <address> is the ledger the controller was configured with (127.0.0.1:3001 by default).

usertrust tb start does not manage TigerBeetle in this version: it prints the same format and start commands. Follow the CLI page's usertrust tb section to run a node yourself, or pass { dryRun: true } to skip the ledger entirely.

When TigerBeetle is unreachable, usertrust refuses to forward the request. This is by design -- without a ledger, budget enforcement is impossible.


AuditDegradedError

Thrown (or flagged) when an audit chain write fails. Usually that is after the LLM call and ledger settlement both succeed. Under audit.failClosed a trust() client throws it earlier in two cases (below): at the start of a call on a client whose audit writer is already degraded, before anything is charged, and when the call's own primary record (the llm_call event, or an action's own event) fails to append, before the ledger POST.

class AuditDegradedError extends Error {
  readonly cause_message: string;
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: Usually after the LLM call succeeds and the ledger POST settles. This is failure mode 15.3. The response is still returned to the caller with auditDegraded: true on the receipt (except that a failed warn-mode injection_detected append is swallowed without marking the call or its receipt). The failed event is written to the dead-letter queue when that write succeeds. Under audit.failClosed, a failed primary record (the call's own llm_call event, or an action's own event) refuses the call before its POST, and the hold is released by a void request (the void is best-effort: see the callout below); a failure of any later record is refused only after the charge posts. Also under audit.failClosed, a trust() client whose audit writer was already degraded by an earlier failed write throws it at the start of every later call instead: the call is refused before it reaches the provider, and nothing is charged. Recreate the client after fixing the audit directory.

Hint: "Check disk space and permissions on the .usertrust/audit/ directory."

By default usertrust does not fail a call because the audit write degraded: if the LLM call succeeded and the ledger settled, the response is returned with auditDegraded: true (except that a failed warn-mode injection_detected append is swallowed without marking the call or its receipt, though the client is then degraded), the event is written to the dead-letter file when that write succeeds (a sidecar-only failure, where the event was fsynced but its .meta write failed, still rejects the append and still writes a dead letter carrying the event's hash; under audit.failClosed the call is refused before its POST only when the failed record is the call's primary llm_call record; the same sidecar failure on a later record, such as the settlement_shortfall append after a capped post, is refused only after the charge has posted), and usertrust verify reports any dead-lettered event missing from the chain. If the dead-letter write fails too and leaves no bytes, the gap is visible only to usertrust verify --receipts with your own receipts (it sees a missing primary spend record, the receipt's own llm_call or action event, only; a lost auxiliary record such as a settlement shortfall, a detection, an implausible-usage or ambiguous-settlement record is invisible to it, as to verify and health when its dead letter was not written; a partial dead-letter line left by a short write is read as an unreadable entry, so verify reports INCOMPLETE), and usertrust health is blind there as well. With trust() and audit.failClosed: true the call is refused with AuditDegradedError instead, and whether it was charged depends on when the client's audit writer degraded. If the writer was already degraded when the call started, the call is refused at its start, before any policy check, hold, provider call or action runs (after the check that the client was not destroyed, and for an action after its cost input is validated): no hold, no spend, nothing charged. If the writer degrades during a call, the outcome depends on how the call ran: a non-stream call or action whose own audit record is written settles and is then refused after its charge posts; one whose own record fails is refused before the POST and not charged (the hold is released by a void request; the void is best-effort, so if the ledger refuses it the hold stays reserved until it expires, up to 300 s); a streamed call always settles before its record is written, so it may have been charged; wherever the settlement POST failed, the outcome is unknown. Treat an AuditDegradedError as possibly charged unless its message says nothing was charged. A retry does not undo a charge that posted: interceptCall claims the settle outcome before it POSTs and its outer catch voids only a hold still pending, so the posted transfer stays, and the retry is a new call with a new transfer id; on the same client it is refused at its start until the client is recreated. A messages.stream() call is refused only through its .receipt: await it, because the helper's iteration, done() and finalMessage() can still succeed. A client's writer stays degraded until the client is destroyed, so every later call on it is refused at its start in this way until the client is destroyed and recreated; the mark is per client, so another client on the same vault is not affected. createGovernor() and usertrust-server do not enforce audit.failClosed: they report auditDegraded and do not refuse.


VaultNotInitializedError

Reserved for a missing .usertrust/ vault directory. Exported, but never thrown.

class VaultNotInitializedError extends Error {
  readonly path: string;
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: Never. trust() does not require an initialized vault: a missing usertrust.config.json falls back to defaults and the audit writer creates .usertrust/audit/ itself. The class is defined and exported but no code path throws it; the credential vault throws VaultKeyMissingError instead when the master-key env var is unset.

Hint: 'Run "npx usertrust init" to create the vault directory.'


VaultKeyMissingError

Thrown when the vault is opened and the environment variable that holds the vault master key is not set.

class VaultKeyMissingError extends Error {
  readonly envVar: string;     // the variable that was checked, e.g. USERTRUST_VAULT_KEY
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: In createVaultStore(), before any credential is read or written. The variable name comes from vault.masterKeyEnv in config (default USERTRUST_VAULT_KEY). The only caller is the usertrust secret command, so this error surfaces from the CLI rather than from an LLM call. No tokens are consumed.

Hint: "Set the USERTRUST_VAULT_KEY environment variable to the vault master key." — the variable named in the hint is whichever one was checked.

Docs URL: https://usertrust.ai/docs/errors/vault-key-missing


SpendLedgerUnreadableError

Thrown when .usertrust/spend-ledger.json exists but cannot be read or interpreted: a read error other than a missing file, invalid JSON, no numeric budgetSpent field, or a negative or non-finite value.

class SpendLedgerUnreadableError extends Error {
  readonly cause_message: string;   // "<path>: not valid JSON", "<path>: EACCES ...", ...
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: At startup, inside trust() or createGovernor(), when the persisted cumulative spend is loaded to seed the in-process budget and the TigerBeetle enforcing wallet. No LLM call has been made.

Hint: "Fix permissions on .usertrust/spend-ledger.json, restore it from a snapshot, or delete it to reset cumulative spend to zero."

Docs URL: https://usertrust.ai/docs/errors/spend-ledger-unreadable

Why not zero. A missing ledger means nothing has been spent yet, which is true on a first run, so it reads as zero. An unreadable ledger means an unknown amount has been spent. Defaulting that to zero would re-grant the full budget both in-process and in the enforcing wallet, which is seeded with max(0, budget - budgetSpent). Deleting the file is a deliberate reset; usertrust will not make that choice for you.


CredentialAccessDeniedError

Represents a credential whose vault scope does not permit the requesting agent or action.

class CredentialAccessDeniedError extends Error {
  readonly credentialName: string;
  readonly reason: string;
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: It does not. The class is exported from usertrust but no code path constructs it. A scope denial in vault.get() returns { granted: false, reason } and appends a credential_denied audit event instead of throwing. You will only see this error if your own code, or a later release, throws it.

Hint: "Check the credential scope in .usertrust/credentials.enc or update the scope with `usertrust secret add --scope`."

Docs URL: https://usertrust.ai/docs/errors/credential-access-denied

No such flag. That hint is the error's own text, quoted verbatim, and usertrust secret add takes only <name> <value>; there is no --scope flag, and a credential added from the CLI always receives the vault.defaultScope from config. Set the scope there.


SkillVerificationError

Thrown when a skill manifest fails schema validation during supply-chain enforcement.

class SkillVerificationError extends Error {
  readonly skillId: string;    // manifest.id, or "unknown" when the manifest has none
  readonly reason: string;     // "Schema validation failed: ..."
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: In enforceSkillLoad(), at the first step of the verification pipeline, and only while supplyChain.enabled is true (the default). usertrust skill verify does not raise it: the CLI checks the manifest with validateManifest() first, prints Schema error: ... for malformed input and returns before it reaches enforceSkillLoad(). enforceSkillLoad() is exported, so an SDK caller that passes it a manifest failing the schema meets this error directly, before any code is executed. A missing registered key, a bad signature, an entryHash mismatch, or a denied permission does not throw; those come back as a SkillVerification result with valid: false and an error string.

Hint: "Verify the skill manifest is signed by a trusted publisher, or add the publisher to supplyChain.trustedPublishers."

Docs URL: https://usertrust.ai/docs/errors/skill-verification

Hint vs cause. The hint talks about signatures and supplyChain.trustedPublishers, but the only condition that throws is a manifest that does not match the schema. Read reason first: it names the failing fields. Publisher trust is anchored by supplyChain.publisherKeys, the hex-encoded Ed25519 keys you register per publisher, not by a key embedded in the manifest.


AnomalyError

Thrown mid-stream when the streaming anomaly detector trips on token rate, spend velocity, or an injection cascade. Detection is off by default.

class AnomalyError extends Error {
  readonly kind: "token_rate" | "spend_velocity" | "injection_cascade";
  readonly metric: number;      // the observed value that tripped
  readonly threshold: number;   // the configured limit it crossed
  readonly hint: string;
  readonly docsUrl: string;
}

When it fires: After the LLM call has been forwarded, while chunks are being consumed from a streaming response, and only when anomaly.enabled is true (default false). The detector observes every chunk; on a tripped verdict usertrust appends an anomaly_detected audit event, throws this error from the stream, releases the in-flight hold and voids it, records a breaker failure, and appends a stream_partial_delivery event. The provider has already produced the chunks delivered so far; the ledger hold is voided rather than settled.

Hint: "The streaming circuit breaker tripped on anomalous behavior. Tune anomaly thresholds in config or call detector.reset() to clear the trip."

Docs URL: https://usertrust.ai/docs/errors/anomaly-detected — note the slug is anomaly-detected, not the class name.

Thresholds live under anomaly in config: tokenRate.thresholdTokPerSec (default 500; 5000 for local models), spendVelocity.thresholdDollarsPerMin (default 1.0), injectionCascade.eventCount (default 3 in 60 s), and cooldownMs (default 30000), after which the detector resets itself.

Two breakers. This is not the per-provider circuit breaker on the Circuit Breaker page, which raises CircuitOpenError. The detector that trust() builds is internal and is not returned to you, so the hint's detector.reset() applies only to a detector you construct yourself with the exported createAnomalyDetector(); for the built-in one, wait out cooldownMs. On the Anthropic messages.stream() helper path a trip aborts the stream instead of throwing this error.


Error Summary

ErrorFailure ModeLLM Call Forwarded?Tokens At Risk?
InsufficientBalanceErrorPre-callNoNo
PolicyDeniedErrorPre-callNoNo
AccountNotFoundErrorNot thrown——
IdempotencyConflictErrorNot thrown——
LedgerUnavailableError15.4NoNo
AuditDegradedError15.3Yes when it fires after the provider call (the usual case, and a failed primary record under audit.failClosed); No when audit.failClosed refuses a call at its start on an already-degraded clientSettled and charged in the usual case; nothing is charged on a start-of-call refusal or a failed primary record (the hold is released by a best-effort void request, see above)
VaultNotInitializedErrorNot thrown——
SpendLedgerUnreadableErrorStartupNoNo
AnomalyErrorMid-streamYes (aborted)No (the ledger hold is voided; the provider can still bill the tokens it generated)
VaultKeyMissingErrorVault openNoNo
SkillVerificationErrorSkill verificationNoNo
CredentialAccessDeniedErrorNot thrown——

All pre-call errors are thrown before any interaction with the LLM provider. No tokens are consumed and no budget is affected.