Skip to content

feat(apple): RFC 025 on the Apple plane — the tailnet login on every identity surface, and the login gate - #191

Merged
jamesyong-42 merged 14 commits into
mainfrom
feat/rfc025-apple-login
Sep 16, 2026
Merged

jamesyong-42 merged 14 commits into
mainfrom
feat/rfc025-apple-login

Conversation

@jamesyong-42

@jamesyong-42 jamesyong-42 commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Stacked on #188 (the Apple identity and watch-recovery fixes); rebased onto main once #188 merged.

AuthenticatedPeer.loginName/displayName (from the WhoIs UserProfile), BackendPeer.loginName, BackendStatus.loginName, Peer.loginName, MeshNode.loginName; MeshConfiguration.loginAllow with the same grammar as the Go and Rust gates (LoginGlob, a faithful path.Match port, verified against 1.7 M generated pairs); Handshake.server applies RFC 025 §3.4's table (a gate is never bypassed by .allowUnverified); MeshNode.upsertFromLayer3 admits only isAppPeer && allowed and EVICTS an admitted peer whose row turns refused (closing its session, peerLeft); MeshError.helloRefused(code:reason:) on the dial side; MeshError.loginRefused(login:) server side; MeshError.loginUnknown(peer:) when a gated node declines to open a new session to a kept peer whose owner is unresolved (its login reads nil; an existing session stands); SessionCloseCode.loginRefused = 4004; MeshNode.whoIs(remoteEndpoint:) so an app can gate a raw accepted connection (the raw plane is not gated by loginAllow).

The pinned TailscaleKit decodes IpnState.PeerStatus without UserID, so TailscaleKitBackend.refreshStatus also GETs /localapi/v0/status over the authenticated loopback and overlays the logins (LocalAPIIdentity.swift; one extra GET per refresh; a failed overlay leaves logins absent, so a gated node admits nobody, with a one-shot health notice). RFC 024 §8.1.2 documents it; RFC 025 §3.3 carries the dated correction.

Gates: swift test --package-path apple 70 → 107 tests / 17 suites · the root "as published" build+test 107/17 · xcodebuild -scheme TruffleTailscale for both iOS destinations (serially) BUILD SUCCEEDED, zero warnings · reviewed read-only with a live probe (the login-change bypass found, fixed, re-proven). Not witnessed here: the production LocalAPI reads on a real device (owed as a device gate).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

jamesyong-42 and others added 14 commits September 16, 2026 13:03
RFC 025 §3.6 (D7) and VibeField petition T4: the Apple plane could not say
who was signed in. A grep for LoginName over apple/ was empty, so the phone
could not label a peer as its own account's, nor scope account-owned state.

The login is now a first-class field, optional and honest — absent, never
fabricated. An empty string on the wire becomes nil, because RFC 022's
honesty rule forbids surfacing "" as an identity and RFC 025 §3.2 fails
closed on an empty login.

