flowscope 0.20.0

Passive flow & session tracking for packet capture (runtime-free, cross-platform)
Documentation
# 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:

| netring (async) | flowscope (sync) |
|-----------------|------------------|
| `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):

| Trait | Decision | Why |
|-------|----------|-----|
| `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.