# Design notes
The opinionated bits of flowscope's shape — why the library is
runtime-free, why parallelism happens outside, why the L7 layer
is a trait pair and not a tagged enum. This document is the
*why*; [`concepts.md`](concepts.md) is the *what*.
## Runtime-free in the core
**Constraint:** flowscope's lib crate has zero dependencies on
tokio, async-trait, futures, or any background tasks. Async
adapters live in [`netring`](https://crates.io/crates/netring),
which depends on flowscope, not the other way around.
**Why:**
- **Sync consumers are real.** Offline pcap replay, embedded
capture, custom CLI tools, test harnesses — none of these want
to compile a tokio runtime into their build.
- **Async runtime monoculture is a trap.** Tying flowscope to
tokio forces every downstream into the same runtime. With the
sync core, an `async-std` or `smol` consumer wraps the same
traits in their own adapter.
- **Determinism in tests.** Sync code is easier to reason about
and reproducible without runtime quirks.
- **Composability with `rayon`, RSS sharding, AF_XDP queues.**
These deployment models don't need (or want) an async runtime
in the per-flow worker.
**Consequence:** every consumer that wants async iteration brings
their own adapter. netring is the reference, but it's not the
only possible shape — embedded, real-time, or non-tokio consumers
build their own from the same traits.
## Run-to-completion threading
**Pattern:** shard packets per-flow via NIC RSS or symmetric
kernel hashing, then run the entire pipeline (capture → classify
→ extract → output) on a single core dedicated to that shard.
```
NIC RSS → N AF_XDP queues → N pinned worker threads, each owning
its own (FlowExtractor, FlowTracker, Reassembler, SessionParser)
end-to-end.
```
This is what nDPI, Suricata's `workers` runmode, Zeek's cluster,
and modern DPDK / VPP pipelines all converge on. The alternative —
*pipeline stages on separate threads (capture → classify →
extract)* — is anti-pattern for production DPI:
- Per-flow state has to be shared across stages, which means
locks or message-passing per packet.
- Cache locality is destroyed when each packet bounces between
cores.
- Backpressure becomes a hard problem; queue depth has to be
tuned per stage.
flowscope is shaped for the run-to-completion model: per-flow
state is local (one `FlowTracker` per worker), no locks on the
hot path, no atomics required. This is the *recommended* default,
not a hard constraint — the typed `Driver<E>` is `Send + Sync`
since 0.13 (and `SlotHandle<M, K>` since 0.12), so a worker may be
moved to a tokio task on another core or shared across drainers
via `Arc` when a deployment genuinely needs cross-thread fan-out.
The point stands that the per-shard, shared-nothing layout is the
one that scales; the Send+Sync bounds just don't *forbid* the
other shapes.
**Recommendation when deploying:** one extractor + tracker +
reassembler + parser per worker, fed from one RSS queue, pinned
to one core. Throughput scales linearly with cores until you run
out of NIC queues or memory bandwidth.
## Layered traits, not a tagged enum
**Choice:** each layer ships a trait (`FlowExtractor`,
`Reassembler`, `SessionParser`, `DatagramParser`) plus a default
implementation. Consumers can swap any layer for their own.
**Alternative considered:** ship a single `ParserStack` enum
covering every protocol, with each variant carrying its own
parser state.
**Why traits won:**
- **Custom protocols outnumber shipped ones.** Every consumer has
at least one in-house protocol (vendor frames, RPC, telemetry,
config) that flowscope can't anticipate. The trait shape lets
them compose without forking.
- **Type erasure costs.** A tagged enum forces every variant's
state into the same struct; cold variants pay for hot ones.
Per-trait dispatch is monomorphised — only the parsers a
consumer uses incur their state cost.
- **Stability of the surface.** Adding a shipped parser is
additive (new feature flag, new type); it doesn't require
matching `#[non_exhaustive]` enums in every consumer.
- **Composability.** `StripVlan(InnerVxlan(FiveTuple))` is a
trait composition; tagged enums would force each combinator
into the variant list.
The trait shapes (`SessionParser` and `DatagramParser`) have been
locked since 0.1.0 — additions stay additive, default methods
extend the surface without breaking existing impls.
## One API shape per L7 protocol
Every shipped L7 module — HTTP, TLS, DNS, ICMP — uses the
typed-stream shape: a `SessionParser` (TCP) or `DatagramParser`
(UDP) implementation that drains typed messages into a
caller-supplied `&mut Vec<Message>` sink from `feed_*` / `parse`
(the alloc-free idiom adopted in 0.11, plan 119 — was a returned
`Vec<Message>` through 0.10); the consumer drains typed messages
from each parser's `SlotHandle`.
TLS additionally ships `TlsHandshakeParser` — also a
`SessionParser`, but aggregating ClientHello + ServerHello +
Alert into one `TlsHandshake` message per handshake. The
per-message `TlsParser` and the aggregator stand side by side
for the two granularities consumers want.
**Why one shape:**
- Typed parsers compose naturally with netring's async session-event
stream and the sync typed `driver::Driver<E>` (one session/datagram
slot per parser) alike — the same parser drives both paths.
- A consumer who wants callback ergonomics writes a
`driver.track_into(view, &mut events)` + `slot.drain(&mut msgs)`
loop and dispatches inside the arms — no need for a separate
callback trait.
- Single shape = single docs path, single test fixture, single
set of guarantees. The legacy callback-factory shape that
shipped through 0.8 was removed in 0.9 once the
`SessionParser` story was complete.
## Bounded memory everywhere
Every stateful component has an explicit memory cap:
- `FlowTracker::max_flows` — LRU eviction at the limit.
- `BufferedReassembler::with_max_buffer` — per-side byte cap with
configurable `OverflowPolicy` (`SlidingWindow` rotates;
`DropFlow` poisons).
- `dns::Correlator::max_pending` — bounded query/response
correlation table.
- `DnsResolutionCache::with_capacity` — LRU-bounded resolution
table.
**Why:** flowscope is a passive observer of attacker-controlled
input. Any cap-less data structure is a denial-of-service vector
— a peer can stretch it indefinitely. The caps are tuneable but
never absent.
## Trait-method overrides for diagnostics
When a trait grows a diagnostic method (e.g.
`Reassembler::high_watermark`, `Reassembler::retransmits`), it
ships with a default-zero / default-`None` implementation.
Existing third-party `Reassembler` impls don't break.
**Convention:** a default return means *"this implementation
doesn't track that"*, not *"the value is zero / absent"*.
Downstream consumers reading these diagnostics get coherent
fallback behaviour from any implementation.
## Single vocabulary across event stream and metrics
`AnomalyKind` is the source of truth for both:
- `FlowEvent::FlowAnomaly` / `FlowEvent::TrackerAnomaly` event
variants.
- `flowscope_anomalies_total{kind="..."}` metric label.
Adding a new `AnomalyKind` variant requires adding the matching
arm in `src/obs.rs::anomaly_label` in the same PR. The
integration test catches drift.
**Why:** consumers route on the same string in both pipelines.
Drift between event-stream kinds and metric labels would force
every consumer to maintain a translation table.
## Sync / async parity
Every async helper in netring has a sync mirror in flowscope:
| `flow_stream` | `FlowDriver` |
| `session_stream` | `driver::Driver` + a session slot |
| `datagram_stream` | `driver::Driver` + a datagram slot |
| `conversation` | (planned; today: drive the reassembler manually) |
The async path is the ergonomic one; the sync path is what
offline-pcap consumers and embedded users get. Functional parity
keeps the documentation and test surface consistent between
modes.
## Locked wire format for serde
Once the `serde` feature ships (0.8.0), the JSON / structured
output vocabulary becomes a stability commitment:
- snake_case field names (`bytes_initiator`, `idle_timeout`, …).
- Internal tagging for all-struct enums (`{"type": "started",
...}`).
- Adjacent tagging for tuple-variant enums (`{"kind": "tcp"}` /
`{"kind": "other", "value": 99}`).
- `Timestamp` → `{"sec": u32, "nsec": u32}`.
Renames require a CHANGELOG-documented breaking change. Golden
file round-trip tests guard against accidental drift.
**Why:** consumers build dashboards on these field names. A
silent rename invalidates queries downstream.
## Pre-1.0 backward-compatibility policy
flowscope is pre-1.0. The policy:
- **When a sharper API shape is better, we ship it and migrate
consumers.** netring and known external consumers update in
lockstep.
- **Every break ships with a CHANGELOG migration recipe.** The
recipe is copy-pastable; the break is mechanical.
- **`#[non_exhaustive]` on every public struct/enum that may
grow.** Variant-field and variant additions are unconditionally
non-breaking.
- **Post-1.0 the trade-off flips.** Then we hold the line on the
shape and additions go through extension traits / new methods
instead of mutations.
The serde wire format (above) is the first surface to opt out of
this policy early — it locked at 0.8 because downstream dashboards
depend on it.
## Trait extensibility — open vs. sealed (1.0 decision)
The public extension traits are **deliberately left open** (not
sealed) for 1.0. Reviewed as part of the pre-1.0 API-stability sweep
(issue #78):
| `SessionParser` / `DatagramParser` | **open** | Custom protocol parsers are the documented headline use case (`examples/06-custom-protocols/`, `tests/length_prefixed_example.rs`). Sealing would defeat the point of the trait. |
| `FlowExtractor` | **open** | Custom extractors and decap combinators (`StripVlan(InnerVxlan(FiveTuple))`) are a core composition story; downstream keys are expected. |
| `KeyFields` | **open** | The emit writers are generic over `K: KeyFields`, and `anomaly_fields.rs` documents + tests `impl KeyFields for CustomKey`. A downstream crate with its own flow key *must* implement it to use the CSV/Zeek/NDJSON/EVE writers — it is a genuine extension point, not just an internal emitter contract. |
| `DetectorScore` | **open** | Custom detectors that produce an `OwnedAnomaly` for the structured sinks are a supported pattern. |
**Consequence we accept:** because these traits are open, *adding a
required (non-defaulted) method is a breaking change* post-1.0.
Mitigation: every accessor on `KeyFields` / `AnomalyFields` /
`DetectorScore` defaults (to `None` / a derived value), and future
additions will too — so the traits grow additively without breaking
downstream impls. We do **not** use a `mod sealed { pub trait Sealed }`
super-trait bound on any of these. (The numeric `RateValue` helper in
`correlate::rolling_rate` is "sealed-style" only by documentation; it
is not a hard-sealed trait either.)
## What we deliberately don't ship
A few things we get asked for and consistently say no to, because
they belong elsewhere:
- **Live AF_PACKET / AF_XDP / pcap capture.** That's netring's
job. flowscope is source-agnostic.
- **`tokio` in the dependency tree.** Hard rule, also called out
in `CLAUDE.md`. Async lives in netring.
- **eBPF in-kernel correlation.** Different architecture; would
belong in a separate sister crate.
- **Learned-model anomaly detection.** flowscope ships *feature
extraction* — `flowscope::ml_features` (CICFlowMeter parity) and
the `nprint` matrix landed in-crate in 0.18 — but not the model
itself. Train and run the classifier on those vectors in your
own stack; shipping a learned-model inference pipeline (and its
weights) isn't flowscope's job.
- **Text-DSL rule language** (Suricata-style). A parser stack, not
a rule engine; not flowscope's scope.
- **CLIs.** `flow-summary` / `flow-replay` etc. belong in
`flowscope-cli`; no current consumer asking.
## Sister-crate roadmap
Two sister crates are pre-architected but unstarted (no current
consumer asking):
- `flowscope-cli` — `flow-summary` / `flow-replay` reference
CLIs for "try without writing code" demos.
- `flowscope-rss` — multi-worker orchestration helpers (RSS
sharding, AF_XDP queue management). Today consumers wire this
themselves; the helpers would absorb the common shape.
(IPFIX export was originally slated for a `flowscope-export`
sister crate, but a concrete consumer arrived: the IE registry +
`FlowRecord` and the RFC 7011 binary encoder shipped **in-crate**
in 0.18 under the `ipfix` / `ipfix-export` features. sFlow /
NetFlow v5/v9 output, if ever asked for, would still fit a
sister crate.)
If you need one of these, file an issue with a concrete use case.
None are blocked on design — they're blocked on demand signal.