The collector clicked a name. The outbound-call system patched the collector through to the customer. Nobody dialled anything. The number went from the store to the telephony provider, and the call connected.
The screen showed the number anyway. All of it. The address too. The collectors turned over, and the tool stayed the same. Whoever sat in the chair that month read what the last one read, because the screen was built before anyone asked what a collector does with a phone number.
This was after the migration. The PII had moved out of the application tables, the audit had accepted where things stood and where they were headed, and detokenization was wired into the read path so that nothing upstream had to change. I wrote about that project as primary PII: where the data lives, and whether you need it at all. That post stopped where the bytes did. The readers had not moved.
What each reader does with it
The useful question for each caller is what it does with the plaintext. The answers come in a short list.
It displays it. The document review queue is a real read. The collector’s screen shows more than the call needs.
It compares it. A support call checks the date of birth the caller gives against the one on file. The workflow needs a yes or a no. It was getting the value.
It joins on it. Fraud checks look for the same number across applications. That needs equality, not the number.
It passes it through. The dialer, the payment rail, the messaging provider. The value goes in one side and out the other, and no human needs to see it on the way.
It logs it, because request logging sits after detokenization and keeps the body the way the application saw it. Or it does nothing with it: a batch job detokenizes a whole table and reads one column.
Analytics was the easy case. It was already masked and it never complained. Masking by role, offered last time as the cheapest way to shrink the secondary surface, is the main tool on the read side. Most readers on the list want less than they get, and the store is the cheapest place to give them less.
Ready, and not needed
Onboarding wanted to know whether a phone number was already registered. That is the uniqueness example from last time, and with the plaintext in the vault, every check is a hop. Engineering built a proof of concept for the shortcut that post describes: “a deterministic hash kept alongside the token.”
That line is missing a word: keyed. Two paragraphs later, the same post says a leaked token “is useless on its own.” For the token, that holds. For the row it sits in, it does not.
An unkeyed hash of a phone number is the number with a delay. The space of valid numbers is small enough to hash every one of them, so anyone holding the table holds the numbers. The post makes the same omission about email: hashing it at ingest and dropping the original, it says, is less risk than encrypting it. Unkeyed, that hash is a lookup against any list of addresses.
Without the key, a keyed hash cannot be enumerated. It still answers equality for anyone holding the table: who is already a customer, which numbers repeat across applications. That is what onboarding and fraud want. It is also what every other reader of the table gets.
To let only the answer out, the match has to run inside the boundary: send the number in, get a yes or no back, and answer only callers allowed to ask. A yes or no that anyone can ask without limit is the same enumeration, one number at a time. That is also the hop the hash was built to avoid.
Onboarding never used it. The team reworked the flow so the check was not needed, and the proof of concept sat ready and never shipped. The cheapest equality to protect was the one nobody had to compute.
One reader
The readers were there before the vault. The collector’s screen read the full number when it was plaintext in a column, and it read the full number after. The migration did not create a single new reader. It carried every one across, unexamined, and that was the point of it.
The first post praised exactly this: “Application code stays mostly as it was.” It did. The detokenization sat in the model layer, and the callers kept calling. A read made in the model layer arrives under the application’s own credential. To the vault there was one reader, and it read everything.
The vault could count reads. It could not say whose they were. The collector’s screen, the support lookup and the nightly job all arrived as the same caller. The abstraction that made the change invisible to callers also made the callers invisible, even to the system that logged every read.
A redesign went on the table: named reads, so each workflow would arrive as itself, and a screen that showed what the call needed while the call carried the number.
Flagged, then cleaned
We did not delete in the live flow. A record to be removed got a flag, and every workflow we could name was taught to skip flagged records. An offline job cleaned up flagged data later, on its own schedule.
A flag is a read rule. It holds for every reader that checks it, and only those. The cleanup holds for every store on its list, and only those.
The copies that left before the flag are on nobody’s list. The file sent to an outside collection agency. The log line that kept a detokenized body. Any training set built from the tables. Any model trained on that set, which has no record to flag and no row to clean.
The “where does PII live” diagram drew the support tool as a copy. It never asked what the tool did with the number.
