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.