Skip to content

Boot proves four exits and assigns each a role - #2712

Open
zancas wants to merge 30 commits into
devfrom
finish_nym
Open

Boot proves four exits and assigns each a role#2712
zancas wants to merge 30 commits into
devfrom
finish_nym

Conversation

@zancas

@zancas zancas commented Aug 18, 2026

Copy link
Copy Markdown
Member

Implements the first half of ADR 0045.

What changes

Going online proved one exit. Every other operation then birthed its own
on demand, which is why a first price fetch measured 30.7 seconds against
a quote of under two seconds.

Boot now proves four exits at once and gives each a job. The roles are
assigned in the order the exits confirm: IndexerSweep, PriceFetch,
IndexerClient, and a spare. The role belongs to the exit rather than to
the client, so one client persists while four roles do.

Two mechanisms retire. A birth draws one exit instead of a Clutch of
four, so the pool's preference for proven exits decides what a birth
uses. ProvenBirth::probed becomes a typed Proof, carrying the
Sentinel's round trip when earned and the inherited expiry when trusted.

Evidence

acquire_proven already births up to six times, so four lanes are 24
attempts. At a quarter of exits carrying nothing, four attempts fill the
quartet about a third of the time and 24 leave a shortfall probability
near 2e-10.

A new workbench census reports the directory as 840 exits across 840
gateways, one requester each. Draws are therefore independent failure
domains, which is what the arithmetic above assumes.

Not yet done

The sweep and the price fetch still acquire their own transports rather
than using the births boot holds. run_speed_prioritized remains. The
sweep does not yet fail closed. Those follow in this branch.

Verification

cargo check, cargo clippy -- -D warnings, and cargo fmt --all --check pass for zingolib with the nym feature. The test suites have not
been run locally; this PR exists so CI covers them.

zancas and others added 30 commits August 17, 2026 20:55
ADR 0045 assigns each of four proven exits a role, and the roles outlive
every client that binds them, so the vocabulary belongs in the glossary
rather than only in the decision that minted it.

Exit Role carries the general rule — the role is the exit's, not the
client's, and health decides whether an exit may be used while the role
decides which of the proven four an operation reaches for. The four
entries beneath it say only what is particular to each: the IndexerSweep
exit's failure aborting boot, the PriceFetch exit's quote being printed
and stored nowhere, the IndexerClient exit's client being the only one
that persists, and the spare exit's trigger.

Standing Client is recorded as retired in favour of IndexerClient. It
was never a glossary term, only a name in code, and the role has since
moved onto the exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EXIT_ANNOUNCEMENT_GRACE budgets 25 seconds for a client to bind an exit
and announce it, and nothing has ever recorded how much of that a birth
actually spends. The constant was chosen without a distribution to
choose it against, and ADR 0045 leans on the answer: if announcements
routinely land near the bound, a four-lane boot is bounded near half a
minute, and if they land in seconds the grace is pure headroom.

Both outcomes are now logged at the one seam every birth passes through.
A birth that announces reports the elapsed milliseconds against its
budget; a birth whose grace expires reports that instead, which is the
NotReady the loop already retries on and which nothing counted.

This measures rather than decides. The grace is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two changes to how a birth is described, sharing the functions they
reshape.

The Clutch retires. `draw_clutch` drew four reservations, ordered them
fresh-Proven first, collected them into a HashSet that discarded the
ordering, and handed all four to one proxy that bound whichever
announced first — so the preference survived only as which four were
drawn. `draw_exit` now draws one, and the preference decides what the
birth uses because nothing downstream can overrule it. A report naming
any other exit is a defective child and refuses typed.

`ProvenBirth::probed` becomes `proof: Proof`. The boolean recorded
whether the birth answered the Sentinel and was read by nobody, while
the one consumer that needed the distinction re-queried the pool for the
expiry the birth had just inspected. The enum carries what each case
knows: the Sentinel's round trip when earned, the inherited
observation's expiry when trusted. `standing_client_from_birth` reads
the variant, so it loses its `pools` parameter, and `Pools::proven_until`
loses its last caller.

