Privilege Log Generation: Justifying Withholdings Without Disclosing Them

A privilege log is the document that makes withholding defensible. When a party withholds responsive material on a claim of privilege, the rules require it to describe each withheld item in enough detail for the other side to assess the claim — without revealing the very content the privilege protects. Inside the Review & Production Workflows pipeline, privilege log generation is the subsystem that turns the review team’s privilege tags into that log, automatically and reproducibly. Done by hand, privilege logs drift from what was actually withheld, omit entries, and expose privileged content in an over-descriptive “description” field. Done as an engineered stage, the log is a pure function of the review tags: every withheld family produces exactly one defensible entry, generated from structured metadata, with the privileged content never touched. This guide builds that subsystem.

Log Generation Subsystem Flow

The log is derived, not authored. Withheld and partially withheld items are selected by disposition, grouped into families so a thread logs as one entry rather than fifty, populated from non-privileged metadata, justified by a controlled-vocabulary basis, and rendered to the required log format — with a completeness check that every withholding has an entry and no produced document leaked onto the log.

Privilege log generation subsystem flow Five stages run left to right. Select withheld and partially withheld items by review disposition. Group them into families so a thread logs as one entry. Populate each entry from non-privileged metadata such as date, author, recipients, and type. Assign a controlled-vocabulary privilege basis: attorney-client, work-product, or both. Render the log to the required delimited or tabular format. A completeness check confirms every withholding has exactly one entry and no produced document leaked onto the log. Derived from tags, never hand-transcribed 1 Select withheld by review disposition 2 Group families one thread, one entry 3 Populate meta date · author · type 4 Assign basis AC · WP · both 5 Render log completeness checked Every withholding gets exactly one entry; no produced document ever appears on the log, and no log entry exposes protected content.

Resource Constraints & Design Rationale

The log is small relative to the collection — it lists only withheld items — so the engineering challenge is not scale but correctness under two opposing failure modes. Under-log and you have withheld responsive material without justifying it, inviting a motion to compel and a waiver argument. Over-describe and the log entry itself discloses the privileged content (“Email conveying legal advice that the company should not disclose the 2019 defect”). The design therefore treats the log as a projection of a fixed, non-privileged field set — date, author, recipients, document type, and a controlled-vocabulary basis — and forbids free-text that could carry protected content into the description.

Because the log is derived from tags rather than typed by reviewers, it is reproducible: re-running the generator against the same review state yields a byte-identical log. That reproducibility is what lets the log be regenerated after a re-review, a clawback, or a downgrade, and it is why the subsystem reads its inputs from the structured review store rather than from a spreadsheet a reviewer maintained by hand. The privilege designations themselves originate in the matter’s privilege schema design, so the log speaks the same controlled vocabulary the review used.

Family Grouping & Entry Construction Algorithm

Two algorithms carry the log’s defensibility. Family collapse ensures a withheld email thread or an attachment family logs as a single, coherent entry rather than as dozens of near-identical rows. Using the family_id carried down from deduplication and family grouping, the generator groups withheld items and emits one entry per family, with the entry’s metadata drawn from the family’s parent and a child count noted. This both shrinks the log to a reviewable size and reflects how privilege actually attaches — to a communication and its attachments as a unit.

Entry construction populates each row from a strict allowlist of non-privileged fields and a controlled basis vocabulary. The date, the author, the recipients (which themselves can support the privilege claim — an attorney on the line is evidence of attorney-client privilege), and the document type come straight from metadata. The basis is one of a fixed set — attorney-client, work-product, or both — never free text. The description, where the rules require one, is generated from a template keyed on the basis and the document type, so it justifies the claim (“Confidential communication between counsel and client reflecting a request for legal advice”) without ever quoting the content.

Resilience & Failure Routing

