.rpol language reference
The .rpol language defines typed routing policy for rustbgpd.
The .rpol language defines typed routing policy for rustbgpd.
Status: shipped (ADR-0096, complete). The frontend — lexer,
parser, typechecker, in-language tests, and rbgp policy check — the
daemon integration — [policy] rpol_files config references, mixed
TOML/rpol chains, SIGHUP hot-apply, and the rbgp policy test
live-RIB dry run — and the explain surfaces — per-term statement
traces in rbgp policy explain / rbgp rib --prefix P advertised PEER --explain and
live per-term hit counters via rbgp policy stats — are all live
(see "Using policies in the daemon" below). The M80 interop lab
proves route-for-route parity against FRR route-maps expressing the
same intent, plus .rpol-edit-under-traffic hot-apply with Route
Refresh scoped to the peers whose chains changed.
.rpol compiles to the same public typed IR (rustbgpd_policy::ir)
that TOML policy chains compile to, and is evaluated by the same
tree-walk engine. Match data (prefix sets, community sets, AS-path
regexes) compiles out of the program into shared indexed structures —
a thousand-member set costs one hash probe per route, not a
thousand-statement walk.
Example
# Sets: match data, compiled to indexed structures.
prefix-set customers { 10.10.0.0/16 ge 24 le 28, 192.0.2.0/24 }
community-set tagged { 65000:100, 65000:200 }
# A predicate policy: matches bogons, rejects everything else.
policy bogon-filter {
term bogons { if route.prefix == 0.0.0.0/8 { accept } }
term everything-else { reject }
}
# A parameterized policy: `peer_lp` is substituted at instantiation.
policy customer-in(peer_lp: u32) {
term rpki-guard { if route.rpki == invalid { reject } }
term customer-routes {
if route.prefix in customers && route.communities has 65000:100 {
set local-pref peer_lp;
add community 65001:999;
accept
}
}
term bogon-guard { if apply(bogon-filter) { reject } }
}
# In-language tests, run by `rbgp policy check`.
test customer-in-accepts-tagged {
route { prefix 10.10.1.0/24; communities [65000:100]; rpki valid }
expect customer-in(200) == accept with local-pref 200
}$ rbgp policy check customer-in.rpol
customer-in.rpol: 1 passed, 0 failed
$ echo $?
0Exit codes: 0 clean, 1 compile diagnostics (or unreadable file),
2 in-language test failures, 3 coverage below an explicit threshold
(see Coverage and lints). --json emits a
machine-readable report.
Lexical structure
- Comments:
#to end of line. - Identifiers (set, policy, term, test, parameter names) are
kebab-case:
[A-Za-z_][A-Za-z0-9_]*(-[A-Za-z0-9_]+)*. Maximal munch is permanent (ADR-0103):a-bis always one identifier, so binary subtraction requires whitespace —route.med - 1subtracts,route.med-1is an unknown-field error (with a did-you-mean note). - Reserved keywords (not usable as names):
prefix-set,community-set,asn-set,policy,term,test,if,else,set,add,remove,prepend,accept,reject,apply,in,has,matches,contains,ge,le,route,peer,expect,with,as,self,u32. Field names (local-pref,communities, …) and enum members (valid,internal, …) are contextual, not reserved. - Literals are single tokens, longest-match:
- Prefix:
10.0.0.0/8,2001:db8::/32. Host bits set beyond the prefix length are a compile error (not silently masked). IPv6 literals must use the compressed::form — all-numeric full-form IPv6 is ambiguous with community literals. - IP address:
192.0.2.1,2001:db8::1. - Standard community:
65000:100(both parts u16), or a well-known name:NO_EXPORT,NO_ADVERTISE,NO_EXPORT_SUBCONFED,BLACKHOLE,GRACEFUL_SHUTDOWN. - Large community:
65000:1:2(three u32), also accepted asLC:65000:1:2for parity with the TOML frontend. - Extended community (Route Target / Route Origin):
RT:65001:100,RO:65001:100,RT:192.0.2.1:5(IPv4 admin),RT:200000:100(4-octet-AS admin). IPv4 and 4-octet-AS admins take a u16 local part (RFC 4360 encodings). Well-known names:OV_VALID,OV_NOT_FOUND,OV_INVALID— the RFC 8097 origin-validation states (non-transitive opaque, type0x43sub-type0x00, state in the last octet), matched and added/removed by exact wire value. - Strings (AS-path regexes, peer-group names):
"..."with\"and\\escapes.
- Prefix:
- Statement separators:
;between statements is conventional but optional (the grammar is unambiguous without it); the idiomatic style semicolon-terminates everything except a final verdict.
Types
The type universe is fixed; there are no user-defined types, no maps, and
no unbounded iteration (ADR-0096 Decision 2/4; bounded for loops are
covered under "Loops"), and inference is trivial: every field
has a known type and every operator a fixed signature.
| Type | Values | Where |
|---|---|---|
| bool | guard expressions | if conditions |
| u32 | integer literals, parameters, let bindings | local-pref, med, as-path.len, origin-as, peer.asn, arguments; the only arithmetic type — all arithmetic is checked (overflow/underflow/division-by-zero are evaluation errors, never wraps or traps) |
| prefix | prefix literals | route.prefix |
| community (3 kinds) | community literals | community lists, sets, actions |
| as-path | — (matched, never named) | route.as-path |
| rpki-state | valid, invalid, not-found | route.rpki |
| aspa-state | valid, invalid, unknown | route.aspa |
| route-type | local, internal, external | route.route-type |
| route-family | ipv4-unicast, ipv6-unicast, ipv4-labeled-unicast, ipv6-labeled-unicast, vpnv4, vpnv6, ipv4-flowspec, ipv6-flowspec, evpn, rtc, bgp-ls, bgp-ls-vpn | route.family |
| IP address | address literals | route.next-hop, peer.address |
| string | string literals | regexes, peer.group |
Sets
prefix-set NAME { PREFIX [ge N] [le N], ... }
community-set NAME { COMMUNITY-LITERAL, ... }
asn-set NAME { ASN, ... }ge/lebounds are per member, with the same semantics as the TOML frontend (and Cisco/Juniper prefix lists): the candidate is masked at the member's length and its length must fall in the derived range (geonly →ge..=address_bits;leonly →member_len..=le; neither → exact length). Bounds must satisfymember_len <= ge <= le <= address_bits(32 for IPv4, 128 for IPv6); a member whose range could never match, such as10.0.0.0/24 le 16or10.0.0.0/24 ge 28 le 26, is a compile error. Dataset snapshot files use the same grammar and the same rule.- Community sets may mix standard, large, and extended members.
Membership (
in) matches when any route community of any kind matches any member — the set is kind-partitioned internally, and the field you write it against (route.communities in tagged) reads as documentation, not a kind filter. - ASN sets hold plain ASN literals (u32; 4-byte ASNs are first-class,
out-of-range literals are a compile error, duplicates deduplicate).
Membership is probed by
route.origin-as in NAMEandpeer.asn in NAME— one hash probe regardless of set size. Like strict next-hop, this is an.rpol-only surface: the TOML frontend has no equivalent. - Sets are content-interned: identical sets (in any member order) share one indexed structure across all policies.
- There is no
as-path-setin V1 — inlineroute.as-path matches "regex"covers the need (deliberately deferred; the regex engine is the existing Cisco-style matcher).
Migrating an origin lock from an AS-path regex to an ASN set:
# Before: one regex alternation, re-matched against the rendered
# AS-path string per evaluation.
policy customer-origins-old {
term match { if route.as-path matches "_(64500|64501|64502)$" { accept } }
term rest { reject }
}
# After: one indexed set, one O(1) probe against the typed origin;
# reusable across policies, no regex escape gymnastics for new ASNs.
asn-set customers { 64500, 64501, 64502 }
policy customer-origins {
term match { if route.origin-as in customers { accept } }
term rest { reject }
}Datasets — external set content
dataset prefix-set bogons
dataset asn-set customers
dataset community-set scrub-tagsA dataset is a named set whose members come from an operator-maintained file instead of source text — the shape of "data the daemon doesn't have": bogon feeds, IX participant lists, customer origin sets, tag feeds produced by external automation. The declaration carries only the name and kind; the daemon config binds the name to a snapshot file:
[policy]
rpol_files = ["policies/core.rpol"]
[policy.datasets.customers]
path = "/var/lib/rustbgpd/datasets/customers.list"Guards reference a dataset exactly like a set of its kind —
route.prefix in bogons, route.origin-as in customers,
peer.asn in customers, route.communities in scrub-tags, and the
binding/parameter forms <binding> in customers. Probes run the same
indexed structures in-language sets compile into, at the same cost.
The differences from a source-defined set:
- Content is a runtime snapshot. Each dataset has an immutable, atomically-swapped snapshot with a monotonic generation. Every evaluation pins each referenced snapshot once at walk start — one route never observes two generations of the same dataset, even when a refresh lands mid-walk.
- Content changes are data, not policy. A refresh never recompiles or replaces chains; installed chains see the new generation at their next walk, and the daemon refreshes exactly the peers whose chains reference the swapped dataset (Route Refresh inbound for import chains, forced re-advertisement for export chains). Unrelated peers, chains, and hit counters are untouched.
- Namespace is shared. Datasets live in the flat set namespace: a dataset and a set with the same name is a compile error, as is declaring one name with two kinds (across imports too). At most 16 datasets per compilation unit.
- Probe-only in this slice.
for x in <dataset>is a compile error — content is a runtime snapshot, so iteration bounds are not compile-time knowledge. Probe withininstead. - Parameter probes stay runtime probes.
<parameter> in <set>folds to a constant for source sets; against a dataset it stays a live probe, because the answer changes with the content. - Membership only — no tag maps. A key→value dataset kind
(prefix → customer tag, ASN → tier) is deliberately deferred: the
language's value model is
u32membership and comparison, and a map lookup would introduce a new value-producing guard form. Model tags as one membership dataset per tag value for now.
There is deliberately no external code tier — no WASM, no host functions, no per-route callouts (ADR-0103 Decision 9). External computation runs outside the daemon on its own schedule and writes snapshot files; datasets are the entire external surface of the policy language.
Snapshot file format
One entry per line; # starts a comment; blank lines and surrounding
whitespace are ignored. Entries use the exact literal grammar of the
declared kind's set definition:
# customers.list (asn-set)
64500
64501 # trailing comments are fine
4200000001# bogons.list (prefix-set)
10.0.0.0/8 ge 8 le 32
192.0.2.0/24
2001:db8::/32 ge 48# scrub-tags.list (community-set)
65000:666
65000:1:2
RT:65001:7
NO_EXPORTBounds: 64 MiB per file, 1,000,000 records. A line that does not parse — or a file of the wrong kind — fails the whole snapshot with a line-numbered error; there are no partially-loaded snapshots.
Produce snapshots atomically: write to a temporary file on the
same filesystem, then rename(2) over the target (mv -f tmp final)
— the daemon reads the file on refresh and must never see a torn
write. A cron job shape:
fetch-customers > /var/lib/rustbgpd/datasets/.customers.list.tmp \
&& mv -f /var/lib/rustbgpd/datasets/.customers.list.tmp \
/var/lib/rustbgpd/datasets/customers.listRefresh, failure, and observability
Dataset files are (re)read at config load and on every SIGHUP reload —
there is no file watcher and no dedicated refresh RPC; kill -HUP (or
systemctl reload) after the rename is the refresh trigger. Refresh
semantics:
- Content-equal re-reads are no-ops — no generation bump, no peer refresh (the same content-equality discipline chain reinstalls use, so a SIGHUP with nothing changed touches nothing).
- Changed content swaps atomically and bumps the generation, then refreshes only the referencing peers.
- A file that fails to load or parse rejects the whole reload.
Every declared dataset must load before anything is published, so a
malformed or unreadable file leaves the prior snapshot serving, the
rest of the candidate unapplied, and the candidate file on disk. The
daemon increments
bgp_policy_dataset_refresh_errors_total{dataset}and logsSIGHUP reload rejected without runtime effect. Empty data is never substituted — an empty snapshot requires an explicitly empty file. - Adding, removing, or re-mapping a dataset (a declaration, a kind change, or a file path) is reloadable in the same compensated generation as the neighbor and policy changes that use it; see the reload routes for the combinations that still reject. Its file must load cleanly or the whole load/reload is rejected, like any other config error.
- Superseded snapshots are retained only while in-flight walks still pin them; there is no generation history.
rbgp policy stats reports every dataset's kind, generation, record
count, bound path, and last refresh error. Explain traces annotate
dataset probes with the pinned generation —
route.origin-as in customers [dataset asn-set customers, gen 7] —
without ever dumping content.
In-language test blocks never read operator files: a test whose
policy probes a dataset provides the content itself with a dataset
override before the fixture (kind-checked against the declaration; a
missing override fails the test with a message naming the dataset):
dataset asn-set customers
policy origin-guard {
term customers { if route.origin-as in customers { accept } }
term rest { reject }
}
test member-accepted {
dataset customers { 64500, 64501 }
route { prefix 10.0.0.0/24; as-path "64777 64500" }
expect origin-guard == accept
}rbgp policy check works on dataset-declaring files (tests supply
content); compiling a dataset-referencing policy for live use requires
the daemon config's bindings.
Policies, terms, and evaluation order
policy NAME[(param: u32, ...)] {
[default-action accept|reject]
term NAME { statement... }
...
}- Terms evaluate in order. Statements inside a term are: bare
actions,
letbindings (see "Bindings"),forloops (see "Loops"), orif <expr> { actions... } [else { actions... }].ifbodies are flat action/letlists — no nestedifin V1 (split the condition with&&or use another term). - Verdicts:
acceptpermits the route (with all modifications executed so far in this policy);rejectdenies it (a denied route has no attributes — modifications in a rejecting branch are discarded). A verdict ends the policy's evaluation. - Fallthrough: if a guard doesn't match, or a matched body ends without a verdict, evaluation continues to the next term. Modifications executed along the way (Junos-style modify-and-continue) are kept and merged into the eventual accept.
- End of policy without a verdict uses the policy's
default-action. When omitted, it remainsaccept, with accumulated modifications. An accepting policy continues the chain; a rejecting policy stops it and discards staged modifications. - Statements after a bare
accept/rejectin the same term (or actions after a verdict in the sameifbody) are unreachable and a compile error.
An optional default-action accept|reject declaration must appear at most
once, before the first term. Its semicolon is optional. For example:
prefix-set customers { 192.0.2.0/24 }
policy authorized-customers {
default-action reject
term authorized {
if route.prefix in customers { accept }
}
}The default decides only when no term has returned a verdict. Explicit
default-action accept has the same behavior as omission, including continuing
to later policies in a chain. Existing unconditional terms, such as
term reject-rest { reject }, remain valid. A default declaration does not
detect an accidentally removed guard or conjunct: include negative route
assertions that would fail if the allowed set widened.
Lowering into the IR (normative)
The IR's Term is one (guard, action) pair; an .rpol term lowers
to one or more IR terms:
- Each
ifbecomes an IR term (guard = condition); itselsebecomes a following IR term guarded by the negated condition. - A run of bare modification actions flushes as an unconditional
ContinueIR term (a small IR addition made for this frontend: guard matched → apply modifications → keep walking this policy's terms). - Each
letbecomes aBindIR term: guard = the enclosing branch condition (Truein term-body position; theifguard — or its negation forelse— for a body binding), action = evaluate the initializer and write its frame slot. LikeContinueit never decides; body bindings re-evaluate the branch guard, which is pure and cannot diverge. - When one
.rpolterm produces multiple IR terms they are named<term>.<n>(1-based); a lone IR term keeps the plain term name. Explain traces,rbgp policy testterm hits, andrbgp policy statsrender these names.
Parameters
Parameters are u32 only in V1 and can appear anywhere a u32 is
expected (set local-pref peer_lp, route.local-pref >= peer_lp,
apply(p(peer_lp)), route.as-path contains asn_param). A
parameterized policy is a template: each use site with concrete
arguments is monomorphized at compile time (clone-with-substitution;
policies are small, this is microseconds). The evaluator only ever
sees constants. The prepend count must be a literal (1–255, it is
wire-encoded as u8).
Expressions
Boolean operators, loosest to tightest: ||, &&, !. Parentheses
group. Comparisons: ==, !=, >=, <=.
| Predicate | Meaning |
|---|---|
route.prefix == 10.0.0.0/24 | exact prefix (same network and length) |
route.prefix in customers | prefix-set membership (mask + ge/le ranges) |
route.communities has 65000:100 | route carries this community (kind-checked against the field) |
route.communities in tagged | any route community matches any set member |
route.as-path.len >= 3 | AS-path ASN count (RFC 4271 counting) |
route.as-path contains 65001 | boundary-anchored ASN presence (sugar for matches "_65001_") |
route.as-path matches "^65010" | Cisco/Quagga-style regex; _ is a boundary anchor |
route.origin-as == 64500 | origin AS — the last ASN of the rightmost non-empty AS_SEQUENCE (==/!= only) |
route.origin-as in customers | asn-set membership (one hash probe) |
peer.asn in customers | evaluation-peer ASN against the same asn-sets |
route.local-pref >= 200, route.med <= 50 | u32 comparisons; ==/!= also allowed |
route.med + 50 >= threshold, route.as-path.len * 10 >= route.med | checked-arithmetic comparison (see "Value expressions"); an unresolvable operand denies the route |
route.next-hop == 10.0.0.1 | next-hop equality (==/!= only) |
route.next-hop == peer.address | strict next-hop — the one field-vs-field comparison; reads peer identity, so an export chain using it makes the peer ineligible for update-group sharing (policy_peer_context) |
route.rpki == invalid | RPKI origin validation state |
route.aspa == unknown | ASPA verification state |
route.route-type == external | route source class |
route.evpn-route-type == 2 | EVPN route type (integer literal 1–6, RFC 7432 §7 / RFC 9136 / RFC 9251; ==/!= only) |
route.family == ipv4-unicast | typed AFI/SAFI route family (==/!= only); route-context-only, so it never disqualifies update-group sharing |
peer.address == 192.0.2.1 | evaluation-peer address |
peer.asn == 65010 | evaluation-peer ASN (==/!= only) |
peer.group == "leaf" | evaluation-peer group name |
apply(other-policy) / apply(p(42)) | policy-as-predicate (below) |
Implicit defaults (identical to the TOML engine and RFC 4271):
comparisons against route.local-pref see 100 when the attribute
is absent, route.med sees 0. Prefix predicates never match a
prefixless route (e.g. BGP-LS NLRIs); route.next-hop,
route.route-type, route.evpn-route-type, and route.family never
match when the corresponding attribute is absent. route.origin-as is
absent on empty and AS_SET-only paths and then matches neither ==
nor != nor in (!(... in ...) negates plainly, matching the
prefix-set precedent).
For plain comparisons on optional attributes, != and explicit Boolean
negation are different. With an optional field x and a literal y:
| Field state | x == y | x != y | !(x == y) |
|---|---|---|---|
| Present, equal | true | false | false |
| Present, different | false | true | true |
| Absent, without an implicit default | false | false | true |
For example, an ungrouped peer matches !(peer.group == "leaf"), but
does not match peer.group != "leaf". Include fixtures with omitted
attributes when testing guards over optional fields. route.local-pref
and route.med use their implicit defaults, so they do not have the
absent-field behavior in the last row.
Checked u32 value expressions
u32 value positions — either side of a u32 comparison, and the
set local-pref / set med arguments — accept value expressions
(ADR-0103): arithmetic over integer literals, parameters,
let bindings (below), u32-typed fields (route.local-pref,
route.med, route.as-path.len, route.origin-as, peer.asn), and
three bounded builtins. A comparison's left side may also carry
arithmetic when it starts with a field or a binding.
set med route.med + 50
set local-pref min(route.local-pref * 2, 400)
if route.as-path.len * 10 >= route.med { reject }
if route.med >= threshold + 20 { set local-pref 90 }- Operators:
+ - * / %, with* / %binding tighter than+ -, and both tighter than comparisons. Parentheses group inside value positions (a(at the start of anifcondition opens a boolean group, so lead with the field:route.med + 2 * 3 >= n, or put the parenthesized arithmetic on the right). - Whitespace disambiguates
-: identifiers are kebab-case with permanent maximal munch, soroute.med - 1is subtraction androute.med-1is an unknown-field error. - Builtins:
min(a, b),max(a, b),clamp(x, lo, hi). Statically inverted clamp bounds (lo > hiwith both constant) are a compile error; a data-dependent inversion is an evaluation error. - Everything is checked. All arithmetic is checked u32: overflow, underflow, and division/modulo by zero are evaluation errors, never wraps or process traps.
- Constant folding. Constant subexpressions fold at compile time
with the same checked operators:
set med 25 + 25compiles to exactly whatset med 50does, and a statically invalid constant expression (4294967295 + 1,1 / 0) is a compile error at its source span.
Failure is closed (the evaluation-error contract). Any evaluation
error — checked-arithmetic failure, inverted clamp, or an absent
operand (route.origin-as on an empty/AS_SET-only path, unknown
peer.asn) — denies the route: staged modifications are discarded,
bgp_policy_eval_errors_total{direction, kind} and the chain's
eval-error counter increment (the latter with the failing
policy/term retained, surfaced by rbgp policy stats), a
rate-limited WARN names the failing policy and term, and explain
traces render the error in place of a verdict. route.local-pref and route.med read their
implicit defaults (100 / 0) when absent, consistent with comparisons.
Note the deliberate divergence from plain comparisons:
route.origin-as == 64500 on an origin-less route
matches neither == nor != (never-match), while
route.origin-as * 1 == 64500 is unresolvable and denies — the
computed form has no non-verdict to fall back to, exactly like
computed prepend operands.
A value comparison does not need arithmetic: route.origin-as == (64500)
also reads a checked value and denies an absent origin with
absent-operand. A runtime let binding on the right has the same
behavior. Grouping the whole condition, (route.origin-as == 64500),
keeps the plain comparison's no-match behavior. Explicit ! does not
suppress an evaluation error: !(route.origin-as == (64500)) still
denies an absent origin. Test errors with expect … == error absent-operand;
a plain reject expectation does not count an evaluation error as a pass.
Update-group note. peer.asn as an operand (guard or computed
set value) reads peer identity and keeps its peers out of shared
update groups; arithmetic over route fields alone never disqualifies
grouping.
Migration example — BIRD's arithmetic filters are the natural comparison. A BIRD MED-dampening filter:
filter pad_med {
if bgp_med + 50 > 1000 then reject;
bgp_med = bgp_med + 50;
bgp_local_pref = bgp_local_pref * 2;
accept;
}becomes:
policy pad-med {
term dampen { if route.med + 50 >= 1001 { reject } }
term pad {
set med route.med + 50;
set local-pref min(route.local-pref * 2, 400);
accept
}
}The difference under the syntax: BIRD's integer arithmetic evaluates
in a general-purpose interpreter, and an out-of-range result is
whatever the interpreter does that day; .rpol arithmetic is checked
u32 with a pinned failure contract (deny + counter + explain), and
the min cap makes the local-pref doubling total instead of relying
on it.
Bindings — let
let <name> = <value expression> names a computed u32 in statement
position — a term body, or an if/else body (ADR-0103):
policy dampen {
term score {
let origin = route.origin-as
let penalty = route.as-path.len * 10
if penalty >= route.med { reject }
if origin == 64500 { set med penalty; accept }
}
term rest { accept }
}let is a contextual identifier, not a reserved word: statement
position admits no other bare identifier, so sets, policies, and
parameters named let keep working.
Immutable, u32-only. There is no assignment, no var, and no
mutation: a binding's value is fixed by its initializer for the rest
of its scope. Bindings hold u32 values only this slice — the
initializer is a value expression, so the bindable inputs are integer
literals, parameters, other bindings, and the u32 fields
(route.local-pref, route.med, route.as-path.len,
route.origin-as, peer.asn); binding a non-u32 field is a compile
error naming the u32 field set.
Scope and shadowing (normative).
- A term body is a scope; each
if/elsebody is a nested scope. A binding is visible from its statement to the end of its scope — a term-body binding reaches into laterifbodies of the same term; a body binding is invisible outside its body. Bindings never cross terms, and never escape the policy (no globals, no cross-route state, no closures — the purity contract). - A
letmay shadow an earlier binding, including one in the same scope, and a policy parameter: the innermost declaration wins at each use. The initializer is resolved before its own name is declared, solet x = x + 1reads the outerx— shadowing, never self-reference. - Resolution is position-typed, the same rule that lets a
parameter named
originkeep its meaning underprepend as: bindings are readable only in value positions. Enum members (valid,internal, …) still win in enum comparisons,min/max/clampfollowed by(are still builtin calls, andprepend as originstill means the origin operand even with alet originin scope. Compile-time-constant positions (applyarguments, prepend operands/counts,contains) reject bindings with a dedicated diagnostic — they are runtime values. - Use before definition is a compile error at the use's span, as is any unknown name (with did-you-mean suggestions over parameters and visible bindings).
Evaluation is eager and fail-closed. The initializer evaluates
when its statement executes — reached in the term walk, branch taken —
whether or not the value is ever read. It rides the same rails as all
checked arithmetic: overflow, underflow, division/modulo by
zero, or an absent operand (route.origin-as on an origin-less route,
unknown peer.asn) denies the route — staged modifications are
discarded, the eval-error counter increments, explain renders the
error in place of a verdict. A statement position the walk never
reaches (an earlier verdict decided, the branch not taken) never
evaluates. A binding whose comparison uses a plain u32 field
(route.origin-as == x) makes that comparison a value comparison:
fail-closed on absent operands, unlike the never-match plain form.
Reads see the original route — never staged writes. Route
mutation stays transactional: set/add/remove/prepend stage
modifications applied only after a complete successful evaluation, so
set med 500 followed by let x = route.med binds the route's MED
as it arrived, not 500. Read-back of staged writes is deliberately
out of scope (it would break the memoization contract — ExportMemo
keys on source attributes plus modifications — and needs its own ADR
if ever demanded).
Static slots, zero allocation. Bindings compile to fixed slots in
a 256-slot register file (term, nested loop, and if scopes at the
64-binding per-scope cap; inlined function calls draw on the same
frame): slot assignment is a pure function of the source
(identical source → identical compiled form, so unchanged reloads
still diff as no-ops), sibling scopes reuse slots, and the frame is a
lazily-materialized stack array — policies without bindings pay
nothing, and no evaluation ever heap-allocates for bindings. The
65th let in one scope is a compile error at its span. The cost
DP charges each binding its initializer cost plus one slot step; each
read costs one step, so factoring a repeated subexpression through a
let is never more expensive than inlining it (and evaluates
identically — pinned by test).
apply restriction. A policy that declares let bindings cannot
be a target of apply this slice: apply inlines its target as a
pure predicate expression, which has no term walk to execute bindings
in. The compile error points at the offending apply and the target's
first let; factor the shared value into the applying policy, or use
a user function (fn) — the designed vehicle for composing
computed values.
Migration example — factoring a repeated computed value:
# Before: the padded MED is computed twice and must be kept in sync.
policy pad-med-inline {
term dampen { if route.med + 50 >= 1001 { reject } }
term pad { set med route.med + 50; accept }
}
# After: one binding, one place to change the padding.
policy pad-med {
term pad {
let padded = route.med + 50
if padded >= 1001 { reject }
set med padded
accept
}
}Update-group note. A binding whose initializer reads peer.asn
reads peer identity — even if the value is never used, an unknown
peer ASN already denies — so it keeps its peers out of shared update
groups, exactly like a peer.asn guard operand. Bindings over route
fields alone never disqualify grouping.
Loops — for
for <var> in <source> { ... } walks a finite collection, binding
each element to an immutable u32 loop variable — a fresh binding per
iteration, scoped to the body (ADR-0103 Decision 3):
community-set scrub { 65000:100, 65000:200 }
asn-set bogon-asns { 64512, 65535 }
policy route-server-in {
term scrub-communities {
for c in route.communities {
if c in scrub { remove community c }
}
}
term bogon-path-guard {
for asn in route.as-path {
if asn in bogon-asns { reject }
}
accept
}
}for, break, and continue are contextual identifiers like let
(statement position admits no other bare identifier), so existing
names keep working. There is no while — every loop's source is
finite by construction, which is what keeps every bound provable or
runtime-meterable.
Iteration sources — exactly three forms.
route.communities— the route's standard (RFC 1997) communities as rawu32values (ASN << 16 | value), in attribute order. This is the community-iteration decision for this slice: a standard community is au32, so it rides the u32-only value model without a new type.route.large-communitiesandroute.ext-communitiesdo not iterate (their members are 96/64-bit — probe them withhas/in); community-sets do not iterate either (mixed-kind members). A standard community literal doubles as a u32 in value positions, soif c == 65000:100 { ... }reads naturally.route.as-path— every ASN in wire order: segments in order, ASNs within each segment in stored order, prepend duplicates included.AS_SETmembers are yielded individually in received order (note the asymmetry withroute.as-path.len, which counts a whole set as 1 per RFC 4271 §9.1.2.2). A route without anAS_PATHiterates zero times.- A named
asn-set— members in canonical order (sorted, deduplicated — the interned representation), so iteration order is deterministic across compiles and insertion orders. Sets larger than the 4,096 per-loop bound are rejected at compile time (probe those within).
The loop variable in guards and actions. It resolves like any
let binding: value comparisons and arithmetic, in membership —
against an asn-set, or against a community-set's standard members
(c in scrub above; large/ext members of the set never match a u32)
— and the binding-valued community actions add community <var> /
remove community <var> (standard kind only), which stage the
element's value per execution: the scrub-loop idiom. Shadowing follows
the let rules — the loop variable shadows outer bindings, a body
let may shadow it.
break, continue, verdicts. break exits the innermost loop
(the walk continues after it); continue skips to the next iteration.
Staged modifications before either still apply — they are control, not
verdicts. accept/reject inside the body terminate the whole policy
at that iteration, exactly as in an if body; staged modifications
from earlier iterations merge under an accept and are discarded by a
reject, the ordinary rules.
Iterated collections cannot change mid-loop. Reads always see the
route as it arrived — staged modifications are never read back
(ADR-0103 Decision 2) — so add community inside a
for c in route.communities body cannot extend its own iteration
(pinned by test).
Bounds and fuel (ADR-0103 Decision 3). Compilation, chain attachment, and evaluation enforce the following bounds:
-
1,000,000 structural IR nodes per chain (
MAX_CHAIN_NODES). Named chains and finalized effective import/export chains share this cap across.rpol, TOML, legacy inline policies, and implicit GSHUT/BLACKHOLE tails. Each policy, lowered term, action, guard, and value-expression node counts once; loop bodies count once regardless of iterations. Repeated policy references count each occurrence. Interned match-set contents and regex data do not count, and unused policy definitions are not summed together.compile_rpolapplies the cap when composing all zero-parameter policies. Oversized startup, reload, and API candidates are rejected before installation; policy API requests returnINVALID_ARGUMENT. Reload installs new policy definitions before later chain-reference edits, so that intermediate combination must also fit. If a change both grows definitions and shortens their chains, shorten the chains and reload first, then load the larger definitions. An oversized registry replacement preserves the installed registry and chains. This is a structural size bound, separate from per-policy evaluation cost and runtime loop fuel; it is not a universal CPU-time bound. The infallible RustPolicyChainandcompile_chainAPIs retain their trusted, hand-built IR boundary; embedding callers can checkcompile::ChainNodeBudgetbefore accepting external input. -
4,096 iterations per loop (
MAX_LOOP_ITERATIONS). Set sources are checked at compile time. Route-attribute sources are checked at runtime — a peer-supplied route with more elements than the cap (possible with RFC 8654 extended messages) is an evaluation error at the 4,097th element: uniform Deny, counter, rate-limited log — cap-then-error, never silent truncation. -
Nesting ≤ 4 loops, and the DP charges each loop at its static bound × per-iteration body cost — multiplicatively for nests — so a loop over one route attribute nested in a loop over another (4,096 × 4,096 steps) is rejected at compile time against the 1,000,000-step worst-case budget (
MAX_EVAL_COST), not metered per route. Nest small set loops, or restructure with membership probes. -
Runtime fuel. Every evaluation starts with
MAX_EVAL_COSTfuel, decremented only at loop iteration steps — straight-line code is pre-paid by the compile-time bound, so a chain with no loops pays exactly zero (fuel is one register write, never read again). Exhaustion — reachable only by compounding data-dependent iteration across a chain — is an evaluation error on the same uniform-Deny rail (fuel-exhaustedin the error counters and explain traces).
Explain. Traces render a bounded loop summary — iteration count
plus the deciding iteration (loop reject at iteration 3 of 3), never
per-iteration lines. Hit counters count the loop's term once per walk;
body terms are inside the loop node and carry no counter rows.
apply restriction. Like let, a policy containing for cannot
be an apply target — apply inlines a pure predicate, which has no
walk to run iterations in.
Update-group note. Iterating route.communities / route.as-path
/ a set reads no peer identity and never disqualifies update-group
sharing; a peer.* read inside a loop body counts exactly as it
would outside.
Migration example — an FRR/BIRD-style AS-path bogon check without a regex:
# Before: anchored regex over the rendered path string.
policy bogon-guard-regex {
term walk { if route.as-path matches "_(64512|65535)_" { reject } accept }
}
# After: typed iteration + one hash probe per ASN; the set is
# maintainable data, not pattern syntax.
asn-set bogon-asns { 64512, 65535 }
policy bogon-guard {
term walk {
for asn in route.as-path {
if asn in bogon-asns { reject }
}
accept
}
}Functions — fn
fn NAME(param: u32, ...) -> u32 { ... } names a pure computation
over u32 values (ADR-0103 Decision 2):
fn penalty(len: u32, weight: u32) -> u32 {
let base = len * weight
min(base, 1000)
}
policy p {
term dampen { if penalty(route.as-path.len, 10) >= route.med { reject } }
term rest { accept }
}fn is a top-level contextual identifier like statement-let: top
level previously admitted no bare identifier, so existing names keep
working. Functions get their own namespace — duplicate fn names are
errors, but a set or policy may share a function's name (every
reference position is disjoint); the builtin names min/max/clamp
are reserved and cannot be shadowed.
Bodies are expression-shaped. A body is zero or more let
bindings followed by exactly one result expression — the
last-expression rule; there is no return. Verdicts, set/add/
remove/prepend, if, and for are policy-term territory and get
a typed diagnostic inside a body (if would need expression-if,
which does not exist in the language — min/max/clamp cover
selection; a later slice may revisit). Parameters and return values
are u32 this slice, and the return type is spelled explicitly
(-> u32).
Closed over nothing (normative). A function body may not read
route.*/peer.* fields — every input arrives as a parameter, so a
function is a pure function of its arguments. This is what keeps the
hot-path analyses call-site-local: penalty(peer.asn, 10) counts
toward requires_peer_context because the argument reads peer
identity; penalty(route.as-path.len, 10) never disqualifies
update-group sharing, no matter what the body does. The compile error
says to pass the field as an argument.
No recursion. The call graph must form a DAG — direct or mutual
recursion is a compile error naming the cycle, exactly like apply.
Call chains are depth-capped at 8 (MAX_CALL_DEPTH), enforced in the
same Kahn-ordered cost DP as the apply bounds.
Full inlining (normative). A call is compile-time sugar for a
scope of let bindings: each argument binds to a caller-frame slot
(named fn.param in explain surfaces), each body let to
fn.binding, and the result expression to a slot named by the
rendered call itself, which the call site reads. There are no
runtime call frames — evaluation walks the same flat term list as
before, and a program that never calls functions pays nothing.
Consequences:
- Evaluation is eager, like
let. Arguments and the body evaluate when the walk reaches the statement containing the call — even if a&&short-circuit would have skipped the value — so a call that errors (checked arithmetic; functions are pure and terminating but not total) denies the route on the uniform eval-error rail whenever its statement executes. - Term identity survives. The inlined binds become
<term>.<n>IR terms of the calling term (the established multi-term split naming), so the counter grid and trace skeleton stay a function of the source; per-function hit counting is explicitly out of scope (Decision 6.3 — it would need runtime call frames). - Attribution names both sides. An error inside a body renders
with the calling term's name and the qualified binding, e.g.
term compute.3: let share.each = share.total / share.parts [matched]followed byterm compute.3: evaluation error: division by zero — fail closed => reject; the live-path WARN carries the same(let share.each)label. Guards render calls source-level:penalty(route.as-path.len, 10) >= route.med. - Budgets compound honestly. The cost DP charges the fully-inlined
body per call site, against the same
MAX_APPLY_EXPANSION/MAX_EVAL_COSTbudgets asapply— a big body called from many sites is rejected at compile time. Inlined bodies also consume caller-frame binding slots (arguments + body lets + the result, per call site); a term whose calls exceed the 256-slot frame is a compile error at the term's span.
Calling parity. penalty(a, b) evaluates exactly like writing the
body inline — same verdicts, same modifications, zero fuel (calls are
straight-line code; only loop iterations meter) — pinned by test.
apply restriction. Like let and for, a policy that calls
functions cannot be an apply target: calls lower to binding terms a
pure inlined predicate cannot execute. Call the function in the
applying policy instead.
Migration example — factoring a computation repeated across policies
(the step beyond a single-policy let):
# Before: the damping formula is duplicated — and must be kept in
# sync — across two policies.
policy transit-in {
term dampen { if min(route.as-path.len * 10, 1000) >= route.med { reject } }
term rest { accept }
}
policy peer-in {
term dampen { if min(route.as-path.len * 12, 1000) >= route.med { reject } }
term rest { accept }
}
# After: one definition, weights at the call sites.
fn penalty(len: u32, weight: u32) -> u32 {
let base = len * weight
min(base, 1000)
}
policy transit-in {
term dampen { if penalty(route.as-path.len, 10) >= route.med { reject } }
term rest { accept }
}
policy peer-in {
term dampen { if penalty(route.as-path.len, 12) >= route.med { reject } }
term rest { accept }
}Route-family branching
route.family is the route's typed AFI/SAFI family, carried by the
evaluation context itself — never inferred from the shape of the
route's prefix (BGP-LS and RTC NLRIs have no prefix at all; a FlowSpec
rule's destination component is not its family). It lets one chain
attached to several families branch per family instead of being split
into near-identical per-family policies.
Migration example — before, two chains that differ only in one guard:
policy edge-v4 { term dampen { if route.med >= 500 { reject } } term rest { accept } }
policy edge-v6 { term dampen { if route.med >= 800 { reject } } term rest { accept } }after, one chain attached to both families:
policy edge {
term dampen-v4 { if route.family == ipv4-unicast && route.med >= 500 { reject } }
term dampen-v6 { if route.family == ipv6-unicast && route.med >= 800 { reject } }
term rest { accept }
}Family predicates read no peer identity, so unlike peer.* or strict
next-hop they never push an export peer onto the ungrouped
policy_peer_context path — peers sharing the chain still share one
update group.
apply — policy as predicate
apply(p) is a boolean: "would policy p permit this route?".
The applied policy's actions do not execute — this is a predicate,
not a Junos-style subroutine call with side effects. The compiler
inlines p's first-match decision structure as a pure guard
expression; Continue-style modify-and-fallthrough terms in p
don't decide anything and are skipped.
Two consequences worth internalizing:
- Policy composition must form a DAG — recursion through
apply(including self-application) is a compile error naming the cycle. - With an accepting default, a policy that never
rejects is constant-true underapply. Predicate policies should decide both ways: usedefault-action rejectwith guardedacceptterms, or retain a final rejecting term (as inbogon-filterabove). - A policy that declares
letbindings cannot be applied: the inlined predicate has no term walk to execute bindings in. The compile error names the target's firstlet. The same rule coversforloops and function calls (a call is sugar for a scope of lets).
Actions
| Action | Effect |
|---|---|
accept / reject | terminal verdict (see evaluation order) |
set local-pref <u32 | value-expr> | override LOCAL_PREF, e.g. set local-pref min(route.local-pref * 2, 400) |
set med <u32 | value-expr> | override MED, e.g. set med route.med + 50 |
set next-hop <ip> / set next-hop self | override NEXT_HOP |
add community 65001:999 / remove community ... | standard communities |
add large-community 65000:1:2 / remove ... | large communities |
remove large-community 65000:*:* | every arrived large community with global administrator 65000 |
add ext-community RT:65001:100 / remove ... | extended communities (RT/RO, or well-known: add ext-community OV_INVALID) |
prepend as <asn> <count> | prepend <count> copies of <asn> (ASN: 1–4294967295; count: literal 1–255) |
prepend as self|peer|origin|path-first <count> | prepend a computed ASN (see below) |
Literal AS 0 is a compile error. A parameter that resolves to AS 0 in a prepend action is rejected when the daemon attaches the policy chain, including prepends inside loops. These checks reject invalid policy before the daemon evaluates routes with it; a computed operand that evaluates to 0 denies the route, and the wire encoder also rejects AS 0 under RFC 7607.
The kind keyword must match the literal's kind (add community RT:... is a compile error pointing at add ext-community). Within a
policy, later sets of the same attribute win; across a chain, the
existing merge semantics apply (later policy wins scalars, add/remove
lists merge with later-policy-wins cancellation). Literal and computed
prepends share one scalar slot: a later prepend of either form
replaces an earlier one of either form; computed set values share
their attribute's slot the same way. A computed set value resolves
when the matched term's action executes — constant expressions have
already folded to literals at compile time, and expressions reading
route/peer fields evaluate per route (an evaluation error denies the
route; see "Value expressions").
The large-community wildcard is deliberately removal-only and accepts exactly
one decimal u32 global administrator followed by :*:*, with no spaces or
prefix. It scans only the arrived route, preserves foreign administrators, and
stages concrete, first-seen deduplicated removals. An input with more than 5,461
arrived large communities fails the route closed before scanning; the compiler
pre-pays that same conservative wire-derived bound.
Computed prepend operands
prepend as also takes a computed operand instead of a literal ASN:
self— the local speaker's ASN (the daemon's[global] asn, stamped onto the chain when it is attached; in-language tests state it with thepeer { local-as N }fixture field).peer— the evaluation peer's ASN.origin— the route's origin AS: the last ASN of the rightmost non-emptyAS_SEQUENCE(the same valueroute.origin-asreads). A policy parameter namedoriginshadows the operand — the parameter keeps its existing meaning.path-first— the first ASN of a non-empty leadingAS_SEQUENCEin the typed routeAS_PATH. A parameter with this name also shadows the operand.
Direction legality. self, origin, and path-first are legal on import and
export chains. peer is import-only: on an export chain it would
prepend the receiving peer's own ASN, which the receiver rejects as
an own-AS loop (RFC 4271 §9.1.2) unless it runs allowas-in. A chain
using prepend as peer is rejected when it is attached as an export
chain (config load, reload, transaction) — a config error naming the
policy and term, never a per-route runtime surprise.
| operand | import | export | notes |
|---|---|---|---|
self | yes | yes | outbound TE; inbound self-prepend biases best-path like the literal form already could |
peer | yes | rejected at attach | the inbound "prepend the neighbor's AS" idiom |
origin | yes | yes | origin AS is already in the path — no loop-detection impact |
path-first | yes | yes | route-derived BIRD bgp_path.first equivalent; does not read peer identity |
Comparison: FRR's set as-path prepend last-as N (prepend the
neighbor's AS) is its inbound route-map idiom and the model for
prepend as peer; FRR has no self/origin operands (operators
write literals). BIRD's bgp_path.prepend() takes only explicit ASN
values — no peer-derived operand exists there at all. Neither
implementation documents a legitimate outbound use of a
peer-AS prepend, hence the attach-time rejection.
Failure is closed. A computed operand resolves when the matched
term's action executes. If the value is unknown — no usable
AS_SEQUENCE for origin, no non-empty leading AS_SEQUENCE for
path-first (including a leading AS_SET), unknown peer ASN for peer, a chain
evaluated outside a daemon config for self — or the context value is
zero (AS 0 is prohibited on the wire, RFC 7607), the route is
denied: staged modifications are discarded, ASN 0 is never
prepended, and explain traces name the failing term, operand, and
reason.
Update-group note. prepend as peer reads peer identity, so (like
peer.asn guards) it keeps its peers out of shared update groups;
self, origin, and path-first never disqualify grouping.
Extended-community wire encoding follows the daemon's other frontends: dotted-quad admin → RFC 4360 type 0x01, ASN > 65535 → type 0x02, otherwise type 0x00 (subtype 0x02 RT / 0x03 RO).
Tests
test NAME {
route { FIELD VALUE; ... }
[peer { address IP; asn N; local-as N; group "NAME" }]
expect POLICY[(args)] == accept|reject [with ASSERTION, ...]
expect POLICY[(args)] == error [KIND]
[expect ...]
}Route fixture fields: prefix, communities [..],
large-communities [..], ext-communities [..], as-path "65001 65002", next-hop, local-pref, med, rpki, aspa,
route-type, evpn-route-type, family. Omitted fields are absent
attributes (so local-pref/med comparisons see the implicit 100/0,
rpki defaults to not-found, aspa to unknown; an omitted
family matches no family predicate — it is never derived from the
fixture's prefix). The fixture AS-path length is the
whitespace-word count of the string form; the fixture origin AS is
the last plain ASN outside {...} AS_SET braces (mirroring the
typed-path rule), so as-path "65010 64500" has origin 64500 and
as-path "{64500 64501}" has none.
with assertions check the evaluation result's modifications
(the frontend has no live route to apply them to): local-pref N,
med N, next-hop IP|self, community LIT (and
large-community/ext-community, asserting presence in the add
lists), prepend as ASN COUNT. Computed prepend operands resolve
during evaluation, so the assertion states the resolved ASN:
peer { asn 65010 } + prepend as peer 3 asserts as
with prepend as 65010 3. peer { local-as N } supplies the value
prepend as self resolves to (omitted ⇒ it fails closed and the
expectation is a reject).
Errors are not verdicts. An evaluation error (checked-arithmetic
failure, absent operand, budget exhaustion — the ADR-0103 Decision 4
fail-closed rail) FAILS an accept or reject expectation, with
the error kind and the failing policy/term rendered — a test cannot
accidentally pin a broken policy as "rejects correctly". To pin the
rail itself, expect it: expect p == error passes on any evaluation
error, and expect p == error KIND pins the kind (error overflow,
error divide-by-zero, error absent-prepend-operand, ...; unknown
kinds are compile diagnostics with a suggestion). with assertions
cannot follow == error — an error discards every staged
modification. In the daemon the same route is denied; the test form
exists so CI can prove which rail fires:
test missing-origin-fails-closed {
route { as-path "{64500 64501}" }
expect origin-pad == error absent-prepend-operand
}Tests run at check time (rbgp policy check, CI) with zero daemon
involvement. Testing a candidate policy against a live RIB is
rbgp policy test (below).
Coverage and lints
rbgp policy check FILE --coverage reports which terms the test
blocks actually exercised — the blind spot route-map hit counters
miss and operators otherwise print-debug. For every term of every
tested policy it reports two distinct facts: was the guard ever
evaluated (the walk reached it), and did it ever match:
coverage: 2/5 terms exercised by tests
matched coverage: 1/5 terms matched by tests
policy edge-in
term bogon-guard evaluated 5x, matched 2x
term customer-routes evaluated 3x, never matched <- no test route hits this
term rest never evaluated <- earlier terms always decide
policy unused-helper never referenced by any test"Never evaluated" means earlier terms always decided; "evaluated,
never matched" means no fixture satisfies the guard. A term counts as
matched in a walk when any of its conditional branches matched; an
unconditional term (term catch-all { accept }) matches whenever it
is reached. Parameterized policies aggregate across instantiations
(expect p(200) and expect p(300) both count toward p). With
imports, each policy is attributed to its defining file.
A policy's default-action is a fallback, not a source term: it adds no
term to coverage or hit counters. Explain reports a default verdict without
a matched term name. Test the unmatched case explicitly to exercise the
fallback; a coverage percentage does not prove that case ran.
Two boundaries, both deliberate:
applyis a predicate, not a walk.apply(p)inlinesp's decision as a boolean guard, so term-level facts insidepare not attributable through it. A policy reached only viaapplyfrom tested policies is reported asexercised via apply only (terms not attributable)— its terms stay in the denominator; test it directly to cover them.fns inline fully and have no terms.- Chains live in the daemon config, which a standalone check
cannot see. A policy referenced by no test and no
applyis reported as such (and lint-flagged) within these files only — it may well be referenced by a config chain.
Static lints ride the same pass and need no fixtures:
unused-set/unused-dataset/unused-fn— declared, never referenced by any policy (fn-to-fn calls count as uses).unreachable-term— an earlier term in the policy always decides. Statically-certain cases only: a bareaccept/reject, anif/elsewith both branches terminal, or a constant guard that folds true (e.g. a folded builtin call). Runtime guards are conservatively reachable — this is not a reachability prover.unreferenced-policy— no test and noapplynames it in the compilation unit (with the config-chain caveat above).
Coverage is a report: it never changes the exit code by itself. For
CI, --coverage-min PCT (which implies --coverage) exits 3
when the exercised-term percentage falls below the threshold —
distinct from 1 (diagnostics) and 2 (test failures), which take
precedence. --coverage-matched-min PCT independently gates the
percentage of source terms matched by at least one test route. It
also implies --coverage and uses the same exit codes and precedence.
Both thresholds accept a finite percentage from 0 through 100;
invalid values exit 2 before the policy file is loaded. Both
thresholds can be supplied; both must pass.
The matched threshold uses the same source-term denominator, including untested and apply-only policies. A file with no terms reports 100% for both percentages. This gate detects terms no fixture matches; it does not guarantee branch coverage or detect every widened guard. Removing a narrowing condition can increase matched coverage. Keep negative-case fixtures that assert the intended route verdict and modifications alongside positive fixtures.
-j adds a coverage object with stable keys
(terms_total, terms_exercised, percent, terms_matched,
matched_percent, per-policy status of
tested/apply-only/untested, per-term evaluated/matched
counts, and lints with machine-readable kind labels). percent
continues to mean evaluated-term coverage; matched_percent is the
separate matched-term percentage. The existing top-level ok describes
compilation and test success; use the process exit code to enforce a
requested coverage threshold.
Modules and imports
import "lib/bogons.rpol"
import "lib/customers.rpol"
policy edge-in {
term bogon { if route.prefix in bogons { reject } } # from lib/bogons.rpol
term customer { if route.prefix in customers { accept } } # from lib/customers.rpol
}import "relative/path.rpol" is a top-level declaration that splices
another file's definitions — sets, functions, policies, and test
blocks — into the compilation unit. Modules are a resolution
feature, not a language feature (ADR-0103): after resolution the
compiled artifact is indistinguishable from one concatenated source,
and only diagnostics remember file boundaries (an error in an imported
file renders an excerpt of that file).
Resolution and roots. Import paths must be relative. Each resolves
against the importing file's directory first, then against each
configured policy root in order — [policy] rpol_roots in the daemon
config, repeatable --root DIR for rbgp policy check. The resolved
file (after symlinks and .. are canonicalized away) must stay inside
the main file's directory or one of the roots; escaping every root is
a compile error. There is no ambient working-directory lookup.
One flat namespace. All modules share a single namespace after
resolution; defining the same set/fn/policy/test name in two modules
is a compile error naming both files. This is the deliberate V1 shape
— shared libraries (bogon lists, customer sets, hygiene policies) get
short unprefixed names at every use site. Qualified names
(bogons.martians) and selective imports are compatible later
extensions if real collision pain shows up; today, rename at the
definition.
Determinism and reload identity. Imports resolve depth-first in declaration order — never filesystem order — and each file loads once (diamond imports are fine; cycles are compile errors naming the cycle). The policy identity the reload planner compares is the resolved module graph's content: editing an imported leaf makes every unit that (transitively) imports it read as changed, while a byte-identical graph reloads as a content-equal no-op regardless of file paths. One unit compiles all-or-nothing — a broken or missing import anywhere rejects the whole load and the running generation is untouched.
Budgets. Import nesting ≤ 8; a unit's total source ≤
[policy] rpol_max_graph_bytes (default 256 MiB — sized so IRR-scale
route-server renders, ~65 MB for a 320-member exchange, load with
headroom while still bounding load-time memory) across ≤ 64 files.
There is no separate per-file limit: a single oversized file exhausts
the graph budget by itself. All enforced at load with diagnostics;
evaluation never touches the filesystem. rbgp policy check (no
daemon config) checks against the default budget — pass
--max-graph-bytes to mirror a daemon whose budget was raised, so the
offline verdict matches the daemon's. The over-budget diagnostic names
the knob.
Auditing. rbgp policy check FILE --list-deps [--root DIR]...
prints the resolved graph — every module's canonical path, SHA-256
content hash, and imports — for packaging and change review.
Inline sources (rbgp policy test, the TestPolicy RPC) cannot
import: there is no filesystem to resolve against, and the diagnostic
says so. Check import-using files with rbgp policy check; the daemon
resolves them at config load / SIGHUP.
Migration example
This example extracts a repeated bogon set into a shared library.
# Before: every edge policy file carries its own bogon list.
prefix-set bogons { 10.0.0.0/8 le 32, 192.168.0.0/16 le 32, ... }
policy edge-in { term bogon { if route.prefix in bogons { reject } } ... }# lib/bogons.rpol — the shared library (sets, fns, even policies):
prefix-set bogons { 10.0.0.0/8 le 32, 192.168.0.0/16 le 32, ... }
# edge.rpol — after: one definition, imported where needed.
import "lib/bogons.rpol"
policy edge-in { term bogon { if route.prefix in bogons { reject } } ... }
test still-rejects-bogons { # tests see imported names too
route { prefix 10.1.0.0/24 }
expect edge-in == reject
}Because the resolved content is identical, this refactor reloads as a no-op: no chain reinstall, no Route Refresh.
Grammar sketch
file := (import-decl | dataset-decl | prefix-set-def | community-set-def | asn-set-def | fn-def | policy-def | test-def)*
import-decl := "import" STRING # contextual `import`
dataset-decl := "dataset" ("prefix-set" | "asn-set" | "community-set") IDENT # contextual `dataset`
prefix-set-def := "prefix-set" IDENT "{" [prefix-entry ("," prefix-entry)*] "}"
prefix-entry := PREFIX ["ge" INT] ["le" INT]
community-set-def := "community-set" IDENT "{" [community ("," community)*] "}"
asn-set-def := "asn-set" IDENT "{" [INT ("," INT)*] "}"
fn-def := "fn" IDENT "(" [param ("," param)*] ")" "->" "u32"
"{" fn-let* value "}" # contextual `fn`
fn-let := "let" IDENT "=" value [";"]
policy-def := "policy" IDENT ["(" param ("," param)* ")"] "{" [default-decl] term* "}"
default-decl := "default-action" ("accept" | "reject") [";"] # contextual; before all terms
param := IDENT ":" "u32"
term := "term" IDENT "{" stmt* "}"
stmt := if-stmt | let-stmt | for-stmt | action [";"]
let-stmt := "let" IDENT "=" value [";"] # contextual `let`
for-stmt := "for" IDENT "in" for-source "{" stmt* "}" # contextual `for`
for-source := "route" "." ("communities" | "as-path") | IDENT # IDENT: asn-set
if-stmt := "if" expr "{" body-stmt* "}" ["else" "{" body-stmt* "}"]
body-stmt := let-stmt | action [";"]
action := "accept" | "reject"
| "break" | "continue" # loop bodies only
| "set" ("local-pref" | "med") value
| "set" "next-hop" (IP | "self")
| ("add" | "remove") ("community" | "large-community" | "ext-community") community
| ("add" | "remove") "community" IDENT # binding-valued
| "prepend" "as" (u32arg | "self" | "peer" | "origin" | "path-first") INT
expr := and ("||" and)*
and := unary ("&&" unary)*
unary := "!" unary | "(" expr ")" | "apply" "(" IDENT ["(" u32arg,* ")"] ")" | predicate
| IDENT [arith-tail] ("=="|"!="|">="|"<=") value # binding/parameter LHS
| IDENT "in" IDENT # binding vs asn-set / community-set
predicate := field (("=="|"!="|">="|"<=") (rhs | value) | "in" IDENT | "has" community
| "matches" STRING | "contains" u32arg)
| field arith-tail ("=="|"!="|">="|"<=") value # LHS arithmetic
value := mul (("+"|"-") mul)* # checked u32
mul := atom (("*"|"/"|"%") atom)*
atom := INT | STD-COMMUNITY | IDENT | field | "(" value ")" # IDENT: parameter or binding
| ("min"|"max") "(" value "," value ")"
| "clamp" "(" value "," value "," value ")"
| IDENT "(" [value ("," value)*] ")" # user-function call
field := ("route" | "peer") ("." IDENT)+
u32arg := INT | IDENT # parameter reference
test-def := "test" IDENT "{" dataset-override* route-block [peer-block] expect+ "}"
dataset-override := "dataset" IDENT "{" [member ("," member)*] "}" # content for a declared dataset
expect := "expect" IDENT ["(" INT,* ")"] "=="
(("accept"|"reject") ["with" assertion,*] | "error" [IDENT]) # IDENT: error kindDiagnostics
Every error carries labeled source spans (ariadne rendering), and the
compiler recovers to report multiple independent errors per file.
Unknown names (sets, datasets, policies, functions, fields, enum
members, parameters, test fixture fields) get did-you-mean suggestions
by edit distance, with a label at the suggested symbol's definition
site; an exact name match of a different kind is named as such
(`bogons` is an asn-set; `route.prefix in` needs a prefix-set)
instead of a spelling suggestion. Kind/type mismatches say what to
write instead.
Error: unknown prefix-set `custmers`
╭─[ broken.rpol:5:28 ]
│
5 │ if route.prefix in custmers && route.rpki == vaild { reject }
│ ────┬───
│ ╰───── no prefix-set with this name
│
│ Note: did you mean `customers`?
───╯Formatting — rbgp policy fmt
rbgp policy fmt FILE... rewrites .rpol files into the one
canonical style — no options, no configuration (the gofmt
philosophy), so multi-file policy repos never drift.
rbgp policy fmt --check FILE... rewrites nothing and exits 1 with a
diff when any file is not canonically formatted — the CI mode. -
reads stdin and writes the formatted source to stdout (editor
integration). Exit codes: 0 all files clean/formatted, 1 a
--check difference or an error (unreadable file, or syntax errors —
broken files are refused, never rewritten).
The canonical style:
- Four-space indentation; one statement per line; opening braces on
the construct's line (
policy p {), closing braces on their own line. - A
term/if/elsebody stays on one line when it holds a single statement, contains no comments, and the line fits in 100 columns —term rest { accept },term guard { if route.rpki == invalid { reject } }.policy,test,fn, andforbodies always expand. - Set bodies and test
route/peer/datasetfixtures stay on one line when they fit, otherwise one member per line. - Single spaces around operators and between tokens; none inside
parentheses or after a call name (
min(a, b),customer-in(200)). - Blank-line runs collapse to one; a blank line always precedes a
top-level
policy,fn, ortest(attached comments move with it). - No trailing whitespace; exactly one trailing newline. Expressions are never wrapped — a long guard stays on its line.
The formatter is layout-only: it never adds, removes, or reorders a
token. Semicolons stay exactly as written (they are optional in the
grammar), import declarations keep their order (resolution order is
semantic), and comments are preserved. Formatting is therefore
guaranteed parse-identical — and the formatter proves it on every
run, re-lexing its own output and refusing to write anything if the
token-and-comment sequence moved. Idempotence (fmt(fmt(x)) == fmt(x)) and compiled-IR identity are additionally property-tested
over every .rpol fixture in the repository.
Using policies in the daemon
.rpol files become live daemon policy through [policy] rpol_files
in the config (full reference:
CONFIGURATION.md):
[policy]
rpol_files = ["policies/core.rpol"] # relative to the config file
rpol_roots = ["policies/lib"] # extra `import` roots, same resolution
[[neighbors]]
address = "10.0.0.2"
remote_asn = 65002
import_policy_chain = ["customer-in(200)", "bogon-filter"]Every file compiles (parse + typecheck) at config load; diagnostics
are load errors. Policies join the same namespace as
[policy.definitions] TOML policies — chains mix both freely, and
parameterized policies are instantiated by call-form (u32 arguments,
arity-checked at load). Editing a referenced file + SIGHUP hot-applies
the change to exactly the peers whose resolved chains moved, with
Route Refresh for materially changed import policy.
Live-RIB policy dry runs
rbgp policy check runs a file's own test blocks locally (CI-able,
no daemon). rbgp policy test goes further: it sends the candidate
source to a running daemon, which compiles it server-side and
evaluates the selected policy read-only over a snapshot of the live
RIB — no route state changes, no session impact, no policy counters
move (SensitiveRead authorization).
$ rbgp policy test policies/core.rpol --policy "customer-in(200)" \
--direction import --neighbor 10.0.0.2 --show-changes 2
policy "customer-in(200)" (import) over 1204 routes:
accepted 990 rejected 214 modified 990
Term hits:
rpki-guard 3
customer-routes 990
bogon-guard 211
Changes (up to 2):
10.10.1.0/24 (from 10.0.0.2):
local_pref 100 -> 200
communities + 65001:999
10.10.2.0/24 (from 10.0.0.2):
local_pref 100 -> 200
communities + 65001:999--direction importevaluates the retained post-policy Adj-RIB-In (all peers, or one with--neighbor): routes import policy admitted when they were received or last re-evaluated, with the attributes it set. It can still hold routes the installed policy would now reject: after an import-policy change until a Route Refresh replay re-evaluates them (a reload requests one for affected peers;rbgp neighbor <addr> softresetrequests one on demand), and under an active GR/LLGR window until re-sync/EOR. Rejected routes are not stored there, so the dry run shows candidate rejections or modifications among retained post-policy routes, but not which routes it would newly admit. If an earlier policy rewrote a prefix or origin attribute, the candidate sees that stored value rather than the original received value. Inspect currently rejected routes withrbgp rib received PEER --rejected, which keeps only the most recent rejections per peer ([policy.reject_retention]).--direction exportevaluates Loc-RIB best routes, with--neighborsetting the peer context guards see (peer.address,peer.asn,peer.group).--family ipv4_unicast|ipv6_unicastfilters the snapshot; V1 scope is IPv4/IPv6 unicast routes (other families are not walked).--limit Ncaps how many routes are evaluated (N≥ 1;--limit 0is a usage error);--all, the default, evaluates every route.--show-changes Ncaps the before/after attribute diff samples.--dataset NAME=PATHsupplies the contents of a candidate.listfile for a dataset declared in the submitted source. Repeat it for each dataset the selected policy probes; a missing, duplicate, undeclared, or malformed binding fails the dry run. The CLI reads the files and sends their contents; the daemon does not read candidate paths. The source and all candidate contents together must fit below the 4 MiB RPC receive limit (up to 16 datasets). Larger candidates need a smaller policy-specific input.--show-rejected Nshows up toNcandidate rejections among retained post-policy routes (N≤ 1000), alongside the totalrejectedcount. Samples follow the snapshot's canonical order. This previews the rejection of routes retained by the live daemon; it does not show routes a candidate would newly admit, replay the full import chain, or predict export impact. Without the flag, the output is unchanged.- Compile diagnostics come back rendered exactly as
policy checkprints them (exit code 1); a clean run exits 0. --jsonemits the counts, per-term hits, and diffs structurally, plusrejected_routeswhen rejection sampling is requested.
Per-term hit counters answer "which term is doing the work" (the
IOS-XR show pcl idea); a term lowered to several IR steps reports as
name.1, name.2, ....
Explain — which term decided, and why
.rpol policies are first-class citizens of the daemon's explain
surfaces (ADR-0073 / ADR-0096 Decision 3.3):
-
rbgp policy explain --neighbor A --prefix P --direction import(import): when the deciding chain member is an.rpolpolicy, the statement trace names the deciding term and lists every evaluated term with its guard rendered back to.rpolsyntax and a matched / not-matched verdict:$ rbgp policy explain --neighbor 10.0.0.2 --prefix 10.10.1.0/24 --direction import import policy explain — peer 10.0.0.2 prefix 10.10.1.0/24 (policy generation 3) permit decision: no policy rejected; chain default permit statements: [0] policy customer-in(200) term customer-routes permit match: guard route.prefix in customers set: local_pref 100 -> 200 term rpki-guard: route.rpki == invalid => reject [not matched] term customer-routes: route.prefix in customers => set local-pref 200; accept [matched]The statement trace still names
customer-in(200)because it records the members consulted. The Permit decision attribution ischain_default_permit: no member rejected the route.Guards render with sets shown by their source name; terms after the deciding one were never evaluated and carry no line. A term that modified without a verdict shows as
... => set med 5; continue. -
rbgp rib --prefix P advertised PEER --explain(ExplainAdvertisedRoute, export): a Deny's policy attribution extends to<chain-ref>:<term>when the rejecting member is.rpol— e.g.export policy "customer-in(200):transit-guard" denied this route. TOML members render unchanged. A Permit after a nonempty export chain useschain_default_permit; the modification list and live term counters retain the proof of which terms ran without mislabelling the final member as the source of the chain decision.
Live per-term policy counters
Where policy test counts hits over a one-shot dry run, rbgp policy stats reads the live counters of the chains actually installed on
the daemon: every route evaluated on the import or export path bumps
its matched terms' counters (relaxed atomics), and the query snapshots
them without resetting anything
(SensitiveRead).
$ rbgp policy stats --neighbor 10.0.0.2 --direction both
10.0.0.2 export chain — 1204 routes evaluated since install (counter instance 4127)
POLICY TERM HITS
customer-in(200) rpki-guard 3
customer-in(200) customer-routes 990
bogon-filter bogons 211
10.0.0.2 import chain — 890 routes evaluated since install (install generation 2)
POLICY TERM HITS
customer-in(200) rpki-guard 1
customer-in(200) customer-routes 889- Counters read as since chain install: replacing a peer's chain (policy reload / hot-apply / gNMI Set) installs a fresh instance and resets its counters to zero. A reload that re-resolves a peer to a content-equal chain skips the reinstall entirely — the installed instance and its counters survive; only peers whose resolved chain content moved reset. Export counters also restart when a peer's session registers again after a flap, unless the peer rejoins an update group that other members kept (it then shares that group's instance); a flap whose session task survives keeps the import counters.
- TOML chain members count too; their unnamed statements report by
term_index(statement 0,statement 1, ...). --directionis required and selects export, import, or both. Export chains are read from the roster the RIB manager publishes; import chains are read from each session's published installed-counter state. Chainless sessions contribute no row. Every capture wait shares one absolute two-second deadline for the whole RPC; exhausting it fails the RPC asDEADLINE_EXCEEDED. A departed session, closed publication, or unavailable counter state fails the complete RPC asUNAVAILABLE, and a listener without the policy-stats runtime returnsFAILED_PRECONDITION, all without partial rows. Counter availability does not establish session progress.- Import chains report their install generation (bumps on every
chain install), so counters that reset to zero read as a chain
replacement, not continuous history. A session's initial chain
reports generation 0; content-equal re-resolves are not reinstalled,
so the generation moves only when the peer's resolved chain content
does. Export chains report their counter-instance id instead
(
counter instance Nin text output): nonzero, shared by update-group members that share counters, and new whenever the counters restart, including at each session registration unless the peer rejoins an update group that other members kept. Compare it only for equality. - Explain queries and
policy testdry runs never move these counters — only live route evaluation counts. - Chains that have denied routes through the evaluation-error rail
(ADR-0103 Decision 4) report the count and the most recent blame
line above the term table —
2 routes denied by evaluation errors (fail closed); last: overflow in policy pad-med term pad— the drill-down for a nonzerobgp_policy_eval_errors_total{direction, kind}rate. Error counts reset with the chain instance, like every other counter here. --jsonemits the rows structurally (eval_errors,last_errorincluded).
Comparison with other policy languages
This section compares .rpol with route maps, BIRD filters, and
GoBGP/OpenConfig statements.
- vs FRR/Cisco route-maps: a route-map is an ordered list of
numbered entries over external prefix-lists / community-lists /
as-path access-lists, with
on-match nextfor fallthrough..rpolexpresses the same decisions (the M80 lab proves outcome parity route for route) but sets are declared next to the policies that use them, terms have names instead of sequence numbers, guards are composable boolean expressions rather than implicit ANDs of match clauses, and policies take parameters — onecustomer-in(peer_lp)replaces a route-map per peer. Route-maps have no unit tests and no dry run;.rpolhas both. - vs BIRD filters: BIRD's filter language is a general-purpose
interpreter — variables, arbitrary control flow, user-defined
functions.
.rpolis deliberately smaller: immutable bindings, only boundedforloops over finite sources, only pure non-recursive functions (fully inlined at compile time), no user types — so every policy terminates by construction and compiles to an indexed IR (a 1,000-member set is one hash probe, not a linear scan). What BIRD can't do: test a candidate policy read-only against the running daemon's RIB (rbgp policy test), trace which term decided a live route (rbgp policy explain), or read per-term live hit counters (rbgp policy stats). - vs GoBGP/OpenConfig statements (and rustbgpd's own TOML): the
same evaluation engine underneath —
.rpoland TOML policies compile to one IR and mix freely in chains — so.rpolis a frontend upgrade, not a fork: named sets, parameters, composition, and tests on top of chain semantics that behave exactly as before.
Migrating from BIRD and FRR
One worked example covering the two newest predicate surfaces together — an origin lock over an ASN set, branched per address family. This is exactly the policy shape the M80 interop lab proves outcome-equivalent against FRR route for route (import and export).
The .rpol version — one policy, attached to a dual-stack peer's
import chain:
asn-set partners { 64999, 65100 }
policy partner-in {
term partner-v4 {
if route.family == ipv4-unicast && route.origin-as in partners {
set local-pref 210;
add community 65001:604;
accept
}
}
term partner-v6 {
if route.family == ipv6-unicast && route.origin-as in partners {
set local-pref 220;
add community 65001:606;
accept
}
}
term rest { accept }
}The FRR route-map spelling of the same intent. The origin lock
becomes one anchored as-path regex per member (re-matched against the
rendered path string on every evaluation — the .rpol set is one
hash probe); the family branch has no route-map keyword, so the
classic idiom is an any-prefix match of the wanted family (a v4
prefix-list entry never matches a v6 route and vice versa), with the
route-map applied in both address families:
bgp as-path access-list PARTNERS seq 5 permit _64999$
bgp as-path access-list PARTNERS seq 10 permit _65100$
ip prefix-list ANY-V4 seq 5 permit 0.0.0.0/0 le 32
ipv6 prefix-list ANY-V6 seq 5 permit ::/0 le 128
!
route-map PARTNER-IN permit 10
match as-path PARTNERS
match ip address prefix-list ANY-V4
set local-preference 210
set community 65001:604 additive
!
route-map PARTNER-IN permit 20
match as-path PARTNERS
match ipv6 address prefix-list ANY-V6
set local-preference 220
set community 65001:606 additive
!
route-map PARTNER-IN permit 30The BIRD filter spelling — bgp_path.last is the origin AS,
net.type the family:
define PARTNERS = [ 64999, 65100 ];
filter partner_in {
if bgp_path.last ~ PARTNERS then {
if net.type = NET_IP4 then {
bgp_local_pref = 210;
bgp_community.add((65001,604));
accept;
}
if net.type = NET_IP6 then {
bgp_local_pref = 220;
bgp_community.add((65001,606));
accept;
}
}
accept;
}What carries over mechanically: BIRD's bgp_path.last ~ [...] and
FRR's anchored _ASN$ regexes both become route.origin-as in <asn-set>; BIRD's net.type checks and FRR's per-AF route-map
application both become route.family == guards on one shared
policy. What has no equivalent to migrate: the in-file test blocks,
rbgp policy test dry runs against the live RIB, and per-term live
hit counters — those come free after the rewrite.
Deliberate V1 exclusions
No while and no unbounded iteration of any kind — for
iterates finite sources only, capped and fuel-metered. No
user-defined types, no maps (safety is total by construction —
ADR-0096 Decision 2). No as-path-set. No else if chains and no nested if
(keep terms small; use && or more terms). No mutable state of any
kind — let bindings are immutable, and reads never
observe staged set writes (read-back would break memoization and
needs its own ADR). EVPN route type takes only an integer literal
(no parameters — the value is wire-encoded u8). Full-form all-numeric
IPv6 literals (use :: compression). These are scope decisions, not
parser accidents; each has an error message steering to the
supported form.
The ADR-0103 v2 extension program is complete: checked
arithmetic, let bindings, bounded loops, pure functions, modules
and imports, external datasets, and the metered-runtime operator
surface — eval-error counters, rbgp policy stats exposure,
error-pinning test expectations — have all shipped on the tree-walk
evaluator.
What deliberately did not ship: a bytecode tier (deferred behind
the ADR-0103 Decision 1 re-entry gate — built only if a real-workload
profile ever shows chain-walk dispatch dominating, which the
post-program re-measurement did not) and any WASM/host-function
escape hatch (rejected outright, ADR-0103 Decision 9 — external
data via datasets, never external code in the per-route path).