Modules and trade-offs
Every other concept page treats one specific behavior as swappable: a source in The pipeline, a matcher in Predicates. This page steps back and treats “swappable” itself as the organizing idea: what a module actually is, how one gets into a running binary, and what it means for an operator that every axis of behavior in blockwatcher is one.
flowchart LR
rpc["RPC endpoints<br/>(external chains)"]
%% Source modules are family-owned; the running list is GET /catalog, not this map.
sources["Sources<br/>one per network"]
sinks["Sinks<br/>webhook · script · log"]
storage["Storage<br/>checkpoints · dead letters · resources"]
api["REST API"]
metrics["Metrics"]
engine["engine<br/>bounded channels · checkpoints"]
subgraph pipeline["Engine pipeline"]
direction LR
decoder["Decoder"]
matcher["Matcher<br/>predicates"]
gate["Gate<br/>threshold · max_once"]
decoder --> matcher
matcher --> gate
end
rpc --> sources
sources -->|"decode and match"| decoder
gate --> sinks
api -->|"manages resources"| storage
storage <--> engine
engine -->|"drives"| pipeline
engine -.->|"reports"| metrics
classDef module fill:none,stroke:#a9a3e3
classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5
class rpc,sources,sinks,decoder,matcher,gate module
class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,api,metrics,engine dim
class sources,decoder,matcher,gate,sinks,storage focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"
Key takeaways
- blockwatcher has exactly six ports (
Source,Decoder,Matcher,Gate,Sink,Storage), and a module is one concrete implementation of exactly one of them. - A module never straddles two ports, even when several modules live in the same crate, and configuration resolves a name against the running binary’s own module catalog.
- Two modules behind the same port can trade off completeness against something else. That trade-off is a property of the module, documented on the module’s catalog row — chain-family rows live on the family page.
- Switching modules is the operator’s actual lever for a trade-off: changing one module leaves every other port’s behavior untouched, since none of them are written against one module’s assumptions.
Everything an operator composes is a module
An blockwatcher deployment has exactly six axes along which behavior can differ:
how it reads a chain, how it turns raw bytes into blockwatcher’s canonical shape,
how it decides whether a decoded occurrence counts, when a predicate-true
hit becomes a delivery, where a match goes, and where resources and
operational state persist. Those six axes are the six
ports blockwatcher defines (Source,
Decoder, Matcher, Gate, Sink, Storage), and a module is, by
definition, one concrete implementation of exactly one of them. Nothing in
blockwatcher is configured by writing code against a bespoke integration
point; every one of these six choices is made the same way, by naming a
module in configuration and giving it whatever config object that module
expects.
The six ports look like this. Chain-family sources and decoders are registered the same way; they are named on each family page, not here.
flowchart TD
source["Source"]
decoder["Decoder"]
matcher["Matcher"] --> expr["expr"]
gate["Gate"] --> threshold["threshold"]
gate --> maxonce["max_once"]
sink["Sink"] --> webhook["webhook"]
sink --> script["script"]
sink --> log["log"]
storage["Storage"] --> memory["memory"]
storage --> sqlite["sqlite"]
storage --> postgres["postgres"]
%% Layout only, no meaning: these invisible edges wrap the six ports
%% into bands. Without them every port sits on one row, which renders
%% too wide for the content column and shrinks the labels. Keep them.
source ~~~ sink
decoder ~~~ storage
expr ~~~ maxonce
ModuleCatalog (crates/blockwatcher-core/src/catalog.rs) is where that
naming resolves: one name-keyed map per port family, each holding factory
functions folded in from every compiled-in module’s own get_all()
enumeration. A config that names a module absent from the catalog (a typo,
or a module simply not compiled into this build) fails at boot or at write
time with EngineError::UnknownModule, listing every alternative that
actually is registered, via unknown and the family! macro’s lookup arm
(catalog.rs), rather than panicking or
silently no-op’ing that pipeline stage. Because the list comes from the
catalog itself, the message always names what this particular binary
carries, never a superset the workspace merely contains somewhere.
One module, one port, one name
A module never straddles two ports. A chain-family crate ships one
Decoder and one or more Source implementations as separate modules,
each registered under its own name and each satisfying exactly one port’s
trait, never blending source and decode logic into a single type. Both
kinds of module fold through build_catalog
(crates/blockwatcher-embed/src/catalog.rs), each get_all() enumeration
separately. Which module handles which resource is itself fixed by the port: a
network names its Source module,
a spec’s chain determines its
Decoder, a sink resource names its
Sink module, blockwatcher.toml names the one Matcher and one Storage
backend for the whole process. An operator never picks a module without
also picking, structurally, which port it fills.
Trade-offs are a property of the module, not a setting
Because two modules behind the same port are interchangeable at the trait
level, they are free to differ arbitrarily in the trade-offs they make.
blockwatcher leans on that rather than trying to expose every axis as a
tunable knob on one do-everything implementation. The clearest illustration
is two sources behind Source: one that only reports activity once it has
a fixed chain position (and can therefore resume after a crash), against
one that reports earlier at the cost of a cursor that is not a chain
position. That pair, and every other chain-family trade-off, is documented
on the family page, not as a setting on a shared
source. evm-subgraph is a third acquisition module on the
EVM family page, not a second registration path and not
a knob on that rpc/mempool pair: it polls a documented GraphQL logs schema
and emits the same confirmed-block envelopes evm-rpc does, covering only
what that subgraph indexed.
The same pattern of “trade-off lives in which module you picked, not in a
shared config surface” carries across the other five ports as well: the
webhook sink trades a network dependency for delivering anywhere HTTP
reaches; the log sink trades reach for having none; sqlite storage
trades a single-writer constraint for surviving a restart, memory
storage trades restart survival for zero setup; threshold trades
per-hit delivery for a session digest that does not stall the checkpoint;
max_once trades later in-window hits for a single alert.
The module catalog
Every row in the matcher, gate, sink, and storage table below is a module
registered into ModuleCatalog by one of the get_all() calls
build_catalog (crates/blockwatcher-embed/src/catalog.rs) folds together.
Chain-family sources and decoders live on the family
page. The running binary’s full list is GET /catalog.
Each source module’s family-page row states two facts: whether it reports a
confirmed tip, and which cold-start field it moves (if any). GET /catalog
advertises the tip half as caps.confirmed_tip on each source object.
| Family | Module | Confirmed tip | Cold start |
|---|---|---|---|
| source | evm-rpc | yes | start_block |
| source | evm-subgraph | yes | start_block |
| source | evm-mempool | no | none |
| source | stellar-rpc | no | start_ledger |
The table below is exhaustive for matcher, gate, sink, and storage modules: every such module compiled into this binary appears here, and every row names something a real registration call actually produces.
| Family | Module | Registered in | Trade-off |
|---|---|---|---|
| matcher | expr | crates/blockwatcher-expr/src/matcher.rs | The only predicate engine shipped; selected once for the whole process rather than per monitor, trading per-monitor flexibility for one well-tested evaluation path. |
| gate | threshold | crates/blockwatcher-gates/ | Session digest: N hits spanning ≤ window_ms of event time fire once, then the bag resets. Holds persist without stalling the checkpoint. Requires block.timestamp. |
| gate | max_once | crates/blockwatcher-gates/ | At most one alert per event-time window; later hits discarded, not dead-lettered. Requires block.timestamp. |
| sink | webhook | crates/blockwatcher-sinks/src/webhook.rs (enumerated crates/blockwatcher-sinks/src/registry.rs) | Reaches anywhere HTTP does, at the cost of a network dependency and a target that must itself stay reachable and fast enough not to trip the engine’s retry/dead-letter policy. url_secret and, for a header such as Authorization, header_secrets carry destination credentials as env:NAME references resolved per delivery rather than stored plaintext; headers stays for values that are not secret, and one header name cannot appear in both maps. |
| sink | script | crates/blockwatcher-sinks/src/script.rs (enumerated registry.rs) | Hands a match to an operator-authored program over stdin (arbitrary local logic), at the cost of owning that program’s own reliability and timeout behavior. |
| sink | log | crates/blockwatcher-sinks/src/log.rs (enumerated registry.rs) | Zero external dependency, one JSON line per match to stdout: the simplest possible delivery target, useful for piping and testing rather than production reach. |
| storage | memory | crates/blockwatcher-storage/src/memory.rs (enumerated crates/blockwatcher-storage/src/registry.rs) | No persistence at all: trades restart survival for zero setup, since checkpoints, dead letters, and resources are all gone the moment the process exits. |
| storage | sqlite | crates/blockwatcher-storage/src/sqlite.rs (enumerated registry.rs) | Single-file, single-writer, one process: survives a restart, at the cost of the concurrent-writer scaling a networked database would offer instead. |
| storage | postgres | crates/blockwatcher-storage/src/postgres/ (enumerated registry.rs, feature postgres) | Networked, multi-writer durable state so an engine can restart or idle without a local file, at the cost of an external server and a feature-gated binary (off in the archive default). |
Omit-gate is engine passthrough, not a catalog row an operator must name. Matcher is still one module for the whole process. Gate is per monitor, like sink.
Module selection is by name in configuration; a name the running binary never registered fails loudly, with the list of names it actually did register, rather than silently doing nothing.
Why this is the operator’s actual lever
Because a trade-off lives inside the module rather than in a shared knob, “choose your trade-offs” and “choose your modules” are the same action for an operator. Wanting a different point on a family’s latency-vs-completeness trade-off means naming a different source module on the network, not flipping a setting on one source that tries to serve both goals at once. The six-port boundary is what makes that swap safe to make at all: switching sources changes nothing about how the decoder, the matcher, a gate, or any configured sink behaves, because none of them were ever written against one source’s assumptions to begin with.