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.