traveler-data-access-demo
Governed, AI-safe access to customer data: consent, PII removal, provenance and audit at the boundary where a model asks a question.
All data here is synthetic and generated by this repo. No real company, product, dataset or branding is involved.
The problem
An agent that can answer "what kind of trip is this traveler likely planning?" is useful. Getting there means handing a model access to customer data, and that access has four ways of going quietly wrong:
It sees personal information it never needed. The traveler's email is sitting in the same row as their favourite destination. Nothing about answering the question requires it, but nothing stops it travelling either into the prompt, the model's context, the logs, and anywhere the response is forwarded.
It acts on data that arrived out of order. Events are replayed, retried and delayed. A correction delivered before the thing it corrects, or an event delivered twice, leaves a profile that is wrong in a way nothing complains about.
It answers an as-of question with data from the future. "How many searches in the 30 days before this booking?" is easy to answer with information the platform did not have at that moment. The number looks reasonable and cannot be reproduced in production.
It sees data the traveler did not consent to share. Consent changes over time, and it changes in both directions. An opt-out delivered today has to survive a replay of last week's opt-in.
What these failures have in common is that none of them raise an exception. Every one produces a plausible number. That is the entire design problem: the dangerous outcome looks exactly like the correct one, so correctness has to be structural rather than careful.
What this is
A small, complete slice of that platform — about 1,700 lines of Python, SQLite for storage, five specs written before the code, and 99 tests that map back to them.
| Piece | Does | Spec |
|---|---|---|
| Event stream | Generates synthetic searches, bookings, cancellations and consent changes with deliberate duplicates, out-of-order delivery and late arrivals | 001 |
| Profile builder | Applies events incrementally; idempotent under replay, correct under reordering | 002 |
| Point-in-time features | "Searches in the last 30 days, as of T" computed from what was actually known at T | 003 |
| Access layer | The single read path: consent, PII scrubbing, provenance, audit | 004 |
| MCP server | Three typed tools — the only surface a model ever sees | 005 |
specs/README.md maps every acceptance criterion to the test that proves it.
The design, in plain English
Two clocks, not one
Every event carries two timestamps:
occurred_at— when the thing happenedingested_at— when the platform found out
Most pipelines keep only the first, and that one omission is what makes point-in-time features wrong. There are two different leaks hiding here:
- Future leakage — the event had not happened yet at
as_of. Filtering onoccurred_atcatches this, and it is the leak everyone remembers. - Knowledge leakage — the event had happened, but had not arrived yet. Only
ingested_atcatches this one.
A feature that passes the first check and fails the second is the dangerous case: it describes a world the platform could not see. Train on it and the model learns from information that will not exist at serving time.
So every feature read goes through one function, features.visible_events, which filters
on both clocks and nothing else does. The synthetic data includes an event that occurred on
2 October and arrived on 11 October specifically so there is an instant where the two
filters disagree, and a test (C6) places its cut
point inside that window.
Events are classified by how they behave under replay
Replaying an event is not uniformly safe or unsafe — it depends on the field:
- Counters (
search_count, destination tallies) commute, so order never matters, but they must be applied exactly once. Theprocessed_eventsledger provides the "once":INSERT OR IGNOREplus arowcountcheck is the duplicate test. Checking first and writing second leaves a window for a concurrent second delivery to slip through. - Last-write-wins fields (
home_origin,preferred_currency) do not commute, so each is guarded by a version comparison and skipped when the incoming event is older. - Consent is last-write-wins, versioned separately, and the one place where being wrong is a privacy incident rather than a bad metric.
A late event is therefore neither a no-op nor a full apply: its counters land and its
overwrites do not. That partial outcome is reported as APPLIED_STALE rather than hidden.
One door, four controls
Every read — from the CLI, from the tests, from the model — goes through
access.serve(). It is the only function that touches traveler data, and it always does
these four things in this order:
- Consent, evaluated at read time against current state. Not as-of:
as_ofrewinds facts, never permission. Allowing it to rewind consent would make it a one-parameter bypass. No consent record means deny — the default is closed. - PII removal, by allowlist. The typed output models simply do not name
contact_email,full_nameorphone, so those fields have nowhere to go. A scrubber runs afterwards and checks field names, this traveler's exact known values, and generic email/phone patterns. It fails the response rather than masking it, because masking lets a leak ship quietly in a degraded form. - Provenance, attached to every result: source, version,
computed_at,as_of, event count, and the age of the newest input event. An agent handed a bare number cannot tell a fresh profile from a stale one. - Audit, appended for allows and denials. A log that records only successes cannot prove anything about the failures. It stores field names, never values — an audit trail full of payloads is a second, less guarded copy of the data it exists to police.
The MCP tools are deliberately thin wrappers with no database access of their own. A test
parses mcp_server.py and fails if a second read path ever appears, because the whole
guarantee rests on there being exactly one.
The schema is the privacy policy
As far as a model is concerned, a field that is not in the output schema does not exist. It cannot be requested, inferred or prompted out. So the tools return closed Pydantic models rather than dicts, and the tests assert against the schema rather than a sample response: a response can happen to be clean, while a schema is a standing promise.
Running it
Python 3.11+. No installation needed beyond the dependencies.
pip install pydantic "mcp>=1.2" pytest pytest-asyncio
# make the package importable (or: pip install -e .)
export PYTHONPATH=src # PowerShell: $env:PYTHONPATH="src"
# 1. generate the synthetic stream (deterministic: seed 7)
python -m traveler_access.generate
# 2. load it and build profiles
python -m traveler_access.pipeline load --reset
python -m traveler_access.pipeline build
python -m traveler_access.pipeline status
# 3. serve one traveler through the governed path
python -m traveler_access.pipeline show trv_003
python -m traveler_access.pipeline show trv_010 # DENIED: AI access revoked
python -m traveler_access.pipeline audit -n 5
# 4. the tests
python -m pytest -q
The demo
python demo/ask_claude.py --dry-run # no API key needed
python demo/ask_claude.py # needs ANTHROPIC_API_KEY
For the second one, copy .env.example to .env and put the key there. .env is
gitignored and only the demo reads it; the pipeline and the tests never need a key.
Both modes launch the MCP server as a real subprocess and speak to it over stdio. The difference is only whether a model chooses the tool calls.
The question asked is "What kind of trip is this traveler likely planning?", for two
travelers: trv_003, who consented, and trv_010, who revoked AI access. Every tool
call and result is printed, then the whole conversation — everything the model was shown
and everything it wrote — is checked against all 36 PII values in the dataset, and the
audit trail the server subprocess wrote is printed.
demo/transcript.md is a recorded run, so the governance is
reviewable without an API key. It also shows the raw event payload containing the email
next to the complete tool response that does not.
trv_003 searches Barcelona and Rome from ARN, two adults, 3.8 nights on average,
premium economy, booked about 37 days ahead, with a live booking departing after the
as-of instant. Short European city breaks for a couple, planned over a month in advance.
trv_010 returns AI_ACCESS_REVOKED from every tool, and the system prompt tells the
model that a denial is a final answer rather than an error to route around.
Other travelers worth looking at
| Traveler | Shows |
|---|---|
trv_003 |
The happy path, plus an out-of-order delivery and a late-arriving search |
trv_010 |
Opt-out, followed by a replayed older opt-in that must not reinstate access |
trv_011 |
Consent for a narrow purpose only → PURPOSE_NOT_PERMITTED |
trv_012 |
No consent record at all → NO_CONSENT_RECORD, the fail-closed path |
What the tests prove
99 tests, grouped by spec. Each is named after the acceptance criterion it covers.
| File | Tests | Covers |
|---|---|---|
test_event_stream.py |
13 | Determinism, bitemporal ordering, duplicates, nothing after NOW |
test_profile_builder.py |
16 | Idempotency under replay, correctness under reordering, checkpointing |
test_point_in_time.py |
15 | Both leakage classes, window boundaries, as-of carriage |
test_access_layer.py |
36 | Consent in all four states, PII leak injection, provenance, audit |
test_mcp_tools.py |
19 | Tool schemas, denial envelopes, single read path, a real stdio session |
Tests that assert a control works are cheap to write and easy to fool, so three of them were checked by deliberately breaking the control and confirming the right tests failed:
| Mutation | Expected failures | Got |
|---|---|---|
Neutralise the ingested_at filter |
knowledge-leak tests only | C2 ×3, C6 ×2 |
| Replace per-field watermarks with one profile-wide watermark | the cross-event-type tests | B5, B3-regression |
| Evaluate consent as-of instead of at read time | the consent-rewind tests | D5 ×2, D4 |
Two bugs the tests actually caught
Both of these were written by me, found by a test, and fixed — they are in here because "the tests pass" is only interesting if the tests were capable of failing.
A profile-wide version watermark looked equivalent to per-field watermarks, and was
not. Searches and bookings write overlapping but different field sets. With one
watermark per profile, a search at version 10 delivered before a booking at version 4
marks the booking stale — so the booking never sets preferred_currency, a field no
search ever writes. The column stays NULL forever and nothing reports an error.
Criterion B5 caught it; the fix was a field_versions JSON column, one watermark per
field, and spec 002 now says why.
utc(datetime.now()) labelled local time as UTC instead of converting it. The helper
treats a naive datetime as already-UTC, which is right for parsing stored timestamps and
wrong for the system clock. Every computed_at and the as_of fallback were off by the
machine's timezone offset — invisible on a UTC host, five hours wrong on mine. It survived
the whole test suite and was caught by eye when the demo printed a provenance timestamp
five hours behind the audit record beside it. There is now a regression test, and the one
correct way to read the clock lives in events.now().
Limitations
Things I deliberately did not build, and would need to before this went near production:
- Preferences are latest-state, not point-in-time.
get_traveler_preferencesreads the current profile; onlyget_recent_searcheshonoursas_of. Making preferences as-of-able means storing profile history, which is a real design decision, not an afterthought. list_travelerswould not exist. Letting an agent enumerate customers is a demo affordance for making the consent cases visible. It is marked as such in the tool description.- Consent is a boolean plus a purpose list. Real consent has jurisdictions, expiry, withdrawal propagation to downstream copies, and lawful bases that are not consent at all.
- No identity resolution. One traveler, one id. The hard part of a customer data platform — deciding that two sets of events belong to the same person — is assumed away.
- SQLite and a single process. The idempotency argument relies on per-event transactions and a unique primary key, which is the same argument in Postgres, but the throughput story is not.
- The scrubber only knows PII it can recognise. Field names, this traveler's exact values, and email/phone shapes. A free-text note containing a surname would pass.
How I built this with AI tools
I used Claude Code throughout, and the division of labour matters more than the volume:
Written first, by hand, and not delegated: the problem framing, the scope, and the
acceptance criteria in specs/. Each spec was written before its implementation and is
what the implementation was checked against. Where the design changed, the spec changed
first.
AI-generated, then reviewed line by line: most of the implementation and most of the test bodies. The generated code was wrong in interesting ways twice — the profile-wide watermark and the UTC bug above — which is roughly the error rate I would expect from myself on first draft, and the reason the specs and tests came first rather than after.
Reviewed and rejected: several things that passed their tests but did not deserve to.
A leak-injection test originally mutated a Pydantic model's __dict__, which
model_dump() ignores, so it proved nothing until it was rewritten to declare a real
PII field. A point-in-time test chose an as_of outside the late event's in-flight
window, so it passed with the knowledge-time filter deleted. Both were found by breaking
the control on purpose and noticing the tests stayed green.
Hand-verified: the mutation table above, the four consent cases end to end, the MCP stdio session as a real subprocess, and the claim that no PII crosses the boundary.
I can explain every line in this repo, including the parts I did not type.
License
MIT — see LICENSE.