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

Architecture decisions

blockwatcher’s contributor-facing rulebook is a local, project-internal document that this wiki does not link to or quote. The page below restates its binding decisions from scratch, in the wiki’s own words, so a reader never needs the original to understand why the workspace is shaped the way it is.

Three goals sit above every individual rule, and each rule below traces back to one of them:

  1. A monitor that quietly misses an event has failed at its one job, so correctness of delivery outranks everything else.
  2. Adding the next chain, source, or sink should cost about what adding the first one did, measured concretely as how many files a contributor has to touch.
  3. One way to solve a given problem, applied everywhere it recurs, beats a clever alternative used once: a codebase with a single learnable shape stays cheap to extend long after the person who wrote a rule has moved on.

Key takeaways

  • This page restates blockwatcher’s contributor rulebook from scratch, in the wiki’s own words, so a reader never needs the original document.
  • Three goals sit above every rule: correctness of delivery outranks everything else, adding the next module should cost about what the first one did, and one way to solve a recurring problem beats a clever alternative used once.
  • Every rule below is a hard constraint: a change that violates one is either wrong or the rule itself needs to be amended in writing first.

The binding rules

Each of the following is treated as a hard constraint, not a guideline: a change that violates one is either wrong or the rule itself needs to be amended in writing before the change lands.

Chain knowledge stays out of the core

blockwatcher-types, blockwatcher-ports, blockwatcher-core, and blockwatcher-api (the core ring) may never depend on a chain SDK, whether directly or through some other dependency’s own dependency tree. Everything that knows a specific chain’s wire format lives in a module crate instead. This is not policed by a human remembering to check; scripts/check-dep-graph.sh reads each crate’s actual manifest and its full transitive dependency tree in CI and fails the build the moment a forbidden package family shows up anywhere in a core crate’s closure. The script is built as an allowlist of what a crate may depend on, not a blocklist of what it may not: a blocklist only ever catches the specific package someone thought to write down in advance, while an allowlist rejects anything unrecognized by default, including a brand-new chain SDK nobody has heard of yet.

There is exactly one way behavior varies

When blockwatcher needs pluggable behavior, it reaches for one mechanism: a Rust trait that can be called through a dyn pointer, implemented by whichever module wants to provide that behavior. Nothing in core ever branches on which chain it is talking to. A chain-specific value crosses from a module into core as an opaque payload carrying a tag naming which chain family produced it, and only that same family’s own module code is allowed to open the payload back up. Core just carries it through. Wherever blockwatcher’s vocabulary needs a name that a chain family invents for itself (an event kind, a namespace inside a predicate), that name is an open slot a family fills in on its own rather than a fixed list core would have to grow for every new chain. Vocabulary that core itself owns completely and reasons about exhaustively (the canonical value model, the error-retry categories, and what a sink may receive — today Match or Retracted { match_id } under one dyn Sink / deliver) stays a closed set on purpose, because matching over it exhaustively is exactly what keeps that vocabulary safe to extend. A new kind of delivery must earn a closed variant here, not arrive as an untyped side channel.

Modules are how an operator makes trade-offs

Every configurable piece of behavior (a source, a decoder, a matcher, a gate, a sink, a storage backend) is a module: it implements exactly one trait, declares its own name and how to build itself right next to its own code (never in a table shared across modules that could fall out of sync), validates its own configuration and rejects anything it does not recognize, and writes down its own trade-offs (cost, latency, how complete its coverage is) where a reader choosing between modules will actually see them. There is exactly one registration path for a module to become selectable; a second, faster-feeling way to wire one in is treated as a defect the moment it appears, because letting two registration patterns coexist is how a codebase quietly stops having one learnable shape.

A trait is only trustworthy if something fake proves it

Every trait behind which behavior varies ships with an in-memory implementation that is exercised in CI the same way a real implementation would be. These fakes are not test convenience; they are the standing proof that a trait’s shape leaks nothing chain-specific or backend-specific: a change to a trait that a fake cannot satisfy is a signal that the trait’s design needs another look before anything else about the change gets reviewed.

Foreign shapes get translated once, at the door

