Skip to content

Audit evidence and retention

An investigator, an auditor or a model validator must be able to answer: what did this agent do, in what order, with what inputs, which sources did it read, what did it answer, and which definition was running. The answer must be tamper-evident and kept for as long as your policy says.

Every agent run now produces a step record for each thing it did, in order. The records of a run are hash-chained, the chain is anchored in the tamper-evident security audit log within about a minute of the run ending, and the records are exported to your SIEM in the same schema as the rest of the audit evidence.

KindOne record perSummary fields
model_callModel callModel, finish reason, input, output and total tokens, number of tool calls requested
tool_callTool callTool, toolkit, status (ok or error) and the error
retrievalIndex or attachment searchIndex, query, filters, top-k, and each returned document’s id, source, score and rank
delegationSaved agent or pipeline called as a toolCalled agent, its type and model
approvalApproval requested when a run pauses, and the decision a resumed run carriesDecision
capLimit hit: output tokens, deadline, budget, context window, rate or size limitThe limit
errorThe failure a run ends onError code
outputFinal answerSize, and the retrieval steps and document ids the answer cites

Every record also carries the run id, its position (seq), the agent and its release and definition digest (see Agent lifecycle), the actor (user:<id>, or agent:<binding> for a governed agent), the pipeline node, and the start and end times.

Citations. An output step cites a retrieval step when the answer contains one of that retrieval’s document ids or sources verbatim (four characters or more). This is a textual match: it shows which retrieved sources the answer names, not which ones the model relied on.

Content is referenced, not stored in the record

Section titled “Content is referenced, not stored in the record”

Prompts, tool arguments, tool results and answers can be large and sensitive. A step record holds only a SHA-256 digest, the size and a reference for each side (inputs and outputs). The content itself is stored in the object store bucket run-evidence, under the run’s project and run id, with its own retention class, step_content.

  • Each side is capped at 256 KiB. Longer content is cut, and the record says truncated with the original size.
  • Minimise tool results with evidence_fields. A toolkit’s evidence_fields setting lists the top-level fields of a JSON tool result to keep (at most 64 names). Every other field is dropped before the result is stored, and the record says filtered. Without the setting the whole result is kept. A malformed value is refused when the toolkit is saved.
  • When retention deletes the content, the chain still verifies, because it covers the digests and not the content. A reader then sees expired, the date and the policy, not a gap.

Within a run, each record’s hash is SHA-256 over the previous record’s hash and the record’s canonical form, including the content digests. The database refuses any change to a stored record except marking its content expired. Within about a minute of the run ending, the run’s last hash and its step count are appended to the tenant’s security audit chain as an ai.elitea.security.v1.run.steps.anchored event, by the elitea-main routine main.run_step_anchor (every minute, listed under Administration → Schedules, paused in maintenance). Anchoring works on every install, whether or not the governed agent profile is on; a run whose anchor was missed is anchored by main.step_record_retention’s hourly pass.

Rewriting a run’s records therefore breaks either the run’s chain or its anchor in the security audit chain, which the SIEM copy also holds.

You need the runs.evidence.read permission. The super_admin and admin roles and the project admin role have it. There is no screen for step records yet; use the API. The steps are in Reconstruct an agent run.

RequestReturns
GET /api/v2/elitea_core/conversation_runs/prompt_lib/{project_id}/{conversation_uuid}The runs of a conversation, with the answer message id and step count of each
GET /api/v2/elitea_core/run_steps/prompt_lib/{project_id}/{run_id}The run’s head and its step records in order, with digests, never content. Pages with after_seq and limit (up to 1000).
GET /api/v2/elitea_core/run_steps/prompt_lib/{project_id}/{run_id}/verifyok, or the first seq that does not verify and why
GET /api/v2/elitea_core/run_steps/prompt_lib/{project_id}/{run_id}/{step_id}/{inputs|outputs}One side’s content, checked against its digest, or its expiry date and policy

