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

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.

  1. Port impl, plus a compile-time assert_port::<Concrete>() beside it so the type is actually Arc<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.
  2. const NAME and factory next to its own code, registered in that family’s get_all().
  3. Config struct with deny_unknown_fields and validation.
  4. Rustdoc header documenting its trade-offs.
  5. 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.
  6. 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 the log sink.
  7. 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:

  1. Documented on the concepts or reference page an operator would actually read.
  2. Catalogued as a new stable ID on Claims. IDs are never reused. The row names the wiki home and the assurance instrument.
  3. 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:" crates cross-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.
  • TODO only 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::automock sits behind the testing feature, 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-expr carries 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 real sleep is 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_PORTS entry. 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 match on 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 Ok on 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-fuzz trees — 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.sh when 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.