DLP — Data Loss Prevention
The DlpInterceptor SPI is stable. RuleBasedDlpInterceptor, DlpRedactionAuditEmitter, and AuditEngineDlpRedactionAuditEmitter are still evolving. Configuration APIs may change in minor releases.
TramAI's DLP (Data Loss Prevention) system intercepts model outputs and tool results before they reach downstream consumers, structured parsers, or cache storage. It applies configurable redaction rules to prevent sensitive data — PII, secrets, credentials — from leaving the AI integration boundary.
Package: dev.tramai.core.security (SPIs), dev.tramai.security (RuleBasedDlpInterceptor, AuditEngineDlpRedactionAuditEmitter)
What
The DLP system is built on four layers:
DlpInterceptor— afun interface:fun inspect(context: DlpContext, text: String): DlpResultRuleBasedDlpInterceptor— the built-in regex implementation withRuleBasedDlpConfigurationandDlpRuleDlpRedactionAuditEmitter— afun interfacefor recording redaction events:suspend fun emit(context: DlpContext, redactions: List<DlpRedaction>)DlpInspectionException— thrown when inspection fails; distinct from provider failures, does not count toward circuit breakers, does not trigger retry
DLP Execution Point
The engine applies DLP at the earliest-safe boundary in callProviderWithRetries():
interceptResponse() → DLP inspect (if configured) → onProviderResponse()
The DLP sanitization happens after response interceptors have transformed the raw provider response and before:
onProviderResponse()observation- Structured output parsing
- Cache storage
- The result reaching the application
For tool results, DLP is applied during message reinjection via sanitizeToolMessageForReinjection().
Cache Semantics
Cache eligibility checks the DLP interceptor type. If the interceptor is not NoOpDlpInterceptor, caching is disabled (isSafeCacheEligible requires dlpInterceptor === NoOpDlpInterceptor). This ensures redacted results are never cached and served to subsequent operations that might not apply the same redaction rules.
DLP Failure Model
DLP failures are explicitly not provider failures:
- The circuit breaker is NOT triggered
- The failure observer (
onProviderFailure) is NOT called - No retry is attempted (DLP is deterministic per response)
- No fallback is executed
- The error propagates immediately to the caller via
DlpInspectionException
When to Use
Use DLP when:
- You need to redact PII (emails, phone numbers, SSNs, credit cards) from model outputs
- You need to redact secrets (API keys, tokens, credentials) from tool results
- You need provider-agnostic redaction that works across all model providers
- You need content-type-aware rules (different rules for model output vs. tool results)
- You need tool-scoped rules (redact only for specific tools)
- You need audit logging of redaction events with per-rule counts
Do not use DLP for input validation — that is the responsibility of the application layer before the TramAI call.
Quickstart
Kotlin DSL
val tramai = Tramai {
provider(openAiProvider, name = "openai", default = true)
model("gpt-4o", "openai")
dlp(
RuleBasedDlpInterceptor(
RuleBasedDlpConfiguration(
maxTextLength = 100_000,
rules = listOf(
DlpRule(
id = "email",
pattern = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
replacement = "[EMAIL REDACTED]",
enabledFor = setOf(DlpContentType.MODEL_OUTPUT),
),
DlpRule(
id = "ssn",
pattern = "\\d{3}-\\d{2}-\\d{4}",
replacement = "[SSN REDACTED]",
enabledFor = setOf(DlpContentType.MODEL_OUTPUT, DlpContentType.TOOL_RESULT),
),
),
),
),
)
}
Sovereign Builder
SovereignTramai.builder()
.profile(profile)
.modelRegistry(registry)
.auditStore(auditStore)
.provider(ollamaProvider, name = "ollama", default = true)
.model("llama3.2", "ollama")
.dlp(
RuleBasedDlpInterceptor(
RuleBasedDlpConfiguration(
rules = listOf(
DlpRule(
id = "api-key",
pattern = "sk-[a-zA-Z0-9]{32,}",
replacement = "[API KEY REDACTED]",
),
),
),
),
)
.build()
Spring Boot
Define exactly one DlpInterceptor bean. Multiple beans cause a fail-fast startup error:
@Bean
fun dlpInterceptor(): DlpInterceptor = RuleBasedDlpInterceptor(
RuleBasedDlpConfiguration(
rules = listOf(
DlpRule(id = "email", pattern = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"),
),
),
)
Define at most one DlpRedactionAuditEmitter bean:
@Bean
fun dlpRedactionAuditEmitter(): DlpRedactionAuditEmitter =
AuditEngineDlpRedactionAuditEmitter(auditEngine)
Rule Configuration Reference
DlpRule Fields
| Field | Required | Default | Description |
|---|---|---|---|
id | Yes | — | Unique rule identifier (normalized via DlpRuleIdNormalizer.normalize()) |
pattern | Yes | — | Java regex pattern (compiled via Pattern.compile()) |
replacement | No | "[REDACTED]" | Replacement text (uses Matcher.quoteReplacement()) |
enabledFor | No | setOf(DlpContentType.MODEL_OUTPUT) | Content types this rule applies to |
toolNames | No | emptySet() (all tools) | Restrict rule to specific tool names |
RuleBasedDlpConfiguration Fields
| Field | Required | Default | Description |
|---|---|---|---|
rules | No | emptyList() | Ordered list of DLP rules |
maxTextLength | No | 100_000 | Maximum input text length (1 to 10,000,000) |
Validation
At construction, RuleBasedDlpInterceptor validates:
maxTextLengthmust be 1..10,000,000- Duplicate rule IDs (after normalization) are rejected
- Each pattern must be non-blank and valid regex
- Each rule must have at least one enabled content type
- Tool names must not be blank or have surrounding whitespace
- Zero-width matches are rejected at runtime
- Replacements that preserve matched content are rejected (case-insensitive check)
DLP SPI Reference
Custom DlpInterceptor
Implement the fun interface directly:
class CustomDlpInterceptor : DlpInterceptor {
override fun inspect(context: DlpContext, text: String): DlpResult {
val sanitized = text.replace(Regex("my-sensitive-pattern"), "[REDACTED]")
return DlpResult(
sanitizedText = sanitized,
redactions = listOf(DlpRedaction(ruleId = "custom", replacementCount = 1)),
)
}
}
DlpContext
Inspection context with metadata about the operation:
| Field | Type | Description |
|---|---|---|
contentType | DlpContentType | MODEL_OUTPUT or TOOL_RESULT |
contentLocation | DlpContentLocation | Exact boundary: MODEL_RESPONSE_CONTENT, TOOL_MESSAGE_CONTENT, TOOL_MESSAGE_TEXT_RUN |
operationInterface | String | The @AiService interface name |
operationMethod | String | The method name |
providerId | String? | Provider that produced the output |
modelName | String? | Model that produced the output |
toolName | String? | Tool being executed (for tool results) |
correlationId | String | Operation correlation ID |
workflowRunId | String? | Workflow run ID (if in orchestration context) |
dataClassification | DataClassification? | Classification of the data |
classificationSource | ClassificationSource? | Source of the classification |
DlpResult
| Field | Type | Description |
|---|---|---|
sanitizedText | String | The redacted text (authoritative output) |
redactions | List<DlpRedaction> | Per-rule redaction evidence (rule ID + count) |
hasRedactions | Boolean | Convenience: redactions.isNotEmpty() |
DlpRedaction
Contains ruleId (String) and replacementCount (Int). Deliberately excludes raw matched values and input text to prevent accidental sensitive data leakage through audit or exception paths.
DlpInspectionException
class DlpInspectionException(
message: String = "DLP inspection failed",
cause: Throwable? = null,
) : RuntimeException(message, cause)
Redaction Audit Bridge
DlpRedactionAuditEmitter SPI
fun interface DlpRedactionAuditEmitter {
suspend fun emit(context: DlpContext, redactions: List<DlpRedaction>)
}
The engine calls emit() after a successful authoritative DLP inspection that produced redactions. If emission fails, the failure is wrapped in a DlpInspectionException.
NoOpDlpRedactionAuditEmitter
Default no-op implementation that silently discards all audit events. Used when no audit emitter is configured.
AuditEngineDlpRedactionAuditEmitter Implementation
Bridges DLP redactions to the sovereign audit engine (in tramai-security):
val auditEngine = AuditEngine(store = auditStore, clock = clock)
val dlpAuditEmitter = AuditEngineDlpRedactionAuditEmitter(auditEngine)
SovereignTramai.builder()
// ...
.dlp(dlpInterceptor)
.dlpRedactionAudit(dlpAuditEmitter)
.build()
Each redaction emits an audit event with:
- Enforcement point:
"DLP_MODEL_OUTPUT"or"DLP_TOOL_RESULT"depending on content type - Decision:
"REDACTED" - Reason code:
"dlp_redaction_applied" - Metadata: contentType, contentLocation, operationInterface, operationMethod, ruleId, replacementCount, providerName, modelName, toolName, classification, classificationSource
The emitter also supports a custom DlpAuditStreamIdResolver for controlling audit stream IDs.
Error Handling
DLP failures throw DlpInspectionException. The engine's catch ordering for DLP inspection is:
try {
// DLP inspection
} catch (e: CancellationException) {
throw e // coroutine cancellation passes through
} catch (e: DlpInspectionException) {
throw e // DLP failures propagate directly
} catch (e: Exception) {
// Wrap unexpected failures
throw DlpInspectionException("DLP inspection failed", e)
}
Application-level handling:
try {
val result = service.analyze(text)
} catch (e: DlpInspectionException) {
// DLP failure — text could not be inspected; circuit breaker NOT triggered
}
Cross-Boundary Detection
The DLP system detects sensitive text across two boundaries during tool result sanitization:
- Aggregate text limits — total text submitted for inspection per tool
- Sanitized text limits — size of text after redaction
Both limits are enforced by the engine's toolResultFilteringSettings.maxAggregateTextLengthForTool(toolName). Exceeding either limit throws DlpInspectionException with a descriptive message and emits a tramai.dlp.tool_result_rejected engine event.
Engine events are also emitted for cross-boundary sensitive text detection and inspection failures.
Limitations
- Regex-only — pattern-based redaction only; no ML/NER-based detection
- No input interception — DLP applies to model outputs and tool results, not to user inputs
- Cache bypass for custom interceptors — any non-
NoOpDlpInterceptordisables caching - Redaction evidence excludes raw values —
DlpRedactioncarries rule ID and count only (by design, to prevent leakage) - Deterministic, no retry — DLP failure is immediate and non-recoverable for that response
Minimum Configuration
Tramai {
dlp(RuleBasedDlpInterceptor(RuleBasedDlpConfiguration()))
}
A RuleBasedDlpInterceptor with an empty rule list passes all text through unmodified — equivalent to NoOpDlpInterceptor but with rule validation overhead.
Next Steps
- Approval Workflows — add human-in-the-loop gates
- Artifact Verification — verify model integrity
- Evidence Packs — generate deployment evidence
