Skip to main content

chio_listing/
discovery.rs

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