pathvector-dev

Originally published at https://blog.pathvector.dev/protocol-in-code-bgp-05/ — part of the free Protocol Lab series.

This post is part of Protocol in Code, a free series that reads network protocols not as configuration examples but as logic with inputs, state, and branches — actual code you can read and run. The whole series lives here: github.com/pathvector-studio/protocol-in-code. If you're newer to this material and want a more hands-on, guided on-ramp first, start with the companion Protocol Lab series and come back.

Today's module is from the BGP track, Session 05. The source file is src/protocol_in_code/bgp/policy.py, and it builds directly on the origin-validation logic from Session 04.

The question to keep in your head

Here's the one thing to turn over as you read:

What happens after origin validation returns valid, invalid, or not_found — and why does the result still need routing policy before anything happens to the route?

There's a piece of folk knowledge that says "RPKI invalid means the router rejects the route." It's the kind of statement that sounds like a rule of the protocol. It isn't. It's one possible policy decision built on top of a validation result. The whole point of this session is to separate those two things in your head, and the code makes the seam impossible to miss.

Two layers, not one

Validation answers a factual question: does this route's origin AS match what the ROAs say it should be? That's Session 04's job, and its output is a ValidationState.

Policy answers a completely different question: given that fact, what do we do? Drop the route? Keep it but make it less preferred? Accept it normally? That's a local decision — different operators configure it differently, and the same validation result can lead to different actions on different routers.

The file models the second layer with three small pieces. First, the set of actions the router can take:

class PolicyAction(str, Enum):
    ACCEPT = "accept"
    DEPRIORITIZE = "deprioritize"
    REJECT = "reject"

Enter fullscreen mode Exit fullscreen mode

Notice there are three outcomes, not two. Reject is real, but so is "keep it, just prefer it less." That middle option is exactly what gets lost when people compress the whole thing to "invalid → drop."

Then the knobs — the local configuration that decides how those actions get chosen:

@dataclass(frozen=True)
class ValidationPolicy:
    reject_invalid: bool = False
    deprioritize_not_found: bool = False

Enter fullscreen mode Exit fullscreen mode

Two booleans, both defaulting to False. Read the defaults carefully, because they're a statement in themselves: out of the box, this policy neither rejects invalid routes nor treats not-found routes with suspicion. The safe-sounding "invalid gets dropped" behavior is opt-in via reject_invalid, not the default.

Reading the decision

Everything comes together in one function. Read it top to bottom — the control flow is the specification:

def decide_route_policy(
    validation_state: ValidationState,
    policy: ValidationPolicy,
) -> PolicyAction:
    if validation_state is ValidationState.INVALID:
        if policy.reject_invalid:
            return PolicyAction.REJECT
        return PolicyAction.DEPRIORITIZE

    if validation_state is ValidationState.NOT_FOUND and policy.deprioritize_not_found:
        return PolicyAction.DEPRIORITIZE

    return PolicyAction.ACCEPT

Enter fullscreen mode Exit fullscreen mode

Trace the branches as inputs, not as prose.

INVALID is handled first, and it never falls through to ACCEPT. Once the state is invalid, the only two reachable returns are REJECT and DEPRIORITIZE. Which one you get depends entirely on the reject_invalid knob:

  • reject_invalid=TruePolicyAction.REJECT — the route is dropped.
  • reject_invalid=FalsePolicyAction.DEPRIORITIZE — the route stays in the table, just less preferred.

That inner if/else is the entire "reject vs. deprioritize" debate rendered as two lines. An invalid route does not disappear on its own; a human's configuration decided its fate.

NOT_FOUND only does something if you asked it to. The second branch fires only when the state is NOT_FOUND and deprioritize_not_found is set. Given how much of the routing table is still not covered by ROAs, "not found" is the common case, and the default here is to let it through:

  • deprioritize_not_found=TruePolicyAction.DEPRIORITIZE
  • deprioritize_not_found=False → falls through to the final return PolicyAction.ACCEPT

Everything else accepts. VALID, and any NOT_FOUND you didn't ask to deprioritize, land on the last line. There is no branch that turns a valid result into anything other than ACCEPT — validation success buys you normal treatment, nothing more.

Note: The absence of a branch is as informative as its presence. There's no path from VALID to REJECT, and no path from INVALID to ACCEPT. The reachable outcomes for each state are baked into the structure of the ifs — you can read the whole policy space just by asking which return each state can reach.

The reading move

The habit this session is trying to build is small but load-bearing. Stop saying:

"RPKI invalid means the router rejects the route."

Start asking three questions instead:

  1. What validation state came back?
  2. What local policy is configured?
  3. What concrete action follows from that policy?

The answer to question 1 alone never determines the outcome. It's always 1 and 2, resolved by decide_route_policy, that produce question 3's answer.

This is the same shape you'll see over and over in the series once you start looking for it: a lookup or check produces a state, and a separate, locally-configured policy decides what that state means for behavior. It's the same split as a DNS resolver distinguishing "the record didn't validate" from "what do I serve the client," or a TLS stack distinguishing certificate-verification failure from the application's decision to proceed anyway. Validation is a fact. Policy is a choice. Keeping them in different functions is what lets one operator reject invalids while the next merely frowns at them.

Run it and watch the combinations

The walkthrough prints several combinations of validation result and local policy and shows the resulting action, so you can check your reading against the actual output:

PYTHONPATH=src python3 examples/bgp/session_05_walkthrough.py

Enter fullscreen mode Exit fullscreen mode

Before you run it, try to predict each line. Pick a ValidationState, pick the two booleans, and walk the branches yourself. Then confirm. The goal isn't to memorize the output — it's to get to where the output holds no surprises because you already ran the function in your head.

Toy Model Boundary

This model is deliberately small, and it's worth being precise about what it leaves out, because the gap between this and a production router is large.

  • Only three actions exist here. Real BGP policy does far more than accept / deprioritize / reject. "Deprioritize" in the wild means concrete knobs — lowering LOCAL_PREF, prepending to the AS path, tagging communities — and the ranking interacts with the full best-path selection algorithm. This model collapses all of that into a single enum value.
  • Validation state is the only input. A real policy also weighs prefix length, neighbor, communities, AS-path contents, max-prefix limits, and more. Here, decide_route_policy sees exactly one signal.
  • Two boolean knobs stand in for a policy language. Production route policy is written in expressive, vendor-specific configuration (route-maps, RPL, routing-policy blocks) with ordering and match/set semantics. reject_invalid and deprioritize_not_found are a teaching-sized caricature of that surface.
  • No time, no churn, no state machine. There's no notion of ROA updates arriving, routes being re-evaluated, or the RPKI-to-Router protocol feeding fresh data. The function is a pure input-to-output decision, evaluated once.
  • The valid state gets no special treatment. RFC 6811 leaves room for policy that actively prefers valid routes; here, valid just means "accept normally," identical to an un-deprioritized not_found.

None of this makes the model wrong — it makes it readable. But when you move to real gear, expect the two-layer split to survive and everything downstream of it to get much larger.

Check yourself

Don't take my walkthrough as the answer. Open policy.py and see if you can answer these purely by reading the code — no running required:

  1. What happens to an invalid route when reject_invalid is False? What about when it's True? Which line makes each true?
  2. Can any configuration of ValidationPolicy turn a not_found route into a REJECT? Trace the branches and be sure.
  3. Why is policy kept in a separate function and separate layer from validation at all — what would you lose if decide_route_policy had to do the validation itself?

If you can answer all three by pointing at specific lines, you've got the session.

Further reading

  • RFC 6811, Section 2.1 — the validation states and what they mean.
  • RFC 6811, Section 3 — where the spec is explicit that using the validation result is a matter of local policy, not a mandated action.