One route, one function chain: reading a BGP pipeline as code
This post is part of Protocol in Code, a free series that reads network protocols as logic rather than configuration — inputs, state, branches, and the functions that connect them. The full source lives at pathvector-studio/protocol-in-code, and every module reads one real Python file you can open alongside the article. If you're newer to this and want to build up hands-on muscle first, start with the companion Protocol Lab series, then come back here.
This is Session 10 of the BGP track, and it's the integration lesson. Sessions 01 through 09 taught the pieces in isolation: attributes, validation, import policy, best-path selection, RIB storage, export. Each one made sense on its own. None of them told you what actually happens to a route.
The core question
If you trace one received route end to end, which functions touch it before it becomes an advertisement or disappears?
That's the question to keep turning over while you read. Not "what does best-path do" — you already know that. The question is about the chain: what order, what state changes where, and which step is responsible for each decision.
The file is src/protocol_in_code/bgp/pipeline.py. It's short. That's the point — the integration should be small enough to hold in your head all at once.
The shape, before the details
Strip the pipeline down to its skeleton and it looks like this:
store_received_path(adj_rib_in, peer_id, prefix, attributes)
for _, received_attributes in received_attributes_for_prefix(adj_rib_in, prefix):
validation_state, action, installable = evaluate_candidate(prefix, received_attributes, vrps, policies)
best = select_best_path(installable_candidates)
install_best_path(loc_rib, best)
exported = prepare_export(best, export_peer_type, policies.export_policy)
Five lines, five phases: store, evaluate, select, install, export. Everything else in the file is either a data structure that keeps those phases from bleeding into each other, or a branch handling the case where a phase produces nothing.
Read it in this order and it unfolds cleanly:
PipelinePoliciesPipelineResultevaluate_candidate()apply_policy_action()process_single_route()
Two dataclasses that do the structural work
Start with the inputs and outputs, because they tell you what the pipeline thinks its own boundaries are.
@dataclass(frozen=True)
class PipelinePolicies:
import_policy: ImportPolicy
validation_policy: ValidationPolicy
export_policy: ExportPolicy
Three separate policy objects, not one blob of configuration. Import policy decides whether a route enters at all. Validation policy decides what an RPKI outcome means for this operator. Export policy decides what leaves. In a real speaker these live in different config sections for good reason, and the toy keeps that separation visible in the type.
@dataclass(frozen=True)
class PipelineResult:
prefix: str
received_validation_state: ValidationState
received_action: PolicyAction
candidate_count: int
selected_path: PathCandidate | None
selected_exported_path: PathCandidate | None
This one is worth staring at. Notice that it reports two different things about the same call:
received_validation_stateandreceived_actiondescribe the route that just arrived.selected_pathandselected_exported_pathdescribe whatever won for the prefix.
Those are not the same route. A perfectly valid route can arrive, pass validation, pass import policy, survive policy action — and still lose best-path to an incumbent. candidate_count is the tell: it exists so you can see that the pipeline compared N survivors, not that it rubber-stamped the one route you happened to feed it.
Note: Most toy pipelines you'll find online collapse this into a single "did the route get installed" boolean. That collapse is exactly what makes them useless as a mental model — it hides the fact that arrival and selection are separate events with separate outcomes.
Bridging two sessions with one function
def origin_as_from_attributes(attributes: PathAttributes) -> int:
if not attributes.as_path:
return 0
return attributes.as_path[-1]
Four lines, and they're the seam between Session 02 (attributes) and Session 04 (validation). Validation wants a BGPRoute with an origin_as. What actually arrived on the wire was a PathAttributes with an AS_PATH. Something has to convert one into the other, and in this pipeline it's this function.
It takes the rightmost AS in the path. Hold onto that — we'll come back to it when we talk about what this model leaves out.
Where a route lives or dies
evaluate_candidate() is the heart of the file:
def evaluate_candidate(
prefix: str,
attributes: PathAttributes,
vrps: list[VRP],
policies: PipelinePolicies,
) -> tuple[ValidationState, PolicyAction, PathCandidate | None]:
route = BGPRoute(prefix=prefix, origin_as=origin_as_from_attributes(attributes))
validation_state = validate_origin(route, vrps)
raw_candidate = candidate_from_attributes(prefix, attributes)
imported = apply_import_policy(raw_candidate, validation_state, policies.import_policy)
if imported is None:
return validation_state, PolicyAction.REJECT, None
action = decide_route_policy(validation_state, policies.validation_policy)
installed = apply_policy_action(imported, action)
return validation_state, action, installed
Read the order carefully, because the order is the lesson.
Validation runs first, and it produces a ValidationState — Valid, Invalid, NotFound. It does not reject anything. It just labels.
Then apply_import_policy() gets both the candidate and that label. This is the first place a route can vanish: if import policy returns None, the function short-circuits and reports PolicyAction.REJECT. The route never reaches best-path.
If it survives import, decide_route_policy() translates the validation state into a PolicyAction under the operator's validation policy. And that is a separate decision from the label itself. An RPKI-invalid route doesn't have to be dropped; the operator decides.
The action is then applied:
def apply_policy_action(
candidate: PathCandidate,
action: PolicyAction,
) -> PathCandidate | None:
if action is PolicyAction.REJECT:
return None
if action is PolicyAction.DEPRIORITIZE:
lowered = max(0, candidate.local_pref - 50)
return replace(candidate, local_pref=lowered)
return candidate
Three branches, three outcomes. REJECT removes the candidate. DEPRIORITIZE keeps it but knocks 50 off local_pref, floored at zero — the route stays eligible, it just loses ties it would otherwise win. Anything else passes through untouched.
This is the distinction that survives all the way into the full pipeline: a validation result is not a decision. ValidationState is a fact about the route. PolicyAction is what this operator has chosen to do about that fact. Two separate values, computed by two separate functions, both reported separately in PipelineResult.
The full chain
Now process_single_route(), which is where the state actually moves:
store_received_path(adj_rib_in, peer_id, prefix, attributes)
validation_state, action, _ = evaluate_candidate(prefix, attributes, vrps, policies)
installable_candidates: list[PathCandidate] = []
for _, received_attributes in received_attributes_for_prefix(adj_rib_in, prefix):
_, _, installable = evaluate_candidate(prefix, received_attributes, vrps, policies)
if installable is not None:
installable_candidates.append(installable)
The first line stores the raw received path in Adj-RIB-In — before any policy, before any validation, before any selection. That ordering matters and it isn't an accident: Adj-RIB-In holds what the peer said, not what you decided about it.
Then two evaluations happen, and they look redundant until you notice the discard. The first call's result is kept for reporting (validation_state, action) with the candidate thrown away via _. The loop then re-evaluates every stored path for the prefix, including the one that just arrived, and collects the survivors. That's the difference between "what did this update do" and "what does the prefix look like now."
The empty case comes next, and it's the branch most toy models skip entirely:
if not installable_candidates:
remove_best_path(loc_rib, prefix)
withdraw_staged_advertisement(adj_rib_out, export_peer_id, prefix)
return PipelineResult(
prefix=prefix,
received_validation_state=validation_state,
received_action=action,
candidate_count=0,
selected_path=None,
selected_exported_path=None,
)
If nothing survives, stale state has to go. Loc-RIB loses its entry, Adj-RIB-Out loses its staged advertisement. A pipeline that only handles the happy path leaves a withdrawn route advertised forever — which is a real bug class, not a hypothetical.
Otherwise:
best = select_best_path(installable_candidates)
install_best_path(loc_rib, best)
exported = prepare_export(best, export_peer_type, policies.export_policy)
if exported is not None:
stage_advertisement(adj_rib_out, export_peer_id, exported)
else:
withdraw_staged_advertisement(adj_rib_out, export_peer_id, prefix)
select_best_path() compares all survivors — this is the only place multiple candidates meet. install_best_path() writes to Loc-RIB. prepare_export() transforms the winner for the outbound peer, and can still return None, in which case the advertisement gets withdrawn rather than staged.
Three RIBs, three different moments:
| RIB | Holds | Changes at |
|---|---|---|
| Adj-RIB-In | raw received attributes, per peer | the very first line, before anything is decided |
| Loc-RIB | the selected PathCandidate |
after best-path selection |
| Adj-RIB-Out | the export-transformed candidate | after export policy, per outbound peer |
They hold different objects and they change at different points. That's the sentence to be able to say without notes.
Same shape, different protocol
The structure here — store what arrived, label it, decide what the label means, select among survivors, transform for output — is not BGP-specific. It's the shape of every control plane that accepts untrusted input and has to publish a decision.
DNS resolvers do it: the cache holds what the authoritative server said (Adj-RIB-In), DNSSEC validation labels it (ValidationState), local policy decides whether a bogus answer is served or SERVFAIL'd (PolicyAction), and the answer that goes back to the client is a transformed view of the stored record (Adj-RIB-Out). The names differ; the branches are the same.
Once you can see that shape, reading a new protocol's control plane becomes a matter of finding where each stage lives rather than learning it from scratch.
Run it
The walkthrough is executable:
PYTHONPATH=src python3 examples/bgp/session_10_walkthrough.py
It seeds one existing candidate first, then introduces a second valid route and recomputes the whole prefix through import policy, validation, policy action, best-path, Loc-RIB installation, and eBGP export. The printed PipelineResult distinguishes the received route's validation state and action from the selected route that actually won for the prefix — which is precisely the distinction that's easy to nod along to on the page and easy to get wrong in your head.
Watch candidate_count in the output. If it's ever 1 when you expected 2, you've learned something about where a route disappeared.
Toy model boundary
This is a pipeline, not a BGP speaker. Being specific about the gap:
No session layer. There's no peer state machine, no OPEN/KEEPALIVE/NOTIFICATION handling, no hold timers. process_single_route() assumes a route arrived from a peer that exists and is established. Real speakers spend most of their code on the part this file assumes away.
No event dispatch. A real speaker has an event loop: updates arrive, timers fire, sessions flap, and each event schedules work. Here you call a function and it runs to completion synchronously.
No export refresh after every recompute. The pipeline updates Adj-RIB-Out for the single export_peer_id passed in. A real speaker re-evaluates export for all peers whenever Loc-RIB changes, and handles route refresh requests. This model does one peer, one call.
Origin AS is a shortcut. origin_as_from_attributes() takes the rightmost AS in AS_PATH. That's a teaching simplification. Real origin determination has to deal with AS_SET segments, AS path prepending edge cases, confederations, and empty paths from iBGP-originated routes. The function returns 0 for an empty path, which is a sentinel, not a real AS.
The integration order is a choice. The course separated best-path, validation, and policy so each could be learned alone. Reconnecting them required picking an order, and this pipeline lets validation influence import and policy before local installation. Real implementations put those boundaries in different places — some run RPKI validation earlier, some fold it into import policy entirely. The order here is pedagogically clean, not canonical.
No withdrawal handling from peers. Stale state is cleaned up when nothing survives evaluation, but there's no path for a peer explicitly withdrawing a prefix.
None of these make the model wrong. They make it small — and small enough to read is the whole point. But don't carry the diagram in this file into a conversation about a production speaker without carrying these caveats too.
Check yourself
Close the article and answer these from the source alone. If you have to guess, open pipeline.py and trace it.
- Which single step can drop a candidate before it ever reaches best-path — and what does the pipeline report as the action when that happens?
- Which step lowers local preference without rejecting the route, and what's the floor?
- Which step is the only one that compares multiple surviving candidates, and which step changes the outbound AS path?
They're not trick questions. Every answer is a specific function name in a 130-line file.
Further reading
- RFC 4271 — A Border Gateway Protocol 4 (BGP-4) — §3.2 on the three RIBs, §9.1 on the decision process
- RFC 6811 — BGP Prefix Origin Validation — the Valid / Invalid / NotFound states and the explicit separation of validation from local policy
- RFC 6480 — An Infrastructure to Support Secure Internet Routing — where VRPs come from
- RFC 7454 — BGP Operations and Security — what import and export policy look like in practice