Added dry-mode skip visibility to ingest_for_consumer in runner.rs. Previously a dry rehearsal that was gated out (stale or dispatch-skipped) was indistinguishable from “nothing happened.” Now every gate-out in dry mode surfaces a Telegram notify and a tracing::info log entry. Branch: fix-dry-skip-visibility (off main, NOT merged). Gate: GATE_EXIT=0, all 334+87 tests pass, clippy/fmt clean.

What Changed

1. dry_skip_msg helper

fn dry_skip_msg(source_id: &str, condition_id: &str, reason: &str) -> String

Formats a consistent message: "🧪 DRY skipped (<source_id> <condition_id>): <reason>". The 4 substrings are locked by a unit test.

2. per_message_mode() call moved above stale_gate

Previously eff (the effective mode for this source) was resolved AFTER the stale-gate check, so the stale path returned without ever knowing whether the consumer was in Dry. The call was hoisted above the stale check so eff is available at both skip points.

3. Stale path — now mode-aware

GateOutcome::Skip(reason) after stale_gate:

  • eff == Mode::Drynotify(dry_skip_msg(...)) + tracing::info!
  • otherwise → tracing::debug!

Return is still ConsumerOutcome::Stale in both cases (no behavior change for Live).

4. Post-dispatch skip path — now mode-aware

Dispatched::Skipped(reason) after dispatch_ingest:

  • eff == Mode::Drynotify(dry_skip_msg(...)) + tracing::info!
  • otherwise → tracing::debug!

dispatched_to_consumer call is unchanged.

5. Unit test

dry_skip_msg_includes_source_condition_reason — verifies that the output contains "DRY", "skipped", the source_id, the condition_id, and the reason as substrings.

Key Insight

A dry rehearsal whose signal was filtered by the stale gate or the dispatch gate was previously invisible to the operator. There was no way to distinguish “engine gated this signal out” from “no signal arrived at all.” With this change, dry mode produces a Telegram alert and a log entry for every gate-out, giving the operator full rehearsal visibility before arming live.

For Agents

This change is additive to Live behavior (Live paths are unchanged). The only new symbols are dry_skip_msg (private fn in runner.rs) and the test dry_skip_msg_includes_source_condition_reason. The mode-branching logic is in ingest_for_consumer at both the stale-gate return and the Dispatched::Skipped arm. Effective mode is still resolved via per_message_mode()config::effective_mode(global, source).

Quick Reference

  • Helper: dry_skip_msg(source_id, condition_id, reason) in runner.rs
  • Skip points: stale-gate return + Dispatched::Skipped arm in ingest_for_consumer
  • Live paths: untouched — no behavior change when eff != Mode::Dry
  • Test: dry_skip_msg_includes_source_condition_reason in runner.rs