Skip to content

filterframe: a DDoS mitigation control plane that only ever adds BGP objects - #1

Open
lunarthegrey wants to merge 26 commits into
mainfrom
chore/workspace-skeleton
Open

filterframe: a DDoS mitigation control plane that only ever adds BGP objects#1
lunarthegrey wants to merge 26 commits into
mainfrom
chore/workspace-skeleton

Conversation

@lunarthegrey

Copy link
Copy Markdown
Contributor

Builds filterframe from an empty repository to a daemon that runs the full
mitigation loop end to end in observe mode: 14 commits, 253 tests, make lint
clean on the pinned Rust 1.97.1.

What this is

A DDoS mitigation control plane for Linux. It reads the active mitigation set
from a policy engine and carries out the decisions as BGP: blackholing a host
upstream for short sharp attacks, or diverting a prefix to a scrubbing provider
and back for large sustained ones. It runs on any Linux box that can hold BGP
sessions and is agnostic about which router or provider is on the other end.

The rule everything derives from

filterframe only ever adds BGP objects. It never withdraws a route that is
carrying traffic.

This is why it does not originate protected prefixes. The edge keeps announcing
those unconditionally; filterframe announces a separate signal route, and the
edge's export policy suppresses the prefix while it is present. If filterframe
crashes, is killed, or is simply stopped, the signal ages out and normal routing
returns on its own.

Verified behaviour

Run against a stub policy engine, the log is the argument:

INFO  originated path=rtbh:198.51.100.5/32 peers=1
INFO  converged: +1 -0 (1 desired, 1 mitigations)
WARN  policy source is stale; holding every engagement and withdrawing nothing
        reason=malformed response: HTTP 503  holding=2      ← x6 ticks
WARN  policy source is stale ... reason=empty result not yet confirmed (1/3)
WARN  policy source is stale ... reason=empty result not yet confirmed (2/3)
INFO  withdrawn path=rtbh:198.51.100.5/32
INFO  converged: +0 -2 (0 desired, 0 mitigations)
INFO  termination requested; announcements are left in place

The engine failing held both engagements. The engine recovering with an empty
list
was refused twice as unconfirmed before being believed. SIGTERM left
everything standing.

Three things worth a reviewer's attention

The safety invariant is structural, not aspirational. MitigationView::Stale
carries no mitigation list, so there is no expression anywhere that computes a
withdrawal from a failed poll — "unreachable policy engine causes a teardown" is
a type error rather than a code-review finding. stale_never_tears_down is
table-driven over all seven failure reasons across twenty consecutive failed
polls, and should never be deleted.

The divert sequence is proved by exhaustion. It is a pure state machine with
no I/O, so the safety property (signal_up implies scrubber_up) is checked by
enumerating every state against every event, walking every reachable sequence to
depth six, and injecting a crash at every index of five representative
sequences.

Four real bugs were caught by tests rather than by review, and each is
described in the commit that fixed it: a ceiling measuring from the wrong clock
that expired live mitigations and re-announced them in the same tick; a
return-path gate that recovered on a single success, so a flapping tunnel would
have read as healthy forever; a verdict loop reporting a policy rejection while
another path was being advertised fine; and a three-valued signal collapsed to a
boolean, which would have torn down a working diversion over an unreadable sysfs
file.

What is deliberately not done

mode enforce refuses to start. The GoBGP backend is compile- and
unit-tested but has never met a live sidecar, and list_originated returns
Unknown rather than guessing which paths in a shared RIB are ours — getting
that wrong means withdrawing another controller's work. Announcing nothing while
reporting success would be worse than refusing.

The embedded BGP speaker is not implemented. netgauze-bgp-speaker 0.13.0
has no public route-origination path; the workable route is driving Peer
through a six-parameter generic whose cancel-safety is undocumented, on a crate
with 24 downloads at that version. Being the first production user of a Rust BGP
originator inside a DDoS mitigation path is a bad place to be first. rustybgp
implements GoBGP's gRPC API, so a pure-Rust speaker later is a configuration
change rather than a rewrite. Both decisions are written up in
docs/runbooks/bgp-backends.md.

Blocking prerequisite before enforce

The edge's export policy must suppress the protected prefix when it sees the
signal route. filterframe cannot self-check this, and says so rather than
implying a clean bill of health. docs/runbooks/edge-policy.md has the FRR,
Junos and IOS-XR forms; the test that matters is announce the signal, kill -9
the thing announcing it, and confirm the prefix comes back on its own.

FRR has had bugs in conditional advertisement, so verify on the exact build.

Structure

Mirrors packetframe: crates/{common,bgp,policy,cli} plus feature-gated tier
modules under crates/modules/. Same conventions — thiserror only, hand-written
line-based config grammar, Prometheus textfile rather than an HTTP endpoint,
systemd and .deb with no Docker.

Packaging lands in the second commit rather than the last, so CI has been
producing installable .deb artifacts and install-verifying them in a clean
container from the beginning. The systemd unit ships with an empty capability
set
— filterframe needs no privilege to hold BGP sessions and read sysfs, and
that emptiness is what makes the additive-only rule enforceable rather than
merely intended.

Reviewing

Start with conf/example.conf. It is a primary document, annotated per
directive with defaults and reload semantics, and a test parses it verbatim so
it stays accurate. Then crates/modules/scrub-divert/src/machine.rs for the
ordered sequence, and crates/common/src/mitigation.rs for why the stale view
has no list in it.

lunarthegrey and others added 14 commits August 20, 2026 05:18
… toolchain pinned exactly

Establishes the workspace filterframe will grow into, with nothing in it yet
that touches a router. `filterframe version` runs, `make lint` and `make test`
are green, and CI gates on both plus a per-triple cross-build.

The layout mirrors packetframe deliberately: `crates/common` holds shared types
and trait definitions with no async runtime and no I/O, so that tier modules can
depend on it without inheriting the weight of backends they never touch, and
`crates/cli` is the only crate that will know all of them. Tier modules will
depend only on `-common` and never on each other.

Three choices that diverge from packetframe, and why:

