Events and the log¶
The envelope¶
Every fact in AAA is an Event. The envelope is compatible with CloudEvents 1.0 and is
immutable (frozen Pydantic model).
| Field | Meaning |
|---|---|
id |
Unique within its stream. Time-sortable ULID for external events; deterministic hash for events derived from other events (see below). |
type |
Namespaced name such as aaa.llm.completed. aaa.* is reserved for the library; use your own prefix (acme.ticket.created). |
source |
Who emitted it (an algorithm name, external, engine). |
subject |
Optional entity the event is about. |
time |
UTC timestamp. |
stream |
The ordering and concurrency boundary. For runs this is the run id. |
run_id, trace_id, span_id |
Which run, and the trace the run belongs to (children share their root's trace_id). |
tenant |
Owner of the run, stamped at start and inherited by every derived event. See Tenancy. |
causation_id, correlation_id |
The event that directly caused this one; the first event of the chain. |
seq |
0-based position in the stream, assigned by the store on append. |
schema_ref |
Optional name/vN payload schema for versioned validation and upcasting. |
data |
The JSON payload. |
blobs |
Claim-check pointers for payloads that live in a blob store. |
meta |
Free-form string/number/bool tags (tenant, labels). |
from aaa.events import Event
e = Event(type="acme.invoice.received", data={"invoice": 1042})
child = e.caused("acme.invoice.parsed", {"lines": 3}) # keeps stream, trace and causation
Streams and ordering¶
A stream is an append-only sequence with its own seq counter. Within a stream, order is total. Across streams,
no order is promised. A run is one stream; a child run is another stream linked to its parent by events.
The store enforces two properties on append:
- Optimistic concurrency.
append(stream, events, expected_seq=n)fails withConcurrencyErrorunless the stream currently ends atn.expected_seq=-1means "the stream must be empty". The default (ANY_SEQ) skips the check. - Idempotency. An event whose
idis already in the stream is not appended again. The existing stored event is returned withdup=True. This single property underpins crash recovery, retries and multi-worker operation.
Deterministic ids¶
Event.caused(type, data) derives the new event's id from (parent id, type, canonical JSON of data). Deriving the
same event twice from the same parent therefore yields the same id, and the second append is absorbed by the store.
Results use a fixed id too: the result of request R is always R:result, so two workers racing on one request
produce exactly one result on the log.
Why this matters
A worker crashes after appending three of the four events it was about to produce. On restart the same input event is redelivered. The algorithm runs again, produces all four events with the same ids, and the store keeps the new one and ignores the three duplicates. No de-duplication code exists in any algorithm.
Reading the log¶
events = await engine.store.read(run_id) # whole stream
tail = await engine.store.read(run_id, from_seq=40) # from a position
async for e in engine.handle(run_id).events(): # replay, then follow until the run ends
...
Payloads that were claim-checked into a blob store appear as {"$claim": True, ...} on the stored event; call
await engine.hydrate(event) for the full payload. Handlers and algorithms receive hydrated events automatically.
Schemas and versions¶
Register a Pydantic model per versioned payload and migrate old events on read:
from pydantic import BaseModel
from aaa.events import SchemaRegistry
class NoteV2(BaseModel):
text: str
lang: str = "en"
schemas = SchemaRegistry()
schemas.register("acme.note/v2", NoteV2)
schemas.upcaster("acme.note", 1, lambda d: {"text": d["body"]}) # v1 -> v2
engine = Engine(schemas=schemas)
schema_ref values without a registered schema pass through untouched, so unregistered event types cost nothing.
CloudEvents interoperability¶
from aaa.events.cloudevents import to_cloudevent, from_cloudevent
ce = to_cloudevent(event) # a plain dict in CloudEvents 1.0 structured JSON mode
same = from_cloudevent(ce) # round-trips exactly
The conversion has no dependency on the CloudEvents SDK. Fields CloudEvents cannot carry (float meta values, blob
pointers) travel in aaa* extension attributes.
The event catalog¶
Every event type the library emits, with payload shapes, is listed in the event catalog.