Docs Sign in Start building

Connect your bank to Hyperscale with ADL

Arc reaches a bank's core through an ADL declaration: one JSON file that names the bank's paths, the status codes it answers with and what each code means, where each plane's calls go and how they authenticate. Arc builds every request from the file and reads every answer through it. Your bank writes no code for Arc and runs nothing of Arc's.

A self-hosted Arc loads declaration files at boot. A hosted Arc calls only the adapters its release compiles. The account owner reads the adapters Arc calls at GET /v1/adapters in the HTTP API, or with hyperscale adapters list.

The declaration

A declaration file is a JSON object with these keys. Arc refuses a key it does not know, so a typo never reads as a missing field.

Key Required What it declares
provider yes The adapter's key, as HSX names it: lowercase, starting with a letter, as core_bank.
capability yes boundary for a bank core.
bindings yes Each resource kind the operations touch and how strongly a provider answer binds it.
operationMap yes Each operation: its kind, its request template, the state family that reads its answer, its evidence.
states no The state families: the field holding the bank's status code, and each code's outcome.
money no The currencies and limits the bank takes.
environments no The sandbox and live planes: a base URL and the credential each sends.
credentials no The credentials the planes name, by kind. A declaration never holds a secret.
apiVersion no A header and value sent on every call.
idempotency no The header that carries the instruction or call id, how long the bank keeps it, and its mismatch code.
callDeadline no How long Arc waits for a call's final answer before it lapses, as 15m. Every call kind needs one.

A request template uses $ values that Arc fills from the instruction or call, as $instructionId, $amount, $currency, $payee and $debitReference. A template value the instruction lacks stops the call before it is sent, so nothing reaches the bank half filled. HTTP status never decides an outcome: only a status code the declaration maps to confirmed posts money, and only one mapped to rejected voids it.

The ADL authoring guide, adl/docs/authoring-guide.md in the Hyperscale repository, covers bindings, state families, money and operations field by field. The fixture bank's declaration, adl/src/fixture-bank.ts, is a complete example.

Call kinds

An operation's kind says what Arc asks it for. A Product that mirrors its accounts at a bank calls these eight:

Kind What Arc asks for
account.provision Open an account under a master, or a collection master; answer the reference and IBAN
account.lookup Read one account back
account.post Mirror a Hyperscale move between two of the bank's accounts
account.balance The booked and available balance now, and the day's opening balance
account.statement One finished day's bookings
account.intraday The current day's bookings after a cursor
credit.observe Credits and debits booked on a master's accounts after a cursor
payout Send a payout to its payee from a named account

A payout also uses enquire to ask after an instruction the bank has not finished, and a batch of payouts uses payout.batch. Card, message and bill kinds (authorize, capture, void, refund, message.send, message.observe, bill.enquire) are there for banks that offer them.

Each call carries the request values its kind lists:

