Conventions
This page is the guardrail around a change: what a pull request must not forget, how code is written and checked, and the process that keeps claims and catalogs from drifting. It is not the system design.
The shape the workspace must keep, and why, lives on Architecture decisions. If a change would alter that shape, that page is the constraint. This page is what the change must still remember after it agrees with the architecture.
Extending blockwatcher, Testing strategy, and Claims are the long form of adding a module, of the test layers, and of the matrix. This page does not retell them.
Key takeaways
- Architecture decisions are invariants of the system. This page is invariants of a change.
- Adding a module, a file around a port, or an operator-facing feature has a closed checklist. Skipping a step is how a second pattern starts.
- A published guarantee is not done until the claims triple lands in the same change.
- Comments, config, errors, and dependencies have one house style. A second style is spaghetti even when the architecture is untouched.
Adding a module
A new module is complete when all of these are true. The worked example is Extending blockwatcher. If the module needs a core change, the port is wrong; stop.
- Port impl, plus a compile-time
assert_port::<Concrete>()beside it so the type is actuallyArc<dyn Port>, not a look-alike inherent impl. An inherent method block whose names and signatures match the trait compiles and can pass method-level tests without ever proving the trait bound. const NAMEand factory next to its own code, registered in that family’sget_all().- Config struct with
deny_unknown_fieldsand validation. - Rustdoc header documenting its trade-offs.
- Tests against the port contract through the same harness the fake
uses. If the port has a compiled artifact (
Decoder,Matcher), a foreign artifact is handed in and rejected. If it does not (Source), the port’s own behavioural contract (cursor ordering, cancellation, status publication) is asserted on the real module. - A registry-completeness test that enumerates the family’s
get_all()and constructs every entry from its documented example config (registry_examples/<name>.json). Extending walks this for thelogsink. - Wiki catalog row: chain-family modules on that family’s page under
docs/wiki/src/concepts/families/; every other module on Modules and trade-offs.
Nothing else. One registration path.
Layers around a port
The three kinds of code, and why they exist, are on Architecture decisions. When adding a file around a port:
- Name it as a decision, an effect, or a driver. A file that cannot say which it is is a driver by default, and drivers are where trapped decisions hide.
- A decision imports types and a clock. It does not import a port trait,
a completion guard, a checkpoint, storage, or anything that performs
I/O. It is testable with an injected
now. - An effect imports that one port trait and types. It does not import guards or checkpoints.
- Decision and effect modules are declared by name in
scripts/check-dep-graph.sh, which enforces those import limits.
A driver over the size budget is the same prompt as any other overrun: a decision is usually still inside it.
Feature class
The two classes, and why they must not merge, are on Architecture decisions. When adding an operator-facing feature, classify it first. This split is enforced by review, not by a gate: no script can tell a lossless optimization from one whose author believed it was.
Transparent optimization owes: a losslessness proof in its own
rustdoc; a runtime interlock that disables it for the rest of the process
if the proof is violated (monotone, no config knob of its own); a metric
for the saving and for the interlock; a config shape that is a bool or
a tuning number; default on.
Semantic policy owes: a documented trade-off next to its own code; accounting for every item it suppresses or defers (a guard completes, or a dead letter is written; nothing is dropped silently); a config shape that is an explicit opt-in structure; default off.
A feature that cannot say which class it is has not been designed. An optimization whose correctness check is itself a toggle, or a suppression policy treated as transparent, has been misclassified.
Claims
Architecture decisions states that a published guarantee is a claim. Shipping one means three artifacts in the same change:
- Documented on the concepts or reference page an operator would actually read.
- Catalogued as a new stable ID on Claims. IDs are never reused. The row names the wiki home and the assurance instrument.
- Tested by a named test (unit, e2e, live, or an explicit gap /
by-design reason on that same row) that carries
/// Claims:naming the ID.grep -rn "/// Claims:" cratescross-checks the matrix against reality.
There is one matrix. A second table, a spreadsheet, or IDs that live
only in tests is a second pattern.
scripts/verify-claims.sh covers the e2e half; the matrix itself is
reviewed against the wiki pages it cites.
Comments
A comment says something the code cannot: a constraint, an invariant, a rationale, a consequence, or a warning. Write it for a reader who never saw the PR.
- No history, no task or review references, no time-relative wording (“currently”, “the old X”), no narration of the next line. If a comment would be redundant given a better name, rename.
- Cite the substance, not a pointer into Architecture decisions or this page. Numbering moves.
- A number or coverage claim names the test or procedure that witnesses it, or it does not belong in a comment.
TODOonly with an owner and a condition a reader can act on.- Doc comments describe contracts, not steps inside.
- A decision that looks wrong deserves one permanent sentence saying why.
Prefer deleting a comment to letting it drift. This governs comments and
identifiers in shipped code (crates/, scripts/, .github/, ui/).
None of these rules are language-specific: a TypeScript construct carries
no gravestone a Rust module may not.
Documents about the process cite these sections freely.
Errors and configuration
One error enum per port, in blockwatcher-ports next to the trait,
thiserror-derived. Adapters flatten SDK errors into it at the
boundary. Box large payloads. Where retry behavior matters, classify
Transient | Permanent | RateLimited | RetryNarrower.
Every config struct: serde with deny_unknown_fields, per-field
defaults, and a test that partial config deserializes to those defaults.
No untagged enums in config. The two planes (instance config vs
resources) are an architectural invariant; this page only owns
field-level strictness.
Tests and dependencies
Testing strategy is the layers. What a change must not forget:
- Port fakes live with the port and type-check in CI.
mockall::automocksits behind thetestingfeature, consumed from[dev-dependencies]only. Never on a production path.- Decoders: golden-file fixtures (real chain input, expected
DecodedEvents out). A decoder that declares dotted or digit-bearing field names includes a fixture pairing each declaration with the nested shape it decodes to. - The engine is proven by integration tests over a fake source, the real pipeline, and a fake sink, asserting checkpoint, ordering, and backpressure.
blockwatcher-exprcarries property tests and a fuzz target. A language change that skips them has not been tested.- Time-dependent tests use
tokio::test(start_paused = true)unless a realsleepis documented. - A headline property is pinned by a test that was shown to fail when
the property breaks. Order-insensitive equality (
IndexMap, JSON objects) does not pin order; content hashes or explicit sequence assertions do.
Versions are pinned once in [workspace.dependencies]. A direct
dependency on a core crate needs a justification in the PR. Registry
releases only; never a git revision of a third-party crate. A fork we
must carry is a fork we own and publish. Prefer std or a small crate
over a framework used for one function.
Size is a prompt, with numbers
Architecture decisions
states that an overrun asks whether a concept is missing. The numbers
scripts/check-dep-graph.sh uses:
- No production file over ~500 lines, no crate over ~5k, core
(
blockwatcher-types+blockwatcher-ports+blockwatcher-core) under ~8k. - No port trait over ~10 methods without an
OVER_BUDGET_PORTSentry. A port over that count is the same prompt: a facet wants extraction. - Production lines are code above a file’s own
#[cfg(test)]module, excluding blanks, comments, and doc comments. Doc comments are excluded because a module must document its trade-offs next to its own code, and a budget that charged for rustdoc would put those two rules in opposition. - An over-budget file, crate, or port is allowed only with a registry
entry in that script (
OVER_BUDGET,OVER_BUDGET_CRATES,OVER_BUDGET_PORTS) that names the missing concept. An overrun with no entry fails CI.
A #[cfg(test)] suite is judged by whether it still reads as one
concept’s proof, not by line count.
Hard prohibitions
System invariants, restated here only as a stop list. The why is on Architecture decisions.
- Never
matchon a chain discriminant in core crates. - Never introduce a second pattern for a solved problem. Amend the pattern or follow it.
- Never use lossy channels for delivery-critical events, or ignore a send or lag error.
- Never advance a cursor past work that was not delivered or dead-lettered.
- Never let a cursor regress except for two counted, explicit cases: a positively detected reorg, rewound only to the proven fork point (when the entire tracked history is refuted, rewind to just below the oldest tracked height, bounded by the tracker’s own depth, never to genesis); or a restart that re-reads the checkpoint after drain. A rewind is a decision with evidence, never a fallback for confusion. Never stitch one logical scan across endpoints with different chain views. The operational sequence lives in Delivery guarantees.
- Never let the binary crate accumulate logic. It wires; it does not implement.
- Never hardcode network or chain data in Rust source. That is resource data.
- Never shut down by
abort(). Tasks are tracked, cancelled cooperatively, and drained with a deadline. - Never block on async work inside a sync factory (
block_in_place). Factories are async from day one.
Practice, owned on this page:
- Never depend on a git revision of a third-party crate.
- Never de-optimize builds to cope with dependency weight. Cut or quarantine instead.
- Never ship test fixtures, builders, or mocks on production paths.
- Never encode composite identity in a formatted string.
- Never swallow an error, construct-and-drop an error for its log
side-effect, or return
Okon a path that did not do the work. - Never accept unknown config fields or untagged config enums.
- Never leave a comment that only makes sense to someone who watched the code get written.
- Never ship a behavioral guarantee without the claims triple.
Checks
CI, and the release process, live in the repository’s
CONTRIBUTING.md.
The scripts a change is expected to still pass:
-
cargo fmt,cargo check --locked, clippy with warnings denied (#[allow]needs a comment naming the exception). -
scripts/check-dep-graph.sh(rings, import limits for decision and effect files, size registry, git-dep ban). Every Rust tree in the repository is named in it, not only the workspace members: a crate with its own[workspace]and its own lockfile is listed there, and a tree the script does not name fails it. Otherwise living outside the workspace would be an exemption from every net at once, granted to whoever never asked.What a named tree is checked for depends on whether anything consumes it. A tree that ships gets the full set, allowlist and size budgets included. A harness that only drives the crate beside it — the
cargo-fuzztrees — gets discovery and the git-dependency ban but not the dependency allowlist, because that net asks what a consumer inherits and a fuzz binary has no consumer. -
scripts/verify-claims.shwhen the proof is an e2e scenario.
A branch that lands as one unit gets one review of the whole diff before merge. Reviews of individual commits or tasks do not substitute: they cannot see defects that only exist across the full change.
Architecture fitness is reviewed against Architecture decisions and this page together. A mechanical pass on this page is not a pass on the system’s shape.
Amendment
An amendment of process (this page) or of a system invariant (Architecture decisions) travels as its own commit, message stating what moved and why, separate from the code it enables. Silence is not consent: code that contradicts a rule does not amend it. Amending an architectural invariant is a design change; amending a comment rule is a process tweak. Same commit discipline, different weight.
Amendments are expected. Most come from a reviewer noticing that a rule overstated its own enforcement, or that two rules collided in a case neither anticipated. Recording the collision is the point.