Review & Production Workflows: Defensible Assembly of Court-Ready Productions

Review and production sit at the far end of the EDRM lifecycle, where every earlier decision — how items were ingested, hashed, deduplicated, and grouped into families — is finally converted into the set of documents that actually leaves your custody and enters the record. This is the stage a court, opposing counsel, and your own client will see, and it is unforgiving: a redaction that lifts off in the produced PDF, a Bates number that collides across two workers, a load file whose delimiters shift a metadata column, or a privileged email that slips into the responsive set are not cosmetic defects. They are clawback motions, sanctions exposure, and re-productions billed to the client. When this layer is missing or built as a pile of one-off scripts, the failures surface only after delivery, when they are most expensive to unwind. A production-grade review-and-production layer treats every endorsed page, every load-file row, and every privilege-log entry as an individually attestable event, engineered so that the bytes you deliver can be proven — months later, under scrutiny — to be exactly the bytes review approved.

Production Flow at a Glance

A production is assembled, not exported. Review dispositions fix each document’s fate, redaction and endorsement rewrite the produced image, the load file binds images and metadata into a package the receiving platform can ingest, and a final QC pass proves the package is internally consistent before anything ships. The diagram traces a reviewed collection from disposition to a court-ready production set:

Foundational Taxonomy & Routing

Before a single page is endorsed, the production engine has to know what each document is and what its review disposition permits, because those two facts determine the entire downstream path. A document marked privileged and withheld never reaches the imaging stage; a responsive document with a redaction tag routes through burn-in before endorsement; a technical file with no reviewable text routes to a slip-sheet placeholder rather than a blank image. The taxonomy below is the routing contract the production workers honor — it maps a document’s review disposition to the rendering path and the production artifact it ultimately becomes.

Review disposition Production form Rendering path Load-file treatment
Responsive, not privileged Imaged + native + text Direct render to TIFF/PDF, extracted text Full metadata row, image + native links
Responsive with redactions Imaged (burned-in) + redacted text Redaction burn-in, then render; native withheld Metadata row, image link, redacted-text flag
Privileged, withheld Not produced No render; logged only No load-file row; entry on the privilege log
Privileged, partially produced Imaged (redacted) Redaction burn-in over privileged spans Metadata row + privilege-log cross-reference
Non-responsive Not produced No render Excluded; retained for defensibility audit
Technical / non-viewable Slip-sheet placeholder Placeholder image with reason code Metadata row, placeholder image link

Routing begins the moment review dispositions are frozen for a production set. Each document arrives carrying the review tags applied against the matter’s privilege schema design, and the engine resolves those tags into exactly one production form. Responsive documents take the fastest path when they carry no redaction or privilege flag: render, endorse, link. Redacted and partially privileged documents require the burn-in step handled by redaction automation before any image is generated, because an image rendered before the redaction is applied is a leak waiting to be produced. Withheld privileged items never render at all — they are diverted to privilege log generation, where each withholding is justified without exposing the protected content. This routing table is code, not reviewer discretion, precisely so that the same disposition always yields the same production artifact and the mapping can be reproduced on demand.

Chain-of-Custody & Boundary Enforcement

Production is where chain of custody is most likely to break, because it is the only stage that deliberately creates new bytes — a burned-in image, a Bates-endorsed page, a load file — that did not exist in the collection. Defensibility requires binding every one of those new artifacts back to the collected original it derives from. The engine carries each document’s ingestion-time digest, established through cryptographic hash generation, all the way into the production manifest, so that the produced image of DOC-000123 records the SHA-256 of the source native it was rendered from. That linkage is what lets you prove, at a challenge, that the endorsed page opposing counsel is holding is a faithful rendering of a specific collected file and not a substitution.

Immutable state is the second safeguard. A production set does not overwrite; it accumulates. Each production is a numbered volume with its own append-only manifest, and re-productions never mutate a prior volume — they issue a superseding volume that cross-references what it replaces. When a document is endorsed with a Bates number, that number is claimed from a monotonic allocator and written to the manifest before the image is finalized, so a crash mid-production can never silently reuse or skip a number. The Bates range, the source digest, the redaction status, and the production timestamp become a permanent record that the production compliance frameworks layer treats as evidence.

Boundary enforcement ties the two together. Before the load file is finalized, the engine validates that every Bates number referenced in the load file has a corresponding image on disk, that every image has a load-file row, and that every produced document’s source digest still matches its recorded anchor. A mismatch is never silently corrected; it fails the production and routes the offending document to a review queue, because a page that cannot be traced to its source is a page that cannot be defended. This makes every endorsement, redaction, and load-file binding a legally material event rather than a side effect of an export button.

