Skip to content

fix(bus): dial replies over a candidate set, drop scopeless link-local (#61) - #64

Merged
hartsock merged 1 commit into
docs/floating-identityfrom
fix/61-floating-reply-candidates
Jul 15, 2026
Merged

fix(bus): dial replies over a candidate set, drop scopeless link-local (#61)#64
hartsock merged 1 commit into
docs/floating-identityfrom
fix/61-floating-reply-candidates

Conversation

@hartsock

Copy link
Copy Markdown
Member

What this PR does

Fixes the scopeless link-local dial-back bug (#61) by retiring the
single-address reply route from #21 in favour of fingerprint-keyed
candidate resolution — the shape docs/decisions/floating_identity.md
mandates (locations are candidates, never load-bearing).

  • reply_dial_candidates(source) — builds the reply dial set: the recv
    source path (its recvmsg scope id already intact) plus same-host
    v4/v6 loopback at the source port. Never a single load-bearing address;
    never empty for a same-host peer. dial_reply_peer now dials this set
    (iroh races the paths) instead of endpoint.dial(pubkey, [one_addr]).
  • is_scopeless_link_local(addr)fe80::/10 with scope_id == 0.
    Such an address is undialable and, left in an iroh dial set, fails
    sendmsg with InvalidInput, aborting the whole race. Filtered out at
    both dial sites (the reply path and the mDNS dial_peer set), so
    the scopeless fe80:: that mDNS surfaces can no longer poison the dial.
    Hand-rolled because Ipv6Addr::is_unicast_link_local is unstable on
    our MSRV (1.75).

Why a quiet-bind peer was affected: announce:false makes it
mDNS-invisible, so dial-back was its only route — the single scopeless
address was a true single point of failure. Loopback keeps the set
non-empty, so its identity stays reachable same-host.

Spike that de-risked the fix

Traced iroh 0.98.2: an inbound datagram's source scope id is
preserved end-to-end — noq-udp decode_recv reads sin6_scope_id
(unix.rs), MultipathMappedAddr::Ip keeps the full SocketAddr, and
Incoming::remote_addr() returns it verbatim. So the recv source path is
dialable as-is; the scopeless fe80:: poison enters via mDNS
records (which carry no scope), which is why the filter lives at the
candidate-set layer, not the recv-capture layer. No upstream iroh change
needed.

Test plan

  • New pure, deterministic unit tests in agent-mesh-bus/src/bus.rs
    (gate every PR):
    • scopeless_link_local_source_dropped_loopback_survives — the bus: reply dial-back to a scopeless link-local IPv6 source fails; quiet-bind clients become unreachable #61
      regression: scopeless fe80:: source → set drops it, keeps both
      loopbacks, never empty. Verified RED against a single-address stub
      before the fix.
    • scoped_link_local_source_is_kept — a fe80::%N source (real scope)
      stays a candidate.
    • routable_source_yields_source_plus_loopback — source + both
      loopbacks; length ≥ 2 (never one address). Uses RFC 5737 TEST-NET-1.
    • loopback_source_not_duplicated — dedup.
    • scopeless_link_local_predicate_boundariesfe80::/10 edges,
      scoped vs scopeless, loopback/global/IPv4/fec0:: negatives.
  • just check clean: cargo fmt --check, clippy --workspace --all-targets -D warnings (zero warnings), cargo test --workspace
    (all suites green; agent-mesh-bus lib 54 passed).
  • just cov-ci passes the 75% floor (line coverage clears it even with
    the excluded pyo3 modules counted).

Out of scope

  • Real dial-back-over-iroh integration proof. Stays #[ignore]d on
    the real-LAN tier (per the repo's existing convention that real-mDNS
    round-trips are flaky on hosted CI); the deterministic unit tests above
    are the per-PR gate.
  • Downstream test tiering. newt-mesh's
    quiet_client_gets_replies_via_dial_back is being split into a mocked
    per-PR unit tier + a live iroh smoke lane under newt-agent#1190;
    this fix greens that smoke lane at the source. Separate repo, separate
    PR.
  • Cross-host scopeless link-local. Genuinely unroutable without an
    interface; cross-host peers announce routable addrs over mDNS, so this
    is not a regression.

Fixes #61. Supersedes the #62 WIP handoff (close #62 when this lands).

Stacked on docs/floating-identity (the doctrine this implements);
retarget to main once that base merges.

#61)

Retire the single-address reply dial-back introduced in #21:
`dial_reply_peer` dialed the request's one source `SocketAddr`, so a
scopeless link-local IPv6 (`fe80::` with no scope id — what mDNS surfaces
on CI and most Linux hosts) became an undialable single point of failure.
QUIC `sendmsg` returned `InvalidInput`; a quiet-bind (`announce:false`)
peer, being mDNS-invisible, had dial-back as its only route, so its reply
was lost.

Per the floating-identity doctrine (docs/decisions/floating_identity.md):
a location is only ever a candidate, never load-bearing.

- `reply_dial_candidates`: the source path (its recvmsg scope id already
  intact — verified preserved through iroh's noq-udp fork) plus same-host
  loopback at the source port. Never one address, never empty.
- `is_scopeless_link_local`: hand-rolled `fe80::/10` + `scope_id == 0`
  test, because std's `is_unicast_link_local` is unstable on MSRV 1.75.
  Applied to BOTH dial sites — the reply path and the mDNS `dial_peer`
  candidate set — so a scopeless `fe80::` can no longer `sendmsg`-abort
  the iroh dial race (the racy full-suite failure seen while diagnosing
  the bug).

Regression tests are pure and deterministic, so they gate every PR; the
real dial-back-over-iroh proof stays #[ignore]d on the real-LAN tier.

Supersedes the #62 WIP handoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hartsock hartsock added the risk:low Low-risk change: scoped, tested, no CI/build/hook edits label Jul 15, 2026
@hartsock
hartsock merged commit cac0a10 into docs/floating-identity Jul 15, 2026
3 checks passed
hartsock added a commit that referenced this pull request Jul 19, 2026
…ad-bearing (#63)

* docs: floating-identity doctrine — locations are candidates, never load-bearing

Elevates Shawn's PR #62 review from thread prose to binding design law:
identity (fingerprint) is the routing key; socket addresses are only ever
candidate paths; a quiet peer must never resolve to zero paths;
conversations outlive locations. Includes the #61 dial-back case study
(adding candidates fixed, removing the only path regressed — the bug is the
SINGULAR address, so dial_reply_peer(pubkey, [one addr]) is the shape to
retire) and the reviewer checklist + ratchet direction (harnesses fungible,
conversational context durable — the container/cloud playbook one layer up).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bus): dial replies over a candidate set, drop scopeless link-local (#61) (#64)

Retire the single-address reply dial-back introduced in #21:
`dial_reply_peer` dialed the request's one source `SocketAddr`, so a
scopeless link-local IPv6 (`fe80::` with no scope id — what mDNS surfaces
on CI and most Linux hosts) became an undialable single point of failure.
QUIC `sendmsg` returned `InvalidInput`; a quiet-bind (`announce:false`)
peer, being mDNS-invisible, had dial-back as its only route, so its reply
was lost.

Per the floating-identity doctrine (docs/decisions/floating_identity.md):
a location is only ever a candidate, never load-bearing.

- `reply_dial_candidates`: the source path (its recvmsg scope id already
  intact — verified preserved through iroh's noq-udp fork) plus same-host
  loopback at the source port. Never one address, never empty.
- `is_scopeless_link_local`: hand-rolled `fe80::/10` + `scope_id == 0`
  test, because std's `is_unicast_link_local` is unstable on MSRV 1.75.
  Applied to BOTH dial sites — the reply path and the mDNS `dial_peer`
  candidate set — so a scopeless `fe80::` can no longer `sendmsg`-abort
  the iroh dial race (the racy full-suite failure seen while diagnosing
  the bug).

Regression tests are pure and deterministic, so they gate every PR; the
real dial-back-over-iroh proof stays #[ignore]d on the real-LAN tier.

Supersedes the #62 WIP handoff.

Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(floating-identity): trim thesis prose, tighten throughout

Cut the 20-year-playbook framing + blockquote and the "commodity intelligence" flourish; compress each section to its load-bearing point. The four design laws are unchanged in substance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(floating-identity): law 5 — first contact is a ceremony; cite prior art

Add the one gap the Zero-Trust research pass found: keyless first contact is a TOFU leap of faith (RFC 7401's own admission). The pin decision is exposed as data and rendered by the consumer (#65). Add a terse prior-art section (Saltzer RFC 1498, HIP, NIST 800-207, SPIFFE, iroh) so reviewers cite the canon instead of rearguing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:low Low-risk change: scoped, tested, no CI/build/hook edits

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant