Skip to content

Nym linear - #1240

Draft
zancas wants to merge 52 commits into
devfrom
nym_linear
Draft

Nym linear#1240
zancas wants to merge 52 commits into
devfrom
nym_linear

Conversation

@zancas

@zancas zancas commented Jul 29, 2026

Copy link
Copy Markdown
Member

No description provided.

zancas and others added 30 commits July 28, 2026 21:28
The nym_mobile_adoption branch now carries the fix/ironwood-split-mobile
merge (zingolib 365d8aba), so one pin serves both the mixnet seam and the
mobile split APIs. The lock moves every zingolib-source package to that
commit, and the pin comment records the standing conflict policy: on every
merge from PR #1187, nym_mobile_adoption wins and the lock advances.

The FFI layer adapts to what the ZIP 318 side of that merge retired. The
cadence choice is gone from zingolib (the Poisson schedule draws every
delay itself), so reschedule_parts now fails typed with
MigrationCadenceFixed, start_ironwood_migration ignores its per_bucket
argument, and migration_status reports per_bucket as null; the JSON and
uniffi shapes are unchanged so the Kotlin, Swift, and TS layers keep
compiling until the cadence surface is retired end to end. The error
funnel maps the new mixnet-refusal variants (MixnetNotReady,
NoEligibleBroadcastIndexer, MigrationBroadcastTargetIsSyncEndpoint), the
nym build's price-fetch refusal replaces PriceFetchUnsupported, note
splitting surfaces the queued preparation count and the awaiting_schedule
step, and the retired CadenceFixed mapping is dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ZIP 318 Poisson schedule draws every broadcast delay itself, so the
per-window cadence choice no longer exists in zingolib. This removes the
surface that offered it, across every layer, instead of keeping a control
that always refuses.

In Rust, reschedule_parts and the MigrationCadenceFixed error variant are
gone, start_ironwood_migration takes only the plan hash, and the status
JSON drops per_bucket; the uniffi UDL and the checked-in Kotlin binding
are regenerated to match. The Android and iOS bridge methods follow, as
do their outcome-table tests. The FfiArgs u32 helpers stay: they are
generic bridge plumbing with sibling callers and their tests use
per_bucket only as an example name.

In the app, the MigrationCadence chooser screen is deleted and splitting
completion navigates straight to the schedule review, which never read
the perBucket param it declared. Progress counts parts directly on the
status screen and the history banner, since each part now has its own
drawn window. The migrationcadence translation blocks are removed from
all five languages, and the routing helper for the cadence screen goes
with its tests.

Verified locally: cargo check and clippy clean, tsc clean, all 428 jest
tests and 95 snapshots pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CONTEXT.md gains the Mixnet Mode section (Mixnet Mode, Fail-closed,
Silent alpha APK, and the Always On build flavor) resolved in the design
session, and the CI section gains the mobileclient scenario contract.
The alwayson flavor produces the silent alpha APK (CONTEXT.md): the
forced-on, fail-closed Mixnet Mode policy runs unchanged, but the app
withholds the mixnet view projection, so the stock UI renders and a
refusal surfaces as a plain typed send error.

The flag rides a per-flavor res bool, following the
enforce_privacy_controls precedent, because BuildConfig generation is
deliberately disabled for build reproducibility. NymTransportModule
exports it as the mixnetAlwaysOn constant, and LoadedApp gates exactly
two seams on it: the initial context state (null, never the
send-blocking INITIAL_MIXNET_VIEW) and the onMixnetViewChanged
callback. Every mixnet surface already renders nothing for a null view,
so no component changes. A unit test pins the gate across every
native-module shape, including the module's absence on iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zingolib pin advanced to a ZingolibError that adds a Mixnet variant
between Read and Send, but only the generated bindings learned of it.
Swift classifies by a hand-written switch that carries no default,
deliberately so a new variant breaks the build rather than degrading
silently to "Unknown", and that switch is what failed the iOS
build-for-testing job. The Kotlin bridge derives its code reflectively
from the exception class, which is why Android compiled throughout.

The TypeScript boundary failed more quietly: FFI_ERROR_CODES is the
closed set of stable rejection codes, and anything outside it maps to
"Unknown", so a genuine mixnet refusal would have reached the app
unrecognized rather than as a typed failure.

Each surface gains the variant in the position the Rust enum declares
it, and the Swift contract-variant test gains its case, so all three
hand-written surfaces again enumerate exactly the twenty-one variants
the Rust enum defines. The Kotlin FfiOutcome test is keyed by FFI
method rather than by variant, so it is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The silent alpha now builds as a mainnet/testnet pair: alwaysontest
carries a distinct application id beside alwayson, prod, and beta, and
first-runs on the testnet default server. The chain choice rides a new
per-flavor default_chain_name res string, exported by RPCModule as the
defaultChainName constant and read by flavorDefaultChainName(), which
steers only the no-persisted-settings path; a stored server setting
always wins, and any absent or unexpected constant falls back to
mainnet. A unit test pins that fallback over every module shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device verification of the alpha caught a policy hole: a failed
mixnet enable leaves the wallet's mode at off, and with the mixnet UI
withheld, a send or a CEX price fetch proceeded over clearnet
silently. Fail-closed lived partly in the view layer, which is exactly
what the silent flavors remove.

The gate now lives in the backend. WalletBackend.sendTransaction
refuses unless the coordinator's last report is ready, and getZecPrice
refuses through the shared mixnetGate module the coordinator mirrors
its readiness into; both refusals carry the Nym mixnet marker so
classifySendFailure files them as mixnetRefusal, never a server
problem. Because the silent flavors have no human re-enable path, the
coordinator gains an auto-recovery loop that re-runs the enable a
minute after a failure, a died transport, or an unconsented off.

Fixing the suite that pins this exposed a real defect: schedulePolling
tore down through stop(), so every reschedule closed the gate and
cancelled pending recovery. Rescheduling now clears only the poll
interval. nymTransport also resolves its native module lazily — eager
capture at import coupled every walletBackend importer to the host's
NativeModules shape and broke the unit environment — and the coordinator
logs enable failures and the silent flavors log their withheld views,
so the alpha stays diagnosable with a stock UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The price surface flattened four genuinely different failures — the
fail-closed gate refusal, a typed FFI rejection, the oracle reporting
failure in its payload, and a malformed payload — into one sentinel
number and one string, which cost exactly the diagnostic information
the silent alpha needed when Gemini-over-mixnet failed on device.

getZecPrice now returns ZecPriceOutcome, a six-arm discriminated union
grounded in the real producers, with the ZingolibError variant code
preserved on the rejection arm and offending payloads carried verbatim.
The store consumes it through an exhaustive handler record dispatched
by matchZecPriceOutcome: one handler per arm, each narrowed, so a new
arm fails compilation at the record, by name, with no default to
forget (ADR 0004). Every failure arm also lands in the dev log — the
snackbar renders only a headline, and the silent flavors have no other
price diagnostics. Visible behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typed outcome unions that cross a module boundary are consumed through
an exhaustive handler record dispatched by a single generic match
function, so a new arm fails compilation at every consumer, by name,
with no default to forget. Pure local folds may keep a switch with a
never default. First implemented for the price-fetch surface.
…e TLS fix

The pin advances to zingolib b03b9763, whose zingo-price verifies the
oracle fetch against the compiled-in webpki bundle instead of an empty
manual root store — the UnknownIssuer failure that made every price
fetch die at the first handshake, proven and fixed by probing through a
live emulator nym tunnel.

The FFI's error text now renders the full source chain, deepest cause
last. Display alone truncates: that UnknownIssuer hid for a whole
debugging session under three layers of 'request failed', and the chain
is the diagnostic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Absence of evidence is not evidence of absence: a silent success was
indistinguishable from an unnoticed new failure mode, and the static
no-clearnet-tier argument had no runtime companion. The pin advances to
zingolib 9113eed5, whose update_current_price returns the price
together with the tunnel endpoint it traveled through; zec_price
carries both in the data channel.

The TS surface types the attestation rather than nulling it:
PriceRouteAttestation is 'attested' with the endpoint, or the named
preAttestationNativeLayer case for payloads from native layers that
predate it — never a bare null. Successful fetches now log price,
route, and duration, so success and failure are both positively
observable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Catalogue all 421 literal `null` occurrences across 352 lines and 80 files,
assess whether each one's meaning is unambiguous, and rank the sites that can
hide a misinterpretation bug. The nulls fall into nine categories, one of
which is specific to this codebase: the arg-skip sentinel that `updateToField`
uses in Send.tsx, where a positional null means "leave this field unchanged"
while an empty string means "clear it".

Four sites carry a concrete misinterpretation-failure path and are fixed in
the commit that follows. Seventeen more are ambiguous without a demonstrated
failure, and the remaining majority are idiomatic. The audit records the
review heuristic it produced: the defect is never the null declaration, it is
the loose read that follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four nulls in the audit carried a concrete misinterpretation-failure path.
Each collapsed several distinct states into a single `null` that the reading
code then had to guess at, and in each case the guess was wrong for at least
one reachable input. This commit replaces each of them with a pure,
side-effect-free interpretation function returning a discriminated union
whose members name the states the null used to conflate, following the
precedent this branch already set for price fetching and send failures
(ADR 0002: errors are types).

`fetchWallet` answered a bare null for an FFI rejection, an empty payload,
and an unparseable payload alike, and answered a truthy empty object when the
payload parsed but carried no key material. The recovery-info caller in
LoadedApp tested `if (wallet)` and so treated every one of those as nothing
to do, skipping the store step in silence and leaving the user with an
enabled setting, no backup, and no error. `interpretWalletFetchResult` names
all five outcomes, `fetchWalletOutcome` exposes them, and the caller now
reverts the toggle and tells the user when nothing was stored. A birthday of
zero, which regtest wallets really have, survives interpretation instead of
being dropped by a truthiness check.

`selectingServer` resolves a server only when its probe answered, so every
resolved probe describes a reachable server. Three callers in LoadingApp
nevertheless read the resolved latency with truthiness, which reads a zero
millisecond measurement as the null "probe failed" state. A localhost or LAN
regtest server can answer within the same millisecond, so the custom-server
modal rejected healthy servers and `checkServer` reported them dead.
`serverProbeVerdict` decides reachability once, strictly.

The Receive screen stores an address index whose null means the addresses
effect has not run, but that effect encodes an empty filtered list as index
zero. Six read sites treated a non-null index as proof that an address
exists, and indexing an empty list there throws. `deriveAddressSelection` is
total over every list-and-index pair, separates not-loaded from empty, and
clamps a stale index left over from a previous list.

VerifyAddress stored `is_wallet_address` directly off `JSON.parse` behind a
strict null gate, so a payload missing the field stored undefined, passed the
gate, and rendered the definitive "this address does not belong to you". A
verification screen reporting a confident false negative is the worst form
this class of bug can take. `interpretCheckAddressResult` requires an actual
boolean and reports anything else as malformed, which the screen surfaces
instead of rendering as a verdict.

Every function is covered by unit tests exercising the inputs that triggered
the misinterpretations: a payload with no key material, a zero birthday, a
zero millisecond probe, an empty list stored as index zero, a stale index,
and a payload lacking `is_wallet_address`. Each assertion was first run
against a verbatim transcription of the current production logic, where all
five fail, the empty-list case with the TypeError it throws in the app.

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

The previous commit introduced two patterns in duplicate, and this branch
already carried two more copies of one of them. This commit defines each
pattern once and applies it at every site.

`deriveListSelection` (app/utils/listSelection.ts) generalizes the Receive
screen's address-selection derivation and replaces the two-sentinel guard in
the address book's detail sheet, which stores null for a closed sheet and -1
for Add-new mode. The shared form drops the previous clamping of a stale
index: the address book showed why that policy was wrong, since clamping
would open an edit sheet on a different contact than the user chose, so a
stale index now designates nothing anywhere.

`decodeFfiJson` (app/walletBackend/ffi.ts) is the transport-level triage —
rejection, empty payload, unparseable payload, or JSON — that
interpretWalletFetchResult, interpretCheckAddressResult, getZecPrice, and
isWalletAddress each performed themselves. All four now start from the
decoded arm and add only their own domain validation. As a consequence
getZecPrice classifies a non-object JSON payload as malformed rather than
whatever member access on it happened to produce.

matchWalletFetchOutcome and its handlers type had no consumer and are gone.
Module comments now describe only the current contracts; the historical
misreadings live in the audit document and the regression tests, which cite
them because that is what the tests exist to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
updateToField took five positional slots — address, amount, amountCurrency,
memo, includeUAMemo — where null meant "leave this field alone", the empty
string meant "clear it", and four of the five slots shared the type
`string | null`. A caller stating which field it writes had only slot
position to say so, and the audit (docs/null-audit.md, items 5-7) ranked
that protocol the codebase's largest remaining null hazard: transposing two
arguments compiles cleanly and silently writes the wrong field.

A form write is now a value of `SendFieldUpdate`, a discriminated union
whose discriminant names the field, and updateToField takes a batch of
them. The transposition class of mistake is thereby inexpressible: the
regression test's scenario — "the user typed 2 ZEC" expressed one slot to
the right — ran the amount coupling backwards and left the form holding
2 / 35 ≈ 0.057 ZEC, a wrong-amount transaction waiting for a signature,
with the type system silent. That test failed against a verbatim
transcription of the positional protocol (expected '2', received
'0.05714286') and passes by construction against the union.

The field arithmetic lives in `applySendFieldUpdates`, a pure fold over the
current field values: it owns the one coupling in the form (the two amount
fields are a single value in two units, so writing either recomputes or
clears its counterpart) and is exhaustively switched over the union, so a
new field arm fails compilation until every consumer handles it.
updateToField keeps only the effects: the async URI-address path and the
state writes, which now touch exactly the fields a batch changed.

Writing the pure core surfaced one dependency wart: the locale number
parsers lived on the Utils class, whose module imports the native bridge,
so nothing pure could import them. They now live in
app/utils/localeNumber.ts as a leaf module and Utils delegates to it.

The audit's tally attributed 73 of Send.tsx's 90 nulls to this protocol;
they are gone, and every remaining null in the file is one the audit rated
idiomatic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit with knip, ts-prune, and ts-unused-exports over all 383
TypeScript sources found no unused files, one dead implementation, nine
dead barrel re-exports, and twenty-six exports with no importer. Only
findings all three tools agreed on were acted on.

The dead implementation was openOptionsPanel, removed together with its
listener wiring (_openListener and registerOpen). Its siblings toggle
and close remain in use from LoadedApp.

The dead re-exports came out of two barrels. app/walletBackend/index.ts
loses FfiError, FfiErrorCode, PriceRouteAttestation,
ZecPriceOutcomeHandlers, StartMigrationRoute, doSaveBackup, and
cancelIronwoodMigration, all of which every consumer imports from the
defining module instead. components/OptionsPanel/index.ts loses its
default export and OptionsPanelProps for the same reason.

