Workspace map
blockwatcher’s Cargo.toml declares one workspace of member crates. This page
groups them by the role each one plays, shows the actual dependency edges between
them, and explains how the grouping was checked against
scripts/check-dep-graph.sh (the script CI runs to fail a build the moment
a crate’s dependencies drift outside its assigned role), rather than simply
asserted from memory.
Key takeaways
- blockwatcher’s workspace crates are grouped into three rings, each crate sitting in exactly one: core (cannot do without, forbidden from chain/HTTP/storage knowledge), modules (chain knowledge or external systems), and glue and test (the composition root plus test-only scaffolding).
- The grouping is verified, not asserted: it falls out of what
check-dep-graph.shactually enforces per crate, an allowlist of direct dependencies plus a denylist checked against the full transitive tree. - The dependency graph table lists every production workspace-internal edge; the diagram below it is the transitive reduction, so no edge is silently dropped, only pruned from the picture.
Three rings
Core
The crates a running instance cannot do without, and the only crates the dependency gate forbids from ever seeing a chain SDK, an HTTP client, or a storage driver.
- blockwatcher-types, the vocabulary crate: the
canonical value model,
resource shapes, and deterministic IDs. It depends on nothing but
serdeand two arbitrary-precision integer crates. - blockwatcher-ports, the trait boundary: the six port definitions, their error enums, and the in-memory fakes that stand in as proof that a port hides no chain-specific detail.
- blockwatcher-core, the engine: pipeline assembly, checkpointing, hot reload of resources, and the control handle the API drives.
- blockwatcher-api, the REST surface: turns HTTP requests into calls against the engine’s control handle.
Modules
The crates that hold chain knowledge or talk to an external system, each built so that adding, removing, or swapping one never touches a core file.
- blockwatcher-expr, the default predicate language: a small expression engine that is also the shipped matcher module.
- blockwatcher-rpc, a chain-agnostic endpoint pool: retries, failover, and health tracking over any request/response transport, without ever deciding what a request means.
- blockwatcher-storage, the storage backends: an
in-memory implementation and a sqlite-backed one, both behind the
Storageport. - blockwatcher-sinks, the delivery modules: webhook, script, and log sinks.
- blockwatcher-gates, the
gate modules:
thresholdandmax_once. Depends on types + ports, never core; catalog fold is inblockwatcher-embed. - blockwatcher-metrics, Prometheus wiring: installs a
recorder and serves the metrics scrape endpoint from its own
axumserver. - blockwatcher-evm, the EVM chain family: the
evm-rpcandevm-mempoolsources and the EVM log decoder, built onblockwatcher-rpcand thealloySDK.
Glue and test
The composition root and the crates that exist only to make the rest of
the workspace testable. blockwatcher-embed is loaded in production when
a host (or the binary) boots the engine; the test crates are not.
- blockwatcher-embed, the in-process composition
façade:
build_catalogand the engine types a host needs to boot without the binary’s CLI, API, metrics, or signals. Glue, like the binary: it may depend onblockwatcher-core. - blockwatcher (binary), the composition root: wires modules
into a running process, seeds initial resources on first boot, and offers
an offline config check. Catalog construction is delegated to
blockwatcher-embed. - blockwatcher-testkit, shared test scaffolding: a recording metrics recorder and harnesses that exercise a port’s contract the same way across every module that implements it.
- blockwatcher-evm-testkit, a scripted mock
JSON-RPC and WebSocket node that
blockwatcher-evm’s own tests drive; nothing production-facing ever depends on it. - blockwatcher-e2e, black-box tests that drive the real
blockwatcherbinary against a local chain; it ships no production code of its own at all.
Verifying the rings
The three rings above are not a description layered on top of the crates.
They fall out of what scripts/check-dep-graph.sh actually enforces per
crate. The script runs two checks against every workspace crate: an
allowlist of the crates and external packages each one may depend on
directly, and a denylist of chain-SDK/HTTP/storage/web-framework package
families that must not appear anywhere in that crate’s transitive tree,
with narrow, per-crate exemptions where a crate’s own direct allowlist
already approved exactly one such family.
Reading those two lists crate by crate gives a mechanical way to reproduce each ring, rather than trusting a label:
- Core is
blockwatcher-types,blockwatcher-ports, andblockwatcher-core, plus every crate whose allowlist is permitted to nameblockwatcher-coreas a direct dependency and which is not glue. Onlyblockwatcher-apiqualifies for that core-ring “plus”: no module crate’s allowlist namesblockwatcher-core.blockwatcher-embedand theblockwatcherbinary also nameblockwatcher-core, and they are classified as glue rather than core because they are composition roots, not an HTTP surface over the engine.blockwatcher-types,blockwatcher-ports, andblockwatcher-corecarry no family exemption at all in the script (they cannot carry a chain SDK, an HTTP client, or a storage driver anywhere in their tree);blockwatcher-apicarries exactly one exemption, for the web-framework family its own allowlist already approves (axumand what it pulls in), and no others, so it can serve HTTP without ever being able to see a chain SDK or a storage driver either. - Glue and test is the binary,
blockwatcher-embed, plus the crates the script names explicitly rather than folding into the general allowlist rule:blockwatcher-testkitandblockwatcher-evm-testkitare checked by name to confirm no crate outside[dev-dependencies]depends on either (the mechanical form of “test scaffolding never ships”), andblockwatcher-e2e’s allowlist entry is empty on purpose, so any production dependency at all fails the check; the crate is structurally incapable of shipping code. The binary’s own allowlist is exempted for every chain-SDK/HTTP/storage family the module crates individually carry, because it is the one crate that links all of them into a single process, plusaxumfor the listeners it owns.blockwatcher-embed‘s allowlist namesblockwatcher-coreand the module crates it folds; its family exemption covers those modules’ families (alloy,reqwest,hyper,tower-http,rusqlite) and notaxum, because embed never serves HTTP. - Modules is everything left over:
blockwatcher-expr,blockwatcher-rpc,blockwatcher-storage,blockwatcher-sinks,blockwatcher-gates,blockwatcher-metrics, andblockwatcher-evm. Each either carries its own single-family exemption for the reason it exists (blockwatcher-storageforrusqlite,blockwatcher-sinksforreqwest,blockwatcher-evmforalloyand its transport crates,blockwatcher-metricsforaxum) or, forblockwatcher-expr,blockwatcher-rpc, andblockwatcher-gates, carries none because none needs one, and none of them appears in the core or glue/test criteria above.
This reproduces the brief’s grouping exactly, derived from the gate’s own rules rather than restated from a design doc.
Dependency graph
The table below lists every workspace-internal edge exactly as declared in
each crate’s [dependencies] section (production dependencies only:
[dev-dependencies] edges, such as every crate’s dev-dependency on
blockwatcher-testkit, are deliberately excluded, since those are what the gate
above forbids from ever becoming production edges).
| Crate | Depends on (workspace, [dependencies] only) |
|---|---|
blockwatcher-types | none |
blockwatcher-ports | blockwatcher-types |
blockwatcher-core | blockwatcher-types, blockwatcher-ports |
blockwatcher-api | blockwatcher-types, blockwatcher-ports, blockwatcher-core |
blockwatcher-expr | blockwatcher-types, blockwatcher-ports |
blockwatcher-rpc | blockwatcher-ports |
blockwatcher-evm | blockwatcher-types, blockwatcher-ports, blockwatcher-rpc |
blockwatcher-evm-testkit | blockwatcher-types, blockwatcher-evm, blockwatcher-rpc |
blockwatcher-testkit | blockwatcher-types, blockwatcher-ports |
blockwatcher-storage | blockwatcher-types, blockwatcher-ports |
blockwatcher-sinks | blockwatcher-types, blockwatcher-ports |
blockwatcher-gates | blockwatcher-types, blockwatcher-ports |
blockwatcher-metrics | none |
blockwatcher-embed | blockwatcher-core, blockwatcher-storage, blockwatcher-gates, blockwatcher-expr, blockwatcher-evm, blockwatcher-sinks |
blockwatcher (binary) | blockwatcher-types, blockwatcher-ports, blockwatcher-core, blockwatcher-embed, blockwatcher-api, blockwatcher-metrics, blockwatcher-storage |
blockwatcher-e2e | none |
That is 36 edges across 16 nodes, most of them implied by another edge in
the same table (blockwatcher-core depends on blockwatcher-types directly, but also
reaches it via blockwatcher-ports, which already depends on blockwatcher-types).
Drawing all 36 makes the diagram unreadable without adding information, so
the diagram below draws the transitive reduction instead: it keeps an edge
only when no other path in the table above already reaches the same
target, and every edge it omits is one step away from an edge it keeps:
for example blockwatcher-core → blockwatcher-types is omitted because
blockwatcher-core → blockwatcher-ports → blockwatcher-types already covers it, and the same
reduction removes blockwatcher-api’s direct edges to blockwatcher-types and
blockwatcher-ports (both reachable via blockwatcher-api → blockwatcher-core), blockwatcher-evm’s
direct edges to blockwatcher-types and blockwatcher-ports (reachable via
blockwatcher-evm → blockwatcher-rpc), blockwatcher-embed’s direct edge to blockwatcher-core’s
own dependencies, and four of the binary’s seven direct edges
(blockwatcher-types, blockwatcher-ports, and blockwatcher-core are reachable via
blockwatcher → blockwatcher-api; blockwatcher-storage is reachable via
blockwatcher → blockwatcher-embed). No edge is added that is not in the table above;
none of the 16 nodes or 36 edges is silently dropped from the record.
They are only pruned from the picture.
graph TD
subgraph core["core"]
types[blockwatcher-types]
ports[blockwatcher-ports]
core_[blockwatcher-core]
api[blockwatcher-api]
end
subgraph modules["modules"]
expr[blockwatcher-expr]
rpc[blockwatcher-rpc]
storage[blockwatcher-storage]
sinks[blockwatcher-sinks]
gates[blockwatcher-gates]
metrics[blockwatcher-metrics]
evm[blockwatcher-evm]
end
subgraph glue["glue / test"]
embed[blockwatcher-embed]
bin[blockwatcher]
testkit[blockwatcher-testkit]
evmtestkit[blockwatcher-evm-testkit]
e2e[blockwatcher-e2e]
end
ports --> types
core_ --> ports
api --> core_
expr --> ports
rpc --> ports
storage --> ports
sinks --> ports
gates --> ports
evm --> rpc
evmtestkit --> evm
testkit --> ports
embed --> core_
embed --> storage
embed --> expr
embed --> evm
embed --> sinks
embed --> gates
bin --> api
bin --> metrics
bin --> embed
blockwatcher-metrics and blockwatcher-e2e are the only nodes with no outgoing edge
at all, in either the full table or the reduced diagram, and that absence is
itself a checked property rather than an omission: blockwatcher-metrics declares no workspace crate in
[dependencies] (it wraps metrics-exporter-prometheus directly), and
blockwatcher-e2e declares no [dependencies] at all: the empty allowlist entry
scripts/check-dep-graph.sh gives it exists precisely to keep that crate at
zero production dependencies. blockwatcher-testkit and blockwatcher-evm-testkit have
outgoing edges but no incoming ones in this graph, because every crate
that uses them does so through [dev-dependencies], which this graph
excludes by construction (the same rule the dependency gate checks by name
for those two crates).