Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Extending blockwatcher

Every axis of behavior blockwatcher has is a module behind a port trait, and every module follows the same mechanical steps to become selectable. This page walks through those steps against a module that already ships: the log sink (crates/blockwatcher-sinks/src/log.rs), the smallest real module in the workspace. It is a worked example of the general recipe, not a special case: the same steps apply to a new source, decoder, matcher, gate, or storage backend.

The steps are the whole of the mechanism, but they are not the whole of the work: which trait a module implements decides what it actually has to guarantee, and those guarantees differ sharply from port to port. What each port asks of a module is the per-port half, one section per port, and Extending to a new network covers the one change that spans two ports at once.

This page does not restate what already has a canonical home elsewhere: Modules and trade-offs covers what a module is and how the catalog resolves a name at boot; Architecture decisions § Modules are how an operator makes trade-offs and § The cost-of-change budget state the binding rule and the number a change is judged against; Testing strategy covers the three test layers in full; blockwatcher-ports § Key types and traits tabulates every trait and compiled artifact the ports define. This page’s job is narrower: walk one real module through every step, then state what each port asks of the module behind it.

Key takeaways

  • Every axis of behavior is a module behind a port trait, and every module follows the same mechanical steps to become selectable.
  • The steps are identical for all six ports; this page walks the log sink through them as a worked example, then covers per-port obligations separately.
  • What differs per port is the contract, not the mechanism: cursor ordering for Source, chain-keyed selection for Decoder, bounded compilation for Matcher, an engine-owned journal for Gate, engine-owned retry for Sink, and a shared behavioural contract for Storage.
  • A new network on a chain family a build already carries is configuration and touches no code; a new chain family is a new crate implementing Source and Decoder.
  • Modules and trade-offs and Architecture decisions already state what a module is and the binding rule; this page’s job is narrower: one real module through every step, plus each port’s own obligations.

The steps, in order

flowchart LR
    port["implement<br/>the port trait"] --> config["define the<br/>config type"]
    config --> catalog["register in<br/>the catalog"]
    catalog --> allowlist["allowlist entry<br/>(if a new crate<br/>or dependency)"]
    allowlist --> tests["tests against the<br/>fake / example config"]

1. Implement the port trait

A module implements exactly one port trait. Sink is one method:

#![allow(unused)]
fn main() {
#[cfg_attr(feature = "testing", mockall::automock)]
#[async_trait]
pub trait Sink: Send + Sync {
    /// Deliver one event. The ENGINE owns retry/backoff/dead-letter policy;
    /// the sink reports classified errors and does not retry internally.
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError>;
}
}

(crates/blockwatcher-ports/src/sink.rs)

LogSink (crates/blockwatcher-sinks/src/log.rs) implements it in the most direct way a sink can: deliver writes one line to stdout and returns whatever the write reports:

#![allow(unused)]
fn main() {
#[async_trait]
impl Sink for LogSink {
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError> {
        self.deliver_to(event, &mut tokio::io::stdout()).await
    }
}
}

(log.rs)

A compile-time assertion right next to the struct, rather than a comment, is what actually proves the trait is implemented:

#![allow(unused)]
fn main() {
const _: fn() = || {
    fn assert_port<T: Sink>() {}
    assert_port::<LogSink>();
};
}

(log.rs:23-26)

Whatever error a module’s own logic produces has to become the port’s own error type at the point of contact, never a foreign one handed further in (the same rule Architecture decisions § Foreign shapes get translated once, at the door states in general). LogSink classifies a std::io::Error into SinkError::Delivery with an explicit ErrorClass, and its own doc comment argues the specific choice rather than asserting it:

#![allow(unused)]
fn main() {
/// A broken pipe will not reopen itself, so a reader could argue this
/// should classify as `Permanent`. `Transient` is the safer default: the
/// engine's retry budget bounds how long a truly dead consumer costs, while
/// a wrong `Permanent` classification dead-letters a match that a
/// momentarily blocked consumer (a slow reader, a full OS pipe buffer)
/// would have accepted on the next attempt. Losing a match to an
/// under-classified retry budget is not possible; losing one to an
/// over-eager `Permanent` is.
fn transient(e: std::io::Error) -> SinkError {
    SinkError::Delivery {
        message: format!("stdout write failed: {e}"),
        class: ErrorClass::Transient,
    }
}
}

(log.rs:64-77)

2. Define the config type

A module’s config is a private serde-deserialized struct with deny_unknown_fields, reachable only through the module’s own factory, never a public type another crate could reach into directly. log takes none, and still enforces the rule on an empty shape, so an unrecognized key is a boot-time typo rather than a silent no-op:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
struct Config {}
}

(log.rs:86-88)

3. Register a name and a factory, right beside the code

A module declares its own selectable name and how to build itself through blockwatcher-ports’ one registration contract, ModuleRegistry (crates/blockwatcher-ports/src/registry.rs): a NAME constant, a Factory type alias (one of the *Factory aliases beside each port trait), and a factory() that returns a function from opaque JSON config to a constructed Arc<dyn Port>. log’s registration is the whole of its public surface beyond LogSink itself:

#![allow(unused)]
fn main() {
impl ModuleRegistry for Registry {
    const NAME: &'static str = "log";
    type Factory = SinkFactory;
    fn factory() -> Self::Factory {
        |config| {
            Box::pin(async move {
                let Config {} =
                    serde_json::from_value(config).map_err(|e| SinkError::InvalidConfig {
                        message: format!("invalid log config: {e}"),
                    })?;
                Ok(Arc::new(LogSink) as Arc<dyn Sink>)
            })
        }
    }
}
}

(log.rs:102-116)

A crate that ships more than one module in a family folds every registration into one get_all() enumeration, and that enumeration, not the trait impl, is what actually makes a module selectable: a module absent from it can never be selected by config, however completely it implements Sink. Adding log to blockwatcher-sinks’ family enumeration is one entry:

#![allow(unused)]
fn main() {
pub fn get_all() -> Vec<(&'static str, SinkFactory)> {
    vec![
        (crate::log::Registry::NAME, crate::log::Registry::factory()),
        (
            crate::webhook::Registry::NAME,
            crate::webhook::Registry::factory(),
        ),
        (
            crate::script::Registry::NAME,
            crate::script::Registry::factory(),
        ),
    ]
}
}

(crates/blockwatcher-sinks/src/registry.rs:9-21)

4. Register in the composition root’s catalog

get_all() only enumerates a family within one crate; a running binary still has to fold every family’s enumeration into one ModuleCatalog (crates/blockwatcher-core/src/catalog.rs). The composition façade blockwatcher-embed does that once, per family, feature-gated so what a build can select is exactly what it linked. The blockwatcher binary calls the same build_catalog; it must not keep a second registrar.

#![allow(unused)]
fn main() {
#[cfg(feature = "sinks")]
for (name, factory) in blockwatcher_sinks::registry::sinks::get_all() {
    catalog.register_sink(name, factory)?;
}
}

(crates/blockwatcher-embed/src/catalog.rs)

Another module joining a family a build already carries (a new sink beside the ones a build already registers, webhook, script, and log) touches only step 3 above: its crate’s own get_all() gains one entry, and this composition-root fold picks it up automatically on the next build, because the fold iterates the enumeration rather than naming each module by hand. Only a brand-new family joining the binary for the first time (a new chain family’s sources and decoders, or a first module behind a port this binary has never carried before) needs a new block here, and, if it arrives in its own crate, a new feature flag beside evm, expr, and sinks in crates/blockwatcher/Cargo.toml (blockwatcher (binary) § Feature-flag wiring covers the flag wiring itself).

5. Update the dependency gate’s allowlist, when the change needs it

scripts/check-dep-graph.sh enforces every crate’s allowed dependencies by name; Architecture decisions § How the dependency gate turns rules into a mechanical check covers its two-layer allowlist-plus-denylist mechanics in full, so this page only states when adding a module actually touches it: a module added to a crate whose allowlist already covers every dependency that module needs (as log does; it adds no dependency blockwatcher-sinks’s existing ALLOW_BLOCKWATCHER_SINKS entry does not already carry) touches nothing here at all. The script’s allowlist needs an edit only when the change also introduces something new to the dependency graph: a brand-new module crate needs its own ALLOW_BLOCKWATCHER_<CRATE> entry (and a FAMILY_EXEMPT_BLOCKWATCHER_<CRATE> entry if it pulls a denylisted family such as a chain SDK or an HTTP client on purpose) and its name added to ALLOW_BLOCKWATCHER_EMBED, the crate build_catalog lives in. A module added to an existing crate needs an edit only if it brings a dependency that crate did not already declare. The binary’s ALLOW_BLOCKWATCHER entry is not that list: evm / expr / sinks reach the process through embed, not as direct dependencies of blockwatcher.

6. Add tests against the port’s fake, or the family’s own contract

Which test infrastructure a new module tests against depends on what already exists for its port:

  • Every port ships an in-memory fake behind blockwatcher-portsfakes feature (FakeSink, FakeSource, FakeDecoder, FakeMatcher, MemoryStorage), registered through the same ModuleRegistry contract a real module uses. Something else that needs a sink without linking yours reaches for FakeSink instead, the same way blockwatcher-sinks’s own dev-dependency on blockwatcher-core exercises a real sink through blockwatcher-core’s actual engine and sink-worker machinery rather than a reimplemented harness. Storage additionally has a shared behavioral contract, exercise_storage_contract (blockwatcher-testkit § The storage contract), that blockwatcher-storage’s own module and the ports fake both run against, so a new storage module is proven equivalent to the existing ones by the same contract rather than by shared code; no other port has an equivalent shared runner. Source has the next closest thing, blockwatcher-testkit’s recv and stop (blockwatcher-testkit § Driving a source): not a contract over a whole backend, but deadline-bounded drivers that fail a source which stops emitting or ignores its cancellation token instead of hanging the suite.
  • Every module crate proves its own registered modules construct from their own documented example config, in one self-verifying test per crate. blockwatcher-sinks’ version is sinks_get_all_constructs_every_entry_from_its_documented_example_config (registry.rs): it reads each module’s registry_examples/<name>.json and runs the module’s factory against it, so the enumeration in step 3 and the example config in a module’s own rustdoc header cannot drift apart. Adding a module means adding one match arm here alongside its own registry_examples/<name>.json.
  • A module’s own #[cfg(test)] block proves what is specific to it: log proves the emitted line is the canonical body plus one newline, in the_emitted_line_is_the_canonical_body_newline_terminated (log.rs), that concurrent deliveries never interleave a line, in concurrent_large_deliveries_never_interleave_their_lines (log.rs), and that an unrecognized config key is rejected as SinkError::InvalidConfig rather than silently ignored, in factory_rejects_unknown_config_fields_as_invalid_config (log.rs).

Testing strategy’s layering is what these three map onto: the fake and the family-completeness test are unit-layer proof that construction and substitution work; a module’s own test suite that exercises something wire-level or storage-level (a real sqlite file, a scripted mock node) is the module layer above it.

What each port asks of a module

Steps 1 through 6 do not change from port to port. What a module has to guarantee does, and the guarantees are not symmetric: Sink is one method with one rule, while Source carries an ordering invariant nothing checks at compile time and Storage has the widest surface of the six. Each port’s own trait file in blockwatcher-ports is the authority, and its doc comments argue each rule rather than assert it; the sections below are the contributor’s map of what to satisfy and which shipped module to read as a reference.

The obligations worth listing here are the ones a compiler cannot enforce. A module that violates one of them still builds, still registers, and still runs, so each is stated with the failure it produces.

Source

  • Trait (crates/blockwatcher-ports/src/source.rs): run is required. scan (bounded history fetch, behind a monitor dry run’s fetch) and confirmed_tip (the confirmed head for a cold start) both default to SourceError::Unsupported, so a streaming-only source implements neither and stays a fully valid source.
  • Reference modules: evm-rpc and evm-mempool (blockwatcher-evm § The sources).
  • Selected on: a network resource’s source.module.
  • Test infrastructure: FakeSource, plus blockwatcher-testkit’s recv and stop for driving a running source under a deadline.

