Skip to content

Commit 767b631

Browse files
Ryanmello07claude
andcommitted
spec: smart routing — class-aware scored placement, thin dark-first learner, platform tiers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT7KcWCPKfFwQUc7SM3oZY
1 parent 70d682a commit 767b631

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Smart routing — learned, class-aware exit selection
2+
3+
Date: 2026-08-11. Status: owner-directed design, pending owner review of this document.
4+
Research basis: probes/multi-exit audit (live telemetry, both testers), DPI feasibility
5+
study (incl. a local nDPI compile spike), gopacket lineage investigation, DWLC-vs-bandit
6+
literature review (19 cited sources), platform constraints research, and a build-seams
7+
audit of sdk/connect/android/apple/windows. Owner decisions incorporated: auto-learn +
8+
manual overrides; separate ndpi library file; 5 traffic classes; learning on by default
9+
with opt-out, reset, 90-day default retention settable to unlimited; every heavy piece
10+
optional per platform with legacy compatibility.
11+
12+
## 1. Goal and principles
13+
14+
Evolve exit selection from evidence-based-but-uniform to class-aware and self-improving:
15+
bandwidth-, latency-, and program-aware placement that trains locally and gets better the
16+
longer it runs — without touching what already works.
17+
18+
- **Learning optimizes; verdicts protect.** The existing safety machinery (probes
19+
qualify, traffic convicts, quarantine, shared-fate holds, verdict budget) remains the
20+
SOLE removal authority. The learner ranks healthy exits only; it can never keep traffic
21+
on an exit the evidence has condemned, never bypass the verdict budget.
22+
- **Deterministic first, learner thin.** Our telemetry shows exits degrade to conviction
23+
within minutes and slots turn over ~14 min on average — the regime where bandit regret
24+
is worst and fresh measurement pays most. So placement is a deterministic score over
25+
live metrics; the learner is a small additive bonus, shipped dark until logs prove it.
26+
- **Everything heavy is optional.** cgo classification and per-app attribution are
27+
platform-gated capability seams (nil interface = today's behavior, cost one branch).
28+
Pure-Go layers ship everywhere. Zero-value-off on every new knob; old clients interop
29+
with the same servers by construction (client-local feature, no protocol change).
30+
31+
## 2. Classification — what is this flow
32+
33+
Five classes: latency-sensitive, streaming, bulk, browsing, background.
34+
35+
Precedence: manual per-app override > nDPI verdict > exe/app default > port+SNI heuristic.
36+
37+
- **nDPI (full tier)**: shipped as a SEPARATE library file (ndpi.dll / libndpi.so /
38+
libndpi.dylib) — clean LGPL-3.0 separation, and file-presence doubles as the runtime
39+
full/light switch on every platform. Our own thin binding (~12 externs, opaque pointers
40+
only, 2-3 one-line C shims; no struct mirroring — that is what killed every prior Go
41+
binding) against a PINNED nDPI release tag, in a new package in the sdk/cgo module
42+
(separate Go module — gomobile builds cannot see it by construction).
43+
- **Budget**: first 8 packets per flow, both directions, then classify-or-giveup and free
44+
(1.3 KB/flow while under classification, ≤512 flows concurrently, queue 1024). One
45+
dedicated worker goroutine owning one detection module (~14 MB once, ~1-2 µs/packet
46+
measured). Hot path pays one nil-check when idle; when classifying, a mandatory bounded
47+
copy (≤1600 B) — payload slices alias pooled buffers, queueing a live slice is
48+
use-after-recycle (review-blocking invariant). Queue full → drop the job, count it,
49+
flow keeps its heuristic class. nDPI is never called from the pump thread.
50+
- **Light tier** (no library present / iOS): pure-Go classifier using connect's existing
51+
SNI sniffer, DNS reverse index, and ports. Same 5 classes, lower confidence.
52+
- **App attribution**: implement the existing FlowOwnerLookup seam per platform
53+
(async-with-default — it must never run synchronously inside the pump's C→Go call):
54+
Windows service = TCP/UDP owner table → exe; Linux = sock_diag netlink → /proc;
55+
Android = getConnectionOwnerUid (API 29+, once per flow + 5-tuple cache; measure
56+
binder cost under flow storms before finalizing); macOS = none at launch (needs a
57+
future companion filter extension); iOS = none (no API exists).
58+
- **gopacket**: NOT a production dependency (stays test-only per the policy comment in
59+
connect). Housekeeping: retarget the 6 test files from github.com/google/gopacket
60+
v1.1.19 (2020 code; carries unfixed remotely-triggerable decoder panics, e.g.
61+
CVE-2026-65819-class bugs never advisoried there) to github.com/gopacket/gopacket
62+
≥ v1.7.1 (all High decoder-panic advisories fixed + regression-tested). If its TLS
63+
ClientHello decode is ever used outside tests: feed exact-capacity slices or a copy —
64+
its parser re-slices data[:cap(data)]. QUIC classification comes from nDPI, not
65+
gopacket (no lineage has a QUIC layer).
66+
67+
## 3. Placement — deterministic scored selection
68+
69+
At the existing single placement site (one shared code path on all platforms; sticky
70+
affinity continues to govern already-placed flows):
71+
72+
- Composite score per (class, exit) from: probe RTT, goodput EWMA, stall/receive
73+
evidence, jitter; per-class metric weights (latency-sensitive weights RTT/jitter,
74+
streaming weights sustained throughput + stability, bulk weights throughput, etc.).
75+
- **Incumbent hysteresis**: a challenger must beat the incumbent by >10% composite score
76+
(Fortinet-style A = R/(1+L/100)) — kills score-noise churn.
77+
- **N-of-M demotion**: rank demotion requires 2-of-3 consecutive bad intervals, never one
78+
sample (Cisco EAAR multiplier idiom). Convictions are unaffected — they are
79+
evidence-based, not score-based.
80+
- **Anti-herding tie-break**: when the top exits are within the hysteresis margin, place
81+
the new flow on the LESS-LOADED one (power-of-two-choices) — directly fixes the
82+
measured 37-flows-on-one-exit accretion.
83+
- Score reacts within one metric interval (1-5 s); failover remains the verdict layer's
84+
job and is faster still.
85+
86+
## 4. Learning — thin, dark-first, background-funded
87+
88+
- **Per-(class, exit) Sliding-Window UCB** (disjoint tables, ~5-min window; explicitly
89+
NOT LinUCB — at 5 classes × ≤18 exits, feature models add complexity without coverage).
90+
The UCB optimism bonus is ADDITIVE to the deterministic score and keeps under-sampled
91+
exits measured.
92+
- **Exploration budget**: exploration placements only ever use background-class flows,
93+
hard-capped at ≤1-in-8 background placements, suppressed during shared-fate holds and
94+
above the flow soft cap. Interactive traffic never pays the tuition.
95+
- **Dark launch**: the learner logs its would-be choices (counterfactuals) with zero
96+
behavioral effect first; it goes live only when logged uplift is positive (Phase 0
97+
reward instrumentation defines the metric: class-normalized goodput + stall-free time,
98+
counting only backlogged flows to avoid demand confounding).
99+
- **Cold providers / probation**: optimism-under-uncertainty replaces an ad-hoc probation
100+
flag — cold or bench-returned exits carry a decaying uncertainty bonus and earn rank
101+
through background traffic. The same prior biases recruitment ranking in the 2x
102+
evaluation pool.
103+
- **Persistence**: coarse per-provider-IDENTITY priors only (score EWMA, conviction
104+
count, last-seen; TTL per §7) — never raw UCB windows or posteriors (stale-on-load at
105+
our churn, and a poisoning surface for adversarial providers).
106+
107+
## 5. Upgrades to the existing machinery (from the same research)
108+
109+
1. **Quarantine flap damping** (observed: same exits benched 4-5x, bench migrate always
110+
movable=0): RFC 2439-style escalation — re-conviction bench 60 s → 120 s → 240 s with
111+
penalty half-life ~10-15 min; release-on-receive-progress preserved.
112+
2. **Re-entry ramp**: a released exit returns at reduced score weight and ramps to full
113+
eligibility (fast to leave, slow to return) — implemented as a temporary score
114+
penalty, not a state-machine change.
115+
3. **Removal census fix rides along** (task #51): self-closing channels emit a
116+
reason=channel-closed removal line where exitLost fires.
117+
4. Shared-fate holds and the verdict budget are independently validated by the same
118+
literature (rate-limited reaction to shared signals) — unchanged.
119+
120+
## 6. Platform tiers and guards
121+
122+
| Platform | nDPI | Attribution | Pure-Go scoring+learner | Gate |
123+
|---|---|---|---|---|
124+
| Windows | Full — ndpi.dll, LoadLibrary | Full (service, owner table) | On | DLL presence + settings; nil seam |
125+
| Linux | Full — libndpi.so, dlopen | Full (sock_diag) | On | .so presence + settings; nil seam |
126+
| macOS | Full — libndpi.dylib in app bundle, dlopen from the NE extension (macOS NE has NO memory limit, unlike iOS) | Off at launch (needs future filter extension; packet tunnel cannot attribute) | On | build tag + presence; nil seam |
127+
| Android | Optional — per-ABI libndpi.so in the APP's jniLibs (the .aar cannot carry it), lazy dlopen; absent → light | getConnectionOwnerUid (API ≥29) | On | dlopen probe → nil seam; API-level check |
128+
| iOS | **None** — LGPL-3.0 vs sealed/signed bundles is effectively prohibited (FSF position; VLC precedent), and ~14 MB vs the ~50 MB NE jetsam cap is marginal anyway | None (no API in a packet tunnel; NEAppRule is MDM-only) | On (light classifier) | cgo package invisible to gomobile by module boundary; nothing to exclude |
129+
130+
Verified guard mechanics (build-seams audit):
131+
- sdk/cgo is a separate Go module (`sdk/cgo/go.mod`); gomobile binds package
132+
`github.com/urnetwork/sdk` only — a new `sdk/cgo/ndpi` package is invisible to Android
133+
and Apple builds by construction. No build tags needed for anything under sdk/cgo.
134+
- The classifier/attributor seams copy the FlowOwnerLookup pattern exactly: nil-by-default
135+
field, gomobile-safe basic types, atomic pointer, one-branch nil cost on the egress
136+
path, re-applied on every multi rebuild.
137+
- The tier knob follows the ReliabilitySettings pattern: zero value = off/legacy; runtime
138+
swappable; logged in the session settings banner.
139+
- **Trap to respect**: the Android aar build greps exported sources against an allowlist —
140+
any new setter/type in package sdk must use gomobile-exportable basic types (as
141+
FlowOwnerLookup does) or the Android build fails.
142+
- **Second-DLL touch list** (Windows packaging, all name files explicitly and must change
143+
in lockstep): sdk/cgo/Makefile build_windows recipes (note: `; \` chaining means a
144+
failed sub-build does not fail make — add explicit failure), build-sdk.ps1, the CI
145+
"Verify the SDK actually produced DLLs" step (extend or a missing ndpi.dll ships
146+
silently), fetch-deps.ps1, App/Service .vcxproj copy steps, installer Package.wxs,
147+
package-portable.ps1 $required list. Runtime LoadLibrary avoids a second .def/.lib.
148+
- Apple/Android CI: provably zero changes (apple fork has no CI; android CI never
149+
references sdk/cgo and gomobile cannot compile it).
150+
151+
## 7. Persistence, privacy, controls
152+
153+
- New dot-file(s) under the existing per-network-space `.by` dir (precedent:
154+
`.doh_server_scores`), keyed by provider identity. Follows automatically on every
155+
platform via the app-supplied storage root.
156+
- **On by default; opt-out toggle; retention default 90 days, user-settable to unlimited;
157+
reset button.** Reset = delete the dot-file (and Logout already wipes the whole `.by`
158+
dir — existing hook). All data is local only, never uploaded.
159+
- Advanced Mode panel: per-app table (learned class + override), tier switch
160+
(full/light/off), retention setting, reset, and a live "why this exit" explainer for
161+
the current placements (score components + any UCB bonus).
162+
163+
## 8. Phasing (each phase independently shippable)
164+
165+
- **Phase 0 — gates, no behavior change**: (a) prove the nDPI ARM64 windows cross-build
166+
(its configure hard-rejects aarch64-mingw — small vendored Makefile over src/lib +
167+
third_party with pregenerated ndpi_define.h; amd64 uses upstream's own proven autotools
168+
cross); (b) reward instrumentation logging per-(class, exit) outcomes to answer "do
169+
per-class rankings actually diverge" (the go/no-go for Phase 3); (c) fix or exclude the
170+
dead DNS-through-exit probe metric before any score consumes it.
171+
- **Phase 1 — deterministic scorer** (+hysteresis, N-of-M, anti-herding tie-break,
172+
quarantine damping+ramp, recruitment prior, priors dot-file). Pure Go, all platforms.
173+
gopacket test retarget rides along. Windows FlowOwnerLookup service implementation
174+
(async) lands here — exe-class placement works with zero DPI.
175+
- **Phase 2 — classification**: nDPI binding + worker + 5-class mapping on Windows
176+
(separate ndpi.dll); light classifier everywhere; per-class scoring live.
177+
- **Phase 3 — learner**: SW-UCB dark → live on proven uplift. Android optional .so +
178+
UID attribution when the Android fork next takes a feature train.
179+
- **Phase 4 — declined by default**: LinUCB/TS upgrade only if context cardinality grows
180+
far beyond 5 classes; macOS attribution extension as its own future project.
181+
182+
## 9. Testing
183+
184+
- Scorer/hysteresis/tie-break/damping: table-driven with deterministic goldens (SW-UCB is
185+
deterministic given the window — golden-testable; another reason not TS).
186+
- Binding: compile-time sizeof assert against the pinned nDPI tag; the spike harness kept
187+
as a per-upgrade smoke test; classification correctness via captured-flow fixtures.
188+
- Seams: nil-classifier/nil-attributor paths pinned (mobile equivalence); aar validate
189+
allowlist check in CI already enforces exportability.
190+
- Live checkpoint greps: classifier verdict lines, placement "because" lines, dark-launch
191+
counterfactual log, exploration budget counter never exceeding 1-in-8.
192+
193+
## 10. Out of scope
194+
195+
Split tunneling (separate feature, blocked on driver attestation signing — task #23/#24);
196+
iOS nDPI (revisit only with an ntop commercial license — one email if ever wanted);
197+
macOS per-app attribution extension; server-side changes of any kind (none needed).
198+
199+
## 11. Known risks / honest caveats
200+
201+
ARM64 nDPI build is the one unproven leg (Phase 0 gates everything on it). The iOS 50 MB
202+
figure is Apple-forum-stated, not contractual. Android attribution binder cost under flow
203+
storms is unmeasured (Phase 3 pre-task). macOS App Store review of a dlopened LGPL dylib
204+
is untested — direct distribution is the safe path. nDPI ABI churns every minor release —
205+
survivable only via the opaque-pointer + pinned-tag discipline; every prior Go binding
206+
died of struct mirroring. Sticky-affinity flow-cap exceedance (owner 37, friend 1324 vs
207+
cap 16) still lacks per-placement attribution to confirm/refute the known TOCTOU race —
208+
Phase 0 instrumentation covers it (ties to task S2/#15).

0 commit comments

Comments
 (0)