The trust ratchet holds inside one CandidateRotation value, but not across a restart. A hand-typed local route that has already been upgraded away from leads the rotation again on the next launch, and the long-lived bearer goes with it.
The sequence
Every step is reachable through normal use.
- Someone types a LAN address in Settings.
Session.updateAddress (ios/App/Session.swift:535-541) builds it with priority: 0 and puts it at the head:
updated.promote(endpoint)
updated.endpoints = [endpoint] + existingRoutes.filter { $0.url != endpoint.url }
-
That route fails. CandidateRotation.advance upgrades to a hosted or tailnet route and drops the local one from the rotation — the ratchet working as intended.
-
The protected route says hello, and rememberWorkingRoute() (Session.swift:478-484) calls promote(winner). But promote (Failover.swift:307-319) only assigns activeEndpoint and appends the winner if it was missing:
public mutating func promote(_ winner: CompanionEndpoint) {
activeEndpoint = winner
host = winner.host
port = winner.port
if let existing = endpoints,
!existing.contains(where: { $0.url == winner.url }) {
endpoints = existing + [winner]
}
...
}
It never reorders and never reprioritises, so the priority: 0 local route is still first in endpoints.
orderedEndpoints (Failover.swift:196-214) sorts by priority and original offset only, and appends activeEndpoint only when it is missing — it is never pulled to the front:
candidates = candidates.enumerated().sorted {
$0.element.priority == $1.element.priority
? $0.offset < $1.offset
: $0.element.priority < $1.element.priority
}.map(\.element)
-
If the best-effort endpoint refresh 404s, fails, or is cancelled — all deliberately tolerated — that order is what persists.
-
Next launch, CandidateRotation(endpoints: saved.orderedEndpoints) (Session.swift:155) starts from the cleartext route again, treats it as the explicit local choice, and hands it the stored bearer.
Why it matters
The ratchet exists so that a connection which has reached a protected route does not walk back onto cleartext carrying a long-lived token. That guarantee currently lives in the lifetime of a CandidateRotation value. A process restart — or anything that rebuilds the rotation from the store — resets it.
Nothing is corrupted, and it needs a hand-typed local address to have been used at least once. But after that, the reset is automatic and silent.
Shape of a fix
The version that worked when I hit the same thing while porting: make the ordering itself carry the upgrade, so a route already in use that protects credentials leads the stored order regardless of a lower priority number. Reaching a protected route is one-way; the superseded route can stay in endpoints for display and for a future manual choice without being what the rotation reads as the preferred candidate.
That keeps the escape hatch intact — typing the local address again makes it active and the priority order applies once more, which matters when the hosted route is down and someone needs the LAN.
How it was found
Porting the companion to Android. A reviewer traced the same sequence in the Kotlin port, reproduced it with a temporary test, and then checked whether the Swift it was ported from had the same shape. It does. Verified against main at 3557e748; read from source rather than run on a device, since I have no Apple hardware here.
The Android port is deliberately diverging on this point rather than mirroring it, since it is a credential path.
The trust ratchet holds inside one
CandidateRotationvalue, but not across a restart. A hand-typed local route that has already been upgraded away from leads the rotation again on the next launch, and the long-lived bearer goes with it.The sequence
Every step is reachable through normal use.
Session.updateAddress(ios/App/Session.swift:535-541) builds it withpriority: 0and puts it at the head:That route fails.
CandidateRotation.advanceupgrades to a hosted or tailnet route and drops the local one from the rotation — the ratchet working as intended.The protected route says hello, and
rememberWorkingRoute()(Session.swift:478-484) callspromote(winner). Butpromote(Failover.swift:307-319) only assignsactiveEndpointand appends the winner if it was missing:It never reorders and never reprioritises, so the
priority: 0local route is still first inendpoints.orderedEndpoints(Failover.swift:196-214) sorts by priority and original offset only, and appendsactiveEndpointonly when it is missing — it is never pulled to the front:If the best-effort endpoint refresh 404s, fails, or is cancelled — all deliberately tolerated — that order is what persists.
Next launch,
CandidateRotation(endpoints: saved.orderedEndpoints)(Session.swift:155) starts from the cleartext route again, treats it as the explicit local choice, and hands it the stored bearer.Why it matters
The ratchet exists so that a connection which has reached a protected route does not walk back onto cleartext carrying a long-lived token. That guarantee currently lives in the lifetime of a
CandidateRotationvalue. A process restart — or anything that rebuilds the rotation from the store — resets it.Nothing is corrupted, and it needs a hand-typed local address to have been used at least once. But after that, the reset is automatic and silent.
Shape of a fix
The version that worked when I hit the same thing while porting: make the ordering itself carry the upgrade, so a route already in use that protects credentials leads the stored order regardless of a lower priority number. Reaching a protected route is one-way; the superseded route can stay in
endpointsfor display and for a future manual choice without being what the rotation reads as the preferred candidate.That keeps the escape hatch intact — typing the local address again makes it active and the priority order applies once more, which matters when the hosted route is down and someone needs the LAN.
How it was found
Porting the companion to Android. A reviewer traced the same sequence in the Kotlin port, reproduced it with a temporary test, and then checked whether the Swift it was ported from had the same shape. It does. Verified against
mainat3557e748; read from source rather than run on a device, since I have no Apple hardware here.The Android port is deliberately diverging on this point rather than mirroring it, since it is a credential path.