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

Claims and their tests

Every behavioral guarantee the concepts and reference pages make, mapped to the test that holds it up. IDs are stable, new claims get new IDs. Tests written against this page name the claims they verify in a /// Claims: doc comment, so grep -rn "/// Claims:" crates cross-checks this page against reality.

Assurance vocabulary:

  • unit: an in-process test path (crate, file, and test name, or a shared suite when several tests together prove the claim).
  • e2e: a black-box scenario in crates/blockwatcher-e2e (the real binary, operator surfaces only).
  • by design, not live-tested: in-process coverage is judged the right instrument for this claim; the sentence after the colon is the recorded reason.
  • gap: no test proves this claim anywhere yet, unit or e2e. Each gap row names the crate a follow-up unit test belongs in.

A handful of appendix rows cover a small numbered range under one shared description rather than one row per number. This page lists every ID the range spans in that row’s ID column, so each individual ID is still greppable on its own, but keeps the row and its Claim sentence merged, since the underlying scenario and test set is genuinely one thing being proven, not several unrelated ones.

Progress and checkpoints

IDClaimDocumented inAssurance
PROG-1The checkpoint advances only past the longest contiguous fully-done prefix, never further.Delivery guaranteesunit: blockwatcher-core/src/progress.rs::checkpoint_advances_only_past_fully_completed_prefix
e2e: sigkill_and_restart_loses_nothing
PROG-2Fully done means delivered or dead-lettered, nothing else counts toward the prefix.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::successful_match_delivery_is_journaled, ::dead_letter_recording_is_what_advances_the_checkpoint
PROG-3A dropped completion guard stalls its prefix forever, with no timeout and no fabricated completion.Delivery guaranteesunit: blockwatcher-core/src/progress.rs::a_dropped_guard_stalls_the_checkpoint
PROG-4A stalled prefix is visible from outside as in_flight_events in status.Delivery guaranteesunit: blockwatcher-core/src/progress.rs::in_flight_tracks_registration_and_completion
PROG-5An event that produces zero matches still completes at dispatch, and the cursor advances past it.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs suite (event-dispatch and checkpoint-advance tests)
PROG-6A raw event registers with Progress exactly once, after dispatch, never before.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::dispatches_one_work_item_per_action_and_advances_checkpoint_on_completion, ::persist_failure_does_not_skip_begin
PROG-7A cursor persists only once every guard it depends on has completed.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::checkpoint_advances_to_the_second_event_only_after_both_complete_in_order
e2e: sigkill_and_restart_loses_nothing
PROG-8The checkpoint writer refuses to persist a regression against what is already stored.Delivery guaranteesunit: blockwatcher-core/src/pipeline/checkpoint_writer.rs::a_mid_run_regression_is_refused_counted_and_never_persisted
PROG-9Misrouted events (a raw event carrying another network’s id) are dropped and counted, never re-attributed.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::a_raw_event_for_a_different_network_is_counted_and_dropped_not_re_attributed, ::a_misrouted_event_is_exported_not_only_dropped
PROG-10The monitor set is snapshotted once per event, so a hot swap landing mid-flight can never split one event’s processing across two monitor sets.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::a_swap_landing_between_two_monitors_of_the_same_event_cannot_split_its_processing

At-least-once delivery

IDClaimDocumented inAssurance
ALO-1A crash can repeat work already in flight, but it can never skip it.Delivery guaranteese2e: sigkill_and_restart_loses_nothing
ALO-2On resume, the persisted checkpoint is the only truth the successor trusts.Delivery guaranteesunit: blockwatcher-core/tests/engine/acceptance.rs::crash_resume_does_not_redeliver_or_skip
e2e: sigkill_and_restart_loses_nothing
ALO-3MatchId::derive is deterministic across process lifetimes, so a successor mints the same ids a crashed predecessor would have.Delivery guaranteesunit: blockwatcher-types/src/id.rs::match_id_re_derivation_from_the_same_content_is_stable
e2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash, a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
ALO-4A redelivered Retracted or Digest must dedupe on match id at the consumer, same as a Match.Delivery guaranteesunit: blockwatcher-sinks/src/lib.rs::canonical_body_retracted_is_tagged, ::canonical_body_digest_is_tagged
e2e: an_anvil_reorg_retracts_replaced_matches_before_replacements
ALO-5All three SinkEvent variants share the same retry and dead-letter path, no variant gets special treatment.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::transient_failures_retry_with_doubling_backoff_then_succeed (Match), ::retracts_bypass_throttle (Retracted), ::digest_failure_dead_letters_each_match (Digest)
ALO-6The SinkEvent set is closed: nothing outside Match, Retracted, and Digest can reach a sink.Delivery guaranteesunit: compile-enforced by an exhaustive match over SinkEvent throughout blockwatcher-core/src/pipeline/sink_worker.rs, no dedicated runtime test needed
ALO-7Built-in sinks render each variant as tagged, canonical JSON.Delivery guaranteesunit: blockwatcher-sinks/src/lib.rs::canonical_body_match_is_tagged, ::canonical_body_retracted_is_tagged, ::canonical_body_digest_is_tagged
e2e: an_anvil_event_reaches_the_webhook_exactly_once (Match shape), an_anvil_reorg_retracts_replaced_matches_before_replacements (Retracted shape)

Backpressure

IDClaimDocumented inAssurance
BP-1Bounded channels never drop an item on the full side; a full channel blocks the producer instead.The pipelineunit: blockwatcher-core/tests/engine/acceptance.rs::backpressure_never_drops
by design, not live-tested: acceptance::backpressure_never_drops is the right instrument, live channel-capacity plumbing is unobservable without white-box hooks.
BP-2A slow sink eventually stalls the whole pipeline behind it, rather than the pipeline shedding work around it.The pipelineunit: blockwatcher-core/tests/engine/acceptance.rs::backpressure_never_drops
by design, not live-tested: same instrument, live channel-capacity plumbing is unobservable without white-box hooks.
BP-3Channel capacities come from configuration and are applied to every pipeline the same way.The pipelineby design, not live-tested: capacities are wired from config at pipeline construction; live channel-capacity plumbing is unobservable without white-box hooks, so acceptance::backpressure_never_drops is the instrument of record instead of a dedicated wiring test.
BP-4Graceful shutdown drains everything already queued before the process exits.The pipelineunit: blockwatcher-core/src/pipeline/mod.rs::a_graceful_root_cancel_still_delivers_everything_already_buffered_behind_a_slow_sink
e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink

Dead letters

IDClaimDocumented inAssurance
DLQ-1Retry exhaustion always writes a dead letter, a delivery is never simply dropped.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::retry_budget_exhaustion_dead_letters_with_attempt_count
e2e: throttled_deliveries_land_in_the_dead_letter_queue_not_nowhere (throttle path), dead_letters_survive_a_restart_on_sqlite (retry-exhaustion path)
DLQ-2A dead letter carries id, monitor, sink, cursor, attempt count, reason, and payload.Delivery guaranteesunit: blockwatcher-types/src/event.rs::dead_letter_serde_round_trips
e2e (partial): dead_letters_survive_a_restart_on_sqlite
DLQ-3The delivery guard only completes once the dead letter has durably landed in storage.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_letter_recording_is_what_advances_the_checkpoint
DLQ-4A dead-letter write that fails retries forever on backoff rather than giving up.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_letter_recording_is_what_advances_the_checkpoint (FlakyStorage-backed retry)
DLQ-5A dead-lettered Match stays in the delivery journal; a dead-lettered Retracted does not.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_lettered_match_is_journaled (Match half; no test isolates the dead-lettered Retracted half, flagged as a follow-up)
DLQ-6A permanent error dead-letters immediately, with no retry budget spent first.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::permanent_failure_dead_letters_on_first_attempt
DLQ-7Transient failures back off with doubling delay, bounded above.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::transient_failures_retry_with_doubling_backoff_then_succeed
DLQ-8A dead letter’s reason always leads with its ErrorClass prefix (transient:, permanent:, and so on).Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::retry_budget_exhaustion_dead_letters_with_attempt_count
e2e: dead_letters_survive_a_restart_on_sqlite
DLQ-9Listing dead letters pages in insertion order and is a pure read with no side effect.Delivery guaranteesunit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_keep_order_and_page (run per backend via blockwatcher-storage/tests/contract.rs)
DLQ-10A replay that succeeds deletes the letter; a replay that exhausts its budget bumps the attempt count in place and answers 502.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_failure_updates_attempts (exhaustion half)
e2e: a_dead_letter_replays_through_the_api_and_is_deleted_on_success (success half)
DLQ-11A dead letter with no stored payload refuses replay with 422.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_without_payload_is_invalid_resource
by design, not live-tested: pinned in blockwatcher-api wire tests, NO_REPLAY_RETRACTED in particular requires manufacturing a dead-lettered retract live, which costs more than it proves.
DLQ-12A dead-lettered Retracted refuses replay with 422.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_retracted_payload_is_invalid_resource
by design, not live-tested: pinned in blockwatcher-api wire tests, NO_REPLAY_RETRACTED in particular requires manufacturing a dead-lettered retract live, which costs more than it proves.
DLQ-13Discard, single or bulk with filters, counts what it removed and never delivers anything.HTTP API referenceunit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_bulk_discard_combines_filters_and_scopes_by_pipeline (run per backend via blockwatcher-storage/tests/contract.rs)
e2e: bulk_discard_filters_and_counts
DLQ-14Identity for replay is the pair (match_id, sink); an ambiguous match across sinks answers 409.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_ambiguous_match_id_is_conflict
by design, not live-tested: pinned in blockwatcher-api wire tests, which already exercise the HTTP framing a live duplicate would add.
DLQ-15Legacy bare-Match dead-letter rows from before the identity schema still load.Delivery guaranteesunit: blockwatcher-storage/src/sqlite.rs::a_version_five_database_migrates_to_the_dead_letter_identity_schema
by design, not live-tested: pinned in blockwatcher-api wire tests, which already exercise the HTTP framing a live duplicate would add.
DLQ-16A sqlite-backed dead-letter queue survives a restart; a memory-backed one does not.Delivery guaranteese2e: dead_letters_survive_a_restart_on_sqlite (sqlite half; the memory-backend half is not separately asserted anywhere, flagged as a follow-up)
DLQ-17The retention cap prunes the oldest letters in-write, and a zero-valued cap is refused at boot.Delivery guaranteesunit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_retention_prunes_the_oldest_and_reports_what_it_dropped (run per backend via blockwatcher-storage/tests/contract.rs), blockwatcher-core/src/config.rs::validate_refuses_a_zero_or_unstoreable_dead_letter_retention
by design, not live-tested: exhaustively pinned in-process at both write and boot, a live duplicate adds fidelity to nothing but HTTP framing, which api_semantics.rs already exercises.
DLQ-18dead_letter_count in status is read fresh from storage each time, and answers null rather than a fabricated number when storage will not answer.Delivery guaranteesunit: blockwatcher-core/tests/engine/control.rs::status_reports_a_durable_dead_letter_count_independent_of_the_volatile_counter, ::status_reports_unknown_rather_than_fabricated_when_storage_will_not_answer

Throttle