Kind Request values
payout $amount, $currency, $payee, $debitReference, optional $refundOf
capture $amount, $currency
adjust $amount, $currency
void $currency
account.provision $currency, optional $masterReference (none opens a master)
account.lookup none beyond $instructionId
account.post $amount, $currency, $legs, optional $resubmitOf
account.balance none beyond $instructionId
account.statement $date
account.intraday $cursor (0 for the day's first booking)
message.send $messageType (the kind the program names; the adapter owns the format), $document, optional $resubmitOf
message.observe $messageReference
credit.observe $masterReference, $cursor
bill.enquire $biller, $billNumber

An HSX program names the adapter by its key, on a payout's adapter: or a move's boundary { adapter: ..., operation: ... } line.

The connection and its credentials

Each plane names a base URL and the credential its calls send. This bank takes an OAuth client on its test core and a client certificate on its live one:

"environments": {
  "sandbox": { "baseUrl": "https://core-test.bank.example", "credential": "test_client" },
  "live": { "baseUrl": "https://10.20.0.7/core/v1/", "credential": "live_client" }
},
"credentials": {
  "test_client": { "kind": "oauth_client", "tokenUrl": "https://core-test.bank.example/oauth/token", "scopes": ["payments"] },
  "live_client": { "kind": "mtls", "certificate": "bank_client" },
  "events": { "kind": "hmac_sha256", "header": "Bank-Signature", "toleranceSeconds": 300 }
},
"idempotency": { "header": "Idempotency-Key", "lifetime": "24h", "mismatchCode": "DUPL" }

A base URL is https with no user, query or fragment. A sandbox plane may also name a literal loopback address over plain http, as http://127.0.0.1:3010, for a core on the same machine. A live plane never names loopback.

The declaration names each credential's kind; the tenant sets its value with hyperscale providers set, sealed under the tenant's key in Arc's database. Arc unseals it for one call, in memory, and never writes it to a log.

Kind Declares The value the tenant sets
api_key header, and scheme written before it The key.
mtls certificate, the profile it must carry {"cert": "<PEM chain>", "key": "<PEM key>", "ca": "<PEM>"}; ca is optional.
oauth_client tokenUrl (https) and scopes {"clientId": "...", "clientSecret": "...", "ca": "<PEM>"}; ca is optional.
hmac_sha256 header and toleranceSeconds (1 to 3600) The secret the bank signs its pushed events with.

The CLI builds the JSON values from files and asks for your password hidden:

hyperscale providers set --adapter core_bank --credential live_client \
  --cert client.pem --key client.key --ca bank-ca.pem
hyperscale providers set --adapter core_bank --credential test_client \
  --client-id arc --client-secret-file secret.txt

An mtls call hands the certificate and key to that call's TLS handshake, and ca replaces the public roots when the bank's server certificate chains to a private CA. An unencrypted PEM key is required. An oauth_client call trades the client id and secret for a bearer token at tokenUrl (the RFC 6749 client-credentials grant, with HTTP Basic client authentication and the scopes space-joined), keeps the token in memory for that adapter, and fetches a new one a minute before it expires, or at half its life when that is shorter. A call the bank answers 401 drops the token, and the next call fetches another. The token URL passes the same egress checks as the bank's calls and is never redirected. A value not in its kind's shape is refused when it is set, with provider_credential_invalid.

A live plane on your bank's own network needs its range in HYPERSCALE_EGRESS_PRIVATE_ALLOW, as 10.20.0.0/16. Without it Arc refuses every private, loopback and link-local address.

Register a declaration

Put each declaration in its own .json file in one directory, and point Arc at it:

HYPERSCALE_ADAPTERS_DIR=/srv/hyperscale/adapters

The path is absolute. At boot Arc reads every *.json in it, in name order, checks each against the declaration shape and the declaration rules, and registers it beside the adapters the release ships under its provider key. Boot stops and names every problem in every file when one does not load, when two files declare one key, or when a file takes a key the release already ships, as fixture_bank. A hosted Arc refuses the setting.

A Core records the digest of each loaded declaration it compiled against. The next boot stops with adapter_pinned_by_core, naming each Core, when a key a stored Core compiled with is changed, is gone from the directory, or the setting is unset:

adapter_pinned_by_core: the declarations in /srv/hyperscale/adapters no longer hold a declaration a Core compiled with; restore the file under its key and give a changed declaration a key of its own:
  - core_bank changed: live tenant ten_live_... product prd_live_... Core pbld_live_...

Keep the file the Cores compiled with as it is, put the changed declaration in a new file under a new key, and recompose the Products onto it. The Cores on the old key keep the declaration they were proved against, and their payouts in flight still reach the bank. Renaming provider in the old file removes the old key, and boot refuses that too.

hyperscale adapters list prints every adapter an HSX program can name on your Arc, and where it came from. hyperscale adapters show <key> prints one with its operations, planes, credentials, digest and the full declaration.

The laws

hyperscale adapters conform replays recorded exchanges through a declaration and judges eight laws against Arc's settlement rules:

hyperscale adapters conform fixture_bank
hyperscale adapters conform ./recordings/core_bank
hyperscale adapters conform core_bank.json --fixtures ./recordings/core_bank

A key the CLI carries recordings for runs those. A folder with a set.json runs that set through the declaration it names. A .json declaration file is checked for its shape and rules, then replayed through --fixtures. Nothing is sent to a bank or to Arc, and the command exits 1 on any failed law.

Law Holds when
duplicate_delivery_noop Every answer delivered a second time is a duplicate and leaves the book as it was
timeout_keeps_hold An answer that timed out moves no money, and the hold the earlier acceptance made is still there
contradiction_refuses A later answer that contradicts a settled one, or reuses its reference with other contents, is refused
partial_capture_posts_partial A capture for less than the authorization posts exactly the captured amount
refund_over_captured_refuses A refund above what is captured and not yet refunded is refused, and no authorization ends refunded above its captures
capture_after_refund_refuses A capture on an authorization already refunded against is refused
nonterminal_never_posts No answer whose code is outside its family's terminal list posts or voids
requires_action_keeps_hold A requires_action answer reads as requires_action and holds

A law with no exchange to test it fails, so a set cannot pass by leaving one out. A recording that holds an API key, a signing secret, a bearer token, an IBAN that passes its check digits or a card number that passes Luhn is refused. The laws prove what your declaration does with the answers recorded; they do not prove your core answers that way.

A set is a folder holding a set.json and one cassette file per lifecycle. set.json names the declaration (a .json file beside the set works) and how a response reads: the paths of the reference, amount, currency, fee and value date. A cassette lists exchanges in the order the bank answered, each with its request, response and what the book must do:

{
  "id": "partial_capture",
  "law": "partial_capture_posts_partial",
  "title": "a capture of part of the authorization, then a void of the rest",
  "exchanges": [
    {
      "operation": "boundary.capture",
      "recordedAt": "2026-10-20T09:02:00.000Z",
      "request": {
        "method": "POST",
        "path": "/authorizations/ins_a_auth/captures",
        "headers": { "idempotency-key": "cap_a_1" },
        "body": { "reference": "cap_a_1", "amount": "6000" }
      },
      "response": {
        "status": 200,
        "body": {
          "id": "capture:cap_a_1",
          "status": "ACSC",
          "amount": "6000",
          "currency": "SAR"
        }
      },
      "expect": { "effect": "posted", "outcome": "confirmed", "amount": "6000" }
    }
  ]
}

The replay matches each request against the operation's template, so a recording Arc would not send is a problem, not a pass. { "timeout": true } records an answer that never came, and "via": "event" an object the bank pushed with no request.

Events your bank pushes

A bank can push its bookings instead of waiting for Arc's cursor read:

POST /v1/provider-events/{environment}/{adapter}/{tenantId}

The body is {"observations": [...]}, 1 to 100 credit observations, each naming the adapter. The header the adapter's hmac_sha256 credential names carries t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 under the tenant's secret over <t>.<raw body>, signed within toleranceSeconds of now. Arc stores each booking before it answers, and a booking pushed and also read by the cursor runs its action once.

Answer When
202 {"received": n, "stored": m}: the signature checked and the bookings are stored.
400 invalid_request: the body is not the shape, or an observation names another adapter.
401 unauthorized: the tenant set no secret for the credential, or the signature or its time is wrong.
404 not_found: the environment is not sandbox or live, or no declaration under the adapter key declares an hmac_sha256 credential, so Arc has no signature to check. An adapter Arc has not loaded answers 404.

The mock bank

The mock bank is a small bank core holding fake money: customers, accounts, virtual accounts, payments, payroll files, bills, cards, postings and statements over one SQLite database. It answers the fixture bank's paths with the fixture bank's codes, so a declaration written against the fixture bank talks to it unchanged. Run it on your own machine with Bun:

HYPERSCALE_MOCK_BANK_ADMIN_KEY=<16 or more characters> hyperscale mock-bank serve

It listens on 127.0.0.1:3010, where the shipped mock_bank adapter's sandbox plane points; --port moves it, --db <file> keeps the book in a file instead of memory, and --today sets the day it opens on. The admin key opens /admin, which mints API keys and books deposits. Every other door takes an API key and answers only for the accounts that key opened. GET /health answers {"ok": true, "day": "..."}.

To test your declaration against it, copy the mock bank's paths into your file, set the sandbox plane to http://127.0.0.1:3010, load it through HYPERSCALE_ADAPTERS_DIR and run a Product in sandbox.