Surrogates¶
The invariant¶
A surrogate must be indistinguishable in nature from what it replaces, and must never designate a real-world entity.
Everything on this page follows from those two clauses. They were written after
three defects that looked unrelated turned out to be the same one: a /24 that
came out as a word, a documentation range that kept its "private" flag when it
stands for a routable address, and an IPv6 generator that returned a local
address whatever the input.
The test that pins it replays all three; removing any fix makes it fail.
What is preserved, on purpose¶
Five attributes survive substitution, because without them the text stops being readable as infrastructure — and, for the last one, as a chronology:
| Preserved | So that the model can still tell |
|---|---|
| environment | production from staging |
/24 co-membership |
two machines on the same subnet |
| human vs service account | a person from a robot |
| internal vs external | your network from the internet |
| the interval between two dates | how long an incident actually lasted |
A sixth is available and off by default: dates=cote_du_present keeps a
past date past and a future date future. It is off because it preserves one
more thing, and every preserved attribute is something a re-identification can
join on. See Policy and the two prices in
Known limits.
And a surrogate must not INVENT an attribute
The five above are accepted because they are true. A fabricated one is worse than a leak: it reads exactly like a fact of the document, with nothing to tell the operator otherwise. Measured, and fixed: two colleagues with no connection were being given the same surname in 15.5 % of four-person documents, because the family name was drawn from forty-eight words while the first name was composed. Composing the family name instead took it to 0.8 %.
These are accepted leaks, listed in the re-identification analysis. They are the price of a usable agent, and they are stated rather than discovered.
Real examples¶
Produced by the engine, from synthetic inputs:
| Type | Real | Surrogate |
|---|---|---|
HOSTNAME |
db-master.acme.internal |
quarry-inlet.southridge-margie.internal |
HOSTNAME |
srv-billing-prod-01.acme.internal |
meadow-willow-01-prod.southridge-margie.internal |
IP_ADDRESS |
10.1.2.0/24 |
172.18.14.0/24 |
IP_ADDRESS |
192.168.4.17 |
172.20.120.201 |
IP_ADDRESS |
51.75.28.4 |
198.19.145.191 |
EMAIL_ADDRESS |
alice.dupont@acme.corp |
york-corey.vale@olympic-litware.corp |
URL |
https://github.com/acme-corp/billing-api |
https://github.com/blue-contoso/beacon-quarry-api |
FILE_PATH |
/home/alice/acme-nda/notes.md |
/home/vale-quarry/moss-digest/notes.md |
PHONE_NUMBER |
+33 6 12 34 56 78 |
+33 6 39 98 66 50 |
IBAN_CODE |
FR76 3000 6000 0112 3456 7890 189 |
FR59 0000 0309 1434 4904 2525 063 |
HASH |
sha256:9f2b1c4e… |
sha256:ec7a28e3… |
PERSON |
Thibault Escourrou |
Gray Hayden-Emery |
DATE |
12 mars 2019 |
20 décembre 2019 |
DATE |
2026-02-03T14:32:00Z |
2026-11-13T14:32:00Z |
DATE |
August 2026 |
May 2027 |
DATE |
Q3 2024 |
Q2 2025 |
ADDRESS |
14 rue des Grands-Augustins, 75006 Paris |
66 rue des Gray, 31424 Sauveterre-en-Brie |
Read them closely:
- both internal hosts landed in the same fictional zone, because they shared a real one;
- the private address stayed private; the public one became
198.19.x— RFC 2544, reserved for benchmarking, so it can never be someone's machine; - the URL kept its shape: scheme, path depth, query parameter name. Only what identifies moved;
github.comsurvived while the org and repository did not — the model still needs to know it is talking about GitHub;- the date moved by a constant, so
14:32:00is untouched and two dates stay exactly as far apart as they were — an incident still reads as a sequence; - a partial date stays partial and shifts at its own granularity: a
month-year moves by whole months, a quarter by quarters. The missing field is
never invented —
August 2026does not become a full date; - the file keeps its name shape and its extension (
notes.md), so the model can still tell a config from a log; the stem is what identifies, and the stem is what moved; - the address kept
rueanddes, which identify nobody. Its number is at least one, its postcode is five digits in a department that exists, and what follows the postcode is a city — drawn independently of the code, so the pair cannot be anyone's; - the IBAN is checksum-valid (ISO 13616 mod 97) with the bank identifier neutralised, so a validator accepts it and it names no bank;
- the phone number sits in the range regulators reserve for fiction (
06 39 98for ARCEP,555-01xxfor the NANP,07700 900xxxfor Ofcom).
Where fictional values come from¶
| Kind | Space drawn from | Why |
|---|---|---|
| private IPv4 | RFC 1918 | private stays private |
| public IPv4 | RFC 2544 198.18.0.0/15, class E |
reserved: provably nobody's |
| IPv6 | RFC 3849 2001:db8::/32, fc00::/7 |
documentation and unique-local |
| phone | regulator fiction ranges | never rings |
| IBAN / card | mod-97 and Luhn valid, issuer neutralised | passes validation, names no institution |
| hostnames | composed fictional words | plausible, and no dictionary word from the input |
Determinism and injectivity¶
The same real value yields the same surrogate for the whole scope — by default the project. Change the scope and you change the mapping; that is the knob for sharing a vault between sessions or isolating a tenant.
Two real values never share a surrogate (D6), guaranteed by uniqueness in the store rather than by hoping the generator does not collide.
A documented residual
For surrogates that collide on the draw, allocation depends on insertion order — about 4 % of cases, down from 40 % once two words are composed on the first attempt. It has no practical effect (a project has one vault, created once); it matters if you ever rebuild a vault and expect the same output. A test bounds it.
What never gets a surrogate¶
Secrets. A token, a password, a private key: replaced by a reference, and never restored (D4). Restoring one would hand a live credential back to a command the model just wrote.
Values the allowlist declares public, and only those — see Detection and lists.