Audit Trail
With the audit feature, a server records who did what to which task, in a
hash chain per tenant that shows if anything was changed, removed or
reordered afterwards. It is the record an incident responder reconstructs a
multi-agent run from, and the kind the EU AI Act asks a high-risk AI system
to keep: automatic logs over the system's lifetime (Article 12), kept at
least six months (Articles 19 and 26(6)), including how the system
interacted with other AI systems (Article 72(2)). The design, its threat
model and its measured cost are in
ADR 0015;
which articles it does and does not help with is in the
control map.
What is recorded
| Record | When | Carries |
|---|---|---|
call | every RPC ends — including ones authentication refused | method, outcome, the caller and how they authenticated, the trace |
run.started | an executor starts working on a task | task, context and message ids, digests of the message and each part, the caller, the trace |
task.event | the agent emits an event and it is stored | its position in the task's event log, the new state, a digest, and which run.started it belongs to |
task.cancel_requested | CancelTask reaches a running task | task, the caller, the trace |
call.started | a call is admitted, only in required mode | method, trace |
approval | the approval gate admits a decision | task, the approver, a digest of the action, requestId, decision, who asked (requestedBy); under a quorum, also required, approvedBy, reachedExecutor |
halt | an operator halts or resumes | the operator, action, scope (all or tenant) and, for a halt, reason and the ids of the tasks stopped |
subject.erased | erase_subject destroys a person's key | the operator and the records' random handles (sids) |
task.deleted, erasure, hold.placed, hold.released | see Erasure of tasks and Retention and legal holds | the task and its context for task.deleted; the operator for the others, with the scope and counts of an erasure and the reason of a hold.placed. Never content |
Content is recorded as a SHA-256 digest of its canonical JSON, never as text. A record proves what was said — hash the message you hold and compare — without being another copy of it.
Turning it on
#![allow(unused)] fn main() { fn card() -> a2a_protocol_types::agent_card::AgentCard { use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard, AgentInterface, AgentSkill}; AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])) .with_capabilities(AgentCapabilities::none().with_streaming(true)) .with_security_schemes(std::collections::HashMap::from([( "bearer".to_owned(), a2a_protocol_types::security::SecurityScheme::Http( a2a_protocol_types::security::HttpAuthSecurityScheme { scheme: "Bearer".to_owned(), description: None, bearer_format: None, }, ), )])) } use std::sync::Arc; use a2a_protocol_server::audit::{AuditLog, SqliteAuditStore}; use a2a_protocol_server::audit::record::{CheckpointSigner, SigningAlg}; use a2a_protocol_server::{BearerTokenAuthInterceptor, RequestHandlerBuilder}; struct MyAgent; a2a_protocol_server::agent_executor!(MyAgent, |_ctx, _q| async { Ok(()) }); async fn run() -> Result<(), Box<dyn std::error::Error>> { // The checkpoint key: PKCS#8 DER, made as shown below. Keep it out of the // database the records go to. let key = std::fs::read("/run/secrets/audit-ed25519.der")?; let signer = CheckpointSigner::from_pkcs8(SigningAlg::EdDsa, "audit-2026-10", &key)?; let store = SqliteAuditStore::new("sqlite:audit.db?mode=rwc").await?; let log = Arc::new( AuditLog::new(Arc::new(store)) // A signed checkpoint every 1,000 records of a tenant's chain. .with_signer(signer, 1_000), ); let handler = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card()) .with_interceptor(BearerTokenAuthInterceptor::with_labelled_tokens([ ("token-for-billing-agent", "billing-agent"), ])) .with_audit(Arc::clone(&log)) .build()?; let _ = handler; Ok(()) } }
Records name the caller only when authentication does: give each credential
a label (with_labelled_tokens, with_labelled_keys), or use JWT or mutual
TLS. The subject is the scheme and the name together: bearer:<label>,
api-key:<label>, jwt:<issuer>:<sub> (the issuer percent-encodes % and
:) or mtls:<name> (mtls:sha256:<fingerprint> unless
MutualTlsAuthInterceptor::with_names names the certificate);
auth::caller_id builds each. The recording interceptor is placed first in the
chain whatever order you add interceptors in, so refused calls are recorded
too. A refused call's outcome.error says why, with the label
a2a.server.errors uses: unauthenticated for a missing or invalid
credential, permission_denied for a valid one that does not permit the
operation (spec §3.3.2), and otherwise the A2A error's own label
(task_not_found, invalid_params, version_not_supported, …). Before
0.15.0 every refusal an interceptor made was recorded as protocol.
Stores: InMemoryAuditStore (tests, or a deployment that ships records
elsewhere), SqliteAuditStore with sqlite, PostgresAuditStore with
postgres. Replicas can share one PostgreSQL store; they interleave on a
tenant's chain without forking it.
Or as one profile
Profile::Auditable applies the audit trail and an approval gate in one
call. build() then refuses an incomplete configuration and names what is
missing:
- a log that is not required, which would serve calls it cannot record;
- a log that signs no checkpoints, whose chains could lose their end without trace;
- no authenticating interceptor, which leaves the records naming no caller;
- tasks isolated by caller, the default with an authenticating interceptor,
which would keep the gate's approver from the requester's task. Call
share_tasks_across_callers(), or set a gate that allows self-approval (see Human oversight).
#![allow(unused)] fn main() { fn card() -> a2a_protocol_types::agent_card::AgentCard { use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard, AgentInterface, AgentSkill}; AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) .with_description("An example agent") .with_skill(AgentSkill::new("echo", "Echo", "Echoes text").with_tags(["echo"])) .with_capabilities(AgentCapabilities::none().with_streaming(true)) .with_security_schemes(std::collections::HashMap::from([( "bearer".to_owned(), a2a_protocol_types::security::SecurityScheme::Http( a2a_protocol_types::security::HttpAuthSecurityScheme { scheme: "Bearer".to_owned(), description: None, bearer_format: None, }, ), )])) } use std::sync::Arc; use a2a_protocol_server::audit::{AuditLog, SqliteAuditStore}; use a2a_protocol_server::audit::record::{CheckpointSigner, SigningAlg}; use a2a_protocol_server::profile::Profile; use a2a_protocol_server::{BearerTokenAuthInterceptor, RequestHandlerBuilder}; struct MyAgent; a2a_protocol_server::agent_executor!(MyAgent, |_ctx, _q| async { Ok(()) }); async fn run(key: &[u8]) -> Result<(), Box<dyn std::error::Error>> { let log = AuditLog::new(Arc::new(SqliteAuditStore::new("sqlite:audit.db?mode=rwc").await?)) .with_signer(CheckpointSigner::from_pkcs8(SigningAlg::EdDsa, "audit-2026-10", key)?, 1_000) .require_record(true); let handler = RequestHandlerBuilder::new(MyAgent) .with_agent_card(card()) .with_interceptor(BearerTokenAuthInterceptor::with_labelled_tokens([("t", "billing-agent")])) .with_profile(Profile::Auditable(Arc::new(log))) .share_tasks_across_callers() .build()?; let _ = handler; Ok(()) } }
The checkpoint key
A hash chain shows that nothing in the middle changed. Only a signed checkpoint shows that nothing was cut off the end. Make a key with OpenSSL:
# Ed25519
openssl genpkey -algorithm ed25519 | openssl pkcs8 -topk8 -nocrypt -outform DER -out audit-ed25519.der
# or P-256 (ES256)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
| openssl pkcs8 -topk8 -nocrypt -outform DER -out audit-p256.der
The pkcs8 -topk8 step matters for P-256: without it OpenSSL writes a SEC1
key, which CheckpointSigner::from_pkcs8 refuses with that command in the
error. log.trusted_key() returns the public half; publish it where your
auditors can get it without trusting the machine that wrote the records.
Whoever holds the private key can rewrite a chain and sign the result, so the key is what the records' integrity rests on. Keep it in a secret store, not beside the database.
Verifying
#![allow(unused)] fn main() { use a2a_protocol_server::audit::AuditLog; async fn check(log: &AuditLog) -> Result<(), Box<dyn std::error::Error>> { // Cover the tail, then check the store against itself. log.checkpoint("acme").await?; let report = log.verify("acme").await?; assert!(report.is_intact(), "{:?}", report.failure); println!( "{} records, signed through {:?}, {} unsigned at the end", report.records, report.signed_through, report.unsigned_tail() ); Ok(()) } }
That checks the store against the key this process holds. An auditor should
verify an export — log.export(chain) and store.checkpoints(chain) — on
their own machine with a2a_protocol_types::audit::verify_chain and a key
they obtained independently. The a2a CLI does the same from files, with no
code: a2a audit verify records.json --checkpoints checkpoints.json --keys jwks.json prints the report and exits 1 unless the chain is intact. It reports the first thing wrong: an edited
record, a gap, a reordering, a record from another chain, a missing start, a
checkpoint beyond the last record (the tail was removed), or a signature that
does not verify.
The three files come from the export. records.json is
serde_json::to_vec(&log.export(chain).await?) and checkpoints.json the
same over store.checkpoints(chain).await?, each a JSON array. jwks.json is
a JWK Set holding the checkpoint key, under the kid the signer was given (a
key with no kid matches no checkpoint); --keys reads OKP/Ed25519 and
EC/P-256 keys only. log.trusted_key() holds the raw public key, not a
SubjectPublicKeyInfo: 32 bytes for Ed25519, and for P-256 the 65-byte point,
0x04 then X then Y. In Rust,
Jwk::from_verifying_key(&VerifyingKey::from_bytes(key.alg.jws_name(), &key.public_key)?, Some(key.kid.as_str()))
turns that key into a JWK; collect it with Jwks::new(vec![..]) (all three
are in a2a_protocol_types::signing) and serialize it with serde_json. For
Ed25519 the file reads:
{"keys":[{"kty":"OKP","crv":"Ed25519","kid":"audit-2026-10","x":"<base64url, no padding, of the 32-byte key>"}]}
For P-256 the key is "kty":"EC","crv":"P-256" with x and y the two
32-byte halves of the point after its 0x04 prefix, each base64url without
padding.
Following one task across agents
The call, call.started, run.started, task.cancel_requested and
approval records carry the W3C trace id of the call that caused them, when
the call has one. A task.event names its run.started by runSeq, and that
record carries it; the records an operator writes (halt, erasure,
hold.placed, hold.released, subject.erased) and task.deleted carry
none. This SDK's client propagates traceparent when the otel feature and a
recording tracing-opentelemetry layer are present (unless it is built with
with_trace_propagation(false)), or when it carries
TracePropagationInterceptor; neither sends it over WebSocket. The server
keeps the caller's trace id under the default InboundTracePolicy::Continue
(see Trace context). With both agents
audited that way, the records for one user request are the records with one
trace.traceId across the two agents' chains — the interaction Article 72(2)
asks a provider to be able to analyse.
Retention and legal holds
Nothing is deleted unless you call purge, and it never deletes:
- anything younger than the floor —
AuditRetention::six_months()is 184 days, the longest six consecutive calendar months; a shorter floor has to be asked for by name (allowing_shorter_than_six_months); - a chain's newest record;
- anything in a chain under a legal hold (
log.place_hold(chain, reason, by)untillog.release_hold(chain, by)); each is a record in the chain,hold.placedorhold.released, namingby.
#![allow(unused)] fn main() { use a2a_protocol_server::audit::{AuditLog, AuditRetention}; async fn nightly(log: &AuditLog) -> Result<(), Box<dyn std::error::Error>> { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as i64; let report = log.purge(AuditRetention::six_months(), now_ms).await?; for (chain, deleted, anchor) in &report.purged { println!("{chain}: {deleted} records deleted, anchored at {anchor}"); } Ok(()) } }
Before deleting, purge signs an anchor — the hash of the last record it deletes — so what remains still verifies. It therefore needs the checkpoint signer, and refuses to run without one.
Six months is the AI Act's floor for high-risk systems. The GDPR asks that personal data be kept no longer than necessary, and the caller's subject is personal data in most deployments. Choose the floor for your system; the SDK only refuses to choose a shorter one by accident.
Protecting who and what, and erasing a person
The records above name each caller, and a digest of a short message can be
confirmed by anyone who reads the store and guesses. With the encryption
feature, AuditLog::with_privacy protects both before each record is
hashed into its chain:
- each person named (the caller, an approval's requester) is sealed under a key of their own, kept by the audit store, wrapped by your key-encryption key, and named in records only by a random handle;
- each digest becomes an HMAC under your key, bound to its record, so it confirms no guess without the key.
The chain still verifies with no key. Whoever holds the keys reveals a record and checks content against it. Erasing a person destroys their key: their name stops opening in every record, and the chain still verifies.
#![allow(unused)] fn main() { use std::sync::Arc; use a2a_protocol_server::audit::{AuditLog, AuditPrivacy, HMAC_KEY_LEN, InMemoryAuditStore}; use a2a_protocol_server::store::encrypted::{AES_256_KEY_LEN, Aes256GcmCipher}; fn kms_key(_: &str) -> [u8; 32] { [0; 32] } async fn run() -> Result<(), Box<dyn std::error::Error>> { let kek = Arc::new(Aes256GcmCipher::new("kek-2026-10", kms_key("kek"))?); let privacy = AuditPrivacy::new(kek, "hmac-2026-10", kms_key("hmac"))?; let log = AuditLog::new(Arc::new(InMemoryAuditStore::new())).with_privacy(privacy)?; // An auditor reads a record back, with the keys. let records = log.export("acme").await?; if let Some(first) = records.first() { let shown = log.reveal(first).await?; println!("{:?} by {:?}", shown.record.kind, shown.record.actor); } // A person asks to be erased (GDPR Art. 17), outside any legal hold. let done = log.erase_subject("acme", "bearer:[email protected]", "operator:dpo").await?; if !done.in_clear.is_empty() { // Records from before protection was on still name them; report these. println!("still named in records {:?}", done.in_clear); } let _ = (AES_256_KEY_LEN, HMAC_KEY_LEN); Ok(()) } }
What erasure does not do, stated in full in ADR 0015's amendment:
- Unlink. The person's records keep one common handle, so they stay linkable to each other; times and ids in them may identify the person together with other data. Pseudonymised, not anonymised.
- Reach copies. Backups, replicas, a database's write-ahead log, and
PostgreSQL's dead tuples until
VACUUM a2a_audit_subject_keyskeep the key until they are overwritten; your key-encryption key unwraps such a copy. - Reach records from before protection.
erase_subjectreports them inin_clear; they cannot be changed without breaking the chain. - Work with an HMAC key you dropped. Keep each old HMAC key in the ring
(
with_hmac_key) for as long as people's keys made under it may exist.
It also costs: an append took 17.6 µs plain and 47.2 µs protected on the in-memory store, 296 µs and 521 µs on a SQLite file (ADR 0015 gives the method). Retention drops the keys of people no remaining record names.
Erasure of tasks is on record too
Removing a task is something a controller has to be able to show (GDPR Art. 5(2)), so the chain records it:
-
a task deleted through the audited task store (
with_audit) is atask.deletedrecord naming the task, its context and the tenant, and nothing it held; deleting a task that is not there records nothing; -
purge_expiredanderase_callerare calls you make on a concrete store, outside any request, so nothing wraps them. Report what they returned:let swept = store.purge_expired(&policy).await?; log.record_erasure("acme", "operator:dpo", Erased::Expired { tasks: swept.tasks_deleted }).await?; let gone = store.erase_caller("bearer:[email protected]").await?; log.record_erasure("acme", "operator:dpo", Erased::Caller { caller: "bearer:[email protected]", partitions: gone.partitions, rows: gone.rows, }).await?;The SQL stores'
erase_callerreturns aResult, as the?shows; the in-memory stores return theCallerErasuredirectly. Theerasurerecord names who, the scope and the counts. The erased caller is in a fieldwith_privacyseals under that caller's own key, so erasing them from the chain later (erase_subject) takes their name out of the record of their own erasure. The record is written after the erasure, and nothing makes you write it; -
a legal hold is
hold.placedorhold.released, naming who did it.
Required mode
By default a record that cannot be written is logged, counted in
log.failures() and reported to your metrics as a persistence error on
audit_append, and the call is served anyway. If serving an unrecorded call
is worse than refusing it:
#![allow(unused)] fn main() { use std::sync::Arc; use a2a_protocol_server::audit::{AuditLog, InMemoryAuditStore}; let log = AuditLog::new(Arc::new(InMemoryAuditStore::new())).require_record(true); let _ = log; }
Each call then writes call.started before the handler runs, and is refused
if that write fails. That record is written before authentication, so it
names no caller; the call record after it does.
Limits
- Record times come from the server's clock, not a trusted time source.
- A run's link from its events to its caller is kept in memory; a restart
while a task runs leaves that task's later events without a
runSeq, and so does a run forgotten because more than 65,536 runs were tracked at once. - Appends to one tenant's chain are serialised. ADR 0015 gives the measured cost: about 9 µs per record in memory and 270 µs on a SQLite file on the machine measured.