Guide · 3

Reading scrolls with folds

A scroll is an event stream. A fold is how you turn that stream into a typed answer — what's the party's current gold, who's on the roster, which signals became which quests. Same primitive for every "state of X right now" question.

What's a fold

A Fold[T] is a pure function from an event stream to a typed value: start at Init, apply each event in order, return the accumulated T. It is the same shape as a reduce in a functional language, or a left-fold over a list.

// A fold turns a scroll into a typed value by running an Apply
// function over every event in order. Init is the starting value.
type Fold[T any] struct {
    Init  T
    Apply func(T, Event) (T, error)
}

// FoldEvents replays matching events and returns the accumulated T.
result, err := scroll.FoldEvents(ctx, questBoardScroll, AcceptedQuestsFold)

Every fold is deterministic: given the same scroll state, the same fold returns the same result. This is what makes folds safe to call anywhere — inside a reactor, from an HTTP handler, in a test — without worrying about ordering or concurrency.

Three canonical shapes

Most folds fall into one of three patterns. The typed accumulator gives away which one you're writing.

Counter

Accumulates a running total. Gold in the treasury, monsters defeated, rumors heard, XP earned. The state is a single number or small struct.

State map

Tracks the current membership of a collection. Party roster, active quests, open bounties. Handles both addition and removal events so the map reflects the latest append.

Index

Maps one ID to another. Signal IDs to quest IDs, scroll IDs to party IDs. Critical for translating LLM-produced references into real domain entity IDs across reactors.

Counter — a running total

The simplest shape. Init is a zero value, and each matching topic nudges the accumulator up or down.

// A counter fold — current party gold after every deposit and withdrawal.
type Treasury struct {
    Gold int
}

var TreasuryFold = scroll.Fold[Treasury]{
    Apply: func(t Treasury, e scroll.Event) (Treasury, error) {
        switch e.Topic {
        case TopicGoldDeposited:
            var ev GoldDeposited
            if err := json.Unmarshal(e.Data, &ev); err != nil { return t, err }
            t.Gold += ev.Amount
        case TopicGoldSpent:
            var ev GoldSpent
            if err := json.Unmarshal(e.Data, &ev); err != nil { return t, err }
            t.Gold -= ev.Amount
        }
        return t, nil
    },
}

Counters are cheap to compute and trivial to test. Reach for them first — if your state genuinely is a number or a small struct, don't overbuild the fold.

State map — membership over time

When the scroll represents the changing membership of a collection, the accumulator is a map. The fold handles both addition and removal events so the result reflects the state after the latest append — not some cumulative "anyone ever added" set.

// A state-map fold — the current adventurer roster of a party.
// Handles both additions and removals, so the fold is always
// consistent with the latest append.
type Hero struct {
    Name  string
    Class string
}

var PartyRosterFold = scroll.Fold[map[string]Hero]{
    Apply: func(roster map[string]Hero, e scroll.Event) (map[string]Hero, error) {
        if roster == nil {
            roster = make(map[string]Hero)
        }

        switch e.Topic {
        case TopicAdventurerJoined:
            var ev AdventurerJoined
            if err := json.Unmarshal(e.Data, &ev); err != nil { return roster, err }
            roster[ev.HeroID] = Hero{Name: ev.Name, Class: ev.Class}

        case TopicAdventurerLeft:
            var ev AdventurerLeft
            if err := json.Unmarshal(e.Data, &ev); err != nil { return roster, err }
            delete(roster, ev.HeroID)
        }

        return roster, nil
    },
}

The nil-check-then-initialize pattern on the accumulator is idiomatic. Init can set up the map if you prefer, but lazy init keeps the fold's zero value usable in tests without extra wiring.

Index — mapping IDs across reactors

The third canonical shape. A projector creates a domain entity and emits a marker event linking the signal it came from to the entity ID. An index fold rolls those markers up into a stable map[signalID]entityID that any downstream reactor can use to translate references.

// An index fold — signal IDs to the quest IDs they became.
// Downstream reactors use this to translate LLM-produced signal
// references into real domain entity IDs.
var QuestIDBySignalFold = scroll.Fold[map[string]string]{
    Apply: func(m map[string]string, e scroll.Event) (map[string]string, error) {
        if m == nil {
            m = make(map[string]string)
        }

        if e.Topic == TopicQuestProjected {
            var ev QuestProjected
            if err := json.Unmarshal(e.Data, &ev); err != nil { return m, err }
            m[ev.SignalID] = ev.QuestID
        }

        return m, nil
    },
}

This is the pattern that makes the intent-driven pipeline guide work at scale. Without the index, every reactor would need either a MongoDB lookup or a duplicated projection read — both of which reintroduce the staleness problems folds are meant to solve.

Calling a fold inside a reactor

The contract that makes folds load-bearing: inside a reactor handler, folding the source scroll gives you state that is always consistent with the latest append. No catch-up barrier, no stale projection.

// Using a fold inside a reactor — the fold replays from the same
// scroll this tick is processing, so state is consistent with the
// latest append. No catch-up barrier, no stale projection read.
weave.HandleFunc(func(ctx context.Context, events []scroll.Event) error {
    accepted, err := scroll.FoldEvents(ctx, questBoardScroll, AcceptedQuestsFold)
    if err != nil {
        return fmt.Errorf("fold accepted: %w", err)
    }

    for _, e := range events {
        // accepted is a consistent map of every quest accepted so far,
        // including ones appended earlier in this same pipeline tick.
        decide(ctx, e, accepted)
    }
    return nil
})

If the reactor already appended something earlier in this tick, the fold sees it. If the projection catch-up job hasn't finished, the fold doesn't care — it's not reading the projection. This is why reactors fold the scroll they react to instead of querying a read model.

When to fold, when not to

  • Fold when you need derived state inside a reactor. The fold is consistent with the scroll's latest append; a projection isn't.
  • Fold when the state is small relative to event volume. Counters and maps of a few hundred entries fit; a million-entry index probably wants a persistent projection.
  • Don't fold for UI reads. UIs and external APIs query projections — indexed, cached, optimized for the shape callers need. Folding a scroll on every request is how you learn that event streams grow forever.
  • Don't fold across scroll boundaries. A fold is scoped to one scroll. If you need state from several scrolls, fold each one and combine in the handler — or design the source scrolls so the fold you need lives on one of them.

What's next