Obligations run carries beyond its signature:

  • Non-decreasing cursor order, from one loop. The processor’s checkpoint tracker treats arrival order on ctx.events as cursor order. A source that reorders events, or that shares the sender across concurrent producers, publishes a corrupt checkpoint rather than failing, which is the silent loss this project ranks worst. A single source-owned loop satisfies this by construction; nothing else does.
  • The source owns the cursor mapping. Core needs only ordering and serialization and never interprets primary or secondary (crates/blockwatcher-types/src/cursor.rs). EVM’s confirmed source uses primary = block number and packs secondary as (kind << 32) | index so a block’s transactions order ahead of its logs; the mempool source uses primary = arrival counter, secondary = 0. A new source picks its own mapping and documents it, because that mapping is what journal_depth, lag reporting, and rewind ranges are all denominated in.
  • send().await is the backpressure. ctx.events is bounded; a source that drops rather than awaits turns a slow pipeline into missing matches.
  • Cancellation is a normal end. ctx.cancel firing means return SourceOutcome::Ended: not an error, not an invalidate, not a panic.
  • A rewind is a value, not an error. SourceOutcome::Invalidated { from } is how a source reports that everything past from was on a dead fork, and the engine handles it as a control path (Delivery guarantees covers the retract pass it triggers). A source over a feed with no finality, such as a mempool, never produces it.
  • ctx.interest narrows fetching, never matching. Hints are eventually consistent with the monitor set, never synchronised with it, so a source that used a hint to decide what matched would drop matches for monitors added inside that window. The pipeline decides what matched. referenced_fields being None means unknown, which is why an empty set and an absent set are different values.
  • ctx.status is what an operator sees. Live, CatchingUp, and their optional head cursor are what the control plane reports lag against the pipeline’s checkpoint; a source that never publishes status reports no lag rather than no problem.
  • Checkpoint.source_state is module-private. Nothing else reads it. The engine stamps Checkpoint.module and refuses to resume a checkpoint written by a different source module, because a cursor is only meaningful in its writer’s units. A source over a feed that cannot replay from a stored position must document, as its own trade-off, what its checkpoints mean.

Decoder

  • Trait (crates/blockwatcher-ports/src/decoder.rs): chain, compile_spec, compile, and decode are required. interest defaults to no hints and merge_interest defaults to unioning addresses and signatures.
  • Reference module: evm (blockwatcher-evm § The decoder).
  • Selected on: nothing. This is the one port an operator never names.
  • Test infrastructure: FakeDecoder, plus a golden-file suite (crates/blockwatcher-evm/tests/golden_schemas.rs, golden_decode.rs).

A decoder is chosen by chain rather than by name, and that changes steps 2 and 3 for it specifically. At boot the engine constructs one instance of every registered decoder module, each with an empty config object, indexes them by whatever chain() returns, and refuses boot with EngineError::DuplicateChainDecoder when two modules claim the same ChainKind (validate_and_build, crates/blockwatcher-core/src/engine/boot.rs). ChainKind is an open string set (crates/blockwatcher-types/src/id.rs), never an enum, so a new family adds a value and touches no existing type. Because every decoder is constructed from {}, a decoder has no useful per-module config: define the empty Config with deny_unknown_fields anyway, exactly as log does in step 2.

Obligations:

  • Translate once, at the door. CompiledSpec::new(schemas, inner) splits a chain artifact into the chain-agnostic SchemaSet the engine, API, and matcher work from, and a module-private inner only this family downcasts. Nothing chain-shaped may travel further in (Chain-agnosticism § Core must not know chains).
  • compile resolves only the specs its selectors name. specs is the whole chain’s set, and a RawSelector whose spec is None is not licence to read every spec on the chain. The control plane depends on this: deleting a spec no stored monitor names skips the pipeline restart a changed spec forces, precisely because no compiled set can contain it. A decoder that resolved more than it named would keep serving an artifact built from a deleted spec.
  • Same event name, two field sets, is a decision the decoder must make. The port deliberately leaves it open: union the declarations or reject the ambiguity, but a dropped declaration must not be invisible. FakeDecoder keeps the first and discards the second, which is a test-double convenience, not a pattern to imitate.
  • Declared field names must match the shapes decoded. Predicate compilation matches a schema field name flat, so "order.maker" is one name, while evaluation traverses the decoded event (order then maker, digit segments indexing arrays). A decoder that declares a dotted name and emits it as a literal key ships monitors that compile cleanly and never fire. A flat digit-bearing declaration is the same trap from the other side: args.path.0 already type-checks through a declared path: Array(T) by derivation, and declaring "path.0" as well shadows that derivation, type-checking the predicate against one type while the event delivers another. Every decoder’s golden-file suite therefore has to pair each dotted or digit-bearing declaration with the event shape it decodes to, so the contract is executable per module rather than prose.
  • decode output order is deterministic. Match identity derives from each event’s index in that output, so a reordering changes ids and breaks downstream deduplication. A payload this decoder recognizes as malformed is DecodeOutcome::undecodable(), which the engine counts; a payload no selector wanted is no_match(), which is not a failure.
  • Filling chain_specific obliges overriding merge_interest. The default drops it, because only the decoder that produced an erased payload knows how to combine two of them. A decoder that populates it and does not override loses every hint past the first monitor, silently.

Matcher

  • Trait (crates/blockwatcher-ports/src/matcher.rs): compile and matches are required. explain defaults to Explanation::Unsupported and referenced_fields to None, so a matcher that supports neither is still fully valid.
  • Reference module: expr (blockwatcher-expr, and Predicates and the expression language for the language it implements).
  • Selected on: [engine].matcher in blockwatcher.toml, instance-wide. One matcher runs per process, and EngineConfig has no overall default because it refuses to guess which. A control surface that recompiles a monitor compiles against the running instance, never a fresh one, because a predicate compiled against a different instance is not guaranteed to mean the same thing to the one evaluating it.
  • Test infrastructure: FakeMatcher, plus blockwatcher-expr’s property tests (crates/blockwatcher-expr/src/proptests) as the model for a language module: round-trip, integer edge cases, three-valued logic, and a no-panic sweep.

Obligations:

  • compile must bound its own work. The predicate source is operator-controlled. The engine bounds the length it hands in, but a bounded input can still spell unbounded work, and a compile that can be driven to exhaust a stack or heap is a write-path denial of service. This is the obligation most easily missed by a recursive-descent parser written without an explicit depth limit.
  • A missing field is Ok(false), never an error. matches is the hot path, and the third truth value is unknown rather than failure.
  • referenced_fields spells paths as the predicate source spells them (tx.gas, args.order.maker). None means this matcher cannot introspect itself, which forces every caller to assume any field may be read. That is why None is the default: it is the answer that cannot cause a source to skip work a monitor depended on.

Sink

The worked example above, in full. In summary:

  • Trait (crates/blockwatcher-ports/src/sink.rs): deliver is the only method, with no defaulted ones.
  • Reference modules: log, webhook, script (crates/blockwatcher-sinks/).
  • Selected on: a sink resource’s module.
  • Test infrastructure: FakeSink.
  • Obligations: the engine owns retry, backoff, and dead-lettering, so a sink that retries internally is a bug; it reports a classified SinkError::Delivery and returns. Both SinkEvent::Match and SinkEvent::Retracted are at-least-once, so a sink must be idempotent on match_id in both directions (Delivery guarantees).

Gate

  • Trait (crates/blockwatcher-ports/src/gate.rs): compile, on_hit, default no-op on_invalidate.
  • Reference modules: threshold, max_once (crates/blockwatcher-gates/).
  • Selected on: a monitor resource’s gate.module.
  • Test infrastructure: PassthroughGate (always Emit([this])).
  • Obligations: the engine owns the journal, so a module that opens storage is a bug. On invalidate the engine already prunes gate_hits with cursor > from; a module must not drain-all in on_invalidate. P11: two real modules plus the fake already satisfy the port; a third module is a new crate, not a core enum arm. See Gates.

Storage

  • Trait (crates/blockwatcher-ports/src/storage.rs): the widest of the six, covering resource CRUD under optimistic concurrency, checkpoints, dead letters, and the bounded delivery journal. Only put_batch is defaulted, as a plain loop over put.
  • Reference modules: memory and sqlite (blockwatcher-storage, whose § The Storage port contract is the canonical statement of the rules below).
  • Selected on: [storage] in blockwatcher.toml, instance-wide.
  • Test infrastructure: the only port with a shared behavioural contract, exercise_storage_contract. A new backend runs exactly the sequence memory, sqlite, and the ports fake already run, which is what proves it equivalent to them without sharing code. FlakyStorage wraps a backend to inject failures on top.

Obligations:

  • Optimistic concurrency, never a silent overwrite. expected_version: None creates and conflicts if the id exists; Some(v) updates and conflicts on mismatch, NotFound when absent. A create returns version 1. A rejected write must leave state untouched, which the contract checks directly.
  • list and list_checkpoints are unpaginated by contract. Resource and checkpoint counts are operator-scale, not event-scale, so a backend must not invent paging that callers would silently truncate against.
  • record_delivery prunes inside the same write. Rows whose cursor.primary falls strictly behind cursor.primary.saturating_sub(journal_depth) are dropped as part of recording, and re-recording a match id replaces its row. A backend that prunes lazily lets the journal grow without bound (Delivery guarantees § The delivery journal).
  • Round-trip values and cursors beyond a typed column’s range. Cursors are u64 and decoded values carry arbitrary-precision integers, so a backend built on signed 64-bit columns has to say how it stores them; the contract exercises exactly this.
  • Overriding put_batch is what atomicity means here. The default is correct everywhere and atomic nowhere: a mid-batch failure leaves earlier entries written. A backend with a native transaction overrides it, and the one caller that needs the guarantee, the seed path, says so in its own docs.

Extending to a new network

Two different changes both get called “adding a network”, and only one of them is code. Which one applies depends entirely on whether the chain’s family already has a decoder in the build.

flowchart TD
    q{"is a Decoder for this<br/>chain already registered?"}
    q -->|yes| conf["configuration only:<br/>Network + Spec resources"]
    q -->|no| fam["new chain family:<br/>new crate with a Source<br/>and a Decoder"]
    conf --> nothing["no crate, no feature flag,<br/>no allowlist entry"]
    fam --> steps["steps 1 to 6, twice,<br/>plus a feature flag<br/>and dep-gate entries"]

A new network on a chain family the build already carries

An EVM chain blockwatcher has never seen is not a contribution at all. It is a Network resource naming a source module the build already registers and that module’s own endpoints, plus Spec resources whose chain is evm. Nothing is compiled, no module is written, no allowlist changes, and the running instance picks it up through the control plane (Your first monitor walks the writes; Resources § The kinds and their fields is the field reference). This is the case that should cover most chains, and it is the payoff for the decoder boundary: the chain differs, the code does not.

A new chain family

A family blockwatcher has no decoder for is where code is written, and it is the one change that spans two ports at once: at minimum one Decoder claiming a new ChainKind and one Source producing raw events for it. Everything above the decoder, every predicate, the matcher, every sink, and all of storage, works against it unmodified, because none of it was ever written against EVM’s types to begin with.

Beyond running steps 1 through 6 for each of the two modules, a new family touches:

  • One new crate, blockwatcher-<family>, holding both modules plus that family’s own sources::get_all() and decoders::get_all() enumerations. crates/blockwatcher-evm is the shape to copy: separate source/ and decoder/ trees, one registry module, one registry_examples/<name>.json per registered module.
  • A ChainKind value, returned by the decoder’s chain() and written on every Spec for that family. A new string, not a new variant, so no existing type changes.
  • A cursor mapping, chosen by the source and documented as its own trade-off, because journal_depth, lag reporting, and invalidate ranges are all denominated in it.
  • Normalization onto the canonical value tree, so a decoded field on the new family is the same Value an existing predicate already reads (Chain-agnosticism § One value model, every chain).
  • Two registration blocks in crates/blockwatcher-embed/src/catalog.rs, one for the family’s sources and one for its decoders, feature-gated together, plus a feature flag beside evm, expr, and sinks in both crates/blockwatcher-embed/Cargo.toml and crates/blockwatcher/Cargo.toml, where the binary’s flag forwards to embed’s. This is the “brand-new family” case step 4 names, and it is the only case that touches the composition façade.
  • Dependency-gate entries: an ALLOW_BLOCKWATCHER_<FAMILY> list of the crate’s direct dependencies, a FAMILY_EXEMPT_BLOCKWATCHER_<FAMILY> entry for the chain SDK it deliberately pulls, and the crate’s name added to ALLOW_BLOCKWATCHER_EMBED. The exemption relaxes only the transitive family denylist, never the direct allowlist, and it relaxes it only for the families named: the forbidden edge from a chain SDK into the core ring stays blocked (Chain-agnosticism § The CI gate that proves it).
  • A golden-file suite pairing every schema declaration the decoder produces with the event shape it decodes to, which is the per-module form of the field-name contract above.
  • Documentation: a crate page under Workspace map and a row per module in Modules and trade-offs § The module catalog, so the new modules are visible to an operator choosing between them.

