Event sourcing says state is not stored, it is derived: the authoritative record is an append-only log of what happened, and every read model is a fold over it. Agent systems produce that log whether or not they take it seriously — which means the choice is not whether to have one, but whether to get replay, projections and audit out of it, or keep treating it as a chat history you occasionally grep.
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 different levels of support.
Nothing is updated in place. A write appends HypothesisScored{confidence: 0.86}
to a stream; the current confidence is whatever you get by folding the stream. Read models
are derived, disposable, and rebuildable — if a projection has a bug you fix the fold and
replay, rather than 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 — you could always just run it again. With a model in the loop you cannot: re-running gives you a different run. The log is the only record of what the agent actually saw and decided. That moves replay from convenience to the only reliable debugging surface you have, and it is why the unbounded-growth row above is a price worth paying here when it often isn't elsewhere.
session-manager is documented as a conversation store. It is also, without
modification, a correct event log.
A stream is a session. An event is a custom entry whose
custom_type is the event type — a real discriminator slot, not something you
smuggle into the payload. Ordering is an explicit parent_id chain returned 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 not the one that errors. It is the one with a convincing near-miss.
session::append has no conditional write. Pass expected_revision
and it is accepted, ignored, and the append succeeds anyway — no error tells you 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 is validated — 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 — you can fetch it by id — but it sits on a sibling branch that
session::messages does not walk. Any projection reading the ordinary way will
never see it. A's writer received a success response. Nothing, anywhere, reports a problem.
The version guard has to live in state, and the ordering 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, and which
means a concurrency mistake is permanent. There is also no branch enumeration
(session::leaves does not exist; include_branches is not
honored), so recovering an orphaned entry requires already knowing its
entry_id.
Writing the missing feature by hand is the fastest way to judge how missing it really 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, and its convincing near-miss is what makes it dangerous rather than merely missing.
The more valuable half of the from-scratch build is the UnreliableBus: a
notification channel that is at-least-once and unordered on purpose, because that
is the contract session-manager documents for itself. It exists so a test can
prove the following, 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, and the only difference is whether it trusted the delivery order. No exception is raised in either case — the wrong one just reports a wrong number.
The ordering one is not about iii. It is about every event-sourced system with an asynchronous notification channel.
The work is not producing events. It is committing to the log being authoritative, and getting the ordering discipline right.
Agent systems are event-sourced whether or not they mean to be. The transcript is an append-only record of what happened. Event sourcing is the decision to treat it as the source of truth rather than as a log file, and to derive read models from it instead of maintaining them alongside.
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, CAS 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, correct under light load, and wrong the first time anything retries. It never raises. The only way you find out is by deliberately delivering events out of order and asserting that it still matches the log.
That is the end of the series: the same incident-analysis workflow implemented 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, and the parts it doesn't supply are consistently the ones that fail quietly rather than loudly.