Access Control Layer Pattern: Technical Deep Dive

Merivant — 2026

Architecture Spec — Partial Implementation

This 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:


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.

┌─────────────────────────────────────────────────────────┐ │ Your Application │ │ │ │ User input ─── apply() ───┐ │ │ │ ┌──────────────────┐ │ │ ├───▶│ LLM Provider │ │ │ │ │ (cloud / local) │ │ │ │ └──────┬───────────┘ │ │ User output ◀─ rehydrate() ◀──────────┘ │ │ │ │ ┌───────────────────┐ ┌───────────────────────────┐ │ │ │ Registry (YAML) │ │ Placeholder Map (in-mem) │ │ │ │ field types, │ │ <> → "Phoenix" │ │ │ │ patterns, rules │ │ <> → "Alice" │ │ │ └───────────────────┘ └───────────────────────────┘ │ └─────────────────────────────────────────────────────────┘

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.

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:

  1. Pattern. Regex-based. Fast, deterministic, zero false negatives for structured data (SSNs, emails, phone numbers). Run first.
  2. 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.
  3. 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.

Input text │ ▼ ┌──────────────────────┐ │ Layer 1: Patterns │ SSN, email, phone, credit card │ (regex) │ Fast. Deterministic. No misses on structured data. └──────────┬───────────┘ ▼ ┌──────────────────────┐ │ Layer 2: Lists │ Project names, client names, codenames │ (exact match) │ Loaded from CSV/YAML. No false positives. └──────────┬───────────┘ ▼ ┌──────────────────────┐ │ Layer 3: NER │ Unknown persons, organizations, locations │ (model-based) │ Catch-all. May flag non-sensitive entities. └──────────┬───────────┘ ▼ Protected text → LLM

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

  1. Define your registry: list the field types you need to protect (start with pattern-based: SSN, email, phone).
  2. Load your lists: add project names, client names, and personnel to CSV files referenced by the registry.
  3. Wrap your inference call: insert apply() before the API call and rehydrate() after.
  4. Check the audit log: verify that replacements are happening and that the counts match your expectations.
  5. Test multi-turn: send a conversation with repeated entity references. Confirm the same entity gets the same placeholder across turns.
  6. Test streaming: if your application streams responses, verify the StreamingRehydrator restores values without visible placeholder flicker.
← Research paper — The conceptual overview: why the access control layer exists, what problem it solves, and how it compares to existing approaches.

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.