A birth no longer hedges its bootstrap: where four exits raced, one is
drawn and waited out, so a dud costs the announcement grace before the
next birth. ADR 0045 puts that hedge back at the boot layer, and until
it lands this is a regression in birth latency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Going online proved one exit and left every other operation to birth its
own on demand, which is why a first price fetch measured 30.7 seconds
against a quote of under two. ADR 0045 rules that boot proves four and
gives each a job.

`prove_quartet` races four births and assigns roles in the order they
confirm: IndexerSweep, PriceFetch, IndexerClient, spare. The lanes cost
nothing extra to retry, because `acquire_proven` already births up to
six times, so four lanes are twenty-four attempts. At a quarter of exits
carrying nothing, four attempts fill the quartet only about a third of
the time, and twenty-four leave a shortfall probability around 2e-10.

The role belongs to the exit rather than the client. `Pools` records the
binding when the quartet forms, so it outlives every client that binds
it — which is what lets one client persist while four roles do.
`enable_mixnet_from` installs the IndexerClient's birth as the standing
client exactly as before and holds the other three for the jobs boot
gives them; `vacate_mixnet_slot` retires them, so a teardown before boot
consumes them leaves no proxy behind.

This also replaces the bootstrap hedge the Clutch used to provide, and
improves on it: four bootstraps still race, but every winner is kept and
given work instead of three being discarded.

One consequence lands ahead of the rest of the ADR. A lane exhausting
its births now fails the whole enable and stops the partial winners, so
a mixnet that cannot prove four exits refuses to go online — stricter
than the end state, where the sweep is the step that aborts boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ExitNodeId is a network requester's Nym address in the Recipient form
`<client_id>.<client_enc>@<gateway_id>`, so each exit names the gateway
it egresses through. Whether two exits ever share a gateway decides
whether independent draws from the Exit Pool are independent failure
domains: exits behind one gateway fail together when it does, and a
quartet drawing four exits from three gateways has three chances, not
four. ADR 0045's arithmetic assumes four.

The census answers that with one discovery call and no births. Against
mainnet it reports 840 exits, 840 distinct gateways, and 840 distinct
requesters — a one-to-one-to-one partition, no gateway hosting a second
exit and no requester registered at a second gateway. Draws are
independent failure domains, and at a 2.9% sample twenty-four draws
without replacement barely diverge from independence.

The tool restates the `NYM_EXIT=` line prefix rather than depending on
zingo-netutils, because the workbench is deliberately std-only with its
own workspace so a tiny binary never drags in the production dependency
graph. The constant is labelled as an agreeing representation of
`zingo_netutils::NYM_EXIT_LINE_PREFIX`, which it must not drift from.

This measures the partition, not the failure rate. How often an exit is
unavailable still wants the birth trial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two suites encoded arithmetic the quartet changed, and your run found
both.

The bind-failure suite sized its census as six births times a clutch of
four and asserted all twenty-four drawn exits were convicted. A birth now
draws one exit, so six births convict six; the census becomes one exit
per birth and the assertion holds unchanged. The test was right and its
arithmetic was the Clutch's.

The birth-channel harness advertised a single exit. Boot provisions four
requesters, so one lane took it and the rest found the pool exhausted,
failing the enable. The harness now advertises a full quartet.

That second failure is a requirement this branch introduced: an online
session refuses below four advertised exits rather than filling the roles
it can. That is deliberate — four exits must be under test for
EpochProvenness as early as possible, because boot is where
responsiveness matters — and production is far from the floor, with the
directory census reporting 840 exits across 840 gateways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0045 sizes a boot's quartet against a failure rate this workspace has
never measured, and it picked EXIT_ANNOUNCEMENT_GRACE without knowing the
announcement latency the grace has to cover. The trial measures both. It
spawns nym-proxy pinned to one drawn exit, times the NYM_EXIT= line, and
stops the child. The count comes from --births, defaulting to a hundred,
so a short run costs minutes rather than the better part of an hour.

Holding the child's stdin open is the whole difficulty. The proxy races
its bootstrap against its own stdin closing, the watchdog that stops an
orphan outliving its parent, so a trial that lets the child inherit a
closed stdin measures the watchdog instead of the exit and calls every
birth unreachable. An earlier draft did exactly that and reported a
hundred failures out of a hundred, which wrongly implicated the change
that made a birth draw one exit rather than four. A pinned exit does
announce, and it announces with a bound SOCKS5 address.

The outcomes are therefore three, not two. A proxy that gives up and
exits is distinct from one that stays silent to the grace, and folding
them together is what hid the defect. Inheriting the child's stderr
completes the repair, because a refusal the operator cannot read is a
measurement that cannot be checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Announcing and carrying are different things, and only the first was
measured. An exit announces when the proxy binds it and publishes a SOCKS5
address, which proves the bind and nothing else. ADR 0043 put a quarter to
a third of exits at carrying nothing, a figure the ADR asserted without
measurement, and ADR 0045 sizes a boot's quartet against it.

The trial now probes the Sentinel through the address the proxy publishes,
mirroring the production probe: a SOCKS5 connection to 1.1.1.1 on port 53,
an ordinary length-prefixed DNS query, and any non-empty reply as the
proof. The workbench stays std-only, so the SOCKS5 handshake and the query
are written out here and their constants restated as agreeing
representations of the production ones.

Sixty births against mainnet on 2026-08-18 put the carries-nothing rate at
thirty percent, eighteen of sixty, inside the band ADR 0043 asserted. Not
one birth failed to announce. The report gains the mean, the sample
standard deviation, and the raw samples, so a reader can recompute what
the quantiles omit: announcement latency averages 4637 milliseconds with a
deviation of 549 over thirty samples, and a carrying round trip averages
1253 with a deviation of 490.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The readiness gate waited twenty-five seconds for a transport's first Exit
Node announcement, a bound built from one connect attempt plus a hedge
interval. That sum described what the gate might tolerate, never what a
birth actually costs, and no measurement stood behind it.

The birth-trial tool measured thirty pinned births against mainnet on
2026-08-18. Announcement latency averaged 4637 milliseconds with a sample
standard deviation of 549, the slowest sample took 5604, and every birth
announced. The grace becomes seven seconds, the four-deviation figure of
6833 milliseconds rounded up to a whole second. Its doc-test now asserts
the derivation as an inequality, that the grace covers four deviations and
the rounding never reaches a fifth, so the relationship survives a retune
of either input.

SPEED_ACQUISITION_DEADLINE multiplies the grace and the Sentinel budget by
the expected proof count, so it falls with the grace, from 285 seconds to
105. The workbench tool restates the grace as an agreeing representation
and follows it down.

The measurement stands on one host and one network across a single Nym
epoch, so it describes desktop conditions. A slower phone on a worse
connection is the case this narrower grace could refuse where the old one
waited, and it wants its own measurement before mobile depends on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The boot quartet draws one exit per role and the Exit Pool never issues
the same exit twice, so a boot needs four distinct drawable exits. The
stub proxy this test writes advertised one. Three of the four lanes drew
against an exhausted pool, the quartet refused, and the enable failed
closed to Unattached. The session then launched no sync, pepper_sync
logged no error, and the test waited its full twenty seconds for a line
that nothing would ever write.

The fixture is what went stale, not the policy. A boot that cannot fill
its roles refuses rather than start a session it cannot serve, and the
mainnet directory offers eight hundred and thirty-six exits, so only a
stub is ever this poor. The stub now advertises one exit per lane and
echoes back whichever exit the parent pinned, which is what the real
proxy does and what the drawn reservation already assumed.

The test now passes in about one second rather than failing after twenty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0045 gave a boot four proven exits and a role for each, but left the
vocabulary in the wrong crate. zingolib defines ExitNodeId and owns the
Exit Pool, Reservations, births, the clutch, and SlotTunnel, while
zingo-netutils owns the implementation those names describe. Nym's
vocabulary crossed the crate boundary in the wrong direction.

The mobile platform shows what that costs. The desktop births four
clients from the wallet's own pool; mobile receives one endpoint from
its host, births nothing, and holds no reservation. A wallet written
against the first shape cannot express the second, so a mobile session
gets one endpoint and none of the roles. Proving is not free on a phone
either: sixty births measured on 2026-08-18 announced in a mean of 4637
milliseconds, and thirty percent of the exits that announced carried
nothing.

