Skip to main content
Setup · ~5 minutes Arc runs as a single deployable. There is no service mesh to stand up and no cloud account to create: the whole system boots from one command, which is the main reason it was built as a modular monolith. Requires Node 22+, pnpm 9, and Docker.
1

Clone and install

2

Start Postgres and Redis

Postgres holds the ledger; Redis backs the job queues. Both are pinned in ops/docker-compose.yml.
3

Apply the schema

This is the step worth pausing on. The migrations do not just create tables: they install the constraint triggers that make the ledger invariants hold independently of the application code. See enforced twice, on purpose.
4

Run the gate

Format, lint, typecheck, architecture boundaries, documentation integrity, and the full test suite. This is exactly what CI runs; if it is green locally it is green on a pull request.
5

Watch a transfer move

This is the step that makes it concrete. One corridor transfer runs end to end against Postgres — quote, compliance, reserve, swap, on-chain settlement, payout — and every journal it posts is printed, entry by entry, followed by the trial balance in each currency.It exits non-zero if any currency fails to balance, which is why CI runs it as a step rather than trusting the unit tests alone.

What pnpm verify actually checks

It is worth knowing what fails and why, because several of these gates are unusual.
ESLint rejects parseFloat, toFixed, Math.round/floor/ceil, and fractional numeric literals in source:
Exactly two files carry a scoped exception with a written justification: the seeded PRNG in packages/chain/src/random.ts, which does 32-bit bit-mixing, and the Jaro-Winkler implementation in services/risk/src/sanctions.ts, where 0.9 is an algorithm threshold rather than an amount.Why money is never a float →
dependency-cruiser fails the build on a cross-context import. So does an ESLint no-restricted-imports rule.Both are needed, and finding out why was its own small investigation: dependency-cruiser catches relative imports like ../../ledger/src/posting but silently misses package-name imports like @arc/ledger, because that resolves through node_modules to a dist path which does not exist until after a build.The boundary that wasn’t →
Roughly 240 tests across the workspace. The ones that matter most are the property suite (ledger invariants under randomised sequences) and the chaos suite (a failure injected at each saga step, asserting the ledger ends balanced every time).What the tests prove →

The layout you just cloned

packages/ holds shared primitives that anything may import. services/ holds the bounded contexts, which may import from packages/ and from themselves: never from each other. That rule is the load-bearing one, and it is mechanically enforced rather than merely documented.

Poke at it

Reading a passing test suite proves less than breaking it. Run the watcher and make the system object:
In services/ledger/test/posting.test.ts, change a credit so it no longer matches its debit. The error names the currency and the exact difference. There is no tolerance to widen: a single minor unit is a rejection, which is the whole reason money is an integer count rather than a float.
In apps/api/test/last-mile.test.ts, send 100066 instead of 100000. The last two minor units are read as an instruction, and …66 means the payout rail rejects. Watch the saga compensate and the ledger still balance. Try …61 for a compliance block and …68 for a settlement that never reaches finality.
Delete the SELECT … FOR UPDATE from withAccountLocks in services/ledger/src/prisma-store.ts, keeping the transaction. The unit tests still pass. The concurrency test does not: eight simultaneous spends against a balance that funds three will overdraw the account.
Connect with psql and UPDATE a row in ledger_entry. The database refuses: entries are append-only, and a correction is a new opposing journal rather than an edit. The same applies to DELETE.

Where to go next

Read the ledger

The core. Everything else is an interface onto it.

Walk a transfer end to end

€1,000 from Germany to a Kenyan mobile-money wallet, entry by entry.