Skip to content

Contextual name redaction for AI context: replace, don't remove - #93

Merged
KAVentures merged 13 commits into
mainfrom
feat/contextual-name-redaction
Sep 27, 2026
Merged

KAVentures merged 13 commits into
mainfrom
feat/contextual-name-redaction

Conversation

@KAVentures

Copy link
Copy Markdown
Owner

Summary

AI apps connected through MCP now get Redacted context by default: the original title or label, with only the sensitive span replaced by a typed stable token.

Re: Contract for Anna Svensson - Gmail            ->  Re: Contract for PERSON_1A2B3C - Gmail
Faktura 4471 från Erik Lindqvist - Outlook        ->  Faktura 4471 från PERSON_… - Outlook
Call Dr. Lindqvist about lab results              ->  Call Dr. PERSON_… about lab results
Meeting with Johan and Anna re Q3 budget - Google Calendar
                                                  ->  Meeting with PERSON_… and PERSON_… re Q3 budget - Google Calendar
Acme AB - Account - Salesforce                    ->  unchanged

Three layers

Layer Use
Raw Local analysis. Unchanged in storage (verified in the e2e test).
Redacted (new default for every Context MCP tool) Typed stable tokens PERSON_ EMAIL_ PHONE_ PERSONNUMMER_ ID_
Safe allowlist Dashboard glance views and Gateway sharing. Unchanged.