The subsystem fails closed on the one error that matters: a mismatch between what was withheld and what the log accounts for. Before the log is finalized, a completeness check reconciles the set of withheld dispositions against the set of log entries in both directions — every withheld family must have exactly one entry, and every entry must correspond to a genuinely withheld family. A withheld item with no entry is an under-log (a compliance failure); a log entry for a document that was actually produced is a leak of a different kind (it tells the other side something was withheld when it was not). Either mismatch blocks the log and routes the offending items for review.

A second guard scans every generated description and metadata field for the privileged content itself — the protected terms associated with the withholding — and blocks the log if any appears, catching the over-description failure mechanically. This is the same adversarial posture the redaction automation stage takes toward produced images: do not trust that the templating was safe; verify that no protected content reached the output.

Production Python Implementation

The module selects withheld items, collapses families, builds entries from an allowlisted field set and a controlled basis vocabulary, and runs the completeness and over-description guards. It compiles and runs against structured review records.

python
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Sequence

logger = logging.getLogger("ediscovery.privlog")
logger.setLevel(logging.INFO)


class Basis(str, Enum):
    ATTORNEY_CLIENT = "Attorney-Client Privilege"
    WORK_PRODUCT = "Work Product Doctrine"
    BOTH = "Attorney-Client Privilege; Work Product Doctrine"


# Templated, content-free descriptions keyed on basis + document type.
DESCRIPTION_TEMPLATES = {
    (Basis.ATTORNEY_CLIENT, "email"):
        "Confidential email between counsel and client reflecting a request for or provision of legal advice.",
    (Basis.WORK_PRODUCT, "email"):
        "Email prepared in anticipation of litigation reflecting counsel's mental impressions.",
    (Basis.BOTH, "email"):
        "Confidential attorney-client communication prepared in anticipation of litigation.",
}


class PrivilegeLogError(Exception):
    pass


@dataclass
class WithheldItem:
    doc_id: str
    family_id: str
    is_parent: bool
    date: str
    author: str
    recipients: Sequence[str]
    doc_type: str
    basis: Basis
    protected_terms: Sequence[str] = ()   # used only to guard against leakage


@dataclass
class LogEntry:
    entry_id: str
    family_id: str
    date: str
    author: str
    recipients: str
    doc_type: str
    basis: str
    child_count: int
    description: str


def build_entries(items: List[WithheldItem]) -> List[LogEntry]:
    """Collapse withheld items into one entry per family, populated only from
    an allowlist of non-privileged fields."""
    by_family: Dict[str, List[WithheldItem]] = {}
    for it in items:
        by_family.setdefault(it.family_id, []).append(it)

    entries: List[LogEntry] = []
    for n, (family_id, members) in enumerate(sorted(by_family.items()), 1):
        parent = next((m for m in members if m.is_parent), members[0])
        template = DESCRIPTION_TEMPLATES.get(
            (parent.basis, parent.doc_type),
            "Document withheld on the basis stated; see basis field.")
        entries.append(LogEntry(
            entry_id=f"PRIV-{n:05d}",
            family_id=family_id,
            date=parent.date,
            author=parent.author,
            recipients="; ".join(parent.recipients),
            doc_type=parent.doc_type,
            basis=parent.basis.value,
            child_count=len(members) - 1,
            description=template,
        ))
    return entries


def check_completeness(items: List[WithheldItem], entries: List[LogEntry]) -> None:
    """Every withheld family has exactly one entry, and vice versa."""
    withheld_families = {it.family_id for it in items}
    logged_families = {e.family_id for e in entries}
    missing = withheld_families - logged_families
    extra = logged_families - withheld_families
    if missing:
        raise PrivilegeLogError(f"Under-log: withheld families with no entry: {missing}")
    if extra:
        raise PrivilegeLogError(f"Over-log: entries for non-withheld families: {extra}")


def guard_no_disclosure(items: List[WithheldItem], entries: List[LogEntry]) -> None:
    """Block the log if any protected term reached an entry's text fields."""
    terms = {t.lower() for it in items for t in it.protected_terms if t}
    for e in entries:
        blob = f"{e.author} {e.recipients} {e.doc_type} {e.description}".lower()
        hit = next((t for t in terms if t in blob), None)
        if hit:
            raise PrivilegeLogError(f"Entry {e.entry_id} discloses protected content")