ADR 0046 rules that the wallet asks for a conduit by role and never
learns how the request is served. MixnetConduit is an opaque handle
defined in zingo-netutils; roles stay wallet vocabulary in zingolib;
everything a role is served with stays below the seam. The provider
decides how many clients a set of roles takes, so mobile answers every
role with its host's one proven endpoint and reaches responsiveness
after one proof rather than four.

The glossary gains MixnetConduit and amends Exit Role, which said the
role belongs to the exit. That stays true below the seam and goes
invisible above it, where no exit is nameable and the role belongs to
the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A zingo-mobile user was told to run `network on`. That is a zingo-cli
command, and a phone has a toggle and no command line. The text came
from zingolib, which serves both frontends and therefore knows the
condition a surface refused under but cannot know what the user should
press.

Four refusals spelled the command: MixnetNotReady::Unattached and
::Died in the route resolver, and PriceFetchRequiresMixnet and
ProbeRequiresMixnet in the lightclient errors. Each now names the state
change instead of the keystroke, saying to enable Mixnet Mode rather
than to run a command. The typed variants are unchanged, so a frontend
that wants to name its own remedy matches on them and appends it, which
is what zingo-cli already does in commands.rs.

One test asserted the leak, requiring the Died refusal to contain
`network on`. It now asserts the opposite: no refusal may spell any
zingo-cli command, so the rule this commit establishes has a falsifier
rather than only a convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Boot proved four exits and then let two of them idle. The quartet's
sweep, price, and spare births sat in a LightClient field that only
teardown ever read, while the Server-Selection Sweep and the boot price
fetch each birthed a client of their own. Boot therefore paid for six
clients to do the work of four, and the two jobs that most decide how
long a user waits for a prompt both began by bootstrapping a fresh
mixnet client.

They now take the conduit boot proved for their role. The exit authority
holds one conduit per unspent role and hands it over on request, so the
usual case costs no birth at all: a measured birth announces in a mean
of 4637 milliseconds and then owes a Sentinel round trip, and boot's
sweep and price fetch stop paying either. A redraw, or any later
price fetch, finds the conduit spent and births as before, which keeps
consecutive fetches off a shared client.

This is the first step of ADR 0046, where the wallet asks for a conduit
by role rather than acquiring transports itself. The conduits live with
the exit authority rather than the LightClient because a role is what
the request names, so BootClients and the field holding it are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A library that serves zingo-cli, zingo-mobile, and zingo-pc alike should
not explain itself in terms of one of them. Six doc-comments did, citing
zingo-cli's default features, its `--no-mixnet` flag, its
`most_up_indexer_uris` list, and where it puts a bundled binary. Each
described a consumer's arrangements rather than the code beneath it, and
each would mislead a reader arriving from a different frontend.

Every one is replaced by a terse statement about the adjacent code and
nothing else. The paragraphs explaining which platform puts the proxy
binary where, and why a test consent is ungated, lose the frontend names
and keep the fact the reader needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0046 rules that Nym's vocabulary belongs in zingo-netutils and that
the wallet stops owning it. zingolib defined ExitNodeId and the whole
exit authority, so the wallet crate named the implementation it was
supposed to be insulated from.

The cut is clean because the code had one dependency edge. Everything
in the old exit_pool module — Reservation, Observation,
NodeHealthIndex, ExitPool, the verdict and the refusal — referred to
nothing in zingolib except ExitNodeId, which moves with it. The new
zingo-netutils::exit is ungated, because identity and health are data
and gating them would make the featureless build harder to reason
about, not easier.

zingolib re-exports both names, so its own hundred-odd references and
zingo-cli's stand unchanged: the wallet still names an exit, it no
longer defines one. The test-only From<&str> and dangling_for_test now
ride netutils' existing testutils feature, since a cfg(test) item
cannot cross a crate boundary, and downstream dev-dependencies already
enable that feature.

rand and serde join netutils, the first for the draw's per-tier
shuffle and the second for the identity's wire form. rand stays in
zingolib too, where wave ordering, Correspondent rotation, and the
migration schedule all still use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0046 names MixnetConduit as what a wallet holds when it has somewhere
to send mixnet traffic, and puts it in zingo-netutils. The tree carried
SlotTunnel instead, defined in zingolib, so the document described a type
nothing implemented and the wallet still owned the vocabulary.

MixnetConduit now exists in zingo-netutils, MixnetRoute::Mixnet carries
it, and SlotTunnel is gone. Four call sites read the address off the
route, in the liveness probe, the price fetch, the migration transmit
client, and the send route resolver; each reads it off a conduit now.
Retiring the type also retires the last wallet-facing use of the term
tunnel that ADR 0046 set out to excise.

The conduit is not yet opaque, which ADR 0046 asks for and this does not
deliver. zingo-price dials without depending on zingo-netutils, and a
dozen zingolib signatures carry a bare SocketAddr, so the accessor stays
public with a comment naming the reason. Making it opaque means teaching
those dialers to accept a conduit, and for zingo-price that means a new
dependency edge.

BREAKING: zingolib::mixnet::SlotTunnel is replaced by
zingo_netutils::conduit::MixnetConduit, re-exported as
zingolib::mixnet::MixnetConduit. The accessors `addr` and `into_addr`
become `socks5`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The standing rule required consent before adding a trait object, weighed
case by case. That is now a prohibition, in every spelling, including one
hidden behind a type alias. A generic parameter, an `impl Trait`
position, or an enum over the known implementors covers what a trait
object was reached for.

The rule lands in AGENTS.md rather than a review note because a
prohibition weighed once and then forgotten is a prohibition that holds
until the next contributor arrives. It also marks the trait objects
already in the tree as debt rather than precedent, so the acquisition
path's `Arc<dyn TransportAcquirable>` and the `&dyn` parameters threaded
through it are work to retire and never a pattern to copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing sync measurement built a LightClient in process, pointed it
at an indexer, and scanned. It never enabled Mixnet Mode, so it timed the
sync engine alone and said nothing about a session where four proven
exits bootstrap beside the scan. That is the number a user feels, and
nothing measured it.

`makers sync-bench` drives the CLI the way a user does. It spawns
`makers run-cli --online` against a pinned indexer with an empty wallet,
so the session spawns the proxy, proves its quartet, runs the
Server-Selection Sweep, and launches sync under that load.

The completion signal comes from the layer that owns the fact. The
spawned sync task now brackets `pepper_sync::sync` and logs its own
elapsed milliseconds under a minted marker, so the benchmark reads one
measurement taken inside the task that did the work. The alternatives all
measure something else: the prompt's synced indicator redraws on a
ten-second heartbeat, `await_sync` quantises to fifty milliseconds, and
timestamps compared across processes carry both clocks' error. The
driver's own poll interval decides only when it notices, never what it
reports.

Both arms of an A/B must pass the same `--birthday`, so they scan the
same window. The workbench is std-only and cannot query the tip, which is
why the height is an argument rather than derived.

AGENTS.md now requires every commit to be A/B benchmarked against its
parent. This commit and the two before it predate the tool, so they carry
no numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…moves down

AGENTS.md now forbids trait objects outright, and the acquisition chain was
built from them: six `&dyn TransportAcquirable` parameters, an
`Arc<dyn TransportAcquirable>` in the exit authority and another in the
sweep, eight boxed futures across the trait and its impls, and three
`Arc<dyn ProxyHost>` at the mobile boundary. Production zingolib is now
down to one trait object, a boxed closure in the migration parts, and the
mixnet stack has none.

Four changes get there. The acquisition chain became generic over
`A: TransportAcquirable + ?Sized`, which existing callers satisfy
unchanged. `TransportAcquirable` now returns `impl Future` rather than a
pinned box, so every impl is a plain async fn and no acquisition allocates
for its future. An `Acquirer` enum over the known implementors replaced
the stored trait objects, which also retired a test-only escape hatch: the
trait is dyn-incompatible now, so the announcing double became a concrete
variant carrying its own census and yield count.

The provider moved to zingo-netutils, renamed `ProxyHosting`. A host
supplied by a platform really is dynamic, so the `Arc<dyn ProxyHosting>`
survives inside `HostedProvider` where the implementation lives, and the
wallet names only that concrete type. The provider names no async runtime:
its methods block and zingolib, which owns the runtime, does the
spawn_blocking hand-off. That is better layering than the original, and it
came from refusing to add tokio to a crate that had done without it.

One duplication collapsed on the way. The sweep and the price fetch each
ran the same take-the-held-conduit-then-birth sequence; both now make one
role-keyed request through `Pools::conduit_for`. The route resolver stayed
separate deliberately: merging it would let a read-only question about
whether a surface may transmit birth a proxy as a side effect.

This is a step toward ADR 0046 and not its conclusion. The provider still
speaks in exits, clutches, and SOCKS5 addresses, and the wallet still
names `ExitNodeId` in eighty-two places. Retiring that vocabulary is the
next change, and it overturns ADR 0045's role-to-exit binding, so it wants
its own decision record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sync measurement this repo already had lives in
`tests/sync_perf_guard.rs`, an `#[ignore]`d test, so taking a reading
means invoking the test runner. That makes a benchmark awkward to run
against two checkouts back to back, which is exactly what comparing
revisions needs.

The same measurement as an example runs as a plain binary. It takes the
indexer, an optional birthday, and a performance level as arguments, so
one build compares indexers, windows, and levels without editing
constants. It reports outputs per second beside blocks per second,
because scan cost tracks shielded outputs and block counts hide a window
whose density differs.

Reporting the rate is what caught a measurement error: an unoptimized
build scans at roughly 600 outputs per second where a release build
manages 3000, so a debug reading looks like a fivefold regression against
an optimized baseline. Every conclusion drawn from `cargo run` without
`--release` on this harness is wrong by that factor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc-test on `enable_mixnet_via_host` still imported and implemented
`ProxyHost`, and still handed the method an `Arc`. The trait is
`ProxyHosting` now and the method takes the host by value, so the example
named a trait that no longer exists and passed a type that no longer
satisfies the bound.

The break survived every local gate because `cargo check --all-targets`
does not compile doc-tests, and cargo-checkmate's doc job builds the
documentation without running its examples. Only `cargo test --doc`
compiles them, which is what CI ran and what caught this. A rename that
touches a documented API therefore needs a doc-test run before it is
pushed, not a check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three boundaries returned `Box<dyn Error>`, which tells a caller only that
something failed. Each now names what can fail there.

The proxy binary gains `ProxyExit` over the three refusals its run can
meet: an argument grammar it rejects, a mixnet that refuses discovery or
bootstrap, and an interrupt handler that will not install. The workbench
summary tool returns `std::io::Error`, which is what every `?` in it
already produced, spawning a shell and reading its lines. The clearnet
probe's `ProbeStage::Rpc` holds the `Status` it always held, since
`get_lightd_info` is its one source.

`test-summary` also loses a `filter(..).next_back()` that clippy rejects
under `-D warnings`; `rfind` says the same thing and the file had to lint
clean to carry the signature change.