Edition 2024 against its 2021. packetframe's edition is held back by aya-ebpf's
nightly floor, which does not exist here. It buys `unsafe_op_in_unsafe_fn` as a
hard error, which matters for a daemon that will call libc for signals and
pidfile identity, and let-chains, which collapse the nested `if let` ladders a
hand-written config parser is mostly made of. MSRV is set at 1.90 — above
edition 2024's floor of 1.85 and let-chains' 1.88, and deliberately below the
toolchain pin so a contributor a few releases back still builds.

`ipnet` rather than hand-rolled prefix types. packetframe rolled its own because
its BPF LPM maps needed a specific in-memory repr; filterframe has no such
constraint, and the same values travel through NLRI encoding, the state
journal's serde, and metrics labels. One type across all of that beats a config
type plus conversions.

No `anyhow`. packetframe declares it in four manifests and uses it in zero .rs
files; carrying the dead declaration forward would only reproduce the drift.
Errors are scoped thiserror enums whose variants exist to drive a decision.

Dependencies land with the slice that needs them rather than up front, so
`cargo tree` always reflects what the binary actually does. The exit-code
contract is declared the same way — only `EXIT_OK` exists, because a constant
nothing returns is a dead-code allow wearing a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…kaging breakage now fails the PR that caused it

Packaging normally lands last, which means the first time anyone builds a
package is also the first time it can break, at the worst moment. Putting it
first inverts that: every pull request from here on produces an installable
artifact, CI installs it in a clean Debian container, and a change that breaks
the package fails the change rather than the release.

The install verification is what makes this a test rather than a build
artifact. It asserts the unit is valid to systemd itself via `systemd-analyze
verify` — not merely that the file was copied — that the service is installed
disabled, that the reference config and binary are where the docs say, and that
argument parsing works with no configuration present.

The unit ships with an empty CapabilityBoundingSet and AmbientCapabilities.
filterframe holds BGP sessions and reads sysfs, and needs no privilege for
either. That emptiness is what makes the additive-only invariant enforceable
rather than merely intended: a daemon with no CAP_NET_ADMIN cannot install a
route, rewrite a firewall rule, or take an interface down, however badly it is
compromised or however wrong its inputs. A comment in the unit says so, because
the natural response to a future feature that seems to need privilege is to add
it here rather than to ship it as an opt-in drop-in.

Installed disabled and stopped: filterframe cannot do anything useful without an
operator-supplied peer list and policy-engine token, so auto-start would only
produce a first-boot failure in the journal that everyone learns to ignore.

Releases are the same code path with publishing appended, triggered on a v*.*.*
tag, with a workflow_dispatch dry run that builds the whole matrix and skips
publishing so a release can be rehearsed. Signing is conditional on the secret
existing, so a fork still produces a complete verifiable release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ops the daemon at load, not at 3am

The grammar is line-based rather than YAML, matching packetframe. Two
properties earn that: a parse error can cite the exact source line the operator
is looking at, and the shipped reference config can carry a comment above every
directive explaining its default, its reload semantics and why it exists. That
makes conf/example.conf a primary document rather than a sample, and a test
parses it verbatim so it stays one.

The parser is pure — a &str in, a Config out, no filesystem, no clock, no
network. Everything needing the world lives in preflight. That split is what
lets the grammar be exercised exhaustively on a laptop that is not a filter
node, and it is why every refusal below has a named test.

Unknown directives are fatal. Silently ignoring one means an operator who
mistyped a safety guard believes it is in force.

The cross-section validators are the interesting part, because each encodes a
failure that is otherwise silent:

  - `originate-prefix` is required. It is the authority boundary: without it a
    confused or compromised policy engine could have this node announce space
    it does not hold.
  - A peer allowing tier `rtbh` with no `community` is refused. A blackhole
    announced without one is just a host route, forwarded normally — the
    mitigation appears to work and does nothing.
  - Role and tier must agree. A scrubber peer that accepts blackholes is a
    config error, and the right time to learn that is now.
  - An unconditional tier-rule with rules after it is refused, because those
    rules can never fire.
  - A rule selecting a tier no peer can serve is refused, because it would fire
    into nothing.

Communities are parsed into canonical form at load rather than carried as
strings, and well-known values are spelled by name. That is deliberate: a
mistyped `65535:665` reads as plausible in review, where `blackhoel` fails at
config load.

restart_only_delta is pure over two Configs so the reload rules are testable
without a running daemon, and every refusal names the directive and says why —
"restart required" without saying which line caused it is the least useful
message a daemon can emit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oad — a broken config can never take a running daemon down

The daemon runs in the foreground and never forks; systemd is the supervisor.
Signals are polled at 250ms rather than handled on their own thread, because
the same loop will own the reconcile tick, module health and the metrics writer,
and one place where the daemon decides to act beats shared mutable state
between a handler and a loop. The cost is a quarter second of latency answering
a signal, which nobody notices.

Reload is the part worth arguing about. SIGHUP re-reads the file, checks it
against restart_only_delta, and writes the outcome to an acknowledgement marker
in the state directory. `filterframe reconfigure` reads the marker *before*
signalling — so a stale one is not mistaken for this answer — then signals and
polls, and exits non-zero with the daemon's own explanation when an edit is
refused. An operator gets an exit code instead of having to read the journal.

A refused or malformed reload never takes the daemon down. The running
configuration stays in force and the refusal is logged with its line number. A
daemon that exits because someone mistyped a duration during an incident is
worse than one that carries on with what it had.

Presence is three-valued, and that is not pedantry. Pids are recycled, and a
pidfile that outlived its daemon will eventually name somebody else's process;
a two-valued check will one day send SIGHUP to whatever inherited the number.
So the pidfile records pid plus process start time, a mismatch reads as Gone
rather than Running, and `is_running()` exists so that no caller writes
`!matches!(p, Gone)` and silently treats Unknown as alive. On Linux the start
token comes from /proc; macOS has no cheap equivalent, so it degrades to pid
liveness and says so rather than claiming a guarantee it does not have.

Atomic writes refuse to follow a symlink and fsync the containing directory,
not just the file. The directory fsync is the part people leave out, and it is
what makes the rename durable on a node that has just lost power — which is
exactly when the journal mattered.

Logging is installed before argument parsing, because a config that fails to
parse is itself worth logging. RUST_LOG beats the config file, for the life of
the process, and the daemon says so once — otherwise an operator edits
log-level, reloads, sees no change and has no way to know why. A malformed
RUST_LOG warns and hands control back rather than refusing to start.

Verified end to end: a hot change reloads and exits 0, a bgp-section change is
refused by name with the running config unchanged, a second daemon over a live
one is refused with the live pid, and SIGTERM leaves announcements in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… list endpoint lies — a failed poll can never look like an empty world

The client is paranoid because every way this goes wrong produces a *plausible*
answer rather than an error. "The engine is down" and "nothing is under attack"
both look like an empty list, and acting on the second when the first is true
withdraws protection during exactly the kind of event that takes a policy
engine offline.

So every failure resolves to MitigationView::Stale, which carries no list. The
planner takes &[Mitigation], not a view, so there is no expression anywhere that
computes a teardown from a failed poll. It is a type error rather than a
code-review finding.

Each guard exists for observed behaviour, not a hypothetical:

  - the status filter is a compile-time constant, because unrecognised tokens
    are dropped server-side and a typo returns zero rows with HTTP 200
  - `pop` is never sent: the server accepts it and ignores it, so sending it
    would create a false belief that results were scoped to this node
  - the page size cannot be zero — asserted at compile time, because a zero
    limit returns has_more with a null cursor and spins a poll loop forever
  - has_more with no cursor aborts the whole poll: a truncated page set must
    never be mistaken for a complete world
  - the cursor must strictly advance, or pagination is looping
  - time filters are never sent, because they filter on creation time, which
    does not change when a mitigation is withdrawn
  - one structurally bad item fails the whole page rather than shortening it
  - unknown vectors, actions and statuses survive: forward compatibility is a
    safety property here, since an engine upgrade must not blind filterframe
  - an empty result is confirmed over three polls before it is believed, but
    only once a non-empty one has been seen, so a quiet node is never stuck

Seventeen integration tests walk those against a stub server, including
assertions on the query string filterframe actually builds.

Rates come from the per-IP event history, not from the mitigation record: the
record's rate_bps is the policer rate the playbook chose, and it is null for a
discard action — null exactly when the attack is largest. The history endpoint
has no time predicate, so it returns the newest events for an address *ever*;
samples older than five minutes are discarded, and a victim with only stale
events has no rate at all. Lookups are budgeted per tick and spent
oldest-sample-first, because the rate limit is shared with the dashboard and
every detector.

A rule with a rate condition does not fire without a sample. That is what keeps
"large and persistent" from quietly degrading into "persistent".

`filterframe plan --from-file` runs the whole decision layer against a recorded
response and prints which rule line decided each mitigation, including the ones
left unhandled. Offline, no router, and the same pure function the daemon uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…annot withdraw — the invariant is now structural, not aspirational

The loop re-derives desired state from the whole mitigation list every tick and
compares it with what the speaker holds. Nothing reacts to events, so there are
no missed messages to recover from, a steady state is silent, and a restart
costs nothing because there is no accumulated position to rebuild.

The branch that matters is `converge_hold_only`. It is reached only from a
stale view, and a stale view carries no mitigation list — so there is no
expression in it that could compute a withdrawal. The withdraw loop lives
inside the fresh arm, behind `view.fresh()`, and moving it would mean handing
the stale branch a list it does not have. That is the invariant enforced by
structure rather than by review.

`stale_never_tears_down` is table-driven over all seven stale reasons and
asserts zero withdrawals across twenty consecutive failed polls. Adding an
eighth reason without adding a row should feel uncomfortable. It is the test
that must never be deleted.

The BGP trait pair splits by consumer: the fast tier needs only
RouteOriginator, so its blast radius is provable from the signature, and
RibObserver carries the confirmation surface the divert sequence will need.
Two things keep that honest. `Submitted` is #[must_use] and carries no verdict,
so a receipt discarded next to a transit withdrawal is visibly a bug — it fired
on eleven of the mock's own tests, which is the attribute working. And
`Fidelity` records what a positive answer actually established, because BGP has
no application-layer acknowledgement and no backend can honestly claim one. The
mock reports Synthetic and therefore satisfies no real quorum, which is why
observe mode can never be mistaken for a working diversion.

`mode enforce` refuses to start. No real backend exists yet, and refusing is
better than announcing nothing while reporting success.

Verified end to end against a stub policy engine, and the log is the argument:
two mitigations announced as they appeared; the engine returning 503 held both
for six ticks and withdrew nothing; the engine recovering with an empty list was
refused twice as unconfirmed before being believed on the third; both were then
released; the engine disappearing entirely held again; and SIGTERM left
everything in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…easures the right clock

RTBH is one announcement away from dropping a customer's traffic on purpose, so
every guard here refuses rather than clamps, and every refusal is counted with
its own label rather than merged into a number.

The defining behaviour is asymmetry: announcements are immediate, withdrawals
dwell. The fast tier has to be fast in the direction that protects and slow in
the direction that exposes. A detector oscillating around its threshold must not
oscillate a BGP announcement at every transit it touches, but it also must not
wait out a timer before dropping an attack.

Two bugs were found by writing the tests, and the second was a design error
rather than a test error:

`max-lifetime` originally measured from first announcement. That expired a
mitigation the policy engine was still actively confirming — and the admission
loop immediately re-announced it, producing churn dressed as a safety guard. It
now measures time *unconfirmed*, from the last time the engine asked for it. A
continuously confirmed engagement never trips it, which is asserted over a
simulated hour.

That correction is also what makes the ceiling work during an outage. Modules
gained `hold_only(now)`, which takes no desired set — because on a stale view
there is none — and can therefore release nothing for lack of demand. The only
thing it may remove is work the ceiling has expired, which is the bounded
operator-configured exception that stops an unreachable policy engine leaving an
address dark forever. Its signature is the argument: there is no list to pass,
so there is nothing to release against.

Adoption exists because without it a restarted daemon sees paths no module
claims, computes them as surplus, and withdraws the lot — every restart a mass
teardown. Clocks start from now rather than from any persisted timestamp, so a
crash loop cannot accumulate hold credit and then release everything at once.
Adoption runs on the first tick rather than at construction, because the speaker
may not be reachable at startup and refusing to start over that would be worse.

The tier abstraction that came with it keeps modules pure over (desired, now):
no I/O, no clock of their own, monotonic instants handed in by the tick. That is
what makes a damping bug findable by advancing a number in a unit test rather
than at three in the morning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…afety property proved by exhaustion

The sequence is a state machine with no async, no I/O and no clock:
step(state, event) -> (state, actions), and nothing else. That is the whole
testing strategy. The safety property is checked by enumerating every state
against every event, then walking every reachable sequence to depth six, then
injecting a crash at every index of five representative sequences — which is a
far stronger argument than sampling a running system could ever give.

The invariant is `signal_up implies scrubber_up`. filterframe never withdraws
the protected prefix because it never announces it: the edge does that
unconditionally, and filterframe raises a signal route whose presence makes the
edge's export policy stop advertising toward transit. So a signal without a
scrubber announcement means the prefix is announced by nobody. Announced to
*both* is always safe — traffic splits, none is lost — and every failure path
therefore stalls in that direction by construction rather than by care.

The order follows from that. Engage: scrubber, quorum, dwell, then signal —
transit is untouched until the scrubbing path exists, so aborting before the
signal costs nothing. Disengage: restore transit, dwell, then withdraw from the
scrubber — reachability before cleanliness.

Both dwells earn their place. A quorum establishes that the reflectors took the
path; it says nothing about the provider's own propagation, and raising the
signal the instant a quorum appears creates a gap no adjacency query can see.
The teardown dwell is longer on purpose: an overlap costs a little asymmetric
routing, a gap costs an outage.

Losing quorum while diverted, and the return path dying while diverted, are the
same emergency and get the same response — drop the signal immediately, let
traffic come back dirty rather than not at all.

Recovery resolves toward reachability rather than trusting the journal.
Mid-engage resumes at Announcing rather than Settling, because quorum evidence
does not survive a restart — the sessions that gave it are gone. Mid-teardown
resumes at Restoring rather than Draining, because resuming later would shorten
the dwell that protects against the gap. Both are asserted.

The journal exists for exactly one thing: which *direction* a sequence was
moving when the process died. A prefix announced two ways is simultaneously
"engaging, step one done" and "tearing down, step one done", and no amount of
reading the RIB distinguishes them. Everything else is re-derived every tick,
so persisting it would only create a chance for the file and the world to
disagree. Modules own their own format; the journal carries opaque blobs keyed
by module, so adding a module never changes it. It is rewritten only when intent
actually changes — at a two-second tick, writing unconditionally would be forty
thousand fsyncs a day to record nothing.

Timers are deliberately not persisted. Restoring a half-elapsed dwell would
shorten the window it protects, and restoring hold credit would let a crash loop
release everything at once. Every clock restarts on recovery, which biases
toward holding.

Verified: the shipped conf/example.conf now loads and runs with both modules,
six peers and five tier rules, and the token-file permission check refuses
anything looser than 0600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hment that finally lets the divert tier fire

Three commands an operator actually reaches for, plus the wiring that makes the
slow tier reachable at all.

`explain` answers the question that gets asked during an incident: not "what is
announced" but "why". It dry-runs the rule table against a hypothetical
mitigation and prints which rule matched, on which line, which facts satisfied
it, and what the resulting engagement would be — or why there is none. It also
states outright when there is no rate sample, because that is the single most
common reason a divert rule silently does not fire and it otherwise looks like
nothing at all in the output.

`status` reads a snapshot the daemon publishes each tick rather than talking to
a live process, because an operator reaching for it is often doing so precisely
because they suspect there isn't one. The daemon's presence is reported as the
three-valued thing it is, and a report built from a snapshot with no daemon
running says "snapshot, not live" rather than implying the numbers are current.
The ATTENTION block is computed rather than left for someone to spot an anomaly
in a table at three in the morning.

Metrics go to a Prometheus textfile, not an HTTP endpoint: a daemon whose whole
safety story rests on needing no privilege should not open a listening socket to
report on itself. Emitted by hand with one writer, which is what makes the
atomic rename meaningful — a scrape sees the previous complete file or the next
one, never a mixture. `filterframe_policy_stale_seconds` is the series to alert
on; it is what distinguishes "quiet because nothing is happening" from "quiet
because we cannot see anything", which are identical in every other metric here.
Never having polled reports elapsed time rather than zero, because zero reads as
healthy. A final write happens on shutdown so a scrape afterwards sees real
numbers.

Label values are escaped, because an unescaped quote in an operator-chosen node
id produces a file the scraper rejects wholesale — losing every metric on the
box rather than one.

The rate enricher is now called from the poll path, which is what makes the
divert tier reachable: a rule requiring a rate cannot fire without a sample, and
until now nothing produced one. Enrichment failures stay silent by design. A
victim without a sample simply does not satisfy rate rules, where taking the
whole view stale over an auxiliary lookup would trade a better decision for no
decision at all.

Verified end to end: a short attack takes the fast tier, a large sustained one
takes divert to three scrubber peers, and both appear in status, the metrics
textfile and the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s script

Wiring the enricher into the poll path made the client issue a per-victim
history request after every successful poll. The test stub advanced its scripted
response index on *every* request, so those auxiliary lookups silently consumed
pages, and every test scripting a sequence drifted by however many victims
happened to be in the last one.

The stub now serves the history endpoint separately and does not count it.

Worth recording how this was caught: the total test count dropped from 201 to
180 between two runs. The suite itself was reporting a failure the whole time,
but the summary being used to eyeball progress was summing passed-counts across
suites and happily added up a partial run. The count moving in the wrong
direction was the signal, not the summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… kernel actually sets

