> ## Documentation Index
> Fetch the complete documentation index at: https://docs.abtestly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The Exact Experiment Ledger

> Every event ABTestly accepts is written to a canonical store, a queue, gzipped NDJSON in R2, and a per-experiment Durable Object. That is what your final numbers come from.

Every event that arrives at `api.abtestly.com/e` is written to two
places. The first is Cloudflare Analytics Engine, fast and cheap to
query, drives the live counters on the dashboard. The second is the
**Exact Experiment Ledger**, the canonical store your final numbers
come from.

The ledger is the difference between a dashboard that says
"approximately 12,483 visitors" and one that says "12,483 visitors,
here is the file".

## Why two stores

Analytics Engine is optimized for a huge write rate and a cheap query.
Under very heavy bursts, it can sample its own data, that is fine
when you are updating a live counter, less fine when the number is
going into a report.

The ledger is optimized for correctness. Every event, in order,
retrievable. It is what the results page reads once your test is out
of "very-fast-changing" territory, and what the [raw event
export](/results/exports) reads for you.

## How an event lands

1. Beacon arrives at `POST /e`.
2. Worker writes a row to the acceptance journal (D1).
3. Worker enqueues the batch on a Cloudflare Queue.
4. Queue consumer:
   * Archives the batch as gzipped NDJSON in R2 (partition by
     site/experiment/date, one file per batch).
   * Projects into the experiment's Durable Object, updating the
     canonical counters.
5. Journal row is marked `queued` → `settled`.

If step 3 or later fails, the journal row stays `pending` and is
retried. The results page reads a `completeness` field with the count
of pending rows for the current experiment.

## The completeness banner

When the ledger has a backlog, the results page shows a banner:

> **Processing.** Your dashboard counters are current. The verdict is
> held for a moment while the ledger catches up on the burst.

Two things it does *not* mean:

* **It does not mean data is being dropped.** Pending events are on
  disk; the queue will process them.
* **It does not mean the live counters are wrong.** Analytics Engine
  is up-to-date; only the ledger-driven verdict is being paused.

A backlog usually resolves in seconds. If a batch fails permanently,
it lands in the dead-letter queue and shows as a warning until
resolved by us.

## What the ledger stores

* Every exposure and every goal fire.
* With their full payload, variant key, order id, currency, entry
  path, all the segment fields, timestamps.
* Retained for **24 months**, then purged.

## Where the ledger lives in the code

* Acceptance journal: D1 (`acceptance_journal` table).
* Cold archive: R2 (`raw/site=…/experiment=…/epoch=…/y=…/m=…/d=…/<batchId>.ndjson.gz`).
* Per-experiment canonical projection: Durable Object (`ExperimentLedgerDO`,
  SQLite inside).

You do not need to know any of that to use it. It is here so nothing
about the ledger is a black box if you go looking.

## What you use the ledger for

* **Trustworthy verdicts.** The results page reads the ledger for its
  numbers.
* **Raw event export.** The [export](/results/exports) reads the ledger
  archives in R2.
* **Audits.** If a compliance team needs "every event this experiment
  saw", the ledger is the source of truth.

## What you do not use it for

* **Live counters.** Those still read Analytics Engine, the second-by-second
  update on the dashboard would not scale to per-experiment DO reads.

## The "no sampling" claim, in one sentence

For any experiment you have run, the ledger contains every exposure
and every goal that was accepted. When the results page runs its
statistics, they are computed against that set. There is no sampling
step, at any point, on any surface you make decisions from.
