Skip to content

What a receipt costs ​

A receipt adds a policy decision, two Ed25519 signatures, and a log append to a tool call. This page says how long each step takes on one machine, how to measure it on yours, and what is written to disk.

Measuring it ​

From packages/receipts:

bash
bun run bench

This runs scripts/hot-path-bench.ts (node scripts/hot-path-bench.ts [iterations], default 20,000 after 2,000 warm-up). It is not part of ci. Each iteration issues one receipt the way the issuer does, and times each step on its own:

stepwhat it times
cedar evaluateevaluate() on the fixture's Cedar policy. The requests vary by tool, amount, customer, and verification, so a decision cache could not answer them from one entry.
receipt signaturedsseSign over a receipt statement
log append (all)MerkleLog.append on a local file: the lock, reading what other writers appended, the torn-tail check, the write, and the Merkle push, inclusion proof, and root
of which Merklethe push, proof, and root alone, on an in-memory copy of the tree
tree-head signaturedsseSign over the new tree head
rego evaluate (alternative)the same requests decided by the same policy in Rego, compiled once with opa build. The script checks that both engines give the same decision for each request. It is skipped when opa is not on PATH.

The reference rows time a bare appendFileSync, a bare write on a descriptor held open, and the lock owner stamp (see below). There is no network and no fsync. The script writes to a temporary directory and removes it. The site's three numbers (a decision, a gateway call, a verification) come from scripts/bench.ts.

On one machine ​

An Apple M5 laptop, macOS, Node 22.21.1, 9 November 2026, 20,000 receipts per run, means and medians in microseconds. The two runs were interleaved on a machine that was also running other work (load average about 5).

stepbefore, meanbefore, p50after, meanafter, p50
cedar evaluate196.5187.885.978.8
receipt signature30.426.929.725.5
log append (all)1392.51305.61308.61218.9
of which Merkle10.99.59.78.4
tree-head signature26.123.125.021.7
rego evaluate (alternative)11.310.010.08.4
appendFileSync (reference)22.819.528.124.1
write on open fd (reference)5.04.44.43.3
lock owner stamp (reference)1170.21095.81124.81054.4

A second interleaved pair agreed: Cedar went from a mean of 178.1 µs to 91.8 µs, and the log append stayed at about 1.3 ms.

What changed:

  • Cedar parses each policy once. evaluate() used to hand the policy text to Cedar on every call, so Cedar parsed the whole file for every decision, and the text was hashed for the digest every time. Each text is now parsed once with preparsePolicySet, and decided with statefulIsAuthorized. The cache is keyed by the text, so an edited file is parsed again and gets its new digest. It holds 8 policy sets and evicts the least recently used, only after the new set is in place, and a new text never takes a slot that a live entry holds. A text that does not parse is a deny with Cedar's errors, as before.
  • The log keeps its file open. An append used to open and close the file. The log now keeps one descriptor open for append. It reopens the file when a prune has replaced it, when the file was removed, and after a failed write. On this machine the saving, about 20 µs, is smaller than the run-to-run spread of the lock, so the table cannot show it. The reference rows show the write itself.

Rego needed no change: a .rego file is compiled once per change, and the gateway passes the digest it computed when the file loaded.

The lock reads its owner stamp once ​

On macOS most of a log append was the lock that keeps the processes writing one log in order. Its owner record carries the process's start time, so that a later process with the same PID is not taken for the owner. On Linux that comes from /proc/<pid>/stat. macOS has no /proc, so each append ran ps, about 1.1 ms. A process's start time cannot change while it runs, so it is now read once per process (ownProcessStamp), which the operation store already did. A read that failed is tried again on the next lock. The lock record is the same.

The same machine and day, interleaved again, before and after this change alone (load average about 4 to 6):

stepbefore, meanbefore, p50after, meanafter, p50
cedar evaluate86.382.263.057.3
receipt signature27.725.126.223.7
log append (all)1275.91219.6160.4137.8
of which Merkle9.48.58.37.4
tree-head signature23.021.519.718.3
rego evaluate (alternative)10.49.06.85.9
lock owner stamp (reference)1090.91050.41140.41086.3

A second pair agreed: the log append went from a mean of 1350.9 µs to 159.9 µs. The reference row still times one read of the stamp, which a process now pays once. This change touches only the lock, so the lower Cedar and Rego numbers in the "after" runs are most likely the machine with one ps fewer per receipt, not anything in the evaluators. The site's gateway call (scripts/bench.ts) went from 2.1 ms to 0.7 ms.

Where the rest of the time goes ​

The rest of a log append, about 140 µs at the median, is the lock file itself (an exclusive create, the owner record written and read back, and a remove), reading what other writers appended, and the torn-tail check. A gateway on Linux never paid the ps cost.

No decision is cached, only the parsed policy. Receipts are signed and appended before the call returns, never after: authorize never defers, strict mode defers nothing, and a financial operation's receipt does not defer (see the security invariants in CONTRIBUTING.md).

What is on disk when an append returns ​

The log line has been written to the file with O_APPEND, so it is in the operating system's page cache. Nothing fsyncs the log: neither appendFileSync before nor the held descriptor now. A process crash loses nothing that was acknowledged. A power failure or a kernel crash can lose the last appends. A line cut short by a writer that died during its write is moved to <log>.torn by the next append, and that leaf was never acknowledged.

Some other files are fsynced: the issuer writes a receipt it keeps in the outbox with fsync on the file and its directory, and an approval's single-use record is synced the same way. A receipt bundle, its authorization file, its salts, and the log are not.