Access Control Layer Pattern: Technical Deep Dive
Architecture Spec — Partial ImplementationThis page is for engineering teams evaluating the access control layer pattern for implementation. It covers the data flow, core algorithms, registry configuration, streaming behavior, audit output, failure modes, and integration scenarios. For the conceptual overview, see the research paper.
What's shipped vs. what's roadmap. The core algorithm (typed placeholders, bidirectional map, streaming rehydration, audit logging) is implemented and in production. The three-layer detection model described below is partially complete:
- Layer 1: Patterns — Shipped. Regex-based detection for SSNs, credit cards, phone numbers, email addresses, dates of birth, ZIP codes, passport/driver's license numbers, medical IDs (MRN, NPI, DEA, Medicare/Medicaid), insurance IDs, prescription numbers, medical codes (ICD-10/CPT), API keys, bearer tokens, AWS keys, GitHub/Slack tokens, PEM blocks, street addresses (common US formats), and P.O. boxes.
- Layer 2: Lists — Roadmap. CSV-loaded exact-match lists for known entities (project names, client names, codenames). Currently supported via manual entries in
sensitive.yaml. - Layer 3: NER — Roadmap. Local named-entity recognition as a catch-all for unstructured PII. This is the layer that would catch freeform references like contextual street addresses and names not in a list.
Architecture overview
The access layer sits inside your application process. It wraps the inference call — no sidecar, no proxy, no external service. Two functions handle the full lifecycle: apply() strips sensitive values on the way out, rehydrate() restores them on the way back.
The placeholder map is scoped to the session. Each conversation maintains its own map, so <<PERSON_0>> in one session is independent from <<PERSON_0>> in another. The map lives in memory — never written to disk, never sent to the provider.
Core algorithm
Typed placeholders
The access layer does not use generic [REDACTED] tokens. Each sensitive value is replaced with a typed, indexed placeholder that tells the LLM what kind of thing it is working with, without revealing the actual value.
Format: <<TYPE_N>> where TYPE is the field category and N is a zero-based index within that category.
<<PERSON_0>>,<<PERSON_1>>— the LLM knows these are two different people<<PROJECT_0>>— the LLM knows this is a project name<<EMAIL_0>>— the LLM knows this is an email address<<SSN_0>>— the LLM knows this is a social security number
This is the key difference from traditional redaction. When two client names are both replaced with [REDACTED], the LLM cannot distinguish them. With typed placeholders, <<CLIENT_0>> and <<CLIENT_1>> are unambiguously different entities. The LLM can reason about their relationship, compare them, and reference each one correctly in its response.
The placeholder map
The map is a bidirectional dictionary that grows as new sensitive values are encountered during a session.
getOrCreatePlaceholder
function getOrCreatePlaceholder(
value: string,
type: string,
map: PlaceholderMap
): string {
// If we've already seen this exact value, return the same placeholder.
// This ensures consistency across a multi-turn conversation.
const existing = map.forward.get(value);
if (existing) return existing;
// Assign the next index for this type.
const index = map.counters.get(type) ?? 0;
map.counters.set(type, index + 1);
const placeholder = `<<${type}_${index}>>`;
map.forward.set(value, placeholder);
map.reverse.set(placeholder, value);
return placeholder;
}
The forward map (value → placeholder) is used during apply(). The reverse map (placeholder → value) is used during rehydrate(). Both are keyed on exact string matches.
apply()
Scans the input text against the registry's field definitions and replaces matches with placeholders.
apply — outbound protection
function apply(text: string, registry: Registry, map: PlaceholderMap): string {
let result = text;
for (const field of registry.fields) {
// Each field defines a type (e.g., "PERSON") and a match strategy:
// - "pattern": regex-based (SSN, email, phone)
// - "list": exact match against a known list (project names, client names)
// - "ner": named entity recognition (see "Layered detection" below)
const matches = findMatches(result, field);
// Sort by position descending so replacements don't shift indices.
matches.sort((a, b) => b.start - a.start);
for (const match of matches) {
const placeholder = getOrCreatePlaceholder(match.value, field.type, map);
result = result.slice(0, match.start) + placeholder + result.slice(match.end);
}
}
return result;
}
rehydrate()
Scans the LLM's response for placeholder tokens and restores the original values.
rehydrate — inbound restoration
function rehydrate(text: string, map: PlaceholderMap): string {
// Match all <<TYPE_N>> tokens in the response.
return text.replace(/<<([A-Z_]+_\d+)>>/g, (token) => {
return map.reverse.get(token) ?? token;
});
}
If the LLM invents a placeholder that is not in the map (e.g., <<PERSON_5>> when only indices 0–3 exist), the token passes through unchanged. This is a deliberate safety choice: unknown tokens surface visibly rather than silently corrupting the output.
Registry configuration
The registry defines what the access layer protects. It is a YAML file that lists field types, match strategies, and optional constraints.
access-layer-registry.yaml
version: 1
fields:
- type: SSN
strategy: pattern
pattern: '\b\d{3}-\d{2}-\d{4}\b'
description: US Social Security numbers
- type: EMAIL
strategy: pattern
pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
description: Email addresses
- type: PHONE
strategy: pattern
pattern: '\b\d{3}[-.]?\d{3}[-.]?\d{4}\b'
description: US phone numbers
- type: PERSON
strategy: list
source: contacts.csv # loaded at startup
column: full_name
description: Known people
- type: CLIENT
strategy: list
source: clients.csv
column: company_name
description: Client organizations
- type: PROJECT
strategy: list
source: projects.csv
column: project_name
description: Internal project names
- type: ENTITY
strategy: ner
model: default # built-in NER, no external call
categories: [PERSON, ORG, LOCATION]
description: Catch-all for entities not in lists
Three match strategies, in order of priority:
- Pattern. Regex-based. Fast, deterministic, zero false negatives for structured data (SSNs, emails, phone numbers). Run first.
- List. Exact-match against a loaded dataset. Used for known entities that do not follow a structural pattern — project names, client names, internal codenames. Loaded at startup, reloaded on file change.
- NER. Named entity recognition as a catch-all. Catches names and organizations that are not in the list. Slower, may produce false positives. Run last, only on text that was not already matched by pattern or list.
Layered detection
The three strategies compose into a defense-in-depth model. Each layer catches what the previous layer missed.
Each layer only processes text that was not already replaced by a prior layer. This prevents double-matching and keeps the NER layer focused on genuinely unrecognized entities.
Why not just use NER for everything? NER models are probabilistic. They miss structured identifiers (SSNs that look like numbers, not names) and are slower than regex. Patterns handle the deterministic cases; NER handles the ambiguous ones. The combination is more reliable than either alone.
Streaming support
LLM providers return responses as a stream of tokens. A placeholder like <<PROJECT_0>> arrives as multiple tokens: <<, PROJECT, _0, >>. The access layer cannot rehydrate a partial placeholder.
The streaming buffer solves this by accumulating tokens when a potential placeholder opening is detected:
Streaming token buffer
class StreamingRehydrator {
private buffer = "";
private map: PlaceholderMap;
constructor(map: PlaceholderMap) {
this.map = map;
}
/** Feed a token, get back zero or more output chunks. */
push(token: string): string[] {
this.buffer += token;
const output: string[] = [];
while (this.buffer.length > 0) {
// Complete placeholder found — rehydrate and emit.
const match = this.buffer.match(/^(<<[A-Z_]+_\d+>>)/);
if (match) {
const original = this.map.reverse.get(match[1]) ?? match[1];
output.push(original);
this.buffer = this.buffer.slice(match[1].length);
continue;
}
// Partial placeholder opening — hold in buffer, wait for more tokens.
if (/<$|<<$|<<[A-Z_]*$|<<[A-Z_]+_\d*$/.test(this.buffer)) {
break;
}
// No placeholder in progress — emit the first character and continue.
output.push(this.buffer[0]);
this.buffer = this.buffer.slice(1);
}
return output;
}
/** Flush any remaining buffer at end of stream. */
flush(): string {
const remaining = this.buffer;
this.buffer = "";
return remaining;
}
}
This introduces minimal latency. Most tokens pass through immediately. Only tokens that could be the start of a placeholder are buffered, and the buffer is at most ~20 characters long (the length of the longest placeholder token).
Audit trail
Every apply() call produces an audit entry. The entries log what happened without recording what the values were.
Audit log entry
{
"timestamp": "2026-03-07T14:32:01.447Z",
"action": "access_layer.apply",
"session": "a1b2c3",
"replacements": [
{ "type": "SSN", "count": 1 },
{ "type": "PERSON", "count": 2 },
{ "type": "PROJECT", "count": 1 }
],
"inputChars": 847,
"outputChars": 851,
"layerStats": {
"pattern": { "matched": 1, "ms": 0.3 },
"list": { "matched": 3, "ms": 0.8 },
"ner": { "matched": 0, "ms": 12.1 }
}
}
The audit log contains no sensitive data. It records counts and types, not values. This means the log itself can be shipped to external monitoring systems (SIEM, Splunk, Datadog) without creating a secondary data exposure in your security infrastructure.
The rehydrate() step also logs:
{
"timestamp": "2026-03-07T14:32:03.112Z",
"action": "access_layer.rehydrate",
"session": "a1b2c3",
"restored": 4,
"unrecognized": 0,
"outputChars": 1203
}
An unrecognized count greater than zero means the LLM invented placeholder tokens that were not in the map. This is a signal to review the response — the LLM may have hallucinated a reference to a non-existent entity.
Performance characteristics
| Metric | Pattern | List (10k entries) | NER |
|---|---|---|---|
| Latency per 1k chars | < 1ms | ~2ms | ~15ms |
| False positive rate | ~0% | ~0% | ~3–5% |
| False negative rate | ~0%* | Depends on list | ~2–8% |
| Memory overhead | Negligible | ~1MB per 10k | Model-dependent |
* Pattern false negatives are near zero for well-defined formats (SSN, email). They do not catch freeform descriptions like "my social is the one I gave the bank."
For a typical request (500–2000 characters, pattern + list layers), the access layer adds under 5ms of latency. This is negligible compared to inference time (200ms–30s depending on provider and model). NER adds ~15ms but only runs on unmatched segments.
Failure modes
1. Unknown entity (false negative)
A sensitive value is not in the list, does not match a pattern, and the NER misses it. The value passes through to the LLM unprotected.
Mitigation: Maintain lists. Use NER as catch-all. Monitor audit logs for sessions with zero replacements on inputs that should contain sensitive data. Consider a review queue for high-risk conversations.
2. Over-matching (false positive)
The NER flags a non-sensitive word as an entity. "Spring" (the season) is replaced as <<ORG_3>> because the NER thought it was a company name.
Mitigation: Allowlists for common false positives. NER confidence thresholds. False positives are annoying but safe — they degrade quality, not security.
3. Placeholder leakage in response
The LLM returns a placeholder that is not in the map. rehydrate() passes it through unchanged. The user sees <<PERSON_5>> in the response.
Mitigation: The unrecognized audit counter flags this. Optionally, strip unrecognized placeholders or replace with a generic label like "[unknown reference]".
4. Partial match corruption
A value appears as a substring of another value. Replacing "Al" (a person's name) inside "Algorithm" produces <<PERSON_0>>gorithm.
Mitigation: Word boundary anchoring in match logic. List entries are matched with \b boundaries by default. Short values (under 3 characters) require exact-match mode.
5. Map state loss
The in-memory placeholder map is lost (process crash, restart). Subsequent rehydrate() calls cannot restore values.
Mitigation: Persist the map to an encrypted session store. On restart, reload. The map is small (typically under 1KB per session) and contains the sensitive values, so it must be encrypted at rest.
End-to-end example
A complete request/response cycle showing the full data flow.
1. User input
"Can you draft a status update for the Acme Corp engagement? John Smith and Jane Doe are the primary contacts. John's email is jsmith@acme.com and his SSN is 123-45-6789."
2. After apply()
"Can you draft a status update for the <<CLIENT_0>> engagement? <<PERSON_0>> and <<PERSON_1>> are the primary contacts. <<PERSON_0>>'s email is <<EMAIL_0>> and his SSN is <<SSN_0>>."
Placeholder map state
forward: {
"Acme Corp" → "<<CLIENT_0>>"
"John Smith" → "<<PERSON_0>>"
"Jane Doe" → "<<PERSON_1>>"
"jsmith@acme.com" → "<<EMAIL_0>>"
"123-45-6789" → "<<SSN_0>>"
}
3. LLM response (raw)
"Status Update — <<CLIENT_0>> Engagement Met with <<PERSON_0>> and <<PERSON_1>> on Tuesday. <<PERSON_0>> confirmed the Q3 timeline. Action item: send updated SOW to <<EMAIL_0>>. Note: <<SSN_0>> on file — verify before onboarding."
4. After rehydrate()
"Status Update — Acme Corp Engagement Met with John Smith and Jane Doe on Tuesday. John Smith confirmed the Q3 timeline. Action item: send updated SOW to jsmith@acme.com. Note: 123-45-6789 on file — verify before onboarding."
The LLM referenced each entity correctly, distinguished between two people, and maintained the SSN reference — all without ever seeing the real values. The audit log recorded 5 replacements (1 CLIENT, 2 PERSON, 1 EMAIL, 1 SSN) and 0 unrecognized tokens on rehydrate.
Integration scenarios
Wrapping an OpenAI-compatible API
The access layer wraps the inference call. Any SDK that follows the OpenAI chat completions interface works.
import { createAccessLayer } from "./access-layer";
import OpenAI from "openai";
const client = new OpenAI();
const accessLayer = createAccessLayer("./access-layer-registry.yaml");
async function chat(userMessage: string, sessionId: string) {
// Protect outbound
const { text: protected, map } = accessLayer.apply(userMessage, sessionId);
// Call LLM with protected text
const response = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: protected }],
});
const raw = response.choices[0].message.content;
// Restore inbound
return accessLayer.rehydrate(raw, map);
}
Streaming responses
async function* chatStream(userMessage: string, sessionId: string) {
const { text: protected, map } = accessLayer.apply(userMessage, sessionId);
const rehydrator = new StreamingRehydrator(map);
const stream = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: protected }],
stream: true,
});
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content ?? "";
const restored = rehydrator.push(token);
for (const part of restored) {
yield part;
}
}
const remaining = rehydrator.flush();
if (remaining) yield remaining;
}
Multi-turn conversations
For multi-turn conversations, the same placeholder map is reused across turns. This ensures consistency: if "Acme Corp" was <<CLIENT_0>> in turn 1, it remains <<CLIENT_0>> in turn 5. The access layer's apply() function checks the existing map before creating new placeholders.
// The session map persists across turns.
const session = accessLayer.getSession(sessionId);
// Turn 1
const t1 = accessLayer.apply("Tell me about Acme Corp.", session);
// Turn 3 — same map, same placeholder for "Acme Corp"
const t3 = accessLayer.apply("What about the Acme Corp deadline?", session);
// t3.text contains <<CLIENT_0>> — same index as turn 1
Local inference (no access layer needed)
When using a local LLM that runs on your own hardware, the access layer is optional. The data never leaves your infrastructure, so there is no exposure risk. The access layer can still be useful for audit logging, but the protection layer is architecturally unnecessary.
Hybrid routing: A system that routes between cloud and local models can skip the access layer for local calls and activate it for cloud calls. The decision is per-request: high-intelligence tasks go to the cloud (through the access layer), routine tasks go to local models (direct). This combines cost efficiency with security.
Technical comparison
| Characteristic | Network DLP | Cloud proxy | Access Layer |
|---|---|---|---|
| Deployment | Gateway / agent | SaaS | In-process |
| Added latency | 10–50ms | 50–200ms | < 5ms |
| Streaming compatible | Partial | Varies | Yes |
| Reversible | No | No | Yes |
| Third-party data exposure | No | Yes | No |
| Config complexity | High (policies, rules, exceptions) | Medium (SaaS config) | Low (one YAML file) |
| Audit log contains secrets | Often | Often | Never |
| Works with any LLM provider | Provider-specific | Provider-specific | Yes |
Try it
- Define your registry: list the field types you need to protect (start with pattern-based: SSN, email, phone).
- Load your lists: add project names, client names, and personnel to CSV files referenced by the registry.
- Wrap your inference call: insert
apply()before the API call andrehydrate()after. - Check the audit log: verify that replacements are happening and that the counts match your expectations.
- Test multi-turn: send a conversation with repeated entity references. Confirm the same entity gets the same placeholder across turns.
- Test streaming: if your application streams responses, verify the
StreamingRehydratorrestores values without visible placeholder flicker.
Working with us
Merivant helps engineering teams implement the access control layer pattern for regulated workloads. We work at the architecture level: registry design, NER tuning, audit integration, streaming optimization, and the infrastructure patterns that make AI data protection sustainable in production.
If you are evaluating the access control layer for your system: request a working session.