Skip to main content

protocol_droid/application/registry/
listing.rs

1//! The read path: a snapshot plus a session, filtered.
2//!
3//! # Why filtering happens on read rather than on publish
4//!
5//! A store holding only the primitives a session may see cannot answer for two
6//! sessions at once, and a store publishing a per-session copy would turn one
7//! registration into one snapshot per live session. So the store holds the
8//! union and the *filter* is the session's, applied where the question is
9//! asked: [`RegistryStore::list_visible`].
10//!
11//! The filter is [`is_visible`], which is a pure function of three inputs and
12//! reads no shared state. That is what lets a listing be a cheap scan — a
13//! `u128` mask test and a scope subset per primitive, no allocation, no lock,
14//! and no promise about what another session would see.
15//!
16//! # The count must come from the same pass
17//!
18//! `list_visible` returns the visible primitives and the total. Computing the
19//! total separately would be a second filter, and a second filter can disagree
20//! with the first if a primitive's metadata were ever anything but a pure
21//! function of its payload — which is why `PrimitiveMeta` is derived and
22//! never stored. One pass, one answer, no way for the two numbers to come from
23//! different worlds.
24
25use std::sync::Arc;
26
27use crate::application::registry::store::RegistryStore;
28use crate::domain::FeatureProfile;
29use crate::domain::registry::{AuthContext, PrimitiveKind, Registered, is_visible};
30
31/// What one session can see, and how much exists.
32///
33/// `visible` and `total` differ exactly when the filter hid something, which
34/// is the thing a caller needs to know: a `tools/list` that returns four of
35/// five tools is a different answer from one that returns five of five, and
36/// the client cannot tell them apart without the total.
37#[derive(Debug)]
38pub struct Listing {
39    visible: Vec<Arc<Registered>>,
40    total: usize,
41    generation: u64,
42}
43
44impl Listing {
45    /// The primitives this session may see, in listing order.
46    #[must_use]
47    pub fn visible(&self) -> &[Arc<Registered>] {
48        &self.visible
49    }
50
51    /// How many primitives of this kind exist, before filtering.
52    #[must_use]
53    pub const fn total(&self) -> usize {
54        self.total
55    }
56
57    /// How many were hidden from this session.
58    #[must_use]
59    pub const fn hidden(&self) -> usize {
60        self.total.saturating_sub(self.visible.len())
61    }
62
63    /// The publish this listing was taken from.
64    #[must_use]
65    pub const fn generation(&self) -> u64 {
66        self.generation
67    }
68}
69
70impl RegistryStore {
71    /// Every primitive of `kind` that `session` may see.
72    ///
73    /// Takes an owned [`Arc<Snapshot>`] rather than a guard, so a caller that
74    /// goes on to build a response, or hand the primitives to a tool, is not
75    /// holding the store against a publish for the duration.
76    #[must_use]
77    pub fn list_visible(
78        &self,
79        session: &AuthContext,
80        profile: &FeatureProfile,
81        kind: PrimitiveKind,
82    ) -> Listing {
83        let snapshot = self.snapshot();
84        let all = snapshot.list(kind);
85        let total = all.len();
86        let visible = all
87            .iter()
88            .filter(|primitive| is_visible(session, profile, primitive.meta()))
89            .map(Arc::clone)
90            .collect();
91        Listing {
92            visible,
93            total,
94            generation: snapshot.generation(),
95        }
96    }
97
98    /// One primitive, if `session` may see it.
99    ///
100    /// A miss is `None` whether the primitive is absent or merely hidden, and
101    /// that is deliberate: telling a caller *which* would let a session probe
102    /// for the existence of a tool it cannot see by reading the difference
103    /// between a 404 and a 403. The server still logs the difference.
104    #[must_use]
105    pub fn get_visible(
106        &self,
107        session: &AuthContext,
108        profile: &FeatureProfile,
109        kind: PrimitiveKind,
110        id: &str,
111    ) -> Option<Arc<Registered>> {
112        let snapshot = self.snapshot();
113        let registered = snapshot.get(kind, &id.into())?.clone();
114        is_visible(session, profile, registered.meta()).then_some(registered)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::RegistryStore;
121    use crate::domain::registry::TestError;
122    use crate::domain::registry::{AuthContext, Primitive, PrimitiveKind, Registered, Tool};
123    use crate::domain::{Feature, FeatureProfile};
124
125    fn tool(name: &str) -> Result<Registered, TestError> {
126        Ok(Registered::from(Primitive::from(Tool::new(
127            name,
128            r#"{"type":"object"}"#,
129        )?)))
130    }
131
132    fn everything() -> FeatureProfile {
133        FeatureProfile::empty()
134            .with(Feature::Tools)
135            .with(Feature::ToolStructuredOutput)
136    }
137
138    /// The listing keeps registration order, because a page cursor is only
139    /// meaningful if the order is defined.
140    #[test]
141    fn a_listing_is_in_registration_order() -> Result<(), TestError> {
142        let store = RegistryStore::new();
143        for name in ["c", "a", "b"] {
144            store.register(tool(name)?);
145        }
146        let session = AuthContext::unrestricted("alice");
147        let listing = store.list_visible(&session, &everything(), PrimitiveKind::Tool);
148
149        let names: Vec<&str> = listing
150            .visible()
151            .iter()
152            .map(|held| held.id().as_str())
153            .collect();
154        assert_eq!(names, vec!["c", "a", "b"]);
155        Ok(())
156    }
157
158    /// `hidden` is derived from the same pass as `visible`, so the two can
159    /// never come from different worlds.
160    #[test]
161    fn the_total_counts_what_the_filter_hid() -> Result<(), TestError> {
162        let store = RegistryStore::new();
163        let structured = Registered::from(Primitive::from(
164            Tool::new("structured", r#"{"type":"object"}"#)?
165                .with_output_schema(r#"{"type":"object"}"#)?,
166        ));
167        store.register(structured);
168        store.register(tool("plain")?);
169
170        let session = AuthContext::unrestricted("alice");
171        let without = store.list_visible(
172            &session,
173            &FeatureProfile::empty().with(Feature::Tools),
174            PrimitiveKind::Tool,
175        );
176
177        assert_eq!(without.visible().len(), 1);
178        assert_eq!(without.total(), 2);
179        assert_eq!(without.hidden(), 1);
180        assert_eq!(
181            without.total(),
182            without.visible().len() + without.hidden(),
183            "visible plus hidden must account for every primitive"
184        );
185        Ok(())
186    }
187
188    /// The generation travels with the listing, so a client can tell two
189    /// identical-looking answers apart.
190    #[test]
191    fn a_listing_reports_the_generation_it_came_from() -> Result<(), TestError> {
192        let store = RegistryStore::new();
193        store.register(tool("a")?);
194        let session = AuthContext::unrestricted("alice");
195        let listing = store.list_visible(&session, &everything(), PrimitiveKind::Tool);
196        assert_eq!(listing.generation(), 1);
197        Ok(())
198    }
199
200    /// A primitive this session may not see is a miss, and is indistinguishable
201    /// from one that is not there. Anything else turns `tools/call` into an
202    /// oracle for the existence of tools the caller cannot see.
203    #[test]
204    fn a_hidden_primitive_and_an_absent_one_both_read_as_none() -> Result<(), TestError> {
205        let store = RegistryStore::new();
206        let guarded = Registered::from(Primitive::from(Tool::new(
207            "guarded",
208            r#"{"type":"object"}"#,
209        )?))
210        .requiring_scope(crate::domain::registry::Scope::Admin);
211        store.register(guarded);
212
213        let nobody = AuthContext::new("mallory", crate::domain::registry::ScopeSet::empty());
214        let hidden = store.get_visible(&nobody, &everything(), PrimitiveKind::Tool, "guarded");
215        let absent = store.get_visible(&nobody, &everything(), PrimitiveKind::Tool, "nope");
216
217        assert!(hidden.is_none());
218        assert!(absent.is_none());
219
220        let root = AuthContext::unrestricted("root");
221        assert!(
222            store
223                .get_visible(&root, &everything(), PrimitiveKind::Tool, "guarded")
224                .is_some(),
225            "the same lookup must succeed for a principal that does hold the scope"
226        );
227        Ok(())
228    }
229}