What's in it

  • server/contextual_redaction.py: runs locally with no network. It uses:

    • bundled name lists;
    • English and Swedish cue rules (from/for/to/with/by/about/re/cc/chat with/meeting with/call/Dr./Mr./Ms./Hej/från/för/till/med/av/hos/om/möte med/samtal med, plus relation words);
    • First Surname, First and/och/&// First, Surname, First and Surname First, a segment that is only a first name, possessives;
    • learned identities.

    Never replaced: apps and sites, UI vocabulary, months and days, organizations that are also surnames (Ericsson), words before place/organization nouns (Hope Street), and the user's never redact list. Always redact is supported. Ambiguous names (May, Bill, Grace, Paris, Claude…) need a listed surname or a strong cue. OWNER handling is unchanged.

  • One choke point (server/ai_context.py, server/ai_context_routes.py): MCP requests carry X-OpenWorkGraph-Context: ai, and the whole JSON response is transformed once according to the effective detail level.

    • Covers routes that returned rich text without person redaction before (/v1/tasks, /v1/summary, /v1/procedural-memory/*, /v1/task-context).
    • Fails closed: an unparseable response is not passed through, and non-JSON in Redacted mode returns 406.
  • Setting "AI context detail" under Connect → Connections: Redacted (default) or Full, plus never/always lists. Stored in config.json under ai_context.

    • AI requests can read the setting but not change it (403).
    • Organization lock: the new Gateway policy key force_redacted_ai_context (restrictive merge, cached by the sync worker) or the managed config.json key organization_ai_context_detail: "redacted". Choosing Full while locked returns 409.
  • detail_level in every MCP response, taken from the level the server actually applied (X-OpenWorkGraph-Detail-Level).

  • Export: new Redact names in export (default off), using the same redactor. The flag is bound into the short-lived export ticket, so a redacted ticket can't be replayed unredacted.

The learned-identity bug

Reproduced: an identity seen as Name <email> while serving a response was forgotten by the next response.

  • Cause 1: the display pipeline is built with persist_registry=False (by design, read paths never write the registry), so the learned alias was dropped.
  • Cause 2: the new layer would have given the same person a different token (name hash vs email hash).
  • Fix: display-time identities are kept in a per-data-dir process-memory overlay. It's merged into the registry reads of both pipelines and cleared by Reset learned person aliases. The registry file is still never written on a read path, which is tested.
  • Ingest path: learning through /v1/events already worked, verified end to end.

Name lists: sources and licenses

server/name_lexicon/ holds names only, about 62 KB: 4,387 first names and 4,196 surnames. It's rebuilt by scripts/build_name_lexicon.py; nothing is downloaded at runtime.

  • US Census Bureau, 1990 name files: public domain (US federal work, 17 U.S.C. § 105). Top 1,000 female and 800 male first names, top 2,500 surnames.
  • Statistics Sweden (SCB), "Namn med minst två bärare, 31 december 2022": CC0 1.0. Tilltalsnamn with ≥ 200 bearers, efternamn with ≥ 500.

The ambiguous-name list is hand-curated in code, not data.

Evaluation

tests/fixtures/redaction_eval.jsonl has 246 hand-labelled titles and labels, 193 English and 53 Swedish, with 113 names and 138 negatives.

  • Apps: Gmail, Outlook, Teams, Slack, Calendar, Salesforce, HubSpot, Jira, GitHub, Docs/Sheets, a clinical journal system and generic UI labels.
  • Hard negatives: companies, products, places, and names that are also words.
  • It deliberately includes names that are not in the lists.

scripts/redaction_eval.py aligns input and output by tokens:

Result Required
Name recall 0.982 (111/113) ≥ 0.95
Over-redaction 0.010 (9/929 words) ≤ 0.05

Remaining errors are documented in docs/OWNER_REDACTION.md:

  • Misses: Bill Gates, Florence Nightingale Museum.
  • Over-redactions:
    • brands and holidays named after people (Morgan Stanley, Jack Daniels, Martin Luther King Jr. Day), which the never-redact list covers;
    • two mail rows the existing mail-row policy replaces as a whole segment.

The first metric version aligned characters and wrongly credited "Anna Svensson" → "PERSON_…" with shared letters. It now aligns on word tokens.

Tests

  • tests/test_ai_context_mcp_v092.py: a real API process through server.enterprise_runner and the real compact stdio MCP server.
    • Data: label Open email from Anna Svensson, window title Re: Contract for Anna Svensson - Gmail.
    • Calls all 8 Context tools. Redacted: "Anna"/"Svensson" never appear, while Open email from PERSON_ and Re: Contract for PERSON_ do, and every result has detail_level: "redacted".
    • Full: the raw strings appear.
    • Org lock: overrides the user's Full, and POSTing Full returns 409. AI-context POST returns 403.
    • The raw DB rows are unchanged afterwards.
  • tests/test_contextual_redaction_v092.py (23 tests):
    • the expected outputs above, and non-sensitive titles left unchanged;
    • same person, same token across apps and fields; typed tokens;
    • learned-identity regression and reset; never/always lists; OWNER;
    • org lock via the Gateway state; restrictive policy merge;
    • the eval thresholds, with numbers printed;
    • timing: about 20 µs per uncached title, well under the 1 ms bound.
  • tests/test_ai_context_http_v092.py:
    • default /v1/workflow-trace with 600 events measured 129 ms baseline and 132 ms with AI redaction (bound: less than 3× + 0.5 s);
    • redacted export, and ticket tampering refused;
    • a non-JSON AI request fails closed.
  • tests/js/connections.test.mjs: the dashboard control and the export checkbox.
  • Results:
    • Full pytest run twice in a row: 659 passed, 0 failed each time. The 1 skip is pre-existing.
    • node --test tests/js/*.test.mjs: 51/51.
    • scripts/check_injected_dashboard_js.py: OK.
  • Existing privacy-boundary and dashboard-allowlist tests are unchanged. test_presentation_redaction pins the export line, so the code was shaped to keep it.
  • Two test edits:
    • tests/test_release_version_v087.py expected 0.90.0 after the v0.91.0 release (it already failed on main) and now matches the shipped version.
    • test_v47 pinned secure_get("/v1/ai-access") exactly; it now checks the call prefix, because the call passes client.
  • Also checked by hand in a browser: the setting toggles, the lists persist to config.json, and the lock disables Full.

Docs

README, docs/MCP_ARCHITECTURE.md, docs/PRIVACY_AND_DATA.md, docs/OWNER_REDACTION.md (with the metrics), AI_GUIDE.md (detail_level), docs/CHANGELOG_V092.md (unreleased; no version bump), and server/name_lexicon/README.md.

Context MCP tools now default to a Redacted layer: the original title or
label with only sensitive spans replaced by typed stable tokens
(PERSON_, EMAIL_, PHONE_, PERSONNUMMER_, ID_). Raw evidence stays
unchanged in storage; the dashboard allowlist is unchanged.

- server/contextual_redaction.py: local, deterministic name detection
  (bundled US Census 1990 / SCB 2022 name lists, EN+SV context cues,
  pairs, coordinations, Surname-First order, learned identities,
  stoplists for apps/UI/months/organizations, ambiguous-name handling,
  never/always-redact lists). ~20 us per title, cached per string.
- server/ai_context.py + ai_context_routes.py: AI context detail setting
  (Redacted default | Full), org lock (Gateway force_redacted_ai_context
  or managed config), one middleware choke point for requests marked
  X-OpenWorkGraph-Context: ai, fail-closed, detail level header.
- MCP: every request marked as AI context; every tool result carries
  detail_level.
- Dashboard: AI context detail control with never/always lists; export
  gains 'Redact names in export' (default off, ticket-bound).
- Fix: identities learned from 'Name <email>' while serving a response
  were forgotten by the next response; now kept in process memory
  (never written on read paths) and reused as the same token.
- Eval: 246 labeled titles; name recall 0.982, over-redaction 0.010.
- tests/test_release_version_v087.py aligned with shipped 0.91.0.
@KAVentures
KAVentures merged commit 12108b9 into main Sep 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant