Skip to main content

laser_sdk/
capabilities.rs

1use laser_wire::checkpoint::CheckpointReadConsistency;
2use laser_wire::filter::FilterCodec;
3use laser_wire::query::Consistency;
4
5pub use laser_wire::destination::BackendResourceId;
6pub use laser_wire::hello::{
7    BackendDescriptor, BackendDesiredState, BackendImplementation, BackendLimits, BackendMode,
8    BackendObservedState, BackendReadiness, BackendReadinessCode, BackendReadinessReason,
9    FilterAnnounce, MaintenanceCapabilities, MaterializationCapability, OpVersions,
10    QueryCapabilities, QueryPagingCapability, SchemaCapabilities, TimeTravelCapability,
11};
12
13/// What the connected infrastructure serves beyond the open SDK surface. The open
14/// SDK works on Apache Iggy with everything off (`OPEN`). LaserData Cloud
15/// reports a richer set at connect, lighting up the matching paths with no change
16/// to the caller's imports. Nothing here falls back to a working state on raw
17/// Apache Iggy: when a capability is off, the matching call returns
18/// `LaserError::Unsupported`.
19///
20/// Capabilities are grouped by where they live and what they depend on, rather
21/// than a flat list of unrelated flags. [`managed`](Self::managed) is the root
22/// (connected to a managed backend at all). The managed surfaces ([`query`](Self::query),
23/// [`kv`](Self::kv), [`graph`](Self::graph), [`forks`](Self::forks),
24/// [`a2a_gateway`](Self::a2a_gateway)) are served by that backend. A surface's
25/// sub-features nest under it ([`QueryCaps::consistency`], [`KvCaps::cas`]) so a
26/// dependent feature cannot be advertised apart from the surface it refines.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct Capabilities {
30    /// Connected to a managed backend (LaserData Cloud). The root managed switch:
31    /// with no backend, every managed surface below is unavailable.
32    pub managed: bool,
33    /// The managed query surface (`Laser::query`) and the read-consistency it
34    /// serves.
35    pub query: QueryCaps,
36    /// Materialization destination declarations and managed checkpoint lifecycle.
37    pub destinations: DestinationCaps,
38    /// The managed key-value surface (`Laser::kv`) and its conditional-write
39    /// support.
40    pub kv: KvCaps,
41    /// The managed knowledge-graph surface (`Laser::graph`). Agentic memory
42    /// composes the query and graph surfaces, so it has no capability of its own.
43    pub graph: bool,
44    /// Managed copy-on-write forks of the read model (`Laser::fork`).
45    pub forks: bool,
46    /// A managed A2A gateway (auth, streaming, persisted task store).
47    pub a2a_gateway: bool,
48    /// Managed reads of agent sessions in one stream.
49    pub sessions: bool,
50    /// The deployment scopes every managed name to one stream, and each
51    /// stream's change feed rides its own ops topic.
52    pub stream_tenancy: bool,
53    /// The change feed (`Laser::watch`): `ChangeRecord`s published after each
54    /// committed projector batch for a binding that opted into `notify`. Off
55    /// when the deployment does not publish the feed.
56    pub watch: bool,
57    /// The authorization control surface (`Laser::whoami` and the role/binding
58    /// verbs). Fork-native, advertised by the `AUTHZ` feature bit.
59    pub authz: bool,
60    /// Server-side consumer filters, administered through a consumer group's
61    /// `filter()` handle: native filtered reads and, with a managed backend,
62    /// the group policy catalog.
63    pub filters: FilterCaps,
64    /// The wire op versions the server advertised in its `AGDX_HELLO` reply, or
65    /// `None` against Apache Iggy and pre-versioned servers. When present, the
66    /// SDK fails fast with the surface's typed `Version` error before a round-trip
67    /// whenever its pinned op version is not the advertised one.
68    pub versions: Option<OpVersions>,
69    /// Structured, versioned, secret-free backend observations advertised at connect.
70    pub backends: Vec<BackendDescriptor>,
71    /// What the `AGDX_HELLO` probe established at connect. A group consumer
72    /// reads natively only when the managed surfaces are positively absent.
73    pub hello: HelloOutcome,
74}
75
76/// The outcome of the connect-time `AGDX_HELLO` probe.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub enum HelloOutcome {
79    /// No probe ran: a bring-your-own client or an explicit capability set.
80    #[default]
81    Unknown,
82    /// The server answered with a managed announcement.
83    Answered,
84    /// The server answered without one: Apache Iggy refusing the command or
85    /// an older server's empty body. The managed surfaces are absent.
86    Rejected,
87    /// The probe failed or timed out, so nothing is established.
88    Failed,
89}
90
91/// The managed query surface and the strongest read-consistency it serves.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub struct QueryCaps {
94    /// Whether `Laser::query` is served.
95    pub available: bool,
96    /// The strongest read-consistency level the surface honors. The ladder is
97    /// `Eventual < ReadYourWrites < Strong`, so a level implies every weaker one
98    /// (a `Strong` surface serves a read-your-writes query too). `Eventual` is
99    /// the default when a query surface is available.
100    pub consistency: Consistency,
101    /// Whether the surface serves lexical relevance search (`.text()` /
102    /// `.text_in()`). Off means a `text` query is refused locally before
103    /// sending, since an unaware server would silently drop the additive field
104    /// and answer wider than asked.
105    pub keyword: bool,
106    /// Whether an executing query can continue through opaque cursor pages.
107    pub cursor_paging: bool,
108    /// Whether query cancellation is served.
109    pub cancellation: bool,
110    /// Whether query execution status is served.
111    pub execution_status: bool,
112}
113
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct DestinationCaps {
116    pub available: bool,
117    pub consistency: CheckpointReadConsistency,
118}
119
120/// Server-side consumer filters.
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct FilterCaps {
123    /// Filtered reads, fenced acknowledgments, previews, sample tests, and
124    /// validation. Served by the streaming server itself, advertised by the
125    /// `CONSUMER_FILTERS` feature bit, so it needs no managed backend.
126    pub native: bool,
127    /// Saved filters, revisions, group bindings, and mutation outcomes. Needs a
128    /// ready managed backend that advertises the `filter` op version.
129    pub catalog: bool,
130    /// Group-aware reads: the server resolves a consumer group's own policy,
131    /// delivers an unbound group unfiltered, and fences acknowledgments by
132    /// policy generation. Advertised by the `GROUP_POLICY_READS` feature bit.
133    /// A normal group consumer needs it on a server that serves filters.
134    pub group_policy_reads: bool,
135    /// The evaluator version and codecs the server announced. `None` from a
136    /// server that predates the announcement, which then evaluates as this
137    /// build does.
138    pub evaluation: Option<FilterAnnounce>,
139}
140
141impl FilterCaps {
142    /// Whether the server evaluates a filter built for `evaluator_version`
143    /// with `codec` exactly as this build does.
144    #[must_use]
145    pub fn evaluates(&self, evaluator_version: u32, codec: FilterCodec) -> bool {
146        self.evaluation
147            .as_ref()
148            .is_none_or(|evaluation| evaluation.evaluates(evaluator_version, codec))
149    }
150}
151
152/// The managed key-value surface and its conditional-write support.
153#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
154pub struct KvCaps {
155    /// Whether the managed key-value store is served (`Laser::kv` get/set/scan).
156    pub available: bool,
157    /// Whether the store serves compare-and-swap (`Kv::set(..).commit()`).
158    /// Independent of plain get/set: a backend that cannot do a conditional write
159    /// leaves it off, and `commit()` then returns `LaserError::Unsupported`.
160    pub cas: bool,
161    /// Whether the store serves fenced compare-and-swap (`Kv::cas_fenced(..)`).
162    /// Independent of plain `cas`: a backend leaves it off when it cannot gate a
163    /// write on a live fence sequence, and `cas_fenced` then returns
164    /// `LaserError::Unsupported`.
165    pub cas_fenced: bool,
166    /// Whether the store serves the revocable fenced-lease contract
167    /// (`KV_LEASE_OP_VERSION`): holder-scoped acquire, renewal, fence-validated
168    /// release, fenced compare-and-swap requiring a live lease, and the
169    /// barriered read. The SDK's lease, renew, release, and `cas_fenced` calls
170    /// all gate on this bit, because a server without it would decode their
171    /// reshaped payloads under the old contract instead of rejecting them.
172    pub fenced_leases: bool,
173}
174
175impl Capabilities {
176    /// The baseline every deployment has: nothing beyond the open SDK surface.
177    pub const OPEN: Self = Self {
178        managed: false,
179        query: QueryCaps {
180            available: false,
181            consistency: Consistency::Eventual,
182            keyword: false,
183            cursor_paging: false,
184            cancellation: false,
185            execution_status: false,
186        },
187        destinations: DestinationCaps {
188            available: false,
189            consistency: CheckpointReadConsistency::PotentiallyStale,
190        },
191        kv: KvCaps {
192            available: false,
193            cas: false,
194            cas_fenced: false,
195            fenced_leases: false,
196        },
197        graph: false,
198        forks: false,
199        a2a_gateway: false,
200        sessions: false,
201        stream_tenancy: false,
202        watch: false,
203        authz: false,
204        filters: FilterCaps {
205            native: false,
206            catalog: false,
207            group_policy_reads: false,
208            evaluation: None,
209        },
210        versions: None,
211        backends: Vec::new(),
212        hello: HelloOutcome::Unknown,
213    };
214
215    /// True when the connected infrastructure advertised nothing beyond the open
216    /// SDK (`OPEN`).
217    pub fn is_open_only(&self) -> bool {
218        let mut open = Self::OPEN;
219        open.hello = self.hello;
220        *self == open
221    }
222
223    pub fn backend(&self, resource_id: BackendResourceId) -> Option<&BackendDescriptor> {
224        self.backends
225            .iter()
226            .find(|backend| backend.resource_id == resource_id)
227    }
228
229    pub fn enabled_backends(&self) -> impl Iterator<Item = &BackendDescriptor> {
230        self.backends
231            .iter()
232            .filter(|backend| backend.desired_state == BackendDesiredState::Enabled)
233    }
234
235    pub fn unready_backends(&self) -> impl Iterator<Item = &BackendDescriptor> {
236        self.enabled_backends().filter(|backend| {
237            backend.observed_state != BackendObservedState::Ready || !backend.readiness.ready
238        })
239    }
240
241    pub fn readiness_reasons(
242        &self,
243        resource_id: BackendResourceId,
244    ) -> Option<&[BackendReadinessReason]> {
245        self.backend(resource_id)
246            .map(|backend| backend.readiness.reasons.as_slice())
247    }
248
249    pub fn is_ready(&self) -> bool {
250        if !self.managed {
251            return false;
252        }
253        let mut enabled = self.enabled_backends();
254        let Some(first) = enabled.next() else {
255            return false;
256        };
257        let ready = |backend: &BackendDescriptor| {
258            backend.observed_state == BackendObservedState::Ready && backend.readiness.ready
259        };
260        ready(first) && enabled.all(ready)
261    }
262
263    // The struct is `#[non_exhaustive]`, so these chainable setters are the
264    // supported way to build a custom set: `Capabilities::OPEN.with_query(true)`.
265
266    /// Returns a copy connected to a managed backend (the root switch).
267    #[must_use]
268    pub fn with_managed(mut self, value: bool) -> Self {
269        self.managed = value;
270        self
271    }
272
273    /// Returns a copy with the managed query surface available.
274    #[must_use]
275    pub fn with_query(mut self, value: bool) -> Self {
276        self.query.available = value;
277        self
278    }
279
280    /// Returns a copy advertising the strongest read-consistency the query
281    /// surface serves.
282    #[must_use]
283    pub fn with_query_consistency(mut self, level: Consistency) -> Self {
284        self.query.consistency = level;
285        self
286    }
287
288    /// Returns a copy advertising lexical relevance search on the query surface.
289    #[must_use]
290    pub fn with_query_keyword(mut self, value: bool) -> Self {
291        self.query.keyword = value;
292        self
293    }
294
295    #[must_use]
296    pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
297        self.query.cursor_paging = paging;
298        self.query.cancellation = cancellation;
299        self.query.execution_status = status;
300        self
301    }
302
303    #[must_use]
304    pub fn with_destinations(mut self, value: bool) -> Self {
305        self.destinations.available = value;
306        self
307    }
308
309    #[must_use]
310    pub fn with_destination_consistency(mut self, value: CheckpointReadConsistency) -> Self {
311        self.destinations.consistency = value;
312        self
313    }
314
315    /// Returns a copy with the managed key-value surface available.
316    #[must_use]
317    pub fn with_kv(mut self, value: bool) -> Self {
318        self.kv.available = value;
319        self
320    }
321
322    /// Returns a copy advertising key-value compare-and-swap.
323    #[must_use]
324    pub fn with_kv_cas(mut self, value: bool) -> Self {
325        self.kv.cas = value;
326        self
327    }
328
329    /// Returns a copy advertising the key-value fenced-lease contract.
330    #[must_use]
331    pub fn with_kv_fenced_leases(mut self, value: bool) -> Self {
332        self.kv.fenced_leases = value;
333        self
334    }
335
336    /// Returns a copy advertising key-value fenced compare-and-swap.
337    #[must_use]
338    pub fn with_kv_cas_fenced(mut self, value: bool) -> Self {
339        self.kv.cas_fenced = value;
340        self
341    }
342
343    /// Returns a copy with the managed graph surface available.
344    #[must_use]
345    pub fn with_graph(mut self, value: bool) -> Self {
346        self.graph = value;
347        self
348    }
349
350    /// Returns a copy with managed forks available.
351    #[must_use]
352    pub fn with_forks(mut self, value: bool) -> Self {
353        self.forks = value;
354        self
355    }
356
357    /// Returns a copy advertising the managed A2A gateway.
358    #[must_use]
359    pub fn with_a2a_gateway(mut self, value: bool) -> Self {
360        self.a2a_gateway = value;
361        self
362    }
363
364    /// Returns a copy advertising managed session reads.
365    #[must_use]
366    pub fn with_sessions(mut self, value: bool) -> Self {
367        self.sessions = value;
368        self
369    }
370
371    /// Returns a copy advertising stream tenancy.
372    #[must_use]
373    pub fn with_stream_tenancy(mut self, value: bool) -> Self {
374        self.stream_tenancy = value;
375        self
376    }
377
378    /// Returns a copy advertising native consumer filters and, separately, the
379    /// saved-filter catalog.
380    #[must_use]
381    pub fn with_filters(mut self, native: bool, catalog: bool) -> Self {
382        self.filters = FilterCaps {
383            native,
384            catalog,
385            group_policy_reads: native,
386            evaluation: None,
387        };
388        self
389    }
390
391    /// Returns a copy with the advertised wire op `versions`.
392    #[must_use]
393    pub fn with_versions(mut self, value: Option<OpVersions>) -> Self {
394        self.versions = value;
395        self
396    }
397
398    /// Returns a copy advertising the structured backend observations.
399    #[must_use]
400    pub fn with_backends(mut self, value: Vec<BackendDescriptor>) -> Self {
401        self.backends = value;
402        self
403    }
404
405    /// Fold the per-surface sub-features a hello reply's op versions advertise
406    /// into this set: the compare-and-swap bit, and the consistency bits (raising
407    /// the served level to the strongest advertised). Additive, so a sub-feature
408    /// already set by a BYO builder survives, and one the server does not
409    /// advertise stays off.
410    #[cfg(any(feature = "streaming", test))]
411    pub(crate) fn merge_features(&mut self, versions: &OpVersions) {
412        use laser_wire::hello::feature;
413        self.kv.cas |= versions.has_feature(feature::KV_CAS);
414        self.kv.cas_fenced |= versions.has_feature(feature::KV_CAS_FENCED);
415        self.kv.fenced_leases |= versions.has_feature(feature::KV_FENCED_LEASES);
416        self.kv.cas_fenced |= self.kv.fenced_leases;
417        self.sessions |= versions.has_feature(feature::SESSIONS);
418        self.stream_tenancy |= versions.has_feature(feature::STREAM_TENANCY);
419        self.query.keyword |= versions.has_feature(feature::KEYWORD_SEARCH);
420        self.watch |= versions.has_feature(feature::WATCH);
421        self.authz |= versions.has_feature(feature::AUTHZ);
422        self.filters.native |= versions.has_feature(feature::CONSUMER_FILTERS);
423        self.filters.group_policy_reads |= versions.has_feature(feature::GROUP_POLICY_READS);
424        self.destinations.available |=
425            versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
426        if self.destinations.available {
427            self.destinations.consistency = CheckpointReadConsistency::Linearizable;
428        }
429        if versions.has_feature(feature::STRONG_CONSISTENCY) {
430            self.query.consistency = self.query.consistency.max(Consistency::Strong);
431        } else if versions.has_feature(feature::READ_YOUR_WRITES) {
432            self.query.consistency = self.query.consistency.max(Consistency::ReadYourWrites);
433        }
434    }
435
436    /// Whether the query surface serves read-consistency `level`. Because the
437    /// levels form a ladder, this is `level <= self.query.consistency`: `Eventual`
438    /// is always served, and a `Strong` surface subsumes a read-your-writes query.
439    /// An unknown future level is treated as not served (fail-safe).
440    pub fn serves_consistency(&self, level: Consistency) -> bool {
441        level <= self.query.consistency
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use laser_wire::hello::feature;
449
450    fn backend(
451        id: u128,
452        desired: BackendDesiredState,
453        observed: BackendObservedState,
454        readiness: BackendReadiness,
455    ) -> BackendDescriptor {
456        BackendDescriptor::new(
457            BackendResourceId::from_u128(id),
458            BackendMode::Operational,
459            format!("backend-{id}"),
460            BackendImplementation {
461                kind: "embedded".to_owned(),
462                version: "1.0.0".to_owned(),
463            },
464            1,
465            1,
466        )
467        .with_state(desired, observed, readiness)
468    }
469
470    #[test]
471    fn given_advertised_feature_bits_when_merged_then_should_light_up_the_capabilities() {
472        let mut caps = Capabilities::OPEN;
473        let versions = OpVersions::new(1, 1, 1, 1).with_features(
474            feature::KV_CAS
475                | feature::READ_YOUR_WRITES
476                | feature::KV_CAS_FENCED
477                | feature::KV_FENCED_LEASES
478                | feature::SESSIONS,
479        );
480        caps.merge_features(&versions);
481        assert!(caps.sessions);
482        assert!(caps.kv.cas, "KV_CAS bit should set kv.cas");
483        assert!(
484            caps.kv.cas_fenced,
485            "KV_CAS_FENCED bit should set kv.cas_fenced"
486        );
487        assert!(
488            caps.kv.fenced_leases,
489            "KV_FENCED_LEASES bit should set kv.fenced_leases"
490        );
491        assert_eq!(
492            caps.query.consistency,
493            Consistency::ReadYourWrites,
494            "the read-your-writes bit should raise the level"
495        );
496    }
497
498    #[test]
499    fn given_the_consumer_filters_bit_when_merged_then_should_light_up_native_filters_only() {
500        let mut caps = Capabilities::OPEN;
501        caps.merge_features(&OpVersions::new(1, 1, 1, 1).with_features(feature::CONSUMER_FILTERS));
502        assert!(caps.filters.native);
503        assert!(
504            !caps.filters.catalog,
505            "the catalog needs a ready backend with the filter op version"
506        );
507    }
508
509    #[test]
510    fn given_the_strong_bit_when_merged_then_should_raise_the_level_to_strong() {
511        let mut caps = Capabilities::OPEN;
512        caps.merge_features(
513            &OpVersions::new(1, 1, 1, 1).with_features(feature::STRONG_CONSISTENCY),
514        );
515        assert_eq!(caps.query.consistency, Consistency::Strong);
516        // Strong subsumes the weaker level, structurally.
517        assert!(caps.serves_consistency(Consistency::ReadYourWrites));
518    }
519
520    #[test]
521    fn given_only_fenced_leases_when_merged_then_should_also_advertise_fenced_cas() {
522        let mut caps = Capabilities::OPEN;
523        caps.merge_features(&OpVersions::new(1, 1, 1, 1).with_features(feature::KV_FENCED_LEASES));
524        assert!(caps.kv.fenced_leases);
525        assert!(
526            caps.kv.cas_fenced,
527            "the full fenced-lease contract subsumes fenced CAS"
528        );
529    }
530
531    #[test]
532    fn given_an_explicit_sub_feature_when_merging_unadvertised_then_should_preserve_it() {
533        let mut caps = Capabilities::OPEN.with_kv_cas(true);
534        caps.merge_features(&OpVersions::new(1, 1, 1, 1));
535        assert!(
536            caps.kv.cas,
537            "an explicit sub-feature must survive the merge"
538        );
539    }
540
541    #[test]
542    fn given_open_capabilities_when_checking_consistency_then_should_serve_only_eventual() {
543        assert!(Capabilities::OPEN.serves_consistency(Consistency::Eventual));
544        assert!(!Capabilities::OPEN.serves_consistency(Consistency::ReadYourWrites));
545        assert!(!Capabilities::OPEN.serves_consistency(Consistency::Strong));
546    }
547
548    #[test]
549    fn given_read_your_writes_only_when_checked_then_should_not_serve_strong() {
550        let ryw = Capabilities::OPEN.with_query_consistency(Consistency::ReadYourWrites);
551        assert!(ryw.serves_consistency(Consistency::ReadYourWrites));
552        assert!(
553            !ryw.serves_consistency(Consistency::Strong),
554            "read-your-writes must not imply strong"
555        );
556    }
557
558    #[test]
559    fn given_strong_when_checked_then_should_subsume_read_your_writes() {
560        let strong = Capabilities::OPEN.with_query_consistency(Consistency::Strong);
561        assert!(strong.serves_consistency(Consistency::Strong));
562        assert!(
563            strong.serves_consistency(Consistency::ReadYourWrites),
564            "strong must subsume read-your-writes"
565        );
566    }
567
568    #[test]
569    fn given_advertised_backends_when_set_then_should_expose_them_and_open_has_none() {
570        assert!(Capabilities::OPEN.backends.is_empty());
571        let caps = Capabilities::OPEN.with_backends(vec![
572            BackendDescriptor::new(
573                laser_wire::destination::BackendResourceId::from_u128(1),
574                BackendMode::Operational,
575                "Embedded",
576                BackendImplementation {
577                    kind: "embedded".to_owned(),
578                    version: "1.0.0".to_owned(),
579                },
580                1,
581                1,
582            ),
583            BackendDescriptor::new(
584                laser_wire::destination::BackendResourceId::from_u128(2),
585                BackendMode::Lakehouse,
586                "Warehouse",
587                BackendImplementation {
588                    kind: "columnar".to_owned(),
589                    version: "2.1.0".to_owned(),
590                },
591                1,
592                1,
593            ),
594        ]);
595        assert_eq!(caps.backends.len(), 2);
596        assert_eq!(caps.backends[1].label, "Warehouse");
597        assert!(!caps.is_open_only() && !caps.managed);
598    }
599
600    #[test]
601    fn given_enabled_ready_backends_when_checking_readiness_then_should_be_ready() {
602        let first_id = BackendResourceId::from_u128(1);
603        let caps = Capabilities::OPEN.with_managed(true).with_backends(vec![
604            backend(
605                1,
606                BackendDesiredState::Enabled,
607                BackendObservedState::Ready,
608                BackendReadiness::ready(1),
609            ),
610            backend(
611                2,
612                BackendDesiredState::Disabled,
613                BackendObservedState::Disabled,
614                BackendReadiness::not_ready(BackendReadinessCode::Disabled),
615            ),
616        ]);
617        assert!(caps.is_ready());
618        assert_eq!(
619            caps.backend(first_id).map(|backend| backend.label.as_str()),
620            Some("backend-1")
621        );
622        assert_eq!(caps.enabled_backends().count(), 1);
623        assert_eq!(caps.unready_backends().count(), 0);
624        assert_eq!(caps.readiness_reasons(first_id), Some([].as_slice()));
625    }
626
627    #[test]
628    fn given_starting_backend_when_checking_readiness_then_reason_should_be_exposed() {
629        let id = BackendResourceId::from_u128(3);
630        let caps = Capabilities::OPEN
631            .with_managed(true)
632            .with_backends(vec![backend(
633                3,
634                BackendDesiredState::Enabled,
635                BackendObservedState::Starting,
636                BackendReadiness::not_ready(BackendReadinessCode::ConfigurationPending),
637            )]);
638        assert!(!caps.is_ready());
639        assert_eq!(caps.unready_backends().count(), 1);
640        assert_eq!(
641            caps.readiness_reasons(id)
642                .and_then(|reasons| reasons.first())
643                .map(|reason| reason.code),
644            Some(BackendReadinessCode::ConfigurationPending)
645        );
646    }
647
648    #[test]
649    fn given_open_capabilities_when_the_probe_outcome_changes_then_should_still_be_open_only() {
650        for hello in [
651            HelloOutcome::Unknown,
652            HelloOutcome::Answered,
653            HelloOutcome::Rejected,
654            HelloOutcome::Failed,
655        ] {
656            let mut caps = Capabilities::OPEN;
657            caps.hello = hello;
658            assert!(caps.is_open_only());
659            assert!(!caps.with_managed(true).is_open_only());
660        }
661    }
662
663    #[test]
664    fn given_managed_without_enabled_backends_when_checking_readiness_then_should_not_be_ready() {
665        assert!(!Capabilities::OPEN.with_managed(true).is_ready());
666    }
667}