What stays boxed is what std's shape requires. Walking a cause chain takes
`&(dyn Error + 'static)`, because that is what `Error::source` returns, so
the four chain renderers keep it. `zingo-cli`'s `NotYetTyped` also stays:
its name is an admission, fifty-two call sites feed it, and typing them is
its own arc rather than a rider on this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ProveOnce` was a boxed `FnOnce` whose doc-comment promised it took
ownership of everything the build needs and captured no wallet reference.
Both claims were true and neither was checkable: what crossed to the
proving thread lived inside a closure body, where a reader had to
reconstruct it by reading seventy lines.

It is a struct now, and the captured set is its fields: the account's
spending key, the anchor, the bound note and its Merkle path, the chain
type, the denomination and part fee, the target and expiry heights, and
the migration parameters. The body moved verbatim into `prove(self)`, so
the transaction it builds is unchanged.

`PrepareResult::Ready` holds a `Box<ProveOnce>`. Inline, the struct made
that variant far larger than its sibling, which clippy rejects; a box of a
concrete type is not a trait object, so the enum stays pointer-sized and
the prohibition holds. The struct is `pub` because `PrepareResult` is, and
a public field may not expose a crate-private type.

zingolib's production code now holds no trait objects at all. The one
remaining is a boxed stream in the `testutils`-gated mock indexer, whose
shape tonic's streaming API asks for.

BREAKING: `ProveOnce` is a struct rather than a boxed closure, and
`PrepareResult::Ready::prove` holds `Box<ProveOnce>`. Call `prove.prove()`
where the closure was invoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`discover_live_indexers` already took `impl Fn(String) + Send + Sync`, then
erased it to `Arc<dyn Fn(String) + Send + Sync>` on the next line, purely
so each probe task could hold a shared handle. The sharing needs a
reference count, not an erasure.

Naming the parameter and passing `Arc<F>` gives every task the same
callback with no trait object and no dynamic dispatch. The one caller, the
`live-indexer-discovery` binary, passes a closure and is untouched.

That leaves two trait objects in this crate: the cause-chain walker, whose
`&(dyn Error + 'static)` is the shape `Error::source` returns, and the
provider's supplied host, which is dynamic by design and sits below the
seam for exactly that reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The glossary ratifies Survey Lane as one slot in a Survey Wave, and
`select.rs`, `sweep.rs`, and `speed.rs` all use the word in that sense.
`prove_quartet` had borrowed it for something else entirely: the four
concurrent things boot races to fill its roles.

They are acquisitions. Each is one `acquire_proven` call, which spends up
to `MAX_PROVING_BIRTHS` proving births of its own as it convicts dead
exits and draws successors, so naming one a birth would understate it
sixfold. The glossary already defines the Acquisition Race as the
winner-take-all redundancy pattern of an acquisition, which is what these
four are doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0045 made exits first-class wallet vocabulary: four named exit roles,
health keyed by exit, and a spare trigger that charges one. ADR 0046 then
ruled that the wallet asks for a conduit by role and never learns how the
request is served. Both cannot hold, and the tree follows 0045.

ADR 0047 rules for 0046 and records what that costs. A role keys a
conduit. The Exit Pool, the health index, and failure attribution go below
the seam with the exits they name. The provider chooses which exit serves
a role and remembers the binding for the epoch. zingolib stops
re-exporting `ExitNodeId`, which is what makes the opacity real rather
than stated.

The record is also an admission. Commit 03d8c7d moved the exit types
into zingo-netutils and 5318ff4 replaced SlotTunnel with MixnetConduit,
but the wallet still names both, so ADR 0046 has so far been a relocation
rather than an encapsulation. Eighty-two references in zingolib remain,
and they cannot go while a role binds an exit above the seam.

Two things the ADR decides rather than inherits. It keeps `Role` above the
seam, rejecting the symmetrical fix of moving it down, because a crate
that knows nothing of sweeps or prices should not own that enumeration.
And it keeps the user-visible report alive: `render_exit_nodes` prints
bound exits after `network on`, so the conduit carries a label and the
status surface reports labels rather than typed identities.

ADR 0045's Exit Role entry is amended rather than retired. Below the seam
the role still belongs to the exit, exactly as 0045 says. Above it, the
role belongs to the job and no exit is nameable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0045 separates roles across four exits so no exit sees two kinds of
traffic. On a phone that costs four continuous cover-traffic streams,
because a Nym client holds its gateway connection and generates cover
traffic whether the wallet is working or not. ADR 0046 already made the
count a provider's choice, so a phone may answer differently, and this
records what it answers.

One client at a time, rotating on a randomised five-to-ten-minute interval,
binding a different exit each time. The hand-off is make-before-break: the
replacement bootstraps and proves before the retiring client stops, so a
rotation is never visible as an outage, and an in-flight Transmission
finishes on the client that started it.

The privacy property changes rather than degrades, and the ADR says so.
Role separation stops one exit linking a price fetch to a Transmission.
Rotation stops any exit seeing more than ten minutes of the wallet. Within
a window the rotating exit sees everything, so a send that coincides with a
price fetch is linkable. That residual is small, because sends are rare,
and it is also the moment that matters most, so it is stated rather than
buried. For a session that runs for hours on a carried device, bounding
exposure beats bounding scope.

The record corrects a premise that had been assumed: exits are not unique
to an epoch. A network requester's address is a stable bonded identity, and
the API's own arithmetic spans key rotation across `validity_epochs` rather
than one. An epoch rotates the active set, not the identity, so rotation is
a privacy choice and never an expiry requirement.

Two questions are left open deliberately. The mobile host owns the client,
so either it drives rotation itself or the FFI grows a way for the wallet
to ask for a replacement while the old one still serves. And rotating while
the app is backgrounded spends battery to unlink traffic that is not
happening, so the cadence may want to follow activity rather than the
clock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0048 left who performs a rotation open. It is settled: the mechanism
belongs to zingo-netutils and the policy belongs to the platform.

Proving a replacement, holding both clients through the hand-off, and
draining in-flight work are subtle and identical everywhere, so they are
written once below the seam rather than reimplemented per platform.
Deciding whether now is a moment to spend a bootstrap needs the battery
level, the foreground state, and whether the radio is on wifi or cellular.
None of those are observable from this workspace, and a wallet cannot make
a resource-constrained decision it has no inputs for.

The split is a trait netutils defines and the platform implements, in the
shape `ProxyHosting` already has: the host supplies a transport when asked
and answers whether a rotation is welcome. The cadence bounds stay in
`zingo_netutils::time` and reach the host through `mixnet_timing`, which
exists so neither side pins its own copy of a number the other enforces.

That inverts mobile's seam. Today the platform pushes an address through
`attach_mixnet` and the wallet can only accept it. A supply-on-request seam
is what lets the mechanism live below the seam and run the hand-off, and
the two can coexist while migrating, since a first attach and a hand-off
are different intents.

The record now also names the blocker. `attach_mixnet` calls
`vacate_mixnet_slot` before installing and then asserts the slot was empty,
so it expresses break-before-make and nothing else.
`install_failover_client` is the only path that replaces an attached client
and it requires the incumbent to be condemned, which a healthy rotation's
never is. Both want an entry point that installs the replacement and
retires the superseded client once its work has drained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prohibition is absolute and the codebase now nearly satisfies it:
zingolib's production code holds none, and the workspace's survivors are
either forced by `Error::source`, which returns `&(dyn Error + 'static)`
and admits no alternative, or confined to test code.

One is neither. `Arc<dyn ProxyHosting>` holds a platform host implemented
outside this workspace and reached across an FFI boundary, so its concrete
type cannot be named here. Both alternatives were built and weighed before
this was written down. A type parameter propagates through `Acquirer`,
`Pools`, and `LightClient`, which every consumer names in its own
signatures. A request channel was implemented, tested green, and reverted:
it relocates the dynamism behind a queue rather than removing it, invents a
host-vanished failure mode that owning the host makes impossible, and
serialises the boot's concurrent acquisitions unless the host spawns per
request, which is a silent regression on the path mobile responsiveness
depends on.

So the exception is recorded rather than left as a rule the code visibly
breaks. It is one field, below the seam, and the wallet names only the
concrete `HostedProvider`. An audit that finds it should stop there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR 0048 splits a mobile rotation in two. The mechanism is
platform-identical and subtle, so it belongs below the seam. The timing
needs the battery level, the foreground state, and whether the radio is on
wifi or cellular, none of which this workspace can observe. A wallet cannot
make a resource-constrained decision it has no inputs for.

So `ProxyHosting` grows `rotation_verdict`, which a platform answers with
`Now` or `Defer(interval)`. A host that will not rotate at all answers a
long defer rather than a third variant, which keeps the answer total and
spares the caller a case that means the same as waiting.

The cadence stays in `zingo_netutils::time` as `CLIENT_ROTATION_MIN` and
`CLIENT_ROTATION_MAX`, and `rotation_interval` draws from them with a
supplied generator, randomised so a session's rotations do not fall on a
predictable cadence an observer could align to. A doc-test pins the
relation the bounds exist for: a rotation must bound exposure more tightly
than an epoch does, or rotating buys nothing over waiting for the topology
to turn over.

`MixnetTiming` carries both bounds, so a platform reads them through the
record that exists to stop a constant being pinned twice across the FFI.

This is the contract, not the rotation. Nothing yet drives it, and
`attach_mixnet` still cannot express a hand-off: it vacates before
installing and asserts the slot was empty, while `install_failover_client`
replaces only a condemned incumbent. Both are named in ADR 0048 as the next
work.

BREAKING: `ProxyHosting` gains a required `rotation_verdict`. A platform
that does not rotate answers `RotationVerdict::Defer` with a long interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant