Addressed the review finding on impl-qxqwpi5f52as35cwcxtjuudf: the claimed-DID bootstrap could not run on the real inbound connection path, because PrivateEndpoint.#adopt closed an unnamed inbound link on the accept — and both iroh bindings call onLink the moment they accept a connection, so the dialer's catch-up frame (the one carrying from) was destroyed along with the round trip it travelled in. The branch now carries the predecessor's commit rebased onto main, plus this fix.
What changed #
core/src/private/endpoint.ts— an inbound connection no attached space names is held for a bounded grace (DEFAULT_UNKNOWN_LINK_GRACE_MS30 s,MAX_UNKNOWN_LINKS8) instead of closed. Nothing is served over it: the bus refuses every frame against the same directory as before, so the answer a stranger gets is unchanged. It is adopted if the directory catches up — on the next frame from that peer, when a space attaches under it, or in a last look at expiry — and closed otherwise. Past the bound, a new stranger is closed on the accept as before.- Adoption of a held link is
onlyWhenIdle.PeerConnections.adoptreplaces deliberately (a peer that redialled has said which connection it believes it has), and that rule is wrong for a link that has been waiting: it is older than anything adopted since, so promoting it would close a live connection in favour of a socket the peer may already have hung up on. Fresh connections still replace; held ones only fill a gap. ingest/src/private.ts— the verification a claim buys is tightened and named: a DID is kept only when its own repo binds the claimed endpoint, asked throughconnectablePeersrather thanauthorizeEndpoint(which answers with the first device it finds for an endpoint, and a daemon running two identities publishes two addresses for one). Bounds are named constants, and the per-endpoint cooldown table is now bounded — its keys are a stranger's to invent.- Docs — ADR §30 records the decision and its residuals (the deadlock chain, the claim's worth, the grace, the hint disclosure, what is left open);
docs/design.mdgains the wire's optionalfromand the held-connection rule;CLAUDE.mdandpackages/daemon/README.mdfollow.
Why the review's test gap existed, and what closes it #
Both loopback doubles were one-directional — an accepter could hang up while the dialer went on being served — so any accept-side lifecycle decision reported as working. Both now share one open flag across a connection's two ends. Every new regression starts from the honest state, a replica that has never heard of the peer about to dial it:
packages/core/test/private-endpoint.test.mjs(new) — held and refused with the claim captured; adopted on the next frame once the directory names it, without a redial; adopted when its space attaches under it; closed when the grace expires; bounded at 8; newest connection per endpoint wins; all closed on shutdown.packages/daemon/test/private-transport.test.mjs— end to end: founder seeds and leaves, an agent daemon holds the corpus, and a fresh replica of the founder's own identity (new data dir, new endpoint, polling only its own repo) converges in one cycle after the daemon dials it. Reverting only the hold makes this and two neighbours fail.packages/ui/src/lib/private-space.test.ts— the reported scenario in a browser: a tab that has never held the space, whose bootstrap can name nobody but the founder and itself, fills from a daemon after that daemon dials in. Without the hold it fails withSpace record not found— the reported symptom.packages/ingest/test/private.test.mjs— verify-then-keep: one poll per claim, a claim another author's records would have "verified" is forgotten, a redialling stranger buys no second poll, and a crowd of claims is capped per cycle.
Provenance and checks #
- Branch
radial/impl-85e167a3a5ee, rebased ontoorigin/main(7bab53f); commit4ce0251958f30060e2839fe9712c2a714e24ab85. pnpm lint,pnpm typecheck,pnpm testall pass (core 392, ingest 51, sidecar 110, daemon 589, ui 77 + 709 vitest, atproto 35, transports 19).- No lexicon change, no fold rule, no protocol version bump: everything here is a connection statement,
admission.tsanddevices.tsare untouched, andmaterialize()and the digest are unchanged.
Remaining risks #
- Unverified against a real relay. Every test runs over
loopbackLink, by design; the ADR §9 manual observation (daemon up, a browser profile that has never held the replica) has not been repeated in this container. - A stranger can hold up to 8 connections for 30 s each. Bounded and serving nothing, but it is a larger idle surface than closing on the accept was; the claim it exists for costs at most four public repo reads per cycle with a per-endpoint cooldown.
- First-open latency is bounded by the daemon's directory-poll interval — it must see the browser's device before it dials. A tuning question, not a correctness one.
- Hints in a public
joinbookmark name the operator's agent DIDs (from the predecessor's commit, flagged in the plan). The dial-back path converges without them, one poll slower, for anyone who leaves them out.