A second source for a family that already has a decoder, such as a different acquisition strategy for EVM, is not this case. It is the cheap case from step 3: one new file plus one entry in that crate’s existing sources::get_all().

Documenting trade-offs where a reader will see them

Architecture decisions § Modules are how an operator makes trade-offs states the rule: a module writes down its own trade-offs where a reader choosing between modules will actually see them, not in a document separate from the code. log’s crate-level doc comment does exactly that, right above the code it documents (log.rs): zero external dependencies and zero configuration make it the cheapest way to smoke-test a pipeline end to end, delivery is only as durable as whatever consumes stdout, there is no acknowledgement beyond the write succeeding, and a composition root that selects it must route diagnostics elsewhere or an interleaved non-JSON line breaks a consumer’s parse.

The registration function’s own doc comment, on Registry (log.rs), carries the second half of the same idea, pointing a reader at LogSink itself for the trade-offs above and explaining why the example below it cannot go stale: the example is include_str!-ed straight from the module’s own registry_examples/log.json, the same file the family-completeness test in step 6 reads, so the text a reader sees in rendered documentation and the text a test actually constructs from cannot diverge:

#![allow(unused)]
fn main() {
#[doc = concat!("```json\n", include_str!("registry_examples/log.json"), "```")]
pub struct Registry;
}

(log.rs:99-100)

Modules and trade-offs § The module catalog is where every shipped module’s trade-off, log’s included, is collected into one exhaustive table; this page does not repeat that table.

The cost-of-change budget

Architecture decisions § The cost-of-change budget states the number precisely: adding a new module should touch one new file (or one new crate) plus one registration line in the composition root, and nothing else. Reading the steps above against that budget:

  • A module added to a family a build already carries (another sink beside the ones a build already registers) is the cheap case: one new file (the module itself) plus one line in that crate’s own get_all(). It touches no core crate, no composition-root file, and no allowlist entry unless the new module’s own dependencies demand one.
  • A brand-new family joining the binary for the first time is the one case that also touches the composition façade: one registration block in crates/blockwatcher-embed/src/catalog.rs, and, if the family lives in a new crate, one new ALLOW_BLOCKWATCHER_<CRATE> entry plus the crate’s own name added to ALLOW_BLOCKWATCHER_EMBED (and ALLOW_BLOCKWATCHER if the binary links it too). This is the “one registration line” the budget names, and a change that needs more than that is expected to explain why, the same way a size-budget overrun is a prompt to ask a question rather than an automatic rejection.

Using a module once it is compiled in

Everything above is the contributor side: getting a module into a binary. An operator who already has that binary selects the module by name, in whichever resource or instance-config section that module’s port family is configured on:

Port familySelected onCovered in
Sourcea network resource’s source.moduleResources § The kinds and their fields
Decoderautomatic, from a spec’s chainResources § The kinds and their fields
Sinka sink resource’s moduleResources § The kinds and their fields
Matcher[engine].matcher in blockwatcher.toml, instance-wideConfiguration reference § [engine]
Gatea monitor resource’s gate.moduleGates
Storage[storage] in blockwatcher.toml, instance-wideConfiguration reference § [storage]

Every one of these is the same envelope: a module name plus that module’s own opaque config object. A name the running binary never registered, whether because of a typo or because the build simply was not compiled with that module’s crate or feature flag, is refused at write or boot time with the list of names this particular build actually carries, never a superset the workspace merely contains somewhere else (Resources § Write-time validation covers the refusal path for resources in full; blockwatcher (binary) § Feature-flag wiring covers exactly which registrations a feature flag removes).