Guide · 2

Intent-driven pipelines

The pattern that makes weave pipelines correctable: capture what the adventurer intended, not the database mutation that implements it. We'll build a quest board — three scrolls, two reactors, one rule.

The scenario

A guild's quest board takes proposals — "slay the goblin chief," "recover the lost relic," "escort the merchant north" — from adventurers who chat with a herald. The board doesn't post everything the herald hears. A Dungeon Master vets each proposal for duplicates, scope, and reward balance before it goes up. Once posted, the quest picks up enrichment — difficulty, terrain, monster roster — and eventually becomes a row on the public board the rest of the guild sees.

That's the shape: proposals flow through a reconciler, accepted proposals become domain entities, and enriched domain state feeds the read model. Every pipeline of that shape wants the same three scrolls and the same two reactors between them.

Adventurer turn / system event


  ┌──────────────────┐
   quest board   proposals, intents, verdicts
  └──────────────────┘

  Dungeon Master  conflict detector


  quest_accepted / herald_rejected / conflict_resolved

       projector


  ┌──────────────────┐
  quest scroll   quests, monsters, parties,
  └──────────────────┘

     enricher (fan-out nanos)


  ┌──────────────────┐
 projections (DB) │   read-only, eventually consistent
  └──────────────────┘

Three scrolls — signals, domain, projections — and two reactors between each pair. The signals scroll is the intake surface; nothing touches the domain scroll directly. Everything proposed goes through a reconciler first.

Why intent, not action

The instinct is to write db.quests.updateOne(...) the moment the model produces a new field. It's the shortest path from LLM to UI. It's also the path that makes the system unobservable and uncorrectable. What you lose:

Conflict detection

Two intents on the same target are a visible fact. Two direct DB writes are last-write-wins — the conflict vanishes silently and you learn about it from a user bug report months later.

Audit trail

Intent events carry evidence, reason, sourceMessageId — the why of each change. Database patches carry only the what. You can't reconstruct motive from a mutation log.

Re-projection

If the materialization logic changes — new enrichment axis, different scoring, corrected schema — you replay intents through the new projector. You cannot replay DB patches into a better state than the one they produced.

Judgment beats fire-and-forget

Intents flow through a reconciler that can accept, reject, defer, or supersede. Direct writes can't be rejected — they've already happened. The separation is what makes the system correctable.

Intent events are past tense and specific: a quest was proposed, not please create a quest. They commit the desire to do something; what actually happens is a consequence committed later by a reconciler or projector.

The signals scroll

The signals scroll holds three kinds of event: proposals (new things the adventurer or system wants to add), intents (edits, removals, renames on existing things), and verdicts (the reconciler's accept/reject decisions plus the projector's "this signal became this entity" markers).

// Intent events — what the adventurer or system wanted to happen.
event "quest_proposed" {
  boardId         string
  summary         string     // a one-line quest description
  category        string     // bounty | escort | recovery | investigation
  evidence        string     // why this was proposed
  sourceMessageId string     // traceability back to the turn
  turnSequence    int64
}

event "intent_edit" {
  boardId   string
  targetRef string    // stable reference to the quest being edited
  newValue  string
}

event "intent_remove" {
  boardId   string
  targetRef string
}

// Verdict events — what the reconciler decided.
event "quest_accepted" {
  signalId         string
  domainScroll     string    // which domain scroll to project into
  domainEventTopic string    // which domain event this maps to
  artifactId       string    // the created quest ID
}

event "herald_rejected" {
  signalId string
  reason   string
  guidance string     // what the adventurer should do differently
}

One signals scroll per aggregate instance — for the quest board that's one per board, keyed quest_board:{board-id}. The turnSequence and sourceMessageId fields thread every proposal back to the conversation turn that produced it, so any verdict stays linked to the evidence the adventurer actually gave.

Reconciler — proposals in, verdicts out

A reconciler subscribes to one kind of proposal, folds the existing accepted state from the source scroll, and emits a verdict. It never writes to a domain scroll — only to the signals scroll where it reads from.

// A reconciler — proposals in, verdicts out.
// Nothing touches domain scrolls from here; only the quest board.
reconcile := weave.NewReactor("reconcile-quest",
    questBoardScroll, heraldState,
    weave.OnTopic(TopicQuestProposed),
    weave.HandleFunc(func(ctx context.Context, events []scroll.Event) error {
        // 1. Fold existing accepted quests — always consistent with the scroll.
        accepted, err := scroll.FoldEvents(ctx, questBoardScroll, AcceptedQuestsFold)
        if err != nil {
            return fmt.Errorf("fold accepted: %w", err)
        }

        for _, e := range events {
            var proposal QuestProposed
            if err := json.Unmarshal(e.Data, &proposal); err != nil {
                return err
            }

            // 2. Call the reconciler agent — a mini-tier LLM that returns a verdict.
            var verdict Verdict
            if _, err := w.Execute(ctx, dungeonMaster,
                weave.Context{
                    "proposal": proposal,
                    "accepted": accepted,
                },
                &verdict,
            ); err != nil {
                return err
            }

            // 3. Emit the verdict to the consumer side of the quest board.
            switch verdict.Kind {
            case VerdictAccept:
                consumer.AppendQuestAccepted(ctx, ...)
            case VerdictDuplicate, VerdictReject:
                consumer.AppendHeraldRejected(ctx, ...)
            }
        }
        return nil
    }),
)

The fold is the key move. Calling a MongoDB projection here would introduce a race: the projection might not yet reflect the events this reactor just processed. Folding the source scroll gives you state that is always consistent with the latest append, because it replays the same event stream the reactor is reading from.

Projector — verdicts in, domain entities out

Once a proposal is accepted, a projector subscribes to the acceptance, looks up the original proposal payload, and creates the actual domain entity. This is the one place where the signals scroll meets a domain scroll.

// A projector — accepted verdicts in, domain entities out.
// This is the one place the quest board meets the quest scroll.
project := weave.NewReactor("post-quest",
    questBoardScroll, posterState,
    weave.OnTopic(TopicQuestAccepted),
    weave.HandleFunc(func(ctx context.Context, events []scroll.Event) error {
        for _, e := range events {
            var accepted QuestAccepted
            if err := json.Unmarshal(e.Data, &accepted); err != nil {
                return err
            }

            // Filter to quest acceptances only.
            if accepted.DomainEventTopic != DomainTopicQuestCreated {
                continue
            }

            // Look up the original proposal payload.
            proposal := lookupProposal(ctx, questBoardScroll, accepted.SignalID)

            // Create the domain entity on the quest scroll.
            questID, err := questService.CreateQuest(ctx, proposal)
            if err != nil {
                return err
            }

            // Emit a projection marker so downstream folds can map
            // signal IDs to real quest IDs.
            consumer.AppendQuestProjected(ctx, QuestProjected{
                SignalID: accepted.SignalID,
                QuestID:  questID,
            })
        }
        return nil
    }),
)

The quest_projected marker is what lets downstream reactors translate "signal ID X" (what the LLM knows about) into "domain quest ID Y" (what the database knows about). Folding that marker back from the signals scroll gives any enricher a stable signal-to-entity map — no external lookup, no cache.

Producer / consumer split

A scroll is one thing; the ways you write to it are many. The pattern enforces write-side separation by generating two narrowed interfaces for each signals scroll:

// Two narrowed interfaces over the quest board — enforce who writes what.

// QuestBoardProducer — herald tool handlers hold this.
// Can only append proposals and intents. Cannot write verdicts.
type QuestBoardProducer interface {
    AppendQuestProposed(ctx context.Context, p QuestProposed) error
    AppendRewardProposed(ctx context.Context, r RewardProposed) error
    AppendIntentEdit(ctx context.Context, i IntentEdit) error
    AppendIntentRemove(ctx context.Context, i IntentRemove) error
}

// QuestBoardConsumer — reconciler and projector handlers hold this.
// Can only append verdicts and projection markers. Cannot write proposals.
type QuestBoardConsumer interface {
    AppendQuestAccepted(ctx context.Context, a QuestAccepted) error
    AppendHeraldRejected(ctx context.Context, r HeraldRejected) error
    AppendConflictDetected(ctx context.Context, c ConflictDetected) error
    AppendConflictResolved(ctx context.Context, c ConflictResolved) error
    AppendQuestProjected(ctx context.Context, p QuestProjected) error
}

Herald tool handlers hold a QuestBoardProducer; they can propose but not accept. Reconciler and projector handlers hold a QuestBoardConsumer; they can adjudicate but not propose. The compiler enforces that a reconciler can't accidentally emit a new proposal, and the herald can't write its own acceptance verdicts. The split is the reason review by code reading works at all at this scale.

Folds, not projection reads

Every time a handler needs to know "what's the current state of X on this scroll," the answer is a fold. Folds replay from the event stream and are always consistent with the latest append in the same pipeline tick.

The MongoDB projection is the read model for the outside world (APIs, UIs) — not for the pipeline's own feedback loop. Using a projection as pipeline input is what creates the race conditions you were trying to avoid by going reactive in the first place.

Rule. Inside a reactor handler, fold the source scroll. Outside a reactor (HTTP handlers, CLI commands, user-facing reads), query the projection.

When not to use this shape

  • There is no reconciliation to do. If everything that comes in is valid and goes straight to storage, you don't need a signals scroll. A single domain scroll and a reactor that projects into the read model are enough.
  • There is no external reasoning to capture. The three-layer shape earns its complexity when an LLM or a human is producing the intent and you need to record what they wanted separately from what you did. Pure mechanical pipelines can skip it.
  • Latency requirements make the reconciler too expensive. A reconciler that calls an LLM adds seconds. For synchronous paths where that's unacceptable, accept the lossier audit trail of direct projection and compensate with logging — but know you've made the trade.

What's next

  • Reactor concept — the primitive every pattern in this guide is built on.
  • Agent concept — including the nano / mini / full tiers the reconciler agent uses.
  • Nano decomposition — the fan-out pattern for enrichers that produce many atomic events in parallel. Coming soon.