Research LabAlpha

Open Pseudonym

Task-aware pseudonymisation for LLM traffic. Detected entities become consistent, realistic stand-ins; secrets become handles bound to the host they came from; every request can carry an Ed25519-signed, hash-chained egress receipt.

v0.1.1 Nov 6, 2026 Python and TypeScript Apache-2.0 code, CC BY 4.0 spec

  • 19data types
  • 10actions
  • 11starter profiles
  • 88 / 88shared vectors, Python and TypeScript
  • ~1 msto mask an item (offline bench)

The masking pipeline.

Masking runs on your side before any byte leaves; restoring runs on your side after the answer arrives. The vault that links the two stays with you.

To the model

  1. 1Inputtext, chat messages, tool resultsJSON is masked string by string
  2. 2Detectpatterns.json, known values, MLone regex subset for Python and JS
  3. 3Mergepriority, length, start, typeoverlaps resolved once
  4. 4Policyprofile: an action per typetighten() only ever stricter
  5. 5Stand-insHMAC-SHA256 DRBG, seeded.example domains, 555-01xx, test cards
  6. 6Egress checkexact bytes, then detect againa leak stops the request
  7. 7ReceiptJWS EdDSA over JCSprev = digest of the last one
VaultReal values, stand-ins, counters. AES-256-GCM at rest, 12-byte nonce, additional data opn-vault-v1. The key never sits next to the file.

Back to you

  1. r1Modelsees stand-ins onlynotes explain each handle
  2. r2Restoreexact, handles, digits, datesstreaming, window L of 40 or more
  3. r3Resolvehandles to real valuesbound site or tool, uses, ttl, ask
  4. r4Youreal values backfilled at the last moment
The Open Pseudonym pipeline. To the model: input, detect, merge, policy, stand-ins, egress check, receipt. Back to you: model, restore, resolve handles, you. The vault holds the mapping, encrypted with AES-256-GCM.

A real run, five profiles.

One message, five starter profiles. Everything below is the SDK's own output, not a mock-up. Pick a profile.

The message

From: Dana Reyes <dana.reyes@northwind.example>
Hi Sam, can we move Thursday's call to March 12, 2026? Call me on (617) 555-0142.
The deposit is $1,250.00 on card 4242 4242 4242 4242.
Your code is 482913.

What the model sees under draft_reply

From: Rowan Sato <rowan.sato@seaborne.example>
Hi Hayden, can we move Thursday's call to November 20, 2025? Call me on (206) 555-0193.
The deposit is $1,250.00 on card {{CARD_1}}.
Your code is {{OTP_1}}.
TypeActionReal valueStand-in
PERSONfakeDana ReyesRowan Sato
EMAILfakedana.reyes@northwind.examplerowan.sato@seaborne.example
PERSONfakeSamHayden
DATEshiftMarch 12, 2026November 20, 2025
PHONEfake(617) 555-0142(206) 555-0193
CARDhandle4242 4242 4242 4242{{CARD_1}}
OTPhandle482913{{OTP_1}}

