Originally published at https://blog.pathvector.dev/protocol-in-code-bgp-07/ — part of the free Protocol Lab series.
This post is part of Protocol in Code, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. Every module points at one real Python file and asks you to read it the way you'd read any other code: what comes in, what mutates, where does control leave early. The source lives at github.com/pathvector-studio/protocol-in-code.
Note: If you're newer to this and want to run things before you read things, start with Protocol Lab — the hands-on companion series that builds the muscle memory this one assumes.
The question
How does local import policy change or reject a path before best-path selection runs?
That's the whole module in one line, and it hides a claim worth being suspicious of. Best-path selection in BGP is the famous part — the ordered tiebreaker list everyone half-remembers: highest weight, highest local_pref, shortest AS path, and so on. It's easy to treat that comparison as the decision point, as if routes arrive from peers and get ranked.
They don't arrive and get ranked. They arrive, get rewritten, and then get ranked. Import policy is a function that runs between the wire and the comparison, and it has two powers: it can change the values the comparison reads, and it can make the candidate not exist at all.
Which means the interesting question isn't "who won best-path" but "what did best-path actually receive."
Read the code
The file is src/protocol_in_code/bgp/import_policy.py. It's short enough to hold in your head all at once, which is the point — the shape is the lesson.
Start with the policy object:
@dataclass(frozen=True)
class ImportPolicy:
local_pref_override: int | None = None
weight: int = 0
reject_next_hops: tuple[str, ...] = ()
reject_invalid: bool = False
Four knobs, and notice they're not four of the same thing. Two of them (local_pref_override, weight) change a value. Two of them (reject_next_hops, reject_invalid) delete the route. A frozen dataclass, so the policy itself is immutable — the policy doesn't accumulate state across candidates, it's just a description of a transformation.
Also notice the default of local_pref_override: it's None, not 0 or 100. That's load-bearing. weight defaults to 0 because zero is a meaningful weight; local_pref_override needs a sentinel because there's no integer that means "don't touch this."
Now the function:
def apply_import_policy(
candidate: PathCandidate,
validation_state: ValidationState,
policy: ImportPolicy,
) -> PathCandidate | None:
The return type tells you the story before you read a single line of the body: PathCandidate | None. This function is allowed to return nothing. A path goes in and possibly no path comes out — the candidate is gone before best-path selection has any opinion about it.
Read the body in the order it executes:
if candidate.next_hop in policy.reject_next_hops:
return None
if validation_state is ValidationState.INVALID and policy.reject_invalid:
return None
Rejection first, both times. These are early returns, and their position matters: nothing gets rewritten before it gets dropped. There's no point spending work on a candidate that's about to stop existing, and — more importantly for reading — it means you can answer "does this route survive?" without reading the second half of the function at all.
The two rejections are different in kind. The first is unconditional local intent: this next-hop is on the list, so no. The second is conditional on a value computed elsewhere — validation_state comes in as a parameter, decided by validation logic this file never sees. And it's gated by policy.reject_invalid. Read that conjunction carefully:
if validation_state is ValidationState.INVALID and policy.reject_invalid:
INVALID alone doesn't drop the route. The local operator has to have opted in. This is the module's real point about validation: validation produces a result, and policy decides what that result does. A route can be known-invalid and still sail through to best-path selection if reject_invalid is False. That separation — result versus action — is the thing Session 05 set up, and here's where it gets consumed.
Then the rewrite half:
updated = candidate
if policy.local_pref_override is not None:
updated = replace(updated, local_pref=policy.local_pref_override)
if policy.weight != updated.weight:
updated = replace(updated, weight=policy.weight)
return updated
replace() is dataclasses.replace — it builds a new frozen PathCandidate with one field swapped. Nothing mutates. updated is rebound, never modified in place, and the original candidate the caller passed in is still intact afterward. If you've been reading this codebase in order, that pattern should be familiar by now: every stage returns a new value rather than editing the one it received, which is what makes it possible to reason about a pipeline stage in isolation.
The two conditions guarding the rewrites are worth comparing side by side, because they don't match:
-
if policy.local_pref_override is not None:— a sentinel check. Is an override configured at all? -
if policy.weight != updated.weight:— a difference check. Is the configured weight already what the candidate has?
The second one is an optimization more than a semantic: with the default weight=0 and a candidate that also has weight=0, you skip building a new object for no change. But it also means weight has no "unset" state. Leaving weight alone in your policy isn't neutral — it's asserting 0. If a candidate arrived with a non-zero weight from somewhere upstream, a policy that never mentions weight will still flatten it back to 0. Whether that's a bug or the intended semantics is exactly the kind of thing worth deciding for yourself by reading it.
What this does to best-path
Here's the connection that makes the module worth the time. local_pref and weight are not arbitrary fields — they're the top two inputs to the best-path comparison. Import policy writes directly into the highest-priority tiebreakers before the tiebreakers run.
So the pipeline is:
- A peer sends you a path with whatever attributes it chose.
- Validation produces a
ValidationStatefor it. -
apply_import_policyeither drops it or hands back a modified copy. - Best-path selection compares what survived.
Step 4 has no idea steps 1–3 happened. It sees a PathCandidate with a local_pref and a weight and compares them, and it cannot distinguish "the peer sent this" from "we wrote this a microsecond ago." That's the sentence to walk away with: the candidate entering best-path is not always the one the peer originally sent.
This is also why "the same route looks different on different routers" isn't a mystery. Two routers receiving the identical UPDATE can hand two different candidates to their comparison logic, because import policy is local. Nothing about it is negotiated with the peer. The peer doesn't know and can't tell.
Same shape, different protocol
Once you've seen this shape you'll notice it everywhere: a transform stage that sits in front of a decision stage and quietly rewrites the decision's inputs.
It's the same structure as a firewall's mangle table rewriting packet marks before the routing decision reads them — the routing table lookup doesn't know the mark was synthetic. It's the same structure as a DNS resolver's local overrides answering before recursion happens. It's the same structure as a TLS stack's cipher-suite preference list reordering what the handshake "sees" as offered.
In each case the decision logic is honest and deterministic, and the interesting behavior lives entirely in what got fed to it. When a protocol surprises you, the transform stage in front of the decision is usually a better place to look than the decision itself.
Run it
The walkthrough is executable:
PYTHONPATH=src python3 examples/bgp/session_07_walkthrough.py
It runs four candidates through apply_import_policy: one that gets its local_pref rewritten, one that picks up a local weight, one dropped by the next-hop rule, and one invalid path rejected before best-path ever sees it. Watch which ones come back as objects and which come back as None — that binary is the entire first half of the function, made visible.
Then change the policy and run it again. Set reject_invalid=False and watch the invalid path survive. Set local_pref_override without weight and see exactly one field move.
Toy model boundary
This is a toy model, and being precise about what it isn't is the point of this section.
Real import policy is a rule chain, not a single struct. Vendor implementations evaluate an ordered list of route-map or filter clauses, each with match conditions and set actions, with terminating and continue semantics. ImportPolicy here is one flat set of unconditional knobs applied to every candidate — there is no "match prefix 10.0.0.0/8 then set local-pref 200, else next clause." That ordering and matching is most of the complexity of real policy, and none of it is here.
Only two attributes are writable. Real policy can rewrite MED, prepend or replace the AS path, add, remove, and modify communities and large communities, set the next-hop, tag the route, and more. This model touches local_pref and weight because those are the two that most cleanly demonstrate "policy writes best-path's inputs."
Rejection is a return value, not a state. Here a dropped path simply ceases to exist. Real implementations distinguish between filtered-at-input routes that are discarded and routes retained in an Adj-RIB-In (soft reconfiguration) so policy can be re-applied without bouncing the session. There's no RIB in this file at all.
weight isn't a protocol field. local_pref is a real BGP path attribute carried in UPDATE messages within an AS. weight is a vendor-local, Cisco-originated concept that never leaves the router and never appears on the wire. Putting them adjacent in one dataclass is pedagogically useful and protocol-wise a bit of a lie — worth knowing before you go looking for weight in a packet capture.
No prefix matching, no peer scoping, no direction. Real policy is bound per-neighbor and per-address-family, and there's a symmetric export policy on the other side doing the same kind of rewriting outbound. This function takes one candidate with no notion of which peer it came from.
Validation state arrives from nowhere. validation_state is a parameter. In a real system it comes from RPKI-to-Router state that changes asynchronously as VRPs are added and withdrawn, which raises re-evaluation questions this model doesn't have to answer.
Check yourself
Don't look these up — answer them by reading import_policy.py and then confirm with the walkthrough.
-
Exactly which conditions cause
apply_import_policyto returnNone? Can you state both, including what has to be true for the second one to fire? -
If a policy sets only
local_pref_overrideand leaves everything else at its default, what does the returnedPathCandidatelook like? Be specific aboutweight— is it untouched, or not? - Why is rewriting an input a fundamentally different operation from rejecting a path? Both change the outcome of best-path selection. What can the downstream comparison logic tell about which one happened to it?
You should be able to finish this module able to say, without notes: import policy runs before best-path; it can rewrite local_pref and weight; it can drop a path before comparison; and the candidate entering best-path is not always the one the peer sent.
Top comments (0)