IDClaimDocumented inAssurance
THR-1A match over the throttle budget dead-letters as permanent: throttled:, never silently dropped.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_suppresses_excess_and_dead_letters_it
e2e: throttled_deliveries_land_in_the_dead_letter_queue_not_nowhere
THR-2Throttled letters are listable and replayable exactly like any other dead letter.Delivery scenariose2e: replaying_a_throttled_letter_bypasses_the_still_open_window; structurally covered by the generic replay path in blockwatcher-core/tests/engine/control.rs, which carries no throttle-specific branch
THR-3Replaying a throttled letter bypasses the window it was originally suppressed under.Delivery scenariosunit: blockwatcher-core/tests/engine/control.rs::replaying_a_throttled_letter_bypasses_the_still_exhausted_window
e2e: replaying_a_throttled_letter_bypasses_the_still_open_window
THR-4Only a delivered match spends throttle budget; a suppressed or failed one does not.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_suppresses_excess_and_dead_letters_it, ::throttle_counts_every_suppressed_match_not_every_suppressed_delivery
THR-5A Retracted skips throttle accounting entirely, in both directions.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::a_successful_retract_does_not_spend_an_open_throttle_window, ::retracts_bypass_throttle
THR-6The throttle window is scoped per (network, sink), never global across the process.Delivery scenariosunit: blockwatcher-core/tests/engine/acceptance.rs::a_throttle_window_is_scoped_per_network_not_per_sink_id
THR-7The throttle window anchors at the first admitted delivery, not at configuration time.Delivery scenariosunit: blockwatcher-core/src/pipeline/throttle.rs::the_window_belongs_to_the_first_delivery_it_admits
e2e (in passing): a_throttle_window_resets_on_restart
THR-8The throttle window is runtime-only state; a restart opens a fresh one.Delivery scenariose2e: a_throttle_window_resets_on_restart (no dedicated unit test isolates this; candidate: blockwatcher-core/src/pipeline/throttle.rs)
THR-9A zero-valued throttle setting is refused both at write and at boot.Delivery scenariosunit: blockwatcher-types/src/resource.rs::throttle_validate_rejects_zero_max_deliveries, ::throttle_validate_rejects_zero_window_ms
write: blockwatcher-core/src/control/writes.rs::put_sink_rejects_a_zero_valued_throttle
boot: blockwatcher-core/tests/engine/boot.rs::a_zero_valued_sink_throttle_fails_boot_before_anything_spawns
HTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource
THR-10Omitted throttle fields default to 60 deliveries per 60000ms.Delivery scenariosunit: blockwatcher-types/src/resource.rs::throttle_defaults_and_partial_config
THR-11A digest spends exactly one delivery of the throttle budget, however many matches it folded.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::a_digest_spends_one_delivery_of_the_throttle_budget
THR-12A throttle-refused digest dead-letters each of its member matches individually.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_counts_every_suppressed_match_not_every_suppressed_delivery

Aggregation

IDClaimDocumented inAssurance
AGG-1Only Match buffers for aggregation; a Retracted is never held.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::retract_flushes_before_delivering
AGG-2There are exactly four flush triggers: max_batch, the window deadline, a retract, and drain on shutdown.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch, ::digest_flushes_at_window_deadline, ::retract_flushes_before_delivering, ::drain_flushes_the_buffer
e2e (max_batch): three_matches_in_one_window_arrive_as_one_digest_through_the_template
AGG-3A retract flushes the buffer first; it never overtakes buffered matches.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::retract_flushes_before_delivering
AGG-4A single-item flush renders as a plain Match, not a one-member Digest.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::single_item_flush_is_a_plain_match
AGG-5A multi-item flush renders as exactly one Digest.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch
e2e: three_matches_in_one_window_arrive_as_one_digest_through_the_template
AGG-6Buffered matches stall the checkpoint by design, visible as in_flight_events until the window flushes.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::checkpoint_stalls_while_buffered
e2e (observable): a_buffered_aggregate_window_is_re_emitted_after_a_crash
AGG-7A crash drops buffered completion guards; the replay after restart re-delivers, and consumer-side dedupe by id yields the full set.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::hard_cancel_drops_buffered_guards
e2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash
AGG-8No aggregation window state is persisted; everything a restart needs is re-derived.Delivery scenariose2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash
AGG-9Digest members journal at their own cursor, in order, before the digest’s completion guards resolve.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch, ::a_digest_interrupted_mid_journal_does_not_complete_its_guard
AGG-10Digest exhaustion dead-letters each member individually, in order.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_failure_dead_letters_each_match
AGG-11A digest member replays through the dead-letter API as a single ordinary match, not as part of a digest.Delivery scenariosunit: blockwatcher-core/tests/engine/control.rs replay suite (no test isolates the digest-member case specifically)
AGG-12Aggregation bounds (max_batch, window_ms) are enforced both at write and at boot.Delivery scenariosunit: blockwatcher-core/src/control/writes.rs::put_sink_rejects_an_out_of_range_aggregate, blockwatcher-core/tests/engine/boot.rs::a_zero_valued_sink_aggregate_fails_boot_before_anything_spawns
HTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource
AGG-13Retry backoff bounds are validated both at write and at boot.Delivery scenariosunit: blockwatcher-core/src/control/writes.rs and blockwatcher-core/tests/engine/boot.rs retry-bound refusal tests
HTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource

Gates

