Skip to content

feat(mirror): wire a production broadcaster for the mirror lifecycle (#424) - #434

Draft
MichaelTaylor3d wants to merge 5 commits into
mainfrom
loop/424-production-broadcaster
Draft

feat(mirror): wire a production broadcaster for the mirror lifecycle (#424)#434
MichaelTaylor3d wants to merge 5 commits into
mainfrom
loop/424-production-broadcaster

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — the gate round has not returned. This PR stays DRAFT until it has.

production_broadcaster() was a literal None, and sign_and_broadcast refuses on it before signing. That single fact made three things inert: #411's spend-record writer (which is unbypassable but never fired), #410's mirror half, and reclaims entirely. This wires it.

Shape

shared_client stays pub(crate). Nothing was widened.

The stated obstacle — "wiring ChiaQueryBroadcaster needs ChainTransport::shared_client, which is pub(crate)" — does not hold. ChiaQueryBroadcaster::new is already pub and takes an Arc<ChiaQuery>, never a ChainTransport. And the pattern for exposing a shared-client-derived capability without exposing the client already exists in that same file: ChainTransport::chain_source is pub, calls shared_client() internally, and returns a purpose-shaped provider.

So this adds the narrow counterpart, ChainTransport::broadcaster() — it can push a signed bundle and nothing else.

Why not just make shared_client public. That hands every future caller the client itself, which is the capability chain_source's own comment records as having previously given a live node two independent peer pools with two notions of the peak (dig_ecosystem#2761). It fails open and silently: nothing breaks at the call site, and the damage appears as a node disagreeing with itself about the chain.

It is deliberately not impl Broadcaster for ChainTransport. An unused one sat in that file once and made a one-line .with_broadcaster(chain.clone()) compile, pass every test, and silently enable node-custodied sending on a default install (sage/chain.rs:578). A caller must now ask by name, and asking is a visible line in a diff.

ChiaQueryBroadcaster, not ConfirmingBroadcastersign_and_broadcast treats Ok(()) as reached the mempool and states that only a chain observation may promote that to confirmed. A confirming broadcaster would block the synchronous pass on block inclusion and blur the boundary the journal is built on.

What stays off by default

enable_live_broadcast defaults false (config.rs:176/:242). On a default install the broadcaster is now never constructed and no chain is dialed — it is not merely unused, so there is nothing in scope for a later edit to attach. The gate is the first statement of the seam.

The single seam, preserved

production_broadcaster keeps its role as the one derivation read by both the announced capability (open_signer) and the effects the scheduler builds. It gains the inputs it needs and becomes async; both call sites still call the same function. SpendCapability::Available now holds exactly when a broadcaster is handed over, and that equivalence is asserted directly rather than by comparing today's literal values.

SpendCapability::BroadcasterUnwired is removed: it meant "this build wires no broadcaster", which this change makes false, and no branch could produce it. It is replaced by ChainUnreachable, a genuinely reachable and genuinely different condition — a wallet, live broadcast on, and no chain to send through. Reporting that as Available would announce a power the node lacks; reporting it as BroadcastDisabled would send an operator to set a flag they already set.

Stated limitation: the announced capability is derived once at bring-up while the seam is re-read per pass, so a node that was offline at start-up announces ChainUnreachable while later passes may broadcast. That understates the node's power — it fails in the safe direction.

Blast radius checked

production_broadcaster had exactly two non-test call sites (lifecycle.rs:567 open_signer, server.rs:2806 spawn_mirror_passes); both are updated and both still read the one seam. open_signer had three call sites (two production, one test), all updated. SpendCapability is consumed only by four log arms in server.rs:2712-2727. shared_client gained no new external caller. MEASURED on a fresh per-worktree gitnexus index (not grep-derived; an earlier draft of this body said the tools were unreachable, which was wrong -- I had not tried ToolSearch).

WARNING: production_broadcaster is risk HIGH -- 11 impacted, 4 direct, 1 affected process. The rating comes from reachability: the seam is on the node startup path, so depth 2-3 pulls in serve_with_shutdown -> serve -> run_service and three tests/server.rs integration tests, all transitive through spawn_mirror_passes.

Direct dependents are exactly spawn_mirror_passes, open_signer, and the two new tests -- so both production call sites still read the one seam, measured rather than asserted. open_signer is LOW with 3 direct (1 production, 2 tests; an earlier count here said 2 production).

ChainTransport::shared_client has 5 direct callers, all inside dig-wallet (build_with, build_live_wallet, its own test, chain_source, and the new broadcaster) -- no caller outside the crate, so the pub(crate) boundary is intact.

detect_changes against origin/main shows no unexpected symbol, and exactly one process with changed steps beyond its entry: Spawn_mirror_passes -> Client at steps 2/3/4 (open_signer -> production_broadcaster -> broadcaster) -- the seam chain itself.

Note: gitnexus analyze writes tracked files; it modified AGENTS.md and CLAUDE.md, which were reverted. HEAD is unchanged and git status is clean.

Preserved, verified against the diff

git diff touches none of these; each confirmed still in place:

  • the intra-pass funding-coin reservation — RefCell<HashSet<String>> at lifecycle.rs:147, borrow_mut().extend(..) at :285 inside the broadcast-reached-mempool arm only, selection borrow scoped and dropped at :404;
  • create still a synchronous fn, with no .await anywhere in it;
  • the advertised-URL refusal still the first statement of create, ahead of any chain read or coin selection.

The structural guard no_signer_or_broadcaster_is_ever_installed_on_the_served_wallet_backend is untouched and passes: no .with_signer( / .with_broadcaster( string is introduced in any guarded file. The lifecycle asks for a broadcaster by name and holds it itself.

§908

The only key reached is the §16.4 operator wallet open_signer already opened. MirrorSigner is unmodified, nothing is attached to the served WalletBackend, and nothing here can reach a user key.

Verification

cargo test -p dig-node-service --lib mirror::lifecycle — a --lib unit target, compiled and run by the same invocation. 10/10 green.

Each new test revert-proofed by reverting only its production change (committed first; reverted via a file copy, never git checkout <path>), confirming RED on its own assertion rather than a compile error, then restoring:

revert RED test assertion
drop the !live_broadcast gate a_default_install_builds_no_broadcaster_and_cannot_spend lifecycle.rs:785 — "must report the switched-off flag, never a built broadcaster"
ChainUnreachable => Available the_capability_decision_names_each_seam_outcome left: Available, right: ChainUnreachable
(same) the_announced_capability_holds_exactly_when_a_broadcaster_is_handed_over left: true, right: false

The default-install test stayed green under the second revert — the tests are independent, not one property asserted twice.

Full red/green output on the ticket.

SPEC.md §25 updated in the same unit: the "BUILT and not SENT" paragraph replaced with what is now true.

Version: 0.178.0 (feat → minor), Cargo.toml + Cargo.lock, verified consistent under cargo metadata --locked.

Closes #424

`production_broadcaster()` was a literal `None`, so `sign_and_broadcast` refused
before signing and both the spend-record writer (#411) and the mirror signer (#410)
were inert.

`ChainTransport::broadcaster()` is the narrow counterpart to the existing public
`chain_source()`: it returns a broadcaster built on the ONE shared client, so
`shared_client` stays `pub(crate)` and no caller gains the client itself.

The seam keeps its single-derivation role and gains a three-way answer, so an
unreachable chain is no longer reported as a switched-off flag. On a default
install no broadcaster is constructed and no chain is dialed.

Refs #424
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The mirror lifecycle has no production broadcaster, so no reclaim can reach chain

1 participant