Fixing Privilege-Log Drift: Reconciling the Log Against the Withheld Set

The privilege log that does not match what was actually withheld is a quiet but serious defect: entries for documents that were produced, withheld documents with no entry, and stale rows left over from a prior review pass. It surfaces in the Privilege Log Generation stage whenever the log is treated as a hand-maintained artifact that lags the review state instead of a projection of it. The consequence is concrete — a motion to compel over an under-logged document, or a waiver argument when the log describes something already produced — and it almost always traces to the log and the review store having diverged. This guide isolates the drift, gives a two-way reconciliation that detects every mismatch, and delivers a regeneration procedure that makes the log a deterministic function of current review tags.

Diagnostic Log Signatures

The reconciliation pass emits a precise mismatch report. Capture it verbatim — each line names a document whose log state and review state disagree.

text
[INFO] Privilege log reconcile: 1284 withheld families, 1291 log entries
[ERROR] UNDER_LOG family=FAM-004821 doc=DOC-051200 withheld=true logged=false
[ERROR] OVER_LOG entry=PRIV-00733 family=FAM-002100 logged=true withheld=false (produced)
[ERROR] STALE_ENTRY entry=PRIV-00990 family=FAM-000012 (family no longer exists)
[WARN] BASIS_DRIFT family=FAM-003007 log=work_product review=attorney_client
[CRITICAL] Privilege log BLOCKED: 3 reconciliation errors, 1 basis drift

Symptom checklist — two or more confirm log drift rather than an isolated typo:

  • The log entry count does not equal the count of withheld families in the current review state.
  • A document tagged withheld has no corresponding log entry (under-log).
  • A log entry names a family that was produced or downgraded (over-log).
  • The basis on a log row disagrees with the review tag’s basis for the same family.
  • Regenerating the log from the review store yields a different set of entries than the shipped log — proof the shipped log is stale.

Root-Cause Breakdown

Drift comes from the log being maintained independently of the review state.

  1. Manual or one-time export. The log was exported once to a spreadsheet and then edited by hand, so every subsequent review change — a clawback, a downgrade, a late privilege call — updated the review store but not the log. The two diverge monotonically from the moment the export is frozen.
  2. No parent-of-record for the family. When entries are built per document rather than per family, a re-review that changes one message in a thread leaves sibling rows inconsistent, and the log double-counts or mislabels the family. Without a deterministic parent-of-record, the same family can log differently on two runs.
  3. Basis stored in two places. If the basis lives both on the log row and on the review tag and they are updated independently, they drift. The basis has a single source of truth — the review tag governed by privilege schema design — or it will disagree with itself.
  4. Deletions not propagated. A family removed from the collection (a de-duplication correction, a scope change) leaves an orphaned log entry pointing at a family that no longer exists.

Remediation Architecture

Stop maintaining the log; regenerate it. Make the log a pure function of current review tags and reconcile both directions before it ships.

  1. Single source of truth. Read withheld dispositions and basis directly from the structured review store at generation time. The log never stores state the review store does not already hold.
  2. Deterministic family projection. Group by family_id, pick a deterministic parent-of-record, and emit exactly one entry per withheld family, so two runs against the same state produce identical logs.
  3. Two-way reconciliation. Compute the set difference in both directions — withheld-but-unlogged and logged-but-not-withheld — plus a stale-entry check for families that no longer exist, and block on any nonzero result.
  4. Regenerate, don’t patch. When drift is found, discard the stale log and regenerate from the review store rather than editing rows, so the fix cannot itself introduce new drift.

The reconciliation flow classifies each mismatch and routes it to the correct correction before regeneration.

