Retention and Egress Controls: Governing Agent Conversations at Scale
Tiered retention windows, pre-persistence redaction, deletion workflows, egress allowlists, residency, and audit evidence for agent conversation logs.
Published on • October 16, 2026
AI Assistant

Every conversation your agents handle becomes a transcript you now own, and that transcript is simultaneously your best training and evaluation asset and your single largest breach surface. It contains account numbers the customer typed, secrets pasted from a terminal, health details from a support rant, and the exact tool calls your agent made in response. Most teams keep all of it, forever, in whatever log store was easiest to wire up. This post is about the opposite stance: decide what you keep, how long, where it lives, and what is allowed to leave your boundary, and make each of those a policy you can enforce and prove.
The legal spine: minimization, storage limitation, accountability
You do not need to be an EU company for this to bite you, but the GDPR states the principles more crisply than most internal memos. Article 5 requires personal data to be “adequate, relevant and limited to what is necessary” (data minimisation) and “kept in a form which permits identification of data subjects for no longer than is necessary” (storage limitation), and makes the controller responsible for, and able to demonstrate, compliance (accountability). Article 17 gives the data subject a right to erasure “without undue delay” in defined circumstances, with explicit carve-outs such as compliance with a legal obligation and “the establishment, exercise or defence of legal claims”.
Read together, those three sentences dictate your architecture. You must be able to delete on demand, you must have a reason for every byte you retain, and you must be able to show both.
Tier the data: hot, replay, aggregate
The mistake is treating a conversation as one object with one lifetime. Split it into three tiers with different retention windows and different sensitivity:
| Tier | Contents | Window | Access |
|---|---|---|---|
| Hot | Full transcript, tool args and results, model outputs | 7-30 days | Debug tooling, on-call |
| Replay | Redacted transcript with identifiers tokenized | 30-180 days | Product, eval dataset builds |
| Aggregate | Counts, latency, outcome labels, no free text | Indefinite | Dashboards, drift monitoring |
Write it down as config so it is reviewable, not tribal:
# retention_policy.yaml
version: 3
default_tiers:
hot:
max_age_days: 14
fields: [full_text, tool_args, tool_results, model_output]
pii: raw_allowed
replay:
max_age_days: 180
fields: [redacted_text, tool_names, outcome]
pii: tokenized
aggregate:
max_age_days: null # indefinite, but contains no personal data by construction
fields: [counts, latency_ms, routing_label, verifier_verdict]
overrides:
acme-health:
residency: eu
hot_max_age_days: 7
hold: legal # litigation hold freezes deletion for this tenant only
The hold key matters: deletion workflows must respect legal holds, and Article 17(3) recognizes exactly that kind of exception. Encode it instead of discovering it during an audit.
The enforcement side is a scheduled job that walks the tiers and hard-deletes past the window, plus a tombstone record so you can prove a delete happened rather than merely asserting it:
from datetime import datetime, timedelta, timezone
from pydantic import BaseModel
RETENTION = {'hot_max_age_days': 14, 'version': '3'}
class Tombstone(BaseModel):
tenant_id: str
conversation_id: str
tier: str
deleted_at: datetime
reason: str # 'retention_expired' | 'subject_request' | 'superseded'
policy_version: str
def purge_hot(store, audit, now=None) -> list[Tombstone]:
now = now or datetime.now(timezone.utc)
cutoff = now - timedelta(days=RETENTION['hot_max_age_days'])
graves = []
for conv in store.iter_tier('hot'):
if conv.created_at < cutoff and not conv.legal_hold:
store.hard_delete(conv.id) # not a soft flag
graves.append(Tombstone(
tenant_id=conv.tenant_id,
conversation_id=conv.id,
tier='hot',
deleted_at=now,
reason='retention_expired',
policy_version=RETENTION['version'],
))
audit.append(graves)
return graves
Redact before you persist, not before you read
The cheapest place to remove a secret is the moment it enters the transcript, because every later copy (trace, cache, eval export, support screenshot) inherits the clean version. In Pydantic AI Harness, guardrails sit on the input edge and can replace rather than block, rewriting the prompt sent to the model:
from pydantic_ai import Agent
from pydantic_ai_harness import InputGuardrail
from pydantic_ai_harness.guardrails.detectors import (
redact_personal_data,
redact_secrets,
)
agent = Agent(
'openai:gpt-6-sol',
capabilities=[
InputGuardrail(guard=[redact_secrets, redact_personal_data]),
],
)
The detectors cover vendor API keys, tokens, private key blocks, emails, card numbers, IBANs, and US SSNs, and they rewrite instead of refuse so the agent keeps doing its job. Two operational details worth knowing before you ship this: a redaction reaches the run’s message history only if the guardrail chain finishes, so a later block leaves the original text in history, which is why you put the redactor last when that matters; and regex detectors are shape-matching, so an email pattern will mangle git@github.com in a code-handling agent. Narrow the patterns with personal_data(only=['us_ssn', 'credit_card', 'iban']) for agents that handle text or paths.
Keep a separate path for genuinely sensitive payloads: detect, tokenize to a stable surrogate ([email:7f3a]), and keep the mapping in a vault you can open for subject-access requests and close for everything else.
Deletion and subject-access workflows
Deletion is a distributed problem. The conversation exists in your message store, your observability backend, your eval datasets, your vector index, and any fine-tuning exports. Design the workflow as an orchestrated job with an explicit fan-out:
# one request, many stores, one evidence bundle
$ agent-gov subject-request delete \
--tenant acme-health \
--subject cus_123 \
--scope "conversations,embeddings,eval_cases,traces" \
--reason subject_request
> 148 conversations matched
> message_store .......... ok (148)
> vector_index ........... ok (148 embedded chunks removed)
> eval_datasets ........... skipped 6 (derived, re-export required)
> trace_backend .......... queued (vendor TTL 7 days)
> evidence bundle ........ request_9f2c.json
Two rules make this survivable. First, derive, do not copy: build eval datasets and fine-tuning exports from the replay tier on a schedule, so deleting upstream is a rebuild rather than a forensic hunt. Second, log the skips. eval_datasets: skipped above is a real answer your auditor will ask about; silence is not. For subject access, the same job runs in export mode and produces a bundle you hand over, and the export itself is an auditable event.
Egress controls: what leaves your boundary
Retention governs what you keep. Egress governs what escapes. For an agent, the outbound list is longer than people assume:
- Model calls: full prompts, including retrieved documents, go to your provider.
- Tool webhooks:
create_refundposts customer data to a third party. - Observability: OpenTelemetry spans from Pydantic AI instrumentation can carry message content to your tracing backend.
- Telemetry and analytics: SDK beacons, error reporters, feature flag clients.
Write an allowlist policy and enforce it in one outbound client rather than in every tool:
# egress_policy.yaml
default: deny
allow:
- host: api.openai.com
ports: [443]
contains: [prompt] # approved for prompt content
- host: "*.internal.example.com"
ports: [443]
contains: [tool_payload]
- host: otlp.eu.example.com
ports: [443]
contains: [traces]
redact: [free_text]
deny_reason_required: true
import httpx
class EgressClient(httpx.Client):
def __init__(self, policy: EgressPolicy, **kwargs):
super().__init__(**kwargs)
self.policy = policy
def request(self, method, url, **kwargs):
decision = self.policy.check(url, body=kwargs.get('content'))
if not decision.allowed:
raise EgressBlocked(f'{url} not in allowlist: {decision.reason}')
audit.record('egress.attempt', destination=url, verdict=decision.verdict)
return super().request(method, url, **kwargs)
If you route agent data through a platform like the Hugging Face Hub, the same idea ships as product: its Network Security feature lets an organization define outbound IP ranges in CIDR form, restrict organization access to those ranges only (rules apply to access tokens too), and layer a Content Access Policy that blocks specific repository types, orgs, or repositories with an “always allowed” exception list. Service account tokens can be exempted for automated workflows running outside the corporate network. Note that this is an Enterprise Plus feature, which is a useful reminder that egress control is a paid, first-class concern in the industry, not a checkbox you finish.
Per-tenant data residency
Residency is egress’s slow cousin: the data does not leave in a request, it just lives somewhere a regulator cares about. Hugging Face’s Storage Regions show the pattern: Team and Enterprise organizations choose where models, datasets, Spaces, and buckets are stored (US and EU today), non-Team repositories are always stored in the US, each repository in a non-default location carries a visible region tag, and the settings page shows an audit of repository locations. EU customers can therefore keep datasets, models, buckets, and inference endpoints in EU data centers for GDPR compliance.
Mirror it with a routing table your code cannot ignore:
RESIDENCY = {
'acme-health': 'eu',
'acme-bank': 'eu',
'default': 'us',
}
def route(tenant_id: str, purpose: str) -> Backend:
region = RESIDENCY.get(tenant_id, RESIDENCY['default'])
backend = REGIONS[region][purpose]
if tenant_id in RESIDENCY_OVERRIDES and backend.region != RESIDENCY_OVERRIDES[tenant_id]:
raise ResidencyViolation(tenant_id, backend.region)
return backend
Fail closed on mismatch. A residency rule that logs and continues is a press release waiting to happen.
Lean on attestations where you can: Hugging Face’s own security docs state that the Hub is GDPR compliant and SOC 2 Type 2 certified, and that GDPR data processing agreements are available through an Enterprise plan. Do the same math for every vendor in your egress list, and write the answer in the policy file instead of in a sales call.
Audit evidence you can actually produce
The HF Audit Logs are a good template because of their shape rather than their volume: every entry records who performed the action, what type of action, a description of the change, location and anonymized IP, and date and time, and the whole log exports as JSON at GET /api/organizations/{org}/audit-log/export, with the export itself recorded as its own event. Events follow a scope.action naming scheme (repo.create, org.settings.network.ip_restriction.enable), which makes filtering programmatic.
Design yours the same way and record the boring events, not just the breaches:
{
"scope.action": "retention.purge",
"tenant": "acme-health",
"actor": "scheduler:retention-worker",
"resource": "tier=hot, deleted=148",
"policy_version": "3",
"ip": "10.4.2.19",
"at": "2026-10-16T00:12:40Z"
}
Ship a monthly report generated straight from that log: purges executed, subject requests honored within SLA, egress denials, residency violations, and redaction hit rates. That report is the “able to demonstrate compliance” half of Article 5(2).
Key Takeaways
- Conversation logs are your most valuable eval source and your biggest breach surface; tier them into hot, replay, and aggregate with separate retention windows.
- Redact secrets and PII at ingress with replace-style guardrails, because every downstream copy inherits whatever you persist.
- Build subject-access and erasure as one fan-out job with an evidence bundle, and record the stores you could not scrub instead of hiding them.
- Enforce egress with a default-deny allowlist in a single outbound client; remember telemetry and observability count as egress.
- Residency needs a routing table that fails closed, plus visible region tagging like HF Storage Regions.
- Audit logs should use
scope.actionevent names, export as JSON, and record the export itself.
References
- https://huggingface.co/docs
- https://huggingface.co/docs/hub/en/storage-regions
- https://huggingface.co/docs/hub/en/enterprise-network-security
- https://huggingface.co/docs/hub/en/audit-logs
- https://huggingface.co/docs/hub/security
- https://gdpr-info.eu/art-5-gdpr/
- https://gdpr-info.eu/art-17-gdpr/
- https://pydantic.dev/docs/ai/harness/guardrails/