Mini-Ledger: batch admission without locks
A demo of yichus admit: given a batch of transfers, declared balance rules, and the real handler, the checker computes which subset can commit together in one write, and explains every operation it turns away.
This is a demo of Yichus, a research checker
for programs written in Bosatsu (a small,
total, functional language from a separate project that this checker builds on). The problem it
demonstrates: TigerBeetle, a financial-transactions database, admits a whole batch of
transfers in one write because its schema and invariants are fixed in advance. Doing the
same for user-declared invariants needs three things. Value-indexed
footprints: whether two transfers conflict or commute depends on which account ids
their inputs name, not just on which table they touch. An escrow
declaration: balances move by signed deltas and invariants are interval bounds.
That is the escrow-transaction method (O'Neil, 1986), which reserves each update's worst case
against the bound so admission needs only interval arithmetic, never the exact balance.
And a query mode on an existing checker: Yichus's interleaving checker
(see the interleaving-checker demo) asks whether the
declared properties hold under every interleaving; yichus admit runs
the same order-enumeration engine as a search for a subset of this batch and a
witness ordering (one concrete execution order, exhibited as evidence)
under which every property holds. A Yichus/Spec::EscrowSpec declares all
three; the sources are below.
The results on this page are not screenshots: on every deploy, the site build reruns
yichus admit against the sources shown and writes the JSON this page renders
(the runs are rows in the
deploy manifest,
executed by the
deploy workflow).
The domain is a minimal ledger with four accounts starting at A 200, B 60, C 40,
D 150. Every balance must stay ≥ 0, and account D is a credit account capped at 200.
There is one operation, transfer(source, target, amount), written as an ordinary
guarded handler.
Reading the sources: Bosatsu's layout is Python-like; struct declares a record
type, match/case pattern-match, <- binds a result
inside an IO block, and exposes names the packages whose types appear in a
package's exported values.
ledger.bosatsu: the domain (limits shared by guard and spec)
loading…
ledger.spec.bosatsu: the escrow declaration
loading…
ledger_handlers.bosatsu: the guarded handler
loading…
1. Escrow admission of the sample batch
The batch is the eight transfers below. For each operation the solver evaluates the spec's
key and amount functions against the concrete input, so it knows which account ids the
operation touches. That set of ids is its value-indexed footprint. Operations that
touch the same key conflict; operations whose keys are all distinct commute. Per account,
the solver keeps an escrow interval
[initial − Σdebits, initial + Σcredits] over the admitted set and admits
greedily in arrival order: an operation is admitted only if every declared bound holds at
the worst case of every ordering of the admitted set. That order-independence is what
removes the locks: no execution order of the admitted set can break a bound, so the whole
set commits in one round. A deferred operation gets a why-trace: which bound, which key,
and which admitted operations consumed the headroom.
batch-sample.json: the eight transfers (each entry is {"operation", "input"})
loading…
yichus admit ledger.bosatsu ledger.spec.bosatsu ledger_handlers.bosatsu \
--batch batch-sample.json --result admission.json
# exit 0 — replay verified
To run this locally: clone github.com/snoble/yichus (needs git, a JVM 17+, and
sbt), then run sbt assembly, which writes
yichus.jar under target/. Set
alias yichus="java -jar $(pwd)/target/scala-*/yichus.jar" and run the command
above from demos/admission/.
Loading admission artifact…
Dig deeper: why the interval covers every ordering
Any prefix of any ordering of the admitted set debits key k by at most the sum
of all admitted debits on k, and credits it by at most the sum of all admitted
credits, so every intermediate balance lies inside
[initial − Σdebits, initial + Σcredits]. Checking the declared bounds at the
two interval endpoints therefore covers every intermediate state of every ordering at
once. Admission is greedy in arrival order; finding the largest admissible
subset is NP-hard and not attempted. Greedy order is the intended semantics.
2. Write-round counts: escrow batch vs serial locking
The two numbers below are computed from the admission result above. They are round counts, not a timing benchmark; no performance measurement is claimed on this page. Serial per-operation locking pays one write round per operation. Escrow admission pays one round for the whole admitted set, because the interval arithmetic already proved every ordering of it safe. A deferred operation still carries its explanation and waits for the next batch.
3. A non-monotone invariant forces bounded witness search
Add one non-monotone policy: account D's balance must stay even. It is a legal,
total predicate, but it is not an interval bound and cannot be classified as a signed
delta, so interval arithmetic cannot admit through it. The declaration
(KeyProperty("d-stays-even", ["D"], is_even)) moves every operation touching D
off the escrow fast path into witness search over its conflict component,
the group of operations linked by shared evaluated keys. Distinct keys still commute, so
the search space is one component at a time, never the whole batch. But when a component
grows past the search bound (8 operations), the solver reports
inconclusive: search bound exhausted rather than admitting anything it did not
check. The trade: with monotone (signed-delta, interval-bounded) structure, admission is
interval arithmetic at any batch size; without it, admission is a per-component search
that can hit its bound.
yichus admit ledger.bosatsu ledger_parity.spec.bosatsu ledger_handlers.bosatsu \
--batch batch-sample.json --result admission.jsonledger_parity.spec.bosatsu: the same spec plus the parity policy
loading…
Loading admission artifact…
Dig deeper: how the witness search works and what the bound counts
Conflict components are built from evaluated keys: two search-path operations are linked
when their key functions evaluate to the same account id, and a component is a connected
group under that relation; arrival order is preserved inside each component. Each
component is searched independently with exact-value simulation, starting from the state
the escrow-admitted operations produce: orderings of the component's operations are
enumerated until one satisfies every declared bound and property at every step. The
bound counts operations per component: in a component larger than 8, the
operations beyond the bound are marked inconclusive: search bound exhausted
and only the first 8 are searched (--max-search can lower the bound; 8 is
the hard cap, the same bound discipline as the interleaving checker). Non-monotone
properties are not the only route here: a delta with mixed sign or a negative amount is
also not escrow-analyzable and takes the search path. The final witness
ordering is the escrow-admitted operations in arrival order, followed by each component's
searched sequence. That is why, in the artifact above, a late-arriving escrow
operation can hold an earlier witness position than a searched one.
4. Run the solver in your browser
The same solver, compiled to JavaScript (the deploy build links the browser bundle from the same sources as the CLI), runs entirely in your browser: spec evaluation, escrow arithmetic, witness search, and replay through the compiled handlers. Things to try:
- Contention: raise the hot-account slider. More transfers hit account D, its escrow band shrinks faster, and deferrals accumulate with "headroom consumed by" traces.
- The non-monotone policy: switch on the parity checkbox and D's operations leave the fast path (search). To see the bound itself, set batch size 24, hot-account share 100%, policy on. D's conflict component then exceeds 8 operations and the verdicts read inconclusive: search bound exhausted.
- Break the handler: edit the handler below and re-run. Make it credit
add(amount, amount)instead ofamount, or drop the debit. Replay verification executes the witness through your edited handler and reports model drift, located at the first diverging operation and key. One thing that does not break it: deleting the guard alone changes nothing, because admission already proved every admitted operation safe. That is why no lock is needed.
ledger_handlers.bosatsu (editable)
How it works
The EscrowSpec declares: the state cell (accounts), its real initial
value, a view projecting each account's balance, Delta rows
classifying transfer as a signed delta per key (Dec on the source,
Inc on the target, key and amount as pure functions of the input), interval
Bounds (the domain's own limit functions, the same compiled code the handler's
guard calls), and optional non-monotone KeyProperty declarations. The
admission solver and the interleaving checker share the order-enumeration engine and
evaluate every declared function with the compiler's own evaluator, so the spec never has
to be translated into a separate modelling language.
Soundness rules, the same discipline as the interleaving checker's: an operation that writes the cell
with no Delta classification poisons every verdict on the cell
(inconclusive: unclassified write); a declared operation with no implementing write
stays inconclusive; beyond the search bound, inconclusive: search bound
exhausted. And because the deltas and bounds are declarations about the handler rather
than facts extracted from it, every batch is replay-verified: the witness ordering executes
through the actual compiled handlers, and any divergence makes the batch
inconclusive: model drift with the divergence located, and the CLI exits nonzero.
The comparison is per step: after each operation of the witness, the cell, projected
through the spec's own view, is compared per key against what the declared
deltas predict. Replay is what ties the interval arithmetic to the real program. Without
it, a handler that does something other than its declared deltas would make every verdict
meaningless.
Dig deeper: stated limits of this pass
Admission is greedy in arrival order; finding the largest admissible subset is NP-hard
and not attempted. Escrow values are Int in this pass; generalizing to other
ordered structures (sets, max/min registers) is listed as future work in the design note.
One EscrowSpec cell per run; operations spanning multiple cells are not yet
supported. Keyed footprints are declared in the spec, not extracted from the
compiled code. Replay verification is the stopgap that guards the declaration, and it
checks the batch's key universe only: a handler that mutated a key no batch operation
declared would be outside this pass's replay scope.
Read more: the design note (the algorithm, the soundness rules, and the adaptation notes behind this demo)
· sources on GitHub
· raw artifacts (each admission JSON is exactly what --result writes):
escrow, with the parity policy, the batch.