Practice Lab

Redact Locally, Reason Remotely

WorkflowSeptember 30, 2026 · David J.S. Madgett · 15 min read

Read two essays in this section together and they describe one machine.

Anonymization Is Not in the Rule. The Test Is. concluded that scrubbing a prompt to the standard of Comment [4] to Rule 1.6 — “no reasonable likelihood that the listener will be able to ascertain the identity of the client or the situation involved” — is the one route to a hosted model that works no matter the privilege, no matter the harm, and no matter whether the client has signed anything. Apple Just Priced the Local Thesis pointed out that the hardware to run a capable model on a desk now starts at $899. And the June piece, Running Models on Your Own Hardware, argued that local models are close to the frontier on narrow, read-heavy work and a real step behind on hard reasoning.

Put those three together and the design falls right out. The local model does what it’s good at: reading raw client text and finding what identifies. The frontier model does what it’s good at: reasoning, on a slice it can’t trace back to anybody. The token map connecting the two never leaves the office. And a person stands at the one spot where information crosses the firm’s boundary.

It’s a design. Here’s what each stage has to do, the code for the mechanical half, and exactly how each stage breaks.

The pipeline

Step 0. Is the matter even eligible?

Three questions come before any software runs.

What did the client pick? In Run It Yourself, Then Let the Client Choose I described a tier election where a Tier 1 client gets “[o]nly models running on the firm’s own hardware. No information relating to the representation is transmitted to any outside provider.” This pipeline doesn’t run on a Tier 1 matter, and scrubbing doesn’t change that. A scrubbed prompt built from a matter is still information relating to the representation; Comment [3] reaches that information “whatever its source.” Comment [4] makes a well-built hypothetical OK to disclose. It doesn’t make the hypothetical stop coming from the client’s matter. The client’s election covered the information, not just the names, and the routing has to honor what the client actually chose.

Has the client told you anything? Comment [17] contemplates that “[a] client may require the lawyer to implement special security measures not required by this Rule”, and Comment [5] makes implied authority subject to “the client’s instructions”. An instruction to keep the matter off outside services ends it right there, same as a Tier 1 election.

Can this job be scrubbed at all? Full-record review, reading a production for the one document that matters, a deposition summary, a medical chronology: those jobs are the specifics, and once you strip the specifics there’s nothing left to analyze. They go to the local model alone, or to a hosted model with the client’s informed consent. The pipeline is for the question you can ask as a hypothetical — the legal issue, how the argument hangs together, a draft built from a fact pattern you can tell without the identifying details.

Step 1. Build the matter’s register

The exact-match pass works off a list, so the list comes first. For each matter: every party’s full name and the short versions people actually use; relatives, employers, and household members, because federal health-privacy law lists identifiers of “relatives, employers, or household members of the individual” for a reason; street addresses; account, claim, policy, and loan numbers; the court file number; the judge; opposing counsel; and any dollar figure or date that also shows up in a public filing.

Build it once from the file, keep it with the matter, and update it when the file changes. The register is client information too, and it lives where the map lives.

Step 2. The exact-match pass

Before any model reads anything, exact matching takes out whatever exact matching can take out. It’s fast, you can audit it, and unlike a model, it doesn’t have good days and bad days. Here’s the core of it:

import json, re, secrets
from pathlib import Path

# Path fragments a sync client is known to use. A floor, not a proof.
SYNCED = ("Dropbox", "OneDrive", "Google Drive", "Mobile Documents", "CloudStorage")

PATTERNS = {                                   # identifiers no party list will contain
    "SSN":   r"\b\d{3}-\d{2}-\d{4}\b",
    "ACCT":  r"\b\d{8,17}\b",
    "PHONE": r"\(?\b\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b",
}

def new_token(kind, tmap):                     # random, never derived from the value,
    used = set(tmap.values())                  # and never a token already in the map
    while True:
        token = f"[{kind}_{secrets.randbelow(10**6):06d}]"
        if token not in used:
            return token