def generate_privilege_log(items: List[WithheldItem]) -> List[LogEntry]:
    entries = build_entries(items)
    check_completeness(items, entries)
    guard_no_disclosure(items, entries)
    logger.info("Privilege log generated: %d entries from %d withheld items",
                len(entries), len(items))
    return entries

Observability & Compliance Metrics

Three KPIs make the log auditable. Coverage (withheld families with an entry ÷ total withheld families) must be exactly 1.0 — anything less is an under-log. Collapse ratio (withheld items ÷ log entries) shows how effectively family grouping shrank the log and is a sanity check that threads were not exploded into per-message rows. Basis distribution (entries by attorney-client, work-product, or both) is the profile opposing counsel will scrutinize and a signal for spotting mis-tagging — an implausible skew often means a review error. The completeness reconciliation is itself the primary compliance artifact: a record that the log accounts for exactly the withheld set.

python
from prometheus_client import Gauge, Counter

LOG_COVERAGE = Gauge("privlog_coverage_ratio", "Withheld families with an entry")
LOG_ENTRIES = Counter("privlog_entries_total", "Privilege log entries", ["basis"])
LOG_COLLAPSE = Gauge("privlog_collapse_ratio", "Withheld items per log entry")


def record_log(items: list, entries: list) -> None:
    families = {getattr(i, "family_id") for i in items}
    LOG_COVERAGE.set(len({e.family_id for e in entries}) / max(1, len(families)))
    LOG_COLLAPSE.set(len(items) / max(1, len(entries)))
    for e in entries:
        LOG_ENTRIES.labels(basis=e.basis).inc()

Conclusion

A privilege log is a projection, not a document someone writes: it must account for exactly the withheld set, justify each withholding from non-privileged metadata, and never let the protected content into its own description. Building it as a derived stage — collapse families to one entry each, populate from an allowlisted field set, assign a controlled-vocabulary basis, and template the description — makes the log reproducible and shrinks it to a reviewable size. Guard it with a two-way completeness reconciliation and an adversarial scan for disclosed content, and the log becomes both a compliance artifact and a defensible account of every document held back, regenerable on demand after any re-review or clawback.

Frequently Asked Questions

Why generate the privilege log from tags instead of having reviewers write it?

Because a hand-maintained log drifts from what was actually withheld and invites two failure modes: entries that are missing (under-logging withheld material) and entries that over-describe (disclosing the privileged content in the description). Deriving the log from structured review tags makes it a reproducible projection of the withheld set — re-running the generator yields the same log — and lets a machine enforce completeness and no-disclosure rules that a human transcriber cannot guarantee.

How does family grouping keep the log defensible and readable?

Privilege attaches to a communication and its attachments as a unit, so logging a withheld fifty-message thread as fifty near-identical rows is both misleading and unreviewable. Collapsing the family into one entry, with the parent’s metadata and a child count, reflects how the privilege actually applies and produces a log the other side can assess. It also shrinks the log dramatically, which the collapse-ratio metric tracks.

What stops the description field from disclosing privileged content?

Two controls. First, descriptions are generated from content-free templates keyed on the basis and document type, so they justify the claim generically (“confidential communication reflecting a request for legal advice”) without quoting anything. Second, an adversarial guard scans every generated entry for the protected terms associated with the withholding and blocks the log if any appears, catching an over-description mechanically before the log ships.

Can the log be regenerated after a clawback or downgrade?

Yes — that reproducibility is a core design goal. Because the log is derived from the current review state, regenerating it after a document is clawed back, downgraded from privileged to produced, or re-tagged simply reflects the new state, and the completeness check confirms the regenerated log still accounts for exactly the withheld set. This is why the generator reads from the structured review store rather than a static spreadsheet.

Up one level: Review & Production Workflows — the pipeline that assembles court-ready productions.