Skip to content

PayPal sandbox only. No real money moves.

ReferenceReference

Data and ledger model

The four stores, the order they are written in, what each record holds and which view hides which field.

Verifiedbacked by a real run or committed evidence

Page last changed 7 Nov 2026 (from the git history)

GAVEL keeps its truth in a few append-only or compare-and-set files. This page names every record, says who writes it and when, and shows what is hidden from whom.

Data and ledger model: what is written, in what order, and who sees what

Fake PayPal for tests
Data and ledger model: what is written, in what order, and who sees whatLeft: four stores. The room log, the engine ledger with its hash chain, the payment journal, and holds, webhook ids, audit rows and the kill switch. A step control shows the write order: room log first, then ledger, then journal after the hammer. Right: a table of five private fields against four views. The operator sees all of them, the auctioneer export and the public replay bundle see none of the private ones, and the auditor projection shows them except for sealed lots before the hammer.STORES (STORE=file; the file names are under /var/data)ROOM LOGrooms.jsonl (Postgres: events table)RoomEvent with seq 1, 2, 3 and no gap (assertGapless). 13 types: bid_accepted, hammered,settlement_update and more. The same file keeps snapshots and the token denylist.ENGINE LEDGERledgers/<auctionId>.ledger.jsonlLedgerEntry: seq, at, type, cmd, payload, prev, hash. hash is SHA-256 of the canonical JSONwithout the hash field; the first prev is 64 zeros. One synced line per batch.PAYMENT JOURNALpayments/journals.json (Postgres: journals table)Journal v1 per auction: state, plan, holds, cursor, intent, unknownSince, winner, failed,voids, history. Saved by compare-and-set on version; one writer holds a lease.HOLDS, WEBHOOK IDS, AUDIT, KILL SWITCHholds.json, webhooks.json, payment-audit.jsonl, kill-switch.jsonHoldRecord status: PENDING_APPROVAL, HELD, CAPTURED, VOIDED, EXPIRED, DENIED, UNKNOWN.Audit rows: one per PayPal call and per state change, with the history seq of the journal.With STORE=postgres the room log, snapshots, denylist and journals move to Postgres;ledgers, holds, webhook ids and the kill switch stay in the data directory, which one host locks.WHO SEES WHICH PRIVATE FIELDOperatoradmin keyAuctioneerexportPublicreplay bundleAuditorprojectionBidder ceilingshownwithheldwithheldshownOver-ceiling rejection amountshownwithheldwithheldshownHold referenceshownwithheldwithheldshownBidder display nameshownwithheldnot therenot thereVoice transcriptshownwithheldwithheldshownSolid: shown. Hatched: withheld. Hollow: not in that record at all (names never enter the ledger).SEALED LOTS (mode second_price)projectPublic: price and leader are null until the hammer.projectAuditor: bid amount, transcript, parsedCents, utteranceId and audiooffsets are withheld until the lot is hammered or unsold. An optionfull: true shows them to an authorised caller.Public export: sealed amounts are dropped until the hammer; proxylots drop each bid amount (the secret maximum) for good.The live socket frames are filtered separately for each role (authorize.ts redaction); this diagram covers the stored records and their exports.

Scroll the figure sideways, or open it full size.

Write order 1 of 5. Arrow keys work too.

Step 1. A command becomes events. The room log holds the event first (appendEvents with the expected seq, so a gap or a repeat is a conflict).

Verified against packages/server/src/boot/compose.ts (stores), packages/engine/src/ledger/chain.ts, ledger/public-export.ts, projections.ts, packages/payments/src/types.ts and saga/trail.ts, and the redaction in packages/server/src/auth/authorize.ts and http/export.ts. The auditor projection is a library function today; no server route calls it. Write order comes from the note in store/file-ledger-store.ts (decisions D-0012 and D-0014).
Text version of this diagram

Four stores and four views.

  • Room log: RoomEvent records with gapless seq, plus snapshots and the token denylist. Written first.
  • Engine ledger: hash-chained entries, one file per auction. Written before the event is acknowledged.
  • Payment journal: one per auction, saved by compare-and-set, only after the hammer is in the ledger.
  • Holds, webhook ids, audit rows and the kill switch file.
  • Operator (admin key) sees ceilings, over-ceiling rejection amounts, hold references, names and transcripts.
  • The auctioneer export drops the ceiling, the over-ceiling amount, names, hold references and transcripts.
  • The public replay bundle (export version 4) drops ceilings, hold references, rejection amounts, leader fields and transcripts; names never enter the ledger.
  • The auditor projection shows the whole ledger, except that bid amounts and recording details of a sealed lot are withheld until it is hammered.

Beginner view: why more than one log

A live auction needs two different kinds of memory.

  1. What happened in the room (who joined, which bids came in, what the screen showed). That is the room log: ordered events, numbered 1, 2, 3 with no gaps, so a reconnecting screen can ask for "everything after 41".
  2. What the auction rules decided (who leads, who won). That is the engine ledger: each entry carries the hash of the entry before it, so changing an old entry breaks every hash after it. Anyone holding the chain can check it.

Money gets its own record, the payment journal, because settlement has to survive a crash halfway through a PayPal call. The journal says what GAVEL intends to do before it does it.

The write order

The room log holds the event first.

The engine ledger is written before the event is acknowledged.

The payment journal is saved only after LOT_HAMMERED is in the ledger.

Every saga transition is one compare-and-set save of the journal; its audit row is appended after the save and carries the journal history seq, so a crash between the two is repaired from the journal on the next run.

The first three lines are the note in packages/server/src/store/file-ledger-store.ts (decisions D-0012 and D-0014). The last is the contract in packages/payments/src/saga/trail.ts.

Records

RoomEvent is a discriminated union in packages/shared/src/events.ts. The store port (RoomStore) appends events from expectedSeq + 1 with no gap and throws conflict when the stored last seq differs.

Types: auction_created, bidder_registered, bid_accepted, bid_rejected, confirm_requested, confirm_resolved, voice_dropped, control, state_changed, hammered, ceiling_revoked, settle_requested, settlement_update.

The same store keeps snapshots, the single-use token ids (jti) and the denylist. In Postgres these are the tables events, snapshots, jti_used, jti_denied and journals.

Who sees which field

Exports are not public

GET /admin/export returns the private ledger with ceilings, hold references and transcripts. It needs the admin key and must never reach a public place. See Backup.

FieldOperator (admin key)Auctioneer exportPublic replay bundleAuditor projection
Bidder ceilingshownwithheldwithheldshown
Over-ceiling rejection amountshownwithheldwithheldshown
Hold referenceshownwithheldwithheldshown
Bidder display nameshownwithheldnot in the ledgernot in the ledger
Voice transcriptshownwithheldwithheldshown
  • Auctioneer export (GET /api/auctions/:id/export, roles auctioneer and admin): redactEventForExport drops the ceiling and the amount of ceiling rejections; redactPersonal drops names, hold references, the auctioneer subject and transcripts for everyone but the admin key.
  • Public replay bundle (GET /admin/replay-bundle, built from the verified ledger): export version 4. Ceilings, hold references, leader fields, transcripts and the amount of an over-ceiling rejection are removed rather than hashed, because a ceiling is a small number and a hash of it can be guessed. After the first removed field, entries carry fullHash: null (or a keyed commitment when an auditor passes a key), so a viewer cannot test amounts by hashing forward. Anyone can verify the public chain with verifyPublicExport.
  • Auditor projection (projectAuditor) is a library function; no server route calls it yet.

Sealed lots

In the second_price mode bids are sealed until the hammer. projectPublic shows a null price and leader; projectAuditor withholds the amount, transcript, parsed amount, utterance id and audio offsets until the lot is hammered or unsold; the public export drops sealed amounts until the hammer. Proxy lots drop each bid amount (the bidder's secret maximum) for good.

Where this lives in the code

Edit on GitHub

Last updated on