Reading content is itself audited: each content read writes an audit trail row naming the reader, the run and the step before the content is returned. If that row cannot be written, the read is refused with 503. A token with no owning user cannot read content.

Step records are always on. What you choose:

  • Retention. Set step_content and step_record on the Data retention page (see Data classes). Both keep forever by default.
  • Minimisation. Set evidence_fields on toolkits whose results carry data you must not keep.
  • Export. When the audit export is configured, step records are exported as the run_steps stream, from the moment of the upgrade. Set ELITEA_AUDIT_EXPORT_RUN_STEPS=false to leave them out, or ELITEA_AUDIT_EXPORT_RUN_STEPS_FROM=beginning to also send records written before the export was turned on. The shipped lag, backlog and dead-letter alerts cover the stream.
  • Dashboard. The Helm chart ships a Grafana dashboard with runs, tool calls, errors and spend per agent, read from the step records. See Operations.

A step record as the audit export sends it (details abridged):

{
"schema": "elitea.audit.v1",
"source": "run_step",
"event_type": "run_step.retrieval",
"time": "2026-10-29T09:14:02.117Z",
"tenant": { "project_id": 31 },
"actor": { "type": "user", "id": "1187" },
"action": "retrieval",
"target": { "type": "retrieval", "id": "412", "name": "search_index" },
"correlation": { "correlation_id": "exec-7f3c…" },
"chain": { "partition": "run:exec-7f3c…", "sequence": 4, "prev_hash": "9b1e…", "hash": "c07a…" },
"details": {
"run_id": "exec-7f3c…",
"seq": 4,
"kind": "retrieval",
"application_id": 905,
"release_id": 12,
"definition_digest": "5d2f…",
"retrieval": {
"index_name": "policies",
"query": "travel expense limit",
"top_k": 5,
"documents": [{ "document_id": "travel-policy.pdf#3", "source": "travel-policy.pdf", "score": 0.82, "rank": 1 }]
},
"inputs": { "digest": "e3b0…", "size": 212, "ref": "run-evidence/31/runs/exec-7f3c…/…/inputs" },
"outputs": { "digest": "41aa…", "size": 6120, "ref": "run-evidence/31/runs/exec-7f3c…/…/outputs" }
}
}

The full schema is in Audit event schema.

  • A worker crash can lose recent steps. The worker sends records during the run (every 64 steps, or when the oldest unsent one is two seconds old) and the rest before the run settles. Records still in a worker’s memory when its process dies are lost, and the worker that takes the run over records a steps_possibly_lost error step. See Reconstruct an agent run for how such a gap shows.
  • Direct toolkit runs (running a tool from the toolkit page) are not agent runs and produce no step records.
  • A summarising index search (stepback_summary_index) returns a model answer rather than documents, so its retrieval step lists no documents.
  • Verification does not cross-check the anchor. The verify request checks the run’s own chain and reports how far it is anchored; to check the anchor itself, compare the run’s head hash with the run.steps.anchored event in your SIEM or with the security audit verifier.
  • No redaction pass yet. Identifiers in stored content are not masked automatically; use evidence_fields and the step_content retention to limit what is kept.
  • The immutable copy. The chain makes changes detectable, not impossible. Export the run_steps stream to a store with object lock (WORM) and keep the copy of record there.
  • Who may read content. Grant runs.evidence.read only to the roles your policy allows to see prompts and tool results, and review the content-read rows in the audit trail.
  • Minimisation choices. Decide which toolkits need evidence_fields, and the step_content maximum, from your data-protection assessment.

Every row written to the admin audit trail (centry.audit_events) since the upgrade is hash-chained in one platform-wide chain. A database trigger chains each row as it is inserted, so no writer can skip it, and a second trigger refuses any change to what a chained row’s hash covers. Rows written before the upgrade are not chained; verification counts them as unchained.

Retention still deletes old rows, but only from the start of the chain, and it records the last position and hash it deleted. Verification starts from that point, so a pruned chain still verifies.

To verify, call GET /api/v2/elitea_core/audit_chain_verify/administration with the models.admin.audit_trail.view permission. The answer has ok, how many rows were checked, the chain head, pruned_through, and the first break (its position, row id and reason) if any. Page a long chain with from and limit (default 100000). Exported admin audit events carry the same chain fields.

Chaining serialises audit inserts: one transaction writes an audit row at a time. Admin and security requests arrive at a low rate, so this costs little.

A regulated firm must keep records for the period its records schedule sets, no shorter and no longer. Data kept too long is a privacy and discovery liability. Data deleted too early breaks a regulatory or litigation duty. Before this control, conversations lived until a user deleted them, and the LLM gateway request log was kept for a fixed 92 days.

This control gives a platform administrator a retention policy per data class, enforced by background sweeps. A legal hold stops deletion of the conversations it covers.

What is enforced:

  • A maximum age per data class. Data older than the maximum is deleted in bounded batches.
  • Platform bounds. A project may set its own maximum for conversations, step content and step records only, and only inside the platform bounds.
  • Legal hold on a project, an agent or a user. While a hold is active, the retention sweeps skip every conversation, and every run’s step records and content, it covers, including the content expiry a conversation delete would cause. A user’s change that would remove or overwrite content of such a conversation is refused with HTTP 409: deleting the conversation, clearing its messages, deleting a message, deleting its attachments, moving message text into a canvas, and regenerating or editing a message.
  • Stopping a run is never refused because of a hold: stop is a safety control. Without a hold, a run stopped before it wrote any answer is removed together with its question. On a held conversation the stop keeps the question and records the turn with an empty, stopped answer, so the record still shows what was asked.

What is not enforced:

  • A minimum age does not stop a user from deleting their own conversation. It only bounds the shortest maximum a project may choose. Use a legal hold to stop a deletion.
  • A legal hold covers conversations and step records. It does not block deleting a whole project or a user account.
  • Attachments have no separate maximum. They are deleted with their conversation.
Data classDefault maximumBoundsProject overrideEnforced byWhat deletion removes
conversationKeep forever1 to 36500 daysYes, inside the platform boundselitea-main routine main.conversation_retention, hourlyThe conversation, its participants, messages, message items, attachment rows and pin, through the same statements as a user’s delete. Stored attachment bytes and their object records are deleted next by a journal drain, with retries.
attachmentKeep foreverKeep forever onlyNoDeleted with the conversationThe stored object and its elitea_storage.objects record.
gateway_request_log92 days1 to 36500 days; keep forever is refusedNoLLM gateway, hourly pruneRows of gateway.llm_request_logs and gateway.guardrail_verdicts. The gateway uses 92 days when it cannot read the policy.
admin_audit365 days7 to 36500 days, or keep foreverNoelitea-scheduler scheduler.audit_retentionRows of centry.audit_events, oldest first, and only as an unbroken prefix of the chain (see Admin audit trail chain).
step_contentKeep forever1 to 36500 days, or keep foreverYes, inside the platform boundselitea-main routine main.step_content_retention, hourlyThe stored inputs and outputs of step records. The record keeps its digests and says when and under which policy the content expired. Deleting a conversation also expires its runs’ content (policy conversation_deleted). The same routine deletes content that was stored for a step whose record never landed, an hour after it was written.
step_recordKeep forever7 to 36500 days, or keep foreverYes, inside the platform boundselitea-main routine main.step_record_retention, hourlyWhole runs whose last step is older than the maximum, once every export that currently sends run steps has delivered them. A receiver you removed, or a run_steps export you switched off, does not hold runs back.
security_auditKeep forever1 to 36500 days, or keep foreverNoelitea-scheduler scheduler.security_audit_outbox_retentionExported rows of the security audit outbox that the durable store already holds. The durable store is append-only and is never deleted from.

A conversation’s age is the time of its last message (its creation time when it has no messages). A conversation in use is not old because it was created long ago.

For admin_audit and security_audit, the deployment settings AUDIT_RETENTION_DAYS and SECURITY_AUDIT_OUTBOX_RETENTION_DAYS apply until an administrator saves the row on the Data retention page. After that, the saved row applies.

  1. Open Data retention

    In the admin area, open Data retention. You need the retention.manage permission. The super_admin and admin roles have it.

  2. Set the platform bounds

    For each data class, set the minimum and maximum age in days. Leave the maximum empty to keep forever. Save the row. The sweep applies the new value on its next pass.

  3. Allow project overrides

    Turn on Allow project override for conversation, step_content or step_record. A project override must be at least the platform minimum and at most the platform maximum. A value outside the bounds is refused.

  4. Watch the sweep

    Open Schedules & Tasks to see main.conversation_retention and the scheduler sweepers, their last run and outcome. You can pause a sweep there.

With the API, PUT /api/v2/admin/retention/policies/{data_class} replaces the row: send all of min_age_days, max_age_days (null keeps forever) and allow_project_override. A request that omits one is refused with 400; nothing is reset to a default.

If you make the platform maximum shorter, the shorter value also applies to every project whose override is now longer. The stored override is not changed.

A sweep deletes nothing while maintenance mode is on. The conversation sweep also deletes nothing when it cannot read the maintenance switch.

You need the legal_hold.manage permission (super_admin and admin).

  1. Open Data retention, then Legal holds, and select New hold.
  2. Select the scope and give the ids it needs:
    • Project: every conversation in the project.
    • Agent: every conversation in which the agent takes part. Give the agent’s own project id and the agent id.
    • User: every conversation the user owns.
  3. Enter the reason, for example the matter reference. The reason is required.
  4. To release the hold, select Release and add an optional reason. Deletion of what the hold covered resumes on the next sweep. A released hold stays in the list as a record.

Every policy change and every hold creation and release is written to the audit trail (centry.audit_events) by the admin audit middleware. See Audit trail. The actions are:

ActionWhen
retention.policy.updateA platform row is saved.
retention.override.setA project override is set.
retention.override.deleteA project override is removed.
legal_hold.createA hold is placed.
legal_hold.releaseA hold is released.

An example event, as the audit trail stores it:

{
"event_type": "legal_hold",
"action": "legal_hold.create",
"entity_type": "legal_hold",
"entity_id": 42,
"entity_name": "user 1187",
"user_id": 7,
"user_email": "records.admin@example.com",
"http_method": "POST",
"http_route": "/api/v2/admin/legal_holds",
"status_code": 201,
"timestamp": "2026-10-28T14:03:11Z"
}

The hold itself, with who created and released it and when, is in elitea_runtime.legal_holds. Rows are never deleted.

The sweeps report through OpenTelemetry metrics and one log line per pass:

MetricMeaning
retention.conversation.deletedConversations the sweep deleted.
retention.conversation.skipped_holdExpired conversations kept because a hold covers them.
retention.conversation.errorsFailures, by stage (select, delete, purge, drain, pass).
retention.conversation.lag_secondsHow far past its cutoff the oldest expired, unheld conversation is. A value that grows means the sweep does not keep up.
  • Your records schedule. Elitea does not know your legal retention periods. Set the maximum per data class to match your records schedule, and keep that decision in your own records.
  • Immutable storage. Elitea deletes data when the policy says so. It does not keep a write-once (WORM) copy. If you must keep an immutable record, export the audit trail and the security audit stream to your own SIEM or WORM store before the maximum age.
  • Legal holds come from your legal process. Elitea applies the holds an administrator places. It does not receive holds from a legal case system. Place and release holds as your legal team instructs.
  • Backups. Retention deletes from the live database and object store. Your database and object store backups keep deleted data for their own retention period. Align the backup retention with your records schedule.