Event Sourcing
Every change in LeftFold is an immutable event. Current state is derived by folding the event history on demand — no per-type tables, no projection lag, and custom types work instantly.
Why event sourcing?
Traditional persistence forces a trade-off: current state OR history, efficient reads OR a complete audit trail. Event sourcing gives you all of them simultaneously.
Key-Value Stores
Overwrites history. Can't answer "what changed?" or "why?" No relationships, no temporal queries.
Amnesia with a notepad
Vector / RAG
Semantic similarity search. Can't answer "what happened over time?" Loses structure and causality by design.
Good at vibes, bad at facts
Event Sourcing
Records what happened, when, and why. Current state is derived by replaying history. Full audit trail. Schema evolution.
Complete knowledge
Append. Fold. Query.
Three concepts that give LeftFold a structured, auditable content model that grows with every interaction.
01 — Append
When content enters the system — through an MCP client, the API, or agent enrichment — it's recorded as a typed, immutable event. "Published an article about TypeScript" becomes an article.created event with structured payload. Nothing is lost, nothing is overwritten.
Events are always immutable, typed, ordered (sequence numbers), and attributed (who did what).
02 — Fold
When you need the current state of an aggregate, the system "folds" its event history — replaying events in sequence order, merging payloads with last-write-wins semantics. Five events about an article become one clean document: title, body, status, tags, enrichments.
The fold is computed on demand and cached. When new events arrive, the cache is automatically invalidated by append_event().
03 — Query
LeftFold provides a MongoDB-style aggregation pipeline for querying folded state. Every surface — dashboard, MCP tools, HTTP API, SDK — uses the same pipeline engine.
Query pipeline
Queries are arrays of stage objects. The first stage must be a $match with aggregate_type. Results are cached and auto-invalidated on write.
$match — Filter events (pre-fold) or documents (post-fold). Supports $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists.
$fold — Core operation. Replays events per instance, producing current state via last-write-wins merge.
$lookup — Join across aggregates. Target aggregate is auto-folded.
$group — Aggregate with accumulators: $sum, $count, $avg, $min, $max, $push.
$project — Select, exclude, or rename fields. Supports dot-notation.
$sort — Order results. 1 for ascending, -1 for descending.
$limit — Cap result count.
Query examples
Get current state of all articles
[
{ "$match": { "aggregate_type": "article" } },
{ "$fold": {} },
{ "$sort": { "_last_event_at": -1 } },
{ "$limit": 25 }
]Filter after fold (published only)
[
{ "$match": { "aggregate_type": "article" } },
{ "$fold": {} },
{ "$match": { "status": "published" } }
]Cross-aggregate join
[
{ "$match": { "aggregate_type": "article" } },
{ "$fold": {} },
{ "$lookup": {
"from": "person",
"localField": "author_id",
"foreignField": "_instance",
"as": "author"
}}
]Group and count
[
{ "$match": { "aggregate_type": "article" } },
{ "$fold": {} },
{ "$group": { "_id": "$status", "count": { "$count": {} } } },
{ "$sort": { "count": -1 } }
]Aggregates & events
An aggregate is a domain concept with a distinct lifecycle — Article, Person, Recipe, Brand. Each has typed events that describe its history.
An event is an immutable fact — article.created, article.published, person.mentioned. Events are past-tense, typed, and timestamped.
Design principles
- Events are
aggregate_type.actionformat:article.published, notPublishArticle - Cross-aggregate relationships use ID references, resolved at query time via
$lookup - Every aggregate type must define at minimum:
.created,.updated,.archived - Custom types registered via event storming are immediately queryable — no migration needed
Schema evolution
Event definitions are versioned. New fields must be optional or have defaults. Existing events automatically receive defaults from newer schema versions when folded. No data migration needed — the fold handles it at read time.
Folded document shape
Every folded document includes metadata fields alongside the merged payload:
_aggregate_type — The type (e.g., "article")
_instance — The aggregate ID (UUID)
_version — Number of events in the stream
_last_event_at — Timestamp of the most recent event
_workspace_id — Workspace that owns this aggregate
_project_id — Project within the workspace (nullable)
Query cache
Pipeline results are cached by SHA-256 hash of the pipeline JSON, scoped to workspace + project. Cache is invalidated synchronously inside append_event() — every write deletes cached entries for the affected aggregate types.
First query computes the fold (cache miss). Subsequent reads serve from cache until a write arrives. No background jobs, no stale reads, no eventual consistency.