bb_runtime/framework/backpressure_notice.rs
1//! `BackoffNoticePayload` - typed wire payload for the backpressure
2//! protocol per
3//! `docs/internal/superpowers/specs/2026-06-23-backpressure-runtime.md`
4//! §2.
5//!
6//! The framework synthesizes one of these whenever
7//! `BackpressureTracker::observe_overload` returns
8//! [`super::BackpressureDecision::EmitNotice`]. The payload is
9//! serialized with `bincode` (matches the universal `SlotValue` wire
10//! encoding at `bb-ir/src/slot_value.rs:194-196`), stamped with a
11//! stable `type_hash` discriminator, packed into a single
12//! [`crate::envelope::SlotFill`] inside a [`crate::envelope::WireEnvelope`]
13//! addressed to the originating sender, and pushed onto the
14//! receiver's `OutboundQueue`.
15//!
16//! Receivers detect the notice by matching the per-fill `type_hash`
17//! against [`backoff_notice_type_hash`] in their inbound envelope
18//! routing - the framework intercepts BackoffNotice envelopes before
19//! data-plane / control-plane dispatch so user Components never see
20//! them. Sender-side handling updates the sender's
21//! [`crate::framework::BackoffTable`] so the existing
22//! `BackoffGateTx` consultation gates the next outbound send to
23//! that peer.
24
25use serde::{Deserialize, Serialize};
26
27use crate::envelope::{CorrelationKind, SlotFill, WireCorrelation, WireEnvelope};
28use crate::framework::address_book::Address;
29use crate::framework::BackoffCause;
30use crate::ids::PeerId;
31use crate::slot_value::type_hash_of;
32
33/// Domain string the framework uses to namespace the backpressure
34/// protocol per `bb-runtime/src/bus.rs:155` reserved framework
35/// prefix. Surfaced for cross-referencing in docs + tests; the
36/// actual routing key is the per-fill `type_hash` from
37/// [`backoff_notice_type_hash`].
38pub const BACKPRESSURE_DOMAIN: &str = "ai.bytesandbrains.backpressure";
39
40/// Wire-encoded BackoffNotice payload.
41///
42/// One of these rides as the sole `SlotFill.payload` of a
43/// BackoffNotice envelope. Field semantics:
44///
45/// - `min_backoff_ns` - minimum back-off duration the receiver is
46/// asking the sender to observe before its next envelope. The
47/// sender's `BackoffTable` translates this into a `next_retry_ns`
48/// so the existing `BackoffGateTx` already gates outbound sends.
49/// - `cause` - why the receiver is requesting back-off.
50/// - `suggested_next_send_ns` - optional wall-clock hint (engine-ns
51/// since epoch) the receiver believes it will be ready by. `None`
52/// when the receiver has no estimate.
53///
54/// The payload uses `bincode` serialization (the universal
55/// `SlotValue::to_wire_bytes` impl at
56/// `bb-ir/src/slot_value.rs:194-196` already routes through bincode),
57/// and the discriminator [`backoff_notice_type_hash`] is identical
58/// on both sides because it derives from
59/// `std::any::type_name::<BackoffNoticePayload>()` via FNV-1a.
60#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
61pub struct BackoffNoticePayload {
62 /// Minimum back-off duration in nanoseconds.
63 pub min_backoff_ns: u64,
64 /// Why the receiver is requesting back-off.
65 pub cause: BackoffCauseWire,
66 /// Optional wall-clock hint (engine-ns since epoch) the receiver
67 /// expects to be ready by. `0` encodes `None`.
68 pub suggested_next_send_ns: u64,
69}
70
71/// Wire-stable encoding of [`BackoffCause`]. Serialized as a u8 so
72/// the on-wire representation never bit-shifts when the framework
73/// enum evolves. Always derived from the framework enum at the
74/// send site + mapped back at the receive site.
75#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
76#[repr(u8)]
77pub enum BackoffCauseWire {
78 /// `IngressQueue` depth crossed the high-water mark.
79 QueueFull = 0,
80 /// `PhiAccrualState` marked the sender as `Suspect`.
81 PhiAccrual = 1,
82 /// A Component returned a typed reject (e.g. role rate-limit).
83 ExplicitDrop = 2,
84}
85
86impl From<BackoffCause> for BackoffCauseWire {
87 fn from(cause: BackoffCause) -> Self {
88 match cause {
89 BackoffCause::QueueFull => Self::QueueFull,
90 BackoffCause::PhiAccrual => Self::PhiAccrual,
91 BackoffCause::ExplicitDrop => Self::ExplicitDrop,
92 }
93 }
94}
95
96impl From<BackoffCauseWire> for BackoffCause {
97 fn from(cause: BackoffCauseWire) -> Self {
98 match cause {
99 BackoffCauseWire::QueueFull => Self::QueueFull,
100 BackoffCauseWire::PhiAccrual => Self::PhiAccrual,
101 BackoffCauseWire::ExplicitDrop => Self::ExplicitDrop,
102 }
103 }
104}
105
106impl BackoffNoticePayload {
107 /// Construct a payload, encoding `None` as `0` per the field
108 /// docstring.
109 pub fn new(
110 min_backoff_ns: u64,
111 cause: BackoffCause,
112 suggested_next_send_ns: Option<u64>,
113 ) -> Self {
114 Self {
115 min_backoff_ns,
116 cause: cause.into(),
117 suggested_next_send_ns: suggested_next_send_ns.unwrap_or(0),
118 }
119 }
120
121 /// Recover the optional wall-clock hint.
122 pub fn suggested_next_send(&self) -> Option<u64> {
123 if self.suggested_next_send_ns == 0 {
124 None
125 } else {
126 Some(self.suggested_next_send_ns)
127 }
128 }
129
130 /// Recover the framework-side [`BackoffCause`].
131 pub fn cause(&self) -> BackoffCause {
132 self.cause.into()
133 }
134
135 /// Serialize via `bincode`. Matches the universal
136 /// `SlotValue::to_wire_bytes` impl at
137 /// `bb-ir/src/slot_value.rs:194-196`.
138 pub fn encode(&self) -> Vec<u8> {
139 bincode::serialize(self).expect("BackoffNoticePayload bincode serialize is infallible")
140 }
141
142 /// Deserialize via `bincode`. Returns `None` when the payload
143 /// bytes don't round-trip (corrupt envelope / version skew).
144 pub fn decode(bytes: &[u8]) -> Option<Self> {
145 bincode::deserialize::<Self>(bytes).ok()
146 }
147}
148
149/// Stable u64 discriminator for `BackoffNoticePayload`. Matches the
150/// canonical `SlotFill.type_hash` the framework stamps at send time
151/// and consults at receive time per
152/// `bb-ir/src/slot_value.rs:203-210`.
153pub fn backoff_notice_type_hash() -> u64 {
154 type_hash_of::<BackoffNoticePayload>()
155}
156
157/// Build a `BackoffNotice` wire envelope addressed to `sender`.
158///
159/// The envelope's `dest_peer_addresses` carries one `/p2p/<sender>`
160/// entry; its sole `SlotFill` uses a reserved framework dest-suffix
161/// `/p2p/<self_peer>` so the receiver's `route_envelope` can
162/// recognize a notice by type_hash before any data-plane /
163/// control-plane dispatch. The fill's `type_hash` is set to
164/// [`backoff_notice_type_hash`] so the receiver matches by
165/// discriminator instead of payload-content inspection.
166pub fn build_backoff_notice_envelope(
167 self_peer: PeerId,
168 sender: PeerId,
169 payload: BackoffNoticePayload,
170) -> WireEnvelope {
171 let dest_addr = Address::empty().p2p(sender).to_bytes();
172 // The fill's dest_suffix uses a reserved `/p2p/<self_peer>`
173 // suffix that intentionally does NOT match the data-plane /
174 // control-plane suffix shapes. The receiver intercepts on
175 // `type_hash` so the suffix is informational only - it carries
176 // the originating self-peer for diagnostics.
177 let dest_suffix = Address::empty().p2p(self_peer).to_bytes();
178 let bytes = payload.encode();
179 WireEnvelope {
180 dest_peer_addresses: vec![dest_addr],
181 fills: vec![SlotFill {
182 dest_suffix,
183 payload: bytes,
184 trigger_only: false,
185 type_hash: backoff_notice_type_hash(),
186 }],
187 correlation: Some(WireCorrelation {
188 kind: CorrelationKind::None as i32,
189 wire_req_id: 0,
190 }),
191 remaining_deadline_ns: 0,
192 edge_rtt_reports: Vec::new(),
193 ..Default::default()
194 }
195}
196