def pseudonymize(text, register, tmap):
    """register: {"PARTY": [...], "ADDRESS": [...], ...} for ONE matter."""
    items = [(k, v) for k, vals in register.items() for v in vals]
    for kind, value in sorted(items, key=lambda kv: -len(kv[1])):   # longest first
        if value not in tmap:
            tmap[value] = new_token(kind, tmap)
        token = tmap[value]
        text = re.sub(rf"(?<!\w){re.escape(value)}(?!\w)", token, text, flags=re.I)
    for kind, pat in PATTERNS.items():
        for value in set(re.findall(pat, text)):
            if value not in tmap:
                tmap[value] = new_token(kind, tmap)
            token = tmap[value]
            text = text.replace(value, token)
    return text

def residue(text, register):
    """Run AFTER every pass, including the local model's. Non-empty = stop the line."""
    hits = [v for vals in register.values() for v in vals
            if re.search(rf"(?<!\w){re.escape(v)}(?!\w)", text, flags=re.I)]
    return hits + [m for p in PATTERNS.values() for m in re.findall(p, text)]

def reidentify(text, tmap):
    back = {tok: val for val, tok in tmap.items()}
    if len(back) != len(tmap):                 # two values share a token: refuse
        raise ValueError("token map contains duplicate tokens; re-identification would swap people")
    lookalikes = set(re.findall(r"\[?\b[A-Za-z]+[_ ]?\d{6}\b\]?", text))
    stray = lookalikes - set(back)
    if stray:                                  # a token the model invented or mangled
        raise ValueError(f"unmapped tokens in output: {sorted(stray)}")
    for tok, val in back.items():
        text = text.replace(tok, val)
    return text

def save_map(tmap, path):
    p = Path(path).expanduser().resolve()
    if any(s in str(p) for s in SYNCED):
        raise SystemExit(f"refusing to write a token map under a synced folder: {p}")
    p.write_text(json.dumps(tmap, indent=1))

Four details in that code are the difference between a pass that works and one that just looks like it works. Each one is a way the naive version breaks on an ordinary test file:

  • Longest first. “Jane Q. Example” has to be replaced before “Jane,” or the output reads “[PARTY_…] Q. Example.”
  • Word boundaries. Without them, a register entry of “Jane” turns a witness named Janet into “[PARTY_…]t,” and the residue check flags a name that isn’t there.
  • Random, unique tokens. Initials or a client number are abbreviations. They aren’t pseudonyms. HIPAA’s re-identification provision, 45 C.F.R. § 164.514(c)(1), states the principle: a re-identification code must be “not derived from or related to information about the individual”. Random tokens bring their own problem: a new token can collide with one already issued, and a map where two names share a token re-identifies one party as the other without throwing any error. new_token keeps drawing until it gets a token the map doesn’t already hold, and reidentify() refuses any map that contains a duplicate.
  • Tokens a machine can spot. Every token carries six digits, so a token the frontier model invents or mangles (“Party 482913,” “[PARTY_999999]”) gets caught on the way back. Mix letters and digits in the token format and the same check flags ordinary words as stray tokens. What it won’t catch is a token with a dropped digit, which is one reason a person still reads the result.

Step 3. The local model pass

A register catches what somebody thought to write down. It won’t catch “her brother-in-law, the county sheriff,” a nickname nobody recorded, a business described by what it does (“the only cold-storage warehouse in town”), or a medical condition rare enough to point straight at whoever has it. That’s reading comprehension, and it’s the local model’s job.

Give the local model two jobs on the already-tokenized text: mark every span the register missed that could identify a person, place, or organization, and propose a generalization for every date, amount, and location that isn’t already a token. The model suggests. It doesn’t decide. Its markings go to the person in Step 6, and its rewritten text goes right back through the residue check in Step 4.

This pass has to be local because it reads the raw text. That’s the whole reason the design has two models instead of one. It’s also exactly the kind of work the June essay said local models do nearly as well as frontier ones: long input, short output, a job with a right answer. So test it that way. Seed thirty real-looking documents with identifiers you planted, run the pass, and count how many it catches. How Do You Know It Works? set the rule for extraction: “Weight recall far above precision.” Here a false alarm costs somebody five seconds of reading. A miss is a disclosure.

