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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| PROG-1 | The checkpoint advances only past the longest contiguous fully-done prefix, never further. | Delivery guarantees | unit: blockwatcher-core/src/progress.rs::checkpoint_advances_only_past_fully_completed_prefixe2e: sigkill_and_restart_loses_nothing |
| PROG-2 | Fully done means delivered or dead-lettered, nothing else counts toward the prefix. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::successful_match_delivery_is_journaled, ::dead_letter_recording_is_what_advances_the_checkpoint |
| PROG-3 | A dropped completion guard stalls its prefix forever, with no timeout and no fabricated completion. | Delivery guarantees | unit: blockwatcher-core/src/progress.rs::a_dropped_guard_stalls_the_checkpoint |
| PROG-4 | A stalled prefix is visible from outside as in_flight_events in status. | Delivery guarantees | unit: blockwatcher-core/src/progress.rs::in_flight_tracks_registration_and_completion |
| PROG-5 | An event that produces zero matches still completes at dispatch, and the cursor advances past it. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/processor.rs suite (event-dispatch and checkpoint-advance tests) |
| PROG-6 | A raw event registers with Progress exactly once, after dispatch, never before. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/processor.rs::dispatches_one_work_item_per_action_and_advances_checkpoint_on_completion, ::persist_failure_does_not_skip_begin |
| PROG-7 | A cursor persists only once every guard it depends on has completed. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/processor.rs::checkpoint_advances_to_the_second_event_only_after_both_complete_in_ordere2e: sigkill_and_restart_loses_nothing |
| PROG-8 | The checkpoint writer refuses to persist a regression against what is already stored. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/checkpoint_writer.rs::a_mid_run_regression_is_refused_counted_and_never_persisted |
| PROG-9 | Misrouted events (a raw event carrying another network’s id) are dropped and counted, never re-attributed. | Delivery guarantees | unit: 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-10 | The 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 guarantees | unit: blockwatcher-core/src/pipeline/processor.rs::a_swap_landing_between_two_monitors_of_the_same_event_cannot_split_its_processing |
At-least-once delivery
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| ALO-1 | A crash can repeat work already in flight, but it can never skip it. | Delivery guarantees | e2e: sigkill_and_restart_loses_nothing |
| ALO-2 | On resume, the persisted checkpoint is the only truth the successor trusts. | Delivery guarantees | unit: blockwatcher-core/tests/engine/acceptance.rs::crash_resume_does_not_redeliver_or_skipe2e: sigkill_and_restart_loses_nothing |
| ALO-3 | MatchId::derive is deterministic across process lifetimes, so a successor mints the same ids a crashed predecessor would have. | Delivery guarantees | unit: blockwatcher-types/src/id.rs::match_id_re_derivation_from_the_same_content_is_stablee2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash, a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain |
| ALO-4 | A redelivered Retracted or Digest must dedupe on match id at the consumer, same as a Match. | Delivery guarantees | unit: blockwatcher-sinks/src/lib.rs::canonical_body_retracted_is_tagged, ::canonical_body_digest_is_taggede2e: an_anvil_reorg_retracts_replaced_matches_before_replacements |
| ALO-5 | All three SinkEvent variants share the same retry and dead-letter path, no variant gets special treatment. | Delivery guarantees | unit: 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-6 | The SinkEvent set is closed: nothing outside Match, Retracted, and Digest can reach a sink. | Delivery guarantees | unit: compile-enforced by an exhaustive match over SinkEvent throughout blockwatcher-core/src/pipeline/sink_worker.rs, no dedicated runtime test needed |
| ALO-7 | Built-in sinks render each variant as tagged, canonical JSON. | Delivery guarantees | unit: blockwatcher-sinks/src/lib.rs::canonical_body_match_is_tagged, ::canonical_body_retracted_is_tagged, ::canonical_body_digest_is_taggede2e: an_anvil_event_reaches_the_webhook_exactly_once (Match shape), an_anvil_reorg_retracts_replaced_matches_before_replacements (Retracted shape) |
Backpressure
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| BP-1 | Bounded channels never drop an item on the full side; a full channel blocks the producer instead. | The pipeline | unit: blockwatcher-core/tests/engine/acceptance.rs::backpressure_never_dropsby design, not live-tested: acceptance::backpressure_never_drops is the right instrument, live channel-capacity plumbing is unobservable without white-box hooks. |
| BP-2 | A slow sink eventually stalls the whole pipeline behind it, rather than the pipeline shedding work around it. | The pipeline | unit: blockwatcher-core/tests/engine/acceptance.rs::backpressure_never_dropsby design, not live-tested: same instrument, live channel-capacity plumbing is unobservable without white-box hooks. |
| BP-3 | Channel capacities come from configuration and are applied to every pipeline the same way. | The pipeline | by 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-4 | Graceful shutdown drains everything already queued before the process exits. | The pipeline | unit: blockwatcher-core/src/pipeline/mod.rs::a_graceful_root_cancel_still_delivers_everything_already_buffered_behind_a_slow_sinke2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink |
Dead letters
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| DLQ-1 | Retry exhaustion always writes a dead letter, a delivery is never simply dropped. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::retry_budget_exhaustion_dead_letters_with_attempt_counte2e: throttled_deliveries_land_in_the_dead_letter_queue_not_nowhere (throttle path), dead_letters_survive_a_restart_on_sqlite (retry-exhaustion path) |
| DLQ-2 | A dead letter carries id, monitor, sink, cursor, attempt count, reason, and payload. | Delivery guarantees | unit: blockwatcher-types/src/event.rs::dead_letter_serde_round_tripse2e (partial): dead_letters_survive_a_restart_on_sqlite |
| DLQ-3 | The delivery guard only completes once the dead letter has durably landed in storage. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_letter_recording_is_what_advances_the_checkpoint |
| DLQ-4 | A dead-letter write that fails retries forever on backoff rather than giving up. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_letter_recording_is_what_advances_the_checkpoint (FlakyStorage-backed retry) |
| DLQ-5 | A dead-lettered Match stays in the delivery journal; a dead-lettered Retracted does not. | Delivery guarantees | unit: 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-6 | A permanent error dead-letters immediately, with no retry budget spent first. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::permanent_failure_dead_letters_on_first_attempt |
| DLQ-7 | Transient failures back off with doubling delay, bounded above. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::transient_failures_retry_with_doubling_backoff_then_succeed |
| DLQ-8 | A dead letter’s reason always leads with its ErrorClass prefix (transient:, permanent:, and so on). | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::retry_budget_exhaustion_dead_letters_with_attempt_counte2e: dead_letters_survive_a_restart_on_sqlite |
| DLQ-9 | Listing dead letters pages in insertion order and is a pure read with no side effect. | Delivery guarantees | unit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_keep_order_and_page (run per backend via blockwatcher-storage/tests/contract.rs) |
| DLQ-10 | A replay that succeeds deletes the letter; a replay that exhausts its budget bumps the attempt count in place and answers 502. | HTTP API reference | unit: 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-11 | A dead letter with no stored payload refuses replay with 422. | HTTP API reference | unit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_without_payload_is_invalid_resourceby 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-12 | A dead-lettered Retracted refuses replay with 422. | HTTP API reference | unit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_retracted_payload_is_invalid_resourceby 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-13 | Discard, single or bulk with filters, counts what it removed and never delivers anything. | HTTP API reference | unit: 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-14 | Identity for replay is the pair (match_id, sink); an ambiguous match across sinks answers 409. | HTTP API reference | unit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_ambiguous_match_id_is_conflictby design, not live-tested: pinned in blockwatcher-api wire tests, which already exercise the HTTP framing a live duplicate would add. |
| DLQ-15 | Legacy bare-Match dead-letter rows from before the identity schema still load. | Delivery guarantees | unit: blockwatcher-storage/src/sqlite.rs::a_version_five_database_migrates_to_the_dead_letter_identity_schemaby design, not live-tested: pinned in blockwatcher-api wire tests, which already exercise the HTTP framing a live duplicate would add. |
| DLQ-16 | A sqlite-backed dead-letter queue survives a restart; a memory-backed one does not. | Delivery guarantees | e2e: dead_letters_survive_a_restart_on_sqlite (sqlite half; the memory-backend half is not separately asserted anywhere, flagged as a follow-up) |
| DLQ-17 | The retention cap prunes the oldest letters in-write, and a zero-valued cap is refused at boot. | Delivery guarantees | unit: 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_retentionby 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-18 | dead_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 guarantees | unit: 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| THR-1 | A match over the throttle budget dead-letters as permanent: throttled:, never silently dropped. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_suppresses_excess_and_dead_letters_ite2e: throttled_deliveries_land_in_the_dead_letter_queue_not_nowhere |
| THR-2 | Throttled letters are listable and replayable exactly like any other dead letter. | Delivery scenarios | e2e: 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-3 | Replaying a throttled letter bypasses the window it was originally suppressed under. | Delivery scenarios | unit: blockwatcher-core/tests/engine/control.rs::replaying_a_throttled_letter_bypasses_the_still_exhausted_windowe2e: replaying_a_throttled_letter_bypasses_the_still_open_window |
| THR-4 | Only a delivered match spends throttle budget; a suppressed or failed one does not. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_suppresses_excess_and_dead_letters_it, ::throttle_counts_every_suppressed_match_not_every_suppressed_delivery |
| THR-5 | A Retracted skips throttle accounting entirely, in both directions. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::a_successful_retract_does_not_spend_an_open_throttle_window, ::retracts_bypass_throttle |
| THR-6 | The throttle window is scoped per (network, sink), never global across the process. | Delivery scenarios | unit: blockwatcher-core/tests/engine/acceptance.rs::a_throttle_window_is_scoped_per_network_not_per_sink_id |
| THR-7 | The throttle window anchors at the first admitted delivery, not at configuration time. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/throttle.rs::the_window_belongs_to_the_first_delivery_it_admitse2e (in passing): a_throttle_window_resets_on_restart |
| THR-8 | The throttle window is runtime-only state; a restart opens a fresh one. | Delivery scenarios | e2e: a_throttle_window_resets_on_restart (no dedicated unit test isolates this; candidate: blockwatcher-core/src/pipeline/throttle.rs) |
| THR-9 | A zero-valued throttle setting is refused both at write and at boot. | Delivery scenarios | unit: blockwatcher-types/src/resource.rs::throttle_validate_rejects_zero_max_deliveries, ::throttle_validate_rejects_zero_window_mswrite: blockwatcher-core/src/control/writes.rs::put_sink_rejects_a_zero_valued_throttleboot: blockwatcher-core/tests/engine/boot.rs::a_zero_valued_sink_throttle_fails_boot_before_anything_spawnsHTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource |
| THR-10 | Omitted throttle fields default to 60 deliveries per 60000ms. | Delivery scenarios | unit: blockwatcher-types/src/resource.rs::throttle_defaults_and_partial_config |
| THR-11 | A digest spends exactly one delivery of the throttle budget, however many matches it folded. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::a_digest_spends_one_delivery_of_the_throttle_budget |
| THR-12 | A throttle-refused digest dead-letters each of its member matches individually. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_counts_every_suppressed_match_not_every_suppressed_delivery |
Aggregation
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| AGG-1 | Only Match buffers for aggregation; a Retracted is never held. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::retract_flushes_before_delivering |
| AGG-2 | There are exactly four flush triggers: max_batch, the window deadline, a retract, and drain on shutdown. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch, ::digest_flushes_at_window_deadline, ::retract_flushes_before_delivering, ::drain_flushes_the_buffere2e ( max_batch): three_matches_in_one_window_arrive_as_one_digest_through_the_template |
| AGG-3 | A retract flushes the buffer first; it never overtakes buffered matches. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::retract_flushes_before_delivering |
| AGG-4 | A single-item flush renders as a plain Match, not a one-member Digest. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::single_item_flush_is_a_plain_match |
| AGG-5 | A multi-item flush renders as exactly one Digest. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batche2e: three_matches_in_one_window_arrive_as_one_digest_through_the_template |
| AGG-6 | Buffered matches stall the checkpoint by design, visible as in_flight_events until the window flushes. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::checkpoint_stalls_while_bufferede2e (observable): a_buffered_aggregate_window_is_re_emitted_after_a_crash |
| AGG-7 | A crash drops buffered completion guards; the replay after restart re-delivers, and consumer-side dedupe by id yields the full set. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::hard_cancel_drops_buffered_guardse2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash |
| AGG-8 | No aggregation window state is persisted; everything a restart needs is re-derived. | Delivery scenarios | e2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash |
| AGG-9 | Digest members journal at their own cursor, in order, before the digest’s completion guards resolve. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch, ::a_digest_interrupted_mid_journal_does_not_complete_its_guard |
| AGG-10 | Digest exhaustion dead-letters each member individually, in order. | Delivery scenarios | unit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_failure_dead_letters_each_match |
| AGG-11 | A digest member replays through the dead-letter API as a single ordinary match, not as part of a digest. | Delivery scenarios | unit: blockwatcher-core/tests/engine/control.rs replay suite (no test isolates the digest-member case specifically) |
| AGG-12 | Aggregation bounds (max_batch, window_ms) are enforced both at write and at boot. | Delivery scenarios | unit: 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_spawnsHTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource |
| AGG-13 | Retry backoff bounds are validated both at write and at boot. | Delivery scenarios | unit: blockwatcher-core/src/control/writes.rs and blockwatcher-core/tests/engine/boot.rs retry-bound refusal testsHTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource |
Gates
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| GATE-1 | A quiet hit (no fire) still registers cleanly, leaving zero outstanding work behind. | Gates | unit: blockwatcher-core/src/pipeline/gate.rs::journal_held_gauge_tracks_the_persisted_length |
| GATE-2 | A gate hit persists before its completion guard resolves. | Gates | unit: blockwatcher-core/src/pipeline/gate.rs::retain_persists_the_journal_including_this_hit |
| GATE-3 | A failed persist stalls the guard; it never silently skips the hit. | Gates | unit: 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-4 | Invalidate prunes gate hits with cursor strictly greater than from, and nothing at or before it. | Gates | unit: 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-5 | Emit metadata (last_emit_cursor) is cleared only when its cursor is strictly greater than from. | Gates | unit: 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-6 | Gate replay is at-least-once, and the ids it mints are deterministic across a crash. | Gates | unit: 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-7 | A max_once discard is not a dead letter; it is a quiet, counted no-op. | Gates | unit: 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-8 | A threshold gate resets its journal on fire; leftover hits past the fired count stay held. | Gates | unit: blockwatcher-gates/src/threshold.rs::three_hits_in_window_emit_oldest_three, ::leftovers_after_emit_are_the_tail |
| GATE-9 | A gate module returns indices into the journal; only core mints the resulting match ids. | Gates | unit: 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-10 | Gate windows are measured in block.timestamp, never wall-clock time. | Gates | unit: 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-11 | A gate journal over its cap drops the oldest entries, and counts what it dropped. | Gates | unit: blockwatcher-core/src/pipeline/gate.rs::journal_over_cap_counts_dropped |
| GATE-12 | A hit with no resolvable event timestamp is skipped and counted, never inserted untimestamped. | Gates | unit: blockwatcher-core/src/pipeline/gate.rs::missing_timestamp_is_quiet_and_does_not_insert |
| GATE-13 | An invalid on_hit decision from a gate module discards only that hit, and is counted. | Gates | unit: 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-14 | A schema with no block.timestamp field refuses a time-based gate at write. | Gates | unit: 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-15 | A gate on a mempool source is refused, and a mempool source under a gated monitor is refused, in both directions. | Gates | unit: 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_timestampse2e: a_gated_monitor_on_a_mempool_source_is_refused_in_both_directions |
| GATE-16 | A hot swap of a gate’s config does not migrate its held journal; the holds reset. | Gates | unit: blockwatcher-core/src/control/writes.rs::put_monitor_clears_gate_hits_when_gate_config_changes, ::put_monitor_keeps_gate_hits_when_gate_is_unchangede2e (in passing): a_pending_emission_outlives_its_deleted_monitor |
| GATE-17 | Sqlite-backed gate holds survive a restart. | Gates | e2e: a_gated_monitor_survives_a_restart_and_delivers_its_digest |
| GATE-18 | A dry-run against a gated monitor is inert: it never writes to the held journal. | Gates | unit: blockwatcher-core/tests/engine/control.rs::test_monitor_holds_an_in_window_hit_without_writing_storage |
| GATE-19 | The dry-run TestReport shape distinguishes no-match, rejected-predicate, and held/fired outcomes. | Gates | unit: 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-20 | Out-of-range gate configuration (window, count) answers 422 at write. | Gates | unit: 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_refusedby 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-21 | An unknown gate module name answers 422 naming the module catalog. | Gates | unit: blockwatcher-core/src/control/writes.rs::put_monitor_rejects_unknown_gate_module_at_write_lookupby 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-22 | A monitor carries at most one gate; the resource schema has no way to express two. | Gates | by design, not live-tested: structurally enforced by the type Monitor.gate: Option<ModuleSel>, so a second gate cannot be constructed to test against |
| GATE-23 | A gate emit is exactly one storage transaction: journal consumed and outbox rows written atomically. | Gates | unit: blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sinke2e (in passing): a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain |
| GATE-24 | A 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, Resources | unit: 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| OBX-1 | Emit writes exactly one outbox row per sink, inside the same transaction that consumes the gate journal. | Gates | unit: 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-2 | An outbox row is held until its delivery outcome is durable, then deleted. | Gates | unit: 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-3 | A crash between a gate emit and its delivery outcome loses nothing. | Gates | e2e: a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain |
| OBX-4 | The startup drain sweeps up any outbox rows a crash left pending. | Gates | e2e: a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain |
| OBX-5 | A pending emission outlives the deletion of its own monitor or network; delivery still completes. | Gates | unit: blockwatcher-core/src/pipeline/sink_worker.rs::a_guardless_outbox_item_delivers_and_settles_without_a_checkpointe2e: a_pending_emission_outlives_its_deleted_monitor |
| OBX-6 | Invalidate prunes outbox rows whose cursor is strictly after from, reorged-out members are dropped. | Gates | unit: 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-7 | A permanent gate-outbox prune error during invalidate does not retry until cancel and does not wedge control-plane writes. | HTTP API reference | unit: blockwatcher-core/tests/engine/invalidate.rs::a_permanent_outbox_prune_during_invalidate_does_not_hang_control_plane_writes |
Delivery journal
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| JRN-1 | A delivery journals before its completion guard resolves. | Delivery guarantees | unit: blockwatcher-core/src/pipeline/sink_worker.rs::journal_write_failure_stalls_the_checkpoint |
| JRN-2 | A successful retract forgets its journaled delivery; a dead-lettered retract keeps the row. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::dead_lettered_retract_keeps_the_journal_rowe2e (in passing): an_anvil_reorg_retracts_replaced_matches_before_replacements |
| JRN-3 | journal_depth prunes the delivery journal in-write, not as a separate job. | Delivery guarantees | unit: 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-4 | There is no prune API and no switch to disable journal pruning. | Delivery guarantees | by 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-5 | A journal gap (an invalidate reaching past what the journal retained) is counted and logged loudly, never silent. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::journal_gap_is_counted_when_invalidate_cursor_is_older_than_retained_windowe2e: a_journal_gap_is_loud_not_silent |
| JRN-6 | Deliveries at or below the invalidate cursor duplicate on redelivery; they are never retracted. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::invalidated_source_retracts_then_offers_replacement_matches |
Reorg and invalidation
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| RRG-1 | A deep invalidate travels through a typed control path (SourceExitKind::Invalidated), not an ad-hoc signal. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::pause_after_matches_still_rewinds_on_invalidatee2e: an_anvil_reorg_retracts_replaced_matches_before_replacements |
| RRG-2 | A shallow fork, strictly inside the confirmation window, emits no Retracted at all. | Delivery guarantees | e2e: a_shallow_fork_emits_no_retract |
| RRG-3 | Invalidate runs its seven-step sequence in order: pause, retract, forget, rewind, resume, and so on. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::invalidated_source_retracts_then_offers_replacement_matchese2e (observable steps): an_anvil_reorg_retracts_replaced_matches_before_replacements |
| RRG-4 | An unrecovered retract failure blocks rewind and blocks the pipeline from restarting. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::retract_failure_does_not_rewind_checkpoint |
| RRG-5 | A rewind is strictly backward, or it is refused and counted, never a silent no-op forward. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::invalidate_never_advances_the_stored_checkpoint, ::invalidate_with_no_stored_checkpoint_writes_none |
| RRG-6 | Retracts finish, per sink, before any post-restart replacement match reaches that same sink. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::two_sinks_each_see_retracts_before_replacement_matchese2e: an_anvil_reorg_retracts_replaced_matches_before_replacements |
| RRG-7 | Retracts are scoped to the sinks the invalidated monitors actually spawned, and are tombstoned after the delete that caused them. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::delete_invalidate_retracts_only_that_networks_sinks |
| RRG-8 | Sibling networks are untouched by one network’s invalidate. | Delivery guarantees | unit: blockwatcher-core/tests/engine/invalidate.rs::delete_invalidate_retracts_only_that_networks_sinks |
| RRG-9 | In-flight work on the pipeline being invalidated is dropped outright, never silently skipped as if it succeeded. | Delivery guarantees | unit: 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-10 | The EVM source invalidates from either a proven ancestor block or the tracked-window edge, whichever applies. | Chain-agnosticism | unit: 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-11 | The block tracker keeps clamp(2 * confirmations, 8, 64) blocks; a deeper reorg invalidates from the deepest provable point. | Chain-agnosticism | unit: 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-12 | The retract pass ignores throttle and aggregation entirely; a retract is never suppressed or buffered. | Delivery guarantees | unit: 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-13 | Core never names a chain, even in the reorg and invalidation path. | Chain-agnosticism | by 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| LIF-1 | Every drain is bounded by drain_deadline_ms; nothing waits forever. | The pipeline | unit: blockwatcher-core/tests/engine/escalation.rs::restart_quiesces_storage_after_an_escalated_drain_before_reading_the_checkpointe2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink |
| LIF-2 | Missing the drain deadline escalates to a hard abort rather than hanging. | The pipeline | unit: blockwatcher-core/tests/engine/escalation.rs::restart_refused_after_quiesce_timeout_leaves_the_network_abandonede2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink |
| LIF-3 | Escalation to abort is loud (logged) and counted (metric), never silent. | The pipeline | e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink (stderr names the missed deadline; no test separately pins the metric incrementing) |
| LIF-4 | Process exit codes are 0 (clean), 1 (config/boot failure), 2 (aborted pipeline), or 64 (usage error). | The pipeline | unit: blockwatcher/src/run.rs::exit_code_tests::clean_drain_exits_zero, ::aborted_pipelines_exit_twoe2e: 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-10 | The quiesce budget, its refusal ladder, and owed-debt accounting behave as a coherent whole across a restart. | The pipeline | unit: 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_completesby 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-11 | Workers and pipeline instances share their topology according to the documented rules, not ad hoc. | The pipeline | unit: 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-12 | source.status uses a fixed vocabulary, including abandoned for a refused restart. | The pipeline | unit: 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-13 | Process stdout carries only canonical match JSON lines, nothing else. | The pipeline | e2e: stdout_carries_only_canonical_match_json_lines |
| LIF-14 | A refused DELETE does not clear that resource’s pause row. | HTTP API reference | unit: 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-15 | Pause or resume of one monitor is not blocked by an unrelated spec on the same chain that no longer compiles. | HTTP API reference | unit: blockwatcher-core/tests/engine/control.rs::pause_monitor_succeeds_when_an_unrelated_spec_no_longer_compiles |
HTTP API
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| API-1 | Auth answers 401 for a missing or unknown token, and 403 for a token too weak in scope. | HTTP API reference | unit: 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-2 | Token comparison is constant-time, and the bearer scheme match is case-insensitive. | HTTP API reference | unit: 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 reference | unit: blockwatcher-api/tests/api/auth.rs::health_answers_without_a_token, ::no_registered_path_and_no_unknown_path_answers_without_a_tokene2e (implicit): the_api_rejects_bad_tokens_and_never_leaks_the_good_one |
| API-4 | env: secrets resolve fresh per request; they are never cached and never logged. | HTTP API reference | unit: 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_note2e: the_api_rejects_bad_tokens_and_never_leaks_the_good_one |
| API-5, API-6, API-7, API-8, API-9 | The 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 reference | unit: blockwatcher-api/tests/api/resources.rs::monitor_crud_round_trips_with_versions, ::network_sink_and_spec_routes_answer_the_same_shapese2e: etag_lifecycle_create_conflict_update_delete |
| API-10 | A collection GET is a bare list; it never carries a version or ETag per item. | HTTP API reference | unit: blockwatcher-api/tests/api/resources.rs::each_family_listing_answers_with_only_its_own_records, ::monitor_crud_round_trips_with_versionsby design, not live-tested: pinned in blockwatcher-api wire and resource tests. |
| API-11 | Deleting a sink a monitor still references answers 422 still_referenced. | HTTP API reference | unit: blockwatcher-api/tests/api/resources.rs::an_unparseable_monitor_is_deletable_and_names_itself_in_a_refusale2e: etag_lifecycle_create_conflict_update_delete |
| API-12 | Server errors (5xx) always answer a fixed, generic body; nothing internal leaks onto the wire. | HTTP API reference | unit: 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_422by 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-13 | The scope ladder (read < operate < admin) is enforced on every registered route. | HTTP API reference | unit: 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_forbiddene2e: the_scope_ladder_holds_over_real_http |
| API-14 | Monitor pause and resume persist, independent of whether the pipeline is currently running. | HTTP API reference | unit: blockwatcher-api/tests/api/ops.rs::monitor_and_network_pause_and_resume_persist_regardless_of_pipeline_state, ::pause_stops_matching_and_resume_restores_ite2e: pause_persists_across_a_restart |
| API-15 | Network pause and resume persist, and a paused network is never spawned on restart. | HTTP API reference | unit: blockwatcher-api/tests/api/ops.rs::network_pause_reports_paused_and_resume_restores_live, ::monitor_and_network_pause_and_resume_persist_regardless_of_pipeline_statee2e: pause_persists_across_a_restart |
| API-16 | paused_monitors answers null, not a fabricated list, when storage will not answer. | HTTP API reference | unit: blockwatcher-core/tests/engine/control.rs::status_reports_unknown_rather_than_fabricated_when_storage_will_not_answere2e (shape, in passing): pause_persists_across_a_restart |
| API-17 | Pause views (monitor vs network) diverge only when a failed republish leaves them out of sync. | HTTP API reference | unit: blockwatcher-api/tests/api/ops.rs::pause_views_diverge_when_a_paused_network_leaves_nothing_to_publish_to |
| API-18 | Skip requires pause first (409 otherwise), refuses to rewind, and honors tip semantics. | HTTP API reference | unit: 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_refusese2e: skip_and_checkpoint_survive_network_recreation |
| API-19 | Checkpoint delete answers 409 while the network resource still exists; deleting the network keeps the checkpoint rather than discarding it. | HTTP API reference | unit: blockwatcher-api/tests/api/ops.rs::checkpoint_reset_is_409_while_network_exists_then_204_then_404e2e: skip_and_checkpoint_survive_network_recreation |
| API-20 | checkpoint_provenance (the module that wrote a checkpoint) is enforced with 409 on a mismatched module swap. | HTTP API reference | unit: 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_moduleby design, not live-tested: pinned in blockwatcher-api wire and resource tests. |
| API-21, API-22, API-23 | Dry-run has an enforced payload cap, and its report semantics (no-match vs error vs held) are pinned. | HTTP API reference | unit: 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_matchby 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-24 | Network operations (skip, pause, checkpoint delete) act directly on storage; they never replay ingestion to get there. | HTTP API reference | unit: 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-25 | Reads stay answerable while a write is in flight; nothing blocks the whole API on one mutation. | HTTP API reference | unit: blockwatcher-core/tests/engine/control.rs::a_read_stays_answerable_while_a_write_is_wedged |
| API-26 | POST /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 reference | unit: blockwatcher-api/tests/api/spec_import.rs suite; blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope |
| API-27 | Write-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, Resources | unit: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource |
| API-28 | Skip 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 reference | unit: 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-29 | GET /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 reference | unit: 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| DASH-1 | The 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, Docker | unit: 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| STEL-IMP-1 | Fetch 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. | Stellar | unit: 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-2 | import_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 reference | unit: 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-3 | CLI 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 reference | unit: 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-1 | A 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. | Stellar | unit: 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| MEM-1 | The mempool source keeps a watermark-only checkpoint; a restart loses whatever was still pending. | Delivery guarantees | unit: blockwatcher-evm/tests/mempool_loop.rs::a_restart_loses_pending_work_by_design |
| MEM-2 | A pending transaction seen again mints a new id; dedupe happens on transaction hash, not on the minted id. | Delivery guarantees | unit: blockwatcher-evm/tests/mempool_loop.rs::a_re_seen_pending_call_gets_a_new_arrival_cursor |
| MEM-3 | The mempool source never produces SourceOutcome::Invalidated. | Delivery guarantees | unit: by construction, mempool/run.rs::run() never constructs that variant; no dedicated runtime test |
| MEM-4 | SourceCaps.event_timestamps is false for mempool sources and true for evm-rpc and evm-subgraph. | Delivery guarantees | unit: 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_timestampse2e: a_gated_monitor_on_a_mempool_source_is_refused_in_both_directions, a_gated_monitor_survives_a_restart_and_delivers_its_digest |
evm-subgraph
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| SUB-1 | Completeness 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. | EVM | unit: 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-2 | Cannot query field for logs / transactions / _meta publishes a named Degraded whose reason names the schema mismatch. It is not an empty successful window. | EVM, Troubleshooting | unit: 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-3 | Disagreement 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). | EVM | unit: 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-4 | Kinded cursors match rpc packing: transactions then logs, pack_secondary identical. | EVM | unit: 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
| ID | Claim | Documented in | Assurance |
|---|---|---|---|
| BOOT-1 | validate_and_build refuses stored rows that are no longer valid, at boot. | Architecture decisions | unit: 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-2 | A malformed secret shape is refused at boot, without ever echoing its value. | Architecture decisions | unit: 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_ite2e (in passing): check_validates_like_boot_and_never_echoes_secrets |
| BOOT-3 | Memory-backed storage persists nothing across a restart. | Architecture decisions | unit: blockwatcher/tests/boot.rs::a_boot_on_the_non_persistent_store_warns_that_nothing_survives_a_restartby 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-4 | Listeners bind before the engine starts; a port conflict is a boot failure, not a hang. | Architecture decisions | e2e: a_port_conflict_is_a_boot_failure_with_exit_1 |
| BOOT-5 | blockwatcher check validates exactly like boot does, and echoes no secret. | Architecture decisions | e2e: check_validates_like_boot_and_never_echoes_secrets |
| BOOT-6 | A populated store is resumed on restart, not re-seeded over. | Architecture decisions | e2e: sigkill_and_restart_loses_nothing |