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). Each one is
version-negotiated before its payload is read — see
Version negotiation:
| 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.
Every operation that carries a schemaVersion is negotiated before its
payload is interpreted, so a consumer never reads a shape this build does not
speak. negotiateSchemaVersion in core/contract/contract.ts is the boundary;
negotiateSchemaVersionOrError returns the same answer as the shared Result
for callers that switch on the code.
Four outcomes, because they call for four different responses:
| Requested | Code | guidance |
Meaning |
|---|---|---|---|
| absent / empty | schema_missing |
upgrade |
No version was sent. A caller bug: nothing about the payload can be trusted, and falling back would mean guessing a version. |
| older, still served | — (served) | — | Served, and reported with status: "deprecated". The payload is valid; plan the upgrade before it stops being served. |
| newer than current | schema_future |
safe_fallback |
The payload may mean something this build cannot read. Ignore it and use defaults; retrying only helps once the consumer is upgraded. |
| anything else | schema_unsupported |
upgrade |
Not served by this build, and not parseable as a version either. The refusal lists the versions that are served. |
retry is reserved for a genuinely transient version problem and is not
produced today; it exists so a future code has a home rather than overloading
upgrade.
const outcome = negotiateSchemaVersionOrError({
requested: payload.schemaVersion,
current: EXPORT_CURRENT_SCHEMA_VERSION,
deprecated: ["0.9"]
});
if (!outcome.ok) {
// outcome.detail.guidance tells a caller whether to retry, upgrade, or fall back.
return err(outcome.code, outcome.detail);
}Two rules for changing any of this:
- a new older version that is still served must be added to the
deprecatedlist, not to a new code — a consumer that handled a deprecation keeps working when the version is finally dropped, and the transition is a documentation change rather than a new failure mode - a version string that is not
major.minoris refused asschema_unsupportedrather than coerced."latest"must never be resolved to whatever happens to be current.
The fixtures in core/contract/fixtures/schemaVersions.ts cover each row above,
and core/contract/__tests__/schemaVersion.test.ts negotiates every one of them.
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.