Two-way privilege-log reconciliation flow The withheld set from the review store and the entries from the shipped log both feed a two-way diff. The diff produces under-log findings for withheld families with no entry, over-log findings for entries whose family was produced, and stale-entry findings for families that no longer exist. Each finding routes to a correction, then the log is regenerated from the review store and re-reconciled until the diff is empty. Two-way diff, then regenerate from source Withheld set review store Shipped log entries Two-way diff Under-log → add entry Over-log → drop entry Stale → purge entry Regenerate re-reconcile clean

Reconciliation implementation

The routine diffs the withheld set against the shipped entries in both directions, flags basis drift, and reports whether regeneration is required. It compiles and runs against plain records.

python
import logging
from dataclasses import dataclass
from typing import Dict, List, Set

logger = logging.getLogger("ediscovery.privlog.reconcile")


@dataclass
class Finding:
    kind: str        # under_log, over_log, stale_entry, basis_drift
    family_id: str
    detail: str


def reconcile(withheld_basis: Dict[str, str],
              logged_basis: Dict[str, str],
              live_families: Set[str]) -> List[Finding]:
    """withheld_basis: family_id -> basis for currently-withheld families.
    logged_basis: family_id -> basis on the shipped log.
    live_families: family_ids that still exist in the collection."""
    findings: List[Finding] = []
    withheld = set(withheld_basis)
    logged = set(logged_basis)

    for fam in sorted(withheld - logged):
        findings.append(Finding("under_log", fam, "withheld family missing from log"))
    for fam in sorted(logged - withheld):
        kind = "stale_entry" if fam not in live_families else "over_log"
        findings.append(Finding(kind, fam, "logged family not in withheld set"))
    for fam in sorted(withheld & logged):
        if withheld_basis[fam] != logged_basis[fam]:
            findings.append(Finding("basis_drift", fam,
                                    f"log={logged_basis[fam]} review={withheld_basis[fam]}"))
    return findings


def requires_regeneration(findings: List[Finding]) -> bool:
    blocking = {"under_log", "over_log", "stale_entry", "basis_drift"}
    hits = [f for f in findings if f.kind in blocking]
    for f in hits:
        logger.error("%s family=%s: %s", f.kind.upper(), f.family_id, f.detail)
    return bool(hits)

Verification Checklist

Confirm the log matches the withheld set before it ships:

Conclusion

Privilege-log drift is what happens when the log is maintained instead of derived. A one-time export edited by hand, a basis stored in two places, or deletions that never propagate all pull the log away from the review state until it under-logs a withheld document or over-describes a produced one. The cure is to make the log a deterministic projection of current review tags, reconcile the withheld set and the entries in both directions, and regenerate rather than patch when they disagree. When re-running the generator reproduces the shipped log exactly and the two-way diff is empty, the log is provably complete and current — and the reconciliation report is the record that proves it.

Frequently Asked Questions

How can a privilege log have entries for documents that were produced?

Usually because the document was withheld at one point, logged, and then downgraded to produced after a re-review — but the log was never regenerated. The stale entry now claims privilege over something the other side received, which is both confusing and a potential waiver signal. A two-way reconciliation catches it as an over-log finding, and regenerating from the current review state removes it.

Why regenerate the whole log instead of just fixing the mismatched rows?

Because patching rows is how drift started. Editing individual entries by hand reintroduces exactly the manual-maintenance failure mode that caused the divergence, and it cannot guarantee the rest of the log is consistent. Regenerating from the single source of truth — the review store — makes the entire log consistent in one deterministic operation, and the reconciliation then confirms it is clean.

What is basis drift and why does it matter?

Basis drift is when a log entry says a family was withheld as work-product while the current review tag says attorney-client (or vice versa). It matters because the basis is the legal justification opposing counsel evaluates; a log that misstates it undercuts the claim and signals the log is not tracking the review. Keeping the basis in one place — the review tag — and projecting it into the log eliminates the drift.

For log-format expectations, see the Federal Rules of Civil Procedure Rule 26(b)(5) and Sedona Conference commentary on privilege logging.

Up one level: Privilege Log Generation — the stage that justifies every withholding.