Diverting traffic to a scrubber is only half of it. The cleaned traffic comes
back over a tunnel, and if that tunnel is not carrying, the diversion is not a
mitigation — it is a hole. So the divert tier is gated on this, and a path that
dies while diverted is the most urgent condition in the daemon.

The check reads `IFF_UP` from `flags`, not `operstate`. GRE tunnels have no
carrier, so the kernel never sets an RFC 2863 operational state for them and
reports `unknown` forever — which the kernel's own documentation defines as
meaning the interface must be considered usable. A gate written the obvious way
would have refused to divert on a perfectly healthy tunnel, in production, only
during an attack. There is a test named for exactly that case.

The return-path signal is three-valued, and collapsing it to a boolean was a bug
I introduced and then had to fix: `Blocked` and `Down` both prevent a new
diversion, but only `Down` justifies undoing a working one. An unreadable sysfs
file is a bad reason to move a customer's prefix across the Internet, and a
two-valued signal made it one.

Hysteresis is asymmetric — three failures to distrust, five successes to trust
again — because a flapping tunnel that is trusted quickly produces a divert
loop, and every cycle of that is real BGP churn at every peer involved.

A second real bug, caught by its own test: recovery from `Degraded` returned
straight to `Up` on a single success. A path alternating down/up therefore
recovered on every other reading, never accumulated failures, and would never
have been declared down — a tunnel flapping once a second would have read as
healthy forever. Any state other than `Up` now requires sustained success.

The probe runs on its own cadence rather than the reconcile tick, because
waiting out a tick interval to notice a dead return path is a second too long.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n proves

The default backend, chosen for process separation rather than protocol
elegance: GoBGP owns the sessions and outlives filterframe, so a restart is
invisible to every router involved. An in-process speaker drops every session
when the daemon dies, which is precisely when a half-completed divert is most
fragile.

Two weaknesses in the reference implementation are fixed here. It connected
eagerly at startup and exited if the sidecar was not yet up, turning a boot
ordering problem into a daemon that will not start; and it stored the channel
once and never reconnected, so a sidecar restart left it handing out a dead
client forever. `connect_lazy` with HTTP/2 keepalives fixes both.

The confirmation semantics are the part worth reading. GoBGP's adjacency-out is
*derived on demand* — `ListPath` builds a fresh view per call and runs the
peer's export policy over it — so it can establish that a path is best and
passes policy, and cannot establish that any UPDATE was written, because there
is no record of transmission to consult. This backend therefore reports
PolicyEligible and no higher, and a quorum built on it means "N peers would send
this", not "N peers received it". The runbook has to say that in those words.

Queries are scoped to one prefix and set `enable_filtered`, which is what
distinguishes "policy says no, waiting is futile" from "not there yet, keep
waiting" — a full-table scan per confirmation poll would be unacceptable at the
rate the gate runs.

Clippy caught a real logic error: the verdict loop returned on the first path,
so a destination carrying several would report a policy rejection while another
path was being advertised perfectly well. One unfiltered path is now enough to
answer yes.

`list_originated` deliberately returns Unknown rather than a guess. Recovering
ownership from a live RIB means matching the origin community across both
families and reconstructing a tier from a prefix, and getting that subtly wrong
means withdrawing another controller's paths. Until it is written and tested
against a real sidecar, reporting nothing is the conservative answer.

The protos are vendored with a SHA-256 manifest that CI verifies, because a
field number that moved is not a compile error — it is a runtime disagreement.
`protoc` comes from `protoc-bin-vendored`, so codegen needs nothing installed
and every cross target gets it. The whole backend is behind a feature that is
off by default: observe mode runs against the mock and should not pay for a gRPC
stack.

Compile-verified and unit-tested only. Everything that needs a live sidecar —
adjacency readback, epoch behaviour across a real flap — is untested until there
is one to test against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ning, plus tunnel-render

Four runbooks, each written for someone who has four minutes and a problem.

policy-loss explains why holding is correct when the policy source is
unreachable, and reads the stale reasons as distinct operator stories rather
than one failure. It states plainly that there is no age at which "I cannot
reach my policy engine" becomes "there is no attack", and that adding a give-up
timer would be a bug.

edge-policy documents the transit-suppression mechanism per platform — FRR,
Junos, IOS-XR — and names the prerequisite filterframe cannot check for itself.
Its verification section ends with the only test that matters: announce the
signal, kill -9 the thing announcing it, and confirm the prefix comes back on
its own. It also says what taking the weaker fallback costs, so that choice is
made deliberately rather than by omission.

bgp-backends states the thing most likely to be misread: BGP has no
application-layer acknowledgement, so on the GoBGP backend a 2-of-3 quorum means
"two reflectors would send this", not "two received it". The fidelity table makes
that concrete, and the section on what happens when filterframe dies covers the
asymmetry between the backends that is otherwise buried in a docstring.

return-path documents the operstate trap in the place an operator will look for
it, and the three-valued gate: an unreadable sysfs file blocks a new diversion
and must never undo a working one.

`filterframe tunnel-render` emits the systemd-networkd units for the return
path, with the three things that are easy to get wrong already handled — the MTU
arithmetic including the four bytes a GRE key costs, `rp_filter` and its
max(all, iface) trap, and MSS clamping. It generates configuration and does not
apply it, because creating a tunnel needs CAP_NET_ADMIN, and that capability is
not "may create tunnels" but root over the whole dataplane. The daemon ships with
an empty capability set, and that emptiness is what makes its additive-only rule
enforceable rather than merely intended. The rendered output says so, where
whoever installs it will read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ted" was doing double duty

End-to-end verification surfaced two things worth fixing.

`Refusal::Protected` was being used both for "an operator put this on the
never-blackhole list" and for "the return path is down". Those are unrelated
conditions with unrelated responses, and merging them made them
indistinguishable in `status` and in the metrics — a refusal count that lumps a
deliberate operator guard together with a broken tunnel is not an answer anyone
can act on. They now have their own variants and their own metric labels.

And a refusal carried only its metric label as far as `status`, so an operator
reading it saw the word "protected" with no indication of which prefix, which
list, or why. Every refusal now describes itself in a sentence, and the tick
carries that sentence alongside the label. The label is for the metric; the
sentence is for the person.

Verified: a node whose return tunnel does not exist now reports

    REFUSED
      divert:198.51.100.0/24  the return path is not usable, so diversion is
                              blocked (the fast tier still applies)

which is the whole story, including the part that stops it reading as an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@socket-security

socket-security Bot commented Aug 21, 2026

Copy link
Copy Markdown

@socket-security

socket-security Bot commented Aug 21, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: cargo hyper-util is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?cargo/tonic@0.14.6cargo/reqwest@0.13.4cargo/hyper-util@0.1.20

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore cargo/hyper-util@0.1.20. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: cargo tokio is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: Cargo.lockcargo/tokio@1.53.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore cargo/tokio@1.53.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

…ow a lint goes green while the build goes red

The cross-build job linted with bare `cargo clippy --target <triple>` and then
built with `cross build`. Those are two different environments, and the lint one
has no cross C toolchain.

`reqwest` 0.13's rustls feature resolves to aws-lc-rs, which compiles
aws-lc-sys through cc and cmake, so the lint step needed
`aarch64-linux-musl-gcc` and friends on the runner:

    error occurred in cc-rs: failed to find tool "aarch64-linux-musl-gcc"

Three of the four cross targets failed on it. The build step would have been
fine — `cross` supplies the toolchain in its container — so this was purely a
lint running somewhere the code cannot be compiled.

`cross clippy` fixes it by running the lint in the same environment as the
build, which is the right place for it regardless.

Dropping `--all-features` there would also have made it pass, and would have
been the wrong fix: the whole reason this step exists is to compile cfg-gated
and test-only code for each triple, and narrowing the features narrows exactly
what it was added to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lunarthegrey

Copy link
Copy Markdown
Contributor Author

CI: cross-target clippy — fixed in 414ff32

Three of four cross-build jobs failed, and the failure was not the lint:

error occurred in cc-rs: failed to find tool "aarch64-linux-musl-gcc"

The job linted with bare cargo clippy --target <triple> and then built with
cross build — two different environments, and only the second has a cross C
toolchain. reqwest 0.13's rustls feature resolves to aws-lc-rs, which
compiles aws-lc-sys through cc and cmake, so the lint needed a target C
compiler the runner does not have. The build step itself would have been fine.

Fixed by running the lint through cross too, which is the right place for it
regardless — linting somewhere the code cannot be compiled is how a lint goes
green while the build goes red.

Dropping --all-features there would also have made it pass and would have been
the wrong fix: that step exists specifically to compile cfg-gated and test-only
code per triple, and narrowing the features narrows exactly what it was added to
catch.

Socket: tokio@1.53.1 and hyper-util@0.1.20 flagged as "90% likely obfuscated"

I treated these as real and checked rather than assuming. Both appear to be
false positives, and I have not suppressed them — that triage call is the
repo owner's.

Evidence, from the vendored sources at the exact locked versions:

tokio 1.53.1 hyper-util 0.1.20
repository in manifest github.com/tokio-rs/tokio github.com/hyperium/hyper-util
Source files 555 .rs 51 .rs
Longest source line 214 chars 163 chars
Files with a >2000-char line (minification) 0 0
include_bytes! of opaque blobs 0 0
String literals >200 chars (base64-ish) 0 0

Both resolve from registry+https://github.com/rust-lang/crates.io-index with
checksums pinned in Cargo.lock, so cargo verifies the artifact against the
registry on every build. Neither shows any marker of actual obfuscation:
ordinary, readable, line-wrapped Rust from the canonical upstream repositories.

tokio's 1119 unsafe blocks are expected for an async runtime implementing I/O
drivers and synchronisation primitives — not obfuscation, and not something a
scanner heuristic distinguishes well.

Neither is optional in any meaningful sense: tokio is the async runtime, and
hyper-util arrives transitively through reqwesthyper. Both Socket checks
report pass, so these are advisory rather than blocking.

If you want them silenced for this PR, the mechanism is a comment of
@SocketSecurity ignore cargo/tokio@1.53.1 and
@SocketSecurity ignore cargo/hyper-util@0.1.20. I have deliberately not posted
those — suppressing a security alert on a maintainer's behalf, when nothing is
blocked by it, is not mine to do.

Codex review bot

Reported its own usage limit rather than any finding. Nothing to address.

🤖 Addressed by Claude Code

lunarthegrey and others added 11 commits August 20, 2026 21:34
…ath it rewrites

Packaging failed outright:

    cargo-deb: Unable to parse crates/cli/Cargo.toml
      because: can't load root workspace
      because: unknown variant `3`, expected `1` or `2`

cargo-deb 2.7.0 predates `resolver = "3"` — its cargo_toml only knew resolvers 1
and 2. 3.7.0 parses it. Keeping resolver 3 rather than downgrading the workspace
is the right way round: it is the edition-2024 default and it makes resolution
MSRV-aware, which is the point of declaring rust-version at all.

Fixing that surfaced a second bug the parse failure had been masking, and this
one would have shipped a wrong package rather than no package:

    Only source paths starting with exactly 'target/release/' are detected as
    Cargo target dir. '../../target/release/filterframe' does not match

`target/release/` is the literal prefix cargo-deb rewrites to
`target/<triple>/release/` when invoked with `--target`. Written as
`../../target/release/`, the rewrite never happens — so a cross-built .deb would
have quietly packaged the host binary, or nothing. An aarch64 package containing
an x86_64 binary is the kind of thing that installs fine and fails at the worst
moment.

Verified locally with cargo-deb 3.7.0: the package now builds and contains
/usr/bin/filterframe, /etc/filterframe/example.conf, the systemd unit, and the
docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ependencies are actually resolved

