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
logsink 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 forDecoder, bounded compilation forMatcher, an engine-owned journal forGate, engine-owned retry forSink, and a shared behavioural contract forStorage. - 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
SourceandDecoder. - 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-ports’fakesfeature (FakeSink,FakeSource,FakeDecoder,FakeMatcher,MemoryStorage), registered through the sameModuleRegistrycontract a real module uses. Something else that needs a sink without linking yours reaches forFakeSinkinstead, the same wayblockwatcher-sinks’s own dev-dependency onblockwatcher-coreexercises a real sink throughblockwatcher-core’s actual engine and sink-worker machinery rather than a reimplemented harness.Storageadditionally has a shared behavioral contract,exercise_storage_contract(blockwatcher-testkit § The storage contract), thatblockwatcher-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.Sourcehas the next closest thing,blockwatcher-testkit’srecvandstop(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 issinks_get_all_constructs_every_entry_from_its_documented_example_config(registry.rs): it reads each module’sregistry_examples/<name>.jsonand 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 ownregistry_examples/<name>.json. - A module’s own
#[cfg(test)]block proves what is specific to it:logproves the emitted line is the canonical body plus one newline, inthe_emitted_line_is_the_canonical_body_newline_terminated(log.rs), that concurrent deliveries never interleave a line, inconcurrent_large_deliveries_never_interleave_their_lines(log.rs), and that an unrecognized config key is rejected asSinkError::InvalidConfigrather than silently ignored, infactory_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):runis required.scan(bounded history fetch, behind a monitor dry run’sfetch) andconfirmed_tip(the confirmed head for a cold start) both default toSourceError::Unsupported, so a streaming-only source implements neither and stays a fully valid source. - Reference modules:
evm-rpcandevm-mempool(blockwatcher-evm § The sources). - Selected on: a network resource’s
source.module. - Test infrastructure:
FakeSource, plusblockwatcher-testkit’srecvandstopfor 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.eventsas 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
primaryorsecondary(crates/blockwatcher-types/src/cursor.rs). EVM’s confirmed source uses primary = block number and packs secondary as(kind << 32) | indexso 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 whatjournal_depth, lag reporting, and rewind ranges are all denominated in. send().awaitis the backpressure.ctx.eventsis bounded; a source that drops rather than awaits turns a slow pipeline into missing matches.- Cancellation is a normal end.
ctx.cancelfiring means returnSourceOutcome::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 pastfromwas 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.interestnarrows 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_fieldsbeingNonemeans unknown, which is why an empty set and an absent set are different values.ctx.statusis 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_stateis module-private. Nothing else reads it. The engine stampsCheckpoint.moduleand 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, anddecodeare required.interestdefaults to no hints andmerge_interestdefaults to unioningaddressesandsignatures. - 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-agnosticSchemaSetthe engine, API, and matcher work from, and a module-privateinneronly this family downcasts. Nothing chain-shaped may travel further in (Chain-agnosticism § Core must not know chains). compileresolves only the specs its selectors name.specsis the whole chain’s set, and aRawSelectorwhosespecisNoneis 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.
FakeDecoderkeeps 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 (orderthenmaker, 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.0already type-checks through a declaredpath: 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. decodeoutput 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 isDecodeOutcome::undecodable(), which the engine counts; a payload no selector wanted isno_match(), which is not a failure.- Filling
chain_specificobliges overridingmerge_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):compileandmatchesare required.explaindefaults toExplanation::Unsupportedandreferenced_fieldstoNone, 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].matcherinblockwatcher.toml, instance-wide. One matcher runs per process, andEngineConfighas 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, plusblockwatcher-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:
compilemust 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.matchesis the hot path, and the third truth value is unknown rather than failure. referenced_fieldsspells paths as the predicate source spells them (tx.gas,args.order.maker).Nonemeans this matcher cannot introspect itself, which forces every caller to assume any field may be read. That is whyNoneis 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):deliveris 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::Deliveryand returns. BothSinkEvent::MatchandSinkEvent::Retractedare at-least-once, so a sink must be idempotent onmatch_idin both directions (Delivery guarantees).
Gate
- Trait (
crates/blockwatcher-ports/src/gate.rs):compile,on_hit, default no-opon_invalidate. - Reference modules:
threshold,max_once(crates/blockwatcher-gates/). - Selected on: a monitor resource’s
gate.module. - Test infrastructure:
PassthroughGate(alwaysEmit([this])). - Obligations: the engine owns the journal, so a module that opens
storage is a bug. On invalidate the engine already prunes
gate_hitswithcursor > from; a module must not drain-all inon_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. Onlyput_batchis defaulted, as a plain loop overput. - Reference modules:
memoryandsqlite(blockwatcher-storage, whose § TheStorageport contract is the canonical statement of the rules below). - Selected on:
[storage]inblockwatcher.toml, instance-wide. - Test infrastructure: the only port with a shared behavioural contract,
exercise_storage_contract. A new backend runs exactly the sequencememory,sqlite, and the ports fake already run, which is what proves it equivalent to them without sharing code.FlakyStoragewraps a backend to inject failures on top.
Obligations:
- Optimistic concurrency, never a silent overwrite.
expected_version: Nonecreates and conflicts if the id exists;Some(v)updates and conflicts on mismatch,NotFoundwhen absent. A create returns version 1. A rejected write must leave state untouched, which the contract checks directly. listandlist_checkpointsare 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_deliveryprunes inside the same write. Rows whosecursor.primaryfalls strictly behindcursor.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
u64and 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_batchis 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 ownsources::get_all()anddecoders::get_all()enumerations.crates/blockwatcher-evmis the shape to copy: separatesource/anddecoder/trees, one registry module, oneregistry_examples/<name>.jsonper registered module. - A
ChainKindvalue, returned by the decoder’schain()and written on everySpecfor 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
Valuean 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 besideevm,expr, andsinksin bothcrates/blockwatcher-embed/Cargo.tomlandcrates/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, aFAMILY_EXEMPT_BLOCKWATCHER_<FAMILY>entry for the chain SDK it deliberately pulls, and the crate’s name added toALLOW_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 newALLOW_BLOCKWATCHER_<CRATE>entry plus the crate’s own name added toALLOW_BLOCKWATCHER_EMBED(andALLOW_BLOCKWATCHERif 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 family | Selected on | Covered in |
|---|---|---|
| Source | a network resource’s source.module | Resources § The kinds and their fields |
| Decoder | automatic, from a spec’s chain | Resources § The kinds and their fields |
| Sink | a sink resource’s module | Resources § The kinds and their fields |
| Matcher | [engine].matcher in blockwatcher.toml, instance-wide | Configuration reference § [engine] |
| Gate | a monitor resource’s gate.module | Gates |
| Storage | [storage] in blockwatcher.toml, instance-wide | Configuration 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).