IDClaimDocumented inAssurance
GATE-1A quiet hit (no fire) still registers cleanly, leaving zero outstanding work behind.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::journal_held_gauge_tracks_the_persisted_length
GATE-2A gate hit persists before its completion guard resolves.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::retain_persists_the_journal_including_this_hit
GATE-3A failed persist stalls the guard; it never silently skips the hit.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::a_failed_persist_evicts_the_entry_so_memory_never_leads_storage, ::a_failed_commit_leaves_the_journal_intact_and_emits_nothing
GATE-4Invalidate prunes gate hits with cursor strictly greater than from, and nothing at or before it.Gatesunit: blockwatcher-testkit/src/storage_contract/gate_hits.rs::prune_gate_hits_deletes_only_cursor_after_from, ::prune_does_not_delete_cursor_at_or_before_from; blockwatcher-core/tests/engine/invalidate.rs::invalidate_prunes_gate_hits_strictly_after_from, ::invalidate_from_tip_keeps_gate_hit_at_or_before_from
GATE-5Emit metadata (last_emit_cursor) is cleared only when its cursor is strictly greater than from.Gatesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidate_from_later_cursor_keeps_max_once_cooldown_when_journal_is_empty, ::invalidate_clears_cooldown_when_emit_cursor_is_after_from_even_if_journal_is_empty
GATE-6Gate replay is at-least-once, and the ids it mints are deterministic across a crash.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::a_failed_commit_leaves_the_journal_intact_and_emits_nothing (nothing partially committed), blockwatcher-core/src/pipeline/processor.rs::a_gated_emission_dispatches_with_its_outbox_id_per_sink (durable per-sink ids)
GATE-7A max_once discard is not a dead letter; it is a quiet, counted no-op.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::discard_persists_without_the_named_indices; blockwatcher-gates/src/max_once.rs::second_hit_in_window_discards_this_row
GATE-8A threshold gate resets its journal on fire; leftover hits past the fired count stay held.Gatesunit: blockwatcher-gates/src/threshold.rs::three_hits_in_window_emit_oldest_three, ::leftovers_after_emit_are_the_tail
GATE-9A gate module returns indices into the journal; only core mints the resulting match ids.Gatesunit: blockwatcher-gates/src/threshold.rs::three_hits_in_window_emit_oldest_three proves the module returns bare indices, and blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sink proves core creates one outbox row per sink from them; Match::new minting the match id itself happens in apply_gate_memory (blockwatcher-core/src/pipeline/gate.rs:263-268, no dedicated unit test isolates that call)
e2e: a_gated_monitor_survives_a_restart_and_delivers_its_digest is the behavioral proof that engine-minted ids reach the wire
GATE-10Gate windows are measured in block.timestamp, never wall-clock time.Gatesunit: blockwatcher-gates/src/threshold.rs::block_timestamp_int_is_accepted, ::block_timestamp_wrong_type_is_refused; blockwatcher-gates/src/max_once.rs equivalent schema tests
GATE-11A gate journal over its cap drops the oldest entries, and counts what it dropped.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::journal_over_cap_counts_dropped
GATE-12A hit with no resolvable event timestamp is skipped and counted, never inserted untimestamped.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::missing_timestamp_is_quiet_and_does_not_insert
GATE-13An invalid on_hit decision from a gate module discards only that hit, and is counted.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::an_invalid_gate_decision_discards_only_this_hit proves an older hit survives while only the newest is dropped, ::an_invalid_gate_decision_counts_on_the_exported_metric proves blockwatcher_gate_decision_invalid_total increments through the live path; the fallback was previously fronted by a debug assert that made it untestable. The assert is now removed so the documented tolerance is exercised directly
GATE-14A schema with no block.timestamp field refuses a time-based gate at write.Gatesunit: blockwatcher-gates/src/max_once.rs::missing_block_timestamp_in_schema_is_refused, blockwatcher-gates/src/threshold.rs::missing_block_timestamp_in_schema_is_refused; blockwatcher-core/src/control/writes.rs::put_monitor_rejects_a_gate_on_a_source_without_event_timestamps
GATE-15A gate on a mempool source is refused, and a mempool source under a gated monitor is refused, in both directions.Gatesunit: blockwatcher-core/src/control/writes.rs::put_monitor_rejects_a_gate_on_a_source_without_event_timestamps, ::put_monitor_accepts_a_gate_on_a_source_with_event_timestamps
e2e: a_gated_monitor_on_a_mempool_source_is_refused_in_both_directions
GATE-16A hot swap of a gate’s config does not migrate its held journal; the holds reset.Gatesunit: blockwatcher-core/src/control/writes.rs::put_monitor_clears_gate_hits_when_gate_config_changes, ::put_monitor_keeps_gate_hits_when_gate_is_unchanged
e2e (in passing): a_pending_emission_outlives_its_deleted_monitor
GATE-17Sqlite-backed gate holds survive a restart.Gatese2e: a_gated_monitor_survives_a_restart_and_delivers_its_digest
GATE-18A dry-run against a gated monitor is inert: it never writes to the held journal.Gatesunit: blockwatcher-core/tests/engine/control.rs::test_monitor_holds_an_in_window_hit_without_writing_storage
GATE-19The dry-run TestReport shape distinguishes no-match, rejected-predicate, and held/fired outcomes.Gatesunit: blockwatcher-core/tests/engine/control.rs::test_monitor_dry_runs_supplied_payloads_without_touching_the_pipeline, ::test_monitor_flags_an_unselected_event_as_no_match_not_a_rejected_predicate
GATE-20Out-of-range gate configuration (window, count) answers 422 at write.Gatesunit: blockwatcher-gates/src/threshold.rs::count_one_is_refused_at_compile, ::window_ms_zero_and_over_one_day_are_refused, blockwatcher-gates/src/max_once.rs::window_ms_zero_and_over_one_day_are_refused
by design, not live-tested: exhaustively pinned in-process at both write and boot, a live duplicate adds fidelity to nothing but HTTP framing, which api_semantics.rs already exercises.
GATE-21An unknown gate module name answers 422 naming the module catalog.Gatesunit: blockwatcher-core/src/control/writes.rs::put_monitor_rejects_unknown_gate_module_at_write_lookup
by design, not live-tested: exhaustively pinned in-process at both write and boot, a live duplicate adds fidelity to nothing but HTTP framing, which api_semantics.rs already exercises.
GATE-22A monitor carries at most one gate; the resource schema has no way to express two.Gatesby design, not live-tested: structurally enforced by the type Monitor.gate: Option<ModuleSel>, so a second gate cannot be constructed to test against
GATE-23A gate emit is exactly one storage transaction: journal consumed and outbox rows written atomically.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sink
e2e (in passing): a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
GATE-24A gate envelope change or gated monitor delete whose pipeline bring-up fails is returned as an error (HTTP non-2xx); the stored row may already have landed, and /status reports the network abandoned.HTTP API reference, Resourcesunit: blockwatcher-core/tests/engine/control.rs::a_gate_change_whose_restart_finds_an_unknown_sink_module_is_an_error, ::a_gate_state_wipe_whose_bring_up_fails_leaves_the_pipeline_stopped, ::a_gate_envelope_change_refuses_the_wipe_after_a_quiesce_timeout, ::a_delete_that_orphans_gate_state_is_wiped_before_a_recreate_adopts_it; blockwatcher-api/tests/api/resources.rs::a_gate_change_whose_restart_fails_is_not_2xx

Gate outbox

IDClaimDocumented inAssurance
OBX-1Emit writes exactly one outbox row per sink, inside the same transaction that consumes the gate journal.Gatesunit: blockwatcher-testkit/src/storage_contract/outbox.rs::commit_gate_emit_replaces_hits_writes_meta_and_creates_one_row_per_sink; blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sink
OBX-2An outbox row is held until its delivery outcome is durable, then deleted.Gatesunit: blockwatcher-testkit/src/storage_contract/outbox.rs::delete_gate_outbox_removes_one_row_and_absent_ids_succeed; blockwatcher-core/src/pipeline/sink_worker.rs::a_delivered_outbox_item_deletes_its_row_after_journaling, ::a_dead_lettered_outbox_item_deletes_its_row_too, ::outbox_delete_failure_retries_and_stalls_until_it_lands
OBX-3A crash between a gate emit and its delivery outcome loses nothing.Gatese2e: a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
OBX-4The startup drain sweeps up any outbox rows a crash left pending.Gatese2e: a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
OBX-5A pending emission outlives the deletion of its own monitor or network; delivery still completes.Gatesunit: blockwatcher-core/src/pipeline/sink_worker.rs::a_guardless_outbox_item_delivers_and_settles_without_a_checkpoint
e2e: a_pending_emission_outlives_its_deleted_monitor
OBX-6Invalidate prunes outbox rows whose cursor is strictly after from, reorged-out members are dropped.Gatesunit: blockwatcher-testkit/src/storage_contract/outbox.rs::prune_gate_outbox_after_drops_only_matches_strictly_after_from; blockwatcher-core/tests/engine/invalidate.rs::invalidate_prunes_outbox_rows_strictly_after_from
OBX-7A permanent gate-outbox prune error during invalidate does not retry until cancel and does not wedge control-plane writes.HTTP API referenceunit: blockwatcher-core/tests/engine/invalidate.rs::a_permanent_outbox_prune_during_invalidate_does_not_hang_control_plane_writes

