Contents

Session netting

A netting session accepts thousands of small balanced movements into a durable, hash-chained journal and settles the net against the ledger in one posting. The journal is the source of truth, a session settles exactly once, and a long-lived scope rotates epochs.

A bar tab. Instead of ringing up every drink as its own card charge, the bartender writes each one on your tab and charges the card once at closing. The tab is the record that counts — and once it's paid, it's closed; the next round starts a new tab.

Source src/netting.tsopenInstanceSessionsrc/netting.tssharedReservationstest/netting.test.ts

API modules/src_netting

Every operation through the submit pipeline buys its guarantees with a database transaction. That price is right for a top-up or a cash out, and wrong for an in-world economy — one busy game instance can produce hundreds of small purchases a minute, each too small to be worth a commit of its own.

A netting session takes those movements the other way around: accept each one immediately against an in-memory screen, journal it durably in batches, and post the net against the ledger once, on the economy service’s own schedule.

The journal is the source of truth

record takes a balanced set of legs with an idempotency key and accepts or rejects it: idempotent per key, affordable per the reservation registry, durable per journal batch. Accepted movements land in the session journal — never only in session memory — and the journal’s rows are hash-chained per session, the same construction the ledger’s integrity layer uses. Rejections carry the ordinary reason codes.

The registry is what makes “affordable” honest across movements that haven’t settled yet: it tracks each account’s pending spend, so a buyer cannot promise the same credits to two sessions. One registry is shared per process (createReservations); a multi-node deployment shares one through the store (sharedReservations) — see cluster nodes.

Settle folds the tab

MOVEMENTSrecordbuyer → seller · 300.00recordbuyer → seller · 120.00recordrejected: insufficient fundsjournal batchSESSION JOURNAL0000…b4c9…d21e…rows hash-chained per sessionsettlenet postingsvia NETTING_CLEARINGledgeranchors the final headA session settles once: record on a settled session throws SESSION.SETTLED; a long-lived scope rotates epochs instead.
The journal is the source of truth, never session memory. Settle reads the journal back, verifies its chain, and posts the net in clearing chunks — and because each chunk's posting anchors the final head, tamper-evidence runs from the proved ledger down to every movement.

settle derives the net from the journal — not from memory — verifies the session’s chain, and posts the result in clearing chunks through NETTING_CLEARING, bounded by chunkWidth so no chunk locks more accounts than the store can hold at once. The settlement posting anchors the journal’s final head, so tamper-evidence extends from the proved ledger down to every individual movement.

A session settles once

A session’s settlement transaction ids derive from its session id, so settling the same id twice would collide with its own chunks and silently strand later movements. The session enforces this: record on a settled session throws SESSION.SETTLED, wasSettled() reports the state, and recoverSession probes for settlement evidence before accepting anything.

A long-lived scope therefore never re-settles a session — it rotates epochs. Settle sess:<scope>:<n> while sess:<scope>:<n+1> records; epochMinter mints the ids. The instance economy automates the rotation.

The trade is explicit

Sessions are opt-in because they move enforcement. Per-movement checks run at the session (and become database-final at settle), and movements bypass the submit pipeline’s maturity gate — a caller that needs that gate screens before building legs. The instance economy layers the production screens (per-buyer caps, matured-funds parking) on top of the raw session.

A session by hand

import { SYSTEM, credit, debit, decodeAmount, earned, spendable } from '@pwngh/economy-lab';
import { createReservations, openInstanceSession } from '@pwngh/economy-lab/netting';

const reservations = createReservations(); // one per process
const session = openInstanceSession(ports, `sess:${worldId}:0`, { reservations });

// One in-world sale, the same split a main-lane spend posts:
// buyer pays 300.00, the seller nets 254.10, the platform's fee is 45.90.
await session.record({
  idempotencyKey: orderId,
  legs: [
    debit(spendable(buyerId), decodeAmount('300.00', 'CREDIT')),
    credit(earned(creatorId), decodeAmount('254.10', 'CREDIT')),
    credit(SYSTEM.REVENUE, decodeAmount('45.90', 'CREDIT')),
  ],
});

await session.settle(); // this tier's schedule: cadence, backlog, timeout, or scope close

A game server is not the caller here. The session lives in the economy service — the process holding the store, the journal, and the registry — and game servers are unprivileged callers that request movements. A world instance is a natural key for a session, never its owner.

See also