Skip to main content

chio_listing/
discovery.rs

1//! Capability marketplace discovery: search and compare extensions on top of
2//! the generic listing surface.
3//!
4//! This module is purely additive to the shipped listing types in
5//! [`crate`]. It does not change any existing signatures.
6//!
7//! A tool server operator annotates a listing with a signed
8//! [`ListingPricingHint`] (price-per-call, SLA, revocation rate, recent
9//! receipt volume). Agents search a set of `GenericListingReport`s filtered
10//! by scope prefix, price ceiling, provider, and freshness, then compare the
11//! results in a normalized side-by-side view.
12//!
13//! Listings with non-`Active` status, stale freshness, or missing/expired
14//! pricing hints are filtered out. The search is fail-closed by default: any
15//! listing that cannot be verified or would violate the query bounds is
16//! rejected rather than returned.
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22use crate::crypto::PublicKey;
23use crate::receipt::SignedExportEnvelope;
24use crate::{
25    aggregate_generic_listing_reports, normalize_namespace, GenericListingActorKind,
26    GenericListingFreshnessState, GenericListingQuery, GenericListingReplicaFreshness,
27    GenericListingReport, GenericListingSearchError, GenericListingStatus,
28    GenericRegistryPublisher, MonetaryAmount, SignedGenericListing, MAX_GENERIC_LISTING_LIMIT,
29};
30
31/// Schema identifier for signed pricing hints.
32pub const LISTING_PRICING_HINT_SCHEMA: &str = "chio.marketplace.listing-pricing-hint.v1";
33
34/// Schema identifier for signed marketplace search responses.
35pub const LISTING_SEARCH_SCHEMA: &str = "chio.marketplace.search.v1";
36
37/// Schema identifier for marketplace comparison artifacts.
38pub const LISTING_COMPARISON_SCHEMA: &str = "chio.marketplace.compare.v1";
39
40/// Maximum number of listings a caller may request back from [`search`].
41pub const MAX_MARKETPLACE_SEARCH_LIMIT: usize = MAX_GENERIC_LISTING_LIMIT;
42
43/// Operator-signed pricing + SLA hint paired with a published listing.
44///
45/// The body is signed separately so that listing publication (subject to
46/// registry ownership) and marketplace pricing (subject to the operator's
47/// own key) can be decoupled.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "camelCase")]
50pub struct ListingPricingHint {
51    pub schema: String,
52    /// Listing this hint applies to.
53    pub listing_id: String,
54    /// Namespace of the listing (must match the listing body).
55    pub namespace: String,
56    /// Provider / operator advertising the price (must match the listing
57    /// publisher).
58    pub provider_operator_id: String,
59    /// Capability scope prefix covered by this hint (e.g.
60    /// `"tools:search"` or `"tools:search:*"`). Queries filter against this.
61    pub capability_scope: String,
62    /// Fixed price charged per invocation under the advertised scope.
63    pub price_per_call: MonetaryAmount,
64    /// Advertised SLA for invocations under this hint.
65    pub sla: ListingSla,
66    /// Rolling revocation rate over recent invocations, in basis points.
67    /// `0` means "no revocations in the window"; `10_000` means "100%".
68    pub revocation_rate_bps: u32,
69    /// Number of receipts the provider has produced in the recent window.
70    pub recent_receipts_volume: u64,
71    /// Unix seconds when the hint was issued.
72    pub issued_at: u64,
73    /// Unix seconds when the hint expires. Past expiry, the hint is stale
74    /// and the listing falls out of the marketplace.
75    pub expires_at: u64,
76}
77
78impl ListingPricingHint {
79    /// Validate the hint's structural invariants. Does not verify the
80    /// signature; callers should use [`SignedListingPricingHint::verify_signature`].
81    pub fn validate(&self) -> Result<(), String> {
82        if self.schema != LISTING_PRICING_HINT_SCHEMA {
83            return Err(format!(
84                "unsupported listing pricing hint schema: {}",
85                self.schema
86            ));
87        }
88        non_empty(&self.listing_id, "listing_id")?;
89        non_empty(&self.namespace, "namespace")?;
90        non_empty(&self.provider_operator_id, "provider_operator_id")?;
91        non_empty(&self.capability_scope, "capability_scope")?;
92        non_empty(&self.price_per_call.currency, "price_per_call.currency")?;
93        if self.price_per_call.units == 0 {
94            return Err("price_per_call.units must be greater than zero".to_string());
95        }
96        if self.revocation_rate_bps > 10_000 {
97            return Err("revocation_rate_bps must be within [0, 10000]".to_string());
98        }
99        self.sla.validate()?;
100        if self.expires_at <= self.issued_at {
101            return Err("expires_at must be greater than issued_at".to_string());
102        }
103        Ok(())
104    }
105
106    /// Returns true when the hint is valid at the given unix timestamp.
107    #[must_use]
108    pub fn is_live_at(&self, now: u64) -> bool {
109        now >= self.issued_at && now < self.expires_at
110    }
111}
112
113pub type SignedListingPricingHint = SignedExportEnvelope<ListingPricingHint>;
114
115/// Service-level advertisement paired with a pricing hint.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "camelCase")]
118pub struct ListingSla {
119    pub max_latency_ms: u64,
120    /// Availability SLA expressed in basis points. `10_000` means 100.00%.
121    pub availability_bps: u32,
122    pub throughput_rps: u64,
123}
124
125impl ListingSla {
126    pub fn validate(&self) -> Result<(), String> {
127        if self.max_latency_ms == 0 {
128            return Err("sla.max_latency_ms must be greater than zero".to_string());
129        }
130        if self.availability_bps == 0 || self.availability_bps > 10_000 {
131            return Err("sla.availability_bps must be within (0, 10000]".to_string());
132        }
133        if self.throughput_rps == 0 {
134            return Err("sla.throughput_rps must be greater than zero".to_string());
135        }
136        Ok(())
137    }
138}
139
140/// Marketplace query for [`search`].
141///
142/// All fields are optional filters; an empty [`ListingQuery`] returns every
143/// active listing across every report within [`Self::limit_or_default`].
144#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "camelCase")]
146pub struct ListingQuery {
147    /// Capability scope prefix to match against the hint's
148    /// `capability_scope`. Matching is a literal prefix match after trim.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub capability_scope_prefix: Option<String>,
151    /// Namespace filter. Same normalization as [`GenericListingQuery`].
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub namespace: Option<String>,
154    /// Actor-kind filter. Defaults to `ToolServer` when unset.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub actor_kind: Option<GenericListingActorKind>,
157    /// Only return listings whose price per call is less than or equal to
158    /// this ceiling. Currency must match; listings with differing currency
159    /// are filtered out.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub max_price_per_call: Option<MonetaryAmount>,
162    /// Require a specific provider operator id (matches hint and publisher).
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub provider_operator_id: Option<String>,
165    /// Require fresh listings only. When set to `true` any stale/divergent
166    /// listing is rejected. Defaults to `true`.
167    #[serde(default = "default_require_fresh")]
168    pub require_fresh: bool,
169    /// Maximum number of results to return.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub limit: Option<usize>,
172}
173
174fn default_require_fresh() -> bool {
175    true
176}
177
178impl ListingQuery {
179    #[must_use]
180    pub fn limit_or_default(&self) -> usize {
181        self.limit
182            .unwrap_or(100)
183            .clamp(1, MAX_MARKETPLACE_SEARCH_LIMIT)
184    }
185
186    /// Translate this marketplace query into a listing query for use with
187    /// [`aggregate_generic_listing_reports`].
188    #[must_use]
189    pub fn to_listing_query(&self) -> GenericListingQuery {
190        GenericListingQuery {
191            namespace: self.namespace.clone(),
192            actor_kind: Some(
193                self.actor_kind
194                    .unwrap_or(GenericListingActorKind::ToolServer),
195            ),
196            actor_id: None,
197            status: Some(GenericListingStatus::Active),
198            limit: Some(self.limit_or_default()),
199        }
200    }
201}
202
203/// A listing projected into the marketplace with its accompanying pricing
204/// hint, publisher, and freshness metadata.
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206#[serde(rename_all = "camelCase")]
207pub struct Listing {
208    pub rank: u64,
209    pub listing: SignedGenericListing,
210    pub pricing: SignedListingPricingHint,
211    pub publisher: GenericRegistryPublisher,
212    pub freshness: GenericListingReplicaFreshness,
213}
214
215impl Listing {
216    /// The listing identifier is the primary handle agents reference.
217    #[must_use]
218    pub fn listing_id(&self) -> &str {
219        &self.listing.body.listing_id
220    }
221
222    /// Price advertised per call under this listing.
223    #[must_use]
224    pub fn price_per_call(&self) -> &MonetaryAmount {
225        &self.pricing.body.price_per_call
226    }
227
228    /// Returns true only when the underlying listing is `Active` and the
229    /// pricing hint is live at `now`.
230    #[must_use]
231    pub fn is_admissible_at(&self, now: u64) -> bool {
232        matches!(self.listing.body.status, GenericListingStatus::Active)
233            && self.pricing.body.is_live_at(now)
234            && self.freshness.state == GenericListingFreshnessState::Fresh
235    }
236}
237
238/// Signed marketplace search response.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
240#[serde(rename_all = "camelCase")]
241pub struct ListingSearchResponse {
242    pub schema: String,
243    pub generated_at: u64,
244    pub query: ListingQuery,
245    pub result_count: u64,
246    pub results: Vec<Listing>,
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub errors: Vec<GenericListingSearchError>,
249}
250
251/// Normalized comparison of a set of [`Listing`] entries.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253#[serde(rename_all = "camelCase")]
254pub struct ListingComparison {
255    pub schema: String,
256    pub generated_at: u64,
257    pub entry_count: u64,
258    pub rows: Vec<ListingComparisonRow>,
259    /// `true` when every non-empty row shares the same price currency.
260    pub currency_consistent: bool,
261}
262
263/// One normalized row in a [`ListingComparison`].
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase")]
266pub struct ListingComparisonRow {
267    pub listing_id: String,
268    pub provider_operator_id: String,
269    pub capability_scope: String,
270    pub price_per_call: MonetaryAmount,
271    /// Price normalized against the row with the minimum price per call
272    /// within the same currency. Expressed in basis points where 10_000
273    /// means "equal to the minimum".
274    pub price_index_bps: u32,
275    pub sla: ListingSla,
276    pub revocation_rate_bps: u32,
277    pub recent_receipts_volume: u64,
278    pub freshness_state: GenericListingFreshnessState,
279    pub status: GenericListingStatus,
280}
281
282/// Search a collection of generic listing reports, filtered by marketplace
283/// criteria, and pair each surviving listing with its operator-signed
284/// pricing hint.
285///
286/// Listings without a matching, signed, non-expired pricing hint are
287/// dropped. Listings whose verified pricing hint does not satisfy the
288/// `capability_scope_prefix`, `max_price_per_call`, or
289/// `provider_operator_id` filters are also dropped.
290///
291/// This function does not panic. All hint signature failures and structural
292/// validation errors are returned in
293/// [`ListingSearchResponse::errors`].
294#[must_use]
295pub fn search(
296    reports: &[GenericListingReport],
297    pricing_hints: &[SignedListingPricingHint],
298    query: &ListingQuery,
299    now: u64,
300) -> ListingSearchResponse {
301    let listing_query = query.to_listing_query();
302    let aggregated = aggregate_generic_listing_reports(reports, &listing_query, now);
303    let mut errors = aggregated.errors;
304
305    // Index pricing hints by listing_id for O(n) lookup. Store only the
306    // most-recent verified hint per listing.
307    let mut indexed_hints: BTreeMap<String, SignedListingPricingHint> = BTreeMap::new();
308    for hint in pricing_hints {
309        if let Err(error) = hint.body.validate() {
310            errors.push(GenericListingSearchError {
311                operator_id: hint.body.provider_operator_id.clone(),
312                operator_name: None,
313                registry_url: String::new(),
314                error: format!("pricing hint `{}` invalid: {error}", hint.body.listing_id),
315            });
316            continue;
317        }
318        match hint.verify_signature() {
319            Ok(true) => {}
320            Ok(false) => {
321                errors.push(GenericListingSearchError {
322                    operator_id: hint.body.provider_operator_id.clone(),
323                    operator_name: None,
324                    registry_url: String::new(),
325                    error: format!(
326                        "pricing hint `{}` signature is invalid",
327                        hint.body.listing_id
328                    ),
329                });
330                continue;
331            }
332            Err(error) => {
333                errors.push(GenericListingSearchError {
334                    operator_id: hint.body.provider_operator_id.clone(),
335                    operator_name: None,
336                    registry_url: String::new(),
337                    error: format!(
338                        "pricing hint `{}` verification failed: {error}",
339                        hint.body.listing_id
340                    ),
341                });
342                continue;
343            }
344        }
345        if !hint.body.is_live_at(now) {
346            continue;
347        }
348        match indexed_hints.get(&hint.body.listing_id) {
349            None => {
350                indexed_hints.insert(hint.body.listing_id.clone(), hint.clone());
351            }
352            Some(existing) if existing.body.issued_at < hint.body.issued_at => {
353                indexed_hints.insert(hint.body.listing_id.clone(), hint.clone());
354            }
355            Some(_) => {}
356        }
357    }
358
359    let max_price = query.max_price_per_call.as_ref();
360    let scope_prefix = query
361        .capability_scope_prefix
362        .as_deref()
363        .map(str::trim)
364        .filter(|prefix| !prefix.is_empty());
365    let provider_filter = query
366        .provider_operator_id
367        .as_deref()
368        .map(str::trim)
369        .filter(|id| !id.is_empty());
370
371    let mut results: Vec<Listing> = Vec::new();
372    for aggregated_result in aggregated.results {
373        if matches!(
374            aggregated_result.listing.body.status,
375            GenericListingStatus::Revoked
376                | GenericListingStatus::Retired
377                | GenericListingStatus::Suspended
378                | GenericListingStatus::Superseded
379        ) {
380            continue;
381        }
382        if query.require_fresh
383            && aggregated_result.freshness.state != GenericListingFreshnessState::Fresh
384        {
385            continue;
386        }
387
388        let Some(hint) = indexed_hints.get(&aggregated_result.listing.body.listing_id) else {
389            continue;
390        };
391
392        if normalize_namespace(&hint.body.namespace)
393            != normalize_namespace(&aggregated_result.listing.body.namespace)
394        {
395            errors.push(GenericListingSearchError {
396                operator_id: hint.body.provider_operator_id.clone(),
397                operator_name: None,
398                registry_url: String::new(),
399                error: format!("pricing hint `{}` namespace mismatch", hint.body.listing_id),
400            });
401            continue;
402        }
403        if hint.body.provider_operator_id != aggregated_result.publisher.operator_id {
404            errors.push(GenericListingSearchError {
405                operator_id: hint.body.provider_operator_id.clone(),
406                operator_name: None,
407                registry_url: aggregated_result.publisher.registry_url.clone(),
408                error: format!(
409                    "pricing hint `{}` provider does not match publisher",
410                    hint.body.listing_id
411                ),
412            });
413            continue;
414        }
415        if let Some(prefix) = scope_prefix {
416            if !hint.body.capability_scope.starts_with(prefix) {
417                continue;
418            }
419        }
420        if let Some(max) = max_price {
421            if max.currency != hint.body.price_per_call.currency {
422                continue;
423            }
424            if hint.body.price_per_call.units > max.units {
425                continue;
426            }
427        }
428        if let Some(provider) = provider_filter {
429            if hint.body.provider_operator_id != provider {
430                continue;
431            }
432        }
433
434        results.push(Listing {
435            rank: 0,
436            listing: aggregated_result.listing,
437            pricing: hint.clone(),
438            publisher: aggregated_result.publisher,
439            freshness: aggregated_result.freshness,
440        });
441    }
442
443    // Rank by: price ascending (same currency), then revocation rate
444    // ascending, then receipts volume descending, then origin-publisher
445    // preference, then listing id for stability.
446    results.sort_by(|left, right| {
447        let left_currency = &left.pricing.body.price_per_call.currency;
448        let right_currency = &right.pricing.body.price_per_call.currency;
449        left_currency
450            .cmp(right_currency)
451            .then(
452                left.pricing
453                    .body
454                    .price_per_call
455                    .units
456                    .cmp(&right.pricing.body.price_per_call.units),
457            )
458            .then(
459                left.pricing
460                    .body
461                    .revocation_rate_bps
462                    .cmp(&right.pricing.body.revocation_rate_bps),
463            )
464            .then(
465                right
466                    .pricing
467                    .body
468                    .recent_receipts_volume
469                    .cmp(&left.pricing.body.recent_receipts_volume),
470            )
471            .then(
472                left.listing
473                    .body
474                    .listing_id
475                    .cmp(&right.listing.body.listing_id),
476            )
477    });
478
479    for (index, result) in results.iter_mut().enumerate() {
480        result.rank = (index + 1) as u64;
481    }
482    results.truncate(query.limit_or_default());
483
484    ListingSearchResponse {
485        schema: LISTING_SEARCH_SCHEMA.to_string(),
486        generated_at: now,
487        query: query.clone(),
488        result_count: results.len() as u64,
489        results,
490        errors,
491    }
492}
493
494/// Produce a normalized side-by-side comparison of the given listings.
495///
496/// The comparison computes a `price_index_bps` column that expresses each
497/// row's price relative to the cheapest listing **within the same
498/// currency**. Rows with mismatched currency receive `price_index_bps =
499/// 10_000` in their own sub-group and a `currency_consistent = false`
500/// flag on the comparison.
501#[must_use]
502pub fn compare(listings: &[Listing]) -> ListingComparison {
503    let generated_at = listings
504        .iter()
505        .map(|entry| entry.pricing.body.issued_at)
506        .max()
507        .unwrap_or_default();
508    let mut currencies: BTreeMap<String, u64> = BTreeMap::new();
509    for entry in listings {
510        let currency = entry.pricing.body.price_per_call.currency.clone();
511        let min = currencies.entry(currency).or_insert(u64::MAX);
512        *min = (*min).min(entry.pricing.body.price_per_call.units);
513    }
514
515    let currency_consistent = currencies.len() <= 1;
516
517    let rows = listings
518        .iter()
519        .map(|entry| {
520            let currency = entry.pricing.body.price_per_call.currency.clone();
521            let min = currencies.get(&currency).copied().unwrap_or(u64::MAX);
522            let units = entry.pricing.body.price_per_call.units;
523            let price_index_bps = if min == 0 || units == 0 {
524                10_000
525            } else {
526                // Multiply in u128 to avoid overflow; clamp to u32::MAX.
527                let numerator = (units as u128).saturating_mul(10_000_u128);
528                let value = numerator / (min as u128);
529                value.min(u32::MAX as u128) as u32
530            };
531            ListingComparisonRow {
532                listing_id: entry.listing.body.listing_id.clone(),
533                provider_operator_id: entry.pricing.body.provider_operator_id.clone(),
534                capability_scope: entry.pricing.body.capability_scope.clone(),
535                price_per_call: entry.pricing.body.price_per_call.clone(),
536                price_index_bps,
537                sla: entry.pricing.body.sla.clone(),
538                revocation_rate_bps: entry.pricing.body.revocation_rate_bps,
539                recent_receipts_volume: entry.pricing.body.recent_receipts_volume,
540                freshness_state: entry.freshness.state,
541                status: entry.listing.body.status,
542            }
543        })
544        .collect::<Vec<_>>();
545
546    ListingComparison {
547        schema: LISTING_COMPARISON_SCHEMA.to_string(),
548        generated_at,
549        entry_count: rows.len() as u64,
550        rows,
551        currency_consistent,
552    }
553}
554
555/// Resolve a listing + pricing-hint pair by id from previously aggregated
556/// search results, returning `None` when the listing is absent or fails the
557/// fail-closed admission check at `now`.
558#[must_use]
559pub fn resolve_admissible_listing<'a>(
560    search_results: &'a [Listing],
561    listing_id: &str,
562    now: u64,
563) -> Option<&'a Listing> {
564    search_results
565        .iter()
566        .find(|listing| listing.listing_id() == listing_id && listing.is_admissible_at(now))
567}
568
569/// Returns the [`PublicKey`] that signed the resolved listing's pricing hint.
570/// Used by the bid/ask protocol to bind capability tokens back to the
571/// provider's advertised pricing authority.
572#[must_use]
573pub fn provider_signing_key(listing: &Listing) -> &PublicKey {
574    &listing.pricing.signer_key
575}
576
577fn non_empty(value: &str, field: &str) -> Result<(), String> {
578    if value.trim().is_empty() {
579        Err(format!("{field} must not be empty"))
580    } else {
581        Ok(())
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::crypto::Keypair;
589    use crate::{
590        GenericListingArtifact, GenericListingBoundary, GenericListingCompatibilityReference,
591        GenericListingFreshnessWindow, GenericListingSearchPolicy, GenericListingSubject,
592        GenericListingSummary, GenericNamespaceOwnership, GenericRegistryPublisherRole,
593        GENERIC_LISTING_ARTIFACT_SCHEMA, GENERIC_LISTING_REPORT_SCHEMA,
594    };
595
596    fn sample_namespace(keypair: &Keypair) -> GenericNamespaceOwnership {
597        GenericNamespaceOwnership {
598            namespace: "https://registry.chio.example".to_string(),
599            owner_id: "operator-a".to_string(),
600            owner_name: Some("Operator A".to_string()),
601            registry_url: "https://registry.chio.example".to_string(),
602            signer_public_key: keypair.public_key(),
603            registered_at: 1,
604            transferred_from_owner_id: None,
605        }
606    }
607
608    fn sample_listing(
609        keypair: &Keypair,
610        listing_id: &str,
611        status: GenericListingStatus,
612    ) -> SignedGenericListing {
613        let body = GenericListingArtifact {
614            schema: GENERIC_LISTING_ARTIFACT_SCHEMA.to_string(),
615            listing_id: listing_id.to_string(),
616            namespace: "https://registry.chio.example".to_string(),
617            published_at: 10,
618            expires_at: Some(1000),
619            status,
620            namespace_ownership: sample_namespace(keypair),
621            subject: GenericListingSubject {
622                actor_kind: GenericListingActorKind::ToolServer,
623                actor_id: format!("server-{listing_id}"),
624                display_name: None,
625                metadata_url: None,
626                resolution_url: None,
627                homepage_url: None,
628            },
629            compatibility: GenericListingCompatibilityReference {
630                source_schema: "chio.certify.check.v1".to_string(),
631                source_artifact_id: format!("artifact-{listing_id}"),
632                source_artifact_sha256: format!("sha-{listing_id}"),
633            },
634            boundary: GenericListingBoundary::default(),
635        };
636        SignedGenericListing::sign(body, keypair).expect("sign listing")
637    }
638
639    fn sample_publisher(operator_id: &str) -> GenericRegistryPublisher {
640        GenericRegistryPublisher {
641            role: GenericRegistryPublisherRole::Origin,
642            operator_id: operator_id.to_string(),
643            operator_name: Some(format!("Operator {operator_id}")),
644            registry_url: format!("https://{operator_id}.chio.example"),
645            upstream_registry_urls: Vec::new(),
646        }
647    }
648
649    fn sample_report(
650        keypair: &Keypair,
651        operator_id: &str,
652        generated_at: u64,
653        listings: Vec<SignedGenericListing>,
654    ) -> GenericListingReport {
655        GenericListingReport {
656            schema: GENERIC_LISTING_REPORT_SCHEMA.to_string(),
657            generated_at,
658            query: GenericListingQuery::default(),
659            namespace: sample_namespace(keypair),
660            publisher: sample_publisher(operator_id),
661            freshness: GenericListingFreshnessWindow {
662                max_age_secs: 300,
663                valid_until: generated_at + 300,
664            },
665            search_policy: GenericListingSearchPolicy::default(),
666            summary: GenericListingSummary {
667                matching_listings: listings.len() as u64,
668                returned_listings: listings.len() as u64,
669                active_listings: listings.len() as u64,
670                suspended_listings: 0,
671                superseded_listings: 0,
672                revoked_listings: 0,
673                retired_listings: 0,
674            },
675            listings,
676        }
677    }
678
679    fn sample_pricing_hint(
680        operator_keypair: &Keypair,
681        operator_id: &str,
682        listing_id: &str,
683        scope: &str,
684        price_units: u64,
685        issued_at: u64,
686    ) -> SignedListingPricingHint {
687        let body = ListingPricingHint {
688            schema: LISTING_PRICING_HINT_SCHEMA.to_string(),
689            listing_id: listing_id.to_string(),
690            namespace: "https://registry.chio.example".to_string(),
691            provider_operator_id: operator_id.to_string(),
692            capability_scope: scope.to_string(),
693            price_per_call: MonetaryAmount {
694                units: price_units,
695                currency: "USD".to_string(),
696            },
697            sla: ListingSla {
698                max_latency_ms: 250,
699                availability_bps: 9_990,
700                throughput_rps: 50,
701            },
702            revocation_rate_bps: 25,
703            recent_receipts_volume: 1_000,
704            issued_at,
705            expires_at: issued_at + 600,
706        };
707        SignedListingPricingHint::sign(body, operator_keypair).expect("sign hint")
708    }
709
710    #[test]
711    fn search_filters_by_scope_prefix_and_price_ceiling() {
712        let registry_keypair = Keypair::generate();
713        let listing_cheap = sample_listing(
714            &registry_keypair,
715            "listing-cheap",
716            GenericListingStatus::Active,
717        );
718        let listing_pricey = sample_listing(
719            &registry_keypair,
720            "listing-pricey",
721            GenericListingStatus::Active,
722        );
723        let listing_other_scope = sample_listing(
724            &registry_keypair,
725            "listing-offscope",
726            GenericListingStatus::Active,
727        );
728        let report = sample_report(
729            &registry_keypair,
730            "operator-a",
731            100,
732            vec![
733                listing_cheap.clone(),
734                listing_pricey.clone(),
735                listing_other_scope.clone(),
736            ],
737        );
738
739        let operator_keypair = Keypair::generate();
740        let hints = vec![
741            sample_pricing_hint(
742                &operator_keypair,
743                "operator-a",
744                "listing-cheap",
745                "tools:search",
746                50,
747                110,
748            ),
749            sample_pricing_hint(
750                &operator_keypair,
751                "operator-a",
752                "listing-pricey",
753                "tools:search:premium",
754                500,
755                110,
756            ),
757            sample_pricing_hint(
758                &operator_keypair,
759                "operator-a",
760                "listing-offscope",
761                "tools:write",
762                10,
763                110,
764            ),
765        ];
766
767        let query = ListingQuery {
768            capability_scope_prefix: Some("tools:search".to_string()),
769            max_price_per_call: Some(MonetaryAmount {
770                units: 100,
771                currency: "USD".to_string(),
772            }),
773            ..ListingQuery::default()
774        };
775        let response = search(&[report], &hints, &query, 120);
776
777        assert_eq!(response.result_count, 1);
778        assert_eq!(response.results[0].listing_id(), "listing-cheap");
779        assert_eq!(response.results[0].price_per_call().units, 50);
780    }
781
782    #[test]
783    fn search_rejects_non_active_listings_and_missing_hints() {
784        let registry_keypair = Keypair::generate();
785        let revoked = sample_listing(
786            &registry_keypair,
787            "listing-revoked",
788            GenericListingStatus::Revoked,
789        );
790        let active_no_hint = sample_listing(
791            &registry_keypair,
792            "listing-no-hint",
793            GenericListingStatus::Active,
794        );
795        let report = sample_report(
796            &registry_keypair,
797            "operator-a",
798            100,
799            vec![revoked, active_no_hint],
800        );
801        let response = search(&[report], &[], &ListingQuery::default(), 120);
802        assert_eq!(response.result_count, 0);
803    }
804
805    #[test]
806    fn search_fails_closed_on_tampered_pricing_hint_signature() {
807        let registry_keypair = Keypair::generate();
808        let listing = sample_listing(&registry_keypair, "listing-1", GenericListingStatus::Active);
809        let report = sample_report(&registry_keypair, "operator-a", 100, vec![listing]);
810
811        let operator_keypair = Keypair::generate();
812        let mut hint = sample_pricing_hint(
813            &operator_keypair,
814            "operator-a",
815            "listing-1",
816            "tools:search",
817            10,
818            110,
819        );
820        // Tamper: mutate body after signing.
821        hint.body.price_per_call.units = 1;
822
823        let response = search(&[report], &[hint], &ListingQuery::default(), 120);
824        assert_eq!(response.result_count, 0);
825        assert!(response
826            .errors
827            .iter()
828            .any(|error| error.error.contains("signature is invalid")));
829    }
830
831    #[test]
832    fn search_rejects_stale_pricing_hint() {
833        let registry_keypair = Keypair::generate();
834        let listing = sample_listing(&registry_keypair, "listing-1", GenericListingStatus::Active);
835        let report = sample_report(&registry_keypair, "operator-a", 100, vec![listing]);
836
837        let operator_keypair = Keypair::generate();
838        // Hint expires at 710.
839        let stale = sample_pricing_hint(
840            &operator_keypair,
841            "operator-a",
842            "listing-1",
843            "tools:search",
844            10,
845            110,
846        );
847
848        let response = search(&[report], &[stale], &ListingQuery::default(), 2_000);
849        assert_eq!(response.result_count, 0);
850    }
851
852    #[test]
853    fn compare_normalizes_prices_within_currency() {
854        let registry_keypair = Keypair::generate();
855        let listing_a =
856            sample_listing(&registry_keypair, "listing-a", GenericListingStatus::Active);
857        let listing_b =
858            sample_listing(&registry_keypair, "listing-b", GenericListingStatus::Active);
859        let report = sample_report(
860            &registry_keypair,
861            "operator-a",
862            100,
863            vec![listing_a, listing_b],
864        );
865
866        let operator_keypair = Keypair::generate();
867        let hints = vec![
868            sample_pricing_hint(
869                &operator_keypair,
870                "operator-a",
871                "listing-a",
872                "tools:search",
873                100,
874                110,
875            ),
876            sample_pricing_hint(
877                &operator_keypair,
878                "operator-a",
879                "listing-b",
880                "tools:search",
881                200,
882                110,
883            ),
884        ];
885        let response = search(&[report], &hints, &ListingQuery::default(), 120);
886        let comparison = compare(&response.results);
887        assert_eq!(comparison.entry_count, 2);
888        assert!(comparison.currency_consistent);
889        // listing-a is cheapest; its price_index should be 10_000 (1.0x).
890        let row_a = comparison
891            .rows
892            .iter()
893            .find(|row| row.listing_id == "listing-a")
894            .expect("row a present");
895        let row_b = comparison
896            .rows
897            .iter()
898            .find(|row| row.listing_id == "listing-b")
899            .expect("row b present");
900        assert_eq!(row_a.price_index_bps, 10_000);
901        assert_eq!(row_b.price_index_bps, 20_000);
902    }
903
904    #[test]
905    fn compare_flags_currency_inconsistency() {
906        let registry_keypair = Keypair::generate();
907        let listing = sample_listing(&registry_keypair, "listing-a", GenericListingStatus::Active);
908        let operator_keypair = Keypair::generate();
909
910        let hint_usd = SignedListingPricingHint::sign(
911            ListingPricingHint {
912                schema: LISTING_PRICING_HINT_SCHEMA.to_string(),
913                listing_id: "listing-a".to_string(),
914                namespace: "https://registry.chio.example".to_string(),
915                provider_operator_id: "operator-a".to_string(),
916                capability_scope: "tools:search".to_string(),
917                price_per_call: MonetaryAmount {
918                    units: 100,
919                    currency: "USD".to_string(),
920                },
921                sla: ListingSla {
922                    max_latency_ms: 500,
923                    availability_bps: 9_990,
924                    throughput_rps: 10,
925                },
926                revocation_rate_bps: 0,
927                recent_receipts_volume: 10,
928                issued_at: 100,
929                expires_at: 500,
930            },
931            &operator_keypair,
932        )
933        .expect("sign usd");
934        let hint_eur = SignedListingPricingHint::sign(
935            ListingPricingHint {
936                schema: LISTING_PRICING_HINT_SCHEMA.to_string(),
937                listing_id: "listing-b".to_string(),
938                namespace: "https://registry.chio.example".to_string(),
939                provider_operator_id: "operator-a".to_string(),
940                capability_scope: "tools:search".to_string(),
941                price_per_call: MonetaryAmount {
942                    units: 80,
943                    currency: "EUR".to_string(),
944                },
945                sla: ListingSla {
946                    max_latency_ms: 500,
947                    availability_bps: 9_990,
948                    throughput_rps: 10,
949                },
950                revocation_rate_bps: 0,
951                recent_receipts_volume: 10,
952                issued_at: 100,
953                expires_at: 500,
954            },
955            &operator_keypair,
956        )
957        .expect("sign eur");
958
959        let listings = vec![
960            Listing {
961                rank: 1,
962                listing: listing.clone(),
963                pricing: hint_usd,
964                publisher: sample_publisher("operator-a"),
965                freshness: GenericListingReplicaFreshness {
966                    state: GenericListingFreshnessState::Fresh,
967                    age_secs: 10,
968                    max_age_secs: 300,
969                    valid_until: 400,
970                    generated_at: 100,
971                },
972            },
973            Listing {
974                rank: 2,
975                listing,
976                pricing: hint_eur,
977                publisher: sample_publisher("operator-a"),
978                freshness: GenericListingReplicaFreshness {
979                    state: GenericListingFreshnessState::Fresh,
980                    age_secs: 10,
981                    max_age_secs: 300,
982                    valid_until: 400,
983                    generated_at: 100,
984                },
985            },
986        ];
987        let comparison = compare(&listings);
988        assert!(!comparison.currency_consistent);
989    }
990}