Event sourcing derives state instead of storing it. The authoritative record is an append-only log of what happened, and every read model is a fold over that log. An agent system produces the log whether or not it means to. So the choice is not whether to have one, but what you get out of it. Commit to the log and you get replay, projections and an audit trail. Ignore it and you keep a chat history you grep when something breaks.
github.com/tacoda/iii-event-sourcing8 passed, offline and deterministic
"What is the state" and "how did it get that way" stop being two questions with two levels of support.
A write never updates a value in place. It appends HypothesisScored{confidence: 0.86}
to a stream, and the current confidence is whatever the fold over that stream returns.
Every read model derives from the log, which makes it disposable and rebuildable. If a
projection has a bug, fix the fold and replay instead of migrating corrupted rows.
| State-in-place | Event-sourced | |
|---|---|---|
| Current value | Read a row | Fold a stream (or read a projection) |
| History | Whatever you remembered to log | The system itself |
| Fixing a bad read model | Data migration | Fix the fold, replay |
| "Why is it like this?" | Forensics | A query |
| Storage | Bounded by state size | Grows forever |
| Cost of a schema change | One migration | Old events are immutable (the fold carries every version, forever) |
With deterministic code, replay is a debugging convenience, because you can always run the code again. With a model in the loop you cannot: a second run gives you a different run. Because of that, the log is the only record of what the agent saw and decided. That record moves replay from a convenience to the debugging surface you depend on. So the row above about unbounded growth is a price worth paying here, and often not elsewhere.
The docs call session-manager a conversation store. Without modification, it is also a correct event log.
A stream is a session. An event is a custom entry whose
custom_type names the event type. That field is a real discriminator slot, not
a type you smuggle into the payload. Ordering is an explicit parent_id chain,
and the engine returns it on every append. So the log is a linked list rather than a
timestamp-sorted bag.
iii trigger session::append session_id=$SID \
message='{"role":"custom",
"custom_type":"ClaimRecorded",
"content":[{"type":"text","text":"{\"claim\":\"one consumer attached\"}"}],
"timestamp":1717800000000}'
{ "entry_id": "e_45d958d6…", "parent_id": "e_5e3d1086…", "timestamp": … }
| Concept | iii | Status |
|---|---|---|
| Stream | a session; aggregate identity in metadata | config |
| Event type | custom_type on the entry | config |
| Ordering | explicit parent_id chain | config |
| Read the log | session::messages, already in chain order | config |
| Projection | session::message-added binding → state scope | config |
| Per-aggregate filter | metadata subset match on the binding | config |
| Fork / what-if replay | session::fork | config |
| Aggregate version guard | state::compare-and-set , not in the log | config |
| Optimistic concurrency on append | — | absent |
The dangerous kind of missing feature is the one with a convincing near-miss rather than the one that errors.
session::append has no conditional write. Pass expected_revision
and the engine accepts the parameter, ignores it, and appends anyway. No error tells you
that your concurrency control does nothing:
$ iii trigger session::append session_id=$SID expected_revision=3 message=…
{"entry_id": "e_399c8b87…", "parent_id": "e_20ec1d48…"} ← appended regardless
parent_id, meanwhile, is real, and the engine does validate
it: an unknown parent returns session/parent_not_found. So it looks exactly
like the conditional-append primitive. It isn't. Two writers appending at the same parent
both succeed:
writer A -> e_fd3ce7e1ffe6 (parent e_9da6…) ok
writer B -> e_cad909ed44b3 (parent e_9da6…) ok
$ iii trigger session::messages session_id=$SID
… e_9da67bbfd6c1 {"confidence":0.86}
… e_cad909ed44b3 {"claim":"writer B"}
← A is not here
$ iii trigger session::get-message entry_id=e_fd3ce7e1ffe6
{"id":"e_fd3ce7e1ffe6", "message":{…"writer A"…}, "revision": 0}
A's event is durable, and you can fetch it by id, but it sits on a sibling branch.
session::messages does not walk that branch, so no projection reading the
ordinary way will ever see A's event. Still, A's writer received a success response, and
nothing anywhere reports a problem.
The version guard has to live in state, and the order is not negotiable:
compare-and-set first, append second. Append first and you have written
an event you cannot un-append. The log is append-only, which is the point, so a
concurrency mistake is permanent. There is also no branch enumeration:
session::leaves does not exist, and the engine ignores
include_branches. So you can recover an orphaned entry only if you already
know its entry_id.
Writing the missing feature by hand settles how missing it is.
def append(self, stream, type, payload, *, expected_version=None) -> Event:
log = self._streams.setdefault(stream, [])
if expected_version is not None and expected_version != len(log):
raise Conflict(f"{stream}: expected {expected_version}, found {len(log)}")
event = Event(seq=len(log) + 1, type=type, payload=payload, stream=stream)
log.append(event)
self._notify(event)
return event
Four lines. Which is what makes the absence notable: this is not a hard feature to write. The convincing near-miss is what makes it dangerous rather than just missing.
The more valuable half of the from-scratch build is the UnreliableBus. That bus
is a notification channel that is at-least-once and unordered on purpose, because
session-manager documents that contract for itself. It exists so one test can
prove the claim below, which is the single most transferable finding in this series:
_seed(store) # 4 events, appended in order
bus.drain(lambda e: (naive_handler(e), doorbell_handler(e)))
truth = Projection("truth", incident_fold).rebuild(store.read(STREAM))
assert doorbell.state == truth # re-reads the ordered log. correct.
assert naive.state != truth # folds in arrival order. corrupted.
assert len(naive.state["claims"]) != len(truth["claims"])
Identical events. Identical fold function. One projection is right and one is silently wrong. The only difference is whether it trusted the delivery order. Neither case raises an exception, and the wrong projection just reports a wrong number.
The ordering finding is about every event-sourced system with an asynchronous notification channel, not about iii.
The work is committing to the log being authoritative and getting the ordering discipline right, not producing events.
Agent systems are event-sourced whether or not they mean to be. The transcript is an append-only record of what happened. So event sourcing is the decision to treat that transcript as the source of truth rather than as a log file. It also means deriving read models from the log instead of maintaining them alongside it.
On iii, the store is already there and the concurrency control is not.
session-manager gives you streams, typed events, chain ordering, projections
via trigger bindings, and fork-for-replay. It does not give you conditional append, it
accepts expected_revision and ignores it, and concurrent appends branch
silently. Put the version in state, compare-and-set before you append, and
never the other way round.
Write the ordering test before you write the projection. A projection that folds in arrival order is correct in development and correct under light load. Then it is wrong the first time anything retries, and it never raises. So you find out only by delivering events out of order and asserting that the projection still matches the log.
That ends the series: the same incident-analysis workflow over actors and supervision, a blackboard, and an event log. Three architectures, one task, two implementations each. The pattern across all three is that iii supplies more of each model than expected. Its gaps are consistently the parts that fail without an error, rather than with one.