Step 4. Check for residue, again

Run residue() on whatever the local model hands back. Models rewrite, and a model told to “generalize the location” will sometimes put a name back from context while it’s at it. Anything on the register that shows up again stops the line. The check is cheap, it’s mechanical, and it runs every time, because Step 2 is pointless if Step 3 can quietly undo it.

Step 5. Generalize the situation

Software can’t finish this step, because it turns on what the question actually needs.

Go through every exact value left in the text and ask whether the answer depends on it. A severance amount that only sets the scene becomes “a five-figure severance.” A termination date that only puts events in order becomes “nine days after the report.” A county that only places the story becomes “a rural county.” Those generalizations are what move a prompt from “no names in it” to “no way back to the situation.”

When the exact value does drive the answer, don’t send it. If the question is whether a deadline has run, compute the deadline on your desk and send what’s left of the question. If it’s whether a claim clears a threshold, answer that locally and send the legal issue without the number. When one value both identifies the client and decides the question, you’ve hit the pipeline’s limit, and it’s local-only or consent.

Step 6. A person reads what’s about to leave

In The Agent Drafts. The Human Sends. I set the rule for this firm’s stack: “A human fires anything that crosses the firm’s boundary.” A scrubbed prompt headed to a hosted model is exactly that kind of crossing, and it gets a person.

The reader asks one question: could a stranger with this prompt and a search engine find the client? And the reader reads the whole conversation that’s about to go, not just the newest turn, because the model gets the whole thread, and twelve harmless turns can add up to one identifiable fact pattern.

Sometimes the answer is “this cannot be de-identified.” That’s a perfectly good answer. It sends the job to the local model alone, or to an informed-consent conversation under Rule 1.0(f), which requires “adequate information and explanation about the material risks of and reasonably available alternatives to the proposed course of conduct.”

Step 7. Send the smallest slice, in a fresh session

Send only what the question needs, to a provider whose terms you’ve read. Opinion 512 makes that the baseline at page 7: “all lawyers should read and understand the Terms of Use, privacy policy, and related contractual terms and policies of any GAI tool they use”. Rule 5.3’s Comment [3] ties the diligence to “the terms of any arrangements concerning the protection of client information”.

Start a fresh session for every question. A long-running thread about one matter is the pile-up problem from Step 6 built right into the setup. Separate questions in separate sessions give each listener one piece.

Step 8. Check the answer before anybody re-identifies it

reidentify() refuses to run if the answer has a token that isn’t in the map. That catches a model that invented “[PARTY_999999]” to name somebody it assumed exists, and a model that re-cased or re-spaced a token so it won’t map back. Refusing is the right call: an answer about a party who doesn’t exist is a wrong answer, and quietly leaving the stray token in place would hide that.

What code can’t catch is a model that guesses. An answer that says the facts “resemble” a reported case, or names a likely employer, has put identity back in words. Whoever reads the output reads it for that too.

Step 9. Re-identify on the desk

The answer gets re-identified locally, into the matter’s own working file, by somebody authorized to hold the map. Re-identified text never goes back into the hosted session. The most common way a clean pipeline leaks is a follow-up typed with the real name in it, because the lawyer has the real answer open in the next window.

Where the map can’t live

