rustbgpd

.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 $?
0

Exit 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-b is always one identifier, so binary subtraction requires whitespace — route.med - 1 subtracts, route.med-1 is 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 as LC:65000:1:2 for 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, type 0x43 sub-type 0x00, state in the last octet), matched and added/removed by exact wire value.
    • Strings (AS-path regexes, peer-group names): "..." with \" and \\ escapes.
  • 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.

TypeValuesWhere
boolguard expressionsif conditions
u32integer literals, parameters, let bindingslocal-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)
prefixprefix literalsroute.prefix
community (3 kinds)community literalscommunity lists, sets, actions
as-path— (matched, never named)route.as-path
rpki-statevalid, invalid, not-foundroute.rpki
aspa-statevalid, invalid, unknownroute.aspa
route-typelocal, internal, externalroute.route-type
route-familyipv4-unicast, ipv6-unicast, ipv4-labeled-unicast, ipv6-labeled-unicast, vpnv4, vpnv6, ipv4-flowspec, ipv6-flowspec, evpn, rtc, bgp-ls, bgp-ls-vpnroute.family
IP addressaddress literalsroute.next-hop, peer.address
stringstring literalsregexes, peer.group

Sets

prefix-set NAME { PREFIX [ge N] [le N], ... }
community-set NAME { COMMUNITY-LITERAL, ... }
asn-set NAME { ASN, ... }
  • ge/le bounds 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 (ge only → ge..=address_bits; le only → member_len..=le; neither → exact length). Bounds must satisfy member_len <= ge <= le <= address_bits (32 for IPv4, 128 for IPv6); a member whose range could never match, such as 10.0.0.0/24 le 16 or 10.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 NAME and peer.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-set in V1 — inline route.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-tags

A 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 with in instead.
  • 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 u32 membership 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_EXPORT

Bounds: 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.list

