Sablestack Ledger
A double-entry ledger that checks its own balance.
An HTTP API for accounts and transactions. Every transaction sums to zero. Every cached balance equals the sum of its entries. The check is a public endpoint.
Live status
Integrity
The check reads every entry on the demo ledger. This page shows a result that is at most 30 seconds old.
Checked 2026-11-10T14:27:06.464Z. The demo ledger is emptied and reseeded every night.
The problem
A balance must equal the movements behind it
A balance is a number that has to equal the sum of the entries posted to the account. Systems that update the balance and the entries as separate steps drift apart under failure and concurrency, and nobody sees it until an audit.
Here the entries and the balance change in one database transaction, so a reader never sees one without the other. Postings take row locks in a fixed order, so concurrent writers queue instead of deadlocking. Postgres triggers reject an unbalanced set of entries even if the application is wrong.
Money is stored as 64-bit integers in minor units with an ISO 4217 currency. Writes need an API key and an idempotency key. Reads are public.
Schema
Three tables
Entries are append-only. The application database role has no UPDATE or DELETE grant on them.
create table accounts (
id uuid primary key,
code text not null unique,
name text not null,
type account_type not null,
currency char(3) not null check (currency ~ '^[A-Z]{3}$'),
-- Signed: debit positive, credit negative. Cache of sum(entries.amount_minor).
balance_minor bigint not null default 0,
version int not null default 0,
-- For POST /v1/accounts idempotency (§7).
idempotency_key text not null unique,
request_hash bytea not null,
created_at timestamptz not null
);
create table transactions (
id uuid primary key,
idempotency_key text not null unique,
request_hash bytea not null,
description text check (char_length(description) <= 200),
status txn_status not null default 'posted',
reverses_id uuid references transactions (id),
version int not null default 0,
posted_at timestamptz not null
);
create table entries (
id bigserial primary key,
transaction_id uuid not null references transactions (id),
account_id uuid not null references accounts (id),
amount_minor bigint not null check (amount_minor <> 0),
currency char(3) not null check (currency ~ '^[A-Z]{3}$')
);Posting path
One database transaction
Posting a transaction is four statements between begin and commit, plus a currency check in the application. The deferred trigger runs at commit.
begin read committed
1 insert into transactions (...)
on conflict (idempotency_key) do nothing
2 select id, currency from accounts
where id in (...) order by id for update
application: compare each entry currency with its account
3 insert into entries (...)
4 update accounts
set balance_minor = balance_minor + delta,
version = version + 1
commit deferred trigger: at least 2 entries,
sum = 0 per currencyA repeated Idempotency-Key with the same body returns the original transaction with 200. The same key with a different body returns 409. A reversal posts the negated entries and marks the original reversed in the same transaction; a second reversal returns 409.
Load test, local
Posting under constant arrival rate
k6 posts transfers between 1,000 seeded accounts at a fixed rate for 60 seconds per run. The environment is stated with every figure.
| Target req/s | p50 (ms) | p95 (ms) | p99 (ms) |
|---|---|---|---|
| 250 | 2.76 | 4.25 | 6.77 |
| 500 | 2.19 | 3.78 | 6.39 |
| 750 | 3.29 | 119.89 | 163.28 |
| 1000 | 2.74 | 2347.46 | 4206.14 |
| 2000 | 327.58 | 1598.64 | 1693.18 |
| Target req/s | Requests | Failed rate | Dropped |
|---|---|---|---|
| 250 | 15,001 | 0.00% | 0 |
| 500 | 30,001 | 0.00% | 0 |
| 750 | 45,001 | 0.00% | 0 |
| 1000 | 55,170 | 0.00% | 4,833 |
| 2000 | 112,981 | 0.15% | 7,020 |
These runs are local: the app, Postgres 17 in Docker and the k6 container shared one laptop. They show how the code behaves on that machine, not what a hosted deployment will do. The p99 threshold for these runs is 100 ms.
Machine, versions, pool size and exact commands: loadtest/reports/environment.md.
Load test, live
Posting to the deployment
The same script against the Vercel deployment and Neon, run from a laptop in South Africa over the public internet. Each run follows a 30-second warm-up at 10 requests per second, which is excluded, with 60 seconds at the stated rate.
| Target req/s | app p50 | app p95 | app p99 | db p99 |
|---|---|---|---|---|
| 25 | 22.21 | 31.32 | 47.62 | 46.88 |
| 50 | 27.96 | 34.86 | 45.80 | 45.00 |
| 100 | 23.88 | 29.97 | 40.88 | 40.30 |
| 200 | 21.46 | 28.87 | 36.18 | 35.61 |
| Target req/s | p50 (ms) | p95 (ms) | p99 (ms) |
|---|---|---|---|
| 25 | 311.95 | 343.43 | 513.06 |
| 50 | 308.21 | 338.99 | 391.65 |
| 100 | 303.90 | 332.41 | 411.64 |
| 200 | 300.86 | 330.83 | 384.75 |
| Target req/s | Requests | Failed rate | Dropped |
|---|---|---|---|
| 25 | 1,501 | 0.00% | 0 |
| 50 | 3,000 | 0.00% | 0 |
| 100 | 6,001 | 0.00% | 0 |
| 200 | 12,001 | 0.00% | 0 |
Fig. 5 is the server's own measurement. The app times each POST with performance.now() and returns it in the Server-Timing header: db is the posting database transaction, app is the whole handler. It leaves out what happens before the handler starts, such as a function cold start and platform routing.
Fig. 6 is what the client saw. It includes the network round trip from the test machine in South Africa to the Vercel region in Virginia (iad1), so it measures that distance as much as the ledger, and no threshold applies to it. The live thresholds are zero failed requests, zero dropped iterations and a server-side p99 under 100 ms. Each run created its own 200 accounts through the API. Two earlier attempts at 200 requests per second that failed for load-generator reasons are described in the environment note and are not in these figures.
Region, tiers, client network measurements and the integrity result after the runs: loadtest/reports/environment.md.
Reproduce
Run it yourself
Docker, Node 24, pnpm 9. No account on any service.
pnpm install
pnpm db:up && pnpm db:migrate
cp .env.example .env.local # set LEDGER_API_KEY and LEDGER_API_KEY_SHA256, see README
pnpm build
loadtest/run.sh 500 # reset, seed 1,000 accounts, start the app, run k6 in DockerAPI
Ten endpoints
Base path /v1. Errors are { error: { code, message, details } }. Lists use cursor pagination.
| Method | Path | Auth |
|---|---|---|
| POST | /v1/accounts | Bearer key |
| GET | /v1/accounts | Public |
| GET | /v1/accounts/{id} | Public |
| GET | /v1/accounts/{id}/entries | Public |
| POST | /v1/transactions | Bearer key |
| GET | /v1/transactions | Public |
| GET | /v1/transactions/{id} | Public |
| POST | /v1/transactions/{id}/reversals | Bearer key |
| GET | /v1/integrity | Public |
| GET | /v1/health | Public |
- POST
- Path
- /v1/accounts
- Auth
- Bearer key
- GET
- Path
- /v1/accounts
- Auth
- Public
- GET
- Path
- /v1/accounts/{id}
- Auth
- Public
- GET
- Path
- /v1/accounts/{id}/entries
- Auth
- Public
- POST
- Path
- /v1/transactions
- Auth
- Bearer key
- GET
- Path
- /v1/transactions
- Auth
- Public
- GET
- Path
- /v1/transactions/{id}
- Auth
- Public
- POST
- Path
- /v1/transactions/{id}/reversals
- Auth
- Bearer key
- GET
- Path
- /v1/integrity
- Auth
- Public
- GET
- Path
- /v1/health
- Auth
- Public