Delivery journal

IDClaimDocumented inAssurance
JRN-1A delivery journals before its completion guard resolves.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::journal_write_failure_stalls_the_checkpoint
JRN-2A successful retract forgets its journaled delivery; a dead-lettered retract keeps the row.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::dead_lettered_retract_keeps_the_journal_row
e2e (in passing): an_anvil_reorg_retracts_replaced_matches_before_replacements
JRN-3journal_depth prunes the delivery journal in-write, not as a separate job.Delivery guaranteesunit: blockwatcher-testkit/src/storage_contract/deliveries.rs::delivery_journal_records_lists_forgets_and_prunes (run per backend via blockwatcher-storage/tests/contract.rs)
e2e (observable pruning): a_journal_gap_is_loud_not_silent
JRN-4There is no prune API and no switch to disable journal pruning.Delivery guaranteesby design, not live-tested: an absence claim with no disable switch to test; verified by the resource route table in crates/blockwatcher-api/src, which carries no such endpoint or config toggle
JRN-5A journal gap (an invalidate reaching past what the journal retained) is counted and logged loudly, never silent.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::journal_gap_is_counted_when_invalidate_cursor_is_older_than_retained_window
e2e: a_journal_gap_is_loud_not_silent
JRN-6Deliveries at or below the invalidate cursor duplicate on redelivery; they are never retracted.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidated_source_retracts_then_offers_replacement_matches

Reorg and invalidation

IDClaimDocumented inAssurance
RRG-1A deep invalidate travels through a typed control path (SourceExitKind::Invalidated), not an ad-hoc signal.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::pause_after_matches_still_rewinds_on_invalidate
e2e: an_anvil_reorg_retracts_replaced_matches_before_replacements
RRG-2A shallow fork, strictly inside the confirmation window, emits no Retracted at all.Delivery guaranteese2e: a_shallow_fork_emits_no_retract
RRG-3Invalidate runs its seven-step sequence in order: pause, retract, forget, rewind, resume, and so on.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidated_source_retracts_then_offers_replacement_matches
e2e (observable steps): an_anvil_reorg_retracts_replaced_matches_before_replacements
RRG-4An unrecovered retract failure blocks rewind and blocks the pipeline from restarting.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::retract_failure_does_not_rewind_checkpoint
RRG-5A rewind is strictly backward, or it is refused and counted, never a silent no-op forward.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidate_never_advances_the_stored_checkpoint, ::invalidate_with_no_stored_checkpoint_writes_none
RRG-6Retracts finish, per sink, before any post-restart replacement match reaches that same sink.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::two_sinks_each_see_retracts_before_replacement_matches
e2e: an_anvil_reorg_retracts_replaced_matches_before_replacements
RRG-7Retracts are scoped to the sinks the invalidated monitors actually spawned, and are tombstoned after the delete that caused them.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::delete_invalidate_retracts_only_that_networks_sinks
RRG-8Sibling networks are untouched by one network’s invalidate.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::delete_invalidate_retracts_only_that_networks_sinks
RRG-9In-flight work on the pipeline being invalidated is dropped outright, never silently skipped as if it succeeded.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidation_refuses_to_prune_retract_or_rewind_after_a_quiesce_timeout, blockwatcher-core/tests/engine/boot.rs::shutdown_reports_a_pipeline_aborted_when_a_permanent_storage_outage_blocks_the_drain_deadline
RRG-10The EVM source invalidates from either a proven ancestor block or the tracked-window edge, whichever applies.Chain-agnosticismunit: blockwatcher-evm/tests/reorg_and_failover.rs::a_reorg_beyond_confirmations_invalidates_from_the_proven_fork, ::a_resume_past_the_tracked_window_invalidates_from_below_the_oldest_tracked_height
RRG-11The block tracker keeps clamp(2 * confirmations, 8, 64) blocks; a deeper reorg invalidates from the deepest provable point.Chain-agnosticismunit: blockwatcher-evm/src/source/rpc/chain.rs::cap_is_clamped_between_8_and_64; deepest-provable behavior covered by the EVM invalidation tests cited in the row above
RRG-12The retract pass ignores throttle and aggregation entirely; a retract is never suppressed or buffered.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::retracts_bypass_throttle, ::retract_flushes_before_delivering; RetractSink in blockwatcher-core/src/engine/invalidate/mod.rs carries no throttle or aggregate fields at all, bypass by construction
RRG-13Core never names a chain, even in the reorg and invalidation path.Chain-agnosticismby design, not live-tested: enforced by an architecture fitness check (scripts/check-dep-graph.sh, a per-crate allowlist plus a transitive denylist scan) rather than a runtime test, architectural by construction

Lifecycle