- AuthenticatedPeer gains loginName/displayName (the WhoIs answer's
  UserProfile); BackendPeer and BackendStatus gain loginName (a peer's, and
  the node's own). Every new init parameter defaults to nil, so no existing
  call site changes.
- Peer.loginName joins the public snapshot AND its equality, so a SwiftUI row
  re-renders when a login appears. Hashing stays ref-only.
- MeshModel.loginName projects the node's own login beside phase/peers.
- LoopbackBackend grows the seams the gate's tests need: a per-node login on
  join, setLogin for a profile switch, and setWithholdWhoIsLogin to model a
  WhoIs answer that resolves a node ID but no login.

FINDING — RFC 025 §3.3 specified the Swift mapping as
status.User[String(peer.UserID)]. That is not expressible: the pinned
TailscaleKit decodes IpnState.PeerStatus WITHOUT UserID, and Status.SelfStatus
is a PeerStatus too, so neither a peer's nor the node's own login can be keyed
out of Status.User — which does exist, and is keyed by the stringified user
id, with nothing to key it. Verified against the vendored source
(.vendor/libtailscale-59d4bb82…/swift/TailscaleKit/LocalAPI/Types.swift:230-249),
the shipped arm64-apple-ios.swiftinterface, and this repo's own libtailscale
patches (which touch only NotifyWatchOpt). RFC 025 §3.3 now carries a dated
correction at its source.

tsnet's JSON does carry UserID; TailscaleKit drops it at decode, and
LocalAPIClient.backendStatus() returns a decoded Status with no raw-bytes
variant. So TailscaleKitBackend.refreshStatus now also GETs
/localapi/v0/status through the same authenticated loopback the WhoIs path
uses, decodes only Self.UserID / Peer[].{ID,UserID} / User{}, and overlays the
logins onto the mapped BackendStatus. Cost: one extra loopback GET per status
refresh. A failed overlay leaves every login nil, so a gated node admits
nobody — the fail-closed answer. When PeerStatus grows UserID the overlay
collapses back into the decoder.

The decoders live in Sources/TruffleTailscale/LocalAPIIdentity.swift, OUTSIDE
TailscaleKitBackend's `#if os(iOS) && canImport(TailscaleKit)` — the same
reason TailscaleEndpoint is its own file. That guard compiles to nothing on
the macOS host, so decoding placed inside it has no test anywhere. #188's
WhoIs address normalisation is untouched; its LocalAPI HTTP plumbing is
factored into a shared localAPIGet and both callers use it, with the WhoIs
error strings byte-identical.

What is real: every field is wired end to end and covered by tests on the
macOS host. What is NOT witnessed: the production LocalAPI reads themselves —
TailscaleKitBackend is iOS-only and has no device gate in this lane, so the
status overlay and the WhoIs profile are proven by their decoders and by
compilation for both iOS slices, not by a live tailnet.

Gates: swift test --package-path apple 70 -> 95 tests in 17 suites, exit 0 ·
root "as published" swift package resolve + build + test likewise 95/17 ·
xcodebuild -scheme TruffleTailscale for both generic/platform=iOS and
generic/platform=iOS Simulator BUILD SUCCEEDED, zero warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
RFC 025 §3.1-§3.4 (D1-D5) and VibeField petition T5, Swift half. Until now a
Swift node admitted every tailnet node carrying the app's hostname prefix, and
its hello admitted any caller that claimed the app id. The Swift core is a
truffle peer that speaks the same hello, so without this gate a foreign
desktop could dial the phone's listener and feed its store.

A node declares the gate once, for its lifetime:

    MeshConfiguration(appId: ..., deviceName: ..., loginAllow: ["*@corp.com"])

Empty (the default) is today's behaviour EXACTLY: the whole tailnet,
hostname-prefix discovery, and the existing IdentityPolicy alone on the
inbound path. Nothing on the wire changes for an ungated node, and the hello
envelope stays at version 2 — the login is never self-declared, WhoIs is its
only authority (D8).

- LoginGlob (Identity/LoginGlob.swift) is the grammar: a faithful port of Go's
  path.Match — scanChunk / matchChunk / getEsc — walking Unicode SCALARS, not
  grapheme clusters, so `?` and the class ranges count and order exactly what
  Go's rune and Rust's char do. match(_:_:) is the case-sensitive primitive
  and throws BadPattern rather than trapping; allowed(_:login:) is the gate —
  empty list true, absent or empty login under a non-empty list false,
  malformed globs skipped. One grammar, three planes.
- Layer 3: MeshNode.upsertFromLayer3 admits a row only if
  Hostname.isAppPeer(...) && LoginGlob.allowed(loginAllow, login:). On a gated
  node a row with NO login is not a peer. Provisional entries from a raced
  inbound hello keep merging as before — that hello already passed the gate at
  Handshake.server, so re-gating here would drop a peer the node has a live
  authenticated session with.
- The hello: Handshake.server gains loginAllow (defaulted, so every existing
  caller and test compiles unchanged) and implements §3.4's table in order —
  validate hello, then absent authenticated identity -> 4003, then claimed
  tailscale_id mismatch -> 4003, then login absent or matching no glob -> 4004
  (SessionCloseCode.loginRefused, MeshError.loginRefused(login:)). All of it
  BEFORE our hello is sent, so a refused caller never learns our identity
  block. The dialing side needs no new check: a gated node only dials peers
  Layer 3 reported, and Layer 3 filtered them.
- A gate is never bypassed by the test policy: on a gated node an absent
  authenticated identity is refused under EITHER IdentityPolicy, including
  .allowUnverified, because without WhoIs there is no login to gate on.
- A tagged node's tagged-devices pseudo-login is passed through, not
  special-cased: refused by a personal glob, admitted by one that names it.

MeshNode also carries its T4 half here — the self login from the last status
(MeshNode.loginName), the registry entry's login, and makePeer/localPeer
projecting it — because the same file owns both and splitting the hunks would
leave neither commit building.

Honest degradation rather than a silent empty mesh: where the Rust provider
refuses to start when its sidecar cannot report logins (§3.3), a Swift node
has no sidecar to interrogate, so a gated node that sees a login-less app peer
in a Layer 3 snapshot emits ONE .health notice. Note this fires from
apply(status:), which is the only Layer 3 path production takes —
TailscaleKitBackend emits .status, .authRequired and .health, never
.peerUpsert; the .peerUpsert path is the loopback's.

Close-code docs corrected at their source: SessionCloseCode gains 4004 and
says 4003 now also covers a gated node under any policy; HelloValidationError
records that validation covers only 4001/4002 and that the refusals depending
on evidence outside the hello are decided afterwards in Handshake.server.

Tested by the following commit: both of the Rust port's glob tables verbatim,
every row of the §3.4 table asserted on the close code the CLIENT sees, and a
gated loopback pair. All three gates green — swift test --package-path apple
and the root "as published" manifest at 95 tests in 17 suites, and both iOS
slices BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…ack pair

25 new tests (70 -> 95 in 12 -> 17 suites), all on the macOS host.

- LoginGlobTests reproduces the Rust port's tables VERBATIM
  (crates/truffle-core/src/network/login_allow.rs): the Go reference table
  from TestAllowedLogin, the path.Match behaviour table, and the bad-pattern
  table. If a row here disagrees with a row there, one of the three planes has
  drifted and the gate is no longer one grammar. All three passed on the first
  run of the port, including the rows that matter for the scalar-vs-grapheme
  choice ("a?b" vs "a☺b", "[a-ζ]*" vs "α").
- HandshakeLoginGateTests walks every row of RFC 025 §3.4 for Handshake.server
  and asserts the close code the CLIENT's frames see — and asserts it is the
  FIRST frame the client receives, which is what proves our hello was never
  revealed to a refused caller. Rows: absent identity (nil AND an empty stable
  ID) refused 4003 under .allowUnverified, node-id mismatch still 4003 even
  when the login WOULD have passed, login absent 4004, login unmatched 4004,
  matching login exchanges hellos, the ungated column accepts a foreign AND an
  absent login, and tagged-devices is refused by a personal glob but admitted
  by one that names it.
- NodeLoginGateTests drives the gate end to end over the loopback tailnet: a
  gated pair with a matching glob converges and messages flow; a foreign login
  is NEITHER listed by Layer 3 NOR admitted at the hello (the ungated peer
  still discovers and dials the gated node, which is exactly what the hello
  gate must stop, and the refusal leaves no provisional entry behind); a
  login-less row on a gated node is not a peer, the node emits its one health
  notice, and the SAME row with a matching login is then admitted — which is
  what proves it was dropped for its login and not its hostname.
- LocalAPIIdentityTests pins the decoders from literal JSON: the WhoIs answer
  with and without UserProfile, empty profile strings becoming nil, the
  tagged-devices passthrough, absent Addresses, and the status overlay —
  resolution through the stringified user map, a UserID with no profile and a
  row with no UserID contributing nothing, the merge onto a mapped
  BackendStatus by stable node ID, and the clearing of a stale login the
  overlay does not own (otherwise a departed user's login could gate a new
  node in).

Verified not vacuous. Three mutations were applied and the suites re-run:
neutering the hello's login guard reddened exactly the three 4004 rows and
left the identity/mismatch/ungated rows green; neutering the Layer 3 filter
reddened both gated discovery tests and left the ungated one green; making the
overlay keep a stale login reddened only clearsStaleLoginsItDoesNotOwn. 13
issues across 3 suites. All three files were then restored and checked back
against their pre-mutation SHA-256.

No interop fixture was added or changed, so the Rust side needs no mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
RFC 025 is the normative text for the grammar, the Layer 3 filter and the
hello table; §8.1.2 records only what the Swift surfaces are and where they
live, and points at RFC 025 for the rest. RFC 025 itself is untouched here —
it lands from the Rust branch.

- §8.1 item 5's close-code list is corrected AT ITS SOURCE rather than only
  downstream: 4004 is added, and 4003 now reads "or no authenticated identity
  at all", which is what a gated node does under either IdentityPolicy.
- §8.1.2 names LoginGlob and its two entry points, the MeshConfiguration
  declaration, every surface the login appears on (including that
  Peer.loginName is part of Peer's equality so a SwiftUI row re-renders), the
  Layer 3 predicate and why provisional entries are exempt, the ordered §3.4
  table with its close codes and thrown errors, and what is unchanged for an
  ungated node.
- It closes by pointing at RFC 025 §3.3's dated correction for the one place
  TailscaleKit's binding could not supply what the RFC specified, and records
  that the status-login overlay collapses back into the decoder when
  IpnState.PeerStatus grows UserID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Four tests asked for on review, covering the gap between "the status names a
UserID" and "we know who that is". The overlay resolves a row's UserID through
the status's User{} map; a UserID the map does not describe must therefore
resolve to NOTHING, on both the peer and the self side.

- unresolvableUserIdYieldsNoLoginRatherThanEmptyString: the `nORPHAN` row names
  user 55555, which User{} has no profile for. Its login comes out nil — and is
  explicitly asserted not to be "" — while the resolvable row beside it still
  carries its login, so the nil is the missing profile and not a wholesale
  decode failure.
- aGatedNodeDropsAnUnresolvableOwnersRow asserts the consequence at the seam
  where the overlay and the gate meet, using the exact predicate
  MeshNode.upsertFromLayer3 applies. Of four rows — a UserID with no profile, a
  row with no UserID at all, a real login on the wrong domain, and a matching
  login — only the last survives the gate.
- unresolvableSelfUserIdLeavesTheSelfLoginNil: the same rule for the node's own
  login, so BackendStatus.loginName and therefore MeshNode.loginName come out
  nil rather than "".
- aPeerlessStatusStillResolvesTheSelfLogin records an upstream fact for whoever
  is tempted to read the lighter endpoint: on tailscale 1.102.3
  StatusWithoutPeers (status?peers=false) deliberately keeps the self user's
  profile in User{} (tailscale/tailscale#19894). So an empty Peer{} means "no
  peers were asked for", never "unknown owner". The overlay reads the FULL
  status because the peers' logins need it; this test guards the self half if
  that ever changes.

Verified not vacuous: mutating LoginOverlay.applied(to:) to fabricate a login
for a row it has none for — the exact bug the "absent, never fabricated" rule
forbids — turned five rows red, with the gate test reporting
admitted ["nORPHAN", "nNOUSER", "nBOB"] against the expected ["nBOB"]. The file
was then restored and checked back against its pre-mutation SHA-256.

Gates after: swift test --package-path apple 95 -> 99 tests in 17 suites,
exit 0 · root "as published" resolve + build + test likewise 99/17 ·
xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both
generic/platform=iOS Simulator and generic/platform=iOS.

Note for anyone re-running these: the two xcodebuild destinations must run
SERIALLY. Run concurrently they share one DerivedData and the second dies with
"unable to attach DB ... database is locked", which reads as a build failure
and is only contention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
On review: RFC 025 §3.3 is normative for the RULE — the login is a Layer 3
fact, and a gated node treats a row without one as not a peer — but not for the
mechanism, which on the Apple plane could not be what it first described. The
first draft of §8.1.2 pointed at RFC 025's dated correction for the mechanism
too, which leaves this plane's actual behaviour readable only by chasing
another document. It is now written out here, and RFC 025 is cited only for the
rule.

Now stated in §8.1.2:
- Why the specified mapping is not expressible: the pinned TailscaleKit decodes
  IpnState.PeerStatus without UserID, Status.SelfStatus is a PeerStatus too, and
  Status.User exists keyed by the stringified user id with nothing to key it.
- The exact read: one additional authenticated GET of the FULL LocalAPI status,
  /localapi/v0/status, over the same loopback the WhoIs path uses, decoding only
  Self.UserID, Peer[].{ID,UserID} and User{} — with the note that Peer is keyed
  by node key while each row's own ID is the stable node ID BackendPeer uses.
- The cost, said plainly: one extra loopback GET per status refresh, and a
  refresh runs on every IPN bus notify.
- Which endpoint and why the full one: the lighter status?peers=false would
  still resolve the SELF login, because on tailscale 1.102.3 StatusWithoutPeers
  keeps the self user's profile in User{} (tailscale/tailscale#19894). So an
  empty Peer{} means "no peers were asked for", never "unknown owner" — a
  distinction that only matters if this moves to the lighter endpoint, and one
  the new tests guard.
- That an unresolvable UserID, a row with no UserID, and a failed overlay read
  all resolve identically: the login is absent, never fabricated and never "",
  a gated node admits nobody, and the one-shot .health notice keeps the emptied
  mesh from being silent.
- The future-work line: when TailscaleKit's PeerStatus decodes UserID, the
  overlay collapses into the decoder and the extra GET goes away.
- Why the decoders sit outside TailscaleKitBackend's
  `#if os(iOS) && canImport(TailscaleKit)` — that guard compiles to nothing on
  the macOS host, so decoding placed inside it would have no test anywhere.

Docs only; no code changed. Gates re-run all the same: 99 tests in 17 suites on
both manifests, both iOS slices BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
HIGH-1 from review, and a real hole in the gate I shipped. I wrote
upsertFromLayer3's existing-entry branch to merge Layer 3 metadata and return
BEFORE both guards, and documented that as deliberate — "that hello already
passed the gate". It is wrong for the case that matters: a stable node ID
survives a device transfer, so a node that RE-SIGNS under a different login
arrives as an UPDATE to the existing row, not as a new one. The gate therefore
ran only at entry creation. The reviewer proved it live: gated Alice admits Bob
as bob@corp.com, Bob re-signs as mallory@evil.com, and Alice keeps him, dials
him, and accepts his frames.

RFC 025 §3.3's rule, as amended: a row whose login the gate REFUSES is a
departure. The entry is evicted, its session closed, and peerLeft emitted —
exactly as if the peer had left the tailnet. A later row that passes readmits
it as a NEW generation, because a rejoin is never the same row (RFC 022 §7.7).

- upsertFromLayer3 now returns whether the row must be evicted. It is sync and
  removeEntry is async, so both callers do the eviction: apply(status:)'s peer
  loop and the .peerUpsert push path. removeEntry already closes the session
  and emits peerLeft, so a refused login produces exactly the events a
  departure does.
- An ABSENT login on an existing row stays STICKY rather than evicting: a
  transient failure to read the logins must not empty a gated mesh. Only a
  login that is present and refused is a departure.
- Provisional entries are not exempt. They still merge, but they are evicted on
  a refused login like any other row — a raced hello is not a permanent pass.
- Ungated nodes are unaffected: LoginGlob.allowed([], login:) is always true,
  so nothing is ever evicted without a gate.

Also LOW from the same review: adoptInbound now carries the WhoIs loginName
that PASSED the hello gate onto the provisional entry, which knew it and
discarded it. confirm(identity:tailscaleId:loginName:) never overwrites a known
login with nothing.

MEDIUM-1 lands here too, because it is the same file. `MeshNode.whoIs(
remoteEndpoint:)` is new and public: a passthrough to the backend's WhoIs, so an
app that opens its own port with `listen(port:)` has an identity to gate on. The
raw plane is deliberately NOT gated by loginAllow (RFC 025 §3.7, D9) — the
node's list admits Layer 3 peers and session-plane hellos, and the app owns
admission on its own port. Until now `MeshAcceptedConnection` carried only
`remoteEndpoint` and there was no way to resolve it, so RFC 025 §3.7's
assumption that "the app reads the connection's identity" had nothing to read.

The MeshNode half of MEDIUM-2 is here as well: establishSession no longer echoes
a 4002 at a remote that already closed with its own code. The Handshake half is
the next commit.

Tested by the following commit, and verified against the ORIGINAL bug: reverting
this branch's guard reproduces the reviewer's scenario exactly — no peerLeft,
the peer retained, send does NOT throw, and the generation unchanged at 1.

Gates: swift test --package-path apple 99 -> 104 tests in 17 suites, exit 0 ·
root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale
BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
MEDIUM-2 from review, Handshake half. `receiveHello` turned every close frame
into `protocolViolation`, so a 4004 the gate sent arrived at the dialing side
indistinguishable from a dead socket — `loginRefused` was only ever thrown in
the SERVER role, by the side that ran the gate. A dialer could not tell "this
node refuses my login" from "the connection broke", which is exactly the
distinction a caller needs in order to stop retrying.

Mirroring the Rust core:

- `MeshError.helloRefused(code: UInt16, reason: String)` is the dialing side's
  view of a refusal, carrying the code the remote actually sent: 4001 app
  mismatch, 4002 hello protocol, 4003 identity, 4004 login refused.
- `receiveHello` raises it for ANY application close (4000–4999) received before
  the hello. Codes outside that range stay `protocolViolation`, since they are
  transport-level closures and not a peer's decision.
- `MeshError.loginRefused(login:)` is unchanged and remains the SERVER-role
  error — the two are different viewpoints on the same event, and both are
  wanted.
- Neither role echoes a 4002 back at a refusal. The socket is already closed
  from the far end, so the echo does nothing except bury the reason in the logs
  of whoever reads them next. `Handshake.isRefusal` is the single predicate both
  roles and `MeshNode.establishSession` use.

Verified against the pre-fix behaviour: disabling the 4000–4999 classification
reproduces the reported symptom exactly — the dialer receives
`protocolViolation("peer closed connection before hello (code 4004: login
refused)")` instead of `helloRefused(code: 4004, reason: "login refused")`.

Gates: swift test --package-path apple 104 tests in 17 suites, exit 0 · root
"as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Five tests for the review findings, each written to fail against the code as it
was rather than to describe the code as it now is.

- aPeerThatResignsAsAForeignLoginIsEvicted is the HIGH-1 scenario end to end:
  gated Alice admits Bob at bob@corp.com and establishes a real session;
  setLogin re-signs the SAME stable node ID as mallory@evil.com; Alice must emit
  peerLeft, drop the row, and stop resolving the snapshot taken while he was
  admitted. Then the reverse — re-signing back onto the allow-list readmits him
  at a NEW generation, which is what distinguishes a rejoin from a row that
  never left.
- anAbsentLoginOnAnAdmittedRowIsSticky pins the other half of the rule, so the
  fix cannot be over-applied: a row that loses its login keeps its place, its
  generation, and the login it was admitted with.
- aRefusedDialerSeesTheCloseCodeNotAProtocolViolation is MEDIUM-2 from the
  dialing side: the error is helloRefused(code: 4004, reason: "login refused"),
  code and reason both.
- whoIsResolvesAnAcceptedAddressOnTheRawPlane is MEDIUM-1: MeshNode.whoIs
  returns the authenticated login and display name for an accepted address, and
  the test gates on it with LoginGlob.allowed both ways, which is the sequence
  an app on its own port would write.
- aProvisionalEntryCarriesTheLoginThatPassedTheGate is LOW, using a hidden node
  — WhoIs-resolvable and able to dial, absent from snapshots — so the entry can
  only have come from the hello. Its hostname is empty and its login is the one
  WhoIs supplied.

Verified against the ORIGINAL defects, not merely green. Reverting the eviction
guard reproduces the reviewer's scenario exactly: no peerLeft (departed → nil),
the peer retained, send does NOT throw, and the generation unchanged at 1.
Disabling the 4000–4999 close classification yields
protocolViolation("peer closed connection before hello (code 4004: login
refused)") in place of helloRefused. Six issues across the suite; both source
files were then restored and checked back against their pre-mutation SHA-256.

Gates: swift test --package-path apple 99 -> 104 tests in 17 suites, exit 0 ·
root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for generic/platform=iOS Simulator and
generic/platform=iOS, run SERIALLY — concurrently they share one DerivedData
and the second dies with "unable to attach DB ... database is locked", which
reads as a build failure and is only contention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…not gated

Three corrections to §8.1.2 from the review, each at its source.

- The Layer 3 bullet said provisional entries were the exception and left the
  impression the predicate runs at entry creation. It now states that the
  predicate runs on creation AND on every later row, explains WHY (a stable node
  ID survives a device transfer, so a re-signed node arrives as an update), and
  gives the rule: a refused login is a departure — evicted, session closed,
  peerLeft emitted, readmitted later as a new generation. The sticky-on-absent
  half is stated beside it so the rule cannot be over-read, and provisional
  entries are described as carrying the login they passed the gate with rather
  than as exempt from it.
- A new bullet records that the dialing side distinguishes a refusal from a
  broken pipe via MeshError.helloRefused(code:reason:), that neither role echoes
  a 4002 at an already-closed socket, and that loginRefused(login:) remains the
  server-role error.
- A new bullet records that the raw plane is NOT gated by loginAllow (RFC 025
  §3.7, D9) and how an app gates an accepted connection itself with
  MeshNode.whoIs(remoteEndpoint:) — including that an empty tailscaleId in the
  answer means WhoIs produced no concrete identity and must be treated as
  untrusted.

Docs only; no code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…icate

Review of the 29d4d4d delta: aGatedNodeDropsAnUnresolvableOwnersRow (211e711)
re-implemented the gate inline — merged.peers.filter { LoginGlob.allowed(gate,
login:) } — and never touched MeshNode. So it asserted my construction against
my construction: it would pass with the node's gate guard deleted, and it
silently dropped the isAppPeer half that sits beside the login check. That is a
test of the grammar wearing the name of a test of the node.

- NodeLoginGateTests.aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves is the real
  witness: a live MeshNode over a LoopbackNetwork applying its OWN predicate to
  five rows — a UserID with no profile, a row with no UserID, a resolvable owner
  on the wrong domain, an ALLOWED login on a node that is not an app peer, and
  one row passing both halves. Only the last is admitted.
  The stranger row is the half the inline predicate would have let through; the
  two absent-login rows are kept separate because they are separate nodes, not
  because the node can tell them apart — the overlay renders both as no login.
- The overlay-level row stays, renamed to
  onlyAResolvableAllowedOwnerYieldsAnAcceptedLogin, with a comment that says
  what it actually does: checks the overlay's OUTPUT against the gate's grammar,
  cannot witness the node applying it, and names the test that can. Narrower
  than its old name claimed, and still worth pinning.

Verified as the reviewer asked: deleting the creation-time guard
(MeshNode.swift, `guard LoginGlob.allowed(config.loginAllow, login:)`) turns the
new row red with exactly the right diagnostic —
admitted ["ts-bob", "ts-mallory", "ts-nouser", "ts-orphan"] against the expected
["ts-bob"] — while ts-stranger stays out, so the row isolates the login half
cleanly. The overlay-level test stayed GREEN under that same deletion, which is
the blindness the review found, now stated in its own comment.
gatedNodeTreatsLoginlessRowAsNotAPeer and foreignLoginIsNeitherListedNorAdmitted
also reddened; aPeerThatResignsAsAForeignLoginIsEvicted correctly did not, since
it exercises the existing-entry eviction path and not the creation guard.
MeshNode.swift was then restored and checked back against its pre-mutation
SHA-256.

Gates: swift test --package-path apple 104 -> 105 tests in 17 suites, exit 0 ·
root "as published" likewise 105/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations, run serially.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…ialed

RFC 025 §3.3 as refined (e34e838): the sticky last-known login was wrong in
both directions, and this fixes both.

REPORTING. upsertFromLayer3's existing-entry branch kept the previous login
when a row carried none (`peer.loginName ?? existing.loginName`). That made the
node assert an owner the current netmap does not name — the absent-never-
fabricated rule (RFC 022) applies to a login that can no longer be SOURCED just
as much as to one never had. The row's login now passes through as-is, so it
reads nil. The entry itself is still KEPT: that is the part the stickiness was
protecting, and it is protected directly instead, so a transient overlay failure
still cannot empty a gated mesh. `confirm` is unchanged and still restores a
login WhoIs authenticated, so an inbound hello re-attributes a peer Layer 3 has
gone quiet about.

DIALING. A gated node now opens no NEW connection to a kept peer whose login is
nil: send / sendBytes / sendJSON / confirmIdentity (via `session(for:)`) and the
raw `dial(to:port:)` throw the new `MeshError.loginUnknown(peer:)` instead. We
would otherwise be opening a connection to someone we cannot attribute, while
the inbound gate already refuses that same peer's fresh hello (WhoIs with no
login → 4004) — both directions now agree.

The rule is about OPENING, never tearing down:
- an EXISTING session stands, so a momentary gap in the logins cannot flap a
  live connection;
- a dial already in flight under a known login is JOINED, not re-judged — the
  guard sits after the in-flight check, so it only ever stops a dial STARTING;
- an ungated node ignores the field entirely (`config.loginAllow.isEmpty` short-
  circuits before the login is ever consulted).

`MeshError.loginUnknown(peer: String)` carries the peer's ref description, like
`peerGone`. The spelling matches what the VibeField consumers were briefed to
expect.

Tested by the following commit. Verified by DELETING both `requireKnownLogin`
call sites rather than mutating the logic: that reddens exactly the three dial
assertions (send, raw dial, confirmIdentity) and leaves the reporting test and
the live-session assertion green, which is the attribution I wanted.

Gates: swift test --package-path apple 105 -> 107 tests in 17 suites, exit 0 ·
root "as published" likewise 107/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations, run serially.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
Three rows for RFC 025 §3.3's refinement, each written to fail against the
behaviour it replaces.

- anAbsentLoginIsKeptAsAPeerButReportedAbsent is the inverse of the test it
  replaces (anAbsentLoginOnAnAdmittedRowIsSticky). It asserts BOTH halves,
  because either alone would pass under a wrong fix: the entry survives and
  keeps its generation, AND its loginName goes nil rather than holding the
  last-known value. A later resolvable row restores the login in the SAME
  generation, which is what separates "went quiet" from "left and rejoined".
- aGatedNodeWillNotOpenASessionToAnUnattributablePeer uses two peers on purpose.
  Bob has an OPEN session when his login goes absent; Carol is admitted with no
  session. Bob's session must keep delivering — asserted with a real message
  through a real subscription, because a fix that tore down the live session
  would otherwise pass every other assertion here. Carol's send, raw dial and
  confirmIdentity must each throw loginUnknown(peer:). Restoring her login makes
  her dialable again.
- anUngatedNodeDialsAPeerWithNoLogin pins the short circuit: without a gate an
  absent login is simply a fact about a peer, never a refusal.

Verified by DELETION, not mutation. Removing both requireKnownLogin call sites
— the call sites, not the predicate — reddens exactly three assertions, all in
the dial test: send throws nothing, the raw dial reports
dialFailed("connection refused: 100.64.0.3:9500") instead of
loginUnknown(peer: "ts-c:2"), and confirmIdentity throws nothing. The reporting
test stays green under that deletion, and the live-session assertion stays green
too, which is the attribution I wanted: each half of the rule has its own
witness. Separately restoring the sticky `?? existing.loginName` reddens the
reporting test alone (kept?.loginName → "bob@corp.com" against nil).
MeshNode.swift was restored after each run and checked back against its
pre-mutation SHA-256.

Gates: swift test --package-path apple 105 -> 107 tests in 17 suites, exit 0 ·
root "as published" likewise 107/17 · xcodebuild -scheme TruffleTailscale BUILD
SUCCEEDED with zero warnings for both iOS destinations, run serially.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
…not dialed

RFC 025 §3.3's refinement, recorded on the Swift plane. Cites §3.3 for the rule.

- The sticky sentence is gone. §8.1.2 now says a row that names no owner is
  neither an eviction nor a sticky keep: the entry stays (so a transient failure
  to read the logins cannot empty a gated mesh) and the login is reported
  absent, with the reason — RFC 022's absent-never-fabricated rule applies to a
  login that can no longer be sourced as much as to one never had.
- A new bullet states the dial-side half: send / sendBytes / sendJSON /
  confirmIdentity / raw dial throw MeshError.loginUnknown(peer:) on a gated
  node; an existing session STANDS and an in-flight dial is joined rather than
  re-judged, because the rule is about opening and not about tearing down; an
  ungated node ignores the field. It notes that the inbound gate already refuses
  such a peer's fresh hello with 4004, so both directions agree.
- The provisional-entry sentence gains the fact that `confirm` restores a login
  WhoIs authenticated even after Layer 3 has stopped naming one.

Docs only; no code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW
@jamesyong-42
jamesyong-42 merged commit c96576c into main Sep 16, 2026
13 of 14 checks passed
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