Utix is a read-only browser toolkit: there is no HTTP service to curl and no
API key to mint. The "public API" is therefore the set of operation shapes
that integration points and downstream consumers rely on. This document is the
single source of truth for those shapes, and core/contract/ enforces them
with drift tests that fail CI whenever a response shape changes incompatibly.
Locked operations (see core/contract/__tests__/contract.test.ts):
| Operation | Shape |
|---|---|
telemetry.event |
Structured record emitted by core/telemetry |
horizon.error |
Classified failure from classifyHorizonError |
worker.job |
Job payload from core/workers |
export.envelope |
Export artifact from core/export |
feature.manifest |
Registry metadata every feature publishes |
lifecycle.state |
Derived state view shared by the UI and API responses |
idempotency.record |
Persisted outcome replayed for a retried write |
reconciliation.report |
Read-only dry-run drift report |
- Results are
{ ok: true, value }or{ ok: false, code, detail? }— the sharedResulttype. Never throw for expected failures. - Error codes are stable strings (
not_found,rate_limited,export_denied,schema_unsupported, …). The UI maps each code to recovery copy; consumers can switch on codes, not on English messages. - Auth: read-only by design. No secrets are ever accepted or returned.
The only authorization decision is export scope — a plain
useractor may requestscope: "own"only;scope: "maintainer"requires a maintainer actor, otherwiseexport_denied. - Pagination: exports paginate with
pageSize/page; the envelope always carriesrecordCountandtotalRecords.
Every field is required.
{"op":"horizon.request","actorType":"client","result":"failure","latencyMs":10012,"correlationId":"8f0c…01","timestamp":"2026-09-25T12:00:00.000Z","errorCode":"timeout"}{"op":"export.generate","actorType":"user","result":"success","latencyMs":2,"correlationId":"8f0c…02","timestamp":"2026-09-25T12:00:01.000Z"}Output of classifyHorizonError(error): { code, detail }.
{"code":"rate_limited","detail":{"status":429}}{"code":"not_found","detail":{"status":404,"title":"Resource Missing","detail":"The resource at the url requested was not found."}}{"id":"…","operation":"maintenance.prune_horizon_clients","params":{"network":"mainnet"},"maxAttempts":3,"attempts":0,"status":"queued","enqueuedAt":"2026-09-25T12:00:00.000Z","correlationId":"8f0c…03"}{"id":"…","operation":"flaky.process","attempts":3,"status":"dead_lettered","lastError":{"code":"Error","message":"naive failure"},"enqueuedAt":"2026-09-25T12:00:00.000Z","correlationId":"8f0c…04"}Success:
{"schemaVersion":"1.0","scope":"own","generatedAt":"2026-09-25T12:00:00.000Z","expiresAt":"2026-09-26T12:00:00.000Z","generator":"utix-export@1","correlationId":"8f0c…05","recordCount":2,"page":1,"totalRecords":2,"records":[{"op":"wallet.detect","result":"success","recordType":"operation_log","scope":"own"}]}Failure (authorization):
{"ok":false,"code":"export_denied"}Failure (version):
{"ok":false,"code":"schema_unsupported"}What manifest.ts publishes for every tool:
{"slug":"payment-qr","title":"Payment QR Generator","description":"Build a SEP-0007 payment request URI and render it as a scannable QR code.","category":"payments","status":"working"}stateView(kind, state) from core/lifecycle/records.ts. The UI and an API
consumer receive the same object: the label, the tone and the legal events all
come from the lifecycle table, so a badge can never disagree with a payload.
{"kind":"worker_job","state":"retrying","label":"Retrying","tone":"warning","terminal":false,"allowedEvents":["start","fail","exhaust","retry","dead_letter"]}{"kind":"notification","state":"purged","label":"Purged","tone":"danger","terminal":true,"allowedEvents":[]}A rejected transition is a Result, not an exception:
{"ok":false,"code":"terminal_state"}Codes: unknown_record_kind, unknown_state, unknown_event,
invalid_transition, terminal_state. See
LIFECYCLE.md.
A retried write replays this record instead of repeating the side effect.
{"key":"export:2026-09-26:1","operation":"export.generate","requestHash":"req-37be50f9","status":"completed","response":{"recordCount":2},"createdAt":"2026-09-26T00:00:00.000Z","expiresAt":"2026-09-27T00:00:00.000Z","replays":1,"correlationId":"8f0c…06"}Refused keys:
{"ok":false,"code":"idempotency_key_conflict"}Codes: idempotency_key_missing, idempotency_key_invalid,
idempotency_key_expired, idempotency_key_conflict, idempotency_in_flight,
idempotency_not_found. See IDEMPOTENCY.md.
Read-only output of a dry run. dryRun is always true; there is no repair
field that mutates anything.
{"runId":"…","startedAt":"2026-09-26T00:00:00.000Z","finishedAt":"2026-09-26T00:00:00.000Z","dryRun":true,"checks":[{"invariant":"balance.amount_agrees","checked":4,"findings":1}],"findings":[{"id":"balance.amount_agrees:G1|USDC","invariant":"balance.amount_agrees","kind":"inconsistent","severity":"critical","subject":"stored_balance","recordId":"G1|USDC","detail":"Stored USDC balance disagrees with ledger reference ledger:100.","expected":"10.51","observed":"10.50","repair":"Treat the ledger as authoritative and re-cache the line. …"}],"summary":{"missing":0,"duplicate":0,"stale":1,"inconsistent":1,"total":2},"clean":false}kind is one of missing, duplicate, stale, inconsistent. See
RECONCILIATION.md.
One event per sensitive action. before/after hold primitives only, so an
event can never carry a payload.
{"id":"4b16c3b6-85b7-4d43-8abb-79491d0fe612","action":"record.state_changed","actor":{"kind":"maintainer","id":"ada"},"actorId":"ada","scope":"maintainer","target":{"kind":"worker_job","id":"job-1"},"reason":"manual_dead_letter","outcome":"allowed","before":{"state":"retrying"},"after":{"state":"dead_lettered","event":"dead_letter"},"at":"2026-09-26T00:00:00.000Z","correlationId":"8f0c…06"}action is one of record.state_changed, record.transition_denied,
export.generated, notification.published, notification.cleared,
reconciliation.reported, idempotency.claim_released,
quota.override_granted, quota.override_revoked. outcome is allowed
or denied, in which case errorCode carries the refusal. Refused reads:
{"ok":false,"code":"audit_denied"}Codes: audit_denied, invalid_audit_field, invalid_filter,
audit_not_found. See AUDIT.md.
npm run test (or npm run check) runs core/contract/__tests__/contract.test.ts,
which captures the real output of each module and validates it against the
locked schemas above. Adding a required field to a response without updating a
contract, or omitting one, fails the suite:
npm run test -- core/contractTo change an intentionally breaking contract, update this document and the
locked ContractSchema in the drift test in the same commit.