IDClaimDocumented inAssurance
LIF-1Every drain is bounded by drain_deadline_ms; nothing waits forever.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::restart_quiesces_storage_after_an_escalated_drain_before_reading_the_checkpoint
e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink
LIF-2Missing the drain deadline escalates to a hard abort rather than hanging.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::restart_refused_after_quiesce_timeout_leaves_the_network_abandoned
e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink
LIF-3Escalation to abort is loud (logged) and counted (metric), never silent.The pipelinee2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink (stderr names the missed deadline; no test separately pins the metric incrementing)
LIF-4Process exit codes are 0 (clean), 1 (config/boot failure), 2 (aborted pipeline), or 64 (usage error).The pipelineunit: blockwatcher/src/run.rs::exit_code_tests::clean_drain_exits_zero, ::aborted_pipelines_exit_two
e2e: a_port_conflict_is_a_boot_failure_with_exit_1, check_validates_like_boot_and_never_echoes_secrets (exit 1)
by design, not live-tested: exit 64 is pinned only as a constant in blockwatcher/src/cli.rs::tests::usage_exit_is_sysexits_ex_usage_and_help_names_it, no test drives the real process to that exit code, and the drain/abort scenarios above already cover the live-observable exits
LIF-5, LIF-6, LIF-7, LIF-8, LIF-9, LIF-10The quiesce budget, its refusal ladder, and owed-debt accounting behave as a coherent whole across a restart.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::restart_quiesces_storage_after_an_escalated_drain_before_reading_the_checkpoint, ::restart_refused_after_quiesce_timeout_leaves_the_network_abandoned, ::a_refused_quiesce_stays_owed_until_one_completes
by design, not live-tested: requires a deterministically wedgeable storage backend, the FlakyStorage/DetachingCheckpointStorage-backed in-process suite is the designed instrument, the live suite covers the escalation exit path (existing shutdown.rs).
LIF-11Workers and pipeline instances share their topology according to the documented rules, not ad hoc.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::put_monitor_naming_a_previously_unreferenced_sink_delivers_through_it_and_keeps_checkpointing, ::a_swap_that_leaves_the_sink_set_unchanged_still_never_restarts_the_pipeline
LIF-12source.status uses a fixed vocabulary, including abandoned for a refused restart.The pipelineunit: blockwatcher-core/src/status.rs::source_status_view_serializes_as_an_internally_tagged_enum; blockwatcher-core/tests/engine/escalation.rs::restart_refused_after_quiesce_timeout_leaves_the_network_abandoned (abandoned reached at runtime)
LIF-13Process stdout carries only canonical match JSON lines, nothing else.The pipelinee2e: stdout_carries_only_canonical_match_json_lines
LIF-14A refused DELETE does not clear that resource’s pause row.HTTP API referenceunit: blockwatcher-core/tests/engine/deletes.rs::a_stale_delete_does_not_clear_a_paused_monitor; blockwatcher-api/tests/api/ops.rs::a_stale_monitor_delete_does_not_clear_pause
LIF-15Pause or resume of one monitor is not blocked by an unrelated spec on the same chain that no longer compiles.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::pause_monitor_succeeds_when_an_unrelated_spec_no_longer_compiles

HTTP API

