Architecture
This is the in-repo map of how probectl is built and, just as importantly, why it is shaped that way. Read it top to bottom and you get the mental model; jump to a section and you get the real mechanism — package names, wire formats, the guardrail each piece serves.
The single idea that explains almost every decision below: a tenant — one isolated customer/organization sharing the deployment — is the outermost boundary in the system, enforced underneath the application code, not on top of it. Everything else — the bus, the stores, the AI layer, the provider plane — is arranged so that a forgotten check cannot leak one tenant's data to another. Keep that in mind and the rest follows.
Shape
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart TB
Provider["Provider / Management Plane — MSP operators (distinct privilege domain)<br/>tenant lifecycle · fleet-across-tenants · metering/billing · probectl banner<br/>audited break-glass (no implicit tenant-data access)"]
subgraph CP["Control Plane — Go, stateless request path + leased singletons, TENANT-AWARE"]
Edge["REST (OpenAPI 3.1) · gRPC (agents, mTLS) · MCP · Webhooks/OTLP<br/>Auth (SSO/RBAC/ABAC) · Audit · Tenant → Org → Team → Project"]
Subsys["subsystems: tenancy · path · bgp · opendata · threat · change ·<br/>topology · cost · slo · compliance · ai · …"]
end
Agents["Agents & collectors — Go static binaries, tenant-bound<br/>canary/path agent · flow · device · eBPF · endpoint (DEM)"]
Analyzer["BGP analyzer (Python)<br/>RouteViews/RIS MRT + RIS Live"]
Bus["Bus — Kafka / in-process memory<br/>(tenant-tagged)"]
Stores["Postgres · ClickHouse · Prometheus/VM<br/>topology graph · object store"]
External["External (read-only, cached, degrade gracefully)<br/>RouteViews · RIPE RIS · RPKI · PeeringDB · MaxMind/Cymru · CT logs · threat-intel · cloud pricing"]
Provider -->|tenant-scoped, isolated| CP
Agents -->|gRPC mTLS| Edge
Analyzer -->|probectl.bgp.events| Bus
Agents -->|results, tenant-tagged| Bus
Bus --> Subsys
Subsys -->|queries, tenant-first| Stores
External -.->|per-tenant ingest, ADR bgp-ingest-model| Analyzer
External -.->|cached| Subsys
Reading the diagram: an agent probes the network, ships each result onto the bus — the publish/subscribe message pipe (Kafka, or an in-process memory mode) that decouples whoever produces data from whoever stores it — stamped with its tenant; a control-plane consumer (the control plane has stateless request/ingest paths plus PostgreSQL-leased singleton background loops; a consumer is the part that reads messages off the bus) persists it and folds it into incidents/topology; and then the API, UI, AI, and MCP server read the unified stores — always filtering to the caller's tenant first, then applying that user's role permissions (RBAC). The external feeds on the right (route collectors, geo data, threat intel) are pulled in once, shared, and then attributed per tenant when stored — probectl owns no measurement fleet of its own.
Producers and consumers — and why "no producers = no data"
The single most important operational fact for a new reader: the control plane is the consumer, and the agents/collectors are the producers. The control plane produces no observations of its own — it does not probe, sniff, poll, or peer with anything. It only receives, stores, correlates, and serves the data that producers ship in. (The one thing it triggers itself is operator-initiated path discovery; see Path visualization. Everything else arrives from a producer.)
The control plane is an editor, not a reporter: it prints only what the field reporters file. The practical consequence: a brand-new control plane with nothing attached is an empty system — by design, not by fault. It will start, serve a healthy UI and API, pass its own health checks, and show zero network data, because nothing is observing the network yet — an empty newsroom still lights up and answers the phone; the paper is just blank until someone files a story. Data appears only once you attach producers. So the first question when a fresh install "shows nothing" is never "is the control plane broken?" — it is "what's producing?"
The producers are the binaries under cmd/probectl-* (deploy configs and the
full how-to live in deploying-agents.md):
| Producer | What it observes | How it ships data in |
|---|---|---|
probectl-agent (canary/synthetic + path engine) |
active ICMP/TCP/UDP/DNS/HTTP probes and traceroutes it runs itself | streams over gRPC/mTLS to the control plane (StreamResults); the control plane re-publishes onto the bus as probectl.network.results |
probectl-flow-agent |
passive NetFlow v5/v9, IPFIX, sFlow v5 exporter datagrams | publishes to the bus as probectl.flow.events |
probectl-device-agent |
SNMP (v2c/v3) + gNMI/OpenConfig metrics; opt-in LLDP/CDP physical adjacency | publishes to the bus as probectl.device.metrics and probectl.device.neighbors |
probectl-ebpf-agent |
zero-instrumentation L3/L4 host flows + service map (Linux) | publishes to the bus as probectl.ebpf.flows |
probectl-endpoint (DEM) |
last-mile experience on a user's device (WiFi, gateway, ISP path, browser timings) | publishes to the bus as probectl.endpoint.results |
probectl-cloud-metrics |
local AWS/Azure/GCP metric export files | posts to /v1/prometheus/write as tenant-scoped remote-write |
Note the three shapes of "ships data in." The canary agent streams over its
tenant-bound mTLS gRPC link (internal/agenttransport, the
probectl.agent.v1.AgentService service — see Agent transport),
and the control plane is the thing that puts that result on the bus. The
flow/device/eBPF/endpoint collectors publish directly to the bus themselves
(internal/bus). The cloud metric importer posts to the authenticated
remote-write HTTP surface, where the control plane forces the tenant label
before storage. Either way the rule from the diagram holds: every record is
stamped with its tenant before a consumer ever sees it.
This is not the same thing as the external feeds on the right of the diagram
(RouteViews/RIS, RPKI, MaxMind, threat-intel). Those are read-only enrichment
data the control plane pulls in to annotate what producers observe — they are
context, not observations of your network. The Python BGP analyzer
(BGP / routing intelligence) sits in that camp: it
reads public collector data and emits probectl.bgp.events, but it is not
watching your network either. So even with every external feed reachable, "no
producers = no data" still holds for your own traffic.
To see the whole producer → consumer → UI loop come alive end-to-end on one
machine, walk through getting-started.md; to choose and
deploy producers for a real environment, see
deploying-agents.md.
First principles
These are the rules that shaped the build. They are non-negotiable, and the sections below are concrete instances of them.
- A tenant is the outermost scope and the security boundary. Every
tenant-owned record, bus message, metric series, and object key is scoped by
tenant_idat the storage/query layer — in Postgres Row-Level Security (RLS: the database itself appends a tenant filter to every statement, so an unscoped query returns nothing rather than everything) and ClickHouse row policies (the ClickHouse analog) — never in handler code alone. The reasoning: handler code has bugs; aWHERE tenant_id = ?you forgot to write leaks data, but a database that physically refuses to return another tenant's rows does not. A cross-tenant isolation test is a permanent CI gate (thecross-tenant-isolationjob). - OpenTelemetry-native. OpenTelemetry (OTel) is the vendor-neutral
standard for describing telemetry — what a metric, trace, or resource
attribute is called — and OTLP is its wire protocol. probectl ingests and
exports OTLP;
see
otlp.md. Result and event schemas are modeled on OTel resource- network semantic conventions from the first line of code that emits them, so exposing OTLP later is just surfacing the schema, not retrofitting it. (Traces/logs are kept bounded for correlation — probectl is deliberately not an APM/tracing product; APM is application performance monitoring, the per-request code-level tracing that products like Datadog APM do.)
- Self-hosted, never phones home. No outbound telemetry is on by default. The operator's network data stays in the operator's network.
- Crypto is abstracted behind
internal/crypto. Handlers and services never call a crypto primitive directly. The payoff: a FIPS 140-3 validated module (FIPS 140-3 being the U.S. federal standard a crypto module is certified against) can be compiled in without touching business logic. mTLS — TLS where both sides present certificates, so the server also knows cryptographically who dialed in — secures every agent↔control-plane link; every listener serves TLS. - Remediation is observe-only and human-gated; threat detection is a signal, not an inline IPS (an intrusion-prevention system — a device that sits in the traffic path and actively blocks it). probectl tells you what is wrong and, at most, proposes a fix a human approves — it never silently acts on the network.
Tenant-scoped data model
This is the foundation the rest of the system stands on. The hierarchy is Tenant → Organization → Team → Project: a tenant is the hard isolation boundary (one customer of an MSP, or the single org in a sovereign install), and orgs/teams/projects are how that tenant subdivides itself internally. Identity, RBAC, audit, and every data plane hang off a tenant.
| Table | Scope | Notes |
|---|---|---|
tenants |
global registry | the outermost entity; provider-managed; has no tenant_id and no RLS — it is the registry of tenants |
organizations, teams, projects |
tenant-owned | the hierarchy; each carries tenant_id + a parent foreign key |
users, service_accounts |
tenant-owned | per-tenant identity |
permissions |
global catalog | the grantable action set, identical for every tenant |
roles, role_permissions, role_bindings |
tenant-owned | the RBAC foundation |
provider_operators, break_glass_grants |
global / provider | the provider privilege domain — operators are not tenant users |
audit_events |
tenant-owned | per-tenant hash chain, append-only |
provider_audit_events |
global / provider | the separate provider / break-glass hash chain |
agents, tests, results |
tenant-owned | the telemetry tables the planes below populate |
Every tenant-owned table carries a non-null tenant_id with an index from
its very first migration — it is never bolted on afterward. (See the migrations
in migrations/, e.g. 0002_tenancy_core.sql.)
Pooled isolation: why a forgotten check still cannot leak
"Pooled" means many tenants share the same physical Postgres tables, told apart
by tenant_id. The obvious worry: one mistyped query and tenant A reads tenant
B. probectl makes that impossible with two independent layers — a seatbelt
and an airbag: either one catches the crash the other misses.
- Storage layer — Postgres Row-Level Security (RLS). Every tenant-owned
table has RLS
ENABLEd andFORCEd, with a policy keyed on a per-transaction setting,probectl.tenant_id(a GUC in Postgres jargon — a session/transaction-scoped configuration variable a client can set). If that setting is unset, the policy matches zero rows — it fails closed. Even a rawSELECT * FROM organizationswith noWHEREclause returns only the current tenant's rows, because the database itself rewrites the query. - Query layer — the tenancy choke point.
tenancy.InTenant(ctx, pool, fn)(internal/tenancy/tenancy.go) is the only way to get a tenant-scoped database handle. It opens a transaction, runsSET LOCAL ROLE probectl_app— a role createdNOSUPERUSER/NOBYPASSRLS, so RLS applies even when the connection logged in as a superuser — then sets theprobectl.tenant_idsetting, and only then runs your code.
probectl_app also holds least-privilege DML: audit tables grant no
UPDATE/DELETE, so even buggy code cannot rewrite history. The application's login
role must be able to assume probectl_app (a superuser always can; otherwise
GRANT probectl_app TO <login_role>), and to assume, without inheriting,
probectl_provider (GRANT probectl_provider TO <login_role> WITH INHERIT FALSE, SET TRUE).
Provider plane and break-glass
Provider operators (the MSP staff running the platform) are a separate privilege
domain from tenant users. The crucial rule: running a tenant grants zero read
access to that tenant's telemetry. To actually read a tenant's data, an
operator needs a break_glass_grant that is time-bounded, consented to by the
tenant, and recorded on a separate audit stream. Ordinary provider queries use
a dedicated Postgres role with no grant on telemetry tables at all; break-glass
reads go through InTenant for the target tenant and are audited per access. See
provider-plane.md.
Audit: two tamper-evident streams
There are two append-only, hash-chained logs: the tenant stream
(audit_events, one hash chain per tenant) and the provider stream
(provider_audit_events, for operator and break-glass actions). A hash
chain makes a log tamper-evident: each record's hash covers the previous
record's hash, so the entries form a ledger where rewriting page 50 visibly
invalidates every page after it. Each record
chains over the previous record's hash via internal/crypto, so deleting,
reordering, or editing any entry breaks the chain and internal/audit's Verify
detects it. The two streams are kept separate so provider activity can never hide
inside tenant logs, or vice versa.
Agent transport
Agents talk to the control plane over gRPC with mutual TLS — gRPC being the
HTTP/2-based RPC framework both binaries speak, and mutual TLS meaning the agent
must present a certificate too, not just the server
(internal/agenttransport; the service is probectl.agent.v1.AgentService with
Register / Attest / Heartbeat / StreamResults, plus the
PollCoordination / ReportEndpoint pair that brokers agent-to-agent
measurement — see Network tests and agent-to-agent).
The server demands and verifies a client certificate, and reads the agent's
tenant and id from that certificate's SPIFFE identity (SPIFFE — a
standard for naming a workload inside its certificate as a URI) —
spiffe://probectl/tenant/<t>/agent/<a> — never from the request body. That
single design choice binds an agent to exactly one tenant cryptographically:
border control reads the passport, never the traveler's own account of their
citizenship. A
compromised or malicious agent cannot claim to be in a different tenant, because
it would need that tenant's certificate.
One more RPC, StreamConfig, exists in the schema but is an explicit deny:
config push is deliberately not a shipped capability (agents load their own
config), and the stub returns an error rather than silently doing nothing — see
adr/config-push.md. The proto lives under
proto/probectl/agent/v1/ and is versioned, additive-only.
Agent runtime
probectl-agent (cmd/probectl-agent, internal/agent) is one static,
multi-arch, database-free binary. Inside it:
- A plugin host runs the compiled-in canaries (
internal/canary) on a schedule, writing each result into a disk-backed, bounded store-and-forward buffer (an append-only framed log, compacted when drained). - A forwarder registers, heartbeats, and drains that buffer to the control plane over mTLS, reconnecting with backoff on failure.
The point of the buffer: probing is decoupled from connectivity. If the control plane is unreachable, the agent keeps measuring; results pile up locally and drain on reconnect — a mailbox that keeps accepting letters while the postal van is away. Delivery is at-least-once: a result may arrive twice (the consumer tolerates that), but never zero times. Every buffered and emitted result is stamped with the agent's tenant and id.
Concurrency idioms
probectl uses a small, fixed set of concurrency patterns. The point is not taste: a codebase with five vocabularies for "run these together" is a codebase where a reviewer cannot tell a deliberate detached goroutine from a forgotten one. These are the sanctioned patterns — reach for the one that fits before inventing a sixth.
| Need | Pattern | Where |
|---|---|---|
| Fan out and JOIN | errgroup.Group — every goroutine joined before the starter returns |
internal/agent (host schedules, coordination handlers), internal/cluster, internal/agent/metrics |
| Collapse duplicate work | singleflight.Group — concurrent misses share one load |
internal/control ABAC policy cache |
| Mutual exclusion across replicas | namespaced, transaction-scoped Postgres advisory locks | nine namespaces, see internal/store |
| Serialize a write against concurrent writers | guarded compare-and-swap | the WORM audit head |
| Hold a role for a bounded time | fencing-epoch lease | pglease |
| Retry a remote dependency | exponential backoff with jitter (jittered) |
agent forwarder and coordinator |
Two rules carry most of the weight:
Joined lifetimes. When a Run returns, nothing it started is still running.
A goroutine that outlives its starter keeps holding resources the starter has
already released — the coordinator's task handlers once outlived Run and could
still be using a gRPC client the reconnect loop had closed. If a goroutine must
genuinely outlive the call, it belongs to a supervisor with its own Run, not
to a bare go.
Jitter every backoff. Backoff without jitter converts one control-plane restart into a synchronized fleet-wide reconnect (SCALE-008). Two identical reconnect loops with only one jittered is the failure mode this rule exists to prevent; it happened.
Store transport
Every store that talks to an external data plane over HTTP goes through ONE
breaker-guarded transport, internal/store/chclient (CODE-006). It owns the
hardened TLS client, a circuit breaker per data-plane endpoint (SCALE-021 —
one down silo never trips another's writes), the bounded response read
(MaxResponseBytes), and the classification of what counts as an upstream
fault: 5xx and 429 are the upstream's problem and trip the breaker; 4xx is the
caller's and does not, so one malformed sample cannot take ingestion down.
The four ClickHouse-backed stores (flowstore, otelstore, pathstore, ebpfstore)
were consolidated onto it first. The TSDB remote-write writer
(internal/store/tsdb) kept a parallel breaker and its own copy of that
classification, so the per-target-breaker fix landed for the silos and not for
metrics — and its admin-API (erasure) calls bypassed the breaker entirely. It
now uses chclient too, and internal/store/tsdb carries a parity test that
drives the same fault sequence through both and requires identical breaker
state. Asserting each path's behavior in isolation is how they drifted.
Stores keep their own DDL, routing, tenant-scoping and SQL; chclient owns only
wire transport and decode. A new HTTP-backed store belongs on it — a second
transport is a second place for a resilience fix to be forgotten.
Result pipeline
A result's journey: agent → gRPC StreamResults → control-plane ingest
(internal/agenttransport) → result bus (internal/bus) → consumer
(internal/pipeline) → time-series writer (internal/store/tsdb). The wire
payload is the canonical OTel-aligned result (proto/probectl/result/v1), whose
attribute names follow OTel resource + network semantic conventions — see
otel-mapping.md.
Tenant integrity at ingest is the load-bearing detail. Before publishing, the
control plane overwrites the result's tenant_id/agent_id with the identity
from the verified mTLS certificate, and keys the bus message by tenant (the
key decides which partition — which ordered lane of the topic — a message
rides, so one tenant's results stay together). So a
malformed or hostile payload can never get itself attributed to another tenant —
the cryptographic identity always wins over the bytes on the wire.
Two pieces are pluggable behind one interface each, so the same code runs from a laptop to a cluster: the bus has a memory mode (in-process — the lightweight default for tiny deployments, roughly under five agents) and a kafka mode; the writer has a memory mode and a prometheus remote-write mode (Prometheus/VictoriaMetrics). In memory mode the agent ACK waits for the in-process consumer handler to finish, and the default overflow policy back-pressures rather than dropping.
Consumer groups mean the same thing in both modes. A message reaches every
group, and exactly one member within a group. The memory bus used to discard the
group entirely — every subscriber got every message — so any statement about
independent offsets or replay isolation was quietly Kafka-only while lightweight
mode is a shipped option. internal/pipeline carries a parity suite that runs
one dead-letter replay scenario against both implementations (kfake gives a real
in-process broker), because two per-bus tests that never compare cannot disagree.
Memory mode is still not a replayable broker log — a record published to a topic
with no live subscriber is gone, and nothing survives a restart — so Kafka
remains the production path for crash-durable transit. The consumer converts
each result into
probectl_probe_* time-series labeled by
tenant_id/agent_id/canary_type/server_address. Because a TSDB — a
time-series database, which stores numeric samples over time indexed by
labels — has no
row-level security of its own, tenant isolation there is enforced by always
scoping reads to a tenant_id label at query time.
Network tests and agent-to-agent
Probes are compiled-in Canary plugins (internal/canary): icmp
(loss/latency/jitter), tcp (connect latency) and udp (echo round-trip)
agent-to-server tests, dns (resolver/trace + DNSSEC), and http (availability +
timing breakdown + TLS capture), plus browser transaction tests that run a
scripted HTTP flow and emit per-step timings through the same result envelope.
They all share the result pipeline above; network probes also share one
latency-statistics core.
Agent-to-agent measurement runs between two registered agents but is
brokered by the control plane (internal/a2a) — the agents never need to
discover each other directly. The broker assigns roles, relays the responder's
listen endpoint to the initiator, and hands each agent its task when it polls
(ReportEndpoint and the polling API). All broker state is tenant-scoped: an
agent only ever receives its own tasks, and only a session's responder may report
an endpoint. A2A is deliberately not a locally scheduled Canary plugin: one
agent's timer cannot safely select and sequence both tenant-bound peers. Pair and
mesh sessions start through the audited A2A API/CLI, while a2a.enabled only
opts an agent into executing assigned roles. The rationale and alternatives are
recorded in the A2A coordination ADR.
The measurement itself is TWAMP-lite (a lightweight take on TWAMP,
the standard two-way active-measurement protocol; internal/canary/a2a.go):
four timestamps T1–T4 yield round-trip = (T4−T1)−(T3−T2), forward one-way = T2−T1,
and reverse one-way = T4−T3. The one-way figures assume the two hosts' clocks are
NTP-synced. Both agents' results flow through the same pipeline into the TSDB.
DNS tests
The dns canary (internal/canary/dns.go) queries DNS over UDP, TCP, DoT
(DNS-over-TLS), or DoH (DNS-over-HTTPS) in two modes. In resolver mode it
sends one query and reports resolution time, answer count, response code, and an
answer summary. In trace mode it performs an iterative delegation walk
from the root hints (dnstrace.go) — starting at the root, following NS/glue
referrals down to the authoritative server, and recording the full delegation
chain (the same thing dig +trace does, but instrumented). DoT verifies the
resolver's certificate; DoH is RFC 8484 application/dns-message over HTTPS, with
outbound TLS validated and the response treated as untrusted input.
DNSSEC validation (dnssec.go) verifies the zone's signature, not the AD bit.
(DNSSEC signs DNS records so forgeries are detectable; the AD bit is
merely a resolver's own "I checked" flag — trusting it means trusting whoever
set it, which is exactly what a forger controls.)
verifyRRSIG is a pure check — given the answer RRset, its RRSIGs, and the zone
DNSKEYs it returns secure (a matching-keytag signature inside its validity
window that verifies), insecure (no RRSIG — the zone is unsigned), or bogus
(signatures present but none verify: tampered, expired, or wrong key). The network
wrapper fetches the signer zone's DNSKEY when it isn't already in the response;
chain-to-root anchoring is a later refinement. A bogus verdict fails the probe, so
forged answers are caught rather than trusted. The crypto lives entirely inside
miekg/dns, keeping the FIPS crypto-abstraction guard green (guardrail 3). The
pure validator is fixture-tested with locally signed RRsets (secure / expired /
tampered / no-key); in-process DNS servers cover the resolver, DoH, and DNSSEC
paths hermetically, with skip-safe live DoT + trace tests.
HTTP server tests
The http canary (internal/canary/http.go) measures HTTP(S) availability and,
crucially, where the time went: a per-phase response-time breakdown captured
via Go's net/http/httptrace (httptrace.go) — callback hooks the HTTP client
fires as each connection phase begins and ends — DNS, TCP connect, TLS handshake,
time-to-first-byte, and total — plus status, content length, and throughput.
Availability is decided by an expect_status matcher (exact codes, Nxx classes
like 2xx, or ranges). The resolved peer IP is recorded as
network.peer.address; that field is the join key that lets an HTTP result be
correlated to path/traceroute data for the same destination, without paying to
re-run a traceroute inside every HTTP probe.
On HTTPS the canary captures the TLS handshake: version, cipher, and the leaf
certificate's subject/issuer/validity/SANs, plus the chain shape and a
cert-expiry-days metric. This is captured now and analyzed later by the TLS-posture
plane (see tls-observability.md). The subtle part:
to capture the certificate chain even when it is invalid, the canary sets
InsecureSkipVerify and then performs the standard chain + hostname verification
itself in VerifyConnection (honoring a ca_file trust anchor). So an expired
or untrusted cert still fails the probe — you are not silently trusting bad
certs — while its details are still attached for posture review. All crypto stays
in crypto/tls + crypto/x509 (FIPS-swappable). Integration tests stand up a
local HTTPS server and walk the required cases — success, 5xx, slow/timeout, and
expired-cert (asserting the cert is captured despite the failure) — minting the
test CA and expired leaf with internal/crypto.
Browser transaction tests
The browser canary (internal/browsercanary) adapts the transaction engine
(internal/browser) into the normal agent plugin interface. A test's target
becomes the default start URL; params.script can carry the full JSON script
when the operator wants multiple steps. The ordinary agent's Go-native
HTTPDriver follows the transaction with a cookie jar, records request waterfall
timings, emits transaction.step.<n>.duration_ms, and stores step metadata as
attributes without spawning Chromium.
The dedicated probectl-browser-agent image connects that same registry to the
Playwright ExecDriver. Agent config selects browser.driver=http|browser, and
the test's browser_driver parameter must match; mismatches fail instead of
changing test meaning. In rendered mode the tenant-bound Go process executes a
local worker and exchanges one JSON request/result over stdin/stdout. The worker
has no listener or Service, is killed with the run timeout, and adds DOM/paint
timings plus visual screenshots. Both drivers reject forbidden script hosts at
construction. HTTP enforces the resolved address in its dialer; Playwright
intercepts every navigation/redirect/subresource, resolves it through the same
deny-list, and fetches only via the checked address.
Path discovery
internal/path is the ECMP/MPLS-aware path engine, and the substrate for the
hero path visualization. The problem it solves: modern networks load-balance
across many equal-cost paths (ECMP), so a naive traceroute gives you a smeared,
incoherent picture. The engine runs Paris-style traceroutes — each trace
fixes a flow identifier so that a load-balancing router keeps that one trace on
a single stable path, and varying the identifier deliberately explores the
different ECMP branches. Fixing the flow is choosing your motorway lane before
setting off — and then deliberately driving each lane once, instead of letting
every packet drift across lanes at random.
The clever bit is how the flow is fixed in ICMP mode: a forced ICMP checksum.
The engine solves for a 2-byte payload "balance" word so that the checksum field
comes out equal to a chosen value while the packet stays valid — which keeps ECMP
hashing stable per flow. In TCP mode the flow is just the fixed 5-tuple. The
engine also detects MPLS label stacks (MPLS — label-switched forwarding
used inside carrier networks; the labels expose the carrier's internal pathing)
(RFC 4884/4950) quoted back on Time
Exceeded responses, and merges several per-flow traces into one multi-path Path:
each TTL becomes a hop whose multiple responders are ECMP branches, with per-node
RTT/loss and MPLS, and the links observed within individual flows. It never
infers an adjacency across an unresponsive * hop — an honest gap stays a gap.
A complete per-hop trace needs raw sockets (CAP_NET_RAW) to read the
intermediate Time Exceeded messages; without that privilege, the datagram-ICMP
fallback still finds the destination but not every hop. The checksum trick, the
MPLS parsing, and the multi-path merge are fixture-tested; a loopback trace is the
live test.
Every newly discovered Path also carries a versioned
measurement_fidelity receipt. It records the probe transport, the acquisition
mode actually used (raw_icmp, icmp_datagram, tcp_connect_raw_icmp, or
tcp_connect), whether hop visibility was full or destination-only, and the
timestamp capabilities. Current RTTs use Go's application monotonic clock;
kernel_timestamping and hardware_timestamping are therefore both false.
Those booleans are evidence, not aspirations: they change only when a future
implementation actually obtains and tests those timestamps. Older stored rounds
omit the receipt and the UI says unknown — legacy snapshot; it never guesses
precision from mode.
Path data is high-cardinality time-series, so it lives in ClickHouse
(internal/store/pathstore): a memory store for the lightweight mode and tests,
and a clickhouse adapter that reads/writes hop and link rows over ClickHouse's
HTTP interface (no native-driver dependency), partitioned by tenant_id so
path data never crosses a tenant boundary.
Endpoint/DEM results split by shape: numeric measurements flow into the TSDB,
while privacy-minimized event attributes are durably stored in
internal/store/endpointstore. Its ClickHouse table partitions and orders with
tenant_id first. GET /v1/endpoints uses a bounded in-memory latest-state
cache, but after restart its first read hydrates only the caller's tenant from
that durable table. The bus-to-store consumer validates the authoritative
tenant lane/agent binding before persistence and preserves exhausted writes in
the result DLQ.
Path visualization
The data API has three routes: GET /v1/tests/{id}/path returns the latest stored
path for a test, POST /v1/tests/{id}/path runs and stores a discovery, and
GET /v1/tests/{id}/path/history returns at most 100 immutable, newest-first
rounds for an RFC3339 window or up to two opaque round_id selectors. Every
history query constrains tenant_id and the tenant-owned test target in the
memory/ClickHouse store before applying a round ID. In ELI5 terms, the copied ID
is a coat-check number, not a building key: the session must already be inside
the right tenant and test. All three routes are tenant-scoped through the test
lookup, and the discoverer is injectable (default path.Run) so handlers are
testable without touching the network. Discovery currently runs from the control
plane (operator-triggered); an agent-vantage scheduler is a future refinement.
The hero UI (web/src/viz) renders the merged multi-path on the design
system: a pure layoutPath function places hops in TTL columns with ECMP branches
stacked and links drawn from observed adjacencies, and an SVG PathGraph colors
nodes by loss (so the lossy hop visually jumps out). Stable ECMP branch identity,
average RTT, loss, destination state, and complete MPLS label values are printed
on the graph; they are not hidden behind hover. The graph, interactive
loss-by-hop chart, and visible searchable hop table share one keyboard-
operable branch selection and per-hop drill-down.
Dense paths have an explicit two-layer contract. The SVG is capped at 120
representative responders and prioritizes destination, selected, lossiest, and
highest-latency branches; a coverage note always states the represented and exact
counts. The table retains every exact responder, searches before applying its
200-row DOM safety bound, and states any truncation rather than silently clipping
data. A compact summary before the graph keeps the path and tenant/time scope,
measurement fidelity, worst branch, and observe-only next action visible at the
1440×900 desktop baseline. A destination-only or mixed fallback is a warning;
a legacy round remains neutral and explicitly unknown. Layout remains linear
in nodes + links and animation respects prefers-reduced-motion.
The same Path page owns the historical workflow. Its X3 clock bounds history, incident, and change reads; a range control scrubs selected rounds and a side-by-side table labels common, measurement-changed, and unique responders. The selected test, two round IDs, branch, filters, and absolute time serialize through the shared X3 pivot contract. Stable path links contain no tenant selector and are re-authorized by the history endpoint on replay. Incident and change links preserve that context while opening the exact evidence object.
BGP / routing intelligence
BGP — the Border Gateway Protocol — is the routing system by which the
internet's networks announce which IP prefixes they can reach; each
independently-operated network is an AS (autonomous system), numbered by an
ASN. The BGP plane is the one probectl component written in Python (analyzer/),
because Python has the richest BGP/MRT tooling; it is bridged into the Go control
plane by internal/bgp. It ingests public collector data — RouteViews/RIPE-RIS
MRT dumps (MRT is the standard archive format for routing data) and the
RIS Live websocket — and never peers with a customer's
routers. It does per-prefix AS-path monitoring with origin-change /
possible-hijack / possible-leak detection and RPKI (RFC 6811) validation
(RPKI — signed statements of which AS may legitimately originate a prefix),
and emits probectl.bgp.events.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
RV["RouteViews / RIS<br/>bulk MRT (HTTP)"] --> MRT["mrt.py<br/>streaming parser"]
RL["RIS Live<br/>websocket (JSON)"] --> RIS["rislive.py<br/>reconnecting client"]
VRP["RPKI validator<br/>VRP set (rpki-client/Routinator)"] --> RPKI["rpki.py<br/>RFC 6811"]
MRT --> MON["monitor.py<br/>per-prefix baseline +<br/>origin/hijack/leak"]
RIS --> MON
RPKI --> MON
MON --> SINK["emit.py<br/>JSONL probectl.bgp.events"]
SINK -->|JSON Lines| BRIDGE["internal/bgp<br/>validate tenant → protobuf"]
BRIDGE -->|"probectl.bgp.v1.BGPEvent<br/>(tenant-keyed)"| BUS["bus<br/>probectl.bgp.events"]
Streaming, never buffered. mrt.py is a bounds-checked RFC 6396 reader that
yields one route at a time (TABLE_DUMP_V2 RIB + BGP4MP UPDATE), so a
multi-gigabyte dump is never loaded into memory as a whole RIB. A malformed record
is logged and skipped, not fatal. RIS Live's parsing core is transport-agnostic
(so it can be replayed in tests), and the live client owns the reconnect/backoff
loop.
Detection is a signal, not an action. Each event carries a confidence and
severity and is tunable and suppressible; probectl never acts on routing. The
rules: an origin differing from the last sighting → origin_change (with old/new
origin + AS path); an origin outside the configured allow-list →
possible_hijack (a more-specific prefix being a higher-confidence sub-prefix
hijack); a configured no-transit AS appearing mid-path → possible_leak; an RFC
6811-invalid announcement → rpki_invalid. If the RPKI source is down or absent,
validation degrades to unknown rather than breaking the whole analysis.
The seam between Python and Go is a stable JSON schema. The analyzer emits
events as JSON Lines — a dependency-light, language-neutral contract — and
internal/bgp is the bridge that parses each line, fails closed on any event
missing a tenant_id (tenant is the outermost scope), translates it to the
canonical probectl.bgp.v1.BGPEvent protobuf, and publishes it on the bus keyed
by tenant, on that tenant's own lane (probectl.t-<slug>.bgp.events). BGP
collector ingestion is per tenant by decision of record
(adr/bgp-ingest-model.md): each monitored tenant
runs its own analyzer subprocess on its own copy of the feeds, so there is no
cross-tenant state to scope — unlike the open-data and threat feeds below, which
are ingested once and then scoped per tenant. RouteViews/RIS are open data; their
acceptable-use terms and per-source provenance are tracked for MSP/commercial
resale, and are irrelevant to single-tenant self-hosted use.
Open-data enrichment
internal/opendata annotates an IP with internet-wide context — ASN, geo, IXP
presence (an IXP is an internet exchange point, the physical meet-me rooms
where networks interconnect), and RIR allocation (an RIR is a regional
internet registry, the body that allocates address space) — drawn from public
datasets, without probectl owning
any measurement fleet. It is a pluggable framework: each Source implements
Enrich(ctx, addr, *Enrichment), and the Enricher runs an IP through every
enabled source and merges the result.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
subgraph Sources["Sources (each a plugin with provenance + AUP)"]
Cymru["Team Cymru<br/>IP→ASN (DNS)"]
Geo["MaxMind GeoLite2<br/>geo (MMDB)"]
PDB["PeeringDB<br/>IXP (REST)"]
RIR["RIR delegated-stats<br/>allocation (file)"]
end
Cymru --> EN["Enricher<br/>merge · cache by IP · degrade gracefully"]
Geo --> EN
PDB --> EN
RIR --> EN
EN -->|Enrichment + provenance| REC["flow / test record<br/>(tenant-scoped by the consumer)"]
Shared once, scoped per tenant. Open data is deliberately not tenant-owned:
the Enricher is tenant-agnostic and returns plain data that the caller attaches
to a tenant-scoped record. The tenant boundary is therefore enforced where the
enrichment is stored, not in this package. Sources run in registration order, so
an ASN-providing source (Team Cymru) runs before one that keys off the ASN
(PeeringDB, which looks up IXP presence for the discovered e.ASN).
Graceful degradation is the whole contract. A source that is disabled, errors,
times out, or even panics is logged, marked degraded/disabled in
Enricher.Status(), and skipped — enrichment returns a partial result and a
failing dataset never breaks a core path. Each contributing source records its
Provenance (name, license, attribution, fields) on the Enrichment, and its
acceptable-use terms (license, commercial-use permission, attribution) travel on
the Descriptor — the matrix in opendata-aup.md that gates
MSP resale. Every external fetch is over TLS with certificate validation and the
response is treated as untrusted; per-IP and per-dataset caching shields
rate-limited upstreams. MaxMind GeoLite2 is operator-supplied (not shipped).
Alerting engine
internal/alert evaluates threshold and baseline (anomaly) rules over the
time-series the result pipeline writes, and delivers firing/resolved
notifications to webhook and email channels. Rules are tenant-owned and
CRUD'd via /v1/alerts.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
TSDB["TSDB<br/>probectl_probe_* series"] --> SRC["MetricSource<br/>(tenant-scoped, latest per series)"]
RULES["/v1/alerts<br/>rule store (RLS)"] --> EVAL["Evaluator<br/>tick every PROBECTL_ALERT_EVAL_INTERVAL"]
SRC --> ENG["Engine<br/>threshold | baseline · ForN debounce · dedupe/renotify"]
EVAL --> ENG
ENG -->|firing / resolved| NOTIF["Notifier<br/>fan-out, degrade per channel"]
NOTIF -->|"POST signed payload<br/>(HMAC-SHA256)"| WH["webhook"]
NOTIF -->|SMTP| EM["email"]
Two rule types. A threshold rule fires when an aggregated value crosses a
bound (gt/lt/…). A baseline (anomaly) rule fires when a value deviates from
its recent rolling mean by more than N standard deviations — and it warms up on
cold start, refusing to fire until the window is full, so a fresh deployment
does not alarm on its first few samples. Evaluation is per series (per label
set), so a single rule independently covers many targets.
Storm avoidance. State advances ok → pending → firing: a for_n
debounce requires N consecutive breaches before firing (so one blip pages
nobody), and while firing the engine
dedupes — it notifies once, then re-notifies only every renotify_seconds,
and emits a single resolved notification on recovery. This is what keeps a
flapping target from generating a thousand pages.
Channels and delivery. The Notifier fans an alert out to a rule's channels
and degrades per channel — one failing or misconfigured channel never blocks
the others. The webhook channel POSTs a stable JSON payload
(probectl.alert.v1) over TLS and, when a secret is set, signs the body with
HMAC-SHA256 (an HMAC is a keyed hash — only a holder of the shared
secret can produce it; via internal/crypto) in an X-Probectl-Signature header, so
the receiver can verify the sender. The email channel sends via SMTP behind an
injectable sender. Webhook secrets are redacted (***) from API responses.
Wiring and current limits. The control plane runs an
AlertEvaluatorSupervisor that ticks every PROBECTL_ALERT_EVAL_INTERVAL,
syncs the active tenant set, and keeps one evaluator engine per active tenant
with a bounded worker pool. Each engine loads its tenant's enabled rules through
the RLS choke point and queries the TSDB scoped to that tenant (so it can never
read another tenant's metrics). Lightweight mode queries the in-process TSDB;
remote-write mode queries the Prometheus/VictoriaMetrics instant-query backend
with a forced tenant_id matcher. The loop disables itself gracefully only when
no supported metric query backend is wired. Alerts are signals — probectl
notifies, it does not act on the network.
Incident timeline and correlation
internal/incident is the cross-plane triage home: related signals from any plane
group into a single Incident with a coherent, time-ordered timeline.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
AL["alert engine<br/>(fired/resolved)"] -->|signalFromAlert| COR["Correlator<br/>time + target/prefix proximity"]
BG["probectl.bgp.events<br/>(bus consumer)"] -->|signalFromBGPEvent| COR
FUT["future planes<br/>threat / change / cost / SLO"] -.->|map to Signal| COR
COR -->|append + aggregate| INC["Incident + timeline<br/>(incidents · incident_signals, RLS)"]
INC --> API["/v1/incidents<br/>list · timeline · resolve"]
API --> UI["web: unified timeline"]
Extensible by construction. A Signal is a generic envelope — a free-form
plane and kind, a target/prefix for correlation, and an arbitrary
attributes map. A new plane contributes simply by mapping its native event onto
a Signal (signalFromAlert, signalFromBGPEvent); neither the incident package
nor the schema changes, because incident_signals.attributes is a jsonb column.
The control plane already feeds the network plane (alert firings, via an engine
sink) and the BGP plane (a probectl.bgp.events bus consumer).
The core move — correlation grouping. Correlator.Ingest places a signal into
an open incident when it is both close in time (within PROBECTL_INCIDENT_WINDOW
of the incident's activity) and related in target — the same target, an IP
inside the other's prefix (in either direction), or overlapping prefixes. This
cross-plane join is the payoff of the whole design: a network loss alert for
192.0.2.10 and a BGP possible-hijack for 192.0.2.0/24 land in one incident
because the IP is inside the prefix, so an operator sees a single correlated story
instead of two disconnected alerts. No match opens a new incident. An incident's
severity is the max of its signals, and AppendSignal updates
last-seen/severity/count atomically inside the tenant transaction.
Tenancy and scope. Incidents are tenant-owned (RLS); the correlator fails closed on a signal with no tenant and only ever groups a tenant's own signals. This is the foundation the higher planes build on — AI root-cause analysis, and the change and threat overlays, all attach as additional signal planes onto this same incident model.
SSO and RBAC
internal/auth is the identity and access foundation: OIDC single sign-on
(OIDC — OpenID Connect, the standard log-in-via-your-identity-provider
protocol built on OAuth 2),
server-side sessions, and RBAC over the role model from the data model
above. It enforces the two-level boundary — resolve the tenant first, then
check RBAC — on every /v1 path.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart TD
subgraph Login
U["browser"] -->|GET /auth/login| LOGIN["handleLogin<br/>state cookie + redirect"]
LOGIN -->|authz code flow| IDP["tenant OIDC IdP<br/>(per-tenant factory)"]
IDP -->|code + state| CB["handleCallback<br/>verify state · exchange · verify ID token"]
CB -->|JIT provision user<br/>in tenant| ISS["session Issue<br/>store only token HASH"]
ISS -->|Set-Cookie: HttpOnly+Secure+Lax| U
end
subgraph Request["every /v1 request"]
U2["browser<br/>(session cookie)"] --> MW["authenticate middleware"]
MW -->|Resolve + idle touch → load perms<br/>rotate token if perms changed| PR["Principal<br/>tenant + permission set"]
PR --> T{"tenant<br/>resolved?"}
T -- no --> U401["401"]
T -- yes --> RB{"principal.Has(route perm)?"}
RB -- no --> F403["403"]
RB -- yes --> H["handler in tenant tx<br/>(RLS-scoped)"]
end
DEV["PROBECTL_AUTH_MODE=dev<br/>synthesized all-perms principal"] -.-> MW
Sessions. A session token is high-entropy random, and only its hash is
persisted in the tenant-owned sessions table. A shared, forced-RLS locator
stores only the hash, session-row UUID, tenant UUID, and inactive timestamps so
the tenant can be resolved before a silo schema is selected; it contains no
email, display name, MFA, preference, or user field. Exact-hash locator
functions are SECURITY DEFINER boundaries owned by a non-login pre-tenant
role, then every detail read/update runs through tenancy.InTenant in the
resolved schema. Rotation changes only the token hash, activity stamp, and
authorization fingerprint; Postgres preserves the source row's identity, MFA
claim, preferences, and absolute lifetime. Authenticated replacement locks the
predecessor locator, preserving one-successor concurrency across physical
silos. The cookie is HttpOnly + SameSite=Lax, and Secure on HTTPS.
Postgres enforces both absolute and idle expiry during lookup. The activity stamp taken on each lookup is best-effort: on a read-only standby or a fenced writer pool a session, API token, OTLP token or SCIM token still resolves — reads keep serving during a failover — and only the idle clock pauses until writes return. Successful login
and effective-permission changes rotate the opaque ID; a composite
(tenant_id, user_id) foreign key prevents cross-tenant user/session pairing at
the storage layer.
Hashing and the RNG go through internal/crypto, so auth imports no crypto
primitive directly and the import guard stays satisfied; ID-token verification
lives inside the go-oidc / go-jose libraries.
Per-tenant IdP. (An IdP — identity provider — is the system that owns
logins: Okta, Entra, Keycloak, …) A ProviderFactory.For(tenant) resolves the OIDC provider for
a given tenant. An enabled tenant_idp row overrides the env-configured fallback;
the row is selected inside a forced-RLS tenant transaction before provider
discovery. Its client secret is envelope-encrypted through internal/crypto
with the tenant id in the authenticated data, so ciphertext is neither plaintext
at rest nor portable across tenants. Missing and disabled rows use the fallback;
an invalid or undecryptable enabled row fails closed. Settings changes and their
audit record share one transaction. A login always resolves to exactly one tenant.
Provider/MSP operators authenticate into the provider domain instead, never into
tenant data here.
RBAC. Each route carries a required permission key, and
requirePermission returns 401 when unauthenticated and 403 when
unauthorized — before the handler runs. Effective permissions are loaded per
request from the user's role bindings (RLS-scoped), so a grant or revoke takes
effect immediately. New users are JIT-provisioned — the account is
created just-in-time, on first successful login — with no roles (the secure
default — you opt in to access, you do not opt out). The AI and MCP query layer
reuses this same Principal: tenant first, then RBAC.
Load/perf harness
internal/perf is a reusable load/perf harness — a cheap, repeatable baseline
that runs in CI, not a long-running soak. Its job is to catch a scaling regression
(including the cost of pooled tenancy) early in CI rather than at the final scale
gate. Two pure drivers exercise the core path, and the recorded numbers and
thresholds live in perf-baseline.md.
%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
subgraph Ingest["DriveIngest (no services)"]
P["producers<br/>build + marshal results"] -->|publish, keyed by tenant| BUS["bus (memory)<br/>probectl.network.results"]
BUS --> CON["pipeline consumer"]
CON --> TS["TSDB (memory)"]
TS -->|throughput + publish p95<br/>+ per-tenant correctness| RPT1["IngestReport"]
end
subgraph Pooled["DrivePooled (Postgres)"]
W["K tenants × N rows<br/>(seeded, pooled stores)"] --> Q["concurrent tenant-scoped<br/>queries (mixed load)"]
Q -->|RLS: each query sees<br/>EXACTLY its rows| ISO{"count ==<br/>expected?"}
ISO -->|p95 latency + isolation_ok| RPT2["PooledReport"]
end
RPT1 --> BL["Baseline.Check<br/>(floors/ceilings)"]
RPT2 --> BL
Ingest baseline measures end-to-end throughput and publish latency on the
lightweight path, and asserts every result is ingested with its series tagged by
the right tenant. Pooled multi-tenant runs tenant-scoped queries concurrently
across many tenants sharing the pooled Postgres stores, and asserts isolation
under load — every query must return exactly its own tenant's rows — plus a
bounded p95 (the 95th-percentile latency: what the slowest one-in-twenty query
experiences). This is the first place a pooled-cardinality or RLS-cost problem
would surface. Baseline holds the generous floors and ceilings that the CI
perf-smoke job asserts; the larger L/XL/XXL scale gate and the fairness work both
build on this same harness (see scale-gate.md).
Synthetic result views
The pipeline flattens results into TSDB series for trends, but each result's
per-type detail (a DNS response code/answers/DNSSEC verdict, an HTTP
DNS→connect→TLS→TTFB waterfall, an ICMP/TCP/UDP latency-and-loss family) lives in
its metrics and attributes. To show that detail without scanning history, a
latest-result read model (internal/control.LatestResults, fed by its own
consumer group on probectl.network.results) keeps the newest full result per
(tenant, type, target, agent) — tenant-partitioned, bounded, newest-wins — and
serves it at GET /v1/results/latest (requires the test.read permission, with a
collector_running honesty flag). The Targets & Tests screen renders that per
type: an HTTP waterfall, a DNS resolution breakdown, a shared latency/loss view
for ICMP/TCP/UDP, and a named-field fallback for future types — so no test type
ever renders as raw JSON. History stays in the TSDB; this is the latest-only
detail view.