Skip to content
Concepts

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.action format: article.published, not PublishArticle
  • 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.