IDClaimDocumented inAssurance
API-1Auth answers 401 for a missing or unknown token, and 403 for a token too weak in scope.HTTP API referenceunit: blockwatcher-api/tests/api/auth.rs::everything_else_is_401_without_or_with_a_wrong_bearer_token, blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope (403)
e2e: the_api_rejects_bad_tokens_and_never_leaks_the_good_one (401), the_scope_ladder_holds_over_real_http (403)
API-2Token comparison is constant-time, and the bearer scheme match is case-insensitive.HTTP API referenceunit: blockwatcher-api/src/auth/mod.rs::token_comparison_accepts_equal_and_rejects_unequal_and_prefixes, ::the_scheme_is_case_insensitive_and_only_bearer_carries_a_credential; blockwatcher-api/tests/api/auth.rs::the_bearer_scheme_is_matched_case_insensitively
API-3/health is exempt from auth ahead of the router, not by a route-level bypass.HTTP API referenceunit: blockwatcher-api/tests/api/auth.rs::health_answers_without_a_token, ::no_registered_path_and_no_unknown_path_answers_without_a_token
e2e (implicit): the_api_rejects_bad_tokens_and_never_leaks_the_good_one
API-4env: secrets resolve fresh per request; they are never cached and never logged.HTTP API referenceunit: blockwatcher-types/src/secret.rs::resolve_reads_the_variable_at_call_time_and_errors_name_the_variable_never_a_value, blockwatcher-api/tests/api/auth.rs::api_token_debug_never_carries_the_secret, ::each_labelled_token_authenticates_and_an_unknown_one_does_not
e2e: the_api_rejects_bad_tokens_and_never_leaks_the_good_one
API-5, API-6, API-7, API-8, API-9The PUT/DELETE ETag lifecycle: create returns 201 with an ETag, a repeat create is 409, a conditional update with the right ETag is 200, a stale If-Match is 412 with actual_version, and delete needs If-Match (428 without, 412 stale, 204 correct).HTTP API referenceunit: blockwatcher-api/tests/api/resources.rs::monitor_crud_round_trips_with_versions, ::network_sink_and_spec_routes_answer_the_same_shapes
e2e: etag_lifecycle_create_conflict_update_delete
API-10A collection GET is a bare list; it never carries a version or ETag per item.HTTP API referenceunit: blockwatcher-api/tests/api/resources.rs::each_family_listing_answers_with_only_its_own_records, ::monitor_crud_round_trips_with_versions
by design, not live-tested: pinned in blockwatcher-api wire and resource tests.
API-11Deleting a sink a monitor still references answers 422 still_referenced.HTTP API referenceunit: blockwatcher-api/tests/api/resources.rs::an_unparseable_monitor_is_deletable_and_names_itself_in_a_refusal
e2e: etag_lifecycle_create_conflict_update_delete
API-12Server errors (5xx) always answer a fixed, generic body; nothing internal leaks onto the wire.HTTP API referenceunit: blockwatcher-api/src/error.rs::internal_errors_never_leak_their_message_to_the_wire, ::the_error_body_is_the_pinned_wire_shape; blockwatcher-api/tests/api/resources.rs::reading_an_unparseable_record_is_500_not_422
by design, not live-tested: pinned in blockwatcher-api wire tests, a live duplicate adds fidelity to nothing but HTTP framing the unit suite already pins exactly.
API-13The scope ladder (read < operate < admin) is enforced on every registered route.HTTP API referenceunit: blockwatcher-api/src/auth/scope.rs::read_is_less_than_operate_is_less_than_admin, blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope, ::a_read_token_cannot_create_a_sink_and_an_admin_token_is_not_forbidden
e2e: the_scope_ladder_holds_over_real_http
API-14Monitor pause and resume persist, independent of whether the pipeline is currently running.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::monitor_and_network_pause_and_resume_persist_regardless_of_pipeline_state, ::pause_stops_matching_and_resume_restores_it
e2e: pause_persists_across_a_restart
API-15Network pause and resume persist, and a paused network is never spawned on restart.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::network_pause_reports_paused_and_resume_restores_live, ::monitor_and_network_pause_and_resume_persist_regardless_of_pipeline_state
e2e: pause_persists_across_a_restart
API-16paused_monitors answers null, not a fabricated list, when storage will not answer.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::status_reports_unknown_rather_than_fabricated_when_storage_will_not_answer
e2e (shape, in passing): pause_persists_across_a_restart
API-17Pause views (monitor vs network) diverge only when a failed republish leaves them out of sync.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::pause_views_diverge_when_a_paused_network_leaves_nothing_to_publish_to
API-18Skip requires pause first (409 otherwise), refuses to rewind, and honors tip semantics.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::network_skip_shapes_conflict_refusal_and_success, ::network_skip_to_tip_uses_confirmed_tip, ::a_tip_lookup_that_cannot_answer_is_a_503_that_writes_nothing; blockwatcher-core/tests/engine/control.rs::skip_network_refuses_when_not_paused, ::skip_network_refuses_rewind, ::skip_network_tip_unsupported_refuses
e2e: skip_and_checkpoint_survive_network_recreation
API-19Checkpoint delete answers 409 while the network resource still exists; deleting the network keeps the checkpoint rather than discarding it.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::checkpoint_reset_is_409_while_network_exists_then_204_then_404
e2e: skip_and_checkpoint_survive_network_recreation
API-20checkpoint_provenance (the module that wrote a checkpoint) is enforced with 409 on a mismatched module swap.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::a_network_put_switching_modules_over_a_checkpoint_is_409; blockwatcher-core/tests/engine/control.rs::put_network_refuses_a_module_switch_over_an_existing_checkpoint, blockwatcher-core/tests/engine/boot.rs::boot_refuses_a_checkpoint_written_by_a_different_module
by design, not live-tested: pinned in blockwatcher-api wire and resource tests.
API-21, API-22, API-23Dry-run has an enforced payload cap, and its report semantics (no-match vs error vs held) are pinned.HTTP API referenceunit: blockwatcher-api/tests/api/wire.rs::test_endpoint_body_matches_the_pinned_literal, blockwatcher-api/tests/api/ops.rs::a_dry_run_over_too_many_payloads_is_refused_naming_the_limit, ::test_endpoint_dry_runs_without_delivering, ::a_predicate_that_errors_evaluating_is_counted_not_read_as_no_match, ::a_payload_no_selector_wanted_is_reported_as_no_match
by design, not live-tested: pinned in blockwatcher-api wire tests, a live duplicate adds fidelity to nothing but HTTP framing the unit suite already pins exactly.
API-24Network operations (skip, pause, checkpoint delete) act directly on storage; they never replay ingestion to get there.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::skip_network_cursor_forward_while_paused (patches checkpoint directly, no rescan); composite evidence from the pause, resume, pause-divergence, and skip tests cited above
API-25Reads stay answerable while a write is in flight; nothing blocks the whole API on one mutation.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::a_read_stays_answerable_while_a_write_is_wedged
API-26POST /specs/{id}/from-chain is Admin, uses If-Match like PUT, maps importer Invalid to 422 invalid_resource and Unavailable to 503, a missing network to 422 missing_reference, and a corrupt stored network through ApiError::stored (500).HTTP API referenceunit: blockwatcher-api/tests/api/spec_import.rs suite; blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope
API-27Write-time InvalidResource (throttle / aggregate / retry bounds, and any other request-body validation the engine raises before persist) is HTTP 422 invalid_resource, never 500 internal. ApiError::stored remains 500 only for a stored row that will not deserialize.HTTP API reference, Resourcesunit: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource
API-28Skip to an absolute cursor moves the source module’s own cold-start field, whatever that module names it, and core never writes one.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::skip_network_cursor_patches_a_ledger_sources_own_start_field, ::skip_network_refuses_a_source_with_no_cold_start; blockwatcher-stellar/src/registry.rs::set_start_writes_start_ledger_and_refuses_a_cursor_past_u32; blockwatcher-evm/src/registry.rs::evm_sources_declare_their_own_cold_start_and_tip
API-29GET /catalog advertises per source module whether it can report a confirmed tip, and skip to tip refuses exactly the modules it reports cannot.HTTP API referenceunit: blockwatcher-api/tests/api/catalog.rs::catalog_advertises_confirmed_tip_per_source, ::skip_to_tip_is_refused_for_a_source_the_catalog_reports_has_no_tip; blockwatcher-core/tests/engine/control.rs::skip_network_tip_unsupported_refuses; blockwatcher-stellar/src/registry.rs::stellar_rpc_reports_no_confirmed_tip

Dashboard

IDClaimDocumented inAssurance
DASH-1The companion HTTP listener binds before ui-ingest is registered. Until that sink exists, the published port answers 503 with a waiting page (Retry-After: 3), never a connection reset. The Host allowlist still answers 403, and /ingest still requires its header once ready.The dashboard, Dockerunit: ui/server/tests/ready.rs::a_not_ready_listener_answers_503_instead_of_resetting, ::a_foreign_host_is_still_403_while_not_ready, ::ingest_still_requires_its_header_once_ready

Stellar spec import