The map is the most sensitive file this whole thing creates. It’s the key to the scrubbed prompt, and anybody holding the map and the vendor’s copy of the prompt holds the original. HIPAA’s re-identification provision makes the same point for health data: the entity can keep a code, provided it “does not disclose the mechanism for re-identification.” The rules for the map come out of Rule 1.6(c)’s “reasonable efforts to prevent the inadvertent or unauthorized disclosure of, or unauthorized access to, information relating to the representation of a client”:

  • Never in a folder a sync client watches. The save_map guard refuses the obvious paths. It only catches the obvious ones, because some sync products sync ordinary-looking folders like Documents and Desktop with no telltale path at all. Keep maps in one directory you’ve confirmed no sync client watches, and check it again after every operating-system update.
  • One map per matter, never one for the firm. A firm-wide map is every client’s identity in one file.
  • Encrypted at rest, and backed up only to an encrypted backup the firm controls. Lose the map and the scrubbed work is stranded. Leak it and all of it comes undone.
  • Limited to the people on the matter. Comment [5] lets lawyers in a firm share client information “unless the client has instructed that particular information be confined to specified lawyers.” A map doesn’t need to go as far as the rule allows, and it shouldn’t.
  • Kept and destroyed with the file. The duty of confidentiality outlives the representation — Comment [19]: “The duty of confidentiality continues after the client-lawyer relationship has terminated.” — and the map goes wherever the closed file goes.

The records this creates

Run this pipeline and you’ve got three records where you used to have one: the vendor’s copy of the scrubbed prompts and answers, kept on the vendor’s terms; the firm’s local copy of the same exchange; and the map. On its own, the vendor’s copy is tokens and generalizations. Put the firm’s copy together with the map and you’ve got everything.

That’s the point. A subpoena served on the provider gets fact patterns about “[PARTY_…].” A subpoena served on the firm gets the whole record, which is where a subpoena for a lawyer’s work ought to have to go. But the firm’s copy is now a complete, timestamped record of counsel’s questions about the matter, and everything I wrote in The Discovery of Your Prompts applies to it as-is: pick the retention period on purpose, make sure a litigation hold can override it, and don’t let the pipeline’s logs turn into one more folder nobody’s ever inventoried.

How it breaks, and what catches it

Failure What catches it What still gets through
The register misses a name Local model pass (Step 3); human read (Step 6) A name neither the model nor the reader recognizes as a name
The local model misses an identifier or restores one Residue check after the model (Step 4) Anything not on the register; that is why Step 6 exists
The situation identifies the client with no name in it Generalization (Step 5); the stranger-with-a-search-engine test (Step 6) A reader who knows the facts too well to see them as a stranger would
Harmless turns accumulate into an identifiable thread Fresh session per question (Step 7); reading the whole thread (Step 6) A single question that genuinely needs all the facts at once; that one is not a candidate
The map syncs to the cloud save_map path guard; one audited directory A sync client configured to watch an ordinary folder
The answer re-attaches identity reidentify() refusing unmapped tokens (Step 8); human read of the output A mangled token with a dropped digit; a guess expressed in words
Re-identified text goes back to the vendor Re-identify only on the desk, never in the session (Step 9) A lawyer typing the real name into a follow-up
Logs outlive the policy, or die before a hold A written retention period the hold process can override Nothing, if the policy exists and is applied

Every row leaves something in the last column, and every one of those is a person’s judgment call. The design puts that judgment in two places, the outgoing read and the incoming read, which is where a small firm can actually afford to spend it.

What it costs, in the terms Comment [17] uses

Comment [17] says the factors for judging whether Rule 1.6(c) efforts were reasonable “include, but are not limited to,” these: “the sensitivity of the information, the likelihood of disclosure if additional safeguards are not employed, the cost of employing additional safeguards, the difficulty of implementing the safeguards, and the extent to which the safeguards adversely affect the lawyer’s ability to represent clients”. Here’s the pipeline scored against each one.

Factor Effect of the pipeline
Sensitivity of the information What the vendor holds is tokens and generalized facts; the sensitive part stays on the desk
Likelihood of disclosure without the safeguard Falls sharply for identity; unchanged for anything that could not be generalized, which is why that material does not go
Cost Hardware from $899 at the entry level; setup time; a few minutes of a person’s reading per outgoing prompt
Difficulty Real. A register per matter, a local model to maintain, and an evaluation set for the de-identifier itself
Adverse effect on the ability to represent clients The real cost. Generalization loses precision, the extra steps add time, and some tasks cannot use the pipeline at all

The last row is where a skeptic ought to push, and he’d be right to. A pipeline that makes the frontier model’s answer materially worse has traded one client interest for another. Measure it: run the same questions with and without the pipeline, on fact patterns you made up for the purpose, and compare the answers. If generalizing is costing you the answer, the pipeline’s telling you that question needed the specifics, and specifics mean local or consent.

What this doesn’t buy you

It doesn’t satisfy a Tier 1 election, and it doesn’t satisfy a client’s instruction to keep the matter in the building. That’s what Step 0 is for.

It doesn’t make anything less privileged. Privilege is about the communication, and a scrubbed copy of a privileged communication still came from one. The pipeline goes to Rule 1.6’s disclosure question. It doesn’t answer a waiver question, and nobody should describe it like it does.

It does nothing about hallucination. The frontier model’s answer is exactly as reliable as it would’ve been with the names in it, and every authority in it gets checked against the source before anyone relies on it.

And it doesn’t replace consent for the work you can’t scrub. What it does is shrink that pile down to the jobs that really need the specifics, so the consent conversation is about the work that needs it instead of boilerplate about everything.

I’ve got nothing to sell here. The code above is free to copy and adapt, which is the whole idea of this section. My interest is the one I disclosed in the anonymization essay: this firm uses these tools, and a design that makes them easier to use defensibly helps it.

When I wouldn’t bother

  • When the volume’s low. If you ask a hosted model a handful of questions a month, write each hypothetical by hand, read it, and send it. The pipeline earns its keep at volume.
  • When the matter’s Tier 1, or the job can’t be scrubbed. Local alone, or consent. No amount of engineering changes that.
  • When the scrubber flunks its test. If the local pass misses planted identifiers at a rate you wouldn’t accept from a paralegal, it doesn’t ship, and it doesn’t matter how good the frontier answers look.
  • When local models catch up on reasoning. The design exists because the best reasoning is remote. If a model on the desk gets good enough for the hard part, the right setup is the simpler one: nothing leaves, and the pipeline gets retired. The July essay, The Model Will Be Free. The Electricity Won’t., argued that day is coming. When it gets here, I’ll be glad to throw this away.

Sources

General commentary on practice management and legal technology. Not legal advice and not ethics advice. The code is a sketch offered for adaptation, not a certified de-identification tool; test it against your own data before relying on it. Every fact pattern and name in this article is invented. No client information appears in this article.

words
3,560
sections
18
code_blocks
1
sources
5
distinctive_terms
scrubbed · register · local · token · identification
Pass it onLinkedInX

Get new articles as they land

One email when something new is published here. No course, no upsell — the Practice Lab stays free either way.

Used only to send Practice Lab posts. Unsubscribe from any email. Subscribing does not create an attorney–client relationship.

The only thing we ask

If something here saves you time, spend some of it on people who could not otherwise afford you.

Everything in the Practice Lab is free. No signup, no subscription, no donations — just take a case you would otherwise have to turn down on economics. More from the Practice Lab →

Keep Reading

42% vocabulary overlap

Anonymization Is Not in the Rule. The Test Is.

No Minnesota rule says you have to anonymize client data before it goes into a model. The word isn't even in Rule 1.6. What Comment [4] gives you is the test a scrubbed prompt has to pass — no reasonable likelihood anyone can figure out the client or the situation involved — and the half lawyers miss is the situation, not the name. Where Minnesota is looser than the ABA, and where nothing takes the place of the client's signature.

Essay · 18 min read

20% vocabulary overlap

Running Models on Your Own Hardware: When It's Worth It

The strongest confidentiality posture is client data that never leaves the building. Here is what local models actually cost, what they are genuinely good at, where they fall down, and the hybrid architecture that is usually the right answer.

Tool · 13 min read

15% vocabulary overlap

What You Owe the Client You Automated

Do you have to tell a client you used AI on their matter? What happens to the fee when a two-hour task takes twenty minutes? The disclosure question really is unsettled. The fee question isn't, and it cuts against the lawyer.

Essay · 12 min read

← All Practice Lab articles