1use 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}