<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: pathvector-dev</title>
    <description>The latest articles on DEV Community by pathvector-dev (@pathvector-dev).</description>
    <link>https://dev.to/pathvector-dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024552%2Ff09668fa-231d-4239-8826-bba1552bc241.png</url>
      <title>DEV Community: pathvector-dev</title>
      <link>https://dev.to/pathvector-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pathvector-dev"/>
    <language>en</language>
    <item>
      <title>Announce, withdraw, and peer down are three different code paths</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Sat, 01 Aug 2026 00:00:13 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/announce-withdraw-and-peer-down-are-three-different-code-paths-26d4</link>
      <guid>https://dev.to/pathvector-dev/announce-withdraw-and-peer-down-are-three-different-code-paths-26d4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-13/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-13/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. Every module points at a real Python file you can open, read, and run; the source lives at &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;. If you're newer to this and want to build up hands-on first, start with the companion &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series, which is the practical, run-it-yourself track.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;When a BGP speaker receives news from the outside world, that news arrives in a few distinct flavors. A peer announces a prefix. A peer withdraws a prefix. A peer's session drops entirely. It's tempting to model all three as "an update happened, go recompute" — one generic entry point with a flag or two.&lt;/p&gt;

&lt;p&gt;So here's the question to hold onto while you read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Core question:&lt;/strong&gt; What function decides which control-plane path to run for announce, withdraw, and peer-down events?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And its sharper follow-up: why are these three separate functions instead of one? The answer is not stylistic. Each event enters the control plane at a different point, and one of them can touch an unbounded number of prefixes.&lt;/p&gt;

&lt;p&gt;The file is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/events.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/events.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three triggers, not one
&lt;/h2&gt;

&lt;p&gt;Start at the top of the file. The events are declared as three separate frozen dataclasses, and the shape of each one already tells you something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AnnounceEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathAttributes&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WithdrawEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PeerDownEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read them as a narrowing sequence. An announce carries the full payload: who, what prefix, and what path attributes came with it. A withdraw carries who and what prefix — there are no attributes, because you're removing a path, not describing one. A peer-down carries only who. It doesn't name a prefix at all, and that omission is the whole reason the third branch has to be written differently: the set of affected prefixes isn't in the event, it has to be discovered from state.&lt;/p&gt;

&lt;p&gt;All three converge on one return type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EventResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;touched_prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
    &lt;span class="n"&gt;best_paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;export_changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;touched_prefixes&lt;/code&gt; is a tuple and &lt;code&gt;best_paths&lt;/code&gt; is a dict keyed by prefix. The return type was designed for the many-prefix case even though two of the three branches will only ever fill it with one entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the dispatch
&lt;/h2&gt;

&lt;p&gt;Before walking through the real code, here's the shape in pseudocode. This is the lesson; the actual functions are longer, but they don't deviate from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;
    &lt;span class="n"&gt;recompute&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;
    &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="n"&gt;exports&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;withdraw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;remove&lt;/span&gt; &lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
    &lt;span class="n"&gt;recompute&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;
    &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="n"&gt;exports&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;peer_down&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;remove&lt;/span&gt; &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;
    &lt;span class="n"&gt;recompute&lt;/span&gt; &lt;span class="n"&gt;affected&lt;/span&gt; &lt;span class="n"&gt;prefixes&lt;/span&gt;
    &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="n"&gt;exports&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things stand out. Only the announce branch has a gate. Every branch ends in the same two steps — recompute, then refresh exports. And only the last branch has a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Announce: gate first, then recompute
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;process_announce_event()&lt;/code&gt; starts with the session gate that Session 11 made explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;receive_update_if_established&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EventResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;touched_prefixes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
            &lt;span class="n"&gt;best_paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;best_paths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
            &lt;span class="n"&gt;export_changes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An announce from a peer that isn't in &lt;code&gt;ESTABLISHED&lt;/code&gt; doesn't get to write into the Adj-RIB-In at all. Look carefully at the early return: it doesn't just bail with a flag. It reports &lt;code&gt;touched_prefixes&lt;/code&gt; containing the prefix, and it reports the &lt;em&gt;current&lt;/em&gt; best path from &lt;code&gt;loc_rib.best_paths.get(event.prefix)&lt;/code&gt; — the one that was already there. &lt;code&gt;export_changes&lt;/code&gt; is empty. The rejected announce is visible in the result, but it changed nothing.&lt;/p&gt;

&lt;p&gt;That's a deliberate distinction worth internalizing: "we looked at this prefix" and "this prefix changed" are different claims, and the result type keeps them separate.&lt;/p&gt;

&lt;p&gt;If the gate passes, the rest is two calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_recompute_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;refresh_exports_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_targets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_recompute_prefix()&lt;/code&gt; is where the event layer hands off all the prefix-level decision work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_recompute_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocRIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VRP&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PipelinePolicies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select_best_installable_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;remove_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="nf"&gt;install_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helper is the reason the three branches stay short. Every hard question — which candidate wins the decision process, whether RPKI validation rejects it, what the policies say — lives behind &lt;code&gt;select_best_installable_for_prefix()&lt;/code&gt;. The event layer only cares about the binary outcome: there's a best path (install it) or there isn't (remove whatever was installed). Session 14 goes deeper into that decision logic, but the dispatch shape you're reading here doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Withdraw: no gate, and it starts by deleting
&lt;/h2&gt;

&lt;p&gt;Now compare &lt;code&gt;process_withdraw_event()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_withdraw_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WithdrawEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocRIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;export_targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExportTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
    &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VRP&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PipelinePolicies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EventResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;withdraw_received_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_recompute_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;refresh_exports_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_targets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two differences from announce, and both are in the signature and the first line.&lt;/p&gt;

&lt;p&gt;First, look at the parameter list: there's no &lt;code&gt;peers&lt;/code&gt; argument. The withdraw path doesn't have access to the session table, which means it structurally &lt;em&gt;cannot&lt;/em&gt; gate on session state. That's not an omission — it's the design being enforced by the type signature.&lt;/p&gt;

&lt;p&gt;Second, the first statement is a removal, not a validation. There is no &lt;code&gt;accepted&lt;/code&gt; check and the function always returns &lt;code&gt;accepted=True&lt;/code&gt;. Withdrawing state you may or may not have is safe; adding state you shouldn't have is not. Removal is idempotent in a way that installation isn't, so the asymmetry is justified rather than sloppy.&lt;/p&gt;

&lt;p&gt;After that first line, the two branches are identical: recompute, refresh. The withdrawal might not even change the best path — if the withdrawing peer wasn't the winner, &lt;code&gt;select_best_installable_for_prefix()&lt;/code&gt; will return the same candidate it did before, and &lt;code&gt;refresh_exports_for_prefix()&lt;/code&gt; will produce no changes. The code doesn't special-case that. It recomputes unconditionally and lets the export refresh decide whether anything is actually different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Peer down: one event, many prefixes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;process_peer_down_event()&lt;/code&gt; is where the shape breaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;lost_prefixes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paths_by_peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paths_by_peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PeerSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is the crux of this whole module. Because &lt;code&gt;PeerDownEvent&lt;/code&gt; carries no prefix, the affected set has to be &lt;em&gt;read out of state&lt;/em&gt; before that state is destroyed. Order matters: snapshot the keys, then pop the peer's table. Reverse those two lines and you lose the list of prefixes you were about to fix up.&lt;/p&gt;

&lt;p&gt;Note also &lt;code&gt;.get(event.peer_id, {})&lt;/code&gt; and &lt;code&gt;.pop(event.peer_id, None)&lt;/code&gt; — a peer-down for a peer with no received paths, or no entry at all, is a no-op rather than a &lt;code&gt;KeyError&lt;/code&gt;. Session teardown races are exactly the situation where you get duplicate or spurious down events.&lt;/p&gt;

&lt;p&gt;The session state moves to &lt;code&gt;ACTIVE&lt;/code&gt;, not &lt;code&gt;IDLE&lt;/code&gt;. That's the FSM's "trying to connect" state, which is where a peer that just dropped should sit. And because &lt;code&gt;PeerSession&lt;/code&gt; is frozen, the update is a replacement of the dict entry, not a mutation.&lt;/p&gt;

&lt;p&gt;Then comes the loop that no other branch has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lost_prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_recompute_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;refresh_exports_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_targets&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EventResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;touched_prefixes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;best_paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;export_changes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same two-step body as announce and withdraw — &lt;code&gt;_recompute_prefix()&lt;/code&gt; then &lt;code&gt;refresh_exports_for_prefix()&lt;/code&gt; — wrapped in a &lt;code&gt;for&lt;/code&gt;. And note what it does &lt;em&gt;not&lt;/em&gt; do: it doesn't blanket-withdraw every prefix the peer had announced. For each lost prefix it re-runs the decision process against what's left in the Adj-RIB-In. If another peer was also announcing that prefix, &lt;code&gt;_recompute_prefix()&lt;/code&gt; finds it and installs it, and the export refresh emits a change to the new next hop rather than a withdrawal. Only prefixes where the downed peer was the sole source collapse to &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the practical answer to the "which branch touches many prefixes" question, and it's why a single generic update function would have been awkward: two of the three branches know their prefix up front, and one has to go find it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Recomputing per prefix in a loop is also where real BGP implementations spend serious engineering effort. A peer down on a full-table session means several hundred thousand recomputations. Real implementations batch, defer, and prioritize; this toy model just loops.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reading lens: the same shape shows up elsewhere
&lt;/h2&gt;

&lt;p&gt;The pattern here — &lt;em&gt;the event doesn't name its own blast radius, so you snapshot state before destroying it&lt;/em&gt; — isn't BGP-specific. It's the same shape as conntrack entry expiry, where a single timeout has to enumerate the flows it invalidates before dropping the table entry. It's the same shape as a DNS resolver flushing everything under a zone when the zone's delegation changes. In each case the trigger is small and singular, and the cleanup has to derive its own scope from state that's about to go away.&lt;/p&gt;

&lt;p&gt;Once you've seen the announce/withdraw/peer-down split, you'll notice that most protocol event loops have exactly this trio: a gated additive event, an ungated removal, and a teardown that fans out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The walkthrough for this session is runnable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_13_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for four things in the output, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one announce producing a best path&lt;/li&gt;
&lt;li&gt;one withdraw removing that best path&lt;/li&gt;
&lt;li&gt;a second announce restoring it&lt;/li&gt;
&lt;li&gt;one peer-down withdrawing all affected exports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is the payoff. Compare the length of &lt;code&gt;touched_prefixes&lt;/code&gt; in that final result against the three before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is a teaching model, and it simplifies in ways that matter if you carry the mental model into production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Events are synchronous and serialized.&lt;/strong&gt; Each &lt;code&gt;process_*_event()&lt;/code&gt; call runs to completion before the next event exists. Real BGP speakers process a stream of UPDATE messages off a TCP socket, with input queues, read batching, and no guarantee that a peer-down is observed before or after the updates that were in flight when the session dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real UPDATE message carries both announcements and withdrawals.&lt;/strong&gt; RFC 4271's UPDATE has a Withdrawn Routes field &lt;em&gt;and&lt;/em&gt; an NLRI field in the same message. Splitting them into &lt;code&gt;AnnounceEvent&lt;/code&gt; and &lt;code&gt;WithdrawEvent&lt;/code&gt; is a modeling choice that makes the branches legible; a real parser has to handle both in one message and get the ordering right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No timers.&lt;/strong&gt; There's no MRAI (Minimum Route Advertisement Interval), no route flap damping, no graceful restart. In this model, peer-down means the paths are gone immediately. Real BGP with graceful restart keeps stale routes marked and installed while it waits for the session to come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The recompute is exhaustive, not incremental.&lt;/strong&gt; &lt;code&gt;_recompute_prefix()&lt;/code&gt; re-runs the full selection for a prefix every time. Production implementations track which candidate is currently best and short-circuit most updates without a full re-scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;export_changes&lt;/code&gt; is a return value, not I/O.&lt;/strong&gt; Nothing is transmitted. There are no OPEN/KEEPALIVE/NOTIFICATION messages, no hold timer, no actual peers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;peers[event.peer_id]&lt;/code&gt; in the announce path will &lt;code&gt;KeyError&lt;/code&gt;&lt;/strong&gt; on an unknown peer. That's fine for a model where you control the inputs; a real speaker never trusts the peer identifier that far.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Try to answer these by reading &lt;code&gt;events.py&lt;/code&gt; alone, without running anything. If you can't, that's the signal for where to look:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why is &lt;code&gt;process_withdraw_event()&lt;/code&gt; missing the &lt;code&gt;peers&lt;/code&gt; parameter that the other two branches take, and what would change if you added a session gate to it?&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;process_peer_down_event()&lt;/code&gt;, what breaks if you swap the order of the &lt;code&gt;lost_prefixes&lt;/code&gt; assignment and the &lt;code&gt;paths_by_peer.pop()&lt;/code&gt; call?&lt;/li&gt;
&lt;li&gt;An announce arrives from a peer in &lt;code&gt;OPEN_CONFIRM&lt;/code&gt;. Trace the return value: what is &lt;code&gt;accepted&lt;/code&gt;, what is in &lt;code&gt;touched_prefixes&lt;/code&gt;, and what is in &lt;code&gt;best_paths&lt;/code&gt;?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you have answers, go verify them against the source rather than against your memory of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Done when
&lt;/h2&gt;

&lt;p&gt;You can explain why &lt;code&gt;announce&lt;/code&gt;, &lt;code&gt;withdraw&lt;/code&gt;, and &lt;code&gt;peer down&lt;/code&gt; are three branches instead of one generic update function — and you can say, without looking, which branch touches exactly one prefix and which one can touch many.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271" rel="noopener noreferrer"&gt;RFC 4271 — A Border Gateway Protocol 4 (BGP-4)&lt;/a&gt; — §4.3 for the UPDATE message format, §9.1 for the decision process&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4724" rel="noopener noreferrer"&gt;RFC 4724 — Graceful Restart Mechanism for BGP&lt;/a&gt; — what a real speaker does on peer down instead of dropping paths&lt;/li&gt;
&lt;li&gt;Previous in this track: Session 11 (the session gate) and Session 12 (export refresh) — both are the pieces this dispatcher chooses between. Session 14 goes into the prefix-level decision logic that &lt;code&gt;_recompute_prefix()&lt;/code&gt; delegates to.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>protocol</category>
    </item>
    <item>
      <title>When Loc-RIB changes, every peer needs a different answer</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Fri, 31 Jul 2026 00:00:09 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/when-loc-rib-changes-every-peer-needs-a-different-answer-5ae3</link>
      <guid>https://dev.to/pathvector-dev/when-loc-rib-changes-every-peer-needs-a-different-answer-5ae3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-12/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-12/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. The source lives at &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;pathvector-studio/protocol-in-code&lt;/a&gt;, and every module points at a real Python file you can open, read, and run. If you're earlier in the journey and want hands-on packet-level exercises first, start with the companion &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series and come back here.&lt;/p&gt;

&lt;p&gt;Today's module is BGP Session 12: &lt;strong&gt;export refresh after recompute&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When &lt;code&gt;Loc-RIB&lt;/code&gt; changes, how do outbound advertisements get refreshed per peer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hold that question while you read. It sounds like plumbing, but it's the question that separates two things people often collapse into one: &lt;em&gt;what this router believes is the best path&lt;/em&gt;, and &lt;em&gt;what this router tells each neighbor&lt;/em&gt;. Those are different pieces of state, updated at different times, by different code.&lt;/p&gt;

&lt;p&gt;Two earlier sessions set this up. Session 08 showed how a single installed route becomes a single exported route — the policy pass that turns "I use this path" into "here is what I'll say about it." Session 09 showed how &lt;code&gt;Loc-RIB&lt;/code&gt; can change after a session goes down and best-path selection reruns. Session 12 is the seam between them: recompute already happened, &lt;code&gt;Loc-RIB&lt;/code&gt; already moved, and now something has to walk the peers and fix up &lt;code&gt;Adj-RIB-Out&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The file to open is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/export_refresh.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/export_refresh.py&lt;/code&gt;&lt;/a&gt;, with &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/export_policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;export_policy.py&lt;/code&gt;&lt;/a&gt; as the companion it leans on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Export is peer-specific, and the types say so
&lt;/h2&gt;

&lt;p&gt;Start with the data, not the function. The first thing worth noticing is that there is no such thing as "the export" of a prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExportTarget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;peer_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PeerType&lt;/span&gt;
    &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExportPolicy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;ExportTarget&lt;/code&gt; bundles three things: &lt;em&gt;who&lt;/em&gt; the peer is, &lt;em&gt;what kind&lt;/em&gt; of peer it is (customer, upstream, and so on), and &lt;em&gt;which policy&lt;/em&gt; applies. Nothing about the route is in here. That asymmetry is the design statement: the route is global to the router, the export decision is local to the peer.&lt;/p&gt;

&lt;p&gt;The output type mirrors it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExportChangeKind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ADVERTISE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;advertise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;WITHDRAW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;withdraw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExportChangeKind&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two kinds of change, and &lt;code&gt;path&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt; for one of them. A withdrawal carries no path — you're not telling the peer about a route, you're telling it to forget one. The type makes that impossible to get wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;ExportChangeKind&lt;/code&gt; subclasses &lt;code&gt;str&lt;/code&gt; as well as &lt;code&gt;Enum&lt;/code&gt;. That's a small ergonomic choice — it means the value compares equal to &lt;code&gt;"advertise"&lt;/code&gt; and serializes cleanly — not a protocol fact. Don't read anything into it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Three values, one peer
&lt;/h2&gt;

