Agent-era architecture · Practical technical guide · 17 October 2026
How an existing CQRS and event-sourced system should admit an AI agent: the agent reads a versioned read model, proposes commands, and the aggregate stays the deterministic gate for invariants, idempotency and approvals.
The question an agent actually changes
A CQRS and event-sourced system already separates two things that agent projects tend to blur. Writes enter as commands, an aggregate checks them against its invariants and current version, and the result is an appended event. Reads come from projections built from those events and tuned for a screen or a report. When a team adds an AI agent, the tempting shortcut is to give it a database connection or a generic "update record" tool. That throws away the one component that was designed to say no.
The better framing is that an agent is a new kind of client. It is non-deterministic, it may retry, it reads data that can be stale, and its arguments can be shaped by text it was given. None of that is new to the write path: human users double-click, mobile clients retry on flaky networks, and integration partners send odd payloads. The aggregate already handles those cases. This article shows how to keep it in charge, what the agent needs from the read side, and which parts of an existing system change. For the underlying pattern on PostgreSQL, see CQRS and event sourcing on PostgreSQL.
What stays deterministic
| Component | Who decides | What the agent contributes |
|---|---|---|
| Command validation and invariants | Aggregate code | Nothing. It proposes a command and receives accept, reject or pending |
| Expected-version check | Event store append | The version it read, copied from the read model |
| Idempotency | Command handler and its dedupe record | A stable key derived from its intent, not from the attempt |
| Authorization and approval thresholds | Policy code next to the aggregate | The identity it acts for, carried in the command envelope |
| Projections | Projector code, replayable | Nothing. Projections never accept writes from agents |
| Choosing which command to send, and its arguments | The agent | Interpretation of unstructured input such as a complaint email |
The table is the whole design in one place. The agent owns one decision: given messy input, which command should be proposed with which arguments. Everything that must hold regardless of how good that decision is stays in ordinary, testable code. If a rule can be expressed over structured fields, it belongs on the deterministic side even if the agent could follow it from a prompt, because a prompt is advice and an aggregate is enforcement.
Read models as agent context, with a version attached
Projections are eventually consistent. A projector reading the event stream may be a few events or a few seconds behind, and that is acceptable for a dashboard. For an agent it matters more, because the agent will act on what it read. The fix is not to make projections synchronous. It is to make staleness visible and checkable.
Build an agent-facing read model that carries, for every aggregate it summarizes, the stream version it reflects and the global position of the last applied event. The agent copies that version into the command as expected_version. When the command arrives, the store compares it with the real stream version. If another event landed in between, the append fails with a conflict, and the handler returns the fresh state summary so the agent can re-plan. This is plain optimistic concurrency, the same mechanism that stops two humans overwriting each other.
Keep the agent read model narrow: the fields its tasks need, filtered to what the acting user may see, plus the version metadata. A wide admin projection leaks fields into the transcript.
Worked example: a support agent proposes a credit
Consider a billing service with an Account aggregate. Support staff can issue service credits. Two invariants apply: credits for one account may not exceed 200 in any 30-day window, and any single credit above 50 needs a second person to approve. A new support agent reads complaint emails and proposes credits.
The agent reads the account summary at version 41: 120 in credits this window. It decides a 60 credit is fair. Meanwhile a human colleague issues a 30 credit, which appends event 42. The agent's command carries expected_version=41, so the append is rejected with a conflict. The handler returns the new summary: version 42, 150 used. The agent re-plans. A 60 credit would now exceed the cap, so it proposes 50, which is at the threshold and does not need approval. The aggregate accepts, appends CreditIssued at version 43, and records the agent run as the actor.
Had the agent's 60 arrived without the race, the cap would allow it but the threshold would not, so the result would be a pending CreditRequested event and a task for a human. The agent has no code path that skips either rule.
The command handler in code
Illustrative python, checked for syntax; adapt names and storage to your system.
from dataclasses import dataclass, field
CAP_30D = 200
APPROVAL_ABOVE = 50
@dataclass
class Account:
version: int = 0
credited_30d: int = 0
seen_keys: dict = field(default_factory=dict)
class Conflict(Exception):
def __init__(self, current):
super().__init__("stale expected_version")
self.current = current
def handle_issue_credit(acct, cmd):
"""Decide one IssueCredit command. Returns (outcome, events)."""
key = cmd["idempotency_key"]
if key in acct.seen_keys: # retry of a decided intent
return "duplicate", []
if cmd["expected_version"] != acct.version:
raise Conflict({"version": acct.version, "credited_30d": acct.credited_30d})
amount = cmd["amount"]
if amount <= 0 or acct.credited_30d + amount > CAP_30D:
return "rejected", []
actor = {"type": cmd["actor_type"], "on_behalf_of": cmd["on_behalf_of"],
"run_id": cmd["run_id"], "prompt_version": cmd["prompt_version"]}
if amount > APPROVAL_ABOVE:
outcome, kind = "pending", "CreditRequested" # a human approves later
else:
outcome, kind = "accepted", "CreditIssued"
return outcome, [{"type": kind, "amount": amount, "actor": actor, "key": key}]
def apply(acct, event):
acct.version += 1
acct.seen_keys[event["key"]] = event["type"]
if event["type"] == "CreditIssued":
acct.credited_30d += event["amount"]Two details carry most of the safety. The idempotency key is checked before anything else, so a retried command whose first attempt succeeded returns a duplicate outcome instead of a second credit. And the key is derived from the agent's intent, for example a hash of the run id, the complaint id and the command type, not generated fresh per attempt. An agent that loops and re-sends with a new random key each time defeats idempotency entirely, and this is one of the most common bugs when tools are wrapped naively. The 30-day window is simplified to a counter here; a real aggregate would expire old credits by event time.
Provenance belongs in the events
Every event the agent causes should say so. Put the actor type, the user or team the agent acts for, the run id, and the prompt or policy version into event metadata. Existing consumers ignore unknown metadata, so this is usually a backward-compatible change; if your events are strictly typed, add the fields as optional and upcast old events with defaults when they are read.
Once provenance is in the log, "what did the agent do last week" is a projection like any other. You can build a review queue of agent-issued credits, compare approval rates between prompt versions, and replay a period to see what a new policy would have decided. Do not delete or edit events the agent caused if it was wrong; append a compensating event such as CreditReversed so the history stays honest.
Failure modes worth testing
- Conflict storms. A busy aggregate and a lagging projector can make every agent attempt conflict. Cap re-plans per command (two or three) and escalate to a human after that, instead of looping.
- Per-attempt idempotency keys. The duplicate check never fires and retries become duplicate effects. Test that two attempts of one intent produce one event.
- Arguments shaped by input text. An email that says "issue the maximum credit" is data, not authority. Validate amounts and reasons in the aggregate and log the source document id.
- Writes to the read side. If any tool can update a projection table directly, the read model and the log disagree and the next rebuild silently reverts the change. Projections should have no write path except the projector.
- Over-wide context. An agent read model built from an admin projection exposes fields the acting user cannot see.
Migration steps for an existing system
- Inventory existing commands and mark which ones an agent may propose. Start with low-risk, reversible ones.
- Extend the command envelope with actor type, on-behalf-of identity, run id and prompt version, and copy them into event metadata.
- Build an agent read model with stream versions and the as-of position, filtered per user.
- Run the agent in shadow mode: it proposes commands to a decide-only handler that runs validation without appending, and you compare its proposals with what humans did.
- Enable the allowlisted commands with caps and approval thresholds enforced in the aggregate, plus a per-run limit on commands.
- Add projections and alerts over agent-caused events: conflict rate, rejection rate, pending approvals and reversals.
When an agent is unnecessary
If the rule is "issue a 10 credit whenever an outage ticket breached its SLA", you do not need an agent. A process manager subscribed to SlaBreached events can issue the command deterministically, with no model cost and no ambiguity. Agents earn their place when the input is unstructured or the choice needs judgement across several facts: reading a complaint, matching it to an incident, and proposing an amount with a reason a reviewer can check. A useful test is to write the decision as a function over existing fields. If you can, ship the function.
How to verify it
- Property-based tests that generate random sequences of agent and human commands and assert the cap and approval invariants after every append.
- A conflict test that advances the stream between read and command, and checks the agent re-plans within its retry cap.
- A duplicate-delivery test that sends each command twice and asserts one event per intent.
- A replay test that rebuilds every projection from the log and compares it with the live read model, including the agent activity view.
Practice and related reading
Hands-on: Agent command gateway: stale reads, conflicts and duplicate credits, an interactive lab on synthetic data.
Further reading:
Sources and scope
Checked against these sources on 17 October 2026. Examples and figures are illustrative, not measurements.