kumo_api_types/
egress_path.rs

1use cidr_map::CidrSet;
2use data_loader::KeySource;
3use dns_resolver::IpLookupStrategy;
4use mailexchanger::MailExchanger;
5#[cfg(feature = "lua")]
6use mlua::prelude::*;
7use openssl::ssl::SslOptions;
8use ordermap::OrderMap;
9use rfc5321::SmtpClientTimeouts;
10use rustls::crypto::aws_lc_rs::ALL_CIPHER_SUITES;
11use rustls::SupportedCipherSuite;
12use serde::{Deserialize, Deserializer, Serialize};
13use std::collections::BTreeMap;
14use std::fmt::Write;
15use std::time::Duration;
16use throttle::{LimitSpec, ThrottleSpec};
17use utoipa::ToSchema;
18
19#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Copy)]
20pub enum Tls {
21    /// Use it if available. If the peer has invalid or self-signed certificates, then
22    /// delivery will fail. Will NOT fallback to not using TLS if the peer advertises
23    /// STARTTLS.
24    Opportunistic,
25    /// Use it if available, and allow self-signed or otherwise invalid server certs.
26    /// Not recommended for sending to the public internet; this is for local/lab
27    /// testing scenarios only.
28    OpportunisticInsecure,
29    /// TLS with valid certs is required.
30    Required,
31    /// Required, and allow self-signed or otherwise invalid server certs.
32    /// Not recommended for sending to the public internet; this is for local/lab
33    /// testing scenarios only.
34    RequiredInsecure,
35    /// Do not try to use TLS
36    Disabled,
37}
38
39impl Tls {
40    pub fn allow_insecure(&self) -> bool {
41        match self {
42            Self::OpportunisticInsecure | Self::RequiredInsecure => true,
43            _ => false,
44        }
45    }
46
47    pub fn is_opportunistic(&self) -> bool {
48        match self {
49            Self::OpportunisticInsecure | Self::Opportunistic => true,
50            _ => false,
51        }
52    }
53}
54
55impl Default for Tls {
56    fn default() -> Self {
57        Self::Opportunistic
58    }
59}
60
61/// Selects how TLS is established on an SMTP connection, independently of the
62/// security posture expressed by [Tls].
63#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Copy, Default)]
64pub enum TlsMode {
65    /// Begin in cleartext and upgrade via the STARTTLS verb, as governed by
66    /// `enable_tls`.
67    #[default]
68    StartTls,
69    /// The connection is TLS from the first byte (SMTPS). There is no STARTTLS
70    /// negotiation. `enable_tls` is consulted only for certificate validation:
71    /// an `*Insecure` variant skips validation, any other variant (including
72    /// `Disabled`) requires a valid certificate. DANE and MTA-STS do not apply.
73    ImplicitTls,
74}
75
76pub fn parse_openssl_options(option_list: &str) -> anyhow::Result<SslOptions> {
77    let mut result = SslOptions::empty();
78
79    for option in option_list.split('|') {
80        match SslOptions::from_name(option) {
81            Some(opt) => {
82                result.insert(opt);
83            }
84            None => {
85                let mut allowed: Vec<_> = SslOptions::all()
86                    .iter_names()
87                    .map(|(name, _)| format!("`{name}`"))
88                    .collect();
89                allowed.sort();
90                let allowed = allowed.join(", ");
91                anyhow::bail!(
92                    "`{option}` is not a valid SslOption name. \
93                    Possible values are {allowed} joined together by the pipe `|` character."
94                );
95            }
96        }
97    }
98
99    Ok(result)
100}
101
102/// Deserializes a usize field, rejecting a value of zero. Used for fields
103/// that cap how many items of a list are kept, where zero would keep nothing.
104fn deserialize_nonzero_usize<'de, D>(deserializer: D) -> Result<usize, D::Error>
105where
106    D: Deserializer<'de>,
107{
108    use serde::de::Error;
109    let value = usize::deserialize(deserializer)?;
110    if value == 0 {
111        return Err(D::Error::custom("value must be at least 1"));
112    }
113    Ok(value)
114}
115
116fn deserialize_ssl_options<'de, D>(deserializer: D) -> Result<Option<SslOptions>, D::Error>
117where
118    D: Deserializer<'de>,
119{
120    use serde::de::Error;
121    let maybe_options = Option::<String>::deserialize(deserializer)?;
122
123    match maybe_options {
124        None => Ok(None),
125        Some(option_list) => match parse_openssl_options(&option_list) {
126            Ok(options) => Ok(Some(options)),
127            Err(err) => Err(D::Error::custom(format!("{err:#}"))),
128        },
129    }
130}
131
132fn deserialize_supported_ciphersuite<'de, D>(
133    deserializer: D,
134) -> Result<Vec<SupportedCipherSuite>, D::Error>
135where
136    D: Deserializer<'de>,
137{
138    use serde::de::Error;
139    let suites = Vec::<String>::deserialize(deserializer)?;
140    let mut result = vec![];
141
142    for s in suites {
143        match find_rustls_cipher_suite(&s) {
144            Some(s) => {
145                result.push(s);
146            }
147            None => {
148                return Err(D::Error::custom(format!(
149                    "`{s}` is not a valid rustls cipher suite"
150                )));
151            }
152        }
153    }
154
155    Ok(result)
156}
157
158pub fn find_rustls_cipher_suite(name: &str) -> Option<SupportedCipherSuite> {
159    for suite in ALL_CIPHER_SUITES {
160        let sname = format!("{:?}", suite.suite());
161        if sname.eq_ignore_ascii_case(name) {
162            return Some(*suite);
163        }
164    }
165    None
166}
167
168#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
169#[cfg_attr(feature = "lua", derive(FromLua))]
170pub enum WakeupStrategy {
171    #[default]
172    Aggressive,
173    Relaxed,
174}
175
176#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
177#[cfg_attr(feature = "lua", derive(FromLua))]
178pub enum MemoryReductionPolicy {
179    #[default]
180    ShrinkDataAndMeta,
181    ShrinkData,
182    NoShrink,
183}
184
185#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
186#[cfg_attr(feature = "lua", derive(FromLua))]
187pub enum ConfigRefreshStrategy {
188    #[default]
189    Ttl,
190    Epoch,
191}
192
193#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
194#[cfg_attr(feature = "lua", derive(FromLua))]
195pub enum ReconnectStrategy {
196    /// Close out the current connection session, allowing the maintainer
197    /// to decide about opening a new session and starting with a fresh
198    /// connection plan
199    TerminateSession,
200    /// Try to reconnect to the same host that we were using and where
201    /// we experienced the error
202    ReconnectSameHost,
203    /// Advance to the next host in the connection, if any. If none remain,
204    /// this is equivalent to TerminateSession
205    #[default]
206    ConnectNextHost,
207}
208
209#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
210#[cfg_attr(feature = "lua", derive(FromLua))]
211#[serde(deny_unknown_fields)]
212pub struct EgressPathConfig {
213    #[serde(default = "EgressPathConfig::default_connection_limit")]
214    pub connection_limit: LimitSpec,
215
216    #[serde(default)]
217    pub additional_connection_limits: OrderMap<String, LimitSpec>,
218
219    #[serde(default)]
220    pub enable_tls: Tls,
221
222    #[serde(default)]
223    pub tls_mode: TlsMode,
224
225    #[serde(default = "EgressPathConfig::default_enable_mta_sts")]
226    pub enable_mta_sts: bool,
227
228    #[serde(default = "EgressPathConfig::default_enable_dane")]
229    pub enable_dane: bool,
230
231    #[serde(default = "EgressPathConfig::default_enable_pipelining")]
232    pub enable_pipelining: bool,
233
234    #[serde(default = "EgressPathConfig::default_enable_rset")]
235    pub enable_rset: bool,
236
237    #[serde(default)]
238    pub tls_prefer_openssl: bool,
239
240    #[serde(default)]
241    pub tls_certificate: Option<KeySource>,
242
243    #[serde(default)]
244    pub tls_private_key: Option<KeySource>,
245
246    #[serde(default)]
247    pub openssl_cipher_list: Option<String>,
248    #[serde(default)]
249    pub openssl_cipher_suites: Option<String>,
250    #[serde(
251        default,
252        deserialize_with = "deserialize_ssl_options",
253        skip_serializing // FIXME
254    )]
255    pub openssl_options: Option<SslOptions>,
256
257    #[serde(
258        default,
259        deserialize_with = "deserialize_supported_ciphersuite",
260        skip_serializing // FIXME
261    )]
262    pub rustls_cipher_suites: Vec<SupportedCipherSuite>,
263
264    #[serde(flatten)]
265    pub client_timeouts: SmtpClientTimeouts,
266
267    /// How long to wait for an established session to gracefully
268    /// close when the system is shutting down. After this period
269    /// has elapsed, sessions will be aborted.
270    #[serde(default, with = "duration_serde")]
271    pub system_shutdown_timeout: Option<Duration>,
272
273    #[serde(default = "EgressPathConfig::default_max_ready")]
274    pub max_ready: usize,
275
276    #[serde(default = "EgressPathConfig::default_consecutive_connection_failures_before_delay")]
277    pub consecutive_connection_failures_before_delay: usize,
278
279    #[serde(default = "EgressPathConfig::default_smtp_port")]
280    pub smtp_port: u16,
281
282    #[serde(default)]
283    pub smtp_auth_plain_username: Option<String>,
284
285    #[serde(default)]
286    pub smtp_auth_plain_password: Option<KeySource>,
287
288    #[serde(default)]
289    pub allow_smtp_auth_plain_without_tls: bool,
290
291    /// {{since('2026.09.22-a276d4a8')}}
292    /// When false (the default), SMTP AUTH PLAIN will not be attempted over a
293    /// TLS session whose peer certificate was not validated (for example, an
294    /// `OpportunisticInsecure`/`RequiredInsecure` session, or a DANE host whose
295    /// TLSA records are unusable). Set to true to restore the previous behavior
296    /// of sending credentials over any encrypted session.
297    #[serde(default)]
298    pub allow_smtp_auth_plain_without_valid_certificate: bool,
299
300    #[serde(default)]
301    pub max_message_rate: Option<ThrottleSpec>,
302
303    #[serde(default)]
304    pub additional_message_rate_throttles: OrderMap<String, ThrottleSpec>,
305
306    #[serde(default)]
307    pub source_selection_rate: Option<ThrottleSpec>,
308
309    #[serde(default)]
310    pub additional_source_selection_rates: OrderMap<String, ThrottleSpec>,
311
312    #[serde(default)]
313    pub max_connection_rate: Option<ThrottleSpec>,
314
315    #[serde(default = "EgressPathConfig::default_max_deliveries_per_connection")]
316    pub max_deliveries_per_connection: usize,
317
318    #[serde(default = "EgressPathConfig::default_max_recipients_per_batch")]
319    pub max_recipients_per_batch: usize,
320
321    #[serde(default = "CidrSet::default_prohibited_hosts")]
322    pub prohibited_hosts: CidrSet,
323
324    #[serde(default)]
325    pub skip_hosts: CidrSet,
326
327    #[serde(default)]
328    pub ip_lookup_strategy: IpLookupStrategy,
329
330    /// {{since('dev')}}
331    /// The largest number of candidate addresses to retain in the connection
332    /// plan that is built for a delivery attempt. MX resolution expands each
333    /// MX host to every one of its `A`/`AAAA` addresses. Neither DNS nor SMTP
334    /// limits how many MX hosts or addresses the nameservers of a destination
335    /// may answer with, and without this cap the destination's answers would
336    /// dictate the size of the plan directly. Addresses are retained in preference
337    /// order, keeping the most-preferred hosts when the limit truncates. RFC
338    /// 5321 section 5.1 permits a configurable limit on the number of
339    /// alternate addresses that are tried. Must be at least 1.
340    #[serde(
341        default = "EgressPathConfig::default_max_mx_plan_size",
342        deserialize_with = "deserialize_nonzero_usize"
343    )]
344    pub max_mx_plan_size: usize,
345
346    /// {{since('dev')}}
347    /// The largest number of addresses to retain from one MX host, applied
348    /// before `max_mx_plan_size`. Caps the `A`/`AAAA` RRset of one host so that
349    /// a host publishing a very large address set cannot crowd the plan with
350    /// its own addresses at the expense of other hosts. The cap keeps the first
351    /// N addresses of the answer in answer order. Under the default
352    /// `Ipv4AndIpv6` strategy the answer orders `A` records before `AAAA`
353    /// records, and a small value keeps only `A` records. Must be at least 1.
354    #[serde(
355        default = "EgressPathConfig::default_max_mx_addresses_per_host",
356        deserialize_with = "deserialize_nonzero_usize"
357    )]
358    pub max_mx_addresses_per_host: usize,
359
360    #[serde(default)]
361    pub ehlo_domain: Option<String>,
362
363    // TODO: decide if we want to keep this and then document
364    #[serde(default)]
365    pub aggressive_connection_opening: bool,
366
367    /// How long to wait between calls to get_egress_path_config for
368    /// any given ready queue. Making this longer uses fewer
369    /// resources (in aggregate) but means that it will take longer
370    /// to detect and adjust to changes in the queue configuration.
371    #[serde(
372        default = "EgressPathConfig::default_refresh_interval",
373        with = "duration_serde"
374    )]
375    pub refresh_interval: Duration,
376    #[serde(default)]
377    pub refresh_strategy: ConfigRefreshStrategy,
378
379    #[serde(default)]
380    pub dispatcher_wakeup_strategy: WakeupStrategy,
381    #[serde(default)]
382    pub maintainer_wakeup_strategy: WakeupStrategy,
383
384    /// Specify an explicit provider name that should apply to this
385    /// path. The provider name will be used when computing metrics
386    /// rollups by provider. If omitted, then
387    #[serde(default)]
388    pub provider_name: Option<String>,
389
390    /// If set, a process-local cache will be used to remember if
391    /// a site has broken TLS for the duration specified.  Once
392    /// encountered, we will pretend that EHLO didn't advertise STARTTLS
393    /// on subsequent connection attempts.
394    #[serde(default, with = "duration_serde")]
395    pub remember_broken_tls: Option<Duration>,
396
397    /// If true, when a TLS handshake fails and TLS is set to
398    /// opportunistic, we will re-connect to that host with
399    /// TLS disabled.
400    #[serde(default)]
401    pub opportunistic_tls_reconnect_on_failed_handshake: bool,
402
403    /// If true, rather than ESMTP, use the LMTP protocol
404    #[serde(default)]
405    pub use_lmtp: bool,
406
407    /// How to behave if we experience either a 421 response, an IO Error,
408    /// or a timeout while talking to the peer.
409    #[serde(default)]
410    pub reconnect_strategy: ReconnectStrategy,
411
412    /// Which thread pool to use for processing the ready queue
413    #[serde(default)]
414    pub readyq_pool_name: Option<String>,
415
416    /// What to do to newly inserted messages when memory is low
417    #[serde(default)]
418    pub low_memory_reduction_policy: MemoryReductionPolicy,
419
420    /// What to do to newly inserted messages when memory is over the soft limit
421    #[serde(default)]
422    pub no_memory_reduction_policy: MemoryReductionPolicy,
423
424    /// If we experience a transport error during SMTP, should we retry the
425    /// current message on the next host in the connection plan, or
426    /// immediately consider it a transient failure for that message?
427    #[serde(default)]
428    pub try_next_host_on_transport_error: bool,
429
430    /// If true, don't check for 8bit compatibility issues during
431    /// sending, instead, leave it to the remote host to raise
432    /// an error.
433    #[serde(default)]
434    pub ignore_8bit_checks: bool,
435
436    /// When set, dispatcher tasks for this egress path that fail to
437    /// make any forward progress for this duration are aborted by the
438    /// maintainer. When omitted the effective value is derived at
439    /// runtime from the protocol:
440    ///   * SMTP / Xfer: max(2 * longest of mail_from, rcpt_to, data,
441    ///     data_dot timeouts, 60s)
442    ///   * Lua / HttpInjectionGenerator / DeferredSmtpInjection: 600s
443    ///
444    /// Users with a large `max_batch_latency` should set this
445    /// explicitly so the watchdog does not flag batch accumulation.
446    #[serde(default, with = "duration_serde")]
447    pub dispatcher_progress_watchdog_timeout: Option<Duration>,
448}
449
450#[cfg(feature = "lua")]
451impl LuaUserData for EgressPathConfig {
452    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
453        config::impl_pairs_and_index(methods);
454    }
455}
456
457impl Default for EgressPathConfig {
458    fn default() -> Self {
459        Self {
460            connection_limit: Self::default_connection_limit(),
461            tls_prefer_openssl: false,
462            enable_tls: Tls::default(),
463            tls_mode: TlsMode::default(),
464            enable_mta_sts: Self::default_enable_mta_sts(),
465            enable_dane: Self::default_enable_dane(),
466            enable_rset: Self::default_enable_rset(),
467            enable_pipelining: Self::default_enable_pipelining(),
468            max_ready: Self::default_max_ready(),
469            consecutive_connection_failures_before_delay:
470                Self::default_consecutive_connection_failures_before_delay(),
471            smtp_port: Self::default_smtp_port(),
472            max_message_rate: None,
473            max_connection_rate: None,
474            max_deliveries_per_connection: Self::default_max_deliveries_per_connection(),
475            max_recipients_per_batch: Self::default_max_recipients_per_batch(),
476            client_timeouts: SmtpClientTimeouts::default(),
477            system_shutdown_timeout: None,
478            prohibited_hosts: CidrSet::default_prohibited_hosts(),
479            skip_hosts: CidrSet::default(),
480            ehlo_domain: None,
481            allow_smtp_auth_plain_without_tls: false,
482            allow_smtp_auth_plain_without_valid_certificate: false,
483            smtp_auth_plain_username: None,
484            smtp_auth_plain_password: None,
485            aggressive_connection_opening: false,
486            rustls_cipher_suites: vec![],
487            tls_certificate: None,
488            tls_private_key: None,
489            openssl_cipher_list: None,
490            openssl_cipher_suites: None,
491            openssl_options: None,
492            refresh_interval: Self::default_refresh_interval(),
493            refresh_strategy: ConfigRefreshStrategy::default(),
494            additional_message_rate_throttles: OrderMap::default(),
495            additional_connection_limits: OrderMap::default(),
496            source_selection_rate: None,
497            additional_source_selection_rates: OrderMap::default(),
498            provider_name: None,
499            remember_broken_tls: None,
500            opportunistic_tls_reconnect_on_failed_handshake: false,
501            use_lmtp: false,
502            reconnect_strategy: ReconnectStrategy::default(),
503            readyq_pool_name: None,
504            low_memory_reduction_policy: MemoryReductionPolicy::default(),
505            no_memory_reduction_policy: MemoryReductionPolicy::default(),
506            maintainer_wakeup_strategy: WakeupStrategy::default(),
507            dispatcher_wakeup_strategy: WakeupStrategy::default(),
508            try_next_host_on_transport_error: false,
509            ignore_8bit_checks: false,
510            ip_lookup_strategy: IpLookupStrategy::default(),
511            max_mx_plan_size: Self::default_max_mx_plan_size(),
512            max_mx_addresses_per_host: Self::default_max_mx_addresses_per_host(),
513            dispatcher_progress_watchdog_timeout: None,
514        }
515    }
516}
517
518impl EgressPathConfig {
519    fn default_connection_limit() -> LimitSpec {
520        LimitSpec::new(32)
521    }
522
523    fn default_enable_mta_sts() -> bool {
524        true
525    }
526
527    fn default_enable_pipelining() -> bool {
528        true
529    }
530
531    fn default_enable_rset() -> bool {
532        true
533    }
534
535    fn default_enable_dane() -> bool {
536        false
537    }
538
539    fn default_max_ready() -> usize {
540        1024
541    }
542
543    fn default_consecutive_connection_failures_before_delay() -> usize {
544        100
545    }
546
547    fn default_smtp_port() -> u16 {
548        25
549    }
550
551    fn default_max_deliveries_per_connection() -> usize {
552        1024
553    }
554
555    fn default_max_recipients_per_batch() -> usize {
556        100
557    }
558
559    fn default_max_mx_plan_size() -> usize {
560        50
561    }
562
563    fn default_max_mx_addresses_per_host() -> usize {
564        10
565    }
566
567    fn default_refresh_interval() -> Duration {
568        Duration::from_secs(60)
569    }
570
571    /// Compute the steady-state ceilings implied by this
572    /// configuration. Per-axis, each ceiling carries a tag for which
573    /// configuration term produced it, so operators can see which
574    /// knob to turn.
575    ///
576    /// If `additional` is supplied, its ceilings are merged in via
577    /// `EffectiveConstraints::merge`. This is the entry point for
578    /// folding in constraints from other configuration layers, such
579    /// as the scheduled-queue rate from `QueueConfig`.
580    pub fn compute_constraints(
581        &self,
582        additional: Option<&EffectiveConstraints>,
583    ) -> EffectiveConstraints {
584        let mut constraints = self.compute_path_constraints();
585        if let Some(extra) = additional {
586            constraints.merge(extra);
587        }
588        constraints
589    }
590
591    fn compute_path_constraints(&self) -> EffectiveConstraints {
592        let max_concurrent_dispatchers = {
593            let mut best = EffectiveCeiling {
594                value: self.connection_limit.limit as f64,
595                source: CeilingSource::Primary,
596                display: self.connection_limit.limit.to_string(),
597            };
598            for (name, spec) in &self.additional_connection_limits {
599                let value = spec.limit as f64;
600                if value < best.value {
601                    best = EffectiveCeiling {
602                        value,
603                        source: CeilingSource::Additional { name: name.clone() },
604                        display: spec.limit.to_string(),
605                    };
606                }
607            }
608            best
609        };
610
611        // Compute the message-rate ceiling across the primary,
612        // additional throttles, and the synthetic K × C ceiling.
613        let mut msg_candidates: Vec<EffectiveCeiling> = vec![];
614        if let Some(spec) = &self.max_message_rate {
615            msg_candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
616        }
617        for (name, spec) in &self.additional_message_rate_throttles {
618            msg_candidates.push(throttle_ceiling(
619                spec,
620                CeilingSource::Additional { name: name.clone() },
621            ));
622        }
623        if let Some(spec) = &self.max_connection_rate {
624            // K × C ceiling: every connection delivers at most
625            // max_deliveries_per_connection messages, so total msg
626            // rate cannot exceed (max_deliveries_per_connection) times
627            // the connection-establishment rate. The display shows
628            // both the factors and the computed total so operators
629            // don't have to do the arithmetic mentally for cases
630            // like 32 × 50/s.
631            let k = self.max_deliveries_per_connection as u64;
632            let total = ThrottleSpec {
633                limit: k.saturating_mul(spec.limit),
634                period: spec.period,
635                max_burst: None,
636                force_local: false,
637            };
638            msg_candidates.push(EffectiveCeiling {
639                value: throttle_rate_per_sec(spec) * k as f64,
640                source: CeilingSource::ReconnectCycling,
641                display: format!("{k} × {spec} = {total}"),
642            });
643        }
644        // total_cmp gives a total ordering even for NaN, so a malformed
645        // candidate cannot cause a panic here. NaN sorts greater than
646        // any finite value, so it naturally loses the min_by.
647        let max_message_rate = msg_candidates
648            .iter()
649            .min_by(|a, b| a.value.total_cmp(&b.value))
650            .cloned();
651
652        // If max_message_rate is explicitly configured but a
653        // different term (typically the synthetic reconnect-cycling
654        // ceiling) wins the minimum, record the declared rate so
655        // renderers can show an "effectively unreachable" annotation.
656        // Preserve the operator's original units via ThrottleSpec's
657        // Display impl.
658        let max_message_rate_declared = match (&max_message_rate, &self.max_message_rate) {
659            (Some(ceiling), Some(declared))
660                if !matches!(ceiling.source, CeilingSource::Primary) =>
661            {
662                Some(declared.to_string())
663            }
664            _ => None,
665        };
666
667        let max_connection_rate = self
668            .max_connection_rate
669            .as_ref()
670            .map(|spec| throttle_ceiling(spec, CeilingSource::Primary));
671
672        let max_source_selection_rate = {
673            let mut candidates: Vec<EffectiveCeiling> = vec![];
674            if let Some(spec) = &self.source_selection_rate {
675                candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
676            }
677            for (name, spec) in &self.additional_source_selection_rates {
678                candidates.push(throttle_ceiling(
679                    spec,
680                    CeilingSource::Additional { name: name.clone() },
681                ));
682            }
683            candidates
684                .into_iter()
685                .min_by(|a, b| a.value.total_cmp(&b.value))
686        };
687
688        EffectiveConstraints {
689            max_concurrent_dispatchers,
690            max_message_rate,
691            max_message_rate_declared,
692            max_connection_rate,
693            max_source_selection_rate,
694        }
695    }
696}
697
698fn throttle_rate_per_sec(spec: &ThrottleSpec) -> f64 {
699    spec.limit as f64 / spec.period as f64
700}
701
702fn throttle_ceiling(spec: &ThrottleSpec, source: CeilingSource) -> EffectiveCeiling {
703    EffectiveCeiling {
704        value: throttle_rate_per_sec(spec),
705        source,
706        display: spec.to_string(),
707    }
708}
709
710/// Steady-state ceiling for a single throughput axis, with a tag
711/// for which configuration term produced it.
712///
713/// {{since('2026.09.22-a276d4a8')}}
714#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
715pub struct EffectiveCeiling {
716    /// Canonical value. For rate axes: events per second; useful
717    /// for numeric comparison. For concurrency: a count.
718    pub value: f64,
719    pub source: CeilingSource,
720    /// Pre-formatted human display preserving the operator's
721    /// original configuration units. A rate configured as
722    /// `10000/hr` renders here as `10000/h` rather than `2.78/s`.
723    /// For concurrency, the integer count. For the synthetic
724    /// reconnect-cycling ceiling, the formula
725    /// `max_deliveries_per_connection × <connection_rate>`.
726    pub display: String,
727}
728
729/// Which configuration term produced an `EffectiveCeiling`.
730///
731/// {{since('2026.09.22-a276d4a8')}}
732#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
733#[serde(tag = "kind", rename_all = "snake_case")]
734pub enum CeilingSource {
735    /// The primary configured term for this axis:
736    /// `connection_limit`, `max_message_rate`,
737    /// `max_connection_rate`, or `source_selection_rate`.
738    Primary,
739    /// A named entry from the corresponding `additional_*` map.
740    Additional { name: String },
741    /// Synthetic ceiling formed from
742    /// `max_deliveries_per_connection × max_connection_rate`.
743    /// Applies only to the message-rate axis: each connection
744    /// delivers at most `max_deliveries_per_connection` messages
745    /// before reconnecting, and new connections are throttled by
746    /// `max_connection_rate`, so the product is a hard ceiling on
747    /// system-wide message rate independent of `max_message_rate`.
748    ReconnectCycling,
749    /// A constraint contributed from a configuration layer outside
750    /// the egress path config. `name` is a free-form, human-readable
751    /// description of where the constraint came from (for example,
752    /// `"scheduled queue max_message_rate"`).
753    Other { name: String },
754}
755
756/// Summary of an MX resolution attempt for a destination.
757///
758/// {{since('2026.09.22-a276d4a8')}}
759#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
760pub struct MxResolution {
761    /// Canonical site name; the value used as the queue grouping
762    /// key for this destination.
763    pub site_name: String,
764
765    /// MX hostnames grouped by preference. Lower preference is
766    /// preferred. Empty when DNS returned no MX records and the
767    /// resolver synthesised A/AAAA against `domain` directly.
768    pub by_preference: BTreeMap<u16, Vec<String>>,
769
770    /// True if real MX records were found in DNS. False when the
771    /// resolver synthesised an A/AAAA lookup against the domain.
772    pub is_mx: bool,
773
774    /// True if the destination was a domain literal like `[1.2.3.4]`.
775    pub is_domain_literal: bool,
776
777    /// True if the DNS result was DNSSEC validated.
778    pub is_secure: bool,
779}
780
781impl From<&MailExchanger> for MxResolution {
782    fn from(mx: &MailExchanger) -> Self {
783        Self {
784            site_name: mx.site_name.clone(),
785            by_preference: mx.by_pref.clone(),
786            is_mx: mx.is_mx,
787            is_domain_literal: mx.is_domain_literal,
788            is_secure: mx.is_secure,
789        }
790    }
791}
792
793impl MxResolution {
794    /// Render a one-line header summarising the MX result, plus an
795    /// indented preference→host listing beneath. Shared by
796    /// `kcli inspect-ready-q` and `kcli resolve-egress-path` so the
797    /// two surfaces produce identical output for the same input.
798    pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
799        let mut flags: Vec<&str> = vec![];
800        if !self.is_mx {
801            flags.push("synthesised");
802        }
803        if self.is_domain_literal {
804            flags.push("domain literal");
805        }
806        if self.is_secure {
807            flags.push("dnssec verified");
808        }
809        write!(out, "mx (site: {}", self.site_name)?;
810        if !flags.is_empty() {
811            write!(out, ", {}", flags.join(", "))?;
812        }
813        writeln!(out, "):")?;
814        for (pref, hosts) in &self.by_preference {
815            for host in hosts {
816                writeln!(out, "  {pref:>4}  {host}")?;
817            }
818        }
819        Ok(())
820    }
821
822    pub fn to_human_string(&self) -> String {
823        let mut s = String::new();
824        let _ = self.render(&mut s);
825        s
826    }
827}
828
829/// Steady-state ceilings implied by an `EgressPathConfig`. Each
830/// ceiling carries a tag for which configuration term produced it.
831///
832/// {{since('2026.09.22-a276d4a8')}}
833///
834/// These are per-queue ceilings; shared limits in `additional_*`
835/// maps are reported at their full value and may be tighter in
836/// practice when the bucket is contended.
837#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
838pub struct EffectiveConstraints {
839    pub max_concurrent_dispatchers: EffectiveCeiling,
840    /// None when neither `max_message_rate` nor any
841    /// `additional_message_rate_throttles` entry nor
842    /// `max_connection_rate` is configured.
843    pub max_message_rate: Option<EffectiveCeiling>,
844    /// Pre-formatted display of the declared `max_message_rate` when
845    /// a different term (typically `ReconnectCycling`) wins the
846    /// minimum. Lets renderers show a "declared but unreachable"
847    /// annotation. Uses the operator's original units.
848    pub max_message_rate_declared: Option<String>,
849    pub max_connection_rate: Option<EffectiveCeiling>,
850    pub max_source_selection_rate: Option<EffectiveCeiling>,
851}
852
853impl EffectiveConstraints {
854    /// Render a human-readable multi-line block. The same formatting
855    /// is used by `kcli inspect-ready-q` and by the
856    /// `resolve-shaping-domain` script, so operators see the same
857    /// output regardless of where they retrieved the config.
858    pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
859        let label_axis = |w: &mut dyn Write, label: &str, value: &str| -> std::fmt::Result {
860            writeln!(w, "  {label:<24}{value}")
861        };
862        let label_source =
863            |w: &mut dyn Write, src: &CeilingSource, primary: &str| -> std::fmt::Result {
864                let s = match src {
865                    CeilingSource::Primary => primary,
866                    CeilingSource::Additional { name } => name.as_str(),
867                    CeilingSource::ReconnectCycling => {
868                        "max_deliveries_per_connection × max_connection_rate"
869                    }
870                    CeilingSource::Other { name } => name.as_str(),
871                };
872                writeln!(w, "    source: {s}")
873            };
874
875        writeln!(out, "ceilings:")?;
876
877        label_axis(
878            out,
879            "concurrent dispatchers:",
880            &self.max_concurrent_dispatchers.display,
881        )?;
882        label_source(
883            out,
884            &self.max_concurrent_dispatchers.source,
885            "connection_limit",
886        )?;
887
888        if let Some(c) = &self.max_message_rate {
889            label_axis(out, "message rate:", &c.display)?;
890            label_source(out, &c.source, "max_message_rate")?;
891            if let Some(declared) = &self.max_message_rate_declared {
892                writeln!(
893                    out,
894                    "    declared: max_message_rate = {declared} ← effectively unreachable"
895                )?;
896            }
897        }
898
899        if let Some(c) = &self.max_connection_rate {
900            label_axis(out, "connection rate:", &c.display)?;
901            label_source(out, &c.source, "max_connection_rate")?;
902        }
903
904        if let Some(c) = &self.max_source_selection_rate {
905            label_axis(out, "source selection rate:", &c.display)?;
906            label_source(out, &c.source, "source_selection_rate")?;
907        }
908
909        Ok(())
910    }
911
912    pub fn to_human_string(&self) -> String {
913        let mut s = String::new();
914        // std::fmt::Write into a String cannot fail in practice;
915        // swallow the error to avoid a panic site here.
916        let _ = self.render(&mut s);
917        s
918    }
919
920    /// Fold another `EffectiveConstraints` into this one by taking
921    /// the per-axis minimum. The losing ceiling is shadowed; the
922    /// winner's `source` tag is preserved. The
923    /// `max_message_rate_declared` annotation is re-evaluated so a
924    /// rate that is now shadowed by an external source gets the
925    /// "declared but unreachable" treatment.
926    pub fn merge(&mut self, other: &EffectiveConstraints) {
927        // Remember the path's declared rate before merging; if a
928        // tighter external source wins, we need to surface the
929        // declared value as the unreachable annotation.
930        let path_declared = self
931            .max_message_rate
932            .as_ref()
933            .filter(|c| matches!(c.source, CeilingSource::Primary))
934            .map(|c| c.display.clone())
935            .or_else(|| self.max_message_rate_declared.clone());
936
937        merge_axis(
938            &mut self.max_concurrent_dispatchers,
939            &other.max_concurrent_dispatchers,
940        );
941        merge_optional(&mut self.max_message_rate, &other.max_message_rate);
942        merge_optional(&mut self.max_connection_rate, &other.max_connection_rate);
943        merge_optional(
944            &mut self.max_source_selection_rate,
945            &other.max_source_selection_rate,
946        );
947
948        if let Some(merged) = &self.max_message_rate {
949            if !matches!(merged.source, CeilingSource::Primary) {
950                if let Some(declared) = path_declared {
951                    self.max_message_rate_declared = Some(declared);
952                }
953            }
954        }
955    }
956}
957
958fn merge_axis(a: &mut EffectiveCeiling, b: &EffectiveCeiling) {
959    if b.value.total_cmp(&a.value).is_lt() {
960        *a = b.clone();
961    }
962}
963
964fn merge_optional(a: &mut Option<EffectiveCeiling>, b: &Option<EffectiveCeiling>) {
965    match (a.as_mut(), b) {
966        (Some(av), Some(bv)) if bv.value.total_cmp(&av.value).is_lt() => *av = bv.clone(),
967        (None, Some(bv)) => *a = Some(bv.clone()),
968        _ => {}
969    }
970}
971
972#[cfg(test)]
973mod constraints_tests {
974    use super::*;
975
976    fn cfg() -> EgressPathConfig {
977        EgressPathConfig::default()
978    }
979
980    /// Derive the canonical numeric value from the same display
981    /// string the production code emits. A bare integer is a
982    /// concurrency count; a `<limit>/<period>` form is a rate (in
983    /// events per second after normalization); a `K × <rate> = <rate>`
984    /// form is the reconnect-cycling formula and uses the total on
985    /// the right side. Keeps tests as declarative `display`
986    /// strings.
987    fn value_from_display(display: &str) -> f64 {
988        if let Some((_, total)) = display.split_once(" = ") {
989            let spec = ThrottleSpec::try_from(total).unwrap();
990            return spec.limit as f64 / spec.period as f64;
991        }
992        if let Ok(spec) = ThrottleSpec::try_from(display) {
993            return spec.limit as f64 / spec.period as f64;
994        }
995        display.parse::<u64>().unwrap() as f64
996    }
997
998    fn primary(display: &str) -> EffectiveCeiling {
999        EffectiveCeiling {
1000            value: value_from_display(display),
1001            source: CeilingSource::Primary,
1002            display: display.to_string(),
1003        }
1004    }
1005
1006    fn additional(name: &str, display: &str) -> EffectiveCeiling {
1007        EffectiveCeiling {
1008            value: value_from_display(display),
1009            source: CeilingSource::Additional {
1010                name: name.to_string(),
1011            },
1012            display: display.to_string(),
1013        }
1014    }
1015
1016    fn other(name: &str, display: &str) -> EffectiveCeiling {
1017        EffectiveCeiling {
1018            value: value_from_display(display),
1019            source: CeilingSource::Other {
1020                name: name.to_string(),
1021            },
1022            display: display.to_string(),
1023        }
1024    }
1025
1026    fn reconnect_cycling(display: &str) -> EffectiveCeiling {
1027        EffectiveCeiling {
1028            value: value_from_display(display),
1029            source: CeilingSource::ReconnectCycling,
1030            display: display.to_string(),
1031        }
1032    }
1033
1034    fn throttle(s: &str) -> ThrottleSpec {
1035        ThrottleSpec::try_from(s).unwrap()
1036    }
1037
1038    #[test]
1039    fn rust_compact_round_trips() {
1040        // Calling the compact TOML renderer directly on a typed
1041        // EgressPathConfig preserves the array vs. map shape of
1042        // each field, so the rendered text deserializes back into
1043        // an equivalent EgressPathConfig.
1044        let original = EgressPathConfig::default();
1045        let s = mod_serde::toml_encode_pretty_compact(&original).unwrap();
1046        let parsed: EgressPathConfig = toml::from_str(&s).unwrap();
1047        assert_eq!(parsed, original);
1048    }
1049
1050    #[test]
1051    fn zero_mx_plan_limits_are_rejected() {
1052        // A zero limit would silently empty the connection plan and fail
1053        // every delivery; it must be a configuration error instead.
1054        let err = toml::from_str::<EgressPathConfig>("max_mx_plan_size = 0").unwrap_err();
1055        k9::snapshot!(
1056            err.to_string(),
1057            "
1058TOML parse error at line 1, column 20
1059  |
10601 | max_mx_plan_size = 0
1061  |                    ^
1062value must be at least 1
1063
1064"
1065        );
1066        let err = toml::from_str::<EgressPathConfig>("max_mx_addresses_per_host = 0").unwrap_err();
1067        k9::snapshot!(
1068            err.to_string(),
1069            "
1070TOML parse error at line 1, column 29
1071  |
10721 | max_mx_addresses_per_host = 0
1073  |                             ^
1074value must be at least 1
1075
1076"
1077        );
1078    }
1079
1080    #[test]
1081    fn defaults_only_concurrency() {
1082        assert_eq!(
1083            cfg().compute_constraints(None),
1084            EffectiveConstraints {
1085                max_concurrent_dispatchers: primary("32"),
1086                max_message_rate: None,
1087                max_message_rate_declared: None,
1088                max_connection_rate: None,
1089                max_source_selection_rate: None,
1090            }
1091        );
1092    }
1093
1094    #[test]
1095    fn additional_connection_limit_wins() {
1096        let mut p = cfg();
1097        p.additional_connection_limits
1098            .insert("provider_shared".to_string(), LimitSpec::new(10));
1099        assert_eq!(
1100            p.compute_constraints(None),
1101            EffectiveConstraints {
1102                max_concurrent_dispatchers: additional("provider_shared", "10"),
1103                max_message_rate: None,
1104                max_message_rate_declared: None,
1105                max_connection_rate: None,
1106                max_source_selection_rate: None,
1107            }
1108        );
1109    }
1110
1111    #[test]
1112    fn primary_connection_limit_wins_when_smaller() {
1113        let mut p = cfg();
1114        p.connection_limit = LimitSpec::new(4);
1115        p.additional_connection_limits
1116            .insert("large_pool".to_string(), LimitSpec::new(100));
1117        assert_eq!(
1118            p.compute_constraints(None),
1119            EffectiveConstraints {
1120                max_concurrent_dispatchers: primary("4"),
1121                max_message_rate: None,
1122                max_message_rate_declared: None,
1123                max_connection_rate: None,
1124                max_source_selection_rate: None,
1125            }
1126        );
1127    }
1128
1129    #[test]
1130    fn primary_message_rate_only() {
1131        let mut p = cfg();
1132        p.max_message_rate = Some(throttle("1000/s"));
1133        assert_eq!(
1134            p.compute_constraints(None),
1135            EffectiveConstraints {
1136                max_concurrent_dispatchers: primary("32"),
1137                max_message_rate: Some(primary("1000/s")),
1138                max_message_rate_declared: None,
1139                max_connection_rate: None,
1140                max_source_selection_rate: None,
1141            }
1142        );
1143    }
1144
1145    #[test]
1146    fn additional_message_rate_wins() {
1147        let mut p = cfg();
1148        p.max_message_rate = Some(throttle("1000/s"));
1149        p.additional_message_rate_throttles
1150            .insert("provider_cap".to_string(), throttle("250/s"));
1151        assert_eq!(
1152            p.compute_constraints(None),
1153            EffectiveConstraints {
1154                max_concurrent_dispatchers: primary("32"),
1155                max_message_rate: Some(additional("provider_cap", "250/s")),
1156                max_message_rate_declared: Some("1000/s".to_string()),
1157                max_connection_rate: None,
1158                max_source_selection_rate: None,
1159            }
1160        );
1161    }
1162
1163    #[test]
1164    fn additional_message_rate_throttles_mixed_periods() {
1165        // Three additional throttles with mixed periods: the smallest
1166        // canonical rate (10/hr ≈ 0.0028 msg/s) must win the min,
1167        // even though numerically 5/s has the smallest *literal*
1168        // limit. This exercises the period-normalized comparison.
1169        let mut p = cfg();
1170        p.additional_message_rate_throttles
1171            .insert("per_hour".to_string(), throttle("10/hr"));
1172        p.additional_message_rate_throttles
1173            .insert("per_minute".to_string(), throttle("8/min"));
1174        p.additional_message_rate_throttles
1175            .insert("per_second".to_string(), throttle("5/s"));
1176        assert_eq!(
1177            p.compute_constraints(None),
1178            EffectiveConstraints {
1179                max_concurrent_dispatchers: primary("32"),
1180                max_message_rate: Some(additional("per_hour", "10/h")),
1181                max_message_rate_declared: None,
1182                max_connection_rate: None,
1183                max_source_selection_rate: None,
1184            }
1185        );
1186    }
1187
1188    #[test]
1189    fn hourly_rate_preserves_units() {
1190        // Operator-configured "10000/hr" should round-trip as
1191        // "10000/h" via ThrottleSpec::Display, not collapse to a
1192        // per-second decimal like "2.78/s". Canonical value is
1193        // still events per second for numeric comparison.
1194        let mut p = cfg();
1195        p.max_message_rate = Some(throttle("10000/hr"));
1196        assert_eq!(
1197            p.compute_constraints(None),
1198            EffectiveConstraints {
1199                max_concurrent_dispatchers: primary("32"),
1200                max_message_rate: Some(primary("10000/h")),
1201                max_message_rate_declared: None,
1202                max_connection_rate: None,
1203                max_source_selection_rate: None,
1204            }
1205        );
1206    }
1207
1208    #[test]
1209    fn reconnect_cycling_wins() {
1210        // K = 10, C = 10/s => 100 msg/s ceiling, smaller than the
1211        // 1000/s max_message_rate.
1212        let mut p = cfg();
1213        p.max_message_rate = Some(throttle("1000/s"));
1214        p.max_deliveries_per_connection = 10;
1215        p.max_connection_rate = Some(throttle("10/s"));
1216        assert_eq!(
1217            p.compute_constraints(None),
1218            EffectiveConstraints {
1219                max_concurrent_dispatchers: primary("32"),
1220                max_message_rate: Some(reconnect_cycling("10 × 10/s = 100/s")),
1221                max_message_rate_declared: Some("1000/s".to_string()),
1222                max_connection_rate: Some(primary("10/s")),
1223                max_source_selection_rate: None,
1224            }
1225        );
1226    }
1227
1228    #[test]
1229    fn reconnect_cycling_does_not_bind_with_large_k() {
1230        // K = 1024 (default), C = 10/s => 10240 msg/s synthetic,
1231        // not binding when max_message_rate is 1000/s. Primary wins
1232        // and no declared-but-unreachable annotation is needed.
1233        let mut p = cfg();
1234        p.max_message_rate = Some(throttle("1000/s"));
1235        p.max_connection_rate = Some(throttle("10/s"));
1236        assert_eq!(
1237            p.compute_constraints(None),
1238            EffectiveConstraints {
1239                max_concurrent_dispatchers: primary("32"),
1240                max_message_rate: Some(primary("1000/s")),
1241                max_message_rate_declared: None,
1242                max_connection_rate: Some(primary("10/s")),
1243                max_source_selection_rate: None,
1244            }
1245        );
1246    }
1247
1248    #[test]
1249    fn reconnect_cycling_alone_sets_message_rate() {
1250        // No explicit max_message_rate, but K × C is still a
1251        // computable ceiling and should be reported. Nothing was
1252        // declared, so no annotation.
1253        let mut p = cfg();
1254        p.max_deliveries_per_connection = 5;
1255        p.max_connection_rate = Some(throttle("2/s"));
1256        assert_eq!(
1257            p.compute_constraints(None),
1258            EffectiveConstraints {
1259                max_concurrent_dispatchers: primary("32"),
1260                max_message_rate: Some(reconnect_cycling("5 × 2/s = 10/s")),
1261                max_message_rate_declared: None,
1262                max_connection_rate: Some(primary("2/s")),
1263                max_source_selection_rate: None,
1264            }
1265        );
1266    }
1267
1268    #[test]
1269    fn merge_external_message_rate_wins() {
1270        // Path config declares max_message_rate = 1000/s, but an
1271        // external source (e.g. a scheduled queue) declares 100/s.
1272        // The merged result picks the external term and surfaces the
1273        // path's declared rate as "effectively unreachable".
1274        let mut p = cfg();
1275        p.max_message_rate = Some(throttle("1000/s"));
1276        let external = EffectiveConstraints {
1277            max_concurrent_dispatchers: EffectiveCeiling {
1278                value: f64::INFINITY,
1279                source: CeilingSource::Other {
1280                    name: "queue config".to_string(),
1281                },
1282                display: "∞".to_string(),
1283            },
1284            max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1285            max_message_rate_declared: None,
1286            max_connection_rate: None,
1287            max_source_selection_rate: None,
1288        };
1289        assert_eq!(
1290            p.compute_constraints(Some(&external)),
1291            EffectiveConstraints {
1292                max_concurrent_dispatchers: primary("32"),
1293                max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1294                max_message_rate_declared: Some("1000/s".to_string()),
1295                max_connection_rate: None,
1296                max_source_selection_rate: None,
1297            }
1298        );
1299    }
1300
1301    #[test]
1302    fn merge_external_does_not_bind() {
1303        // Path config declares max_message_rate = 100/s; external
1304        // declares 1000/s. Path's primary wins; no annotation.
1305        let mut p = cfg();
1306        p.max_message_rate = Some(throttle("100/s"));
1307        let external = EffectiveConstraints {
1308            max_concurrent_dispatchers: EffectiveCeiling {
1309                value: f64::INFINITY,
1310                source: CeilingSource::Other {
1311                    name: "queue config".to_string(),
1312                },
1313                display: "∞".to_string(),
1314            },
1315            max_message_rate: Some(other("scheduled queue max_message_rate", "1000/s")),
1316            max_message_rate_declared: None,
1317            max_connection_rate: None,
1318            max_source_selection_rate: None,
1319        };
1320        assert_eq!(
1321            p.compute_constraints(Some(&external)),
1322            EffectiveConstraints {
1323                max_concurrent_dispatchers: primary("32"),
1324                max_message_rate: Some(primary("100/s")),
1325                max_message_rate_declared: None,
1326                max_connection_rate: None,
1327                max_source_selection_rate: None,
1328            }
1329        );
1330    }
1331
1332    #[test]
1333    fn render_external_wins_with_annotation() {
1334        let mut p = cfg();
1335        p.max_message_rate = Some(throttle("1000/s"));
1336        let external = EffectiveConstraints {
1337            max_concurrent_dispatchers: EffectiveCeiling {
1338                value: f64::INFINITY,
1339                source: CeilingSource::Other {
1340                    name: "queue config".to_string(),
1341                },
1342                display: "∞".to_string(),
1343            },
1344            max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1345            max_message_rate_declared: None,
1346            max_connection_rate: None,
1347            max_source_selection_rate: None,
1348        };
1349        let c = p.compute_constraints(Some(&external));
1350        k9::snapshot!(
1351            c.to_human_string(),
1352            "
1353ceilings:
1354  concurrent dispatchers: 32
1355    source: connection_limit
1356  message rate:           100/s
1357    source: scheduled queue max_message_rate
1358    declared: max_message_rate = 1000/s ← effectively unreachable
1359
1360"
1361        );
1362    }
1363
1364    #[test]
1365    fn source_selection_rate() {
1366        let mut p = cfg();
1367        p.source_selection_rate = Some(throttle("5/s"));
1368        assert_eq!(
1369            p.compute_constraints(None),
1370            EffectiveConstraints {
1371                max_concurrent_dispatchers: primary("32"),
1372                max_message_rate: None,
1373                max_message_rate_declared: None,
1374                max_connection_rate: None,
1375                max_source_selection_rate: Some(primary("5/s")),
1376            }
1377        );
1378    }
1379
1380    #[test]
1381    fn render_defaults() {
1382        let c = cfg().compute_constraints(None);
1383        k9::snapshot!(
1384            c.to_human_string(),
1385            "
1386ceilings:
1387  concurrent dispatchers: 32
1388    source: connection_limit
1389
1390"
1391        );
1392    }
1393
1394    #[test]
1395    fn render_primary_message_rate_no_annotation() {
1396        // max_message_rate is the binding term; no declared-but-
1397        // unreachable annotation should appear.
1398        let mut p = cfg();
1399        p.max_message_rate = Some(throttle("1000/s"));
1400        let c = p.compute_constraints(None);
1401        k9::snapshot!(
1402            c.to_human_string(),
1403            "
1404ceilings:
1405  concurrent dispatchers: 32
1406    source: connection_limit
1407  message rate:           1000/s
1408    source: max_message_rate
1409
1410"
1411        );
1412    }
1413
1414    #[test]
1415    fn render_reconnect_cycling_with_annotation() {
1416        let mut p = cfg();
1417        p.max_message_rate = Some(throttle("1000/s"));
1418        p.max_deliveries_per_connection = 10;
1419        p.max_connection_rate = Some(throttle("10/s"));
1420        let c = p.compute_constraints(None);
1421        k9::snapshot!(
1422            c.to_human_string(),
1423            "
1424ceilings:
1425  concurrent dispatchers: 32
1426    source: connection_limit
1427  message rate:           10 × 10/s = 100/s
1428    source: max_deliveries_per_connection × max_connection_rate
1429    declared: max_message_rate = 1000/s ← effectively unreachable
1430  connection rate:        10/s
1431    source: max_connection_rate
1432
1433"
1434        );
1435    }
1436
1437    #[test]
1438    fn render_additional_throttle_winning() {
1439        let mut p = cfg();
1440        p.max_message_rate = Some(throttle("1000/s"));
1441        p.additional_message_rate_throttles
1442            .insert("provider_cap".to_string(), throttle("250/s"));
1443        let c = p.compute_constraints(None);
1444        k9::snapshot!(
1445            c.to_human_string(),
1446            "
1447ceilings:
1448  concurrent dispatchers: 32
1449    source: connection_limit
1450  message rate:           250/s
1451    source: provider_cap
1452    declared: max_message_rate = 1000/s ← effectively unreachable
1453
1454"
1455        );
1456    }
1457}