&lt;p&gt;Now the function. The whole module is one loop, and the loop body juggles exactly three values per peer. Get those three straight and you've got the session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;refresh_exports_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocRIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBOut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExportTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;best_paths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;installed&lt;/code&gt; is read &lt;strong&gt;once&lt;/strong&gt;, before the loop. That's the "what this router believes" value, and it's the same for every peer — that's the whole point of &lt;code&gt;Loc-RIB&lt;/code&gt;. Note the &lt;code&gt;.get()&lt;/code&gt;: if recompute removed the prefix entirely, &lt;code&gt;installed&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;, and the loop below still runs. A prefix disappearing is not a special case here; it's the same code path with a different input.&lt;/p&gt;

&lt;p&gt;Then, per peer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;current_by_prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advertisements_by_peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_by_prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;installed&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prepare_export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;installed&lt;/code&gt; — what &lt;code&gt;Loc-RIB&lt;/code&gt; says the router uses. One value, shared.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;current&lt;/code&gt; — what this peer has &lt;em&gt;already been told&lt;/em&gt;. Read out of &lt;code&gt;Adj-RIB-Out&lt;/code&gt;, per peer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;desired&lt;/code&gt; — what this peer &lt;em&gt;should&lt;/em&gt; be told. &lt;code&gt;installed&lt;/code&gt; run through &lt;code&gt;prepare_export()&lt;/code&gt; with this peer's type and policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;desired&lt;/code&gt; starts at &lt;code&gt;None&lt;/code&gt; and only becomes a path if &lt;code&gt;installed&lt;/code&gt; exists. And even when &lt;code&gt;installed&lt;/code&gt; exists, &lt;code&gt;prepare_export()&lt;/code&gt; may return &lt;code&gt;None&lt;/code&gt; — that's policy denying the export. Both roads lead to the same place, which is why the next branch doesn't care which one you took.&lt;/p&gt;

&lt;h2&gt;
  
  
  The branch that matters
&lt;/h2&gt;

&lt;p&gt;Here's the reconciliation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;current_by_prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ExportChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WITHDRAW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;current_by_prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;
            &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;ExportChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ExportChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADVERTISE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a two-by-two table of &lt;code&gt;current&lt;/code&gt; against &lt;code&gt;desired&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;current&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;desired&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing — peer never knew, still doesn't&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;withdraw&lt;/strong&gt;, and drop the entry from &lt;code&gt;Adj-RIB-Out&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;advertise&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;set, equal&lt;/td&gt;
&lt;td&gt;nothing — no wire churn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;set, different&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;advertise&lt;/strong&gt; the new value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two "nothing" rows are the ones worth pausing on. The inner &lt;code&gt;if current is not None&lt;/code&gt; guard means you don't emit a withdrawal for a route the peer was never told about — BGP has no way to un-say something you never said. And &lt;code&gt;if current != desired&lt;/code&gt; means an unchanged export produces no &lt;code&gt;ExportChange&lt;/code&gt; at all. That second one is why this is called a &lt;em&gt;refresh&lt;/em&gt; and not a &lt;em&gt;resend&lt;/em&gt;: recompute might touch a prefix and, after policy, produce byte-identical output for a peer. That peer hears nothing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;desired is None&lt;/code&gt; branch is where withdrawal is &lt;em&gt;born&lt;/em&gt;. Nothing upstream decided "send a withdraw" — it's an emergent consequence of &lt;code&gt;Adj-RIB-Out&lt;/code&gt; holding a value that policy no longer justifies. Notice too that both the state mutation and the change record happen together in each branch: &lt;code&gt;Adj-RIB-Out&lt;/code&gt; is the durable state, the returned &lt;code&gt;list[ExportChange]&lt;/code&gt; is the delta. They never diverge because they're written in the same two lines.&lt;/p&gt;

&lt;p&gt;That structure also answers the "why a separate pass?" part of the session. Best-path selection produces one answer per prefix. Export produces N answers per prefix, one per peer, and each one depends on peer-local state (&lt;code&gt;current&lt;/code&gt;) that best-path selection has no business knowing about. Fusing them would mean best-path selection carrying a peer loop inside it. Splitting them means recompute can run, settle, and &lt;em&gt;then&lt;/em&gt; hand a stable &lt;code&gt;Loc-RIB&lt;/code&gt; to a pass whose only job is per-peer reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The module ships a runnable walkthrough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_12_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to look for in the output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An advertisement to the customer peer.&lt;/strong&gt; &lt;code&gt;installed&lt;/code&gt; exists, policy permits, &lt;code&gt;current&lt;/code&gt; was empty → &lt;code&gt;ADVERTISE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No advertisement to the denied upstream peer.&lt;/strong&gt; &lt;code&gt;installed&lt;/code&gt; exists, but &lt;code&gt;prepare_export()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt;. &lt;code&gt;desired is None&lt;/code&gt;, &lt;code&gt;current is None&lt;/code&gt;, so the &lt;code&gt;continue&lt;/code&gt; fires and nothing is emitted. Silence, not a withdraw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A withdrawal when the installed path disappears.&lt;/strong&gt; &lt;code&gt;loc_rib.best_paths.get(prefix)&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt;, so &lt;code&gt;desired&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt; for every peer — but only the peers with a non-&lt;code&gt;None&lt;/code&gt; &lt;code&gt;current&lt;/code&gt; get a &lt;code&gt;WITHDRAW&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Case 2 and case 3 both hit &lt;code&gt;desired is None&lt;/code&gt;. Only one of them produces output. That difference is entirely &lt;code&gt;current&lt;/code&gt;, and it's the single most useful thing to internalize from this file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is a toy model, and being precise about what it isn't is the point of this section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It refreshes one prefix at a time.&lt;/strong&gt; &lt;code&gt;refresh_exports_for_prefix()&lt;/code&gt; takes a single &lt;code&gt;prefix&lt;/code&gt;. A real implementation after a session teardown has thousands to tens of thousands of prefixes to reconsider, and the interesting engineering is entirely in how you batch, prioritize, and pace that work — not in the per-prefix decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not build UPDATE messages.&lt;/strong&gt; There is no packet here. No path attribute encoding, no NLRI packing, no grouping of prefixes that share an attribute set into one UPDATE — which is the main reason real BGP UPDATEs are efficient at all. The function returns a &lt;code&gt;list[ExportChange]&lt;/code&gt;, a description of &lt;em&gt;what should be true&lt;/em&gt;, and stops. Turning that list into wire bytes is a separate problem this module deliberately doesn't touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No timing.&lt;/strong&gt; Real BGP spaces out advertisements with MRAI (Minimum Route Advertisement Interval) timers, precisely so that a flapping route doesn't turn into a flood of UPDATEs. There is no clock in this file. Every change is emitted immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No withdrawal suppression or route damping.&lt;/strong&gt; Nothing here notices that a prefix has advertised and withdrawn ten times in a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison by &lt;code&gt;!=&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;if current != desired&lt;/code&gt; relies on &lt;code&gt;PathCandidate&lt;/code&gt; equality. Real implementations compare over attribute sets with rules about which attributes are transitive, which are optional, and which get rewritten on export.&lt;/p&gt;

&lt;p&gt;What the module &lt;em&gt;does&lt;/em&gt; model faithfully is the decision itself: given what &lt;code&gt;Loc-RIB&lt;/code&gt; holds, what &lt;code&gt;Adj-RIB-Out&lt;/code&gt; holds, and this peer's policy, should this peer see an advertise, a withdraw, or nothing? That decision is real. Everything around it is stripped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape shows up elsewhere
&lt;/h2&gt;

&lt;p&gt;Once you've seen &lt;code&gt;current&lt;/code&gt; vs. &lt;code&gt;desired&lt;/code&gt; reconciled against per-consumer state, you start seeing it in tracks that have nothing to do with BGP.&lt;/p&gt;

&lt;p&gt;It's a &lt;strong&gt;reconciliation loop&lt;/strong&gt;: read the intended state, read the observed state, emit the minimal set of changes to close the gap — and emit &lt;em&gt;nothing&lt;/em&gt; when they already agree. That last property is what makes the operation safe to run repeatedly, which is the same property Kubernetes controllers are built on.&lt;/p&gt;

&lt;p&gt;It also rhymes with cache invalidation across the series. A DNS resolver holding a record that the authoritative zone has since changed is in the same position as a peer holding a stale entry in &lt;code&gt;Adj-RIB-Out&lt;/code&gt;: some pass has to notice the divergence and push a correction. The difference is direction — DNS pulls on TTL expiry, BGP pushes on recompute — but the state comparison at the center is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Close the article and open the file. Can you answer these from the code alone?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A peer's export policy is edited so a prefix that was permitted is now denied, but &lt;code&gt;Loc-RIB&lt;/code&gt; is unchanged. Walk &lt;code&gt;refresh_exports_for_prefix()&lt;/code&gt; for that peer — which branch fires, and what ends up in the returned list?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refresh_exports_for_prefix()&lt;/code&gt; is called twice in a row with identical inputs. What does the second call return, and what in the code guarantees it?&lt;/li&gt;
&lt;li&gt;A peer has never been advertised a prefix, and best-path selection removes that prefix from &lt;code&gt;Loc-RIB&lt;/code&gt;. Does that peer get a &lt;code&gt;WITHDRAW&lt;/code&gt;? Which line decides, and why is that the correct protocol behavior rather than an optimization?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You're done with this session when you can explain why export refresh is a separate pass after recompute, and when you can look at &lt;code&gt;current&lt;/code&gt; and &lt;code&gt;desired&lt;/code&gt; for a peer and say — without running anything — whether that peer sees an advertise, a withdraw, or silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271.html" rel="noopener noreferrer"&gt;RFC 4271&lt;/a&gt; — A Border Gateway Protocol 4 (BGP-4). Section 9.1 covers the decision process; section 9.2 covers update-send process and the Adj-RIB-Out.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271.html#section-9.2.1.1" rel="noopener noreferrer"&gt;RFC 4271 §9.2.1.1&lt;/a&gt; — the MRAI timer this toy model omits.&lt;/li&gt;
&lt;li&gt;Source for this session: &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/export_refresh.py" rel="noopener noreferrer"&gt;&lt;code&gt;export_refresh.py&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/export_policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;export_policy.py&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>protocol</category>
    </item>
    <item>
      <title>Established is not a status label — it's a write permission</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Thu, 30 Jul 2026 00:00:09 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/established-is-not-a-status-label-its-a-write-permission-2dbk</link>
      <guid>https://dev.to/pathvector-dev/established-is-not-a-status-label-its-a-write-permission-2dbk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-11/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-11/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols not as configuration examples but as logic — inputs, state, and branches. Every session points at a small Python file and asks you to read it the way you'd read any other program. The source lives at &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you're earlier in the journey and want hands-on packet-level exercises before diving into implementation logic, start with the &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series instead. This series assumes you're comfortable reading Python and want to know &lt;em&gt;why&lt;/em&gt; the branch is where it is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;Here's the one to keep turning over as you read:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What has to be true before the control plane should accept an UPDATE from a neighbor?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question sounds like it should have a long answer. Route validity, attribute well-formedness, policy, next-hop reachability, max-prefix limits — a real BGP implementation checks all of it. But before any of that, there's a gate so simple it's easy to skip past entirely, and it's the one this session isolates.&lt;/p&gt;

&lt;p&gt;The previous session built an integrated pipeline: an UPDATE arrives, gets stored, gets compared, gets selected. It worked. But it quietly assumed something — that the route arriving at the front of the pipeline had already earned the right to be there. This session makes that assumption explicit and gives it a name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the file in three moves
&lt;/h2&gt;

&lt;p&gt;The file is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/peer_state.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/peer_state.py&lt;/code&gt;&lt;/a&gt;, with &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/session.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/session.py&lt;/code&gt;&lt;/a&gt; as its companion. It's short enough that you could read it top to bottom in thirty seconds and learn nothing. Read it in this order instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. What a peer actually is
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PeerSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BGPSessionConfig&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open_peer_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BGPSessionConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PeerSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PeerSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;establish_neighbor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;open_peer_session()&lt;/code&gt; does almost nothing. It takes an identifier and a config, calls &lt;code&gt;establish_neighbor()&lt;/code&gt; from &lt;code&gt;session.py&lt;/code&gt;, and staples the resulting state onto a frozen dataclass. That's the whole constructor.&lt;/p&gt;

&lt;p&gt;The thing worth noticing is what it &lt;em&gt;doesn't&lt;/em&gt; do. It doesn't retry. It doesn't leave the state mutable so a later step can nudge a peer into &lt;code&gt;Established&lt;/code&gt; because the operator wants the route. The dataclass is &lt;code&gt;frozen=True&lt;/code&gt;: whatever &lt;code&gt;establish_neighbor()&lt;/code&gt; decided, that's the peer's state for the lifetime of this object. The session outcome is an input to everything downstream, not something downstream code negotiates with.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;PeerSession&lt;/code&gt; isn't really a connection object. It's a decision, packaged.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The gate, stated as one question
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;session_accepts_updates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PeerSession&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ESTABLISHED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. One comparison. No &lt;code&gt;or&lt;/code&gt;, no fallback, no "well, &lt;code&gt;OpenConfirm&lt;/code&gt; is close enough."&lt;/p&gt;

&lt;p&gt;The function name is doing real work here — it could have been called &lt;code&gt;is_established()&lt;/code&gt;, which would describe the &lt;em&gt;state&lt;/em&gt;. Instead it's named for the &lt;em&gt;consequence&lt;/em&gt;: does this session accept updates? That naming is the whole point of the session. &lt;code&gt;Established&lt;/code&gt; isn't a label you read off a &lt;code&gt;show bgp summary&lt;/code&gt; output to feel good about. It's the answer to a permission question that gets asked on every inbound UPDATE.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;is&lt;/code&gt; rather than &lt;code&gt;==&lt;/code&gt;. &lt;code&gt;SessionState&lt;/code&gt; is an enum, and identity comparison means there's exactly one object that satisfies this check. There's no clever coercion, no truthy near-miss. Either the peer holds that exact enum member or it doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Where the &lt;code&gt;if&lt;/code&gt; sits
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_update_if_established&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PeerSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathAttributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;session_accepts_updates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="nf"&gt;store_received_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the entire lesson, and it's four lines of body.&lt;/p&gt;

&lt;p&gt;The important thing is not the amount of code. It is the &lt;strong&gt;placement of the &lt;code&gt;if&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Look at what's on each side of the early return. Above it: nothing has happened. Below it: a write into &lt;code&gt;Adj-RIB-In&lt;/code&gt;. There is no path through this function where a route lands in the RIB without first passing &lt;code&gt;session_accepts_updates()&lt;/code&gt;. Not "gets stored and then filtered later." Not "gets stored with a flag." The write is &lt;em&gt;structurally&lt;/em&gt; downstream of the check.&lt;/p&gt;

&lt;p&gt;Compare that to the alternative you've probably seen in real codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NOT what this module does — the shape to avoid
&lt;/span&gt;&lt;span class="nf"&gt;store_received_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;session_accepts_updates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;mark_as_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same information, same check, completely different guarantee. In that version, &lt;code&gt;Adj-RIB-In&lt;/code&gt; contains routes from peers that never reached &lt;code&gt;Established&lt;/code&gt;, and correctness depends on every reader downstream remembering to filter. In the version the module actually implements, &lt;code&gt;Adj-RIB-In&lt;/code&gt; has an invariant: &lt;strong&gt;everything in it came from an established session.&lt;/strong&gt; Downstream code doesn't have to re-check, because the shape of the function made the bad state unrepresentable.&lt;/p&gt;

&lt;p&gt;That's why the return type is &lt;code&gt;bool&lt;/code&gt; and not &lt;code&gt;None&lt;/code&gt;. The caller gets told whether the write happened. A silent no-op would be a worse API — the caller couldn't distinguish "stored" from "dropped at the gate."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;store_received_path()&lt;/code&gt; call takes &lt;code&gt;peer.peer_id&lt;/code&gt; as the key, not the prefix alone. &lt;code&gt;Adj-RIB-In&lt;/code&gt; is per-peer by construction — which is exactly why the gate can be per-peer too. If the RIB were flat, the gate would have to live somewhere messier.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Same shape, different protocol
&lt;/h2&gt;

&lt;p&gt;This gate is worth recognizing because you've already met it under other names.&lt;/p&gt;

&lt;p&gt;A TLS session that hasn't completed the handshake can carry bytes — the TCP connection is up, the socket is writable — but application data sent before &lt;code&gt;Finished&lt;/code&gt; isn't protected by the negotiated keys, and a correct implementation refuses to hand it to the application. Same shape: transport reachability is not the same thing as protocol readiness, and the code has to enforce the difference with a branch, not a comment.&lt;/p&gt;

&lt;p&gt;Or take conntrack: a packet matching an existing flow tuple gets fast-pathed, but only if the state machine says the flow is &lt;code&gt;ESTABLISHED&lt;/code&gt;. A packet arriving for a &lt;code&gt;SYN_SENT&lt;/code&gt; entry takes a different branch entirely. Again: the state isn't decoration, it's the gate on which code path runs.&lt;/p&gt;

&lt;p&gt;In all three cases the mistake looks identical — treating "the pipe is open" as "the protocol is ready." BGP is just the clearest place to see it, because the state has a name printed in every operator's terminal, and it's easy to read that name as a status indicator rather than as a precondition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is where the series stays honest with you.&lt;/p&gt;

&lt;p&gt;This lesson isolates &lt;strong&gt;only&lt;/strong&gt; the session-state gate. &lt;code&gt;Established&lt;/code&gt; is the &lt;em&gt;first&lt;/em&gt; gate in this toy model — it is not the only gate a real implementation uses, and reading it as such will mislead you.&lt;/p&gt;

&lt;p&gt;Specifically, what's missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Address-family activation.&lt;/strong&gt; A real BGP session negotiates which AFI/SAFI pairs are active. A peer can be perfectly &lt;code&gt;Established&lt;/code&gt; for IPv4 unicast and still have no business sending you IPv6 or VPNv4 routes. The toy model has one implicit address family and no per-family gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negotiated capability checks.&lt;/strong&gt; Multiprotocol extensions, route refresh, add-path, 4-byte ASN handling — all negotiated during OPEN, all things that change what an UPDATE is even allowed to contain. None of that exists here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rest of the state machine.&lt;/strong&gt; &lt;code&gt;establish_neighbor()&lt;/code&gt; collapses what RFC 4271 models as &lt;code&gt;Idle → Connect → Active → OpenSent → OpenConfirm → Established&lt;/code&gt; into a single call with a single outcome. There are no hold timers, no keepalives, no transitions &lt;em&gt;out&lt;/em&gt; of &lt;code&gt;Established&lt;/code&gt;. In a real implementation, a peer can be established at the moment an UPDATE arrives and gone by the time it's processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbound policy.&lt;/strong&gt; Passing the session gate gets a route into &lt;code&gt;Adj-RIB-In&lt;/code&gt;. In a real router, inbound route-maps, prefix lists, and max-prefix limits all sit between the wire and that store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason the toy model cuts all of it is that each of those gates has the &lt;em&gt;same structural shape&lt;/em&gt; as the one you just read — a boolean question placed before a write. Once you can see the shape clearly in four lines, the real implementation's dozen gates read as variations rather than as new material.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The walkthrough is runnable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_11_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to watch for in the output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One peer whose TCP reachability never gets it to &lt;code&gt;Established&lt;/code&gt;.&lt;/strong&gt; The transport is fine. The session isn't. Watch what &lt;code&gt;session_accepts_updates()&lt;/code&gt; returns for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One peer whose UPDATE is accepted.&lt;/strong&gt; Trace it through &lt;code&gt;receive_update_if_established()&lt;/code&gt; and confirm it takes the path past the early return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the established peer appearing in &lt;code&gt;Adj-RIB-In&lt;/code&gt;.&lt;/strong&gt; This is the invariant made visible. The rejected peer left no trace — no partial entry, no pending flag, nothing to clean up later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third point is the one to sit with. The absence in the RIB is the whole result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Answer these by reading the source, not by reasoning from what you know about BGP. If you have to guess, go back to the file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A peer's TCP connection is established and packets are flowing, but &lt;code&gt;session_accepts_updates()&lt;/code&gt; returns &lt;code&gt;False&lt;/code&gt;. Where exactly in &lt;code&gt;peer_state.py&lt;/code&gt; does an inbound route get stopped, and what is left behind in &lt;code&gt;Adj-RIB-In&lt;/code&gt; afterward?&lt;/li&gt;
&lt;li&gt;Suppose you moved the &lt;code&gt;store_received_path()&lt;/code&gt; call &lt;em&gt;above&lt;/em&gt; the &lt;code&gt;if&lt;/code&gt; and returned &lt;code&gt;False&lt;/code&gt; afterward anyway. What invariant about &lt;code&gt;Adj-RIB-In&lt;/code&gt; would break, and which downstream reader would notice first?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PeerSession&lt;/code&gt; is &lt;code&gt;frozen=True&lt;/code&gt;. If a peer's session dropped out of &lt;code&gt;Established&lt;/code&gt; after &lt;code&gt;open_peer_session()&lt;/code&gt; returned, what in this file would detect it — and what does your answer tell you about which parts of the state machine this toy model doesn't have?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You're done with this session when you can explain why &lt;code&gt;Established&lt;/code&gt; is a write permission rather than a status label, and point at the exact &lt;code&gt;if&lt;/code&gt; that keeps a route out of &lt;code&gt;Adj-RIB-In&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271" rel="noopener noreferrer"&gt;RFC 4271 — A Border Gateway Protocol 4 (BGP-4)&lt;/a&gt;, especially §8 (BGP Finite State Machine) and §3.2 (Routing Information Bases)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4760" rel="noopener noreferrer"&gt;RFC 4760 — Multiprotocol Extensions for BGP-4&lt;/a&gt;, for the address-family activation the toy model omits&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc5492" rel="noopener noreferrer"&gt;RFC 5492 — Capabilities Advertisement with BGP-4&lt;/a&gt;, for what else gets negotiated before &lt;code&gt;Established&lt;/code&gt; means anything&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>protocol</category>
    </item>
    <item>
      <title>One route, one function chain: reading a BGP pipeline as code</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Wed, 29 Jul 2026 00:00:18 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/one-route-one-function-chain-reading-a-bgp-pipeline-as-code-pdl</link>
      <guid>https://dev.to/pathvector-dev/one-route-one-function-chain-reading-a-bgp-pipeline-as-code-pdl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-10/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-10/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, 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 &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;pathvector-studio/protocol-in-code&lt;/a&gt;, 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 &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series, then come back here.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you trace one received route end to end, which functions touch it before it becomes an advertisement or disappears?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;chain&lt;/em&gt;: what order, what state changes where, and which step is responsible for each decision.&lt;/p&gt;

&lt;p&gt;The file is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/pipeline.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/pipeline.py&lt;/code&gt;&lt;/a&gt;. It's short. That's the point — the integration should be small enough to hold in your head all at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape, before the details
&lt;/h2&gt;

&lt;p&gt;Strip the pipeline down to its skeleton and it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;store_received_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_attributes&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;received_attributes_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;installable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;installable_candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;install_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prepare_export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_peer_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;export_policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Read it in this order and it unfolds cleanly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;PipelinePolicies&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PipelineResult&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;evaluate_candidate()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;apply_policy_action()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;process_single_route()&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Two dataclasses that do the structural work
&lt;/h2&gt;

&lt;p&gt;Start with the inputs and outputs, because they tell you what the pipeline thinks its own boundaries are.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PipelinePolicies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;import_policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ImportPolicy&lt;/span&gt;
    &lt;span class="n"&gt;validation_policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ValidationPolicy&lt;/span&gt;
    &lt;span class="n"&gt;export_policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExportPolicy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;em&gt;means&lt;/em&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;received_validation_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;
    &lt;span class="n"&gt;received_action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;
    &lt;span class="n"&gt;candidate_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;selected_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;selected_exported_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is worth staring at. Notice that it reports two different things about the same call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;received_validation_state&lt;/code&gt; and &lt;code&gt;received_action&lt;/code&gt; describe &lt;strong&gt;the route that just arrived&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;selected_path&lt;/code&gt; and &lt;code&gt;selected_exported_path&lt;/code&gt; describe &lt;strong&gt;whatever won for the prefix&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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. &lt;code&gt;candidate_count&lt;/code&gt; is the tell: it exists so you can see that the pipeline compared &lt;em&gt;N&lt;/em&gt; survivors, not that it rubber-stamped the one route you happened to feed it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bridging two sessions with one function
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;origin_as_from_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathAttributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines, and they're the seam between Session 02 (attributes) and Session 04 (validation). Validation wants a &lt;code&gt;BGPRoute&lt;/code&gt; with an &lt;code&gt;origin_as&lt;/code&gt;. What actually arrived on the wire was a &lt;code&gt;PathAttributes&lt;/code&gt; with an &lt;code&gt;AS_PATH&lt;/code&gt;. Something has to convert one into the other, and in this pipeline it's this function.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a route lives or dies
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;evaluate_candidate()&lt;/code&gt; is the heart of the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathAttributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VRP&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PipelinePolicies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BGPRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origin_as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;origin_as_from_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;validation_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;raw_candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;candidate_from_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;imported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_import_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;import_policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;imported&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide_route_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;validation_policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_policy_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;installed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the order carefully, because the order is the lesson.&lt;/p&gt;

&lt;p&gt;Validation runs &lt;strong&gt;first&lt;/strong&gt;, and it produces a &lt;code&gt;ValidationState&lt;/code&gt; — Valid, Invalid, NotFound. It does not reject anything. It just labels.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;apply_import_policy()&lt;/code&gt; gets both the candidate &lt;em&gt;and&lt;/em&gt; that label. This is the first place a route can vanish: if import policy returns &lt;code&gt;None&lt;/code&gt;, the function short-circuits and reports &lt;code&gt;PolicyAction.REJECT&lt;/code&gt;. The route never reaches best-path.&lt;/p&gt;

&lt;p&gt;If it survives import, &lt;code&gt;decide_route_policy()&lt;/code&gt; translates the validation state into a &lt;code&gt;PolicyAction&lt;/code&gt; under the operator's validation policy. And &lt;em&gt;that&lt;/em&gt; is a separate decision from the label itself. An RPKI-invalid route doesn't have to be dropped; the operator decides.&lt;/p&gt;

&lt;p&gt;The action is then applied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_policy_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REJECT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEPRIORITIZE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_pref&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local_pref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three branches, three outcomes. &lt;code&gt;REJECT&lt;/code&gt; removes the candidate. &lt;code&gt;DEPRIORITIZE&lt;/code&gt; keeps it but knocks 50 off &lt;code&gt;local_pref&lt;/code&gt;, floored at zero — the route stays eligible, it just loses ties it would otherwise win. Anything else passes through untouched.&lt;/p&gt;

&lt;p&gt;This is the distinction that survives all the way into the full pipeline: &lt;strong&gt;a validation result is not a decision&lt;/strong&gt;. &lt;code&gt;ValidationState&lt;/code&gt; is a fact about the route. &lt;code&gt;PolicyAction&lt;/code&gt; is what this operator has chosen to do about that fact. Two separate values, computed by two separate functions, both reported separately in &lt;code&gt;PipelineResult&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full chain
&lt;/h2&gt;

&lt;p&gt;Now &lt;code&gt;process_single_route()&lt;/code&gt;, which is where the state actually moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="nf"&gt;store_received_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;installable_candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PathCandidate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_attributes&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;received_attributes_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;installable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;installable&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;installable_candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;installable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then two evaluations happen, and they look redundant until you notice the discard. The first call's result is kept for reporting (&lt;code&gt;validation_state&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;) with the candidate thrown away via &lt;code&gt;_&lt;/code&gt;. The loop then re-evaluates &lt;strong&gt;every&lt;/strong&gt; 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."&lt;/p&gt;

&lt;p&gt;The empty case comes next, and it's the branch most toy models skip entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;installable_candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;remove_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;withdraw_staged_advertisement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;received_validation_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;received_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;candidate_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;selected_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;selected_exported_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;installable_candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;install_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prepare_export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_peer_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;export_policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;stage_advertisement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;withdraw_staged_advertisement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;export_peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;select_best_path()&lt;/code&gt; compares all survivors — this is the only place multiple candidates meet. &lt;code&gt;install_best_path()&lt;/code&gt; writes to Loc-RIB. &lt;code&gt;prepare_export()&lt;/code&gt; transforms the winner for the outbound peer, and can still return &lt;code&gt;None&lt;/code&gt;, in which case the advertisement gets withdrawn rather than staged.&lt;/p&gt;

&lt;p&gt;Three RIBs, three different moments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RIB&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;th&gt;Changes at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Adj-RIB-In&lt;/td&gt;
&lt;td&gt;raw received attributes, per peer&lt;/td&gt;
&lt;td&gt;the very first line, before anything is decided&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loc-RIB&lt;/td&gt;
&lt;td&gt;the selected &lt;code&gt;PathCandidate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;after best-path selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adj-RIB-Out&lt;/td&gt;
&lt;td&gt;the export-transformed candidate&lt;/td&gt;
&lt;td&gt;after export policy, per outbound peer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They hold different objects and they change at different points. That's the sentence to be able to say without notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same shape, different protocol
&lt;/h2&gt;

&lt;p&gt;The structure here — &lt;em&gt;store what arrived, label it, decide what the label means, select among survivors, transform for output&lt;/em&gt; — is not BGP-specific. It's the shape of every control plane that accepts untrusted input and has to publish a decision.&lt;/p&gt;

&lt;p&gt;DNS resolvers do it: the cache holds what the authoritative server said (Adj-RIB-In), DNSSEC validation labels it (&lt;code&gt;ValidationState&lt;/code&gt;), local policy decides whether a bogus answer is served or SERVFAIL'd (&lt;code&gt;PolicyAction&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The walkthrough is executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_10_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;PipelineResult&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Watch &lt;code&gt;candidate_count&lt;/code&gt; in the output. If it's ever 1 when you expected 2, you've learned something about where a route disappeared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is a pipeline, not a BGP speaker. Being specific about the gap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No session layer.&lt;/strong&gt; There's no peer state machine, no OPEN/KEEPALIVE/NOTIFICATION handling, no hold timers. &lt;code&gt;process_single_route()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No event dispatch.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No export refresh after every recompute.&lt;/strong&gt; The pipeline updates Adj-RIB-Out for the single &lt;code&gt;export_peer_id&lt;/code&gt; passed in. A real speaker re-evaluates export for &lt;em&gt;all&lt;/em&gt; peers whenever Loc-RIB changes, and handles route refresh requests. This model does one peer, one call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Origin AS is a shortcut.&lt;/strong&gt; &lt;code&gt;origin_as_from_attributes()&lt;/code&gt; takes the rightmost AS in &lt;code&gt;AS_PATH&lt;/code&gt;. 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 &lt;code&gt;0&lt;/code&gt; for an empty path, which is a sentinel, not a real AS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The integration order is a choice.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No withdrawal handling from peers.&lt;/strong&gt; Stale state is cleaned up when nothing survives evaluation, but there's no path for a peer explicitly withdrawing a prefix.&lt;/p&gt;

&lt;p&gt;None of these make the model wrong. They make it &lt;em&gt;small&lt;/em&gt; — 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Close the article and answer these from the source alone. If you have to guess, open &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/pipeline.py" rel="noopener noreferrer"&gt;&lt;code&gt;pipeline.py&lt;/code&gt;&lt;/a&gt; and trace it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which single step can drop a candidate &lt;em&gt;before&lt;/em&gt; it ever reaches best-path — and what does the pipeline report as the action when that happens?&lt;/li&gt;
&lt;li&gt;Which step lowers local preference without rejecting the route, and what's the floor?&lt;/li&gt;
&lt;li&gt;Which step is the only one that compares multiple surviving candidates, and which step changes the outbound AS path?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They're not trick questions. Every answer is a specific function name in a 130-line file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271" rel="noopener noreferrer"&gt;RFC 4271 — A Border Gateway Protocol 4 (BGP-4)&lt;/a&gt; — §3.2 on the three RIBs, §9.1 on the decision process&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6811" rel="noopener noreferrer"&gt;RFC 6811 — BGP Prefix Origin Validation&lt;/a&gt; — the Valid / Invalid / NotFound states and the explicit separation of validation from local policy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6480" rel="noopener noreferrer"&gt;RFC 6480 — An Infrastructure to Support Secure Internet Routing&lt;/a&gt; — where VRPs come from&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc7454" rel="noopener noreferrer"&gt;RFC 7454 — BGP Operations and Security&lt;/a&gt; — what import and export policy look like in practice&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>protocol</category>
    </item>
    <item>
      <title>When a BGP peer disappears, one session loss becomes many route decisions</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Wed, 29 Jul 2026 00:00:10 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/when-a-bgp-peer-disappears-one-session-loss-becomes-many-route-decisions-906</link>
      <guid>https://dev.to/pathvector-dev/when-a-bgp-peer-disappears-one-session-loss-becomes-many-route-decisions-906</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-09/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-09/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. Every module points at one small Python file and asks you to read it the way you'd read any other piece of code. The source lives at &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you're earlier on the path and want hands-on packet-level exercises before diving into implementation logic, start with the companion &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What happens to Loc-RIB when one peer disappears and the best path came from that peer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hold onto that while you read. It sounds like a single question, but it hides at least four: how many routes are affected, which store changes first, what replaces the lost path, and what happens when nothing can.&lt;/p&gt;

&lt;p&gt;The operational answer everyone knows is "the routes go away." That's true and useless. It doesn't tell you why some prefixes survive a peer loss with a brief reconvergence and others vanish entirely, or why a peer that advertised 500 prefixes generates 500 independent decisions rather than one.&lt;/p&gt;

&lt;p&gt;The code answer is more precise, and it's about thirty lines long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this sits
&lt;/h2&gt;

&lt;p&gt;Session 06 introduced per-peer received state — the idea that Adj-RIB-In isn't one flat table but a mapping keyed by peer, with each peer's advertisements kept separately. That structure is what makes this session possible. If you'd stored all received paths in a single undifferentiated table, "which routes came from this peer?" would be a scan; with per-peer state it's a key lookup.&lt;/p&gt;

&lt;p&gt;The file we're reading is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/recompute.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/recompute.py&lt;/code&gt;&lt;/a&gt;. Read it in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;recompute_best_path_for_prefix()&lt;/code&gt; — the single-prefix decision&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handle_peer_loss()&lt;/code&gt; — the loop that applies it&lt;/li&gt;
&lt;li&gt;How lost prefixes are collected from one peer&lt;/li&gt;
&lt;li&gt;How each affected prefix is recomputed &lt;em&gt;independently&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Read it like code
&lt;/h2&gt;

&lt;p&gt;Start with the smaller function, because the bigger one is just a loop around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recompute_best_path_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocRIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;remove_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;install_best_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines of logic and one branch. That branch is the whole story.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;build_candidates(adj_rib_in, prefix)&lt;/code&gt; reads the &lt;em&gt;current&lt;/em&gt; contents of Adj-RIB-In and assembles every path anyone is still advertising for this prefix. Note the tense: current. This function has no memory of what used to be there and no notion of "the peer that just went down." It sees state, not events.&lt;/p&gt;

&lt;p&gt;Then the branch. If the candidate set is empty, there is nothing to install, so &lt;code&gt;remove_best_path()&lt;/code&gt; deletes the Loc-RIB entry and the function returns &lt;code&gt;None&lt;/code&gt;. If the set is non-empty, &lt;code&gt;select_best_path()&lt;/code&gt; runs the decision process over the survivors and &lt;code&gt;install_best_path()&lt;/code&gt; writes the winner into Loc-RIB.&lt;/p&gt;

&lt;p&gt;Read that carefully and notice what &lt;em&gt;isn't&lt;/em&gt; there: the function never asks "was the previous best path from the dead peer?" It doesn't compare old and new. It doesn't need to. It recomputes from scratch and overwrites. This is the difference between an event-driven and a state-driven design, and BGP implementations lean state-driven for exactly this reason — recomputing from current state is much harder to get wrong than incrementally patching a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop that makes it plural
&lt;/h2&gt;

&lt;p&gt;Now the outer function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_peer_loss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AdjRIBIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LocRIB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;lost_prefixes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paths_by_peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paths_by_peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lost_prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;recompute_best_path_for_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj_rib_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc_rib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things happen, strictly in order, and the ordering is not incidental.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, snapshot.&lt;/strong&gt; &lt;code&gt;lost_prefixes&lt;/code&gt; is materialized as a &lt;code&gt;tuple&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; anything is mutated. That &lt;code&gt;tuple(...)&lt;/code&gt; isn't stylistic — it copies the keys out of the dict so the subsequent &lt;code&gt;pop&lt;/code&gt; doesn't invalidate what we're about to iterate over. This is the list of prefixes that were affected, captured at the moment of loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, delete.&lt;/strong&gt; &lt;code&gt;adj_rib_in.paths_by_peer.pop(peer_id, None)&lt;/code&gt; removes the entire per-peer subtree. Every path that peer ever advertised is gone from received state in one operation. The &lt;code&gt;None&lt;/code&gt; default means calling this for an unknown peer is a no-op rather than a &lt;code&gt;KeyError&lt;/code&gt; — losing a peer you weren't tracking should be harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, recompute.&lt;/strong&gt; Only &lt;em&gt;after&lt;/em&gt; the deletion does the loop run. This ordering answers one of the questions you should be holding: Adj-RIB-In changes first, Loc-RIB second. It has to. &lt;code&gt;recompute_best_path_for_prefix()&lt;/code&gt; calls &lt;code&gt;build_candidates()&lt;/code&gt;, which reads Adj-RIB-In — if the dead peer's paths were still in there, &lt;code&gt;build_candidates()&lt;/code&gt; would happily hand them back and the peer's paths could get reinstalled as best. The deletion isn't cleanup after the decision; it's the input to it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;for&lt;/code&gt; loop is where "one session loss" becomes "many route decisions." One peer, N prefixes, N independent invocations of the decision process. There is no batching, no shared state between iterations, no notion of "the peer went down" inside the per-prefix function at all. Each prefix asks the same question in isolation: &lt;em&gt;given who's still advertising, what's best now?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And each can get a different answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Another peer also advertises this prefix&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;build_candidates()&lt;/code&gt; returns a non-empty set, &lt;code&gt;select_best_path()&lt;/code&gt; picks a survivor, Loc-RIB gets a new best path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The dead peer was the only source&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;build_candidates()&lt;/code&gt; returns empty, &lt;code&gt;remove_best_path()&lt;/code&gt; fires, prefix disappears from Loc-RIB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The dead peer wasn't best for this prefix anyway&lt;/td&gt;
&lt;td&gt;Recomputation runs regardless and reinstalls the same winner&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third row is worth sitting with. The code doesn't optimize it away. It recomputes prefixes whose best path was never in danger, because checking "was this prefix's best path from the dead peer?" is itself a decision that can be wrong, while recomputing is unconditionally correct. Correctness first; a real implementation would add the optimization later, guarded carefully.&lt;/p&gt;

&lt;p&gt;The return value — &lt;code&gt;dict[str, PathCandidate | None]&lt;/code&gt; — is the outcome of every one of those decisions. A &lt;code&gt;PathCandidate&lt;/code&gt; means a path survived or replaced; &lt;code&gt;None&lt;/code&gt; means the prefix is gone. The &lt;code&gt;| None&lt;/code&gt; in the type signature is the withdrawal case made explicit in the type system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same shape, elsewhere
&lt;/h2&gt;

&lt;p&gt;The pattern here isn't BGP-specific: &lt;strong&gt;maintain per-source state, and when a source disappears, delete its contribution and re-derive the aggregate from what's left.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DNS resolvers do this when a nameserver stops responding — the cached RRsets attributed to it become unusable, and resolution falls back to re-deriving an answer from the remaining authoritative servers. Link-state protocols do it when an LSA ages out: remove the contribution, rerun SPF over the surviving topology. Even TCP's congestion state has the shape, one level down — a loss signal doesn't patch &lt;code&gt;cwnd&lt;/code&gt; incrementally so much as re-derive it from the current state of the connection.&lt;/p&gt;

&lt;p&gt;The alternative design — event-driven incremental patching, where "peer down" tries to surgically fix only the Loc-RIB entries it believes are affected — is faster and much easier to get subtly wrong. Every protocol that has tried it has accumulated a long tail of "stale entry" bugs. The recompute-from-current-state shape trades CPU for the guarantee that the output is always a function of the present input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The walkthrough is executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_09_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It installs a best path from one peer, simulates that peer going down, and shows the backup path taking over. Watch the transition specifically: the prefix stays in Loc-RIB but its best path changes identity. Then modify the script to remove the second peer's advertisement and run it again — now the same code path produces a disappearance instead of a substitution. Same function, same branch, different candidate set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This module is a reading aid, not a BGP implementation. Be clear about what it leaves out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No BGP session machinery.&lt;/strong&gt; &lt;code&gt;handle_peer_loss()&lt;/code&gt; is called by you, with a &lt;code&gt;peer_id&lt;/code&gt; string. Real peer loss is detected by the FSM — hold timer expiry, TCP reset, &lt;code&gt;NOTIFICATION&lt;/code&gt; received, an explicit administrative shutdown — and each of those has different timing characteristics. Hold-timer detection can take tens of seconds; a TCP RST is near-instant. The model has no timers at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No withdrawal propagation.&lt;/strong&gt; In the real protocol, changes to Loc-RIB feed Adj-RIB-Out and generate UPDATE messages toward other peers, subject to export policy. Here, the function returns a dict and stops. Everything downstream of the local decision is absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No route flap damping, no MRAI.&lt;/strong&gt; Real BGP deliberately slows reconvergence: the MinRouteAdvertisementInterval timer rate-limits how often a prefix's changes go out, and damping penalizes prefixes that flap. This model reconverges instantaneously and without hysteresis, which is precisely the behavior operators spend effort suppressing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No graceful restart.&lt;/strong&gt; &lt;a href="https://www.rfc-editor.org/rfc/rfc4724.html" rel="noopener noreferrer"&gt;RFC 4724&lt;/a&gt; exists so that a peer going down &lt;em&gt;doesn't&lt;/em&gt; immediately trigger this code path — routes are marked stale and retained while the peer restarts. The model has no stale-route concept and no restart signalling; loss is always immediate and total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete-then-recompute is not atomic.&lt;/strong&gt; The &lt;code&gt;pop&lt;/code&gt; happens, then the loop runs. In between, Adj-RIB-In and Loc-RIB disagree. A single-threaded toy never observes that window, but real implementations have concurrent readers and need locking or versioning to keep anyone from reading the inconsistent middle state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;select_best_path()&lt;/code&gt; is simplified.&lt;/strong&gt; The decision process here is a reduced version of the real tie-break ladder in &lt;a href="https://www.rfc-editor.org/rfc/rfc4271.html#section-9.1" rel="noopener noreferrer"&gt;RFC 4271 §9.1&lt;/a&gt;, and the model has no ADD-PATH, no multipath, no RIB failure states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost is not modeled.&lt;/strong&gt; N prefixes means N recomputations. With a full table that's ~1M invocations, and real implementations do a great deal of work — prefix indexing, batched walks, deferred best-path runs — to make peer loss survivable. The model's flat loop makes the logic obvious and the performance question invisible.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Can you answer these just by reading the source? Don't take my word for any of it — go verify against &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/recompute.py" rel="noopener noreferrer"&gt;&lt;code&gt;recompute.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which store changes first, Adj-RIB-In or Loc-RIB — and what would break if you swapped the order?&lt;/strong&gt; Trace what &lt;code&gt;build_candidates()&lt;/code&gt; would return if &lt;code&gt;pop&lt;/code&gt; ran after the loop instead of before it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Under exactly what condition does a prefix disappear from Loc-RIB entirely?&lt;/strong&gt; Find the single expression that decides it, and name the state that has to hold across all remaining peers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why is &lt;code&gt;lost_prefixes&lt;/code&gt; built with &lt;code&gt;tuple(...)&lt;/code&gt; rather than used directly as a dict view?&lt;/strong&gt; What concretely goes wrong if you drop the &lt;code&gt;tuple()&lt;/code&gt; call?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You've got this module when you can say, without notes: peer loss deletes Adj-RIB-In state for that peer first; then each affected prefix is recomputed independently; a backup path can become best; and if no backup exists, the Loc-RIB entry disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271.html" rel="noopener noreferrer"&gt;RFC 4271 — A Border Gateway Protocol 4 (BGP-4)&lt;/a&gt;, particularly §9.1 (Decision Process) and §9.2 (Update-Send Process)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc4724.html" rel="noopener noreferrer"&gt;RFC 4724 — Graceful Restart Mechanism for BGP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc2439.html" rel="noopener noreferrer"&gt;RFC 2439 — BGP Route Flap Damping&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Source for this module: &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/recompute.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/recompute.py&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Walkthrough: &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/examples/bgp/session_09_walkthrough.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/bgp/session_09_walkthrough.py&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>routing</category>
    </item>
    <item>
      <title>Export policy decides what leaves: reading BGP outbound state as code</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Mon, 27 Jul 2026 16:00:23 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/export-policy-decides-what-leaves-reading-bgp-outbound-state-as-code-2550</link>
      <guid>https://dev.to/pathvector-dev/export-policy-decides-what-leaves-reading-bgp-outbound-state-as-code-2550</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-08/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-08/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration examples. Each module points at one small Python file and asks you to read it the way you'd read any other code: what comes in, what gets mutated, where does it return early. The source lives at &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you're earlier in the journey and want to &lt;em&gt;do&lt;/em&gt; things with protocols before dissecting them, start with the hands-on &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series instead — it builds the muscle memory this series assumes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;Here's the thing that trips people up when they first look at a real BGP table: &lt;strong&gt;why is the route we advertise to a peer not always identical to the route we installed locally?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You run &lt;code&gt;show ip bgp&lt;/code&gt; on a router, you see a prefix with a next-hop and an AS path. You run the equivalent command on the peer that received it from you, and the fields don't match. The next-hop changed. The AS path grew. Sometimes the prefix isn't there at all — even though it's clearly still installed on your side.&lt;/p&gt;

&lt;p&gt;None of this is mysterious once you stop thinking of advertisement as "copying the table over the wire." It isn't a copy. It's a &lt;em&gt;transform&lt;/em&gt;, and it lives in its own function with its own inputs. Session 06 already established that Adj-RIB-Out is a separate place where outbound state lives. This session gives that outbound state its own policy step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file
&lt;/h2&gt;

&lt;p&gt;Everything in this module happens in &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/export_policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/export_policy.py&lt;/code&gt;&lt;/a&gt;. It's short enough to hold entirely in your head, which is the point.&lt;/p&gt;

&lt;p&gt;Read it in this order: &lt;code&gt;PeerType&lt;/code&gt;, then &lt;code&gt;ExportPolicy&lt;/code&gt;, then &lt;code&gt;prepare_export()&lt;/code&gt;. Inside &lt;code&gt;prepare_export()&lt;/code&gt;, notice four things in sequence — the deny check, &lt;code&gt;next_hop_self&lt;/code&gt;, the eBGP branch, and what the function actually returns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two peer types, because export isn't uniform
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PeerType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;EBGP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ebgp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;IBGP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ibgp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole enum. It exists because the export path branches on it — eBGP export and iBGP export do not look the same, and the code makes that structural rather than incidental. If you've only ever configured BGP through a vendor CLI, this is worth pausing on: the peer type isn't a label on a neighbor statement, it's a &lt;em&gt;branch condition&lt;/em&gt; in the outbound path.&lt;/p&gt;

&lt;h3&gt;
  
  
  The policy is data, not behavior
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExportPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;local_as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;next_hop_self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;deny_prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;extra_prepend_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four fields, each of which corresponds to something you've probably typed into a router config without thinking about it as a function parameter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;deny_prefixes&lt;/code&gt; — the set of prefixes that never leave, regardless of what's installed locally.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next_hop_self&lt;/code&gt; — whether outbound state gets a rewritten next-hop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;local_as&lt;/code&gt; — what gets prepended on eBGP export.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;extra_prepend_count&lt;/code&gt; — deliberate path inflation, the traffic-engineering knob.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dataclass is &lt;code&gt;frozen=True&lt;/code&gt;, and that matters for how you read the next function. Policy is immutable input. The path is immutable input. Everything that happens in &lt;code&gt;prepare_export()&lt;/code&gt; produces a &lt;em&gt;new&lt;/em&gt; object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading &lt;code&gt;prepare_export()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the function in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prepare_export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;peer_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PeerType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExportPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deny_prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_hop_self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_hop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;peer_type&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PeerType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EBGP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prepend_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extra_prepend_count&lt;/span&gt;
        &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_as&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prepend_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exported&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the signature first. It takes a &lt;code&gt;PathCandidate&lt;/code&gt; and returns &lt;code&gt;PathCandidate | None&lt;/code&gt;. That &lt;code&gt;None&lt;/code&gt; in the return type is doing a lot of conceptual work — it's the type system telling you that "there is a best path" and "the peer hears about it" are two separate propositions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The deny check comes first
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deny_prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the first statement in the function, before any transformation happens, and it returns &lt;code&gt;None&lt;/code&gt; rather than raising, logging, or removing anything.&lt;/p&gt;

&lt;p&gt;Notice what it does &lt;em&gt;not&lt;/em&gt; touch: &lt;code&gt;path&lt;/code&gt; is unchanged, and nothing anywhere in this function reaches back into Loc-RIB. A denied prefix is still installed. It's still the best path. Your forwarding table still uses it. The peer simply never hears about it.&lt;/p&gt;

&lt;p&gt;That asymmetry is the single most useful thing in this module. "The route isn't in my table" and "the route isn't in my neighbor's table" are different failures with different fixes, and this early return is where the difference is made concrete.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next-hop rewrite is a state divergence
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_hop_self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_hop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;exported = path&lt;/code&gt; starts the transform chain by aliasing the input, then every step uses &lt;code&gt;dataclasses.replace&lt;/code&gt; to build a new frozen instance. Read this as an accumulator: &lt;code&gt;exported&lt;/code&gt; is the outbound view under construction, and &lt;code&gt;path&lt;/code&gt; stays pristine as the locally installed view.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-hop-self&lt;/code&gt; is the canonical case where those two views diverge. The local route points at whatever next-hop it learned. The advertised route points at you. Same prefix, same best-path decision, different attribute on the wire.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;next_hop="self"&lt;/code&gt; here is a symbolic placeholder, not a literal forwarding address rewrite. The toy model is showing you &lt;em&gt;that&lt;/em&gt; outbound state can differ from local state and &lt;em&gt;where&lt;/em&gt; that divergence is implemented — not the address arithmetic a real implementation performs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The eBGP branch transforms the path
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;peer_type&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;PeerType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EBGP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prepend_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extra_prepend_count&lt;/span&gt;
        &lt;span class="n"&gt;exported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_as&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prepend_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;exported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are packed into &lt;code&gt;prepend_count = 1 + policy.extra_prepend_count&lt;/code&gt;. The &lt;code&gt;1&lt;/code&gt; is the baseline: crossing an AS boundary means your AS number goes on the front of the path. The &lt;code&gt;extra_prepend_count&lt;/code&gt; is deliberate — you're making the path look longer than it is, because path length is an input to somebody else's best-path selection and you'd rather they picked a different entrance.&lt;/p&gt;

&lt;p&gt;The tuple arithmetic makes the semantics unambiguous. &lt;code&gt;(policy.local_as,) * prepend_count&lt;/code&gt; builds a run of repeated AS numbers, and &lt;code&gt;+ exported.as_path&lt;/code&gt; puts them at the &lt;em&gt;front&lt;/em&gt;. AS path is read left to right as most-recent-first; prepending is how you get there.&lt;/p&gt;

&lt;p&gt;And the iBGP case? There's no &lt;code&gt;else&lt;/code&gt;. When &lt;code&gt;peer_type&lt;/code&gt; is &lt;code&gt;IBGP&lt;/code&gt;, this block is skipped entirely and &lt;code&gt;as_path&lt;/code&gt; passes through untouched — which is exactly right, because no AS boundary was crossed. The absence of code is the protocol behavior here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same shape, different protocol
&lt;/h2&gt;

&lt;p&gt;Once you've seen this structure, you start noticing it elsewhere. The pattern is: &lt;strong&gt;a local decision produces internal state, and a separate policy step decides what portion of that state is externally visible, in what form.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DNS does this with authoritative zone data versus what a resolver actually returns to a client. TLS does it with the full certificate chain a server holds versus what it chooses to present in a given handshake. In every case the mistake is the same — assuming the outbound view is a read of the internal view rather than a transform of it. Debugging gets much faster when you stop asking "why doesn't my peer see it?" and start asking "which function decides what my peer sees, and what are its inputs?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The walkthrough is executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_08_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs three cases against the same input path: eBGP export, iBGP export with &lt;code&gt;next-hop-self&lt;/code&gt; enabled, and a deny case where the route stays local but never gets advertised. Watching the same &lt;code&gt;PathCandidate&lt;/code&gt; come out three different ways — and once as &lt;code&gt;None&lt;/code&gt; — is more convincing than reading about it.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/examples/bgp/session_08_walkthrough.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/bgp/session_08_walkthrough.py&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is a teaching model, and it's worth being precise about what it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;next_hop="self"&lt;/code&gt; is symbolic.&lt;/strong&gt; A real implementation rewrites the NEXT_HOP attribute to an actual address — typically a local interface or loopback address chosen per session. The string &lt;code&gt;"self"&lt;/code&gt; here is a marker that a rewrite happened, not a representation of the value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The eBGP branch compresses several behaviors into one.&lt;/strong&gt; Real eBGP export does more than prepend the local AS. It handles NEXT_HOP semantics that vary by topology and session type, MED treatment across AS boundaries, LOCAL_PREF stripping (a well-known attribute that must not cross an eBGP boundary), community handling, and more. This module collapses all of that into a single visible transformation so the &lt;em&gt;shape&lt;/em&gt; — outbound state is transformed, not copied — is unmissable. Don't read the single prepend as a claim of completeness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;next_hop_self&lt;/code&gt; is presented as a boolean knob.&lt;/strong&gt; In production, NEXT_HOP behavior on export depends on peer type, whether the route was learned from an eBGP or iBGP peer, whether it's a directly connected route, route-reflector configuration, and per-vendor defaults. Treating it as one flag is a teaching simplification, not a claim about default behavior in every real eBGP export case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no route-reflector logic here.&lt;/strong&gt; iBGP export in real deployments involves reflection rules, cluster lists, and originator IDs. This model's iBGP path is simply "skip the prepend."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policy is a single function with four fields.&lt;/strong&gt; Real export policy is a chain of route-maps and filter lists with match/set clauses evaluated in sequence, capable of modifying essentially any attribute. &lt;code&gt;prepare_export()&lt;/code&gt; is one function with fixed branches so you can see the control flow rather than a policy DSL.&lt;/p&gt;

&lt;p&gt;The boundary is the point, not an apology for it. The model is deliberately small enough that the branching structure is visible, and once you've internalized that structure the real implementations read as elaborations rather than mysteries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Go back to the source and answer these without looking anywhere else. If you can't, you haven't read it closely enough yet.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which single condition causes &lt;code&gt;prepare_export()&lt;/code&gt; to return &lt;code&gt;None&lt;/code&gt; — and what does the function do to the locally installed path in that case?&lt;/li&gt;
&lt;li&gt;Under what circumstances does &lt;code&gt;next_hop&lt;/code&gt; become &lt;code&gt;"self"&lt;/code&gt;, and does the peer type affect that at all?&lt;/li&gt;
&lt;li&gt;Trace the value of &lt;code&gt;exported.as_path&lt;/code&gt; for an iBGP peer versus an eBGP peer with &lt;code&gt;extra_prepend_count=2&lt;/code&gt;. Which line is responsible for the difference, and which line is responsible for the &lt;em&gt;absence&lt;/em&gt; of a difference?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can articulate why "installed route" and "exported route" are different objects in practice — and point at the exact lines that make them different — the module has done its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc4271" rel="noopener noreferrer"&gt;RFC 4271 — A Border Gateway Protocol 4 (BGP-4)&lt;/a&gt;, particularly §9.2 on Update-Send Process and §9.1 on the Decision Process, for how advertisement is specified as a phase distinct from path selection.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc4456" rel="noopener noreferrer"&gt;RFC 4456 — BGP Route Reflection&lt;/a&gt;, for what this model's iBGP path deliberately omits.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc7454" rel="noopener noreferrer"&gt;RFC 7454 — BGP Operations and Security&lt;/a&gt;, for why &lt;code&gt;deny_prefixes&lt;/code&gt; is the least toy-like field in the whole dataclass.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>protocol</category>
    </item>
    <item>
      <title>Import policy rewrites the route before best-path ever sees it</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Mon, 27 Jul 2026 16:00:15 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/import-policy-rewrites-the-route-before-best-path-ever-sees-it-3429</link>
      <guid>https://dev.to/pathvector-dev/import-policy-rewrites-the-route-before-best-path-ever-sees-it-3429</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-07/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-07/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, 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 &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you're newer to this and want to &lt;em&gt;run&lt;/em&gt; things before you read things, start with &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; — the hands-on companion series that builds the muscle memory this one assumes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How does local import policy change or reject a path before best-path selection runs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;local_pref&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;They don't arrive and get ranked. They arrive, get &lt;em&gt;rewritten&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;Which means the interesting question isn't "who won best-path" but "what did best-path actually receive."&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the code
&lt;/h2&gt;

&lt;p&gt;The file is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/import_policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/import_policy.py&lt;/code&gt;&lt;/a&gt;. It's short enough to hold in your head all at once, which is the point — the shape is the lesson.&lt;/p&gt;

&lt;p&gt;Start with the policy object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImportPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;local_pref_override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;reject_next_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;reject_invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four knobs, and notice they're not four of the same thing. Two of them (&lt;code&gt;local_pref_override&lt;/code&gt;, &lt;code&gt;weight&lt;/code&gt;) change a value. Two of them (&lt;code&gt;reject_next_hops&lt;/code&gt;, &lt;code&gt;reject_invalid&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;Also notice the default of &lt;code&gt;local_pref_override&lt;/code&gt;: it's &lt;code&gt;None&lt;/code&gt;, not &lt;code&gt;0&lt;/code&gt; or &lt;code&gt;100&lt;/code&gt;. That's load-bearing. &lt;code&gt;weight&lt;/code&gt; defaults to &lt;code&gt;0&lt;/code&gt; because zero &lt;em&gt;is&lt;/em&gt; a meaningful weight; &lt;code&gt;local_pref_override&lt;/code&gt; needs a sentinel because there's no integer that means "don't touch this."&lt;/p&gt;

&lt;p&gt;Now the function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_import_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ImportPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PathCandidate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The return type tells you the story before you read a single line of the body: &lt;code&gt;PathCandidate | None&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Read the body in the order it executes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_hop&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reject_next_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INVALID&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reject_invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 — &lt;code&gt;validation_state&lt;/code&gt; comes in as a parameter, decided by validation logic this file never sees. And it's gated by &lt;code&gt;policy.reject_invalid&lt;/code&gt;. Read that conjunction carefully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INVALID&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reject_invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;INVALID&lt;/code&gt; 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 &lt;em&gt;result&lt;/em&gt;, and policy decides what that result &lt;em&gt;does&lt;/em&gt;. A route can be known-invalid and still sail through to best-path selection if &lt;code&gt;reject_invalid&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;. That separation — result versus action — is the thing Session 05 set up, and here's where it gets consumed.&lt;/p&gt;

&lt;p&gt;Then the rewrite half:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_pref_override&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local_pref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_pref_override&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;replace()&lt;/code&gt; is &lt;code&gt;dataclasses.replace&lt;/code&gt; — it builds a new frozen &lt;code&gt;PathCandidate&lt;/code&gt; with one field swapped. Nothing mutates. &lt;code&gt;updated&lt;/code&gt; is rebound, never modified in place, and the original &lt;code&gt;candidate&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The two conditions guarding the rewrites are worth comparing side by side, because they don't match:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;if policy.local_pref_override is not None:&lt;/code&gt; — a sentinel check. Is an override configured at all?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if policy.weight != updated.weight:&lt;/code&gt; — a difference check. Is the configured weight already what the candidate has?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is an optimization more than a semantic: with the default &lt;code&gt;weight=0&lt;/code&gt; and a candidate that also has &lt;code&gt;weight=0&lt;/code&gt;, you skip building a new object for no change. But it also means &lt;code&gt;weight&lt;/code&gt; has no "unset" state. Leaving &lt;code&gt;weight&lt;/code&gt; alone in your policy isn't neutral — it's asserting &lt;code&gt;0&lt;/code&gt;. If a candidate arrived with a non-zero weight from somewhere upstream, a policy that never mentions &lt;code&gt;weight&lt;/code&gt; will still flatten it back to &lt;code&gt;0&lt;/code&gt;. Whether that's a bug or the intended semantics is exactly the kind of thing worth deciding for yourself by reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does to best-path
&lt;/h2&gt;

&lt;p&gt;Here's the connection that makes the module worth the time. &lt;code&gt;local_pref&lt;/code&gt; and &lt;code&gt;weight&lt;/code&gt; are not arbitrary fields — they're the top two inputs to the best-path comparison. Import policy writes directly into the highest-priority tiebreakers &lt;em&gt;before&lt;/em&gt; the tiebreakers run.&lt;/p&gt;

&lt;p&gt;So the pipeline is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A peer sends you a path with whatever attributes it chose.&lt;/li&gt;
&lt;li&gt;Validation produces a &lt;code&gt;ValidationState&lt;/code&gt; for it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apply_import_policy&lt;/code&gt; either drops it or hands back a modified copy.&lt;/li&gt;
&lt;li&gt;Best-path selection compares what survived.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 has no idea steps 1–3 happened. It sees a &lt;code&gt;PathCandidate&lt;/code&gt; with a &lt;code&gt;local_pref&lt;/code&gt; and a &lt;code&gt;weight&lt;/code&gt; 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: &lt;strong&gt;the candidate entering best-path is not always the one the peer originally sent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same shape, different protocol
&lt;/h2&gt;

&lt;p&gt;Once you've seen this shape you'll notice it everywhere: &lt;strong&gt;a transform stage that sits in front of a decision stage and quietly rewrites the decision's inputs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The walkthrough is executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_07_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs four candidates through &lt;code&gt;apply_import_policy&lt;/code&gt;: one that gets its &lt;code&gt;local_pref&lt;/code&gt; rewritten, one that picks up a local &lt;code&gt;weight&lt;/code&gt;, 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 &lt;code&gt;None&lt;/code&gt; — that binary is the entire first half of the function, made visible.&lt;/p&gt;

&lt;p&gt;Then change the policy and run it again. Set &lt;code&gt;reject_invalid=False&lt;/code&gt; and watch the invalid path survive. Set &lt;code&gt;local_pref_override&lt;/code&gt; without &lt;code&gt;weight&lt;/code&gt; and see exactly one field move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is a toy model, and being precise about what it isn't is the point of this section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real import policy is a rule chain, not a single struct.&lt;/strong&gt; Vendor implementations evaluate an ordered list of route-map or filter clauses, each with match conditions and set actions, with terminating and continue semantics. &lt;code&gt;ImportPolicy&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only two attributes are writable.&lt;/strong&gt; 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 &lt;code&gt;local_pref&lt;/code&gt; and &lt;code&gt;weight&lt;/code&gt; because those are the two that most cleanly demonstrate "policy writes best-path's inputs."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rejection is a return value, not a state.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;weight&lt;/code&gt; isn't a protocol field.&lt;/strong&gt; &lt;code&gt;local_pref&lt;/code&gt; is a real BGP path attribute carried in UPDATE messages within an AS. &lt;code&gt;weight&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No prefix matching, no peer scoping, no direction.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation state arrives from nowhere.&lt;/strong&gt; &lt;code&gt;validation_state&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Don't look these up — answer them by reading &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/import_policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;import_policy.py&lt;/code&gt;&lt;/a&gt; and then confirm with the walkthrough.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exactly which conditions cause &lt;code&gt;apply_import_policy&lt;/code&gt; to return &lt;code&gt;None&lt;/code&gt;?&lt;/strong&gt; Can you state both, including what has to be true for the second one to fire?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a policy sets only &lt;code&gt;local_pref_override&lt;/code&gt; and leaves everything else at its default, what does the returned &lt;code&gt;PathCandidate&lt;/code&gt; look like?&lt;/strong&gt; Be specific about &lt;code&gt;weight&lt;/code&gt; — is it untouched, or not?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why is rewriting an input a fundamentally different operation from rejecting a path?&lt;/strong&gt; Both change the outcome of best-path selection. What can the downstream comparison logic tell about which one happened to it?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You should be able to finish this module able to say, without notes: import policy runs before best-path; it can rewrite &lt;code&gt;local_pref&lt;/code&gt; and &lt;code&gt;weight&lt;/code&gt;; it can drop a path before comparison; and the candidate entering best-path is not always the one the peer sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271" rel="noopener noreferrer"&gt;RFC 4271&lt;/a&gt; — A Border Gateway Protocol 4 (BGP-4), for the decision process and &lt;code&gt;LOCAL_PREF&lt;/code&gt; semantics&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4456" rel="noopener noreferrer"&gt;RFC 4456&lt;/a&gt; — BGP Route Reflection, for why &lt;code&gt;local_pref&lt;/code&gt; propagation inside an AS matters&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6811" rel="noopener noreferrer"&gt;RFC 6811&lt;/a&gt; — BGP Prefix Origin Validation, for where &lt;code&gt;ValidationState&lt;/code&gt; comes from and what "invalid" is supposed to mean&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>network</category>
      <category>bgp</category>
      <category>protocol</category>
    </item>
    <item>
      <title>Validation State Doesn't Act By Itself</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Sun, 26 Jul 2026 16:00:19 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/validation-state-doesnt-act-by-itself-35nm</link>
      <guid>https://dev.to/pathvector-dev/validation-state-doesnt-act-by-itself-35nm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-05/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-05/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, 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: &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;. If you're newer to this material and want a more hands-on, guided on-ramp first, start with the companion &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series and come back.&lt;/p&gt;

&lt;p&gt;Today's module is from the BGP track, Session 05. The source file is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/policy.py&lt;/code&gt;&lt;/a&gt;, and it builds directly on the origin-validation logic from Session 04.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question to keep in your head
&lt;/h2&gt;

&lt;p&gt;Here's the one thing to turn over as you read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What happens after origin validation returns &lt;code&gt;valid&lt;/code&gt;, &lt;code&gt;invalid&lt;/code&gt;, or &lt;code&gt;not_found&lt;/code&gt; — and why does the result still need routing policy before anything happens to the route?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;em&gt;policy decision&lt;/em&gt; built on top of a validation &lt;em&gt;result&lt;/em&gt;. The whole point of this session is to separate those two things in your head, and the code makes the seam impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers, not one
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;ValidationState&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Policy answers a completely different question: given that fact, what do we &lt;em&gt;do&lt;/em&gt;? 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.&lt;/p&gt;

&lt;p&gt;The file models the second layer with three small pieces. First, the set of actions the router can take:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ACCEPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DEPRIORITIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deprioritize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;REJECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there are &lt;em&gt;three&lt;/em&gt; 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."&lt;/p&gt;

&lt;p&gt;Then the knobs — the local configuration that decides how those actions get chosen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ValidationPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;reject_invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;deprioritize_not_found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two booleans, both defaulting to &lt;code&gt;False&lt;/code&gt;. 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 &lt;code&gt;reject_invalid&lt;/code&gt;, not the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the decision
&lt;/h2&gt;

&lt;p&gt;Everything comes together in one function. Read it top to bottom — the control flow &lt;em&gt;is&lt;/em&gt; the specification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide_route_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;validation_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ValidationPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INVALID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reject_invalid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REJECT&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEPRIORITIZE&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;validation_state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deprioritize_not_found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEPRIORITIZE&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PolicyAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACCEPT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trace the branches as inputs, not as prose.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;reject_invalid=True&lt;/code&gt; → &lt;code&gt;PolicyAction.REJECT&lt;/code&gt; — the route is dropped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reject_invalid=False&lt;/code&gt; → &lt;code&gt;PolicyAction.DEPRIORITIZE&lt;/code&gt; — the route stays in the table, just less preferred.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That inner &lt;code&gt;if/else&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;NOT_FOUND&lt;/code&gt; only does something if you asked it to.&lt;/strong&gt; The second branch fires only when the state is &lt;code&gt;NOT_FOUND&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;deprioritize_not_found&lt;/code&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;deprioritize_not_found=True&lt;/code&gt; → &lt;code&gt;PolicyAction.DEPRIORITIZE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deprioritize_not_found=False&lt;/code&gt; → falls through to the final &lt;code&gt;return PolicyAction.ACCEPT&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;h2&gt;
  
  
  The reading move
&lt;/h2&gt;

&lt;p&gt;The habit this session is trying to build is small but load-bearing. Stop saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"RPKI invalid means the router rejects the route."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Start asking three questions instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What validation state came back?&lt;/li&gt;
&lt;li&gt;What local policy is configured?&lt;/li&gt;
&lt;li&gt;What concrete action follows from that policy?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The answer to question 1 alone never determines the outcome. It's always 1 &lt;em&gt;and&lt;/em&gt; 2, resolved by &lt;code&gt;decide_route_policy&lt;/code&gt;, that produce question 3's answer.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;state&lt;/em&gt;, and a separate, locally-configured &lt;em&gt;policy&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it and watch the combinations
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_05_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you run it, try to predict each line. Pick a &lt;code&gt;ValidationState&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy Model Boundary
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only three actions exist here.&lt;/strong&gt; Real BGP policy does far more than accept / deprioritize / reject. "Deprioritize" in the wild means concrete knobs — lowering &lt;code&gt;LOCAL_PREF&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation state is the only input.&lt;/strong&gt; A real policy also weighs prefix length, neighbor, communities, AS-path contents, max-prefix limits, and more. Here, &lt;code&gt;decide_route_policy&lt;/code&gt; sees exactly one signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two boolean knobs stand in for a policy language.&lt;/strong&gt; Production route policy is written in expressive, vendor-specific configuration (route-maps, RPL, routing-policy blocks) with ordering and match/set semantics. &lt;code&gt;reject_invalid&lt;/code&gt; and &lt;code&gt;deprioritize_not_found&lt;/code&gt; are a teaching-sized caricature of that surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No time, no churn, no state machine.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;valid&lt;/code&gt; state gets no special treatment.&lt;/strong&gt; RFC 6811 leaves room for policy that actively &lt;em&gt;prefers&lt;/em&gt; valid routes; here, &lt;code&gt;valid&lt;/code&gt; just means "accept normally," identical to an un-deprioritized &lt;code&gt;not_found&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Don't take my walkthrough as the answer. Open &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/policy.py" rel="noopener noreferrer"&gt;&lt;code&gt;policy.py&lt;/code&gt;&lt;/a&gt; and see if you can answer these purely by reading the code — no running required:&lt;/p&gt;

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

&lt;p&gt;If you can answer all three by pointing at specific lines, you've got the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6811" rel="noopener noreferrer"&gt;RFC 6811&lt;/a&gt;, Section 2.1 — the validation states and what they mean.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc6811" rel="noopener noreferrer"&gt;RFC 6811&lt;/a&gt;, Section 3 — where the spec is explicit that using the validation result is a matter of local policy, not a mandated action.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>bgp</category>
      <category>network</category>
      <category>protocol</category>
    </item>
    <item>
      <title>Origin validation is a separate decision from best path</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Sun, 26 Jul 2026 16:00:12 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/origin-validation-is-a-separate-decision-from-best-path-316g</link>
      <guid>https://dev.to/pathvector-dev/origin-validation-is-a-separate-decision-from-best-path-316g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-04/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-04/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols as &lt;em&gt;logic&lt;/em&gt; — inputs, state, and branches — rather than as configuration examples. The full source, walkthroughs, and site lessons live in the repo: &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;pathvector-studio/protocol-in-code&lt;/a&gt;. If you're newer to this and want to build the protocols hands-on before dissecting them, start with the companion &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series instead.&lt;/p&gt;

&lt;p&gt;Today we're on the BGP track, session 04, reading a single small file: &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/validation.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/validation.py&lt;/code&gt;&lt;/a&gt;. It's about 40 lines. The idea inside it is one that trips up a lot of engineers who've been running BGP for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question to keep in your head
&lt;/h2&gt;

&lt;p&gt;BGP's best path selection already ran. It compared local preference, AS_PATH length, MED, and the rest of the tiebreak ladder, and it picked a winner. So here's the question this module wants you turning over:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Core question:&lt;/strong&gt; How do we decide whether the origin AS is &lt;em&gt;authorized&lt;/em&gt; — even after BGP has already selected this route as the best path?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The trap is the sentence "it was the best path, so it must be fine." Best and authorized are two different words, and in the code they are two different decisions made by two different pieces of data. Best path selection asks &lt;em&gt;which of these routes do I prefer?&lt;/em&gt; Origin validation asks &lt;em&gt;is the AS at the end of this path actually allowed to originate this prefix?&lt;/em&gt; A route can win selection and still be a hijack.&lt;/p&gt;

&lt;p&gt;RPKI origin validation is the mechanism that answers the second question, and the file we're reading is a toy model of exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of information
&lt;/h2&gt;

&lt;p&gt;The first thing to read isn't a function — it's the two dataclasses, because the whole session is really about keeping them apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BGPRoute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;origin_as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VRP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;origin_as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BGPRoute&lt;/code&gt; is the BGP-side information — what a router learned from a neighbor. A prefix, and the AS that claims to originate it. That &lt;code&gt;origin_as&lt;/code&gt; is the claim we're going to check.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VRP&lt;/code&gt; is the &lt;em&gt;other&lt;/em&gt; side: a &lt;strong&gt;Validated ROA Payload&lt;/strong&gt;. This is the authorization side, derived from RPKI. It says "this prefix range may be originated by this AS, down to this maximum length." A VRP is not a route. Nobody is forwarding traffic based on a VRP. It's an assertion about who is &lt;em&gt;allowed&lt;/em&gt; to originate, published by the address holder and cryptographically validated upstream — by the time it reaches this code, it's just data: a prefix, a &lt;code&gt;max_length&lt;/code&gt;, and an authorized &lt;code&gt;origin_as&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Notice the asymmetry. The route has no &lt;code&gt;max_length&lt;/code&gt;; the VRP does. That field is the whole reason coverage isn't just "is one prefix inside the other."&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage: is this route even in scope?
&lt;/h2&gt;

&lt;p&gt;Before we can say valid or invalid, we have to ask whether any VRP even &lt;em&gt;talks about&lt;/em&gt; this route. That's &lt;code&gt;vrp_covers_route&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;vrp_covers_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BGPRoute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VRP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;route_net&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_route_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vrp_net&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_vrp_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vrp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;route_net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subnet_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vrp_net&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;route_net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefixlen&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;vrp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two conditions, both required. First, the route's prefix must be a subnet of the VRP's prefix — &lt;code&gt;192.0.2.0/24&lt;/code&gt; is covered by &lt;code&gt;192.0.2.0/22&lt;/code&gt;, but &lt;code&gt;198.51.100.0/24&lt;/code&gt; is not covered by either. Standard containment.&lt;/p&gt;

&lt;p&gt;Second — and this is the part that surprises people — the route's prefix length must be &lt;strong&gt;less than or equal to&lt;/strong&gt; the VRP's &lt;code&gt;max_length&lt;/code&gt;. A VRP for &lt;code&gt;192.0.2.0/22&lt;/code&gt; with &lt;code&gt;max_length = 22&lt;/code&gt; authorizes only the &lt;code&gt;/22&lt;/code&gt; itself. If someone originates &lt;code&gt;192.0.2.0/24&lt;/code&gt;, that &lt;code&gt;/24&lt;/code&gt; is geographically inside the &lt;code&gt;/22&lt;/code&gt; but its &lt;code&gt;prefixlen&lt;/code&gt; (24) exceeds &lt;code&gt;max_length&lt;/code&gt; (22), so &lt;code&gt;subnet_of&lt;/code&gt; is true but the length check is false, and this VRP does &lt;em&gt;not&lt;/em&gt; cover it.&lt;/p&gt;

&lt;p&gt;That's deliberate. &lt;code&gt;max_length&lt;/code&gt; is how an address holder says "I authorize this block, but not arbitrarily deaggregated announcements of it." Without that field, an attacker could announce a more-specific of your authorized prefix and ride your ROA. With it, a more-specific that exceeds &lt;code&gt;max_length&lt;/code&gt; simply isn't covered by that VRP — and, as we're about to see, "not covered" has consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: three answers, not two
&lt;/h2&gt;

&lt;p&gt;Here's the core of the session, &lt;code&gt;validate_origin&lt;/code&gt;, and it's worth reading as three sequential branches rather than one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BGPRoute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VRP&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;covering_vrps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vrp&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vrp&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;vrps&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;vrp_covers_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vrp&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;covering_vrps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NOT_FOUND&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vrp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin_as&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin_as&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vrp&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;covering_vrps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VALID&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INVALID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a funnel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First branch — is anything covering this route at all?&lt;/strong&gt; Filter the VRP set down to the ones that cover the route. If that list is empty, we return &lt;code&gt;NOT_FOUND&lt;/code&gt; and stop. We never even look at the origin AS. There is no VRP that has anything to say about this prefix, so we can't judge it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second branch — does a covering VRP authorize this origin?&lt;/strong&gt; Among the covering VRPs, if &lt;em&gt;any&lt;/em&gt; one has an &lt;code&gt;origin_as&lt;/code&gt; equal to the route's &lt;code&gt;origin_as&lt;/code&gt;, the route is &lt;code&gt;VALID&lt;/code&gt;. The address holder authorized this AS to originate this prefix, and that's exactly who originated it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third branch — covered but not authorized.&lt;/strong&gt; We fall through to &lt;code&gt;INVALID&lt;/code&gt;. This only happens when the funnel got past the first branch: at least one VRP &lt;em&gt;does&lt;/em&gt; cover this prefix, but &lt;em&gt;none&lt;/em&gt; of the covering VRPs name this route's origin AS. Someone is originating a prefix that RPKI says belongs to a different AS.&lt;/p&gt;

&lt;p&gt;The critical distinction is between &lt;code&gt;NOT_FOUND&lt;/code&gt; and &lt;code&gt;INVALID&lt;/code&gt;, and the branch order is what encodes it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;not_found&lt;/code&gt; is not a weaker &lt;code&gt;invalid&lt;/code&gt;. &lt;code&gt;not_found&lt;/code&gt; means &lt;em&gt;no covering VRP existed&lt;/em&gt; — we have no authorization data about this prefix, so we're not claiming anything. &lt;code&gt;invalid&lt;/code&gt; means a covering VRP &lt;em&gt;did&lt;/em&gt; exist and the origin AS contradicted it — we have data, and the route lost. One is silence; the other is a conflict. Real routers treat them very differently: &lt;code&gt;not_found&lt;/code&gt; routes are commonly still accepted (much of the internet has no ROA yet), while &lt;code&gt;invalid&lt;/code&gt; routes are increasingly dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three states, encoded as an enum so the caller has to handle them by name rather than juggling booleans:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;VALID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;INVALID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;NOT_FOUND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Same shape, different protocol
&lt;/h2&gt;

&lt;p&gt;The move this session is training — &lt;em&gt;stop trusting the winner of one decision to answer a second, independent question&lt;/em&gt; — is not unique to BGP. It's a recurring shape.&lt;/p&gt;

&lt;p&gt;The design of this module deliberately teaches origin validation as a &lt;strong&gt;separate concept, after&lt;/strong&gt; best-path selection, so you learn to split "best" from "authorized." In production implementations the two can be wired together — validation state can feed route policy, adjust local preference, or gate installation — and a later session in this track uses one such toy integration on purpose. But the reason to read them apart first is that they &lt;em&gt;are&lt;/em&gt; apart: selection ranks, validation authorizes, and conflating them is how a preferred hijack gets installed.&lt;/p&gt;

&lt;p&gt;If you've read the DNS track, this is the same shape as validating a signed answer versus simply receiving one: getting an &lt;code&gt;A&lt;/code&gt; record back doesn't mean it was authenticated. Winning the race to answer is not the same as being authorized to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it and watch the branches fire
&lt;/h2&gt;

&lt;p&gt;The walkthrough is runnable. It constructs routes and VRPs that land in each of the three states, plus the &lt;code&gt;max_length&lt;/code&gt; edge case, and prints why each one resolved the way it did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_04_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch specifically for the &lt;code&gt;max_length&lt;/code&gt; scenario — a route that's a clean subnet of a VRP's prefix but comes back &lt;code&gt;NOT_FOUND&lt;/code&gt; because its &lt;code&gt;prefixlen&lt;/code&gt; overshot &lt;code&gt;max_length&lt;/code&gt;. If you can predict that result &lt;em&gt;before&lt;/em&gt; you run it, you've read &lt;code&gt;vrp_covers_route&lt;/code&gt; correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy model boundary
&lt;/h2&gt;

&lt;p&gt;This is a teaching model, and it's honest to be precise about what it leaves out — because those omissions are exactly where the real complexity lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No cryptography.&lt;/strong&gt; A real VRP is the output of validating a ROA: an RPKI-signed object, verified up a chain of resource certificates to a trust anchor. Here, &lt;code&gt;VRP&lt;/code&gt; is a plain dataclass you hand-construct. All the signature checking, certificate path validation, and trust-anchor management that produces a &lt;em&gt;validated&lt;/em&gt; payload happens before this code would ever run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No RTR transport.&lt;/strong&gt; In production, routers don't parse RPKI themselves; a relying-party validator does, and ships VRPs to the router over the RTR protocol (RFC 8210) with sessions, serial numbers, and incremental updates. Our &lt;code&gt;vrps&lt;/code&gt; is just a Python list passed in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IPv4 only.&lt;/strong&gt; &lt;code&gt;_route_network&lt;/code&gt; and &lt;code&gt;_vrp_network&lt;/code&gt; call &lt;code&gt;ip_network&lt;/code&gt; and the model assumes &lt;code&gt;IPv4Network&lt;/code&gt;. Real validation is address-family agnostic and ROAs cover IPv6 too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin only — never the path.&lt;/strong&gt; This is the big one. &lt;code&gt;validate_origin&lt;/code&gt; checks the &lt;em&gt;origin&lt;/em&gt; AS: the last AS in the AS_PATH, the one claiming to originate the prefix. It says nothing about whether the intermediate hops in the path are legitimate, whether the announcement respects business relationships, or whether the path was forged after a valid origin. That's the domain of BGPsec and ASPA, and it's entirely outside this file. A route can be RPKI-origin-&lt;code&gt;VALID&lt;/code&gt; and still be a path manipulation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No caching, no staleness, no overlapping-VRP policy nuance&lt;/strong&gt; beyond the plain "any covering VRP matches" rule shown here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bugs in the model. They're the seam between "understand the decision" and "operate the system," and this session is deliberately on the first side of that seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-check
&lt;/h2&gt;

&lt;p&gt;Don't take my summary for it — go read &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/validation.py" rel="noopener noreferrer"&gt;&lt;code&gt;validation.py&lt;/code&gt;&lt;/a&gt; and answer these from the code alone:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A route arrives whose prefix no VRP covers. What does &lt;code&gt;validate_origin&lt;/code&gt; return, and — trace the branches — does it ever look at the origin AS?&lt;/li&gt;
&lt;li&gt;A route is covered by a VRP, but that VRP names a different &lt;code&gt;origin_as&lt;/code&gt;. Which state comes back, and why is that state &lt;em&gt;stronger&lt;/em&gt; than the one in question 1?&lt;/li&gt;
&lt;li&gt;Why does this function, even when it returns &lt;code&gt;VALID&lt;/code&gt;, still not tell you the AS_PATH is trustworthy?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can answer all three by pointing at lines rather than recalling this post, you've got it: best path selection and origin validation are separate decisions, &lt;code&gt;not_found&lt;/code&gt; is silence rather than a verdict, and origin validation checks the origin — not the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RFC 6482&lt;/strong&gt; §3 — Route Origin Authorizations (ROAs), the source of VRPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC 6811&lt;/strong&gt; §2, §2.1 — BGP Prefix Origin Validation, including the valid / invalid / not-found states and prefix coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC 8210&lt;/strong&gt; §1–2 — The RPKI-to-Router (RTR) protocol that delivers VRPs to routers.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>bgp</category>
      <category>network</category>
      <category>protocol</category>
    </item>
    <item>
      <title>How a BGP UPDATE Changes State: Reading Withdrawal and Announcement as Code</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Sat, 25 Jul 2026 16:00:18 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/how-a-bgp-update-changes-state-reading-withdrawal-and-announcement-as-code-4lcp</link>
      <guid>https://dev.to/pathvector-dev/how-a-bgp-update-changes-state-reading-withdrawal-and-announcement-as-code-4lcp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-02/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-02/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols not as configuration examples but as &lt;em&gt;logic&lt;/em&gt; — inputs, state, and branches you can step through. If you'd rather read the whole thing yourself, the source lives at &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;New to this?&lt;/strong&gt; If you can run commands but haven't yet built up hands-on intuition for how these protocols behave, start with the companion &lt;strong&gt;Protocol Lab&lt;/strong&gt; series first: &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-lab&lt;/a&gt;. It gets your hands dirty before you start reading the internals as code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The question to hold in your head
&lt;/h2&gt;

&lt;p&gt;Here's the one thing to keep turning over as you read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How does a BGP UPDATE change routing state, and why is route withdrawal different from session failure?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most explanations of BGP UPDATE treat it as a packet format — here are the withdrawn routes, here are the NLRI, here are the path attributes, memorize the byte layout. That framing tells you what an UPDATE &lt;em&gt;contains&lt;/em&gt;. It doesn't tell you what an UPDATE &lt;em&gt;does&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The move in this session is to stop asking "how do I decode this packet?" and start asking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What disappeared?&lt;/li&gt;
&lt;li&gt;What appeared?&lt;/li&gt;
&lt;li&gt;Which attributes arrived with the new path?&lt;/li&gt;
&lt;li&gt;Did the route leave because of a withdrawal, or because the session itself died?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those last two are the ones people conflate, and the code makes the difference impossible to fudge. Let's read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of an UPDATE
&lt;/h2&gt;

&lt;p&gt;The toy model for this session is a single file: &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/update.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/update.py&lt;/code&gt;&lt;/a&gt;. Start with the data.&lt;/p&gt;

&lt;p&gt;A path attribute set is exactly what travels &lt;em&gt;with&lt;/em&gt; an advertised route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PathAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;next_hop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;as_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
    &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;local_pref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;next_hop&lt;/code&gt;, &lt;code&gt;as_path&lt;/code&gt;, &lt;code&gt;origin&lt;/code&gt;, &lt;code&gt;local_pref&lt;/code&gt; — this is the policy and path context. Note that it's &lt;code&gt;frozen=True&lt;/code&gt;: an announcement carries an immutable description of &lt;em&gt;how&lt;/em&gt; to reach a prefix. Hold onto that; it's the reason announcements and withdrawals turn out to be structurally different operations.&lt;/p&gt;