Privilege Handling & Compliance Integration

Production is the last gate before privileged material would become irreversibly disclosed, so the privilege controls here are strict by design. Every document routed toward production is re-checked against its privilege disposition at the boundary, not trusted from an earlier stage, because a re-review or a late clawback designation must be able to pull a document from the responsive set right up until the volume is sealed. Withheld items are diverted before rendering; partially privileged items are held until redaction automation confirms the protected spans are burned into the image rather than merely hidden behind a layer that a downstream viewer could peel away.

The privilege log is the compliance artifact that makes withholding defensible. For every document held back on a privilege basis, the engine emits a log entry that identifies the family, the basis (attorney-client, work-product, or both), the date, and enough non-privileged metadata to justify the assertion — all generated from the same review tags, never hand-transcribed, so the log can never drift from what was actually withheld. When a privileged document surfaces as a near-duplicate of a responsive one flagged upstream in deduplication and family grouping, the production engine honors the privileged designation across the whole equivalence class, so the “non-privileged copy” can never be produced by accident. All of this conforms to the logging, clawback, and disclosure-remediation obligations defined by the compliance framework: the practical rule is that no production step may ever reduce the privilege protection of a record — it may only preserve it.

Production Python Implementation

The module below assembles a production volume from a set of reviewed documents. It allocates Bates numbers from a monotonic, crash-safe counter, routes each document by its disposition, writes an append-only production manifest, and refuses to finalize a volume whose images and manifest disagree. The inline comments call out the defensibility rationale — why numbers are claimed before rendering, why withheld documents never touch the imaging path, and why a manifest write failure is fatal.

python
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List

logger = logging.getLogger("ediscovery.production")
logger.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter('{"level":"%(levelname)s","msg":"%(message)s"}'))
logger.addHandler(_handler)


class Disposition(str, Enum):
    RESPONSIVE = "responsive"
    RESPONSIVE_REDACTED = "responsive_redacted"
    PRIVILEGED_WITHHELD = "privileged_withheld"
    PRIVILEGED_PARTIAL = "privileged_partial"
    NON_RESPONSIVE = "non_responsive"


class ProductionError(Exception):
    """Raised when a document cannot be produced defensibly."""


@dataclass
class ReviewedDoc:
    doc_id: str
    source_path: Path
    source_sha256: str          # ingestion-time anchor, never recomputed loosely
    disposition: Disposition
    family_id: str
    privilege_basis: Optional[str] = None
    page_count: int = 1
    metadata: Dict[str, Any] = field(default_factory=dict)


@dataclass
class ProducedItem:
    doc_id: str
    bates_start: str
    bates_end: str
    source_sha256: str
    image_path: Optional[Path]
    withheld: bool


class BatesAllocator:
    """Monotonic, gap-free Bates allocator. Numbers are claimed and persisted
    before an image is rendered, so a crash can never reuse or skip a value."""

    def __init__(self, prefix: str, start: int, width: int, state_path: Path):
        self.prefix = prefix
        self.width = width
        self.state_path = state_path
        self._next = self._restore(start)

    def _restore(self, start: int) -> int:
        # Resume from the last persisted value so a restart continues the
        # sequence rather than colliding with numbers already produced.
        if self.state_path.exists():
            return int(self.state_path.read_text().strip()) + 1
        return start

    def claim(self, pages: int) -> tuple[str, str]:
        if pages < 1:
            raise ProductionError("A produced document must have at least one page")
        first = self._next
        last = self._next + pages - 1
        self._next = last + 1
        # Persist the high-water mark *before* the caller renders anything.
        self.state_path.write_text(str(last))
        return self._fmt(first), self._fmt(last)

    def _fmt(self, n: int) -> str:
        return f"{self.prefix}{n:0{self.width}d}"


class ProductionVolume:
    def __init__(self, volume_id: str, manifest_path: Path, allocator: BatesAllocator):
        self.volume_id = volume_id
        self.manifest_path = manifest_path
        self.allocator = allocator
        self._items: List[ProducedItem] = []

    def produce(self, doc: ReviewedDoc, render_image) -> Optional[ProducedItem]:
        """Route one reviewed document to its production form. `render_image`
        is a callable that produces an endorsed image and returns its path."""
        if doc.disposition is Disposition.NON_RESPONSIVE:
            logger.info("Skipped non-responsive document %s", doc.doc_id)
            return None

        if doc.disposition is Disposition.PRIVILEGED_WITHHELD:
            # Withheld items never reach the imaging path. They are recorded so
            # the privilege log can justify the withholding downstream.
            item = ProducedItem(doc.doc_id, "", "", doc.source_sha256, None, withheld=True)
            self._append_manifest(doc, item)
            self._items.append(item)
            return item

        # Verify the source still matches its anchor before we render from it.
        if self._digest(doc.source_path) != doc.source_sha256:
            raise ProductionError(f"Source digest drift for {doc.doc_id}; refusing to produce")

        # Claim the Bates range first, then render into it.
        bates_start, bates_end = self.allocator.claim(doc.page_count)
        image_path = render_image(doc, bates_start, bates_end)
        item = ProducedItem(doc.doc_id, bates_start, bates_end,
                            doc.source_sha256, image_path, withheld=False)
        self._append_manifest(doc, item)
        self._items.append(item)
        return item

    def _digest(self, path: Path) -> str:
        h = hashlib.sha256()
        with open(path, "rb") as f:
            while chunk := f.read(8192):
                h.update(chunk)
        return h.hexdigest()

    def _append_manifest(self, doc: ReviewedDoc, item: ProducedItem) -> None:
        """Append an immutable production record. A failure here is fatal: a
        produced page the manifest did not capture is a page we cannot defend."""
        entry = {
            "volume": self.volume_id,
            "doc_id": doc.doc_id,
            "family_id": doc.family_id,
            "bates_start": item.bates_start,
            "bates_end": item.bates_end,
            "source_sha256": item.source_sha256,
            "disposition": doc.disposition.value,
            "privilege_basis": doc.privilege_basis,
            "withheld": item.withheld,
            "produced_at": datetime.now(timezone.utc).isoformat(),
        }
        try:
            with open(self.manifest_path, "a", encoding="utf-8") as f:
                f.write(json.dumps(entry) + "\n")
        except OSError as exc:
            logger.critical("Production manifest write failed for %s", doc.doc_id)
            raise ProductionError("Manifest logging interrupted") from exc

    def finalize(self) -> Dict[str, Any]:
        """Refuse to seal a volume whose produced images and manifest disagree."""
        produced = [i for i in self._items if not i.withheld]
        missing = [i.doc_id for i in produced if not (i.image_path and i.image_path.exists())]
        if missing:
            raise ProductionError(f"Volume {self.volume_id} has manifest rows without images: {missing}")
        return {
            "volume": self.volume_id,
            "produced": len(produced),
            "withheld": sum(1 for i in self._items if i.withheld),
            "bates_high_water": produced[-1].bates_end if produced else None,
        }

Horizontal Scaling & Observability

Productions run against deadlines — a court order sets a date, and a hundred-thousand-document volume has to image, endorse, and validate inside it — so the production layer scales horizontally across a worker pool built on the same async batch processing primitives used upstream. The hard constraint is that horizontal scale must not break the two invariants production depends on: Bates numbers must stay globally monotonic even when many workers endorse concurrently, and the manifest must remain a single source of truth. Both are solved by centralizing the scarce, ordered resource — the Bates allocator — behind an atomic counter, while the parallelizable work (rendering, burn-in, text extraction) fans out freely.

A production you cannot observe is a production you cannot certify. Three signals matter most. Endorsement throughput (pages per minute) tells you whether the volume finishes before the deadline. Parity rate (the fraction of documents whose image count, text file, and manifest row all agree) is the live health metric for internal consistency. Rejection velocity (documents failing QC per minute) is the early warning that a redaction regression or a corrupt render path has entered the stream. The snippet instruments the volume with Prometheus counters and an OpenTelemetry span so each signal is emitted per document.

python
from prometheus_client import Counter, Histogram
from opentelemetry import trace

tracer = trace.get_tracer("ediscovery.production")

PAGES_ENDORSED = Counter("prod_pages_endorsed_total", "Pages endorsed", ["volume"])
DOCS_PRODUCED = Counter("prod_documents_total", "Documents produced", ["outcome"])
PARITY_FAILURES = Counter("prod_parity_failures_total", "Image/text/manifest mismatches")
ENDORSE_SECONDS = Histogram("prod_endorse_seconds", "Seconds to render and endorse one document")


def instrumented_produce(volume: "ProductionVolume", doc: "ReviewedDoc", render_image) -> None:
    """Wrap production of one document with metrics and a trace span so
    throughput, parity, and rejection signals are emitted per item."""
    with tracer.start_as_current_span("produce_document") as span:
        span.set_attribute("prod.doc_id", doc.doc_id)
        span.set_attribute("prod.disposition", doc.disposition.value)
        with ENDORSE_SECONDS.time():
            item = volume.produce(doc, render_image)

        if item is None:
            DOCS_PRODUCED.labels(outcome="skipped").inc()
            return
        if item.withheld:
            DOCS_PRODUCED.labels(outcome="withheld").inc()
            return

        pages = int(item.bates_end[-volume.allocator.width:]) - int(item.bates_start[-volume.allocator.width:]) + 1
        PAGES_ENDORSED.labels(volume=volume.volume_id).inc(pages)
        DOCS_PRODUCED.labels(outcome="produced").inc()
        span.set_attribute("prod.bates_range", f"{item.bates_start}-{item.bates_end}")

Rejection monitoring closes the loop. Every document that fails a parity check, a redaction validation, or a digest match lands in a rejection queue with its error class and its source digest, and an alert fires when rejection velocity crosses a rolling threshold. Because the queue preserves each document’s family and disposition, remediation never breaks a family: a re-rendered document re-enters the volume under its original disposition rather than seeding an inconsistent production.

Conclusion

Review and production is the stage where the defensibility invested in every upstream layer is either cashed in or squandered. Engineering it well means treating a production as an accumulating, manifest-backed artifact rather than an export: Bates numbers claimed before rendering so they can never collide or skip, redactions burned in before an image ever exists, withheld documents diverted before they can leak, and a final QC gate that refuses to seal a volume whose images, text, and manifest disagree. Instrument the throughput, parity, and rejection signals that prove the work, bind every produced page back to its collected source digest, and the production layer turns a reviewed collection into a set of documents that can be defended page by page — under a clawback motion, a Daubert challenge, or a completeness dispute — long after delivery.

Frequently Asked Questions

What is the difference between review and production in an eDiscovery pipeline?

Review is where humans or models assign each document a disposition — responsive, privileged, redacted, non-responsive. Production is the engineering stage that converts those dispositions into the actual deliverable: endorsed images, extracted text, native files, a load file that binds them, and a privilege log for what was withheld. Review decides what leaves your custody; production controls how it leaves, and is where chain-of-custody and privilege protections are enforced for the last time.

Why must Bates numbers be allocated before rendering an image?

Because the Bates number is the permanent citation for a produced page, it must be gap-free and never reused, even across a crash or a parallel worker pool. Claiming the number from a monotonic allocator and persisting it before the image is rendered means a failure mid-render leaves a claimed-but-unused number (a detectable gap) rather than a silently reused one (an undetectable collision). Reusing a Bates number produces two different pages with the same citation, which is a defect that can invalidate a production.

How does production prevent a privileged document from being disclosed?

Every document is re-checked against its privilege disposition at the production boundary, not trusted from an earlier stage. Withheld items are diverted before any image is rendered and routed to the privilege log; partially privileged items are held until redaction burn-in confirms the protected spans are rasterized into the image rather than hidden behind a removable layer. A privileged designation is honored across an entire near-duplicate equivalence class, so a byte-identical or near-identical copy cannot be produced as a “non-privileged” version.

What is a load file and why does its integrity matter?

A load file (typically a Concordance DAT plus an Opticon OPT, or a Relativity-compatible variant) is the delimited index the receiving review platform reads to import a production: it maps each document’s metadata, its image pages, its extracted text, and its native file. If a delimiter drifts or an encoding is wrong, a metadata column can shift silently, binding the wrong text or image to a document. Because the receiving side trusts the load file, a corrupt one produces a corrupted review set on the other party’s system — which is why load-file generation and validation are treated as a first-class, gated step.

How do you keep Bates numbering monotonic when producing with many parallel workers?

Centralize the scarce ordered resource. The Bates allocator lives behind a single atomic counter (a database sequence, a Redis INCR, or an equivalent), and workers request a contiguous range for a document rather than incrementing locally. The parallelizable work — rendering, redaction burn-in, text extraction — fans out freely, but the number assignment is serialized through the counter, so concurrency scales throughput without ever producing two pages that claim the same citation.

Up: eDiscovery Automation home · Part of the Review & Production Workflows resource.