`systemd-analyze verify` failed the install check:

    filterframe.service: Command /bin/kill is not executable: No such file or
    directory

It was right, and this is a packaging gap rather than a container quirk.
`ExecReload=/bin/kill -HUP $MAINPID` shells out to a binary from procps, and
`depends = "$auto"` derives dependencies from linked shared libraries — it
cannot see a binary a unit file invokes. So the package never declared something
it genuinely needs. Most hosts have procps because it is priority: important,
which is exactly why this would have gone unnoticed until it landed somewhere
minimal.

The verification step also installed with `dpkg -i`, which does not resolve
dependencies. That would have kept passing even after the dependency was
declared — checking that a package unpacks is not checking that it installs. It
now goes through apt, so a missing or unsatisfiable dependency fails the check
the way it would fail an operator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a fixed path

Two bugs, one of which only appeared once the other was fixed.

`depends = "$auto"` is not reproducible across architectures. It shells out to
dpkg-shlibdeps, which cannot inspect an aarch64 ELF on an x86_64 runner, so the
arm64 package came out with *different* dependency metadata from the amd64 one
while both reported success. Differing silently is worse than either answer, so
the dependencies are now stated: libc6 >= 2.31, because cross's gnu containers
link against 2.31 deliberately for backward compatibility, and procps for the
/bin/kill that ExecReload invokes.

The second was the step failing rather than the package:

    Unable to process file command 'output' successfully
    Invalid format 'target/aarch64-unknown-linux-gnu/debian/filterframe_...deb'

`target/` is restored from cache, and the .deb filename carries the commit sha,
so a package built by an earlier commit was still sitting there. `ls *.deb` then
returned two lines, and a two-line value corrupts $GITHUB_OUTPUT. It only
surfaced now because this is the first time the aarch64 job got far enough to
build a second one.

Both workflows now pass an explicit `--output`, so nothing is globbed and
nothing accumulates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit fixed the .deb output path in ci.yml and silently failed to
apply the same change to release.yml — the patch pattern did not match and the
push went ahead with only half the fix in place.

Same reasoning as before: `target/` can carry a .deb from an earlier build, and
copying by glob would put two packages claiming the same version into one
release. The release path now passes an explicit --output.

Worth noting the failure mode, since release.yml only runs on a tag: this would
not have been caught until the first release, and the symptom would have been a
duplicate or wrong-sha artifact attached to it rather than a failed build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…refix walked straight past it

`is_protected` only asked whether a never-entry contained the engagement's
address. Blackholing a prefix blackholes every address inside it, so the
interesting direction was the other one, and it was not checked.

With `max-prefix-length 24` and `never-blackhole 198.51.100.1/32`, an
engagement for 198.51.100.0/24 was tested as
`198.51.100.1/32.contains(198.51.100.0)` — false — so no guard fired at all:
effective={198.51.100.0/24}, refused=[]. The protected host was blackholed
silently by the one lever an operator reaches for during a mistaken
mitigation.

Also hoists the `max-lifetime` > `withdraw-hold` check out of the
`if let Some(..)` that only ran when `max-lifetime` was present. Setting only
`withdraw-hold 45m` was accepted against the 30m default, and every
undemanded engagement then tripped the ceiling before its dwell could release
it — which is the exact failure the error message describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p serialising rate lookups

Three things, all in the client.

`ca-file` was parsed, validated and documented, and never installed. The
connection validated against the system trust store while the operator
believed a private CA was pinned — the one failure mode a TLS setting must
not have. Now loaded via `add_root_certificate`, and a path that cannot be
read or is not PEM refuses the start rather than falling back.

`EMPTY_CONFIRMATIONS` is documented as *consecutive* empty results, but every
failure path returned through `stale()` without touching `empty_streak`. So
empty/503/empty/503/empty walked the counter to three and believed the empty
set — withdrawing protection on the strength of a flapping engine, which is
precisely when withdrawing is worst. Failures now return through `lost()`,
which resets it.

Rate lookups awaited one at a time: `LOOKUP_BUDGET * request-timeout` is 30s
at the defaults, inside a `block_on` on the thread that also polls signals and
the return-path probe. The budget is now a concurrency cap, so a tick costs
one timeout rather than ten. The sample cache is also pruned to
`MAX_SAMPLE_AGE`, since entries past it were already unreadable and the map
otherwise grew one entry per distinct victim forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad gave

CLAUDE.md requires anything touching sysfs to sit behind
`#[cfg(target_os = "linux")]`, and this did not. On macOS every path under
/sys/class/net is absent, which the probe read as a confirmed failure: three
polls to `Gate::Down`, `ReturnPath::Down`, every diverted machine torn down
and every new diversion refused. The unit tests never saw it because they
point `sysfs_root` at a fabricated tree, so the gate stayed green while the
daemon was broken on the platform the project promises to develop on.

A missing interface is now `Unknown` rather than `Down`. It still blocks a new
diversion — a name that is simply wrong should — but it can no longer undo a
working one, which is the module's own rule: an unreadable sysfs file is a bad
reason to move a customer's prefix across the Internet, and a typo in
`return-tunnel` is the same class of thing.

`Gate::Down` and `Gate::Degraded` now carry the reason too. `let _ = why;`
discarded it, so the daemon logged a bare `state=down` for what this module
calls the most urgent condition it can report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s build ignores

Module sections are bound at start: each module parses its section once into
its own state, and the reload path replaces only the shared `Config`. So an
edit was accepted, reported "OK reloaded", and did nothing. Adding
`never-blackhole` mid-incident exited 0 while the guard stayed absent, and
adding a `divertible-prefix` split the planner's view from the module's, so
every affected diversion was refused as not-divertible until a restart.

Refused rather than applied: applying it means rebuilding a module, and a
module's state *is* its damping — every dwell, hold clock and ceiling would
restart at once, mid-incident. The comparison is on content rather than `==`,
because `ModuleSection` carries source lines and a plain equality check called
a module changed whenever an edit above shifted it down.

