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.

Balanced
Accounts50
Transactions500
Entries1,000
Unbalanced transactions0
Drifted accounts0
Transactions under 2 entries0

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.

sql
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}$')
);
Fig. 1  Accounts, transactions and entries, quoted from the migration.Source: db/migrations/0001_init.sql

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.

posting path
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 currency
Fig. 2  The posting path in READ COMMITTED, as implemented in the ledger service.Source: src/ledger/service.ts

A 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.

Run dateTarget req/sMin (ms)p50 (ms)p95 (ms)p99 (ms)Max (ms)
2026-11-102501.632.764.256.7724.68
2026-11-105001.192.193.786.39111.04
2026-11-107501.133.29119.89163.28203.24
2026-11-1010001.162.742347.464206.144509.62
2026-11-1020000.00327.581598.641693.181743.48
Fig. 3  Response time of POST /v1/transactions by target rate, in milliseconds rounded to 0.01. Local runs.Source: local-2026-11-10-250.json, local-2026-11-10-500.json, local-2026-11-10-750.json, local-2026-11-10-1000.json, local-2026-11-10-2000.json
Run dateTarget req/sRequestsAchieved req/sFailed rateDropped
2026-11-1025015,001249.770.00%0
2026-11-1050030,001498.930.00%0
2026-11-1075045,001748.600.00%0
2026-11-10100055,170917.050.00%4,833
2026-11-102000112,9811835.660.15%7,020
Fig. 4  Requests completed, achieved rate, failed requests and dropped iterations for the same runs. Rates are rounded to 0.01.Source: local-2026-11-10-250.json, local-2026-11-10-500.json, local-2026-11-10-750.json, local-2026-11-10-1000.json, local-2026-11-10-2000.json

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.

Run dateTarget req/sapp p50app p95app p99db p50db p95db p99
2026-11-102522.2131.3247.6221.5330.6146.88
2026-11-105027.9634.8645.8027.4134.2545.00
2026-11-1010023.8829.9740.8823.3229.4040.30
2026-11-1020021.4628.8736.1820.9228.3835.61
Fig. 5  Server-side time of POST /v1/transactions from the Server-Timing header, in milliseconds rounded to 0.01. app is the whole handler, db is the posting transaction. Live runs against the Vercel deployment (iad1) and Neon (us-east-1, pooled, free tier).Source: live-2026-11-10-25.json, live-2026-11-10-50.json, live-2026-11-10-100.json, live-2026-11-10-200.json
Run dateTarget req/sMin (ms)p50 (ms)p95 (ms)p99 (ms)Max (ms)
2026-11-1025280.92311.95343.43513.06565.82
2026-11-1050281.26308.21338.99391.65695.99
2026-11-10100278.26303.90332.41411.64821.59
2026-11-10200276.46300.86330.83384.751652.21
Fig. 6  Client-observed response time of the same requests, in milliseconds rounded to 0.01. It includes the network round trip from the test machine in South Africa to iad1. Not a threshold.Source: live-2026-11-10-25.json, live-2026-11-10-50.json, live-2026-11-10-100.json, live-2026-11-10-200.json
Run dateTarget req/sRequestsFailed rateDropped
2026-11-10251,5010.00%0
2026-11-10503,0000.00%0
2026-11-101006,0010.00%0
2026-11-1020012,0010.00%0
Fig. 7  Requests completed, failed requests and dropped iterations for the same runs. k6 divides by the whole test including the warm-up, so its rate is omitted; requests over 60 seconds is the measured rate.Source: live-2026-11-10-25.json, live-2026-11-10-50.json, live-2026-11-10-100.json, live-2026-11-10-200.json

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.

shell
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 Docker

API

Ten endpoints

Base path /v1. Errors are { error: { code, message, details } }. Lists use cursor pagination.

MethodPathAuth
POST/v1/accountsBearer key
GET/v1/accountsPublic
GET/v1/accounts/{id}Public
GET/v1/accounts/{id}/entriesPublic
POST/v1/transactionsBearer key
GET/v1/transactionsPublic
GET/v1/transactions/{id}Public
POST/v1/transactions/{id}/reversalsBearer key
GET/v1/integrityPublic
GET/v1/healthPublic