IDClaimDocumented inAssurance
STEL-IMP-1Fetch materializes SAC to {catalog: stellar-asset} and WASM to {wasm} that compile_spec accepts; G-addresses are Invalid; JSON-RPC -32602..=-32600 is Invalid; other RPC failure is Unavailable without echoing http; a WASM code lookup pins the instance endpoint.Stellarunit: blockwatcher-stellar/src/spec_from_rpc.rs::sac_instance_materializes_catalog, ::wasm_instance_with_code_materializes_wasm, ::g_address_is_invalid, ::rpc_status_500_is_unavailable, ::jsonrpc_invalid_params_is_invalid, ::wasm_code_lookup_pins_the_instance_endpoint
STEL-IMP-2import_spec_from_chain persists only after a successful importer call; missing importer is UnsupportedChain listing importer chains; missing decoder is UnsupportedChain listing decoder chains before the importer is called; a missing network is MissingReference; a network that changes during fetch is Conflict.HTTP API referenceunit: blockwatcher-core/tests/engine/spec_import.rs::import_writes_the_importer_payload_through_put_spec, ::import_without_importer_lists_importer_chains, ::import_does_not_write_on_importer_failure, ::import_without_decoder_lists_decoder_chains_and_does_not_call_importer, ::import_missing_network_is_missing_reference, ::import_conflicts_when_network_changes_during_fetch
STEL-IMP-3CLI spec-import writes a Spec JSON file check would accept, and a no-stellar build refuses at run with UnsupportedChain listing an empty importer set.Configuration referenceunit: blockwatcher/tests/spec_import.rs::spec_import_writes_stellar_asset_catalog_spec, ::spec_import_refuses_when_stellar_is_not_compiled_in; blockwatcher/src/cli.rs::spec_import_requires_network_address_and_id
STEL-SRC-1A configured start_ledger below oldestLedger on first health is refused; a checkpoint below that window clamps with a retention gap, the same as a mid-run floor advance.Stellarunit: blockwatcher-stellar/src/source/run.rs::start_ledger_before_oldest_is_permanent, ::checkpoint_before_oldest_clamps_on_first_health, ::retention_floor_after_degraded_clamps

evm-mempool

IDClaimDocumented inAssurance
MEM-1The mempool source keeps a watermark-only checkpoint; a restart loses whatever was still pending.Delivery guaranteesunit: blockwatcher-evm/tests/mempool_loop.rs::a_restart_loses_pending_work_by_design
MEM-2A pending transaction seen again mints a new id; dedupe happens on transaction hash, not on the minted id.Delivery guaranteesunit: blockwatcher-evm/tests/mempool_loop.rs::a_re_seen_pending_call_gets_a_new_arrival_cursor
MEM-3The mempool source never produces SourceOutcome::Invalidated.Delivery guaranteesunit: by construction, mempool/run.rs::run() never constructs that variant; no dedicated runtime test
MEM-4SourceCaps.event_timestamps is false for mempool sources and true for evm-rpc and evm-subgraph.Delivery guaranteesunit: blockwatcher-core/src/compile.rs::compile_rejects_a_gate_when_the_source_provides_no_event_timestamps, ::compile_accepts_a_gate_when_the_source_provides_event_timestamps; blockwatcher-evm/src/source/subgraph/run.rs::caps_declare_event_timestamps
e2e: a_gated_monitor_on_a_mempool_source_is_refused_in_both_directions, a_gated_monitor_survives_a_restart_and_delivers_its_digest

evm-subgraph

IDClaimDocumented inAssurance
SUB-1Completeness is whatever the deployed subgraph indexed. InterestSet only narrows GraphQL _in fetching; it never changes matching. A monitor for an address the subgraph never stored never fires.EVMunit: blockwatcher-evm/src/source/subgraph/query.rs::empty_interest_omits_in_filters, ::nonempty_interest_adds_in_filters_and_requests_transactions; blockwatcher-evm/src/source/subgraph/run.rs::empty_interest_still_emits_indexed_logs
SUB-2Cannot query field for logs / transactions / _meta publishes a named Degraded whose reason names the schema mismatch. It is not an empty successful window.EVM, Troubleshootingunit: blockwatcher-evm/src/source/subgraph/run.rs::schema_mismatch_publishes_degraded_and_does_not_advance; blockwatcher-evm/src/source/subgraph/fetch.rs::a_verdict_the_pool_retried_to_exhaustion_keeps_its_class
SUB-3Disagreement of _meta.hash or a row blockHash against a height this run already sent through ctx.events returns SourceOutcome::Invalidated { from } with rpc’s end-of-block secondary. In-window disagreement against unsent work retries in place. The tracker is (number, hash) only — not rpc RecentChain (no parentHash on Log).EVMunit: blockwatcher-evm/src/source/subgraph/run.rs::already_emitted_hash_disagreement_invalidates_from_end_of_block, ::in_window_disagreement_retries_without_invalidate; blockwatcher-evm/src/source/subgraph/resume.rs::disagreement_with_no_agreeing_ancestor_invalidates_from_below_oldest, ::disagreement_invalidates_from_the_highest_agreeing_height, ::a_head_below_tracked_heights_refutes_them; blockwatcher-evm/src/source/subgraph/emit.rs::mixed_hashes_in_one_block_are_detected
SUB-4Kinded cursors match rpc packing: transactions then logs, pack_secondary identical.EVMunit: blockwatcher-evm/src/source/subgraph/run.rs::emits_transactions_then_logs_with_rpc_kinded_cursors; blockwatcher-evm/src/source/subgraph/fetch.rs::pages_with_id_gt_until_a_short_page

Boot

IDClaimDocumented inAssurance
BOOT-1validate_and_build refuses stored rows that are no longer valid, at boot.Architecture decisionsunit: blockwatcher-core/tests/engine/boot.rs::an_unregistered_sink_module_fails_boot_before_anything_spawns, ::validate_rejects_what_start_rejects_with_the_same_error; blockwatcher/tests/boot.rs::a_seed_with_an_unknown_sink_module_aborts_boot_and_stores_nothing
BOOT-2A malformed secret shape is refused at boot, without ever echoing its value.Architecture decisionsunit: blockwatcher/src/config.rs::a_literal_auth_token_is_refused_pointing_at_the_reference_form, ::a_pasted_token_is_refused_without_the_message_carrying_it, blockwatcher-storage/src/postgres/mod.rs::tests::a_literal_url_is_refused_pointing_at_the_reference_form_without_echoing_it
e2e (in passing): check_validates_like_boot_and_never_echoes_secrets
BOOT-3Memory-backed storage persists nothing across a restart.Architecture decisionsunit: blockwatcher/tests/boot.rs::a_boot_on_the_non_persistent_store_warns_that_nothing_survives_a_restart
by design, not live-tested: memory storage’s non-persistence is definitional, a live scenario would only re-prove what the type itself already guarantees.
BOOT-4Listeners bind before the engine starts; a port conflict is a boot failure, not a hang.Architecture decisionse2e: a_port_conflict_is_a_boot_failure_with_exit_1
BOOT-5blockwatcher check validates exactly like boot does, and echoes no secret.Architecture decisionse2e: check_validates_like_boot_and_never_echoes_secrets
BOOT-6A populated store is resumed on restart, not re-seeded over.Architecture decisionse2e: sigkill_and_restart_loses_nothing