Notes appended for the model

  • {{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.
  • {{OTP_1}} is a 6-digit one-time code from northwind.example, valid until 00:10 UTC. Write {{OTP_1}} exactly where the value is needed; the real value is filled in outside the model.

What the model sees under schedule

From: Rowan Sato <rowan.sato@seaborne.example>
Hi Hayden, can we move Thursday's call to March 12, 2026? Call me on (206) 555-0193.
The deposit is $1,250.00 on card {{CARD_1}}.
Your code is {{OTP_1}}.
TypeActionReal valueStand-in
PERSONfakeDana ReyesRowan Sato
EMAILfakedana.reyes@northwind.examplerowan.sato@seaborne.example
PERSONfakeSamHayden
PHONEfake(617) 555-0142(206) 555-0193
CARDhandle4242 4242 4242 4242{{CARD_1}}
OTPhandle482913{{OTP_1}}

Notes appended for the model

  • {{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.
  • {{OTP_1}} is a 6-digit one-time code from northwind.example, valid until 00:10 UTC. Write {{OTP_1}} exactly where the value is needed; the real value is filled in outside the model.

What the model sees under web_research

From: Rowan Sato <[EMAIL removed]>
Hi Hayden, can we move Thursday's call to March 12, 2026? Call me on [PHONE removed].
The deposit is $1,250.00 on card [CARD removed].
Your code is [OTP removed].
TypeActionReal valueStand-in
PERSONfakeDana ReyesRowan Sato
EMAILdropdana.reyes@northwind.example[EMAIL removed]
PERSONfakeSamHayden
PHONEdrop(617) 555-0142[PHONE removed]
CARDdrop4242 4242 4242 4242[CARD removed]
OTPdrop482913[OTP removed]

Notes appended for the model

  • No handles under this profile: nothing for the model to be told.

What the model sees under money_math

From: Rowan Sato <rowan.sato@seaborne.example>
Hi Hayden, can we move Thursday's call to November 20, 2025? Call me on (206) 555-0193.
The deposit is $1,250.00 on card {{CARD_1}}.
Your code is [OTP removed].
TypeActionReal valueStand-in
PERSONfakeDana ReyesRowan Sato
EMAILfakedana.reyes@northwind.examplerowan.sato@seaborne.example
PERSONfakeSamHayden
DATEshiftMarch 12, 2026November 20, 2025
PHONEfake(617) 555-0142(206) 555-0193
CARDhandle4242 4242 4242 4242{{CARD_1}}
OTPdrop482913[OTP removed]

Notes appended for the model

  • {{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.

What the model sees under strict

From: Rowan Sato <rowan.sato@seaborne.example>
Hi Hayden, can we move Thursday's call to November 20, 2025? Call me on (206) 555-0193.
The deposit is $1,250.00 on card {{CARD_1}}.
Your code is {{OTP_1}}.
TypeActionReal valueStand-in
PERSONfakeDana ReyesRowan Sato
EMAILfakedana.reyes@northwind.examplerowan.sato@seaborne.example
PERSONfakeSamHayden
DATEshiftMarch 12, 2026November 20, 2025
PHONEfake(617) 555-0142(206) 555-0193
CARDhandle4242 4242 4242 4242{{CARD_1}}
OTPhandle482913{{OTP_1}}

Notes appended for the model

  • {{CARD_1}} is a Visa card ending 4242. Write {{CARD_1}} exactly where the value is needed; the real value is filled in outside the model.
  • {{OTP_1}} is a 6-digit one-time code from northwind.example, valid until 00:10 UTC. Write {{OTP_1}} exactly where the value is needed; the real value is filled in outside the model.

Back on your side

The model answeredSure Hayden, November 20, 2025 works. I'll call at 10.
You readSure Sam, March 12, 2026 works. I'll call at 10.

Real output: open-pseudonym 0.1.1 (Python), vault seed sha256('atlasiw.com research lab example'), clock 1793836800 (2026-11-04 00:00 UTC), source no-reply@northwind.example.

Policy parameters.

Actions and their strength

A policy maps each data type to one action. tighten(a, b) keeps, per type, the stronger rule, so code can only make a policy stricter; nothing takes a policy from model output.

ActionStrengthRestorableStand-in
keep 0nothing replacedthe real value
shift 1yesthe date moved by the session offset (dates only)
generalize 1noa coarser description (“someone”, “March 2026”)
fake 2yesa realistic value of the same type
tag 2yes[TYPE_n]
token 2yestype_ plus 8 hex digits of a keyed hash
fpe 2yesFF1 ciphertext of the digits (digit types only)
handle 3only by resolve{{TYPE_n}}
drop 4no[TYPE removed]
refuse 5nothe whole request is refused

Data types

Compared by a normalised key per type (digits only, lower case, the calendar day). Secret types, marked, become handles in every starter profile.

  • PERSON
  • ORG
  • LOCATION
  • ADDRESS
  • EMAIL
  • PHONE
  • URL
  • IP_ADDRESS
  • DATE
  • MONEY
  • CARD
  • IBAN
  • ROUTING
  • BANK_ACCOUNT
  • SSN
  • API_KEY
  • PASSWORD
  • OTP
  • ID

Handle bindings

Options on a handle rule: uses, ttl_s, ask, tools, sites (*.example.com allows subdomains; a public suffix is an error). A handle with neither a site nor a tool is never filled. resolve(args, sink) refuses with:

  • unknown handle
  • not bound to a site or tool
  • expired
  • already used
  • not allowed for this tool
  • bound to a different site
  • needs the person's approval
  • not approved

Deterministic stand-ins

Every choice comes from the vault seed (at least 16 bytes), so the Python and TypeScript SDKs produce the same stand-ins for the same seed and clock. Dates shift by whole weeks so weekdays stay true:

block(label) = HMAC-SHA256(seed, utf8(label) || 0x00 || uint64_be(counter[label]));  counter[label] += 1
below(label, n): repeat { x = uint64_be(first 8 bytes of block(label)) } until x < floor(2^64 / n) * n; return x mod n
derive(purpose) = HMAC-SHA256(seed, "opn-derive" || 0x00 || utf8(purpose))

w = below("date.offset", 52) - 26; if w >= 0 then w += 1; offset = 7 * w days

Proof of what left.

Before a request goes, its exact bytes are checked for every real value the vault knows, then the detectors run again over every key and string. With receipts on, each request that goes gets a signed, chained receipt.

Receipt: protected header and payload (real, decoded)
{
  "alg": "EdDSA",
  "kid": "sha-256:zskEnjox_Y8weyrGjrEXLeYEdh3ecIohpYhPysG3wCU",
  "typ": "opn-egress+jws"
}

{
  "check": {
    "found": 0,
    "known_values": 7
  },
  "dest": "api.example.com",
  "detectors": [
    "rules@1",
    "known@1"
  ],
  "iat": 1793836800,
  "opn": "0.1",
  "payload": "sha-256:vRvxgmEcY-dwgWeqWbMryYhYerCYO6eRBlwP81unDPs",
  "policy": "sha-256:3pyGzM1KRJVM1phUKj79n8eCEabjJFBVFiQs8TOba_8",
  "prev": null,
  "seq": 1,
  "session": "example",
  "stand_ins": {
    "CARD": 1,
    "DATE": 1,
    "EMAIL": 1,
    "OTP": 1,
    "PERSON": 2,
    "PHONE": 1
  },
  "type": "egress"
}

Fields (spec 11.2)

kid
sha-256 thumbprint of the Ed25519 public key (RFC 7638)
payload
digest of the exact bytes sent
policy
digest of the canonical policy document
prev
digest of the previous receipt's token; null for the first
seq
counts 1, 2, 3 in one log; a gap fails verification
stand_ins
distinct vault entries whose stand-ins appear in the payload
check
known_values: non-keep vault entries at the time; found must be 0 for a request that was sent
Checking a receipts log (README)
opn verify ~/.local/share/open-pseudonym/receipts/<session> --vault my.vault
# receipts: 14
# payloads re-checked against the vault: 14, known values found: 0
# every receipt is signed, chained and matches its payload

What this proves: no value the vault knows, and nothing the detectors find in the outgoing bytes, was in any request with a valid receipt. It cannot prove that a value no detector recognises did not leave, and a log cut short at the end shows only against a count or last receipt you kept (--expect-count, --expect-last).

Offline benchmark.

Offline benchmark, 120 generated items and 14 hand-writtenResult
Cards, IBANs, SSNs, emails, phones, dates, amounts, addresses, codes100% found on the generated set
Names, rules only100% on the generated set, 86% on the hand-written set
Names the app already knows, in any casecaught again everywhere, including “dana reyes”
Planted values that reached the model (strict profile)0 of 570 generated, 1 of 25 hand-written
Same, with the app's contact list0 of 570 and 0 of 25
Restore gives back exactly the original134 of 134
Streaming restore equals whole restore134 of 134
Speedabout 1 ms to mask and 0.2 ms to restore an item

The generated set uses templates the name rules were tuned on, so its 100% is optimistic; the hand-written set is the honest one, and it is small. Run it yourself: python bench/run.py offline (no model, costs nothing).

Install Open Pseudonym.

  • Python
    pip install open-pseudonym
  • JavaScript / TypeScript
    npm install open-pseudonym

Use it in your code

Mask before the model, restore after.

Python
from open_pseudonym import Session, Source

s = Session("draft_reply")
masked = s.mask(email_text, Source(sender="no-reply@acme.com"))   # the source binds handles to acme.com
reply = call_your_model(masked.text, masked.notes)                 # notes explain any {{handles}}
print(s.restore(reply))                                            # real values back for the person
TypeScript
import { Session, Source, Sink } from "open-pseudonym";

const session = new Session("draft_reply");

// What the model may see. The source binds any secret to the site it came from.
const masked = session.mask(emailText, new Source({ sender: "no-reply@acme.com" }));
const answer = await callYourModel(masked.text, masked.notes); // notes explain any {{handles}}

// What the person reads.
console.log(session.restore(answer));

Or change one URL

The local proxy speaks the OpenAI Chat Completions, OpenAI Responses and Anthropic Messages APIs, streamed or not, and passes your API key through untouched. It has no login: one person, one machine, 127.0.0.1.

Shell
opn keygen
opn proxy --profile draft_reply --log-dir ~/.local/share/open-pseudonym/receipts
# then use base_url http://127.0.0.1:8787/v1 (OpenAI style) or http://127.0.0.1:8787 (Anthropic style)

Agents and MCP

Put any MCP server behind the gateway with no code changes: tool results are masked on the way to the model, tool arguments restored on the way to the tool.

Shell
opn mcp-gateway --profile browse_and_fill --source-site example.com \
  --tool-site submit_code=https://login.example.com -- npx some-mcp-server

The specification.

Open Pseudonym v0.1, CC BY 4.0. Pseudonymisation in the GDPR Article 4(5) sense, not anonymisation: what reaches the model is still personal data in law.

  1. 1What this is, and what it is not (opens in a new tab)
  2. 3Data types (opens in a new tab)
  3. 4Detection (opens in a new tab)
  4. 5Policies (opens in a new tab)
  5. 6Masking (opens in a new tab)
  6. 7Making stand-ins (opens in a new tab)
  7. 8The vault (opens in a new tab)
  8. 9Restoring (opens in a new tab)
  9. 10Secret handles (opens in a new tab)
  10. 11Egress check and receipts (opens in a new tab)
  11. 12Bindings (opens in a new tab)
  12. 15Conformance (opens in a new tab)

Known limits (alpha 0.1)

  • Names are found by cues (greetings, titles, headers, role words) or because your app lists them; a name with no cue in free text can be missed.
  • Non-Latin scripts and lookalike letters are not handled by the rules.
  • Binary files (images, PDFs, audio) are passed through untouched; text sent as base64 is decoded, masked and checked.
  • The proxy checks the body and the query string, not request headers.
  • Ordinary personal data is restored into any tool call; handles are the part that resists prompt injection.
  • Model-based quality results are not published yet.

Changelog.

Every release, newest first.

VersionDateWhat changed
0.1.1Nov 6, 2026First release through npm trusted publishing. Install badges and package links in the README. No code changes.
0.1.0Nov 6, 2026First release: the specification (v0.1), the Python SDK (core, local proxy, MCP middleware and gateway, Playwright handle filler, ML detector adapters, the opn command), the TypeScript SDK (core and Vercel AI SDK middleware), shared test vectors, JSON schemas and an offline benchmark. Hardened after two security reviews.

Built while making Atlas.

Atlas is the AI workspace these tools come from. Free during the private beta.