nyms 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. nyms-0.0.2/.gitattributes +1 -0
  2. nyms-0.0.2/.gitignore +119 -0
  3. nyms-0.0.2/LICENSE +21 -0
  4. nyms-0.0.2/PKG-INFO +116 -0
  5. nyms-0.0.2/README.md +90 -0
  6. nyms-0.0.2/docs/adr/0001-pseudonymisation-not-anonymisation.md +35 -0
  7. nyms-0.0.2/docs/adr/0002-sensitivity-policy-and-entity-taxonomy.md +25 -0
  8. nyms-0.0.2/docs/adr/0003-detector-strategies.md +27 -0
  9. nyms-0.0.2/docs/adr/0004-surrogate-generation.md +28 -0
  10. nyms-0.0.2/docs/adr/0005-mapping-store-and-key-management.md +27 -0
  11. nyms-0.0.2/docs/research/deidentification-synthesis.md +60 -0
  12. nyms-0.0.2/docs/research/detection-libraries-and-methods.md +111 -0
  13. nyms-0.0.2/docs/research/pipeline-architecture-and-agent-practice.md +104 -0
  14. nyms-0.0.2/docs/research/prompts/_common-rules.md +9 -0
  15. nyms-0.0.2/docs/research/prompts/detection-libraries-and-methods.md +5 -0
  16. nyms-0.0.2/docs/research/prompts/pipeline-architecture-and-agent-practice.md +5 -0
  17. nyms-0.0.2/docs/research/prompts/surrogates-crypto-and-keys.md +5 -0
  18. nyms-0.0.2/docs/research/prompts/terminology-and-regulation.md +5 -0
  19. nyms-0.0.2/docs/research/surrogates-crypto-and-keys.md +101 -0
  20. nyms-0.0.2/docs/research/terminology-and-regulation.md +140 -0
  21. nyms-0.0.2/nyms/__init__.py +62 -0
  22. nyms-0.0.2/nyms/__main__.py +26 -0
  23. nyms-0.0.2/nyms/core.py +358 -0
  24. nyms-0.0.2/nyms/data/__init__.py +1 -0
  25. nyms-0.0.2/nyms/data/names/__init__.py +1 -0
  26. nyms-0.0.2/nyms/data/names/first_names_da.txt +1 -0
  27. nyms-0.0.2/nyms/data/names/first_names_en.txt +1 -0
  28. nyms-0.0.2/nyms/data/skills/nyms/SKILL.md +36 -0
  29. nyms-0.0.2/nyms/data/wordlists/NOTICE.md +3 -0
  30. nyms-0.0.2/nyms/data/wordlists/__init__.py +1 -0
  31. nyms-0.0.2/nyms/data/wordlists/eff_short_wordlist_2_0.txt +1296 -0
  32. nyms-0.0.2/nyms/detect.py +692 -0
  33. nyms-0.0.2/nyms/keys.py +153 -0
  34. nyms-0.0.2/nyms/mapping.py +376 -0
  35. nyms-0.0.2/nyms/policy.py +140 -0
  36. nyms-0.0.2/nyms/spans.py +101 -0
  37. nyms-0.0.2/nyms/surrogates.py +272 -0
  38. nyms-0.0.2/nyms/taxonomy.py +120 -0
  39. nyms-0.0.2/nyms/tools.py +187 -0
  40. nyms-0.0.2/pyproject.toml +183 -0
  41. nyms-0.0.2/skills/nyms-dev-deidentification/SKILL.md +45 -0
  42. nyms-0.0.2/skills/nyms-dev-deidentification/references/vocabulary.md +22 -0
  43. nyms-0.0.2/tests/fixtures/da_lejesag.md +15 -0
  44. nyms-0.0.2/tests/fixtures/en_letter.md +15 -0
  45. nyms-0.0.2/tests/fixtures/expected.json +24 -0
  46. nyms-0.0.2/tests/fixtures/secrets.txt +3 -0
  47. nyms-0.0.2/tests/test_nyms.py +355 -0
@@ -0,0 +1 @@
1
+ *.ipynb linguist-documentation
nyms-0.0.2/.gitignore ADDED
@@ -0,0 +1,119 @@
1
+ .claude/handoffs/
2
+ .claude/scratch/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+
10
+ .DS_Store
11
+ # C extensions
12
+ *.so
13
+
14
+ # TLS certificates
15
+ ## Ignore all PEM files anywhere
16
+ *.pem
17
+ ## Also ignore any certs directory
18
+ certs/
19
+
20
+ # Distribution / packaging
21
+ .Python
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ downloads/
26
+ eggs/
27
+ .eggs/
28
+ lib/
29
+ lib64/
30
+ parts/
31
+ sdist/
32
+ var/
33
+ wheels/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+ MANIFEST
38
+ _build
39
+
40
+ # PyInstaller
41
+ # Usually these files are written by a python script from a template
42
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
43
+ *.manifest
44
+ *.spec
45
+
46
+ # Installer logs
47
+ pip-log.txt
48
+ pip-delete-this-directory.txt
49
+
50
+ # Unit test / coverage reports
51
+ htmlcov/
52
+ .tox/
53
+ .coverage
54
+ .coverage.*
55
+ .cache
56
+ nosetests.xml
57
+ coverage.xml
58
+ *.cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+
81
+ # PyBuilder
82
+ target/
83
+
84
+ # Jupyter Notebook
85
+ .ipynb_checkpoints
86
+
87
+ # pyenv
88
+ .python-version
89
+
90
+ # celery beat schedule file
91
+ celerybeat-schedule
92
+
93
+ # SageMath parsed files
94
+ *.sage.py
95
+
96
+ # Environments
97
+ .env
98
+ .venv
99
+ env/
100
+ venv/
101
+ ENV/
102
+ env.bak/
103
+ venv.bak/
104
+
105
+ # Spyder project settings
106
+ .spyderproject
107
+ .spyproject
108
+
109
+ # Rope project settings
110
+ .ropeproject
111
+
112
+ # mkdocs documentation
113
+ /site
114
+
115
+ # mypy
116
+ .mypy_cache/
117
+
118
+ # PyCharm
119
+ .idea
nyms-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nyms-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: nyms
3
+ Version: 0.0.2
4
+ Summary: Reversible pseudonymisation of text: deterministic detection of personal and sensitive identifiers, keyed surrogate generation (readable pseudonyms included), and re-identification through a mapping store.
5
+ Project-URL: Homepage, https://github.com/thorwhalen/nyms
6
+ Project-URL: Repository, https://github.com/thorwhalen/nyms
7
+ Project-URL: Documentation, https://thorwhalen.github.io/nyms
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: anonymization,cpr,danish,de-identification,gdpr,pii,privacy,pseudonymisation,redaction
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: cw>=0.1
14
+ Requires-Dist: dol>=0.3
15
+ Provides-Extra: crypto
16
+ Requires-Dist: cryptography>=42; extra == 'crypto'
17
+ Provides-Extra: dev
18
+ Requires-Dist: cryptography>=42; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
20
+ Requires-Dist: pytest>=7.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
22
+ Provides-Extra: docs
23
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
24
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # nyms
28
+
29
+ Reversible pseudonymisation of text before it reaches an AI. A deterministic (non-AI) pass finds personal and sensitive identifiers in Danish and English text, replaces them with surrogates, remembers the mapping per project so the same person is `[PERSON_1]` in every document, and puts the originals back into whatever the AI answers.
30
+
31
+ ```bash
32
+ pip install nyms # add "nyms[crypto]" for an encrypted mapping store
33
+ ```
34
+
35
+ ```python
36
+ from nyms import Pseudonymiser
37
+
38
+ p = Pseudonymiser("case-2026", names=["Rasmus Østergaard Jensen"])
39
+ r = p.pseudonymise("Rasmus Jensen (CPR 010190-1234) skylder 12.500 kr. Rasmus Jensens advokat ringede.")
40
+ r.text
41
+ # '[PERSON_1] (CPR [CPR_1]) skylder [AMOUNT_1]. [PERSON_1]s advokat ringede.'
42
+ p.reidentify("Svar: [PERSON_1] skal betale [AMOUNT_1].").text
43
+ # 'Svar: Rasmus Jensen skal betale 12.500 kr.'
44
+ p.check("Rasmus Jensen bor her") # what would leak if this were sent
45
+ # ['Rasmus Jensen']
46
+ ```
47
+
48
+ The same four verbs from the shell:
49
+
50
+ ```bash
51
+ nyms pseudonymise --path letter.md --project case-2026 --names "Rasmus Østergaard Jensen" --out letter.pseud.md
52
+ nyms reidentify --path answer.md --project case-2026 --out answer.clear.md
53
+ nyms check --path draft.md --project case-2026 # {"ok": false, "leaks": [...]} if an original is in it
54
+ nyms detect "Rasmus Jensen, tlf. 12 34 56 78" # every span and the policy's decision; nothing stored
55
+ nyms readable 010190-1234 --project case-2026 # a keyed hash as two readable words
56
+ ```
57
+
58
+ Content is positional text or `--path` (a file, or `-` for stdin), never guessed from one another.
59
+
60
+ ```bash
61
+ ```
62
+
63
+ `r.report` says what was replaced (by type and by detector), what was detected and kept (dates, postcodes and URLs, under the default policy) and which detectors ran, and ends with the caveat that this is pseudonymisation, not anonymisation.
64
+
65
+ ## What it finds
66
+
67
+ Danish: CPR numbers (the date part must be valid; the pre-2007 modulus-11 check only raises confidence, because numbers issued since October 2007 need not pass it), CVR numbers (modulus-11 and context), DK VAT numbers, phone numbers, bank account numbers (registreringsnummer and kontonummer), IBAN (mod-97), street addresses with floor and postcode, postcode and town, amounts (`12.500 kr.`, `DKK 12.500`, `36.000,-`), dates, court and journal numbers. English: addresses, phone numbers, amounts (`£4,250.00`, `$5,000`), dates. Both: e-mail, URLs, IBAN, credentials (API keys, tokens, private keys), organisation names by legal form (`ApS`, `A/S`, `Ltd`, `GmbH`), names after a title (`Hr.`, `advokat`, `Mr`, `Dr`), names from a bundled Danish and English first-name list, names you pass in (`names=`), and a low-confidence heuristic for capitalised sequences.
68
+
69
+ Once a value has been replaced anywhere in a project, every later occurrence is replaced too, including a Danish genitive (`Rasmus Jensens` becomes `[PERSON_1]s`), ALL-CAPS headings, and a surname or first name alone when it identifies exactly one known person (`Jensen blev` becomes `[PERSON_1] blev`). An identifier inside something the policy keeps (a CPR number in a URL) is still replaced. Pseudonymising already pseudonymised text changes nothing.
70
+
71
+ It does not find: names it has never seen and the heuristic misses, roles and rare facts that identify without an identifier, inflected compounds, OCR-damaged identifiers. The report's `kept` list and the `check` command exist so a reader sees what stayed.
72
+
73
+ ## Choosing what is sensitive
74
+
75
+ A `Policy` decides after detection, so the report can say what it saw and kept. `Policy.default()` replaces credentials, persons, CPR, e-mail, phone, addresses, IBAN, bank accounts, case numbers, CVR, organisations and amounts; `Policy.identifiers()` leaves amounts and organisations; `Policy.strict()` replaces everything detected, dates and postcodes included. `include`, `exclude`, per-type `min_score`, `allow` (never replace) and `deny` (always replace) lists refine any of them. The taxonomy behind the types, with tiers and the equivalent Presidio, Google DLP and HIPAA names, is `nyms.taxonomy.ENTITY_TYPES`.
76
+
77
+ ## Choosing the surrogates
78
+
79
+ | Strategy | Example | When |
80
+ |---|---|---|
81
+ | `Counter()` (default) | `[PERSON_1]`, `[CPR_2]` | the form LLMs alter least in the one published benchmark; numbered per project |
82
+ | `KeyedWords(key)` | `Alder Brook`, `[CPR:mustard-suitcase]` | the same surrogate on any machine that has the key, no shared store needed; readable |
83
+ | `RandomWords()` | `Lunar Cobweb` | a classic tokenisation vault: random, readable, no key |
84
+ | `Aliases({...}, fallback=...)` | `the landlord` | you choose the pseudonym for some values yourself |
85
+
86
+ `KeyedWords` is an HMAC-SHA-256 under the project key rendered as words from the EFF short wordlist (1,296 words, 10.3 bits each). The renderer stands alone as `nyms.readable(value, key=...)` and `nyms readable`. There is no unkeyed option: a plain hash of a CPR number is undone by enumerating the ten billion candidates.
87
+
88
+ Re-identification is tolerant of what models do to bracketed tokens (case, spacing, a dropped bracket, a genitive after it); name-shaped surrogates and aliases are matched exactly, so ordinary prose is left alone. It never guesses: tokens it did not issue come back in `unmatched`.
89
+
90
+ ## Where the mapping and the key live
91
+
92
+ The mapping store (surrogate, original, type) is the key to the text. By default it is a set of JSON files under `~/.local/share/nyms/<project>/mapping/`, and the project key is 32 random bytes in `~/.config/nyms/keys/<project>.key` (mode 0600), created on first use; `NYMS_KEY` in the environment overrides it, and `Pseudonymiser(..., key=...)` accepts any bytes. With `pip install "nyms[crypto]"` and `encrypt=True` the records are Fernet-encrypted under the key before they hit disk; without it they are plaintext in your user directory, which is the trade-off of needing no dependency. Lose the store and `[PERSON_1]` cannot be reversed; lose the key and an encrypted store cannot be read. Back both up, apart from each other.
93
+
94
+ Any `MutableMapping` works as the store (`store=`), so [dol](https://github.com/i2mint/dol) backends (SQLite, S3) drop in, and `store={}` keeps a run in memory.
95
+
96
+ ## For AI agents
97
+
98
+ <!-- epythet:agentic-readme:start -->
99
+ `nyms` ships a skill for coding agents, in [Agent Skills](https://agentskills.io) format:
100
+
101
+ ```bash
102
+ gh skill install thorwhalen/nyms nyms --agent claude-code # or copilot, cursor, codex, gemini
103
+ ```
104
+
105
+ | Skill | Use it to |
106
+ |---|---|
107
+ | `nyms` | pseudonymise documents before they reach a model, re-identify the answer, and check that nothing leaks |
108
+
109
+ The skill folder also ships inside the wheel: `python -c "import nyms, os; print(os.path.join(nyms.__path__[0], 'data', 'skills'))"`.
110
+ <!-- epythet:agentic-readme:end -->
111
+
112
+ ## Design
113
+
114
+ The research behind the design (terminology and regulation, detection libraries and methods, surrogates and key management, pipeline architecture) is in [`docs/research/`](docs/research/), with a [synthesis](docs/research/deidentification-synthesis.md); the decisions are in [`docs/adr/`](docs/adr/). Six seams, each one keyword argument of `Pseudonymiser`: `detectors`, `post_detectors`, `policy`, `strategy`, `store`, `key`. The model-based second pass is the `post_detectors` seam: any callable `text -> spans`, run on the already-pseudonymised text with the surrogates protected; none ships in v1.
115
+
116
+ The EFF wordlist is CC BY 3.0 (see `nyms/data/wordlists/NOTICE.md`). Everything else is MIT.
nyms-0.0.2/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # nyms
2
+
3
+ Reversible pseudonymisation of text before it reaches an AI. A deterministic (non-AI) pass finds personal and sensitive identifiers in Danish and English text, replaces them with surrogates, remembers the mapping per project so the same person is `[PERSON_1]` in every document, and puts the originals back into whatever the AI answers.
4
+
5
+ ```bash
6
+ pip install nyms # add "nyms[crypto]" for an encrypted mapping store
7
+ ```
8
+
9
+ ```python
10
+ from nyms import Pseudonymiser
11
+
12
+ p = Pseudonymiser("case-2026", names=["Rasmus Østergaard Jensen"])
13
+ r = p.pseudonymise("Rasmus Jensen (CPR 010190-1234) skylder 12.500 kr. Rasmus Jensens advokat ringede.")
14
+ r.text
15
+ # '[PERSON_1] (CPR [CPR_1]) skylder [AMOUNT_1]. [PERSON_1]s advokat ringede.'
16
+ p.reidentify("Svar: [PERSON_1] skal betale [AMOUNT_1].").text
17
+ # 'Svar: Rasmus Jensen skal betale 12.500 kr.'
18
+ p.check("Rasmus Jensen bor her") # what would leak if this were sent
19
+ # ['Rasmus Jensen']
20
+ ```
21
+
22
+ The same four verbs from the shell:
23
+
24
+ ```bash
25
+ nyms pseudonymise --path letter.md --project case-2026 --names "Rasmus Østergaard Jensen" --out letter.pseud.md
26
+ nyms reidentify --path answer.md --project case-2026 --out answer.clear.md
27
+ nyms check --path draft.md --project case-2026 # {"ok": false, "leaks": [...]} if an original is in it
28
+ nyms detect "Rasmus Jensen, tlf. 12 34 56 78" # every span and the policy's decision; nothing stored
29
+ nyms readable 010190-1234 --project case-2026 # a keyed hash as two readable words
30
+ ```
31
+
32
+ Content is positional text or `--path` (a file, or `-` for stdin), never guessed from one another.
33
+
34
+ ```bash
35
+ ```
36
+
37
+ `r.report` says what was replaced (by type and by detector), what was detected and kept (dates, postcodes and URLs, under the default policy) and which detectors ran, and ends with the caveat that this is pseudonymisation, not anonymisation.
38
+
39
+ ## What it finds
40
+
41
+ Danish: CPR numbers (the date part must be valid; the pre-2007 modulus-11 check only raises confidence, because numbers issued since October 2007 need not pass it), CVR numbers (modulus-11 and context), DK VAT numbers, phone numbers, bank account numbers (registreringsnummer and kontonummer), IBAN (mod-97), street addresses with floor and postcode, postcode and town, amounts (`12.500 kr.`, `DKK 12.500`, `36.000,-`), dates, court and journal numbers. English: addresses, phone numbers, amounts (`£4,250.00`, `$5,000`), dates. Both: e-mail, URLs, IBAN, credentials (API keys, tokens, private keys), organisation names by legal form (`ApS`, `A/S`, `Ltd`, `GmbH`), names after a title (`Hr.`, `advokat`, `Mr`, `Dr`), names from a bundled Danish and English first-name list, names you pass in (`names=`), and a low-confidence heuristic for capitalised sequences.
42
+
43
+ Once a value has been replaced anywhere in a project, every later occurrence is replaced too, including a Danish genitive (`Rasmus Jensens` becomes `[PERSON_1]s`), ALL-CAPS headings, and a surname or first name alone when it identifies exactly one known person (`Jensen blev` becomes `[PERSON_1] blev`). An identifier inside something the policy keeps (a CPR number in a URL) is still replaced. Pseudonymising already pseudonymised text changes nothing.
44
+
45
+ It does not find: names it has never seen and the heuristic misses, roles and rare facts that identify without an identifier, inflected compounds, OCR-damaged identifiers. The report's `kept` list and the `check` command exist so a reader sees what stayed.
46
+
47
+ ## Choosing what is sensitive
48
+
49
+ A `Policy` decides after detection, so the report can say what it saw and kept. `Policy.default()` replaces credentials, persons, CPR, e-mail, phone, addresses, IBAN, bank accounts, case numbers, CVR, organisations and amounts; `Policy.identifiers()` leaves amounts and organisations; `Policy.strict()` replaces everything detected, dates and postcodes included. `include`, `exclude`, per-type `min_score`, `allow` (never replace) and `deny` (always replace) lists refine any of them. The taxonomy behind the types, with tiers and the equivalent Presidio, Google DLP and HIPAA names, is `nyms.taxonomy.ENTITY_TYPES`.
50
+
51
+ ## Choosing the surrogates
52
+
53
+ | Strategy | Example | When |
54
+ |---|---|---|
55
+ | `Counter()` (default) | `[PERSON_1]`, `[CPR_2]` | the form LLMs alter least in the one published benchmark; numbered per project |
56
+ | `KeyedWords(key)` | `Alder Brook`, `[CPR:mustard-suitcase]` | the same surrogate on any machine that has the key, no shared store needed; readable |
57
+ | `RandomWords()` | `Lunar Cobweb` | a classic tokenisation vault: random, readable, no key |
58
+ | `Aliases({...}, fallback=...)` | `the landlord` | you choose the pseudonym for some values yourself |
59
+
60
+ `KeyedWords` is an HMAC-SHA-256 under the project key rendered as words from the EFF short wordlist (1,296 words, 10.3 bits each). The renderer stands alone as `nyms.readable(value, key=...)` and `nyms readable`. There is no unkeyed option: a plain hash of a CPR number is undone by enumerating the ten billion candidates.
61
+
62
+ Re-identification is tolerant of what models do to bracketed tokens (case, spacing, a dropped bracket, a genitive after it); name-shaped surrogates and aliases are matched exactly, so ordinary prose is left alone. It never guesses: tokens it did not issue come back in `unmatched`.
63
+
64
+ ## Where the mapping and the key live
65
+
66
+ The mapping store (surrogate, original, type) is the key to the text. By default it is a set of JSON files under `~/.local/share/nyms/<project>/mapping/`, and the project key is 32 random bytes in `~/.config/nyms/keys/<project>.key` (mode 0600), created on first use; `NYMS_KEY` in the environment overrides it, and `Pseudonymiser(..., key=...)` accepts any bytes. With `pip install "nyms[crypto]"` and `encrypt=True` the records are Fernet-encrypted under the key before they hit disk; without it they are plaintext in your user directory, which is the trade-off of needing no dependency. Lose the store and `[PERSON_1]` cannot be reversed; lose the key and an encrypted store cannot be read. Back both up, apart from each other.
67
+
68
+ Any `MutableMapping` works as the store (`store=`), so [dol](https://github.com/i2mint/dol) backends (SQLite, S3) drop in, and `store={}` keeps a run in memory.
69
+
70
+ ## For AI agents
71
+
72
+ <!-- epythet:agentic-readme:start -->
73
+ `nyms` ships a skill for coding agents, in [Agent Skills](https://agentskills.io) format:
74
+
75
+ ```bash
76
+ gh skill install thorwhalen/nyms nyms --agent claude-code # or copilot, cursor, codex, gemini
77
+ ```
78
+
79
+ | Skill | Use it to |
80
+ |---|---|
81
+ | `nyms` | pseudonymise documents before they reach a model, re-identify the answer, and check that nothing leaks |
82
+
83
+ The skill folder also ships inside the wheel: `python -c "import nyms, os; print(os.path.join(nyms.__path__[0], 'data', 'skills'))"`.
84
+ <!-- epythet:agentic-readme:end -->
85
+
86
+ ## Design
87
+
88
+ The research behind the design (terminology and regulation, detection libraries and methods, surrogates and key management, pipeline architecture) is in [`docs/research/`](docs/research/), with a [synthesis](docs/research/deidentification-synthesis.md); the decisions are in [`docs/adr/`](docs/adr/). Six seams, each one keyword argument of `Pseudonymiser`: `detectors`, `post_detectors`, `policy`, `strategy`, `store`, `key`. The model-based second pass is the `post_detectors` seam: any callable `text -> spans`, run on the already-pseudonymised text with the surrogates protected; none ships in v1.
89
+
90
+ The EFF wordlist is CC BY 3.0 (see `nyms/data/wordlists/NOTICE.md`). Everything else is MIT.
@@ -0,0 +1,35 @@
1
+ # ADR 0001: nyms does pseudonymisation, and says so
2
+
3
+ *2026-11-08 · status: accepted · research: [terminology-and-regulation](../research/terminology-and-regulation.md), [synthesis](../research/deidentification-synthesis.md)*
4
+
5
+ ## Context
6
+
7
+ The package replaces personal and sensitive identifiers in text before the text reaches an AI service, and can put them back. The user's words for this were "redaction", "mapping to keys", "anonymising". The established vocabulary differs, and the difference carries legal weight: under GDPR Art. 4(5) data that can be re-attributed with separately held additional information is **pseudonymised** and remains personal data (Recital 26); only data that no reasonably likely means can re-attribute is anonymous. The EDPB's 2025 guidelines on pseudonymisation use the same terms and name the mapping and keys the **additional information**, kept outside the **pseudonymisation domain** [1].
8
+
9
+ ## Decision
10
+
11
+ - The package, its API and its documentation say **pseudonymise** / **re-identify**, never "anonymise". The report of every run ends with a fixed caveat sentence (`nyms.core.CAVEAT`) saying so.
12
+ - Vocabulary, used consistently: **detector** (finds spans), **span** (character interval with entity type and score), **policy** (which spans are acted on), **surrogate** (the replacement; a *pseudonym* when it names a person), **surrogate strategy** (how surrogates are generated), **mapping store** (surrogate to original, per **project**), **key** (the project's secret), **re-identification** (restoring originals), **leak check** (originals found in outgoing text).
13
+ - British spelling throughout (`pseudonymise`), matching the EU sources that define the terms.
14
+ - What nyms claims: identifiers it detected were replaced before the text left the machine, and the mapping stayed local. What it never claims: that the output is anonymous, or a numeric risk score. Residual risk is reported as coverage (what was detected, replaced, kept, and which detectors ran), not as a probability.
15
+
16
+ ## The seams (architecture-first, turn 1)
17
+
18
+ | # | Seam | v1 default (no new dependency) | Replacement it allows |
19
+ |---|---|---|---|
20
+ | 1 | `detectors` | regex, checksum and gazetteer detectors (`nyms.detect.default_detectors`); any callable `text -> spans` | a Presidio adapter, more gazetteers |
21
+ | 1b | `post_detectors` | none: stage 2 is off | the model-based second pass (spaCy/DaCy, GLiNER, an LLM through `aix` or `creel`), run on the already-pseudonymised text with the surrogates protected |
22
+ | 2 | `policy` | `Policy.default()` (tiers 0 and 1, plus CVR, ORG, AMOUNT) | `strict()`, `identifiers()`, a serialised policy file |
23
+ | 3 | `strategy` | `Counter` (`[PERSON_1]`) | `KeyedWords`, `RandomWords`, `Aliases`, format-preserving encryption |
24
+ | 4 | `store` | `dol.JsonFiles` under `~/.local/share/nyms/<project>/mapping` | any `MutableMapping`; `encrypt=True` for Fernet at rest; SQLite, S3 through `dol` |
25
+ | 5 | `key` | `resolve_key`: env var, then a 0600 key file, then a passphrase | `keyring`, `config2py`, `age`, a KMS |
26
+
27
+ Surface for v1: Python facade, shipped agent skill, CLI (`cw`). The review of 2026-11-08 found the model pass could not be added as a plain detector (it runs on different text), so the stage became its own seam, `post_detectors`, with no implementation: the user asked for the choice, and the default choice is no second pass. MCP and HTTP: the tools module is the SSOT they would wrap; not built. NOT seams: the span data model, the report shape, the tolerant re-identification matcher, the overlap rule's signature.
28
+
29
+ ## Consequences
30
+
31
+ Users and agents read "pseudonymised" and know the mapping is a key. The client-facing risk guide (a separate document in another repository) owns the legal framing and the advice on what not to send; nyms owns the numbers a run can compute.
32
+
33
+ ## REFERENCES
34
+
35
+ 1. [EDPB, Guidelines 01/2025 on Pseudonymisation](https://www.edpb.europa.eu/system/files/2025-01/edpb_guidelines_202501_pseudonymisation_en.pdf)
@@ -0,0 +1,25 @@
1
+ # ADR 0002: sensitivity policy and entity taxonomy
2
+
3
+ *2026-11-08 · status: accepted · research: [terminology-and-regulation](../research/terminology-and-regulation.md) §3 and §5*
4
+
5
+ ## Context
6
+
7
+ The user asked to choose what counts as sensitive, "before a sensitivity detection process, or even after one, once a list of items has been extracted", and for amounts and other non-identifying sensitive values to be in scope. Existing tools pick entities before detection (Presidio's `entities=` argument) and threshold scores after; Google DLP attaches a sensitivity level to each type; HIPAA's 18 classes and GDPR Art. 9 define legal sensitivity. No tool has Danish entity types.
8
+
9
+ ## Decision
10
+
11
+ **Taxonomy** (`nyms.taxonomy.ENTITY_TYPES`): a small registry with three axes per type: identifier class (credential, direct, quasi, sensitive, organisation), tier (0 block, 1 replace by default, 2 policy decides) and aliases to Presidio, DLP and HIPAA names. Fifteen types in v1: CREDENTIAL, PERSON, CPR, EMAIL, PHONE, ADDRESS, IBAN, BANK_ACCOUNT, CASE_ID (tiers 0 and 1); CVR, ORG, AMOUNT, DATE, POSTCODE_CITY, URL (tier 2). The registry is a dict so a caller adds a type by assignment.
12
+
13
+ **Policy** (`nyms.policy.Policy`): a dataclass evaluated **after** detection: `include`/`exclude` types, `min_score` (global and per type), `allow` (never replace) and `deny` (always replace) lists on span text, and a `keep` hook. All detectors run; the policy decides; the run report lists what was detected and kept. The same object also filters detectors **before** detection (`detector_filter`, `prefilter=True` on the facade) for callers who want to skip work. Presets: `default` (tiers 0 and 1 plus CVR, ORG, AMOUNT), `identifiers` (tiers 0 and 1), `strict` (everything detected, no threshold). `with_names(...)` adds the parties to the deny list. Policies serialise to and from dicts.
14
+
15
+ **Order of operations in a run**: every detector's spans, overlap resolution (ADR 0003), the policy's split into replaced and kept, then a second pass with the project's known values (ADR 0003). Tier 0 (credentials) is replaced whatever `include` says unless excluded explicitly.
16
+
17
+ ## Alternatives considered
18
+
19
+ - Policy before detection only: cheaper, but the report could not say what was seen and kept. Rejected as the only mode; offered as `prefilter`.
20
+ - Per-rule action lists (replace, mask, redact, generalise) as in the research's proposed object: deferred. v1 has two actions, replace and keep. Masking and date generalisation are additions at this seam.
21
+ - Art. 9 as a boolean flag per type: deferred with the above; the identifier class carries "sensitive" for now.
22
+
23
+ ## Consequences
24
+
25
+ Dates, postcodes and URLs stay in the text by default and appear in `report["kept"]`, so a user who needs them replaced sees them and switches to `strict` or adds the type. Amounts are replaced by default because the first use case is a legal and financial dispute; `identifiers` keeps them.
@@ -0,0 +1,27 @@
1
+ # ADR 0003: detector strategies: a deterministic pass first, a model pass as a seam
2
+
3
+ *2026-11-08 · status: accepted · research: [detection-libraries-and-methods](../research/detection-libraries-and-methods.md), [pipeline-architecture-and-agent-practice](../research/pipeline-architecture-and-agent-practice.md)*
4
+
5
+ ## Context
6
+
7
+ The user's requirement: a non-AI pass runs before any content reaches a model, with an optional heavier model-based pass afterwards. The research found no library with Danish recognisers (Presidio has none; Google DLP has only the CPR type), that the CPR modulus-11 check is unreliable for numbers issued since October 2007, that recall is the metric de-identification systems optimise, and that the best Danish NER options (spaCy `da_core_news`, DaCy, GLiNER PII checkpoints) are heavy, optional dependencies.
8
+
9
+ ## Decision
10
+
11
+ - **Native deterministic detectors** (`nyms.detect`): a `Detector` is a compiled regex plus an optional validator (checksum, calendar, stop list) plus context words that raise the score. v1 ships: CPR (date check required, mod-11 only raises the score), CVR (mod-11 plus context), DK VAT, e-mail, Danish and international phone, IBAN (mod-97 required), Danish bank account, Danish and English addresses, Danish postcode and town, amounts (prefix or suffix currency, Danish thousand separators), dates (numeric, Danish and English textual), case and journal numbers, URLs, credentials (vendor prefixes and one assignment heuristic, after `openloops.egress`), organisation names by legal-form suffix, titled names, a capitalised-sequence name heuristic at a low score, a bundled first-name gazetteer (Danish and English) and the caller's known names.
12
+ - **Overlap resolution** (`nyms.spans.resolve_overlaps`): strong spans (score at or above 0.6) outrank weak ones whatever their length; among equals the longer, then the higher score, then the earlier. A function, so it is testable and replaceable.
13
+ - **Two passes for consistency**: after the first pass enters new values into the mapping, a second pass adds a detector per entity type for every value the project already knows, so an occurrence scored too low (a name at a sentence start, a company in a heading) is replaced once the value was accepted anywhere, in this document or an earlier one. This is the benchmark engine's "propagate to every mention" behaviour.
14
+ - **Genitive handling**: detectors never strip anything (a surname may end in `s`: "Niels Thomas"). The mapping store maps `<known value>s` to the known value's surrogate plus the suffix, and the facade writes `[PERSON_1]s`; a genitive seen before its base form is taken literally and relinked when the base form appears.
15
+ - **Nested promotion**: an accepted span inside a span the policy keeps (a CPR number inside a URL) is still replaced; the kept span stays in the report.
16
+ - **Name components**: a surname or first name that identifies exactly one known person (from `names=` or the mapping) stands for that person at score 0.8, so "Jensen blev" becomes `[PERSON_1] blev`. Capitalised words include all-capitals headings (`METTE HOLM`), hyphenated names and initials (`H.C. Andersen`). Letters are the Latin letters of Unicode's blocks up to Latin Extended-B, and text is NFC-normalised first.
17
+ - **Surrogate protection**: the project's own surrogates are detected as a pseudo-type that wins overlap resolution and is never acted on, so pseudonymising already pseudonymised text is idempotent.
18
+ - **Model-based second pass**: the `post_detectors` seam (added after the adversarial review showed a plain detector could not do it, since it runs on different text). Any callable `text -> Iterable[Span]`; it runs on the already-pseudonymised text, so the model never sees what the first pass caught, with the surrogates protected from re-detection; what it finds is replaced in that text and added to the mapping, and the report lists it under its own name. Default: none. Candidates: DaCy or spaCy Danish models, GLiNER PII (Apache-2.0), an LLM through `aix` or `creel`. Piiranha and the ai4privacy data are excluded by licence.
19
+ - **Recall over precision**, with the policy threshold as the user's dial; the heuristic exists because a missed name leaks and a redundant replacement only costs readability.
20
+
21
+ ## Consequences
22
+
23
+ False positives occur (a capitalised phrase replaced as a person); they are visible in the report and reversible. A bare eight-digit number is ambiguous between phone and CVR and is decided by checksum and context. OCR noise and inflected compounds are not handled in v1 (documented limits).
24
+
25
+ ## Fixtures
26
+
27
+ Synthetic Danish and English documents under `tests/fixtures/`, with `expected.json` naming what must be replaced and what must be detected but kept; a leakage test (no original, nor its digit string, survives) and a round-trip test per fixture. No real data; the CPR and CVR numbers are checksum-valid synthetic values or deliberately invalid ones.
@@ -0,0 +1,28 @@
1
+ # ADR 0004: surrogate generation: typed counters by default, keyed readable words as the tool
2
+
3
+ *2026-11-08 · status: accepted · research: [surrogates-crypto-and-keys](../research/surrogates-crypto-and-keys.md), [pipeline-architecture-and-agent-practice](../research/pipeline-architecture-and-agent-practice.md) §2*
4
+
5
+ ## Context
6
+
7
+ The user listed the strategies to support: aliases chosen by hand, plain numbering, a cryptographic hash, and "a cryptographic hash mapped to a sequence of letters that is actually readable", asking for the last one to be built. Two research reports disagreed on the default. The surrogate report reasoned that word-shaped tokens survive LLM paraphrase best; the pipeline report found one measured benchmark (bilingual, single vendor, 2026) in which plain bracketed typed tokens such as `[PERSON_1]` were altered least by models (1 in 8,099 occurrences, against 101 in 5,099 for XML-style tags) and restored at 99.99% with a tolerant restorer. Readable surrogates were not measured. Unkeyed hashes of low-entropy identifiers (a CPR number has at most about 10^10 values) are reversible by enumeration and are rejected by the EDPB's guidance.
8
+
9
+ ## Decision
10
+
11
+ - A **strategy** is a callable `(entity_type, value, *, vault) -> str`, called once per new value by the mapping store; `vault` exposes `__contains__` and `count(entity_type)`.
12
+ - **Default: `Counter`**, `[PERSON_1]`, numbered per type within the project, persisted with the store so the numbering continues across documents. Measured evidence beats reasoning; the counter carries no information about the value.
13
+ - **`KeyedWords`**: HMAC-SHA-256 of `entity_type || NUL || value` under the project key, rendered as words. The same key gives the same surrogate on any machine without sharing a store. The key must be secret and at least 16 bytes; there is no unkeyed option. Collisions with an existing surrogate add a word.
14
+ - **`RandomWords`**: the classic tokenisation vault, random bytes rendered as words.
15
+ - **`Aliases`**: the user's own surrogates for chosen values, any other strategy as the fallback.
16
+ - **Rendering is separate from the source of bits**: `words_from_bytes(data, n, wordlist)` writes the integer in base `len(wordlist)`, most significant word first. The bundled list is the EFF short wordlist 2.0 (1,296 common words, unique three-letter prefixes, CC BY 3.0, attributed in `nyms/data/wordlists/NOTICE.md`): 10.3 bits per word, two words for about 1.7 million values. `syllables_from_bytes` is the list-free fallback. The `readable()` function and the `nyms readable` command expose the renderer stand-alone.
17
+ - **Shape**: word surrogates use a template per entity type, `Alder Brook` (name-shaped) for PERSON and ORG, `[CPR:alder-brook]` for everything else. Numbers never get checksum-valid fake numbers: a surrogate that could pass for a real identifier is a lie the reader cannot detect.
18
+ - **Re-identification is tolerant**: brackets optional, separators loose, case ignored, a genitive after the closing bracket allowed, longest surrogate first, and a word boundary required when the bracket is missing so `PERSON_1` never matches inside `PERSON_11`. Tokens that look like ours but were never issued are reported as `unmatched`, never guessed.
19
+
20
+ ## Alternatives considered
21
+
22
+ - Format-preserving encryption (FF1): a replacement for numeric identifiers only; NIST's 2025 draft keeps FF1 alone and the maintained Python library implements the withdrawn FF3 variants. Not built.
23
+ - Seeded Faker names: realistic, but a fake that matches a real person is a risk and the dependency is large. A replacement at this seam.
24
+ - XML-style tags: measured worse. Rejected.
25
+
26
+ ## Consequences
27
+
28
+ Text sent to a model reads as `[PERSON_1] owes [AMOUNT_1]`, which is less fluent than a readable name but round-trips best. A user who wants fluent text switches to `KeyedWords` with one argument and accepts an unmeasured round-trip risk, which `check()` and the `unmatched` list make visible.
@@ -0,0 +1,27 @@
1
+ # ADR 0005: the reversible mapping store and key management
2
+
3
+ *2026-11-08 · status: accepted · research: [surrogates-crypto-and-keys](../research/surrogates-crypto-and-keys.md) §3 and §4, [pipeline-architecture-and-agent-practice](../research/pipeline-architecture-and-agent-practice.md) §2*
4
+
5
+ ## Context
6
+
7
+ The mapping (surrogate to original) and the key are the "additional information" of GDPR Art. 4(5): whoever holds them re-identifies everything. Every LLM privacy gateway surveyed keeps such a store and warns that it is sensitive; Presidio keeps none and so cannot be consistent across documents. The in-house storage facade is `dol`; the in-house secret resolution is `config2py`; `cryptography` is installed but is not a dependency of a minimal package.
8
+
9
+ ## Decision
10
+
11
+ - **Store** (`nyms.mapping.MappingStore`): one record per distinct value (surrogate, original, entity type, normalised form, strategy, time), keyed by surrogate, indexed in memory by `(entity_type, normalised)`. Normalisation decides identity: digits only for numeric identifiers, upper-case alphanumerics for IBAN and case numbers, lower case for e-mail and URL, casefolded and whitespace-collapsed for the rest.
12
+ - **Backing store**: any `MutableMapping[str, dict]`. Default `local_mapping_store(project)`: `dol.JsonFiles` under `~/.local/share/nyms/<project>/mapping/` (XDG-aware), one JSON file per record, directory mode 0700. A `dict` is the in-memory store for tests and one-off runs. The repository never holds a store; the default root is outside any project directory by design.
13
+ - **Encryption at rest is a seam** (`codec=`): `encrypted(key)` gives a Fernet codec (AES-128-CBC with HMAC-SHA-256) from the optional `cryptography` dependency (`pip install "nyms[crypto]"`, `encrypt=True` on the facade). The default is plaintext in the user's data directory because the standard library has no authenticated cipher and a default must not need a new dependency. The trade-off is stated in the README.
14
+ - **Key** (`nyms.keys.resolve_key`): the `NYMS_KEY` environment variable (hex or base64), then a per-project key file under `~/.config/nyms/keys/<project>.key` (32 random bytes, mode 0600, created on first use), then, when a passphrase is given, scrypt derivation with a stored per-project salt. The key file and the mapping directory are never in the same tree. Replacements: `keyring` (OS keychain), `config2py`, `age`, a KMS, each one `key=` argument.
15
+ - **Rotation** is a new project. Keyed-word surrogates change with the key by construction; counter surrogates survive it because they live in the store. Re-keying an encrypted store means decrypting and re-encrypting the records.
16
+ - **Loss**: lose the store and counter surrogates cannot be reversed; lose the key and an encrypted store cannot be read and keyed-word surrogates cannot be regenerated. Both are stated in the README; a backup is the user's job.
17
+ - `MappingStore.to_dict()` and the `nyms mapping` command print originals. They exist because the user owns the data; the docs mark them sensitive.
18
+
19
+ ## Alternatives considered
20
+
21
+ - Append-only log with compaction: more robust for concurrent writers, more code. One file per record on `dol` gives inspectability and `dol`'s backends for free; deferred.
22
+ - OS keyring as the default key store: the right replacement, but a new dependency and an interactive prompt on first use. Not the default.
23
+ - Per-document keys: destroys cross-document consistency, which is the point of a project. Rejected.
24
+
25
+ ## Consequences
26
+
27
+ A project is the unit of consistency, of key and of store. Two sessions on one project write to the same directory; `dol.JsonFiles` writes are per record, so interleaving is safe at record granularity but the in-memory counter can issue a duplicate under true concurrency. Not a v1 concern; noted for the store seam.
@@ -0,0 +1,60 @@
1
+ # De-identification toolkit research synthesis: what nyms uses and why
2
+
3
+ *2026-11-08 · scope: the four theme reports in this folder · requested for: the design of `nyms`, a reversible pseudonymisation toolkit for Danish and English text sent to AI services*
4
+
5
+ ## Summary and recommendation
6
+
7
+ - **Name the thing correctly**: pseudonymisation (GDPR Art. 4(5)), with the mapping and key as the "additional information" kept outside the pseudonymisation domain. Never claim anonymity; report coverage, not a risk score. The EDPB's 2025 guidelines and the CJEU's 2025 *EDPS v SRB* judgment are the framing; the final post-consultation EDPB text was not confirmed [1][2].
8
+ - **Write the deterministic detectors natively.** No library has Danish recognisers; Presidio, the most mature, has none, and Google DLP has only the CPR type [3][4]. The CPR modulus-11 check cannot be a gate (numbers issued since October 2007 need not pass it) [5]. Study Presidio's span contract, context scoring and overlap rules; do not depend on it.
9
+ - **Default surrogate: `[PERSON_1]`-style bracketed typed counters**, per project, because the only measured benchmark found them altered least by LLMs and restored at 99.99% with a tolerant restorer [6]. Readable keyed-word surrogates are the opt-in strategy and the stand-alone tool the user asked for; the EFF short wordlist (CC BY 3.0) renders them [7]. Unkeyed hashes are rejected [1].
10
+ - **The store is the product's state**: a per-project `dol` store with the key held separately, encryption at rest as an optional codec, propagation of known values to every mention, and a tolerant, non-guessing re-identifier that reports invented tokens. Presidio's statelessness is the gap every LLM gateway had to fill [8][9].
11
+ - **Agent surface**: four verbs (`pseudonymise`, `reidentify`, `check`, `detect`) over one facade, a shipped skill that makes the deterministic pass the first step before any content reaches a model, and a leak check that blocks rather than warns.
12
+
13
+ ## Where the reports agree
14
+
15
+ All four put a detect / transform / reverse split at the centre, with character-offset spans and a score, and all treat the mapping as sensitive. All say recall beats precision for this task and that the residual risk must be stated in words, not a number. All find Danish support absent from existing tools.
16
+
17
+ ## Where they disagree, and the resolution
18
+
19
+ | Question | Surrogates report | Pipeline report | Resolution in nyms |
20
+ |---|---|---|---|
21
+ | Default surrogate form | random readable word tokens, name-shaped | bracketed typed token `[PERSON_1]` | **Counter default** (measured), `KeyedWords`/`RandomWords` opt-in (ADR 0004) |
22
+ | Default key store | OS keyring | `config2py` | **key file under the config dir**, env var override, passphrase option; keyring and config2py as replacements (ADR 0005) |
23
+ | Encryption at rest | required default | encrypted file | **optional codec** (no stdlib cipher; a default must not need a new dependency); README states the trade-off (ADR 0005) |
24
+
25
+ ## What the build uses
26
+
27
+ | Part | From the research | Implementation |
28
+ |---|---|---|
29
+ | Vocabulary and caveat | terminology report §1, §2 | ADR 0001, `nyms.core.CAVEAT` |
30
+ | Taxonomy and policy | terminology report §3, §5 | ADR 0002, `nyms.taxonomy`, `nyms.policy` |
31
+ | Detectors, overlap rule, two passes | detection report §1–§3, pipeline report §2 | ADR 0003, `nyms.detect`, `nyms.spans` |
32
+ | Strategies, word rendering, tolerant restorer | surrogates report §1–§2, pipeline report §2 | ADR 0004, `nyms.surrogates`, `nyms.core.reidentify` |
33
+ | Store, key, rotation, loss | surrogates report §3–§4 | ADR 0005, `nyms.mapping`, `nyms.keys` |
34
+ | Fixtures and tests | detection report §4, pipeline report §5 | `tests/fixtures/`, leakage and round-trip tests; Faker's `da_DK` has no CPR generator, so fixtures carry hand-made values |
35
+
36
+ ## In-house components reused
37
+
38
+ `dol` (the store facade), `cw` (the CLI from the tools module), the pattern design of `openloops.egress` (credential prefixes, one heuristic rule last). Found but not used in v1: `hm` (Major System; a rendering aid, not a hash encoding), `creel` and `aix` (candidates for the model-based second pass), `config2py` (a key-resolution replacement).
39
+
40
+ ## Open questions carried forward
41
+
42
+ 1. Does the bracketed-token advantage hold for Danish and for current frontier models? Rerun the benchmark method on the Danish fixtures (an issue).
43
+ 2. Which Danish NER (DaCy, spaCy, GLiNER PII) is good enough for the second pass, measured by leakage rate on the fixtures?
44
+ 3. Is the final EDPB 01/2025 text changed on hashing and key separation?
45
+ 4. A Danish name list with a redistributable licence: Danmarks Statistik's terms were not found.
46
+ 5. Quasi-identifier actions (date shifting, amount bucketing) and Art. 9 flags: deferred from the policy (ADR 0002).
47
+
48
+ ## REFERENCES
49
+
50
+ 1. [EDPB, Guidelines 01/2025 on Pseudonymisation](https://www.edpb.europa.eu/system/files/2025-01/edpb_guidelines_202501_pseudonymisation_en.pdf)
51
+ 2. [EUR-Lex, Case C-413/23 P, EDPS v SRB](https://eur-lex.europa.eu/eli/C/2025/5551/oj/eng)
52
+ 3. [Presidio, supported entities](https://presidio.dataprivacystack.org/supported_entities/)
53
+ 4. [Google Sensitive Data Protection, infoType reference](https://docs.cloud.google.com/sensitive-data-protection/docs/infotypes-reference)
54
+ 5. [LookupTax, Denmark TIN: CPR and CVR number guide](https://www.lookuptax.com/docs/tax-identification-number/denmark-tax-id-guide)
55
+ 6. [Beskorovainyi V. Reversible Pseudonymisation of Documents Sent to External LLMs, arXiv:2610.34012 (2026)](https://arxiv.org/html/2610.34012)
56
+ 7. [EFF, Deep Dive: EFF's New Wordlists for Random Passphrases](https://www.eff.org/dice)
57
+ 8. [Presidio Anonymizer documentation](https://presidio.dataprivacystack.org/anonymizer/)
58
+ 9. [LLM Guard Anonymize scanner](https://protectai.github.io/llm-guard/input_scanners/anonymize)
59
+
60
+ The theme reports: [terminology-and-regulation](terminology-and-regulation.md), [detection-libraries-and-methods](detection-libraries-and-methods.md), [surrogates-crypto-and-keys](surrogates-crypto-and-keys.md), [pipeline-architecture-and-agent-practice](pipeline-architecture-and-agent-practice.md). Prompts used: [prompts/](prompts/).