You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Basis: the WIT (wit/iroh.wit), its implementation (endpoint/src/endpoint_impl.rs), the sibling packages, issues #3/#6/#9/#10/#12, and a verified survey of iroh 1.0.3 (note: upstream renamed heavily for 1.0 — quinn is replaced by noq with multipath + QNT built in, NodeId→EndpointId, discovery→"address lookup", and the transports layer is iroh/src/socket/transports with an unstable public CustomTransport API).
Overall: the surface is small, honest, and unusually well-documented; the auth semantics (peer() states exactly what is proven, identity-mismatch connections are never surfaced — endpoint_impl.rs:558) are right and better-stated than upstream's docs. The shape connect(endpoint-addr, alpn) / accept() / two-level stream accept maps cleanly onto iroh 1.0's Endpoint. The findings below are ordered by how much they'd hurt.
A. Byte-stream integrity holes (highest severity)
Reset is unobservable on the streaming read path.read-via-stream: func() -> result<stream<u8>, error> (iroh.wit:272) returns a bare stream<u8>. A CM stream end carries no error: a peer RESET_STREAM is indistinguishable from a clean FIN. The implementation confirms it — endpoint_impl.rs:1509 while let Ok(Some(bytes)) ends the stream on reset, silently. wasi:sockets 0.3 solved exactly this with receive() -> tuple<stream<u8>, future<result<_, error-code>>> (sockets.wit:378). Adopt that shape or delete the method; as specified it invites silent truncation.
Connection close is surfaced as clean FIN. endpoint_impl.rs:1414-1417 maps Error::Closed to Ok(None) in read. A connection that dies (even gracefully) before a stream's FIN truncates that stream; quinn/noq surface ReadError::ConnectionLost precisely so applications can tell. write resolves at acceptance, not delivery (iroh.wit:243), so "peer wrote, closed, receiver got EOF" can lose an arbitrary tail with no error anywhere. This contradicts the WIT's own error.closed text ("closed before the operation completed"). One of the two — the doc or the mapping — is wrong; the mapping is.
Reset codes are strings.reset(string) "decimal-rendered" (iroh.wit:97) while the same doc says "do not match on their contents." Together those two sentences delete the application reset code from the interface. Upstream: ReadError::Reset(VarInt). Make it reset(u64). Relatedly, close/reset/stop take u32 codes but QUIC codes are u62 — an upstream peer can send codes this surface can neither receive faithfully nor emit. And wait-closed: async func() (iroh.wit:235) returns nothing: the peer's application close code/reason (which iroh's closed() -> ConnectionError delivers, and protocols do dispatch on) is unobservable.
B. Component-model concurrency model
The design's CM posture is fundamentally sound — async funcs for blocking ops, sync close + latched wait-closed, backpressured write through the async ABI, one-shot stream taps — and it's proven against real CM async runtimes (wasmtime matrix green; jco blocked on upstream scheduler defects #6/#10, correctly diagnosed as host bugs, not contract bugs). Remaining holes:
Concurrent same-stream operations are unspecified, and the implementation corrupts. The CM permits concurrent method calls on one resource handle (each call is its own task; that's the mechanism accept's documented concurrency relies on). Two concurrent send-stream.write calls interleave via write_all's flow-control quanta (endpoint_impl.rs:1370-1402) — byte-level interleaving, i.e. corruption for any framed protocol; two concurrent reads dequeue chunks in arbitrary alternation. quinn forbids this with &mut self; WIT has no exclusive borrow, so the contract must say it: either "a second in-flight op fails/queues" or an explicit hazard warning. The siblings' "concurrent calls supported, implementation-defined order" wording is correct for message transports and wrong for byte streams — don't inherit it.
Cancellation semantics are unstated everywhere. Every async import call is a cancellable subtask (subtask.cancel is real; jco even traps on it, jco-transpile 0.5.2 traps on subtask.cancel: guests must resolve in-flight imports before returning #6). Per-operation post-conditions needed: cancelled write — is a prefix committed, and can the caller learn how much? (No; only reset recovers the stream.) Cancelled connect — does the attempt continue? (Implementation: the ConnEntry leaks forever and the peer sees a live connection nobody owns — endpoint_impl.rs:1187's waiter is the only thing that ever surfaces it.) Cancelled accept / write-via-stream? quinn documents cancel-safety per method; this interface, whose ABI makes cancellation more reachable than Rust drop, documents none.
write-via-stream's one-shot rule is not enforced and its interactions are unspecified. The WIT says "after this call the resource accepts no further writes" (iroh.wit:253), but SendStreamRes has no guard (contrast RecvStreamRes.streaming, endpoint_impl.rs:976): a subsequent write happily interleaves with the streamed payload. Divergence-with-no-artifact by your own AGENTS.md standard. Also unspecified: finish/reset while a write-via-stream is in flight, and what happens when the caller drops the data stream mid-way (implementation: breaks the loop and FINs — turning a producer crash into a clean-looking EOF for the remote peer; arguably it should reset).
No backpressure on accept. Handshakes complete and queue unboundedly whether or not anyone calls accept (endpoint_impl.rs:84, 552). The CM gives you task.backpressure, and upstream gives Incoming::refuse/retry/ignore plus incoming filters, for exactly this. As specified, any peer that can reach your relay can make you hold unbounded fully-handshaked connections. Related contract gap: accept doc doesn't say connections handshake before an acceptor exists (observable: peer's connect resolves while server app is busy).
Poll-only observation is an anti-idiom here.state()/path() are snapshot funcs; path flaps (webrtc→relay→webrtc) between polls are unobservable, and TOCTOU is inherent. Your own siblings established the coalescing-watch stream (state-changes: func() -> stream<...>, webrtc.wit:334) — and endpoint-demo already busy-polls path() to detect the upgrade. Add path-changes/state-changes. This also replaces cross-component polling (a full canonical-ABI call per sample, on top of the implementation's 5ms quanta) with demand-driven reads.
One genuinely good CM call worth recording as a ruling: the accept-loop shape (consumer pulls) instead of iroh's ProtocolHandler callback shape keeps the wac composition acyclic — the endpoint never imports its consumers. Issue #3's worry is resolved by construction.
C. Fidelity to upstream iroh (1.0.3)
transport-addr mirrors upstream faithfully — verified: TransportAddr::{Relay(RelayUrl), Ip(SocketAddr), Custom(CustomAddr)} with CustomAddr { id: u64, data } matches custom-addr exactly, including the id registry (TRANSPORTS.md upstream). Two nits: path-kind has no custom case (iroh.wit:79) — the enum can't report a path the address type can name; asymmetric and non-extensible. And ip(string)/relay(string) leave formats unspecified (IPv6 brackets? zone IDs? URL normalization — the pool dedups only trailing slashes, endpoint_impl.rs:927).
The connect contract bakes v0 narrowings into normative text. "There is no fallback between dial paths" (iroh.wit:165) is implementation latitude promoted to contract. Upstream iroh 1.0 always races paths — QUIC multipath with QNT NAT traversal is in the protocol layer now. A future upstream-backed or multipath-capable implementation of this WIT would be non-conformant against this sentence. State dial-path selection as implementation-defined; keep the narrowing in the implementation's docs where the rest of it already lives (endpoint/src/lib.rs:12).
Single-path model.path: func() -> path-kind assumes exactly one wire at a time. iroh 1.0 connections hold a set of paths with a selected one (paths(), path_events(), per-path RTT). The current model can't even express "relay + webrtc simultaneously during upgrade," which is what the implementation actually does mid-flip. Fine for v0; will not survive direct-UDP-as-upgrade (Direct UDP as an upgrade target #12) without becoming list<path> + events.
Identity lifecycle.bind mints a fresh identity per call; the doc claims persistence will be "not a surface change" (iroh.wit:146). Dubious: selecting a stored key needs some input (a key name/handle in endpoint-options, or a webcrypto key resource parameter — issue Design the exported endpoint WIT surface #3 explicitly lists injection). Upstream has Builder::secret_key. Every real deployment (stable addresses, discovery records) needs this; it's the largest functional gap after datagrams.
Missing vs upstream, worth a roadmap line each: QUIC datagrams (acknowledged, Design the exported endpoint WIT surface #3), stopped()/received_reset on streams, stream priority, RTT/stats (your own AGENTS.md demands measured claims — rtt() is the measuring instrument), 0-RTT, set_alpns dynamism, additional-ALPNs on connect, self-endpoint-addr assembly (there is no way to learn observed addresses; direct-addr is the local bind — publishing a dialable record is impossible until Direct UDP as an upgrade target #12's QAD), address-book/lookup injection, before_connect/after_handshake policy hooks (the accept-DoS mitigation upstream chose).
Error taxonomy is thinner than both upstream and your siblings. No timed-out (folded into connect-failed — but also used for relay-open timeouts on connect), no not-supported (needed the moment a host lacks UDP or WebRTC — see Bump polymorph-tls pin: QUIC descope + uniform hp masking #16), and the read-after-read-via-stream failure is specified as error.other (iroh.wit:270) where both siblings minted a dedicated receiving-via-stream case. other being load-bearing in a documented flow is a contract smell.
D. World-level issues
The world under-declares the component's imports.iroh-endpoint (iroh.wit:282) omits lann:webcrypto/* (used via core/src/crypto/sign.rs) and wasi:random (getrandom in bind, endpoint_impl.rs:1050). The doc comment says so, but a world that isn't the component's real import set defeats the stated "capability-bounded networking through narrow WIT imports" claim — auditors and composition tooling read worlds. Declare them; the crypto split is a feature, not something to hide from the manifest.
All transports are mandatory imports. A browser deployment has no wasi:sockets; a socket-only cloud deployment has no WebRTC — yet the world requires all four imports and endpoint-options can request any wire. Absent optional imports in the CM, the latitude ("this host stubs UDP; udp-bind-addr fails") must be written at the option's definition site, with an error case that isn't other. Currently unrecorded.
WIT/implementation divergences with no WIT-side artifact:relay-url: option<string> but bind rejects none (endpoint_impl.rs:1035); empty alpns rejected but undocumented; read(max=0) returns some([]) undocumented. Each violates your recorded-latitude rule at the surface consumers actually read.
E. Sufficiency for iroh's transport abstractions
Two directions, opposite verdicts:
Implementing iroh's transport abstraction on this surface: no, structurally. iroh 1.0's CustomTransport/CustomEndpoint is a datagram interface (poll_send(dst: CustomAddr, Transmit), poll_recv(..., RecvInfo), watch_local_addrs, GSO hints) below QUIC. lann:iroh/endpoint sits above QUIC and deliberately hides packets, addresses, and paths. Nothing datagram-shaped can be built on it. That's the correct layering for a consumer surface — just don't expect it to serve both roles.
The inverse is the interesting one. Internally, endpoint/ reinvented upstream's transport seam almost exactly: your synthetic 2001:db8::/48 standins + routes table (endpoint_impl.rs:229, 92) are upstream's MultipathMappedAddr ULA mapping + Transports dispatch, independently converged. That seam is currently hardcoded to three wires. If the family ever wants Tor/BLE/new wires as components (the thing upstream's unstable-custom-transports does natively), the move is a lann:iroh/transportimport interface mirroring CustomEndpoint — send(addr, datagram), receive() -> (addr, datagram), local-addr watch — and path-kind.custom(u64). The current WIT neither provides nor obstructs that; the world's fixed import list is the (closed) transport abstraction today. Worth an issue so the seam doesn't ossify.
If only three things change: A1/A2 (reset/close vs FIN — data integrity), B4/B5 (concurrent-op and cancellation contracts — the CM makes both reachable by any consumer), and A3 + wait-closed payload (stop laundering machine-readable QUIC codes through diagnostic strings). All are pre-publication cheap and post-publication breaking.
Basis: the WIT (wit/iroh.wit), its implementation (endpoint/src/endpoint_impl.rs), the sibling packages, issues #3/#6/#9/#10/#12, and a verified survey of iroh 1.0.3 (note: upstream renamed heavily for 1.0 — quinn is replaced by
noqwith multipath + QNT built in,NodeId→EndpointId, discovery→"address lookup", and the transports layer isiroh/src/socket/transportswith an unstable publicCustomTransportAPI).Overall: the surface is small, honest, and unusually well-documented; the auth semantics (
peer()states exactly what is proven, identity-mismatch connections are never surfaced — endpoint_impl.rs:558) are right and better-stated than upstream's docs. The shapeconnect(endpoint-addr, alpn)/accept()/ two-level stream accept maps cleanly onto iroh 1.0'sEndpoint. The findings below are ordered by how much they'd hurt.A. Byte-stream integrity holes (highest severity)
Reset is unobservable on the streaming read path.
read-via-stream: func() -> result<stream<u8>, error>(iroh.wit:272) returns a barestream<u8>. A CM stream end carries no error: a peerRESET_STREAMis indistinguishable from a clean FIN. The implementation confirms it — endpoint_impl.rs:1509while let Ok(Some(bytes))ends the stream on reset, silently. wasi:sockets 0.3 solved exactly this withreceive() -> tuple<stream<u8>, future<result<_, error-code>>>(sockets.wit:378). Adopt that shape or delete the method; as specified it invites silent truncation.Connection close is surfaced as clean FIN. endpoint_impl.rs:1414-1417 maps
Error::ClosedtoOk(None)inread. A connection that dies (even gracefully) before a stream's FIN truncates that stream; quinn/noq surfaceReadError::ConnectionLostprecisely so applications can tell.writeresolves at acceptance, not delivery (iroh.wit:243), so "peer wrote, closed, receiver got EOF" can lose an arbitrary tail with no error anywhere. This contradicts the WIT's ownerror.closedtext ("closed before the operation completed"). One of the two — the doc or the mapping — is wrong; the mapping is.Reset codes are strings.
reset(string)"decimal-rendered" (iroh.wit:97) while the same doc says "do not match on their contents." Together those two sentences delete the application reset code from the interface. Upstream:ReadError::Reset(VarInt). Make itreset(u64). Relatedly,close/reset/stoptakeu32codes but QUIC codes are u62 — an upstream peer can send codes this surface can neither receive faithfully nor emit. Andwait-closed: async func()(iroh.wit:235) returns nothing: the peer's application close code/reason (which iroh'sclosed() -> ConnectionErrordelivers, and protocols do dispatch on) is unobservable.B. Component-model concurrency model
The design's CM posture is fundamentally sound — async funcs for blocking ops, sync
close+ latchedwait-closed, backpressuredwritethrough the async ABI, one-shot stream taps — and it's proven against real CM async runtimes (wasmtime matrix green; jco blocked on upstream scheduler defects #6/#10, correctly diagnosed as host bugs, not contract bugs). Remaining holes:Concurrent same-stream operations are unspecified, and the implementation corrupts. The CM permits concurrent method calls on one resource handle (each call is its own task; that's the mechanism
accept's documented concurrency relies on). Two concurrentsend-stream.writecalls interleave viawrite_all's flow-control quanta (endpoint_impl.rs:1370-1402) — byte-level interleaving, i.e. corruption for any framed protocol; two concurrentreads dequeue chunks in arbitrary alternation. quinn forbids this with&mut self; WIT has no exclusive borrow, so the contract must say it: either "a second in-flight op fails/queues" or an explicit hazard warning. The siblings' "concurrent calls supported, implementation-defined order" wording is correct for message transports and wrong for byte streams — don't inherit it.Cancellation semantics are unstated everywhere. Every async import call is a cancellable subtask (
subtask.cancelis real; jco even traps on it, jco-transpile 0.5.2 traps on subtask.cancel: guests must resolve in-flight imports before returning #6). Per-operation post-conditions needed: cancelledwrite— is a prefix committed, and can the caller learn how much? (No; onlyresetrecovers the stream.) Cancelledconnect— does the attempt continue? (Implementation: theConnEntryleaks forever and the peer sees a live connection nobody owns — endpoint_impl.rs:1187's waiter is the only thing that ever surfaces it.) Cancelledaccept/write-via-stream? quinn documents cancel-safety per method; this interface, whose ABI makes cancellation more reachable than Rust drop, documents none.write-via-stream's one-shot rule is not enforced and its interactions are unspecified. The WIT says "after this call the resource accepts no further writes" (iroh.wit:253), butSendStreamReshas no guard (contrastRecvStreamRes.streaming, endpoint_impl.rs:976): a subsequentwritehappily interleaves with the streamed payload. Divergence-with-no-artifact by your own AGENTS.md standard. Also unspecified:finish/resetwhile awrite-via-streamis in flight, and what happens when the caller drops the data stream mid-way (implementation: breaks the loop and FINs — turning a producer crash into a clean-looking EOF for the remote peer; arguably it shouldreset).No backpressure on accept. Handshakes complete and queue unboundedly whether or not anyone calls
accept(endpoint_impl.rs:84, 552). The CM gives youtask.backpressure, and upstream givesIncoming::refuse/retry/ignoreplus incoming filters, for exactly this. As specified, any peer that can reach your relay can make you hold unbounded fully-handshaked connections. Related contract gap:acceptdoc doesn't say connections handshake before an acceptor exists (observable: peer'sconnectresolves while server app is busy).Poll-only observation is an anti-idiom here.
state()/path()are snapshot funcs; path flaps (webrtc→relay→webrtc) between polls are unobservable, and TOCTOU is inherent. Your own siblings established the coalescing-watch stream (state-changes: func() -> stream<...>, webrtc.wit:334) — andendpoint-demoalready busy-pollspath()to detect the upgrade. Addpath-changes/state-changes. This also replaces cross-component polling (a full canonical-ABI call per sample, on top of the implementation's 5ms quanta) with demand-driven reads.One genuinely good CM call worth recording as a ruling: the accept-loop shape (consumer pulls) instead of iroh's
ProtocolHandlercallback shape keeps thewaccomposition acyclic — the endpoint never imports its consumers. Issue #3's worry is resolved by construction.C. Fidelity to upstream iroh (1.0.3)
transport-addrmirrors upstream faithfully — verified:TransportAddr::{Relay(RelayUrl), Ip(SocketAddr), Custom(CustomAddr)}withCustomAddr { id: u64, data }matchescustom-addrexactly, including the id registry (TRANSPORTS.mdupstream). Two nits:path-kindhas nocustomcase (iroh.wit:79) — the enum can't report a path the address type can name; asymmetric and non-extensible. Andip(string)/relay(string)leave formats unspecified (IPv6 brackets? zone IDs? URL normalization — the pool dedups only trailing slashes, endpoint_impl.rs:927).The
connectcontract bakes v0 narrowings into normative text. "There is no fallback between dial paths" (iroh.wit:165) is implementation latitude promoted to contract. Upstream iroh 1.0 always races paths — QUIC multipath with QNT NAT traversal is in the protocol layer now. A future upstream-backed or multipath-capable implementation of this WIT would be non-conformant against this sentence. State dial-path selection as implementation-defined; keep the narrowing in the implementation's docs where the rest of it already lives (endpoint/src/lib.rs:12).Single-path model.
path: func() -> path-kindassumes exactly one wire at a time. iroh 1.0 connections hold a set of paths with a selected one (paths(),path_events(), per-path RTT). The current model can't even express "relay + webrtc simultaneously during upgrade," which is what the implementation actually does mid-flip. Fine for v0; will not survive direct-UDP-as-upgrade (Direct UDP as an upgrade target #12) without becominglist<path>+ events.Identity lifecycle.
bindmints a fresh identity per call; the doc claims persistence will be "not a surface change" (iroh.wit:146). Dubious: selecting a stored key needs some input (a key name/handle inendpoint-options, or a webcrypto key resource parameter — issue Design the exported endpoint WIT surface #3 explicitly lists injection). Upstream hasBuilder::secret_key. Every real deployment (stable addresses, discovery records) needs this; it's the largest functional gap after datagrams.Missing vs upstream, worth a roadmap line each: QUIC datagrams (acknowledged, Design the exported endpoint WIT surface #3),
stopped()/received_reseton streams, stream priority, RTT/stats (your own AGENTS.md demands measured claims —rtt()is the measuring instrument), 0-RTT,set_alpnsdynamism, additional-ALPNs on connect, self-endpoint-addrassembly (there is no way to learn observed addresses;direct-addris the local bind — publishing a dialable record is impossible until Direct UDP as an upgrade target #12's QAD), address-book/lookup injection,before_connect/after_handshakepolicy hooks (the accept-DoS mitigation upstream chose).Error taxonomy is thinner than both upstream and your siblings. No
timed-out(folded intoconnect-failed— but also used for relay-open timeouts onconnect), nonot-supported(needed the moment a host lacks UDP or WebRTC — see Bump polymorph-tls pin: QUIC descope + uniform hp masking #16), and the read-after-read-via-streamfailure is specified aserror.other(iroh.wit:270) where both siblings minted a dedicatedreceiving-via-streamcase.otherbeing load-bearing in a documented flow is a contract smell.D. World-level issues
The world under-declares the component's imports.
iroh-endpoint(iroh.wit:282) omitslann:webcrypto/*(used viacore/src/crypto/sign.rs) andwasi:random(getrandom inbind, endpoint_impl.rs:1050). The doc comment says so, but a world that isn't the component's real import set defeats the stated "capability-bounded networking through narrow WIT imports" claim — auditors and composition tooling read worlds. Declare them; the crypto split is a feature, not something to hide from the manifest.All transports are mandatory imports. A browser deployment has no
wasi:sockets; a socket-only cloud deployment has no WebRTC — yet the world requires all four imports andendpoint-optionscan request any wire. Absent optional imports in the CM, the latitude ("this host stubs UDP;udp-bind-addrfails") must be written at the option's definition site, with an error case that isn'tother. Currently unrecorded.WIT/implementation divergences with no WIT-side artifact:
relay-url: option<string>butbindrejectsnone(endpoint_impl.rs:1035); emptyalpnsrejected but undocumented;read(max=0)returnssome([])undocumented. Each violates your recorded-latitude rule at the surface consumers actually read.E. Sufficiency for iroh's transport abstractions
Two directions, opposite verdicts:
CustomTransport/CustomEndpointis a datagram interface (poll_send(dst: CustomAddr, Transmit),poll_recv(..., RecvInfo),watch_local_addrs, GSO hints) below QUIC.lann:iroh/endpointsits above QUIC and deliberately hides packets, addresses, and paths. Nothing datagram-shaped can be built on it. That's the correct layering for a consumer surface — just don't expect it to serve both roles.endpoint/reinvented upstream's transport seam almost exactly: your synthetic2001:db8::/48standins +routestable (endpoint_impl.rs:229, 92) are upstream'sMultipathMappedAddrULA mapping +Transportsdispatch, independently converged. That seam is currently hardcoded to three wires. If the family ever wants Tor/BLE/new wires as components (the thing upstream'sunstable-custom-transportsdoes natively), the move is alann:iroh/transportimport interface mirroringCustomEndpoint—send(addr, datagram),receive() -> (addr, datagram), local-addr watch — andpath-kind.custom(u64). The current WIT neither provides nor obstructs that; the world's fixed import list is the (closed) transport abstraction today. Worth an issue so the seam doesn't ossify.lann:iroh/endpointover the iroh crate) is ~90% mechanical — same connect/accept/stream shapes — except where this review already points: the no-fallback dial contract (jco: scheduler stops delivering waitable events once a detached task holds in-flight imports across export calls #10), u32 codes (Design the exported endpoint WIT surface #3), singlepath-kind(Endpoint: WebRTC data-channel and UDP wires behind the same surface #11), andwebrtcas a package-private addr kind. Keeping that door open is cheap and is exactly your conformance story's cross-check implementation.Priority
If only three things change: A1/A2 (reset/close vs FIN — data integrity), B4/B5 (concurrent-op and cancellation contracts — the CM makes both reachable by any consumer), and A3 + wait-closed payload (stop laundering machine-readable QUIC codes through diagnostic strings). All are pre-publication cheap and post-publication breaking.