Each place blockwatcher’s code meets something foreign (a chain’s contract artifact, a chain’s native value encoding, an SDK’s own error type) converts it into blockwatcher’s own vocabulary at that single point of contact, inside the module that owns the boundary, and nothing further downstream ever has to re-learn the foreign shape. A contract artifact becomes a chain-agnostic compiled schema once, at write time, inside the deciding module’s own decode step. A chain’s native value becomes a canonical value model entry inside the same module’s decode function, which is why there is never more than one implementation of value comparison or evaluation regardless of how many chains blockwatcher supports. An SDK’s error type becomes the relevant port’s own error enum inside that module’s adapter code. Core is never handed a raw SDK error type to interpret.

Data moves forward, on purpose, and never silently

Stages inside a running pipeline hand work to each other over fixed-capacity queues, and a stage that produces faster than the next one can keep up simply waits: this is backpressure, and it replaces every form of “drop the newest thing because the buffer is full.” Any channel whose job is to carry something that must be delivered may never be a lossy one; a lossy channel is reserved for observability taps whose drops are themselves counted and which never carry anything load-bearing. Delivery is guaranteed at least once: the position a pipeline resumes from after a restart only moves past an event once every consequence of that event has either reached its destination or been recorded as undeliverable, so a crash can produce a repeated notification, never a missing one, and every notification carries an identifier a consumer can use to recognize a repeat. An undo is the same class of work as an apply: Retracted shares the delivery helper, retry budget, dead-letter write, and completion guard that Match uses — not a second retry stack and not a best-effort log line. Where a later invalidate must name what was already delivered, that fact is journaled before the completion guard completes; retention of that journal is bounded, and anything an invalidate can no longer reach is counted and logged rather than dropped quietly. A gate Retain/Discard completes with outstanding 0 and does not stall that prefix; only an Emit journals a delivery. On invalidate the engine prunes gate_hits by cursor (cursor > from), then retracts already-emitted matches as today. The operational sequence (drain, prune, retract, rewind, restart) and the retention knob live in Delivery guarantees, not as a second pattern beside this one. See Gates for the sixth port’s axis: when a predicate-true hit becomes a delivery.

Anything short of success is a value, not a crash

States like “nothing new yet,” “running behind,” or “given up after retrying” are ordinary return values an operator can query through the status API, not Err results that vanish into a log line. Anything that does require operator attention carries enough structured detail (what failed, where, how badly) to act on without reproducing the failure by hand; only truly incidental internal failures get away with a plain string. Every fallible call’s result is used somehow: handled, returned, or logged with enough context to matter; discarding a result silently is treated the same as never having called it. A positively detected deep invalidate is the same kind of value: the source returns it from run as a typed outcome, not a SourceError, and core never names a chain, a reorg, or a confirmation depth — only the invalidate. The module that talks to the chain decides when a fork is deep enough and translates at its own boundary. How the engine drains, retracts, and rewinds after that outcome is part of Delivery guarantees.

The system checks itself before it starts, and never takes its neighbors down

Nothing gets constructed at boot until every piece of configuration for every module (including one that will not actually be used) is confirmed valid, and until every persisted resource is confirmed to still compile against its schema. Any failure at this stage aborts the process with a message that lists the valid alternatives rather than leaving the operator to guess. Once running, a module that starts failing degrades its own corner of the system loudly (counted errors, visible status) and never brings down the modules around it, and never fails in a way nothing logs or counts.

Anything reusable is compiled once, ahead of the event that needs it

A predicate, a selector, or any other user-supplied specification is parsed, type-checked, and turned into a ready-to-run artifact the moment it is written (through the API or at boot from a seed file), never while an event is actually flowing through the pipeline. A typo in one of these becomes an immediate, specific rejection at write time rather than a monitor that silently never fires. The path a live event actually travels does the minimum possible work: run the already-compiled selector, run the already-compiled predicate, nothing else.

Dependencies always point toward the vocabulary, never toward the engine

A module or library crate may depend on the vocabulary crates (blockwatcher-types, blockwatcher-ports) but never on blockwatcher-core itself, which is what keeps every module usable from some other binary entirely, not only from blockwatcher’s own composition root. The glue crates (blockwatcher and blockwatcher-embed) are the exception that may depend on blockwatcher-core: they assemble a process, they are not modules. The vocabulary crates in turn depend on almost nothing: blockwatcher-types pulls in serialization support and nothing that assumes an async runtime, a web framework, or a chain SDK. A crate that genuinely needs a heavy dependency is the one crate that carries it, rather than spreading it into something shared. Re-exporting another crate’s items is always explicit, never a blanket glob, so a reader can see exactly what a crate’s public surface actually is.

Nothing gets built before two real users need it

An abstraction earns a place in the codebase once there are two genuine implementations behind it, or one implementation plus a fake standing in for a second design that has actually been thought through, not because some earlier, unrelated project happened to have that feature. When it is unclear whether something is needed yet, the default is to leave it out; the module system exists specifically so a genuine need can be added later without redesigning anything.

Every signal names what produced it

A metric, a log line, or a status readout always carries enough identity (which module, which pipeline, which upstream endpoint) that a reader can act on it without cross-referencing something else first. Reporting only that something is failing, without saying which module or which endpoint, does not count as observability. Anything the system drops, caps, or truncates is counted and logged rather than disappearing quietly.

Growth past a size budget is a question, not an automatic rule break

Files, crates, and the core ring as a whole carry soft size targets, checked in review rather than by a script: production code is what counts against them, and a test suite that pins down a genuinely hard-won invariant is judged by whether it still reads as proof of one concept, not by its line count. Going over a budget is allowed: it is a deliberate prompt to ask whether a concept is missing that would make the code smaller again, not a violation to explain away.

The cost-of-change budget

The clearest number in the whole rulebook is this one: adding a new module should touch one new file (or one new crate) plus one registration line in the composition root, and nothing else. Bumping a chain SDK’s version should force a rebuild of one crate (the module crate that chain family lives in), not the rest of the workspace. Both numbers are targets a reviewer checks a change against, not hard gates a script enforces; a change that touches more files than that budget suggests is expected to explain why, in the same way a size budget overrun is a prompt to ask a question rather than an automatic rejection.

How the dependency gate turns rules into a mechanical check

Every crate in the workspace goes through the same two-layer check on every commit:

flowchart TD
    crate["a workspace crate"] --> direct{"every direct dependency<br/>on this crate's own allowlist?"}
    direct -->|"no"| fail["ci fails,<br/>naming crate and package"]
    direct -->|"yes"| tree{"full transitive tree<br/>free of denylisted families,<br/>or exempted?"}
    tree -->|"no"| fail
    tree -->|"yes"| pass["ci passes"]

Most of the rules above would be easy to state and easy to drift away from without something outside code review holding them in place. scripts/check-dep-graph.sh is that something, and it runs on every commit in CI. It works in two layers:

  1. A direct-dependency allowlist, per crate. For every crate in the workspace, the script names exactly which other crates and external packages it may declare in [dependencies]. A crate with no entry in this list fails outright: there is no default “allow unless denied,” so a brand-new crate that forgets to declare its intended shape fails loudly instead of inheriting whatever the crate next to it was allowed.
  2. A transitive family denylist, checked against the crate’s full dependency tree. Even a dependency the allowlist approves might pull in a chain SDK or an HTTP stack of its own, so the script also walks each crate’s entire resolved dependency tree (via cargo tree, across every feature) looking for package-name prefixes belonging to forbidden families: chain SDKs, HTTP clients, storage drivers, web frameworks. A crate gets an exemption from one specific family only when its own direct allowlist already approved that exact family on purpose (the crate that talks to sqlite is allowed to have sqlite show up in its tree; nothing else is exempted for it).

Layered together, these two checks are what let this wiki describe the workspace’s rings as verified facts rather than a design intention: a core crate cannot quietly grow a chain dependency without a CI failure naming exactly which crate and which package tripped the check, and a module crate cannot quietly become a second way to depend on the engine, because “may this crate depend on blockwatcher-core” is itself one of the questions the allowlist answers.

The same script also has two crate-shaped rules beyond the two general layers: it checks by name that nothing outside [dev-dependencies] depends on either of the two test-scaffolding crates, since an ordinary allowlist miss would only catch that accidentally; and it gives one crate (the one whose entire purpose is driving black-box tests against the real binary) a deliberately empty allowlist, so that crate fails the check the moment it declares any production dependency at all, by construction rather than by someone remembering to keep it empty.