Twenty-four symbols used only inside their own module keep their
declarations and lose the export keyword. Two flagged functions,
enableMixnet and stopMixnetTransport, stay exported: they are
documented, deliberately unwired mixnet API on this feature branch (the
exec fallback and the shutdown teardown), and un-exporting them fails
lint because nothing calls them yet. cancelIronwoodMigration likewise
has unit tests but no production caller. All three are flagged in the
pull request rather than removed.

Verified with tsc --noEmit and eslint over every touched file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An on-device hang ran about five minutes to tls handshake eof through a
half-dead tunnel, with the UI spinner circling the whole time. The
fetch is now raced against a 25-second watchdog whose loss is a typed
timedOut arm of ZecPriceOutcome, so the spinner is provably released
whatever the native call does underneath.

Classification moves into classifyPriceFetch, a pure and total function
over RawPriceFetch (the settled decode with its elapsed time, or the
watchdog firing — nothing else is constructible). Every arm carries its
timing, and describePriceOutcome renders the one log line per attempt
through the ADR 0004 handler record, success and gate refusal
included, so a missing line always means no attempt finished. The
store renders the new arm as a timeout snackbar, forced by the
exhaustive record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pin advances to zingolib aa4c80af, the dev merge that renamed the
drain family to immediate migration and unified the value-left-behind
vocabulary. The FFI function names are unchanged; internally the
mobile crate now calls plan_immediate_migration,
quick_immediate_migration, and immediate_migration_progress_handle,
matches ImmediateMigrationPhase, drops the removed
ActivationBoundaryPending error arm, and maps the new
IronwoodEraTooYoung variant.

Payloads follow upstream end to end: stranded becomes residual in the
plan, execution, and split-plan surfaces, next_wakes becomes
upcoming_windows with window_opens_unix_time and
latest_target_unix_time, and the not_due batch report carries
window_opens_unix_time. The TS payload types and the four migration
components rename in lockstep; rendered copy and i18n keys are
untouched. CONTEXT.md retires Stranded into a unified Residual entry,
dated to the upstream unification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dev merge's resolution of walletBackend/index.ts kept a stale
export block naming routeCadencePlan and routeRescheduleParts, which
left with the cadence surface in the ZIP 318 no-cap work, and
duplicated routeStartMigration. The block goes; typecheck is clean
again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tester who hit the always-on gate saw only "Error fetching the price
from the Internet." and could report nothing else. zecPriceFailureReport
renders every failed ZecPriceOutcome arm as one string under a stable
headline, every field verbatim: the gate refusal text, the watchdog
bound, the ffi code and message, the full zingolib oracle error chain,
the malformed payload with its parse detail, and the elapsed time where
the arm carries one. It returns null on success and is exhaustive
through matchZecPriceOutcome, a new arm fails compilation until it
declares its report.

The report is built to feed createAlert with the sendEmail support
button, which pre-fills an email carrying the text with device and
version info. The unit tests pin each arm's report, the gate refusal
verbatim among them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Issue #1221 reports a stock Zingo user under NymVPN who cannot sync and
can offer only a generic error. Two recorded facts make the report
legible: Nym exits carry 443 cleanly and mishandle lightwalletd's 9067,
and the stock server list mixes both ports. The plan closes the
diagnosis gap with a Connection Doctor: a staged per-server probe
(connect, TLS, GetLightdInfo) built on zingolib's net-diag taxonomy
extended to the sync path, surfaced in a debug-build Settings screen
with a copy-out report, delivered to the reporter as a debug APK with a
two-run ask (VPN off, VPN on).

The plan also defines the pin collaboration protocol for the several
agents updating the zingolib pin: the manifest comment is law, one bump
one commit, a verification floor per bump, upstream milestones
announced in the design doc rather than commit archaeology, and a
mobile-adaptation-required marker for breaking surfaces. AGENTS.md
gains a pending-plans pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Advances the pin to the current head of zingolib#2527, eleven days of
upstream work past the previously locked 9113eed5. The lock also picks
up the git-pinned librustzcash patch (e0b48bb5) that rev carries.

Four upstream API changes reach the FFI, adapted here. The price fetch
split into a clearnet method returning a bare f32 and a nym-feature
update_current_price_over_mixnet carrying the via_socks5 attestation;
zec_price now dispatches through mixnet_route, the shared fail-closed
resolver, so the refusal semantics are unchanged and the clearnet leg
omits the attestation field. start_ironwood_migration grew a per_bucket
override, passed as None because the Poisson schedule leaves no cadence
to choose. MigrationPhase::NoteSplitting lost its queued field and
SplitStep lost AwaitingSchedule, so their JSON renderings shrink to
match; nothing app-side read either shape. MigrationError::CadenceFixed
is mapped for exhaustiveness only, since the retired cadence surface
never calls reschedule_parts.

Verified with cargo check, clippy, and fmt on the FFI crate and the
full jest suite (487 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tester who hit a price failure saw a snackbar compose the headline
with one detail and vanish, nothing to copy and nothing to send. The
store now routes every failed fetch through zecPriceFailureReport and
splits the surfacing by initiator. A user-initiated fetch raises
createAlert with the complete report and the Support button, which
pre-fills an email carrying the text plus device and version info. The
60 s auto-refresh keeps to a snackbar built from the report's first
detail line, since a modal firing on a timer would interrupt whatever
the user is doing.

The per-arm snackbar match in the store retires, its exhaustiveness
obligation already living in the transform. PriceFetcher threads
setBackgroundError and the zingolib version from context into the
store's deps. The surfacing helper is exported for its unit test,
which pins the alert path (report and version threaded through), the
snackbar path (refusal text intact), the malformed-payload title, and
silence on success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zingolib#2527 (nym_mobile_adoption) merged into zingolib dev at
3d2f2aa0 on 2026-07-27 and the branch was deleted. The branch-based pin
was dangling, the exact failure mode that broke every dev-based CI
resolve when feat/ironwood was deleted last week. All three
zingolib-repo dependencies now pin zingolib dev, whose head is the
merge commit, one commit past the previously locked b5de9bca with
identical content. The manifest comment changes in the same commit,
per the pin collaboration protocol, and now names dev as the branch
that wins merge conflicts.

Verified per the protocol floor: cargo check --workspace, cargo fmt
--check (one pre-existing diff in android/tests/e2e_tests.rs predates
this commit and is untouched), tsc, and the full jest suite. No
rust/lib/src change, so no release-Kotlin compile was required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Connection Doctor's native leg (the nym-diagnostics plan,
Workstream A). probe_server parses the target uri at the boundary
(unparseable input crosses as the typed InvalidInput error) and runs
zingolib's paired clearnet/mixnet GetLightdInfo probe with a
fifteen-second bound per leg. The result crosses UniFFI as structured
dictionaries — ProbeReport with two ProbeLeg records — rather than
JSON prose; the legs' detail strings stay opaque until the net-diag
taxonomy lands fielded outcomes (zingolib#2560 records that
direction). The Android and iOS native modules serialize the reports
to JSON only at the React Native bridge, the bridge's own boundary
format.

A user-invoked diagnostic: the clearnet leg contacts the target from
the real IP, and nothing calls this on an automatic path. No wallet
lock is held across the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app leg of the nym-diagnostics plan, Workstream A: the diagnosis
issue #1221's reporter could not produce, in the user's own hands.

The screen probes the current server first and then every non-obsolete
stock server for its chain, sequentially so results render as they
land and one dead target never multiplies load. Each row shows the
paired legs with verdicts, timings, and the leg detail; a finished run
copies to the clipboard as one markdown document under a stable
headline, ready to paste into a GitHub issue. Interpretation is a
pure, total function from the decoded FFI payload to a typed outcome
union, dispatched through an exhaustive handler record (ADRs 0002 and
0004), so a new outcome arm fails compilation at the screen and at the
report until each decides what it renders.

The menu entry is gated to debug builds per the plan: the debug-signed
APK handed to a reporter has it, stock release flavors do not. The
unit tests cover every interpreter arm and every report section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's jest coverage run failed both new Doctor suites at import time:
the tests reach interpretServerProbe through connectionProbe.ts, which
also imports RPCModule, and NativeModules.RPCModule is undefined in
that environment. The plain local run masked it.

The interpreter, the outcome union, and the handler record move to
serverProbeOutcome.ts, which imports the FFI module by type only and
loads without the React Native runtime; connectionProbe.ts keeps just
the effectful probeServer wrapper. The report transform and the unit
tests now import only the pure module, which is the house shape (ADR
0004 consumers dispatch over data, transforms stay pure) this code
should have had from the start.

Verified with tsc, eslint, and jest --coverage (76 suites, 501 tests),
the invocation CI runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Temporary pin to zingolib#2561 (net_diag_taxonomy, head 950892e3),
Workstream B's implementation: probe legs carry ProbeSuccess or
NetOpFailure as data, the staged sync-path probe probe_sync_server
lands, and the price path gains typed request failures with client
bounds. Four commits past the previously locked dev head 3d2f2aa0; two
carry the mobile-adaptation-required marker (4dd29ef6, 96c4e8d4), and
the FFI adaptation is the separate commit landing with this one. The
manifest comment changes here, per the pin collaboration protocol, and
the pin reverts to dev when #2561 merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c probe

The adaptation landing with the net_diag_taxonomy pin bump, covering
both of its mobile-adaptation-required commits (96c4e8d4, 4dd29ef6),
and a breaking reshape of the probe surface this branch introduced two
days ago: every closed possibility space now crosses the FFI as an
exhaustive enum, not a boolean beside an optional pair, and no absence
is a bare null.

ProbeLegOutcome is Answered(info) or Failed(failure); ok and detail are
gone, both derivable. MixnetLeg is Probed(leg) or NotCarried — the
proxy-not-ready case has exactly one producer and now has its name.
SyncStageOutcome is Passed or Failed(failure), and SyncServerVerdict is
Reachable(info) or Stopped. The failure record everywhere is the
net-diag taxonomy's: kebab-case stage, target, and the cause chain as a
vector, never a concatenated string. Step and stage names stay open
strings on the FfiErrorCode precedent: upstream may add stages, and a
closed union would break forward compatibility on an open space.

probe_sync_server joins the FFI: the staged sync-path probe
(tcp-connect, tls-channel, grpc-info, each bounded and timed, stopping
at the first typed failure), lock-free and client-free, so the Doctor
runs it per configured server. The screen probes every server through
it and adds the paired covered-surface probe for the current server,
whose DoctorRun arm carries the pairing structurally. The native
modules serialize each enum as a kind-discriminated JSON object at the
React Native bridge, and the pure interpreters validate exactly those
shapes into the same unions.

Verified per the protocol floor: cargo check, clippy, and fmt; tsc;
eslint; jest --coverage (76 suites, 504 tests); and the release-Kotlin
compile over the regenerated sealed-class bindings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TaskDescription.Builder exists only from API 33, but the privacy-controls
path guarded it with API 28. Every prod-flavor install on API 28 through 32
crashed at launch with NoClassDefFoundError before drawing a frame. The
guard now requires TIRAMISU, and older devices keep the default blank
recents card that FLAG_SECURE already produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zancas and others added 22 commits July 28, 2026 21:30
Every variant of this app embeds its JS with --dev false, so __DEV__ is
false in any APK that runs without Metro. The Doctor's __DEV__ gate
therefore hid the screen in exactly the debug-signed handoff APK the
nym-diagnostics plan builds for reporters. RPCModule now exports
debuggableBuild, read from the installed APK's FLAG_DEBUGGABLE bit rather
than BuildConfig (the reproducibility rule), and the menu gates on
__DEV__ || isDebuggableBuild(): Metro sessions and debug-signed APKs show
the Doctor, stock releases hide it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staged proxy shim is a binary build product with nothing tying it to
the manifest pin: UniFFI checksums guard only the interface and the golden
contract test only the wire format. A .so built hours before ADR 0021's
TLS fix rode three pin bumps undetected and fail-closed every mixnet
enable on Android. Three pieces close that hole. consume-android-shim now
stamps jniLibs/shim-provenance.txt with the bundle checkout's revision
(dirty checkouts stamp +dirty and can never match a pin). The new
attest-android-shim records that a staged shim covers a newer pin only
after proving zingo-netutils unchanged between the revisions. The gradle
task verifyShimProvenance runs before every mergeJniLibFolders and fails
any assembly whose staged shim neither matches the locked revision nor
carries an attestation; a build with no staged shim passes, since a
shim-less APK degrades fail-closed by design. The pin collaboration
protocol's verification floor names the new obligation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From net_diag_taxonomy at f701b105 to the zingolib#2568 stack at
220f682d, passing through the taxonomy head 756c7479 (the typed
per-provider connect race, zingolib#2562). The new head carries the
public mixnet_death_detail and mixnet_bootstrap_detail accessors and
types the elapsed client bound on the via_socks5 operations
(zingolib#2564), the surface the death-detail FFI wiring consumes next.
No commit in the range carries mobile-adaptation-required, and the
regenerated shim bindings are byte-identical to the committed copy.
Verified with cargo check, tsc, the jest suite, and the release-Kotlin
compile; the librustzcash re-lock follows the branch defaults as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tester watching the price update could not tell which route carried it,
and a tester hitting a failure could not copy the report out of the
alert. Both surfaces now speak. The FFI's clearnet leg attests its route
(via_clearnet in the payload), so the app distinguishes a deliberate
clearnet fetch from a payload predating the attestation; the route type
gains the clearnet arm and the dev log line names it. A user-initiated
fetch now raises a snackbar naming the transport and the elapsed time
(the auto-refresh stays silent), in all five locales. The failure alert
gains a Copy button beside Support, since native alert text cannot be
selected. Proven on the emulator: over the mixnet in 2.7 s through the
SOCKS5 proxy, over clearnet in 0.7 s after the consent toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staged sync probe's stage lines named no transport, and a reader in
the field parsed them as mixnet results. The sync path never rides the
mixnet (ZIP-318: only broadcast and price-fetch are covered), so every
run's section now opens with 'sync-path probe (clearnet):', in the
screen rows and the copied markdown alike, and the unit test pins the
heading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A died transport reached the app as a bare mode, so "Mixnet connection
lost" could not say what failed, and this week's false-death triage
needed a debugger to read a cause the wallet had already recorded. The
zingolib#2568 pin exposes mixnet_death_detail; this wires it through.
The FFI returns the probes' ProbeFailure dictionary (one taxonomy shape
wherever a failure crosses), the Kotlin bridge marshals absence named
rather than null, and the coordinator fetches the record exactly when
the polled mode is died. The view carries it only in that state — a
failed or absent detail degrades to the bare verdict, since the detail
is evidence, not policy — and Settings renders the stage, target, and
cause chain under the died status through the Doctor's shared renderer.
The presenter test pins all four arms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Testers running the 2026-07-27 handoff APK reported the whole app
turning sluggish with "Check Balance in Dollars" enabled, freezing in
roughly 20-second spells. The mechanism: zec_price() ran under the
global LIGHTCLIENT write lock and held it across the network fetch,
which zingo-price bounds at 20 seconds per attempt. Every other FFI
call queued behind it, and the app's immediate retry doubled the hold
to up to 40 seconds of every 60-second refresh cycle.

The price-fetch flow now never obtains the wallet lock (ADR 0005). The
route is planned purely from a mixnet-state snapshot recorded by calls
that already hold the lightclient for their own reasons (store_client,
enable/attach/disable, and the app's mode poll), resolved through
zingolib's fail-closed resolve_route. The fetch itself calls
zingo-price directly with no lock held, and a successful observation
lands in a bounded in-memory window of at most 1000 points, oldest
dropped first.

Persistence becomes best-effort by contract: an observation reaches
the wallet only when some other operation takes the write lock while
it is buffered, and it may be lost. The piggyback write is not yet
wired; it needs a public price-recording API on the pinned zingolib,
and until that lands the window buffers without draining. The fetch
payload the app displays is unaffected, and its route attestation
(via_socks5 / via_clearnet) is unchanged.

The change was test-driven. A new unit test holds the write lock for
its whole duration and requires zec_price() to settle anyway, failing
precisely when the flow touches the lock: red at a 3-second timeout
before the rewrite, green in milliseconds after it. Pure-core tests
pin the observation window's rotation at the cap and every mixnet
state's fail-closed plan arm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Price surface section names the vocabulary ADR 0005 decides: a
price observation, the bounded observation window, the piggyback write
that is the surface's only persistence path, and the route snapshot the
fetch plans from. The Indexers section, drafted in a parallel design
session, fixes the operator-centric vocabulary for indexer endpoints:
trust, diversity, and exclusion are reckoned per operator, never per
DNS name, and a Broadcast Indexer is never the Sync Indexer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From socks5_typed_timeout at 220f682d to the top of the zingolib#2569
stack at 2d78c1be3, which carries the attach retune (30 s round-trip
bound, the named 61 s readiness budget), the typed MixnetTiming record,
the death report with its latch moment and clamped age, and the
LightClient wrapper flagged mobile-adaptation-required — the adaptation
is the wiring commit landing with this one.

The bump also consolidates zingo-price into the workspace pin block:
the lock-free price commit had pinned it directly on the old branch,
which resolved two copies of the crate the moment the pin moved. All
five zingolib-repo pins now live in one place under the protocol
comment.

Verified with cargo check, clippy, tsc, the jest suite, and the
release-Kotlin compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idge

The mobile adaptation for the attach_retune pin. mixnet_death_report
supersedes mixnet_death_detail as the one death surface: the wallet
computes the clamped age at read time (a stepping clock reads as zero),
so each poll carries a fresh staleness and no consumer subtracts
timestamps; the typed cause rides beside it, absent only for a
causeless death. mixnet_timing crosses the attach readiness budget and
the round-trip bound, so the app paces its connect patience from the
constants the wallet's gates actually run.

On the app side the view's death evidence becomes a discriminated
union — notDied, unreported (a died verdict whose report call failed,
previously an anonymous pair of nulls), and reported with the age and
optional cause — mirroring the latch fold the wallet itself received
in zingolib#2569. Settings renders the cause lines with a latched-age
line beneath the died status, and while bootstrapping shows the
wallet's own budget as the patience hint, in all five locales. The
presenter test pins every evidence state, including the causeless
death that still reports its age.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second half of ADR 0005. A lock-free price fetch buffers its
observation in the bounded in-memory window; this commit wires the
window's only exit. Acquiring the write lock in
with_initialized_lightclient now drains pending observations into the
wallet's price list before the operation's closure runs, so persistence
rides whatever operation next takes the lock for its own reasons.

No zingolib change was needed: the recording path is public on the
current pin (LightClient::wallet, the price_list field, and
PriceList::record_current_price). The one door the public path lacks is
save_required, which stays pub(crate) upstream, so a drained price
reaches disk with the next operation that dirties the wallet. That sits
inside the surface's best-effort contract.

The change was test-driven. The red test buffered an observation,
invoked a write-lock operation, and found the wallet's current price
empty; wiring the drain turned it green. The price tests share a small
support module (the serialization guard and the offline fixture wallet),
a sibling of the private helpers in lock_discipline_tests.rs, to be
unified when that suite settles. The lock-freedom test now also resets
client state first, so it holds under plain cargo test threading as
well as nextest's process-per-test isolation. Verified against the
attach_retune pin at 2d78c1be: 43 of 43 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From attach_retune at 2d78c1be to price_sources at 47e44b79e, which is
attach_retune's exact pinned head plus one commit: the three-source
price race in zingo-price (Gemini, Kraken, CoinGecko; first success
wins, an all-fail report names every operator's typed failure). Nothing
else moves, and zingo-netutils is untouched between the two revisions,
so no shim restage is due under the staged-shim provenance rule.

The manifest comment moves with the pin, per the pin collaboration
protocol in docs/agents/nym-diagnostics-plan.md. The full mobile unit
suite passes on the new head (54 of 54).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lock-free price fetch now runs zingo-price's race_current_price
over the fail-closed route: Gemini, Kraken, and CoinGecko fetched
concurrently, first success reported, losing legs cancelled (ADR 0006).
The payload names the winning source beside the route attestation, and
the JS layer passes the additive field through untouched. An all-fail
settles as one typed failure whose text carries every source's cause
chain, so the failure report stays diagnosable per operator.

The winning observation still lands in the bounded window for the
piggyback write, and the lock-freedom contract is unchanged: the race
runs entirely outside the LIGHTCLIENT lock, within zingo-price's
20-second per-leg bound. A payload test pins the winner's name and both
route attestations; the full suite passes (56 of 56).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The store retried any non-price outcome immediately. After a watchdog
timeout that stacked a second native call behind one possibly still in
flight, and it retried fail-closed gate refusals the glossary says are
never retried.

The retry decision is now an exhaustive record over the outcome union
(ADR 0004): settled failures (noData, oracleError, malformedPayload,
ffiRejection) keep their one immediate retry, while timedOut and
gateRefusal never retry. With the native race in place, a settled
failure's retry also naturally reaches the re-raced sources.

Three tests drive a fresh store against a mocked backend: the timeout
and the refusal each launch exactly one native call, and a settled
failure still retries once. The suite runs on fake timers with explicit
teardown so the store's cooldown and visible-floor timers cannot leak a
force-exited jest worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The price tests carried their own copy of the serialization guard and
the offline fixture wallet, left with a note to unify once the
lock-discipline suite landed. Beyond the duplication, the copy held a
separate mutex, so under plain cargo test's in-process threads the
price tests and the lock-discipline tests never serialized against
each other while sharing the one global LIGHTCLIENT. Both families
now take the same guard and fixture from lock_discipline_tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The linearization replays feat/nym's 83 non-merge commits onto dev and
drops its nine merge commits. Whatever content those merges' conflict
resolutions carried (dev-side surfaces the branch reshaped or retired,
such as the quick-split machinery) has no replayed commit to land in,
so this commit sets the tree to feat/nym's exactly. After it, the
linear branch and feat/nym agree byte for byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From price_sources at 47e44b79 to the census (zingolib#2571) at
ba03ec07, one commit atop the price race: the indexer census, the sole
source of truth for endpoints, flagged mobile-adaptation-required. The
adaptation is the consumption commit landing with this one. Verified
with cargo check, tsc, the jest suite, and the release-Kotlin compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mobile adaptation for the indexer_census pin (zingolib#2571). The
FFI exposes indexer_census() as fielded entries, and the census crosses
the bridge in RPCModule's constants rather than a method, because the
server list is consulted at JS module load — before any async bridge
call could resolve — for the flavor's default server.

serverUris becomes a pure projection of that constant: URIs, chains,
defaults, and obsolete flags come from the census, and the app renders
regions by translating the census's region keys. The old static list
survives only as staticServerFallback, serving a native layer that
predates the census constant (iOS until its export lands, and tests
without a mock); a malformed census degrades to the fallback whole,
never partially. The projection test pins the mapping, the fallback
gate, and the all-or-nothing rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e census

The live registry fetch ran at boot (auto mode), at Settings open, at
list-mode validation, and in every failure recovery. Each call beaconed
the wallet's presence to one operator from the real IP, and the response
steered which indexer the wallet trusted next — a funneling surface
aimed at witness rotation and operator diversity. The census (ADR 0007)
already carries the registry's static value on release cadence.

Auto mode now probes the census's active entries by latency; list mode
validates the stored server against the census and promotes to auto only
when the census retired it; Send-failure and RPC-failure recovery select
from the census, excluding the failed server. fetchServerList is
deleted. The Settings picker populates synchronously from the census,
which is the one snapshot change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase onto dev carried the coordinator forward from the feat/nym
line, where the poll constants were module-private. Dev's hardening-era
coordinator test imports STEADY_POLL_MILLIS to drive its fake timers,
so the constants return to the exported surface they had on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…base

The rebase resolved every Cargo.lock and zingo.kt conflict by taking
dev's side, because both are generated artifacts whose only truth is
their source of record. This commit restores that truth for the rebased
tree: the lockfile is regenerated against the branch's indexer_census
pin, and the checked-in Kotlin bindings are regenerated from the
branch's final zingo.udl with the workbench's uniffi-bindgen invocation,
restoring the probe, death-report, and timing surfaces the rebased arc
declares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase met two independent regenerations of the same snapshot (dev's
banner restyle and the nym arc's refusal reclassification) and resolved
them by taking dev's side, leaving the snapshots answering to neither.
Retaken from the merged code; the full jest suite runs green (78 suites,
516 tests, 95 snapshots).

Co-Authored-By: Claude Fable 5 <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