CADS-Tunnel docs
Explanation

How the edge decides whether to admit a channel join

Agent-Fabric channels covers how a broker-mediated connection gets established — rendezvous, the fallback ladder, ciphertext-only routing. This page covers what happens the instant before that: how the edge’s broker decides whether to admit a presenting (channel, holder) pair at all, source-grounded in crates/edge/src/channel_authorize.rs.

The broker holds no membership state of its own

Every single channel join — not just the first one, every one — makes the edge call POST {control-plane}/internal/channel/authorize with the channel id and the presenting holder’s public key, over the shared edge↔CP admin token. The control plane’s durable channel_members table is the only source of truth; the edge never caches “who’s allowed in” as its own registry. The response is a real, resolved verdict, not a guess:

The operator public key in that verdict is the root of the whole chain — the broker admits whatever grant verifies against it, nothing more. Until #747 (fixed 2026-09-03) the record behind it was softer than everything downstream of it: POST /me/channels was an unconditional upsert, so the owning account re-registering a channel with a different operator_pubkey — deliberately, or via a stale CT_GRANT_CHANNEL export aimed at the wrong channel, which is exactly the near-miss that surfaced this on a production channel — silently replaced the key with no refusal and no audit row. To the broker that’s indistinguishable from a legitimate rotation: on the next join every grant the previous operator signed fails verification, so every admitted member of that channel loses admission at once (an outage), and whoever holds the owning account has quietly taken over who may sign grants for it (a hijack). The guard makes that step explicit. A mismatched operator for a channel you already own is now a 409 with nothing written; only "confirm_rekey": true rotates it, and that rotation leaves a channel_operator_rekeyed entry in the admin audit log. POST /me/rooms runs through the same check with no opt-in at all. Exact responses: API endpoints.

Two different kinds of “no” — and why they used to be the same

Until a fix landed on main (2026-07-31, tracked as scimbe/CADS-Tunnel#231), every non-success outcome from that CP round-trip — a clean 404 (genuinely not a member), a clean 401 (edge↔CP admin-token mismatch), a connection timeout, or the CP simply being mid-restart — collapsed to the exact same refusal. That’s a real problem for a coordination-only architecture like this one: the control plane restarting for a few seconds (routine during active development, and not something a channel member should ever notice) made every presenting grant get refused plane-wide, for every holder, indistinguishable from every membership having been revoked simultaneously — even though membership rows are durable and the CP hadn’t touched them.

Live-reproduced before the fix, not theoretical: a completely fresh ct-agent channel --serve process — never run before, so it can't have "gotten stuck" — failed its very first admission attempt with edge broker refused the channel join the moment the CP blipped. That proved the refusal was happening at this authorize lookup, not anywhere in client-side state.

The fix draws a real distinction the code now enforces:

That 30-second window is a deliberate trade: long enough to bridge a routine restart, short enough that a member revoked mid-outage can’t ride the stale cache for long. It’s also why the underlying HTTP client for this specific call deliberately does not reuse pooled connections — a half-dead pooled connection surviving a CP restart is exactly the ambiguous “not really resolved” state the fix exists to eliminate; a fresh connection per authorize call fails fast and predictably instead.

Admission is only half the story: you still have to wait for your partner

Passing the authorize check above doesn’t connect you to anything by itself — a channel is always between exactly two holders, and the edge has to hold your admitted connection until the other one shows up too. This parking/pairing step, source-grounded in crates/edge/src/channel_broker.rs’s ChannelPairer, is what’s actually behind the “waiting to be paired” feeling of a join that doesn’t immediately connect:

Found live, fixed same day (2026-07-31, tracked as scimbe/CADS-Tunnel#256): that 30s eviction deadline only actually fired for the QUIC-native broker (`:4435`), whose own accept loop sweeps expired waiters on every iteration. Channel members reaching admission through the :443 front door instead — see the next section — went through a *separate* pairer with no equivalent sweep anywhere, so a lone parked front-door member whose partner never showed up was held forever: its TLS stream and socket leaked for the life of the edge process. The front door now spawns its own periodic reaper alongside the QUIC broker's per-accept one, so both paths actually honor the 30s deadline. Pure resource-leak fix — the pairing/eviction *decision* logic shown above was already correct on both paths; only the front door was never actually acting on it.

Two different wires into the same broker

Everything above happens identically regardless of how your join physically reached the edge, and that’s deliberate — but the wire itself comes in two forms:

Both paths call into the exact same authorize-and-pair logic described on this page — there’s no second, weaker admission story hiding behind the front door. The only structural difference is which ChannelPairer instance a given join’s parked wait lives in, which is precisely why the reaper gap above could exist on one wire and not the other: they’re genuinely separate pairing states, not two views onto one.

What this means if your own channel join gets refused

If you see edge broker refused the channel join (or, server-side in the edge’s own log, channel-join NO [not-member] channel=... holder=...: unknown channel or holder not a member), the two now-distinguished causes read very differently:

A third, separate bug in the same neighborhood, found live and fixed 2026-08-01 (scimbe/ct-agent#2): a persistent ct-agent channel --serve process's own retry dispatch — not the edge's admission logic above — mis-forwarded a clean Refused verdict as Ok instead of Err. That got it spawned as a full session (which then immediately failed there, which is why it logged as happening after admission, not during it) and, because the outer loop saw Ok not Err, silently reset the exponential backoff #231 added — so a holder that will genuinely never be a member could hot-loop hundreds of admission attempts an hour against production instead of backing off. Live-verified fixed: before, continuous spawn-then-fail cycles with zero successful sessions over 6 hours; after, hours clean with zero repeats. If your process logs ct-agent channel: serve session ended with error (#200): edge broker refused the channel join repeatedly at a flat, unchanging rate rather than backing off, this is almost certainly it — update past this ct-agent commit.
A gap in the fix's first cut, found live and closed the same day: the transport-class branch (the one the fail-static cache exists to tolerate) returned silently — a real CP-unreachable incident looked identical to routine operation in the edge's own log. The edge now logs ct-edge: channel-authorize UNRESOLVED [reason] channel=... holder=... for every transport-class outcome — [transport] (connection error/timeout), [status=N] (a non-2xx, non-404/401 response), [unparseable-body], or [bad-operator-pubkey] — mirroring the existing channel-join NO [tag] convention. Pure observability; the admission decision itself is unchanged.
Found an error, or something that didn't work as documented? Open an issue →