Bates Numbering & Endorsement: Collision-Free Sequential Citation at Scale
Bates numbering is the citation system the entire litigation depends on: every produced page receives a unique, sequential identifier that briefs, depositions, and exhibits will reference for the life of the matter. Inside the Review & Production Workflows pipeline, endorsement is the stage that stamps each page with that identifier and any required confidentiality legend, and it carries an unusually strict correctness requirement — the numbers must be globally monotonic and never reused, even across worker crashes and a parallel rendering pool. A single reused number gives two different pages the same citation and can invalidate a production; a lost number leaves an unexplained gap. This guide builds an endorsement subsystem whose allocator is atomic and crash-safe, whose stamping is idempotent, and whose every claim is written to an immutable ledger before a pixel is drawn.
Endorsement Subsystem Flow
Endorsement separates the scarce ordered resource — the number — from the parallelizable work — the rendering. A central allocator hands out contiguous ranges atomically; workers render and stamp independently; a ledger records each claim before the image is finalized so a crash is always recoverable to a known state.
Resource Constraints & Design Rationale
The naive endorsement design lets each worker keep its own counter and reconcile later. It cannot work: two workers starting from the same value produce colliding numbers, and reconciliation after rendering means re-stamping already-imaged pages, which is both expensive and error-prone. The only correct design centralizes the counter. But a centralized counter is a contention point, so the challenge is to make it atomic without making it a bottleneck.
The resolution is range claiming, not per-page increment. A worker about to stamp a 40-page document claims the whole 40-number range in one atomic operation, then stamps locally without further coordination. The central counter is touched once per document rather than once per page, so a pool of workers stamping millions of pages contends on the counter only a few hundred thousand times, and each claim is a single atomic add. This keeps the ordered resource globally consistent while the CPU-heavy rasterization and stamping fan out freely, exactly the split the parent pipeline’s async batch processing model is built for.
Allocation Algorithm Deep-Dive
The allocator’s correctness rests on three properties. First, atomicity: the claim reads the current high-water mark and advances it in one indivisible operation, so no two claims can observe the same starting value. In a distributed deployment this is a database sequence, a Redis INCRBY, or a row-level lock — any primitive that guarantees a linearizable increment. Second, durability before use: the new high-water mark is persisted before the range is handed to a renderer, so a crash after claiming but before rendering leaves the number consumed (a safe, detectable gap) rather than available for silent reuse. Third, idempotent stamping: if a worker retries a document after a partial failure, it re-uses the range already recorded for that document id rather than claiming a fresh one, so a retry never burns a second range.
Endorsement placement is the second algorithm. The Bates number and any confidentiality legend must be stamped in a margin that does not obscure content, at a consistent position across every page, and legibly at the production’s DPI. The renderer computes a safe endorsement box from the page geometry — bottom-right for the number, bottom-center or a footer band for the legend — and asserts the stamp fits without overlapping the imaged content, because an endorsement that covers text is itself a defect the QC parity check in production validation will flag.
Resilience & Failure Routing
An endorsement worker faces documents it cannot stamp: a zero-page render, a corrupt image, a page geometry too small for the endorsement box. Fail-closed routing sends these to a hold queue with the reason, rather than stamping a malformed page or — worse — skipping the number silently. Because the range was claimed and recorded before rendering, a held document’s numbers are accounted for: they are either reissued when the document is fixed or logged as an intentional gap, but never quietly reused.
Crash recovery is deterministic. On restart, the worker reads the ledger, finds the last document whose stamping did not commit, and resumes from its recorded range — re-stamping idempotently into the same numbers. The high-water mark in the ledger is the single source of truth for “the next free number,” so the allocator never has to scan produced images to figure out where it left off. This is what lets an endorsement job spanning days survive interruptions without ever producing a colliding citation.
Production Python Implementation
The module implements an atomic, ledger-backed allocator and an idempotent endorsement step. The allocator is shown with a SQLite backend (a linearizable single-writer store) that stands in for a production sequence; the stamping function computes a safe endorsement box and is idempotent on document id.
import sqlite3
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger("ediscovery.endorse")
logger.setLevel(logging.INFO)
class EndorsementError(Exception):
"""Raised when a document cannot be endorsed defensibly."""
@dataclass
class Claim:
doc_id: str
start: int
end: int
class AtomicBatesAllocator:
"""Ledger-backed, crash-safe allocator. Claims are atomic and idempotent
per document id; the high-water mark is durable before a range is used."""
def __init__(self, db_path: Path, prefix: str, width: int, start: int):
self.prefix = prefix
self.width = width
self.conn = sqlite3.connect(db_path, isolation_level=None, timeout=30)
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY CHECK (id=1), high INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS claims (doc_id TEXT PRIMARY KEY, start INTEGER NOT NULL, end INTEGER NOT NULL);
""")
self.conn.execute("INSERT OR IGNORE INTO counter (id, high) VALUES (1, ?)", (start - 1,))
def claim(self, doc_id: str, pages: int) -> Claim:
if pages < 1:
raise EndorsementError(f"{doc_id}: page count must be >= 1")
# Idempotency: a retry of the same document reuses its recorded range.
row = self.conn.execute("SELECT start, end FROM claims WHERE doc_id=?", (doc_id,)).fetchone()
if row:
return Claim(doc_id, row[0], row[1])
# Atomic claim: advance the counter and read the new range in one
# transaction so no two claims can observe the same start value.
self.conn.execute("BEGIN IMMEDIATE")
try:
high = self.conn.execute("SELECT high FROM counter WHERE id=1").fetchone()[0]
start, end = high + 1, high + pages
self.conn.execute("UPDATE counter SET high=? WHERE id=1", (end,))
self.conn.execute("INSERT INTO claims (doc_id, start, end) VALUES (?,?,?)",
(doc_id, start, end))
self.conn.execute("COMMIT")
except sqlite3.Error as exc:
self.conn.execute("ROLLBACK")
raise EndorsementError(f"{doc_id}: claim failed") from exc
logger.info("Claimed %s%0*d-%s%0*d for %s", self.prefix, self.width, start,
self.prefix, self.width, end, doc_id)
return Claim(doc_id, start, end)
def fmt(self, n: int) -> str:
return f"{self.prefix}{n:0{self.width}d}"
def endorsement_box(page_w: float, page_h: float, legend: bool) -> Tuple[float, float]:
"""Return a bottom-right anchor for the Bates stamp inside a safe margin.
Raises if the page is too small to endorse without overlapping content."""
margin = 0.35 * 72 # 0.35 inch in points
if page_w < 2 * 72 or page_h < 1 * 72:
raise EndorsementError("page geometry too small to endorse legibly")
x = page_w - margin
y = margin + (14 if legend else 0) # lift the number above a footer legend
return x, y
def endorse_document(alloc: AtomicBatesAllocator, doc_id: str, pages: int,
geometries: list, legend: Optional[str], stamp_page) -> Claim:
"""Claim a range, then stamp each page idempotently. `stamp_page` draws the
text at the computed anchor and is expected to be safe to re-run."""
claim = alloc.claim(doc_id, pages)
if len(geometries) != pages:
raise EndorsementError(f"{doc_id}: geometry count {len(geometries)} != pages {pages}")
for i, (w, h) in enumerate(geometries):
anchor = endorsement_box(w, h, legend is not None)
bates = alloc.fmt(claim.start + i)
stamp_page(doc_id, i, bates, legend, anchor)
return claim
Observability & Compliance Metrics
Three KPIs keep endorsement legible. Claim rate (ranges claimed per second) measures allocator contention and predicts throughput ceilings. Stamp throughput (pages endorsed per second across the pool) measures the parallel rendering capacity. Hold rate (documents diverted to the hold queue per thousand) is the quality signal — a rising hold rate points at corrupt renders or geometry failures upstream. The compliance-critical invariant, monotonicity, is asserted continuously by checking that the ledger’s high-water mark only ever increases.
from prometheus_client import Counter, Gauge
CLAIMS = Counter("endorse_claims_total", "Bates ranges claimed")
PAGES_STAMPED = Counter("endorse_pages_total", "Pages endorsed")
HELD = Counter("endorse_held_total", "Documents routed to the hold queue")
HIGH_WATER = Gauge("endorse_high_water", "Current Bates high-water mark")
def record_claim(alloc: "AtomicBatesAllocator", claim: "Claim") -> None:
CLAIMS.inc()
PAGES_STAMPED.inc(claim.end - claim.start + 1)
HIGH_WATER.set(claim.end)
Conclusion
Bates endorsement is a correctness problem disguised as a formatting task. The numbers must be globally unique and monotonic through crashes and parallelism, so the design centralizes the counter, claims contiguous ranges atomically, persists the high-water mark before rendering, and stamps idempotently on document id. Route unstampable documents to a hold queue so their numbers are accounted for rather than silently reused, place the stamp in a computed safe box so it never obscures content, and assert monotonicity continuously. Done this way, every produced page carries a citation that is unique for the life of the matter and can be defended against any claim that the numbering was inconsistent.
Frequently Asked Questions
Why claim a whole range per document instead of incrementing per page?
Because per-page increment multiplies contention on the central counter by the page count and makes crash recovery harder. Claiming the document’s entire range in one atomic operation touches the counter once per document, so a pool of workers stamping millions of pages contends only a few hundred thousand times. The worker then stamps its pages locally with no further coordination, which is what lets endorsement scale.
What happens to the numbers if a document fails to render after its range was claimed?
They become a detectable gap, not a silent reuse. Because the high-water mark is persisted before rendering, the claimed range is consumed the moment it is handed out. If the render fails, the document goes to the hold queue and its numbers are either reissued when it is fixed or recorded as an intentional gap in the production’s gap log. The one thing that never happens is another document claiming those same numbers.
How is endorsement made safe to retry?
The allocator is idempotent on document id: the first claim records the range in a ledger, and any retry of the same document reads that recorded range instead of claiming a new one. Stamping is likewise re-runnable, drawing the same numbers into the same pages. So a worker that crashes mid-document resumes into the identical range with no duplicate consumption.
Where should the Bates number be placed on the page?
In a consistent margin position — conventionally bottom-right — computed from each page’s geometry so it sits inside a safe area and never overlaps imaged content. When a confidentiality legend is also required, the number is lifted above the footer band so the two do not collide. Pages too small to endorse legibly are routed to the hold queue rather than stamped over their content, because an endorsement that obscures text is a defect QC will reject.
Related
- Production Validation & QC — the gate that audits Bates continuity after endorsement.
- Sequential Bates Numbering Across Parallel Workers — eliminating collisions in a distributed pool.
- Diagnosing Bates Gaps & Family Breaks — reconciling gaps the allocator leaves behind.
- Async Batch Processing Design — the worker-pool model the stamping fan-out builds on.
Up one level: Review & Production Workflows — the pipeline that turns reviewed documents into court-ready productions.