Established is not a status label — it's a write permission
This post is part of Protocol in Code, 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 github.com/pathvector-studio/protocol-in-code.
Note: If you're earlier in the journey and want hands-on packet-level exercises before diving into implementation logic, start with the Protocol Lab series instead. This series assumes you're comfortable reading Python and want to know why the branch is where it is.
The question
Here's the one to keep turning over as you read:
What has to be true before the control plane should accept an UPDATE from a neighbor?
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.
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.
Read the file in three moves
The file is src/protocol_in_code/bgp/peer_state.py, with src/protocol_in_code/bgp/session.py 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.
1. What a peer actually is
@dataclass(frozen=True)
class PeerSession:
peer_id: str
config: BGPSessionConfig
state: SessionState
def open_peer_session(peer_id: str, config: BGPSessionConfig) -> PeerSession:
return PeerSession(peer_id=peer_id, config=config, state=establish_neighbor(config))
open_peer_session() does almost nothing. It takes an identifier and a config, calls establish_neighbor() from session.py, and staples the resulting state onto a frozen dataclass. That's the whole constructor.
The thing worth noticing is what it doesn't do. It doesn't retry. It doesn't leave the state mutable so a later step can nudge a peer into Established because the operator wants the route. The dataclass is frozen=True: whatever establish_neighbor() 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.
So PeerSession isn't really a connection object. It's a decision, packaged.
2. The gate, stated as one question
def session_accepts_updates(peer: PeerSession) -> bool:
return peer.state is SessionState.ESTABLISHED
One line. One comparison. No or, no fallback, no "well, OpenConfirm is close enough."
The function name is doing real work here — it could have been called is_established(), which would describe the state. Instead it's named for the consequence: does this session accept updates? That naming is the whole point of the session. Established isn't a label you read off a show bgp summary output to feel good about. It's the answer to a permission question that gets asked on every inbound UPDATE.
Note the is rather than ==. SessionState 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.
3. Where the if sits
def receive_update_if_established(
adj_rib_in: AdjRIBIn,
peer: PeerSession,
prefix: str,
attributes: PathAttributes,
) -> bool:
if not session_accepts_updates(peer):
return False
store_received_path(adj_rib_in, peer.peer_id, prefix, attributes)
return True
This is the entire lesson, and it's four lines of body.
The important thing is not the amount of code. It is the placement of the if.
Look at what's on each side of the early return. Above it: nothing has happened. Below it: a write into Adj-RIB-In. There is no path through this function where a route lands in the RIB without first passing session_accepts_updates(). Not "gets stored and then filtered later." Not "gets stored with a flag." The write is structurally downstream of the check.
Compare that to the alternative you've probably seen in real codebases:
# NOT what this module does — the shape to avoid
store_received_path(adj_rib_in, peer.peer_id, prefix, attributes)
if not session_accepts_updates(peer):
mark_as_pending(adj_rib_in, peer.peer_id, prefix) # ...
Same information, same check, completely different guarantee. In that version, Adj-RIB-In contains routes from peers that never reached Established, and correctness depends on every reader downstream remembering to filter. In the version the module actually implements, Adj-RIB-In has an invariant: everything in it came from an established session. Downstream code doesn't have to re-check, because the shape of the function made the bad state unrepresentable.
That's why the return type is bool and not None. 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."
Note: The
store_received_path()call takespeer.peer_idas the key, not the prefix alone.Adj-RIB-Inis 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.
Same shape, different protocol
This gate is worth recognizing because you've already met it under other names.
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 Finished 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.
Or take conntrack: a packet matching an existing flow tuple gets fast-pathed, but only if the state machine says the flow is ESTABLISHED. A packet arriving for a SYN_SENT entry takes a different branch entirely. Again: the state isn't decoration, it's the gate on which code path runs.
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.
Toy model boundary
This is where the series stays honest with you.
This lesson isolates only the session-state gate. Established is the first gate in this toy model — it is not the only gate a real implementation uses, and reading it as such will mislead you.
Specifically, what's missing:
- Address-family activation. A real BGP session negotiates which AFI/SAFI pairs are active. A peer can be perfectly
Establishedfor 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. - Negotiated capability checks. 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.
- The rest of the state machine.
establish_neighbor()collapses what RFC 4271 models asIdle → Connect → Active → OpenSent → OpenConfirm → Establishedinto a single call with a single outcome. There are no hold timers, no keepalives, no transitions out ofEstablished. In a real implementation, a peer can be established at the moment an UPDATE arrives and gone by the time it's processed. - Inbound policy. Passing the session gate gets a route into
Adj-RIB-In. In a real router, inbound route-maps, prefix lists, and max-prefix limits all sit between the wire and that store.
The reason the toy model cuts all of it is that each of those gates has the same structural shape 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.
Run it
The walkthrough is runnable:
PYTHONPATH=src python3 examples/bgp/session_11_walkthrough.py
Three things to watch for in the output:
- One peer whose TCP reachability never gets it to
Established. The transport is fine. The session isn't. Watch whatsession_accepts_updates()returns for it. - One peer whose UPDATE is accepted. Trace it through
receive_update_if_established()and confirm it takes the path past the early return. - Only the established peer appearing in
Adj-RIB-In. This is the invariant made visible. The rejected peer left no trace — no partial entry, no pending flag, nothing to clean up later.
That third point is the one to sit with. The absence in the RIB is the whole result.
Check yourself
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.
- A peer's TCP connection is established and packets are flowing, but
session_accepts_updates()returnsFalse. Where exactly inpeer_state.pydoes an inbound route get stopped, and what is left behind inAdj-RIB-Inafterward? - Suppose you moved the
store_received_path()call above theifand returnedFalseafterward anyway. What invariant aboutAdj-RIB-Inwould break, and which downstream reader would notice first? PeerSessionisfrozen=True. If a peer's session dropped out ofEstablishedafteropen_peer_session()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?
You're done with this session when you can explain why Established is a write permission rather than a status label, and point at the exact if that keeps a route out of Adj-RIB-In.
Further reading
- RFC 4271 — A Border Gateway Protocol 4 (BGP-4), especially §8 (BGP Finite State Machine) and §3.2 (Routing Information Bases)
- RFC 4760 — Multiprotocol Extensions for BGP-4, for the address-family activation the toy model omits
- RFC 5492 — Capabilities Advertisement with BGP-4, for what else gets negotiated before
Establishedmeans anything