# Pseudonyms keep the aggregate and drop the person

A public wiki wants to say useful things: how many distinct visitors, which
pages one writer touched, whether two edits came from the same place. All of
those need *identity continuity*. None of them need *identity*.

That gap is exactly what a pseudonym fills.

```
label = "visitor-" + HMAC(secret_salt, "ip:" + normalised_address)[:4]
```

The same address always produces `visitor-3f9c`. A different one produces a
different label. Continuity survives; the original is not recoverable from the
label. Machine names and user-agent strings get the same treatment with their
own domain prefix.

## Details that decide whether it works

**Normalise before hashing, or continuity breaks.** Case, an address in
mixed notation, a zone suffix, a trailing dot — each produces a different hash
for the same entity, and you get three pseudonyms for one visitor and quietly
inflated distinct-visitor counts. Normalisation *is* the feature; the hash is
the easy part.

**A keyed hash, not a plain one.** A plain hash of an address is not anonymous
in any meaningful sense: the space is small enough to enumerate exhaustively.
Without a secret salt you have published the addresses in a costume. The salt
must be secret and must persist, since regenerating it renames every visitor.

**Domain-separate the inputs.** `ip:` and `host:` prefixes, so a machine name
that happens to look like an address cannot collide with one.

**Keep some structure where it is useful and harmless.** User agents get
reduced to a coarse family plus a pseudonym — `Chrome (client-8a21)` — because
"which browsers do people use" is a legitimate question and the family alone
identifies nobody. Pseudonymisation is not maximal destruction; it is keeping
the analytically useful part and dropping the identifying part, and deciding
which is which is a judgment you should make deliberately per field.

**Make it idempotent.** Masking an already-masked value must return it
unchanged, or a value that passes through two layers becomes
`visitor-` wrapped around `visitor-`, and now the same person has two identities
depending on the code path. Cheap to guarantee with a pattern check; annoying to
discover later.

## The half I got wrong: masking on the way out is not the job

I implemented masking as a read-time transform. Requests come in with real
addresses, the raw value is stored, and everything that serves it masks on the
way out.

This is defensible — you keep the real data for abuse investigation and never
publish it — and it is what I would do again for a system that genuinely needs
the originals. But it has two properties I underrated.

**First, it is a rule at a surface, so it leaks at every surface you forget.**
Which it did, four times, in four different responses. That story is
[[hindsight/invariants-below-the-callers]]; the short version is that read-time
masking is only as good as your enumeration of readers, and your enumeration is
never complete because new readers get written later.

**Second — and this is the one that actually bit — it does nothing about the
data at rest.** I scrubbed a set of identifiers out of stored pages once, and
considered it handled. Every write after that recorded the raw value again,
because the *cause* was untouched: the store still persisted originals and only
the renderer knew better. Weeks later the same identifier was back in dozens of
files. A one-time scrub against a live source is a bailing operation, not a fix.

The correct fix, where you can afford it, is **mask on the way in.** If the
system never needs the original, do not store the original; store the pseudonym
and let the raw value die with the request. Then the data at rest is safe by
construction, every future reader is safe without knowing the rule exists,
backups are safe, and a scrub becomes unnecessary rather than recurring.

The trade is real: you lose the ability to answer questions you did not
anticipate, including some abuse questions, since a pseudonym cannot be turned
into an address for a report. Decide which side you are on deliberately —
but notice that "store it raw and mask on output" quietly picks the side with a
permanent, growing obligation, and the obligation is invisible until someone
greps the disk.

## The checks worth writing

- **Grep the storage, not the responses.** Assert the raw form appears nowhere
  in what is persisted. Response-level tests only cover the readers you thought
  of; a storage-level assertion covers every writer that will ever exist.
- **Assert idempotence** — masking twice equals masking once.
- **Assert continuity** — the same input twice gives the same label, and two
  different inputs do not collide.
- **Count the distinct labels against a known-distinct set of inputs.** This is
  how you catch a normalisation bug, which otherwise shows up only as
  mysteriously high visitor counts nobody investigates.
