Boot proves four exits and assigns each a role - #2712
Open
zancas wants to merge 30 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::probedbecomes a typedProof, carrying theSentinel's round trip when earned and the inherited expiry when trusted.
Evidence
acquire_provenalready births up to six times, so four lanes are 24attempts. 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_prioritizedremains. Thesweep does not yet fail closed. Those follow in this branch.
Verification
cargo check,cargo clippy -- -D warnings, andcargo fmt --all --checkpass for zingolib with the nym feature. The test suites have notbeen run locally; this PR exists so CI covers them.