Building a Production AI Agent in Spring Boot: The Append-Only Audit Trail (Part 13)

java dev.to

The security page of an AI meeting recorder with over two million users is a trophy case. SOC2 compliant. GDPR compliant. EU AI Act compliant. Hosted in the EU. AES-256 encryption. A founder commitment video. Six compliance badges lined up in a row. Buried at the bottom, a single line: if you found a privacy or security issue, email us, and our security team will respond within 24 hours (writeup, 626 points on HN).

That line sat above six months of silence. The researcher who found the tenant-isolation hole from Part 12 emailed the CTO directly in January. Follow-ups went out in February, March, and July. No reply came back. His words: their Firestore database has better uptime than their inbox. The company's rebuttal says the first vector was closed and pentest-validated months ago, the second fixed within 24 hours, and Firebase removed entirely (response). Someone is wrong, and the compliance page cannot tell you who, because a badge describes a process, and a breach tests the process.

Compliance badges are not security. The audit log is where the two diverge, and that is what Part 12 promised this part would build: for an agent, the log is the record of every tool call, with its tenant, its arguments, and its outcome, stored in a way that cannot be quietly edited. The next tl;dv will not be a Firestore collection. It will be an agent that answered with the wrong tenant's data and left no record of which tenant asked.

I am a Senior Software Engineer II at BS23 in Dhaka, and I have been building production AI agents with Spring Boot and Spring AI for over a year. The e-commerce assistant from Parts 1 through 12 is the same agent: same nine tools, same supervisor, same memory, now wrapped in the Part 11 guard and the Part 12 tenant boundary. This part gives it a record of everything it did, and that record is the last layer the series needed.

Step 1: Decide what the log has to record

After any incident with an agent, three questions come up, in order: which tenant asked, what did the agent see, and what did it do? The first two are answered by the seams built in Parts 11 and 12. The third is not, because a tool call happens inside the model's turn and disappears when the turn ends. The model's tool call is the closest thing an agent has to a decision record. You can never replay what the model would have decided, but you can record what it actually did.

So the log entry for every tool call carries a fixed set of fields:

  • Tenant and conversation. Who asked, and in which conversation. Both already ride in the tool context from Part 12.
  • Tool name and arguments. What was invoked, with what input. Arguments get redacted before storage (Step 4).
  • Outcome. Success, refusal by the Part 11 guard, or the Part 7 approval gate intercepting the call.
  • Latency and model. How long the call took and which model made it, because Part 10's fallback logic can change the model mid-conversation.
  • A timestamp with a source you trust. The application clock, not a value the model could have influenced.

That is the entire job. Not transcripts, not prompts, not embeddings. A tool-call record answers the three questions, and anything more is a liability, because a log is a data store, and a data store gets attacked.

Step 2: Hook the log at the tool seam

The demo already has the perfect seam, and Part 12 widened it. Every tool reads its identity from the tool context through a helper that exists in the repo:

private static String conversationId(ToolContext toolContext) {
    Object id = toolContext.getContext().get("conversationId");
    if (id == null) {
        throw new IllegalStateException("conversationId missing from tool context");
    }
    return id.toString();
}
Enter fullscreen mode Exit fullscreen mode

Part 12 added the tenant to the same map, so both identity values arrive at every tool call. The audit layer lives at that same seam: one wrapper around the tool callback, not logging sprinkled across nine tool methods. A wrapper can be an advisor on the chat client, or a decorating ToolCallback as in Part 11, and either way it sees the call before and after the tool runs:

long started = System.nanoTime();
boolean ok = true;
try {
    String result = delegate.call(toolInput, toolContext);
    return result;
} catch (Exception e) {
    ok = false;
    throw e;
} finally {
    audit.record(AuditEntry.builder()
            .tenantId(toolContext.getContext().get("tenantId"))
            .conversationId(conversationId(toolContext))
            .tool(delegate.getToolDefinition().name())
            .arguments(redact(toolInput))
            .outcome(ok ? "ok" : "error")
            .latencyMs(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started))
            .build());
}
Enter fullscreen mode Exit fullscreen mode

One seam, one place, every call. The refusal paths matter as much as the success paths: a call the Part 11 guard rejected is a call the log must show, because the difference between "the agent refused" and "the agent never tried" is exactly what an investigation needs. The Part 6 test harness gets one more assertion: every tool call writes exactly one entry, success or refusal.

Step 3: Append-only, with a chain that detects edits

A log you can edit is a press release, not evidence. The storage rules are the same ones database teams use for financial event stores: the application role can INSERT and SELECT, and nothing else. No UPDATE grant, no DELETE grant, and the DBA account that has them is not the account the agent runs as.

That alone stops accidental edits, and it does not stop a determined one, so the entries get a hash chain. Every row stores the hash of its own payload plus the hash of the previous row:

CREATE TABLE agent_audit_log (
    seq          BIGSERIAL PRIMARY KEY,
    prev_hash    CHAR(64) NOT NULL,
    entry_hash   CHAR(64) NOT NULL,
    written_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    tenant_id    TEXT NOT NULL,
    conversation_id TEXT NOT NULL,
    tool_name    TEXT NOT NULL,
    args_redacted TEXT NOT NULL,
    outcome      TEXT NOT NULL,
    latency_ms   INTEGER NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Entry n stores sha256(prev_hash || payload). Editing row 5 breaks the chain from row 6 onward, because row 6's prev_hash no longer matches row 5's recomputed entry_hash. Verification is a replay: walk the rows in order, recompute, and stop at the first mismatch. The chain does not make the log unreadable, and it does not make it unhackable. It makes silent editing detectable, and detectability is the whole point. The tl;dv timeline would have looked very different if someone had been able to prove what was changed and when.

Step 4: Redact before you write

The log is a data store, and its most valuable field is the one you should not keep: the raw arguments. This agent's tools take a shipping address, a cart action, an order id, and Part 2 showed what a conversation can contain. Storing arguments verbatim turns the audit log into a second database of customer data, which means it becomes a second breach surface and a second GDPR problem.

The rule is to log the shape of the call, not the content. Run the arguments through a redactor at the seam, before the entry is built: keep field names, replace field values that look like addresses, emails, phones, or card numbers with a type tag. The entry then records that shippingAddress was passed, not what it contained. If an investigation needs the raw value, the value lives in the source system, retrievable by id under normal access controls. The log answers what happened; it does not duplicate the data.

Step 5: What the compliance page actually promises

The EU AI Act's Article 12, Record-Keeping, applies to high-risk AI systems from August 2026 and states the obligation plainly: the system shall technically allow for the automatic recording of events (logs) over its lifetime, and the logging capabilities shall enable a level of traceability of its functioning appropriate to the intended purpose (Article 12). The tl;dv page claimed EU AI Act compliance. An agent serving customers is not automatically a high-risk system under the Act, and this article is not legal advice. The point is narrower and it holds either way: when a page promises compliance, the audit log is the artifact that proves the promise exists, and the absence of one is the first thing an auditor or a researcher will look for.

The 24-hour response line is the same story in a smaller frame. You cannot respond within 24 hours if you cannot reconstruct what happened, and you cannot reconstruct what happened from a badge. You can from a log, if the log has the tenant, the conversation, the tool, the outcome, and the timestamp. The page and the log are the difference between promising a response and being able to write one.

The honest cost section

The audit log is a tax on every call, and the tax is why it gets skipped. Storage grows with every tool call and every conversation, so retention needs a decision up front: keep the chain for 90 days, aggregate after that, and archive what the business genuinely needs. Write throughput adds a row per tool call, which is cheap on any real database and not free in a high-volume agent. Redaction is a small library at the seam, and small libraries still need tests. The chain replay needs a script and a schedule, or it is a feature nobody runs. And the log itself becomes a target, which is why Step 4 exists: the most defensible audit log contains the least sensitive data.

That tax is also the defense. The tl;dv page decorated itself with six badges and buried the response promise at the bottom, and neither the badges nor the promise survived contact with an actual incident. An agent with an append-only log has something the page did not: a record that answers which tenant asked, what the agent saw, and what it did, and that cannot be quietly edited to say otherwise. The compliance page is the marketing version of that record. The log is the record.

The Checklist

  • Record every tool call. Tenant, conversation, tool, arguments, outcome, latency, model, timestamp. Refusals included.
  • One audit seam. A wrapper around the tool callback, never scattered log statements across tool methods.
  • Append-only storage. INSERT and SELECT grants only, no UPDATE, no DELETE, for the account the agent runs as.
  • Hash-chain the entries. Each row stores the previous row's hash; replay the chain to detect edits.
  • Redact arguments before writing. Log the shape of the call, not the content; the source system keeps the values.
  • Decide retention up front. The log is a data store with its own lifecycle, and it grows with every conversation.
  • Test the log like any component. Every call writes exactly one entry, and the Part 6 harness asserts it.
  • Know what the badges promise. If your page claims compliance or a response time, the log is what lets you keep the promise.

What Comes Next

This part closes the series. Thirteen parts, one agent, and the production checklist the demo could not hold: tools and function calling, memory and context, SSE streaming, observability, the supervisor pattern, testing without an LLM, the human-in-the-loop approval gate, the eval harness, prompt A/B testing, canary releases with fallback and cost caps, the sandbox rule, tenant isolation, and now the record of everything the agent did. Every layer answers one question. The guard answers what the agent may do. The tenant boundary answers whose data it touches. The audit log answers what it actually did, and that is the last question, because every other layer eventually has to be explained, and the explanation is only as good as the record.

A production agent is not a prompt. It is a system of seams, and the last seam is memory for the machine: the log that survives the incident, the audit, and the awkward question from the person who finds the meetings collection you forgot. The next tl;dv will not announce itself with a misconfigured database. It will answer with the wrong tenant's data, and the only question will be whether you can prove which tenant asked.

Open your agent's logs. If you had to answer, for every call in the last week, which tenant asked, what the agent saw, and what it did, how many could you answer from what you recorded? I read every response.

I write about Java, Spring Boot, and AI agents every week. Subscribe, it's free.

Bookmark this one. The day someone asks you what your agent did last Tuesday, this checklist is the difference between an answer and an apology.

Source: dev.to

arrow_back Back to Tutorials