&lt;p&gt;Now the message itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BGPUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;nlri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;withdrawn_routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;path_attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathAttributes&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields, and they tell you everything the incoming message can contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;withdrawn_routes&lt;/code&gt; — prefixes that should &lt;strong&gt;disappear&lt;/strong&gt; from the table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nlri&lt;/code&gt; — Network Layer Reachability Information, the prefixes being &lt;strong&gt;announced&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;path_attributes&lt;/code&gt; — the context that goes with the announcement. Optional, and its optionality is doing real work, which we'll get to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first thing to notice: all three default to empty/&lt;code&gt;None&lt;/code&gt;. A valid UPDATE can carry only withdrawals, only announcements, or both at once. That's not an accident of the toy model — it's true of real BGP, and it's the crux of the whole session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state that changes
&lt;/h2&gt;

&lt;p&gt;Everything above is input. Here's the thing that actually mutates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RoutingTable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;paths_by_prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PathAttributes&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PathAttributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paths_by_prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;withdraw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paths_by_prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;paths_by_prefix&lt;/code&gt; is the current routing state after each mutation. And now look closely at the two methods, because this is where withdrawal and announcement reveal themselves as &lt;em&gt;different kinds of work&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;apply_update()&lt;/code&gt; &lt;strong&gt;adds&lt;/strong&gt;. It appends a &lt;code&gt;PathAttributes&lt;/code&gt; to the list for a prefix, creating the list if it doesn't exist. Announcing is additive and it &lt;em&gt;requires&lt;/em&gt; attributes — you cannot announce a prefix without saying how to reach it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;withdraw()&lt;/code&gt; &lt;strong&gt;removes&lt;/strong&gt;. It pops the prefix entirely, and — this matters — &lt;code&gt;pop(prefix, None)&lt;/code&gt; never raises. Withdrawing a prefix that isn't there is a no-op, not an error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not two flavors of the same operation. One needs a full &lt;code&gt;PathAttributes&lt;/code&gt; payload; the other needs nothing but a prefix name. That asymmetry is the answer to "why is route withdrawal different from session failure" hiding in plain sight — but let's watch them combine first.&lt;/p&gt;

&lt;h2&gt;
  
  
  One message, two kinds of work
&lt;/h2&gt;

&lt;p&gt;Here is the whole session in one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_update_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RoutingTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BGPUpdate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RoutingTable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Apply withdrawals first, then announcements from a single UPDATE.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withdrawn_routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withdraw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_attributes&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nlri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a sequence of decisions, not as a parser:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Withdrawals go first.&lt;/strong&gt; Every prefix in &lt;code&gt;withdrawn_routes&lt;/code&gt; is popped before anything is added. Order is deliberate — a single UPDATE that both withdraws and re-announces a prefix ends with the announcement winning, because the removal happens first and the append happens after.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No attributes, no announcement.&lt;/strong&gt; The guard &lt;code&gt;if update.path_attributes is None: return table&lt;/code&gt; is the enforcement of that asymmetry from earlier. An UPDATE that carries only withdrawals has no &lt;code&gt;path_attributes&lt;/code&gt;, so the function returns right there — the &lt;code&gt;nlri&lt;/code&gt; loop never runs. This is also why an announcement can't sneak through without a path: the attributes are the ticket of entry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Announcements attach the path.&lt;/strong&gt; Each prefix in &lt;code&gt;nlri&lt;/code&gt; gets &lt;code&gt;update.path_attributes&lt;/code&gt; appended. The same attribute set travels with every prefix in this UPDATE, which is exactly why path attributes are a message-level field and not a per-prefix one.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So a single UPDATE can &lt;em&gt;both remove and add&lt;/em&gt; routes, in that order, and the shape of the message decides which loops execute.&lt;/p&gt;

&lt;p&gt;You can watch this happen. The walkthrough is runnable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_02_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints a sequence of UPDATEs and shows how &lt;code&gt;paths_by_prefix&lt;/code&gt; changes after each one. Run it, then predict the table state &lt;em&gt;before&lt;/em&gt; each line prints — that's the whole exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a withdrawal is not a dead session
&lt;/h2&gt;

&lt;p&gt;Now we can answer the core question directly, and it's worth being precise because operators mix these up constantly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;withdraw()&lt;/code&gt; removes &lt;strong&gt;one prefix&lt;/strong&gt;. That's a routing decision the peer made: "I no longer have a path to &lt;code&gt;10.0.0.0/24&lt;/code&gt;, stop using me for it." The BGP session between you and that peer is perfectly healthy. Other prefixes it advertised are untouched.&lt;/p&gt;

&lt;p&gt;A session &lt;em&gt;dying&lt;/em&gt; is a different event entirely. When the TCP connection underneath BGP drops — hold timer expires, the peer crashes, the link fails — &lt;em&gt;everything&lt;/em&gt; that peer ever told you has to be reconsidered at once. That's not one &lt;code&gt;withdraw()&lt;/code&gt; call; it's the invalidation of a whole peer's worth of state.&lt;/p&gt;

&lt;p&gt;The toy model draws the line but doesn't yet model the second case, which brings us to the honest part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy Model Boundary
&lt;/h2&gt;

&lt;p&gt;This is a deliberately simplified UPDATE model, and the simplification is specific: &lt;strong&gt;it is prefix-wide.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RoutingTable.withdraw()&lt;/code&gt; removes the &lt;em&gt;entire&lt;/em&gt; prefix entry — &lt;code&gt;paths_by_prefix.pop(prefix, None)&lt;/code&gt;. In real BGP, that's too blunt. A prefix can be advertised to you by several peers at once, and each peer keeps its own &lt;code&gt;Adj-RIB-In&lt;/code&gt;. Withdrawing a prefix should remove &lt;em&gt;one peer's&lt;/em&gt; path for it, leaving other peers' paths intact so the best-path selection can fall back to an alternative.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;paths_by_prefix&lt;/code&gt; maps to a &lt;em&gt;list&lt;/em&gt; of &lt;code&gt;PathAttributes&lt;/code&gt; — the structure anticipates multiple paths per prefix — but this session's &lt;code&gt;withdraw()&lt;/code&gt; doesn't yet key removal by peer. Later sessions refine it into per-peer &lt;code&gt;Adj-RIB-In&lt;/code&gt; state, where one peer can disappear while another peer's path for the same prefix survives. Until then, treat "withdraw = drop the whole prefix" as a scaffold, not the real semantics.&lt;/p&gt;

&lt;p&gt;Everything else here — withdrawals-before-announcements ordering, attributes as the gate on announcement, one message doing two jobs — is faithful to RFC 4271. The single-path-per-prefix table is the one place the model is knowingly ahead of itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape elsewhere
&lt;/h2&gt;

&lt;p&gt;Once you've read this, the pattern is recognizable across the series: &lt;strong&gt;a message that carries both "forget this" and "learn this" in a single unit, applied in a fixed order.&lt;/strong&gt; BGP calls them withdrawn routes and NLRI. But the shape — invalidate first, then install, with state that outlives any single message — is the same one you'll meet again in DNS zone transfers and in any protocol that syncs incremental changes to a table rather than resending it whole. Reading BGP's UPDATE at the code level is really reading &lt;em&gt;incremental state reconciliation&lt;/em&gt;, and that's transferable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Don't take my walkthrough as the answer. Open &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/update.py" rel="noopener noreferrer"&gt;&lt;code&gt;update.py&lt;/code&gt;&lt;/a&gt; and see if you can answer these purely by reading the code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What happens if &lt;code&gt;withdrawn_routes&lt;/code&gt; contains a prefix that &lt;strong&gt;already exists&lt;/strong&gt; in the table — and what happens if it contains one that &lt;strong&gt;doesn't&lt;/strong&gt;?&lt;/li&gt;
&lt;li&gt;What happens if &lt;code&gt;path_attributes&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt; but &lt;code&gt;nlri&lt;/code&gt; is non-empty? Which line decides the outcome?&lt;/li&gt;
&lt;li&gt;If a single UPDATE contains &lt;em&gt;both&lt;/em&gt; withdrawals and NLRI for the same prefix, what's in the table when the function returns — and why does the docstring's "withdrawals first" matter here?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can trace each of those to a specific line without running anything, you've read the session. If you can't, run the walkthrough and come back to the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RFC 4271 §3.1 — Routes, NLRI, and path attributes&lt;/li&gt;
&lt;li&gt;RFC 4271 §4.3 — UPDATE message format&lt;/li&gt;
&lt;li&gt;RFC 4271 §5 — Path attributes&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>bgp</category>
      <category>network</category>
      <category>protocol</category>
    </item>
    <item>
      <title>What a BGP Neighbor Needs — Reading Session Setup as Code</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Sat, 25 Jul 2026 16:00:11 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/what-a-bgp-neighbor-needs-reading-session-setup-as-code-277c</link>
      <guid>https://dev.to/pathvector-dev/what-a-bgp-neighbor-needs-reading-session-setup-as-code-277c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-in-code-bgp-01/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-in-code-bgp-01/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol in Code&lt;/strong&gt;, a free series that reads network protocols not as configuration examples but as logic — inputs, state, and branches you can step through. The full course and every source file it reads live in the repo: &lt;a href="https://github.com/pathvector-studio/protocol-in-code" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-in-code&lt;/a&gt;. If you're newer to this and want to build the muscle by &lt;em&gt;doing&lt;/em&gt; rather than reading, start with the hands-on companion series, &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt;, then come back here.&lt;/p&gt;

&lt;p&gt;Here's the question to keep turning over while you read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What inputs are required to form a BGP neighbor, and where does the session stop when one of them is missing?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That second half is the interesting part. Most people can recite that BGP needs a peer IP and an AS number. Far fewer can tell you &lt;em&gt;which state a session lands in&lt;/em&gt; when TCP is fine but the OPEN negotiation fails — versus when TCP itself never came up. Those are different failures, and they stop the machine in different places. The only way to know the difference cold is to read the branches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inputs: what even goes into a session
&lt;/h2&gt;

&lt;p&gt;Before you can reason about failure, you need to know the full surface of inputs. In the toy model that's a single dataclass, and reading it is the fastest way to see what a BGP session actually depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BGPSessionConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;peer_ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;peer_as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;local_as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;tcp_reachable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;hold_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
    &lt;span class="n"&gt;keepalive_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
    &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BGPCapability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;open_message_ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;keepalive_received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as a checklist of everything a neighbor needs, and &lt;em&gt;why&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;peer_ip&lt;/code&gt; — without a destination, TCP can't even start. No IP, no dial tone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;peer_as&lt;/code&gt; and &lt;code&gt;local_as&lt;/code&gt; — you need a remote identity to validate against and a local identity to announce in your OPEN message. A BGP speaker introduces itself by AS number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tcp_reachable&lt;/code&gt; — this is the one people skip past. BGP is an application riding on top of TCP. Before a single BGP message is exchanged, a TCP connection has to succeed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hold_time&lt;/code&gt; / &lt;code&gt;keepalive_time&lt;/code&gt; — a session isn't just created, it's &lt;em&gt;maintained&lt;/em&gt; under timer rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;capabilities&lt;/code&gt; — the two peers have to agree on what they're even able to talk about.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;open_message_ok&lt;/code&gt; — OPEN negotiation can fail on its own terms, even when TCP is perfectly healthy.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;keepalive_received&lt;/code&gt; — the session isn't fully established until a KEEPALIVE has actually come back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice that &lt;code&gt;peer_ip&lt;/code&gt; alone is nowhere near enough. It gets you a destination and nothing else. Everything after it is another gate the session has to pass through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The states it can stop at
&lt;/h2&gt;

&lt;p&gt;The second thing to read is the enum. It's the vocabulary the code uses for "how far did we get":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;IDLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;CONNECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;OPEN_SENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OpenSent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;OPEN_CONFIRM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OpenConfirm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ESTABLISHED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Established&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The canonical happy path runs straight through these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle -&amp;gt; Connect -&amp;gt; Active -&amp;gt; OpenSent -&amp;gt; OpenConfirm -&amp;gt; Established
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the enum isn't just a list of milestones you pass through on success. Each of these is also a &lt;em&gt;place a failed session can come to rest&lt;/em&gt;. That's the mental shift this session is asking for. &lt;code&gt;Established&lt;/code&gt; is not a config line you set — it's the label you earn only after every check upstream has passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the machine
&lt;/h2&gt;

&lt;p&gt;Now the main event. The whole state machine is one function, and it's worth reading top to bottom as a sequence of early returns — because each early return is a different way the session can stall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;establish_neighbor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BGPSessionConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Walk a minimal BGP session state machine.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IDLE&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;

    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONNECT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tcp_reachable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;

    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN_SENT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_as&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_as&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open_message_ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;

    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN_CONFIRM&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keepalive_received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ESTABLISHED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walk the gates in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaving &lt;code&gt;Idle&lt;/code&gt;.&lt;/strong&gt; We start at &lt;code&gt;IDLE&lt;/code&gt;. The very first check is &lt;code&gt;if not config.peer_ip&lt;/code&gt;. No peer IP means we never even try to dial, so we return &lt;code&gt;Idle&lt;/code&gt; unchanged. This is the "you haven't given me anywhere to go" failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Connect&lt;/code&gt;, and the fallback to &lt;code&gt;Active&lt;/code&gt;.&lt;/strong&gt; Once we have an IP we move to &lt;code&gt;CONNECT&lt;/code&gt; — meaning "attempting the TCP connection." Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONNECT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tcp_reachable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If TCP isn't reachable, we don't sit in &lt;code&gt;Connect&lt;/code&gt; — we fall back to &lt;code&gt;Active&lt;/code&gt;. This is the detail that trips people up. In real BGP, &lt;code&gt;Active&lt;/code&gt; doesn't mean "up and doing things"; it means the speaker &lt;em&gt;tried&lt;/em&gt; to connect, failed, and is now waiting to retry. It's arguably the most badly named state in all of networking. The code makes the semantics unambiguous: reaching &lt;code&gt;Active&lt;/code&gt; here means TCP did not come up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;OpenSent&lt;/code&gt;, and the two ways to be stuck there.&lt;/strong&gt; If TCP succeeded, we advance to &lt;code&gt;OPEN_SENT&lt;/code&gt; — the OPEN message is on the wire and we're waiting on the peer. Two separate checks can pin us here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN_SENT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_as&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local_as&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open_message_ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invalid AS numbers (either side ≤ 0) stop us in &lt;code&gt;OpenSent&lt;/code&gt;. So does a failed OPEN negotiation. Both return the same state, but they're distinct causes — one is "your identities don't validate," the other is "the OPEN exchange itself was rejected." The key takeaway: &lt;strong&gt;an OPEN failure and a TCP failure stop the session in different places&lt;/strong&gt; — &lt;code&gt;OpenSent&lt;/code&gt; versus &lt;code&gt;Active&lt;/code&gt;. If someone hands you a stuck session and says "it's in OpenSent," you already know TCP is fine and the problem is up in the BGP layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;OpenConfirm&lt;/code&gt;, waiting on KEEPALIVE.&lt;/strong&gt; Pass the OPEN checks and we move to &lt;code&gt;OPEN_CONFIRM&lt;/code&gt;, which means "OPEN was accepted, now I need to hear a KEEPALIVE back":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN_CONFIRM&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keepalive_received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No KEEPALIVE, and we rest at &lt;code&gt;OpenConfirm&lt;/code&gt;. Only when that final gate passes do we return &lt;code&gt;Established&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last line is the whole point of the session. &lt;code&gt;Established&lt;/code&gt; isn't a setting — it's the result of clearing &lt;em&gt;every&lt;/em&gt; check above it, in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it and watch the states
&lt;/h2&gt;

&lt;p&gt;The reading is the work, but the walkthrough lets you confirm your mental model against real output. It runs several &lt;code&gt;BGPSessionConfig&lt;/code&gt; scenarios and prints which state each one reaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;src python3 examples/bgp/session_01_walkthrough.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you run it, try to predict each scenario's landing state from the source alone. If your prediction and the printout disagree, that gap is exactly the thing to go re-read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toy Model Boundary
&lt;/h2&gt;

&lt;p&gt;This is a deliberately minimal model, and being honest about what it &lt;em&gt;isn't&lt;/em&gt; is the point.&lt;/p&gt;

&lt;p&gt;The dataclass exposes &lt;code&gt;hold_time&lt;/code&gt;, &lt;code&gt;keepalive_time&lt;/code&gt;, and &lt;code&gt;capabilities&lt;/code&gt; because they're part of a realistic BGP session's surface — you should know they exist. But &lt;code&gt;establish_neighbor()&lt;/code&gt; &lt;strong&gt;does not actually negotiate or enforce them yet.&lt;/strong&gt; In this session, the branches that really fire depend only on &lt;code&gt;peer_ip&lt;/code&gt;, &lt;code&gt;tcp_reachable&lt;/code&gt;, valid AS numbers, &lt;code&gt;open_message_ok&lt;/code&gt;, and &lt;code&gt;keepalive_received&lt;/code&gt;. The timers and capabilities are along for the ride as structure, not behavior.&lt;/p&gt;

&lt;p&gt;A few more things the real protocol does that this model collapses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timing is boolean here.&lt;/strong&gt; &lt;code&gt;tcp_reachable&lt;/code&gt;, &lt;code&gt;open_message_ok&lt;/code&gt;, and &lt;code&gt;keepalive_received&lt;/code&gt; are pre-decided flags. Real BGP discovers each of these over time, with retries, backoff, and the &lt;code&gt;ConnectRetry&lt;/code&gt;, &lt;code&gt;Hold&lt;/code&gt;, and &lt;code&gt;Keepalive&lt;/code&gt; timers governing the pace. Here, &lt;code&gt;Active&lt;/code&gt; is a terminal return value; in the real FSM it's a state you loop back &lt;em&gt;out&lt;/em&gt; of on the next connection attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No collision detection, no passive/active roles.&lt;/strong&gt; Real speakers can both initiate, detect the collision, and tear one connection down. This model has a single directional walk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OPEN is a boolean, not a negotiation.&lt;/strong&gt; &lt;code&gt;open_message_ok&lt;/code&gt; hides the actual content — version, AS, hold time, BGP Identifier, and the capability optional parameters that &lt;code&gt;capabilities&lt;/code&gt; is standing in for. Capability &lt;em&gt;mismatch&lt;/em&gt; handling doesn't exist here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that makes the model wrong — it makes it a lens. The FSM's &lt;em&gt;shape&lt;/em&gt; (each input is a gate; each failure has a resting state) is exactly right, and that shape is what carries over to the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape, elsewhere
&lt;/h2&gt;

&lt;p&gt;The move this session trains — stop asking "did the config look correct?" and start asking "which field was missing, which condition failed, which state was the last successful one?" — is not specific to BGP. It's the reading lens for every stateful protocol handshake. A TLS handshake stalling at a specific message, a TCP connection stuck in &lt;code&gt;SYN_SENT&lt;/code&gt; versus &lt;code&gt;SYN_RECEIVED&lt;/code&gt;: same idea. The state you're stuck in &lt;em&gt;is&lt;/em&gt; the diagnosis, because it tells you which gate you cleared and which one you didn't. Learn to read one FSM this way and you can read all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-check: can you answer these from the source alone?
&lt;/h2&gt;

&lt;p&gt;Don't run anything for these — answer them by reading &lt;code&gt;establish_neighbor()&lt;/code&gt;, then verify. The point is to prove the state machine lives in your head, not your notes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If &lt;code&gt;peer_ip&lt;/code&gt; is empty, what state comes back — and why is it &lt;em&gt;that&lt;/em&gt; one rather than &lt;code&gt;Connect&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;If TCP is unreachable, why does the session land in &lt;code&gt;Active&lt;/code&gt; instead of simply staying in &lt;code&gt;Connect&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Invalid AS numbers and a failed OPEN both return the same state — which one, and how would you tell those two failures apart if all you had was the returned state?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can answer all three cold, you've got the session: &lt;em&gt;BGP doesn't start at OPEN — it starts after TCP reachability, OPEN failure and TCP failure stop in different places, and &lt;code&gt;Established&lt;/code&gt; is the result of passing several checks, not a single setting.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc4271" rel="noopener noreferrer"&gt;RFC 4271&lt;/a&gt; §1.1 — BGP overview and the role of TCP&lt;/li&gt;
&lt;li&gt;RFC 4271 §3 — session summary and the peering model&lt;/li&gt;
&lt;li&gt;RFC 4271 §4.2 — the OPEN message format and its fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source file for this session is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/src/protocol_in_code/bgp/session.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/protocol_in_code/bgp/session.py&lt;/code&gt;&lt;/a&gt;, and the runnable walkthrough is &lt;a href="https://github.com/pathvector-studio/protocol-in-code/blob/main/examples/bgp/session_01_walkthrough.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/bgp/session_01_walkthrough.py&lt;/code&gt;&lt;/a&gt;. Read the code, then make it lie to you — hand it a broken config and see if you called the resting state before Python did.&lt;/p&gt;

</description>
      <category>python</category>
      <category>bgp</category>
      <category>network</category>
      <category>protocol</category>
    </item>
    <item>
      <title>Split-Horizon DNS: One Name, Two Answers, and the Server Decides by Who's Asking</title>
      <dc:creator>pathvector-dev</dc:creator>
      <pubDate>Fri, 24 Jul 2026 16:00:59 +0000</pubDate>
      <link>https://dev.to/pathvector-dev/split-horizon-dns-one-name-two-answers-and-the-server-decides-by-whos-asking-4859</link>
      <guid>https://dev.to/pathvector-dev/split-horizon-dns-one-name-two-answers-and-the-server-decides-by-whos-asking-4859</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.pathvector.dev/protocol-lab-dns-views-42-split-horizon/" rel="noopener noreferrer"&gt;https://blog.pathvector.dev/protocol-lab-dns-views-42-split-horizon/&lt;/a&gt; — part of the free &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;Protocol Lab&lt;/a&gt; series.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is part of &lt;strong&gt;Protocol Lab&lt;/strong&gt;, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-lab&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In Lab #41, round-robin DNS rotated the &lt;em&gt;order&lt;/em&gt; of one name's records. Split-horizon DNS pulls a more radical trick: it changes the &lt;strong&gt;answer itself&lt;/strong&gt; based on &lt;strong&gt;who is asking&lt;/strong&gt;. An authoritative server keeps several &lt;strong&gt;views&lt;/strong&gt; of the same zone and picks one by the client's source address.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reading guide:&lt;/strong&gt; &lt;a href="https://github.com/pathvector-studio/protocol-lab/blob/main/rfc-notes/split-horizon-dns.md" rel="noopener noreferrer"&gt;rfc-notes/split-horizon-dns.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisite:&lt;/strong&gt; &lt;a href="https://github.com/pathvector-studio/protocol-lab/blob/main/examples/dns-05-recursive-resolution.md" rel="noopener noreferrer"&gt;DNS Lab 05: Resolving a Name Through the Hierarchy&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Expected time: 35–50 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;One name (&lt;code&gt;app.lab.&lt;/code&gt;) is served through two views:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;strong&gt;internal&lt;/strong&gt; view (&lt;code&gt;match-clients { 10.0.1.0/24; }&lt;/code&gt;) answers with the &lt;strong&gt;private&lt;/strong&gt; address &lt;code&gt;10.0.0.5&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;external&lt;/strong&gt; view (&lt;code&gt;match-clients { any; }&lt;/code&gt;) answers with the &lt;strong&gt;public&lt;/strong&gt; address &lt;code&gt;203.0.113.5&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;two clients on different networks resolve the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;app.lab.&lt;/code&gt; and get &lt;em&gt;different&lt;/em&gt; addresses — the basis of split-brain DNS, used to give insiders a private path and outsiders a public one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end, you should be able to explain this table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;client (source)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;app.lab.&lt;/code&gt; resolves to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;internal (10.0.1.0/24)&lt;/td&gt;
&lt;td&gt;10.0.0.5 (private)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;external (any other)&lt;/td&gt;
&lt;td&gt;203.0.113.5 (public)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What You Will Learn
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What a BIND &lt;strong&gt;view&lt;/strong&gt; is and how &lt;code&gt;match-clients&lt;/code&gt; selects one by source address.&lt;/li&gt;
&lt;li&gt;Why view order matters (specific first, &lt;code&gt;any&lt;/code&gt; last).&lt;/li&gt;
&lt;li&gt;How the same name resolves to different records per view.&lt;/li&gt;
&lt;li&gt;Where split-horizon is used (internal vs. public paths, hiding topology).&lt;/li&gt;
&lt;li&gt;The caveats: caching across boundaries and keeping two zone copies consistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This lab does &lt;strong&gt;not&lt;/strong&gt; cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recursive resolvers and cache separation in depth.&lt;/li&gt;
&lt;li&gt;TSIG-key-based view selection or EDNS Client Subnet.&lt;/li&gt;
&lt;li&gt;Automated zone-data generation for multiple views.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to Read in the RFCs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reference&lt;/th&gt;
&lt;th&gt;What to focus on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RFC 8499&lt;/td&gt;
&lt;td&gt;The terminology for "view" / split DNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 1034&lt;/td&gt;
&lt;td&gt;The basics of authoritative servers and zones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 6950&lt;/td&gt;
&lt;td&gt;Caveats of scoped (audience-dependent) responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 5737 / RFC 1918&lt;/td&gt;
&lt;td&gt;Confirming the addresses used here are local/documentation-only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Big Picture
&lt;/h2&gt;

&lt;p&gt;The DNS server sits between two faces, internal and external. A client on each side asks for the same name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; internal-client (10.0.1.10) --- eth1 [ dns (views) ] eth2 --- external-client (203.0.113.10)
                             10.0.1.2                203.0.113.2
   internal: app.lab -&amp;gt; 10.0.0.5     external: app.lab -&amp;gt; 203.0.113.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
  I["internal-client&amp;lt;br/&amp;gt;src 10.0.1.10"] --&amp;gt;|dig app.lab| D["dns&amp;lt;br/&amp;gt;view match by source"]
  E["external-client&amp;lt;br/&amp;gt;src 203.0.113.10"] --&amp;gt;|dig app.lab| D
  D --&amp;gt;|"internal view&amp;lt;br/&amp;gt;(10.0.1.0/24)"| RI["A 10.0.0.5 (private)"]
  D --&amp;gt;|"external view&amp;lt;br/&amp;gt;(any)"| RE["A 203.0.113.5 (public)"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;10.0.1.0/24&lt;/code&gt; is the internal link; &lt;code&gt;203.0.113.0/24&lt;/code&gt; (RFC 5737) is the external link.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Everything here uses local/documentation address space (RFC 1918 / RFC 5737), so nothing in this lab touches the real internet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;p&gt;Recommended environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linux / WSL2 / a Linux VM&lt;/li&gt;
&lt;li&gt;Docker&lt;/li&gt;
&lt;li&gt;containerlab&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Images used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;protocol-lab/bind9:9.20&lt;/code&gt; — built from the Dockerfile by &lt;code&gt;run.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nicolaka/netshoot:latest&lt;/code&gt; — the clients, providing &lt;code&gt;dig&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Running the Lab
&lt;/h2&gt;

&lt;p&gt;The quick path, which deploys, verifies, and tears down for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/labctl.sh run dns-views-42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or step through it manually:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Move into the working directory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;protocol-lab/examples/dns-views-42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Build the image and deploy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; protocol-lab/bind9:9.20 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;containerlab deploy &lt;span class="nt"&gt;-t&lt;/span&gt; dns-views-42.clab.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Resolve the same name from inside and outside
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;clab-dns-views-42-internal-client dig +short app.lab @10.0.1.2      &lt;span class="c"&gt;# 10.0.0.5&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;clab-dns-views-42-external-client dig +short app.lab @203.0.113.2   &lt;span class="c"&gt;# 203.0.113.5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;app.lab&lt;/code&gt; resolves to a different address depending on the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expected Output
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;internal-client: &lt;code&gt;app.lab&lt;/code&gt; → &lt;code&gt;10.0.0.5&lt;/code&gt; (private).&lt;/li&gt;
&lt;li&gt;external-client: &lt;code&gt;app.lab&lt;/code&gt; → &lt;code&gt;203.0.113.5&lt;/code&gt; (public).&lt;/li&gt;
&lt;li&gt;The two answers differ — split-horizon is working.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why It Works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Split-horizon DNS&lt;/strong&gt; is "one authoritative name, multiple sets of answers, chosen by who's asking."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Views.&lt;/strong&gt; A view is a named container with its own zone definitions (files). The server can hold the same zone separately in multiple views.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selection by &lt;code&gt;match-clients&lt;/code&gt;.&lt;/strong&gt; The server compares the query's &lt;strong&gt;source address&lt;/strong&gt; against each view's &lt;code&gt;match-clients&lt;/code&gt; list, &lt;strong&gt;top to bottom&lt;/strong&gt;, and answers from the zone of the first view that matches. In this lab, the order is internal (&lt;code&gt;10.0.1.0/24&lt;/code&gt;) → external (&lt;code&gt;any&lt;/code&gt;). The internal client (&lt;code&gt;10.0.1.10&lt;/code&gt;) matches the internal view → &lt;code&gt;10.0.0.5&lt;/code&gt;. The external client (&lt;code&gt;203.0.113.10&lt;/code&gt;) doesn't match internal, falls through to external (&lt;code&gt;any&lt;/code&gt;) → &lt;code&gt;203.0.113.5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order is everything.&lt;/strong&gt; Put specific views first and &lt;code&gt;any&lt;/code&gt; last. Reverse them and &lt;em&gt;everyone&lt;/em&gt; lands in the first view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why it's useful.&lt;/strong&gt; Internal users get the private IP (a direct in-house path); external users get the public IP (the published route). Private addresses and internal-only hosts stay invisible to the outside (topology hiding), and users keep using the same name (URL) everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The caveats.&lt;/strong&gt; Responses get cached by resolvers — if you don't separate internal and external resolvers, answers from different views can get mixed. Each view keeps its own copy of the zone, so a missed update leaves inside and outside out of sync. And selection is based on source address, so NAT or VPN can shift a client into an unintended view.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight: &lt;strong&gt;select the view by source address, and the same name resolves to the right face for wherever the client sits.&lt;/strong&gt; Unlike round-robin (which shuffles order), split-horizon changes the answer itself based on who asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confusing it with round-robin.&lt;/strong&gt; RR (Lab 41) changes the &lt;em&gt;order&lt;/em&gt; of records; views change the &lt;em&gt;answer itself&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View ordering.&lt;/strong&gt; Matching goes top-down and the first hit wins. Put &lt;code&gt;any&lt;/code&gt; first and everyone falls into it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming one zone file is enough.&lt;/strong&gt; Each view holds its own copy of the zone; you need an operational story for keeping them in sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching.&lt;/strong&gt; Without separate internal and external resolvers, answers get mixed across the boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting the source address too much.&lt;/strong&gt; NAT, VPNs, and spoofing can all change the apparent source. Design your ACLs carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursion.&lt;/strong&gt; This lab queries the authoritative server directly. Put a recursive resolver in between, and view selection is decided by the &lt;em&gt;resolver's&lt;/em&gt; source address, not the end client's.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cleanup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;containerlab destroy &lt;span class="nt"&gt;-t&lt;/span&gt; dns-views-42.clab.yml &lt;span class="nt"&gt;--cleanup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you used &lt;code&gt;labctl.sh run dns-views-42&lt;/code&gt;, the script runs &lt;code&gt;destroy&lt;/code&gt; for you at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check Your Understanding
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;What is a BIND view? What does &lt;code&gt;match-clients&lt;/code&gt; use to select one?&lt;/li&gt;
&lt;li&gt;How does the view matching order matter? What happens if &lt;code&gt;any&lt;/code&gt; comes first?&lt;/li&gt;
&lt;li&gt;In this lab, why do the internal and external clients get different addresses for the same name?&lt;/li&gt;
&lt;li&gt;How does split-horizon differ from round-robin (Lab 41)?&lt;/li&gt;
&lt;li&gt;Name two operational caveats of split-horizon (think caching and consistency).&lt;/li&gt;
&lt;li&gt;How do NAT or VPN affect view selection?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc1034" rel="noopener noreferrer"&gt;RFC 1034: Domain Names — Concepts and Facilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8499" rel="noopener noreferrer"&gt;RFC 8499: DNS Terminology&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6950" rel="noopener noreferrer"&gt;RFC 6950: Architectural Considerations on Application Features in the DNS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc5737" rel="noopener noreferrer"&gt;RFC 5737: IPv4 Address Blocks Reserved for Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verified Run Log (2026-07-08)
&lt;/h2&gt;

&lt;p&gt;This lab has been confirmed reproducible on real hardware.&lt;/p&gt;

&lt;p&gt;Environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)&lt;/li&gt;
&lt;li&gt;Docker 29.1.3&lt;/li&gt;
&lt;li&gt;containerlab 0.77.0&lt;/li&gt;
&lt;li&gt;dns: &lt;code&gt;protocol-lab/bind9:9.20&lt;/code&gt; (built from the Dockerfile by &lt;code&gt;run.sh&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;internal-client / external-client: &lt;code&gt;nicolaka/netshoot:latest&lt;/code&gt; (&lt;code&gt;dig&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running &lt;code&gt;PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dns-views-42&lt;/code&gt; performed build → deploy → verify → destroy, and &lt;code&gt;verification.json&lt;/code&gt; returned &lt;code&gt;"status": "verified"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same name, a different answer per source
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;internal: 10.0.0.5
external: 203.0.113.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;internal-client&lt;/strong&gt; (source &lt;code&gt;10.0.1.10&lt;/code&gt;, matching &lt;code&gt;10.0.1.0/24&lt;/code&gt;) landed in the internal view: &lt;code&gt;app.lab&lt;/code&gt; → &lt;strong&gt;&lt;code&gt;10.0.0.5&lt;/code&gt;&lt;/strong&gt; (the private address).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;external-client&lt;/strong&gt; (source &lt;code&gt;203.0.113.10&lt;/code&gt;) didn't match internal and fell through to the external (&lt;code&gt;any&lt;/code&gt;) view: the same &lt;code&gt;app.lab&lt;/code&gt; → &lt;strong&gt;&lt;code&gt;203.0.113.5&lt;/code&gt;&lt;/strong&gt; (the public address).&lt;/li&gt;
&lt;li&gt;One authoritative name resolved to different records per source, via &lt;code&gt;match-clients&lt;/code&gt; view selection — private to the inside, public to the outside. Split-horizon confirmed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cleanup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;containerlab destroy &lt;span class="nt"&gt;-t&lt;/span&gt; dns-views-42.clab.yml &lt;span class="nt"&gt;--cleanup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;That's split-horizon DNS: keep multiple views of one zone, match on the source address, and the same name quietly resolves to the right answer for each audience.&lt;/p&gt;

&lt;p&gt;Explore the full &lt;strong&gt;Protocol Lab&lt;/strong&gt; series here: &lt;a href="https://github.com/pathvector-studio/protocol-lab" rel="noopener noreferrer"&gt;github.com/pathvector-studio/protocol-lab&lt;/a&gt;. If these labs are useful to you, please ⭐ &lt;strong&gt;star the repo on GitHub&lt;/strong&gt; — it genuinely helps others find the project.&lt;/p&gt;

&lt;p&gt;Next up, we'll keep pushing on DNS behavior — how resolvers, caches, and trust boundaries shape the answers you actually see.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>bind</category>
      <category>network</category>
      <category>containerlab</category>
    </item>
  </channel>
</rss>
