Skip to main content

chio_listing/
trust_activation.rs

1//! Generic trust activation: admission classes, eligibility policy, activation artifacts, and evaluation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::crypto::{sha256_hex, PublicKey};
6use crate::receipt::lineage::SignedExportEnvelope;
7use crate::util::{
8    ensure_generic_listing_signed_by_namespace_owner, generic_listing_body_sha256,
9    normalize_namespace, validate_non_empty,
10};
11use crate::{
12    canonical_json_bytes, GenericListingActorKind, GenericListingFreshnessState,
13    GenericListingReplicaFreshness, GenericListingStatus, GenericRegistryPublisher,
14    GenericRegistryPublisherRole, SignedGenericListing, GENERIC_TRUST_ACTIVATION_ARTIFACT_SCHEMA,
15};
16
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
18#[serde(rename_all = "snake_case")]
19pub enum GenericTrustAdmissionClass {
20    PublicUntrusted,
21    Reviewable,
22    BondBacked,
23    RoleGated,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum GenericTrustActivationDisposition {
29    PendingReview,
30    Approved,
31    Denied,
32}
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum GenericTrustActivationFindingCode {
37    MissingActivation,
38    ListingUnverifiable,
39    ActivationUnverifiable,
40    ListingMismatch,
41    ListingStale,
42    ListingDivergent,
43    ActivationExpired,
44    ActivationPendingReview,
45    ActivationDenied,
46    AdmissionClassUntrusted,
47    ActorKindIneligible,
48    PublisherRoleIneligible,
49    ListingStatusIneligible,
50    ListingOperatorIneligible,
51    BondBackingRequired,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "camelCase")]
56pub struct GenericTrustActivationEligibility {
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub allowed_actor_kinds: Vec<GenericListingActorKind>,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub allowed_publisher_roles: Vec<GenericRegistryPublisherRole>,
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub allowed_statuses: Vec<GenericListingStatus>,
63    #[serde(default)]
64    pub require_fresh_listing: bool,
65    #[serde(default)]
66    pub require_bond_backing: bool,
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub required_listing_operator_ids: Vec<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub policy_reference: Option<String>,
71}
72
73impl GenericTrustActivationEligibility {
74    pub fn validate(&self, admission_class: GenericTrustAdmissionClass) -> Result<(), String> {
75        for (index, operator_id) in self.required_listing_operator_ids.iter().enumerate() {
76            validate_non_empty(
77                operator_id,
78                &format!("eligibility.required_listing_operator_ids[{index}]"),
79            )?;
80        }
81        if matches!(admission_class, GenericTrustAdmissionClass::RoleGated)
82            && self.required_listing_operator_ids.is_empty()
83        {
84            return Err(
85                "role_gated trust activation requires required_listing_operator_ids".to_string(),
86            );
87        }
88        if matches!(admission_class, GenericTrustAdmissionClass::BondBacked)
89            && !self.require_bond_backing
90        {
91            return Err("bond_backed trust activation must require bond backing".to_string());
92        }
93        if !matches!(admission_class, GenericTrustAdmissionClass::BondBacked)
94            && self.require_bond_backing
95        {
96            return Err(
97                "require_bond_backing is only valid for bond_backed trust activation".to_string(),
98            );
99        }
100        Ok(())
101    }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "camelCase")]
106pub struct GenericTrustActivationReviewContext {
107    pub publisher: GenericRegistryPublisher,
108    pub freshness: GenericListingReplicaFreshness,
109}
110
111impl GenericTrustActivationReviewContext {
112    pub fn validate(&self) -> Result<(), String> {
113        self.publisher.validate()?;
114        self.freshness.validate()?;
115        Ok(())
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "camelCase")]
121pub struct GenericTrustActivationArtifact {
122    pub schema: String,
123    pub activation_id: String,
124    pub local_operator_id: String,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub local_operator_name: Option<String>,
127    pub listing_id: String,
128    pub namespace: String,
129    pub listing_sha256: String,
130    pub listing_published_at: u64,
131    pub admission_class: GenericTrustAdmissionClass,
132    pub disposition: GenericTrustActivationDisposition,
133    pub eligibility: GenericTrustActivationEligibility,
134    pub review_context: GenericTrustActivationReviewContext,
135    pub requested_at: u64,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub reviewed_at: Option<u64>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub expires_at: Option<u64>,
140    pub requested_by: String,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub reviewed_by: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub note: Option<String>,
145}
146
147impl GenericTrustActivationArtifact {
148    pub fn validate(&self) -> Result<(), String> {
149        if self.schema != GENERIC_TRUST_ACTIVATION_ARTIFACT_SCHEMA {
150            return Err(format!(
151                "unsupported generic trust activation schema: {}",
152                self.schema
153            ));
154        }
155        validate_non_empty(&self.activation_id, "activation_id")?;
156        validate_non_empty(&self.local_operator_id, "local_operator_id")?;
157        validate_non_empty(&self.listing_id, "listing_id")?;
158        validate_non_empty(&self.namespace, "namespace")?;
159        validate_non_empty(&self.listing_sha256, "listing_sha256")?;
160        validate_non_empty(&self.requested_by, "requested_by")?;
161        self.eligibility.validate(self.admission_class)?;
162        self.review_context.validate()?;
163        if let Some(reviewed_at) = self.reviewed_at {
164            if reviewed_at < self.requested_at {
165                return Err("reviewed_at must be greater than or equal to requested_at".to_string());
166            }
167        }
168        if let Some(expires_at) = self.expires_at {
169            if expires_at <= self.requested_at {
170                return Err("expires_at must be greater than requested_at".to_string());
171            }
172        }
173        match self.disposition {
174            GenericTrustActivationDisposition::PendingReview => {
175                if self.reviewed_at.is_some() || self.reviewed_by.is_some() {
176                    return Err(
177                        "pending_review trust activation must not carry review completion fields"
178                            .to_string(),
179                    );
180                }
181            }
182            GenericTrustActivationDisposition::Approved
183            | GenericTrustActivationDisposition::Denied => {
184                if self.reviewed_at.is_none() || self.reviewed_by.as_deref().is_none() {
185                    return Err(
186                        "approved or denied trust activation requires reviewed_at and reviewed_by"
187                            .to_string(),
188                    );
189                }
190            }
191        }
192        Ok(())
193    }
194}
195
196pub type SignedGenericTrustActivation = SignedExportEnvelope<GenericTrustActivationArtifact>;
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
199#[serde(rename_all = "camelCase")]
200pub struct GenericTrustActivationIssueRequest {
201    pub listing: SignedGenericListing,
202    pub admission_class: GenericTrustAdmissionClass,
203    pub disposition: GenericTrustActivationDisposition,
204    pub eligibility: GenericTrustActivationEligibility,
205    pub review_context: GenericTrustActivationReviewContext,
206    pub requested_by: String,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub reviewed_by: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub requested_at: Option<u64>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub reviewed_at: Option<u64>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub expires_at: Option<u64>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub note: Option<String>,
217}
218
219impl GenericTrustActivationIssueRequest {
220    pub fn validate(&self) -> Result<(), String> {
221        ensure_generic_listing_signed_by_namespace_owner(
222            &self.listing,
223            "trust activation listing",
224        )?;
225        self.review_context.validate()?;
226        self.eligibility.validate(self.admission_class)?;
227        validate_non_empty(&self.requested_by, "requested_by")?;
228        if matches!(
229            self.disposition,
230            GenericTrustActivationDisposition::Approved
231        ) && self.review_context.freshness.state != GenericListingFreshnessState::Fresh
232        {
233            return Err(
234                "approved trust activation requires fresh listing review context".to_string(),
235            );
236        }
237        Ok(())
238    }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242#[serde(rename_all = "camelCase")]
243pub struct GenericTrustActivationEvaluationRequest {
244    pub listing: SignedGenericListing,
245    pub current_publisher: GenericRegistryPublisher,
246    pub current_freshness: GenericListingReplicaFreshness,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub activation: Option<SignedGenericTrustActivation>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub evaluated_at: Option<u64>,
251}
252
253impl GenericTrustActivationEvaluationRequest {
254    pub fn validate(&self) -> Result<(), String> {
255        self.listing.body.validate()?;
256        self.current_publisher.validate()?;
257        self.current_freshness.validate()?;
258        Ok(())
259    }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "camelCase")]
264pub struct GenericTrustActivationFinding {
265    pub code: GenericTrustActivationFindingCode,
266    pub message: String,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(rename_all = "camelCase")]
271pub struct GenericTrustActivationEvaluation {
272    pub listing_id: String,
273    pub namespace: String,
274    pub evaluated_at: u64,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub local_operator_id: Option<String>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub admission_class: Option<GenericTrustAdmissionClass>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub disposition: Option<GenericTrustActivationDisposition>,
281    pub admitted: bool,
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    pub findings: Vec<GenericTrustActivationFinding>,
284}
285
286pub fn build_generic_trust_activation_artifact(
287    local_operator_id: &str,
288    local_operator_name: Option<String>,
289    request: &GenericTrustActivationIssueRequest,
290    issued_at: u64,
291) -> Result<GenericTrustActivationArtifact, String> {
292    request.validate()?;
293    validate_non_empty(local_operator_id, "local_operator_id")?;
294    let requested_at = request.requested_at.unwrap_or(issued_at);
295    let reviewed_at = request.reviewed_at.or(match request.disposition {
296        GenericTrustActivationDisposition::PendingReview => None,
297        GenericTrustActivationDisposition::Approved | GenericTrustActivationDisposition::Denied => {
298            Some(issued_at)
299        }
300    });
301    let listing_sha256 = generic_listing_body_sha256(&request.listing)?;
302    let activation_id = format!(
303        "activation-{}",
304        sha256_hex(
305            &canonical_json_bytes(&(
306                local_operator_id,
307                &request.listing.body.listing_id,
308                &listing_sha256,
309                request.admission_class,
310                request.disposition,
311                requested_at,
312            ))
313            .map_err(|error| error.to_string())?
314        )
315    );
316    let artifact = GenericTrustActivationArtifact {
317        schema: GENERIC_TRUST_ACTIVATION_ARTIFACT_SCHEMA.to_string(),
318        activation_id,
319        local_operator_id: local_operator_id.to_string(),
320        local_operator_name,
321        listing_id: request.listing.body.listing_id.clone(),
322        namespace: request.listing.body.namespace.clone(),
323        listing_sha256,
324        listing_published_at: request.listing.body.published_at,
325        admission_class: request.admission_class,
326        disposition: request.disposition,
327        eligibility: request.eligibility.clone(),
328        review_context: request.review_context.clone(),
329        requested_at,
330        reviewed_at,
331        expires_at: request.expires_at,
332        requested_by: request.requested_by.clone(),
333        reviewed_by: request.reviewed_by.clone(),
334        note: request.note.clone(),
335    };
336    artifact.validate()?;
337    Ok(artifact)
338}
339
340pub fn evaluate_generic_trust_activation(
341    request: &GenericTrustActivationEvaluationRequest,
342    now: u64,
343    trusted_local_operator_signer: &PublicKey,
344) -> Result<GenericTrustActivationEvaluation, String> {
345    request.validate()?;
346    let mut evaluation = GenericTrustActivationEvaluation {
347        listing_id: request.listing.body.listing_id.clone(),
348        namespace: request.listing.body.namespace.clone(),
349        evaluated_at: request.evaluated_at.unwrap_or(now),
350        local_operator_id: None,
351        admission_class: None,
352        disposition: None,
353        admitted: false,
354        findings: Vec::new(),
355    };
356
357    if let Err(error) = ensure_generic_listing_signed_by_namespace_owner(
358        &request.listing,
359        "trust activation listing",
360    ) {
361        evaluation.findings.push(GenericTrustActivationFinding {
362            code: GenericTrustActivationFindingCode::ListingUnverifiable,
363            message: error,
364        });
365        return Ok(evaluation);
366    }
367
368    let Some(activation) = request.activation.as_ref() else {
369        evaluation.findings.push(GenericTrustActivationFinding {
370            code: GenericTrustActivationFindingCode::MissingActivation,
371            message: "listing visibility requires an explicit local trust activation artifact"
372                .to_string(),
373        });
374        return Ok(evaluation);
375    };
376
377    if !activation
378        .verify_signature()
379        .map_err(|error| error.to_string())?
380    {
381        evaluation.findings.push(GenericTrustActivationFinding {
382            code: GenericTrustActivationFindingCode::ActivationUnverifiable,
383            message: "trust activation signature is invalid".to_string(),
384        });
385        return Ok(evaluation);
386    }
387    if activation.signer_key != *trusted_local_operator_signer {
388        evaluation.findings.push(GenericTrustActivationFinding {
389            code: GenericTrustActivationFindingCode::ActivationUnverifiable,
390            message: "trust activation signer is not trusted by this local operator".to_string(),
391        });
392        return Ok(evaluation);
393    }
394
395    if let Err(error) = activation.body.validate() {
396        evaluation.findings.push(GenericTrustActivationFinding {
397            code: GenericTrustActivationFindingCode::ActivationUnverifiable,
398            message: error,
399        });
400        return Ok(evaluation);
401    }
402
403    evaluation.local_operator_id = Some(activation.body.local_operator_id.clone());
404    evaluation.admission_class = Some(activation.body.admission_class);
405    evaluation.disposition = Some(activation.body.disposition);
406
407    let listing_sha256 = generic_listing_body_sha256(&request.listing)?;
408    if activation.body.listing_id != request.listing.body.listing_id
409        || normalize_namespace(&activation.body.namespace)
410            != normalize_namespace(&request.listing.body.namespace)
411        || activation.body.listing_sha256 != listing_sha256
412        || activation.body.listing_published_at != request.listing.body.published_at
413    {
414        evaluation.findings.push(GenericTrustActivationFinding {
415            code: GenericTrustActivationFindingCode::ListingMismatch,
416            message:
417                "trust activation does not match the current listing identity, namespace, or body hash"
418                    .to_string(),
419        });
420        return Ok(evaluation);
421    }
422
423    match request.current_freshness.state {
424        GenericListingFreshnessState::Stale => {
425            evaluation.findings.push(GenericTrustActivationFinding {
426                code: GenericTrustActivationFindingCode::ListingStale,
427                message:
428                    "current listing report is stale and cannot be activated for runtime trust"
429                        .to_string(),
430            });
431            return Ok(evaluation);
432        }
433        GenericListingFreshnessState::Divergent => {
434            evaluation.findings.push(GenericTrustActivationFinding {
435                code: GenericTrustActivationFindingCode::ListingDivergent,
436                message:
437                    "current listing report is divergent and cannot be activated for runtime trust"
438                        .to_string(),
439            });
440            return Ok(evaluation);
441        }
442        GenericListingFreshnessState::Fresh => {}
443    }
444
445    if activation
446        .body
447        .expires_at
448        .is_some_and(|expires_at| expires_at <= evaluation.evaluated_at)
449    {
450        evaluation.findings.push(GenericTrustActivationFinding {
451            code: GenericTrustActivationFindingCode::ActivationExpired,
452            message: "trust activation has expired".to_string(),
453        });
454        return Ok(evaluation);
455    }
456
457    match activation.body.disposition {
458        GenericTrustActivationDisposition::PendingReview => {
459            evaluation.findings.push(GenericTrustActivationFinding {
460                code: GenericTrustActivationFindingCode::ActivationPendingReview,
461                message: "trust activation remains pending review".to_string(),
462            });
463            return Ok(evaluation);
464        }
465        GenericTrustActivationDisposition::Denied => {
466            evaluation.findings.push(GenericTrustActivationFinding {
467                code: GenericTrustActivationFindingCode::ActivationDenied,
468                message: "trust activation was explicitly denied".to_string(),
469            });
470            return Ok(evaluation);
471        }
472        GenericTrustActivationDisposition::Approved => {}
473    }
474
475    if activation.body.eligibility.require_fresh_listing
476        && request.current_freshness.state != GenericListingFreshnessState::Fresh
477    {
478        evaluation.findings.push(GenericTrustActivationFinding {
479            code: GenericTrustActivationFindingCode::ListingStale,
480            message: "trust activation requires fresh listing evidence".to_string(),
481        });
482        return Ok(evaluation);
483    }
484
485    if !activation.body.eligibility.allowed_actor_kinds.is_empty()
486        && !activation
487            .body
488            .eligibility
489            .allowed_actor_kinds
490            .contains(&request.listing.body.subject.actor_kind)
491    {
492        evaluation.findings.push(GenericTrustActivationFinding {
493            code: GenericTrustActivationFindingCode::ActorKindIneligible,
494            message: "listing actor kind is not eligible under the activation policy".to_string(),
495        });
496        return Ok(evaluation);
497    }
498
499    if !activation
500        .body
501        .eligibility
502        .allowed_publisher_roles
503        .is_empty()
504        && !activation
505            .body
506            .eligibility
507            .allowed_publisher_roles
508            .contains(&request.current_publisher.role)
509    {
510        evaluation.findings.push(GenericTrustActivationFinding {
511            code: GenericTrustActivationFindingCode::PublisherRoleIneligible,
512            message: "listing publisher role is not eligible under the activation policy"
513                .to_string(),
514        });
515        return Ok(evaluation);
516    }
517
518    if !activation.body.eligibility.allowed_statuses.is_empty()
519        && !activation
520            .body
521            .eligibility
522            .allowed_statuses
523            .contains(&request.listing.body.status)
524    {
525        evaluation.findings.push(GenericTrustActivationFinding {
526            code: GenericTrustActivationFindingCode::ListingStatusIneligible,
527            message: "listing lifecycle status is not eligible under the activation policy"
528                .to_string(),
529        });
530        return Ok(evaluation);
531    }
532
533    if !activation
534        .body
535        .eligibility
536        .required_listing_operator_ids
537        .is_empty()
538        && !activation
539            .body
540            .eligibility
541            .required_listing_operator_ids
542            .contains(&request.current_publisher.operator_id)
543    {
544        evaluation.findings.push(GenericTrustActivationFinding {
545            code: GenericTrustActivationFindingCode::ListingOperatorIneligible,
546            message: "listing operator is not eligible under the activation policy".to_string(),
547        });
548        return Ok(evaluation);
549    }
550
551    if matches!(
552        activation.body.admission_class,
553        GenericTrustAdmissionClass::PublicUntrusted
554    ) {
555        evaluation.findings.push(GenericTrustActivationFinding {
556            code: GenericTrustActivationFindingCode::AdmissionClassUntrusted,
557            message: "public_untrusted admission class preserves visibility without runtime trust"
558                .to_string(),
559        });
560        return Ok(evaluation);
561    }
562
563    if activation.body.eligibility.require_bond_backing {
564        evaluation.findings.push(GenericTrustActivationFinding {
565            code: GenericTrustActivationFindingCode::BondBackingRequired,
566            message:
567                "bond_backed activation remains review-visible only until bond backing is proven"
568                    .to_string(),
569        });
570        return Ok(evaluation);
571    }
572
573    evaluation.admitted = true;
574    Ok(evaluation)
575}