Refresh, 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 logs SIGHUP 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, let bindings (see "Bindings"), for loops (see "Loops"), or if <expr> { actions... } [else { actions... }]. if bodies are flat action/let lists — no nested if in V1 (split the condition with && or use another term).
  • Verdicts: accept permits the route (with all modifications executed so far in this policy); reject denies 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 remains accept, with accumulated modifications. An accepting policy continues the chain; a rejecting policy stops it and discards staged modifications.
  • Statements after a bare accept/reject in the same term (or actions after a verdict in the same if body) 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 if becomes an IR term (guard = condition); its else becomes a following IR term guarded by the negated condition.
  • A run of bare modification actions flushes as an unconditional Continue IR term (a small IR addition made for this frontend: guard matched → apply modifications → keep walking this policy's terms).
  • Each let becomes a Bind IR term: guard = the enclosing branch condition (True in term-body position; the if guard — or its negation for else — for a body binding), action = evaluate the initializer and write its frame slot. Like Continue it never decides; body bindings re-evaluate the branch guard, which is pure and cannot diverge.
  • When one .rpol term produces multiple IR terms they are named <term>.<n> (1-based); a lone IR term keeps the plain term name. Explain traces, rbgp policy test term hits, and rbgp policy stats render 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: ==, !=, >=, <=.

PredicateMeaning
route.prefix == 10.0.0.0/24exact prefix (same network and length)
route.prefix in customersprefix-set membership (mask + ge/le ranges)
route.communities has 65000:100route carries this community (kind-checked against the field)
route.communities in taggedany route community matches any set member
route.as-path.len >= 3AS-path ASN count (RFC 4271 counting)
route.as-path contains 65001boundary-anchored ASN presence (sugar for matches "_65001_")
route.as-path matches "^65010"Cisco/Quagga-style regex; _ is a boundary anchor
route.origin-as == 64500origin AS — the last ASN of the rightmost non-empty AS_SEQUENCE (==/!= only)
route.origin-as in customersasn-set membership (one hash probe)
peer.asn in customersevaluation-peer ASN against the same asn-sets
route.local-pref >= 200, route.med <= 50u32 comparisons; ==/!= also allowed
route.med + 50 >= threshold, route.as-path.len * 10 >= route.medchecked-arithmetic comparison (see "Value expressions"); an unresolvable operand denies the route
route.next-hop == 10.0.0.1next-hop equality (==/!= only)
route.next-hop == peer.addressstrict 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 == invalidRPKI origin validation state
route.aspa == unknownASPA verification state
route.route-type == externalroute source class
route.evpn-route-type == 2EVPN route type (integer literal 1–6, RFC 7432 §7 / RFC 9136 / RFC 9251; ==/!= only)
route.family == ipv4-unicasttyped AFI/SAFI route family (==/!= only); route-context-only, so it never disqualifies update-group sharing
peer.address == 192.0.2.1evaluation-peer address
peer.asn == 65010evaluation-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 statex == yx != y!(x == y)
Present, equaltruefalsefalse
Present, differentfalsetruetrue
Absent, without an implicit defaultfalsefalsetrue

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 an if condition 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, so route.med - 1 is subtraction and route.med-1 is an unknown-field error.
  • Builtins: min(a, b), max(a, b), clamp(x, lo, hi). Statically inverted clamp bounds (lo > hi with 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 + 25 compiles to exactly what set med 50 does, 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/else body is a nested scope. A binding is visible from its statement to the end of its scope — a term-body binding reaches into later if bodies 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 let may 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, so let x = x + 1 reads the outer x — shadowing, never self-reference.
  • Resolution is position-typed, the same rule that lets a parameter named origin keep its meaning under prepend as: bindings are readable only in value positions. Enum members (valid, internal, …) still win in enum comparisons, min/max/clamp followed by ( are still builtin calls, and prepend as origin still means the origin operand even with a let origin in scope. Compile-time-constant positions (apply arguments, 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 raw u32 values (ASN << 16 | value), in attribute order. This is the community-iteration decision for this slice: a standard community is a u32, so it rides the u32-only value model without a new type. route.large-communities and route.ext-communities do not iterate (their members are 96/64-bit — probe them with has/in); community-sets do not iterate either (mixed-kind members). A standard community literal doubles as a u32 in value positions, so if 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_SET members are yielded individually in received order (note the asymmetry with route.as-path.len, which counts a whole set as 1 per RFC 4271 §9.1.2.2). A route without an AS_PATH iterates 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 with in).

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_rpol applies the cap when composing all zero-parameter policies. Oversized startup, reload, and API candidates are rejected before installation; policy API requests return INVALID_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 Rust PolicyChain and compile_chain APIs retain their trusted, hand-built IR boundary; embedding callers can check compile::ChainNodeBudget before 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_COST fuel, 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-exhausted in 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 by term 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_COST budgets as apply — 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 under apply. Predicate policies should decide both ways: use default-action reject with guarded accept terms, or retain a final rejecting term (as in bogon-filter above).
  • A policy that declares let bindings cannot be applied: the inlined predicate has no term walk to execute bindings in. The compile error names the target's first let. The same rule covers for loops and function calls (a call is sugar for a scope of lets).

Actions

ActionEffect
accept / rejectterminal 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 selfoverride 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 the peer { local-as N } fixture field).
  • peer — the evaluation peer's ASN.
  • origin — the route's origin AS: the last ASN of the rightmost non-empty AS_SEQUENCE (the same value route.origin-as reads). A policy parameter named origin shadows the operand — the parameter keeps its existing meaning.
  • path-first — the first ASN of a non-empty leading AS_SEQUENCE in the typed route AS_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.

operandimportexportnotes
selfyesyesoutbound TE; inbound self-prepend biases best-path like the literal form already could
peeryesrejected at attachthe inbound "prepend the neighbor's AS" idiom
originyesyesorigin AS is already in the path — no loop-detection impact
path-firstyesyesroute-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:

  • apply is a predicate, not a walk. apply(p) inlines p's decision as a boolean guard, so term-level facts inside p are not attributable through it. A policy reached only via apply from tested policies is reported as exercised 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 apply is 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 bare accept/reject, an if/else with 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 no apply names 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 kind

Diagnostics

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 / else body 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, and for bodies always expand.
  • Set bodies and test route / peer / dataset fixtures 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, or test (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 import evaluates 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> softreset requests 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 with rbgp rib received PEER --rejected, which keeps only the most recent rejections per peer ([policy.reject_retention]).
  • --direction export evaluates Loc-RIB best routes, with --neighbor setting the peer context guards see (peer.address, peer.asn, peer.group).
  • --family ipv4_unicast|ipv6_unicast filters the snapshot; V1 scope is IPv4/IPv6 unicast routes (other families are not walked).
  • --limit N caps how many routes are evaluated (N ≥ 1; --limit 0 is a usage error); --all, the default, evaluates every route. --show-changes N caps the before/after attribute diff samples.
  • --dataset NAME=PATH supplies the contents of a candidate .list file 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 N shows up to N candidate rejections among retained post-policy routes (N ≤ 1000), alongside the total rejected count. 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 check prints them (exit code 1); a clean run exits 0.
  • --json emits the counts, per-term hits, and diffs structurally, plus rejected_routes when 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 .rpol policy, the statement trace names the deciding term and lists every evaluated term with its guard rendered back to .rpol syntax 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 is chain_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 uses chain_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, ...).
  • --direction is 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 as DEADLINE_EXCEEDED. A departed session, closed publication, or unavailable counter state fails the complete RPC as UNAVAILABLE, and a listener without the policy-stats runtime returns FAILED_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 N in 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 test dry 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 nonzero bgp_policy_eval_errors_total{direction, kind} rate. Error counts reset with the chain instance, like every other counter here.
  • --json emits the rows structurally (eval_errors, last_error included).

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 next for fallthrough. .rpol expresses 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 — one customer-in(peer_lp) replaces a route-map per peer. Route-maps have no unit tests and no dry run; .rpol has both.
  • vs BIRD filters: BIRD's filter language is a general-purpose interpreter — variables, arbitrary control flow, user-defined functions. .rpol is deliberately smaller: immutable bindings, only bounded for loops 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 — .rpol and TOML policies compile to one IR and mix freely in chains — so .rpol is 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 30

The 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).

Source on GitHub

On this page