Separately, five directives were parsed, validated with tailored errors,
documented per-directive in example.conf, and read by nothing.
`failure-threshold`, `policy-loss-grace` and `converge-deadline` are now
refused at load; `on-policy-loss` accepts `hold` and refuses `drain`, which
would withdraw protection because a policy engine was unreachable and cannot
be reconciled with the additive-only invariant. An accepted inert directive is
strictly worse than a typo: a typo is caught, where this leaves an operator
believing a guard is in force. example.conf now documents each refusal instead
of a feature that is not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e, so nothing ever diverted

The slow tier could not complete an engagement. `set_quorum` was the only
source of QuorumMet/QuorumImpossible/QuorumLost, it was not on `TierModule`,
and nothing called it — no daemon path, no test. `advance_timers` has no dwell
for `Announcing`, so a demanded prefix announced to the scrubber and waited
there: 600s of continuous demand still reported
`[(198.51.100.0/24, Announcing, 600)]`, transit still carrying, nothing
scrubbed, and no error anywhere saying so.

Wires it up. `PathEvidence` and `TierModule::observe` in common, a
`RibObserver` bound on the reconciler, and verdicts gathered per held path
before the modules decide — confirmation is an input to the sequence, not a
report on it. Peers are matched by address, since a sidecar names neighbours
by address and knows nothing of the names an operator chose.
`MIN_QUORUM_FIDELITY` is `PolicyEligible`, so `Synthetic` never counts and
observe mode cannot half-believe a diversion.

Three separate routes reached `Diverted` without a quorum, all now closed:

- `(Restoring | Draining, Demanded)` raised the signal again immediately. The
  machine is in a teardown because the quorum was lost or the return path
  failed, and neither is undone by the demand returning. Now resumes at
  `Announcing`, keeping the scrubber path but re-running the confirmation and
  the dwell.
- `adopt` inserted `Machine::recover(State::Diverted)`, which is the identity,
  so any path in the RIB at startup was assumed fully diverted and put the
  signal straight up. Now resumes at `Announcing` — the earliest state
  consistent with "a path exists", which is what its comment already claimed.
- `Machine::recover(Diverted)` trusted a journaled diversion. The quorum
  evidence died with the sessions that gave it, so it re-confirms too. That
  also makes the journal's write order non load-bearing, which is a better
  place for the guarantee than a comment about write order.

`invariant_holds` cannot see any of this: `scrubber_up()` means "we are
announcing", not "the scrubber accepted it", so all three satisfied it. Its
docstring now says so, and two exhaustive tests carry the operational half —
`the_signal_only_ever_rises_out_of_settling` and
`no_recovery_path_raises_the_signal`.

Also: an inherited diversion was never released, because `to_release` required
a `demanded` flag that `adopt` and `restore` set false and only the engage path
set true. Ten hours of zero demand left it `Diverted`, with no `max-lifetime`
here to catch it. The gate is gone, and so is the field, which was left
write-only.

`MockSpeaker::list_originated` ignored injected faults and always returned
`Ok`, so the reconciler's unreadable-RIB branch — the guard against computing
a difference from nothing — was unreachable from any test, and the test
claiming to cover it asserted nothing. It now honours `Unreachable`, and the
tick reads the speaker once instead of twice: two reads let a transient
failure on the first defer adoption while the second succeeded, converging with
no module having adopted and every inherited path computed as surplus.

Finally, `states_for_status` was uncalled. `TierModule::progress` surfaces it
as a SEQUENCES block in `status`, and a sequence stuck announcing raises an
ATTENTION line — it is not mitigating anything, and it looks finished in the
engagement list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…UNSPECIFIED

`LOOKUP_EXACT` was a hand-written `0`. In this proto that is
`TYPE_UNSPECIFIED`; `TYPE_EXACT` is 1. Every confirmation query was asking for
the wrong lookup option, and nothing caught it because an `i32` matches any
`i32`. This file already argued that GoBGP v4's typed oneof means "a field
that moves is a compile error here rather than a wire disagreement discovered
during an incident" — a numeric literal opts straight back out of that. All
six protocol constants now come from the generated enums, and a test fails if
anyone writes a literal back in.

It matters more than it did: `advertised` was unreachable code until the
quorum was wired to it, and now it decides whether transit gets suppressed.

`withdraw` also built its delete request through `build_path`, which attached
the origin community but not the tier's — `PathKey` does not carry them and
cannot reconstruct them — so the delete advertised a different attribute set
than the origination. Sending a near-miss set to a matcher is worse than
sending none: a field that is wrong can fail to match, where one that is
absent is not part of the match. It now carries family and NLRI only.

And `list_originated` says what actually happens. The comment claimed "the
reconciler will re-originate what it wants and never withdraw what it does not
recognise", which reads like a safe degraded mode. `Reconciler::tick` treats a
failed read as no information about the world and returns without touching
anything, so a daemon on this backend logs one warning per tick, forever, and
converges nothing — a no-op, not a degraded mode. The docstring now names the
actual blocker too: a path's tier is not recoverable from its prefix and
origin community, and `divert` and `divert-signal` are the indistinguishable
pair whose confusion suppresses transit for a prefix nobody is scrubbing.
Nothing reaches it today, since `mode enforce` refuses to start, but it should
be the first thing whoever wires enforce up reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he real journal write order

CLAUDE.md said "There is a `clippy.toml` rule enforcing this" about monotonic
damping timers. There is no clippy.toml in the tree and no
`disallowed-methods` config anywhere, so a contributor could read the rule as
mechanically enforced, add a `SystemTime::now()` to a damping path, and watch
CI pass. Restated as the review rule it actually is, with a note on what
enforcing it would cost — an `#[allow]` at each of the half-dozen legitimate
uses — so the option stays on the table rather than being quietly dropped.

journal.rs claimed the journal is "written before the actions it authorises",
which the daemon has never done: a tick decides and acts as one step, and the
journal is written when it returns. So a process killed mid-tick left an entry
that *under*-stated progress — the direction the comment said was dangerous.
Recovery no longer depends on it either way, so the docstring now describes the
ordering that exists and says why it is not load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant