app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

projection.rs (16134B)


      1 //! Immutable public availability evidence and retained head views.
      2 //!
      3 //! Shared verification, tolerant admission and deletion evaluation own protocol
      4 //! meaning. These views establish no physical stock, publisher ownership or
      5 //! network authority. Original JSON extras remain unauthenticated observations.
      6 
      7 use std::fmt;
      8 
      9 use radroots_event::envelope::EventTimestamp;
     10 use radroots_event::envelope::event_head::{
     11     CurrentEventHead, EventHeadCandidateResult, EventHeadCoordinate,
     12     event_head_candidate_for_nip01_event,
     13 };
     14 use radroots_event::envelope::kind::KIND_CLASSIFIED_LISTING;
     15 use radroots_event::id::{RADROOTS_NIP01_COORDINATE_MAX_BYTES, RelayUrl};
     16 use radroots_event::listing::classified::ClassifiedListingPartition;
     17 use radroots_event::wire::{DEFAULT_RAW_JSON_MAX_BYTES, Nip01EventWire};
     18 use radroots_event_codec::admission::deletion::{
     19     RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09SuppressionDecision,
     20     RadrootsNip09SuppressionOutcome, evaluate_nip09_suppression_from_borrowed_requests_v1,
     21 };
     22 use radroots_event_codec::admission::food_availability::{
     23     RadrootsFoodAvailabilityAdmissionError, RadrootsFoodAvailabilityAdmissionOutcome,
     24     admit_verified_food_availability_event,
     25 };
     26 use radroots_event_codec::decode::food_availability::RadrootsInboundFoodAvailabilityProjection;
     27 use radroots_event_codec::verify::RadrootsSignatureVerifiedEvent;
     28 
     29 use crate::{
     30     Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, SafeMessage,
     31     UnixTimestamp,
     32 };
     33 
     34 use super::{AvailabilityEventVersion, AvailabilityListingCoordinate, PublicPublisher};
     35 
     36 const MAX_OBSERVATION_SOURCE_BYTES: usize = 2048;
     37 const MAX_DELETION_REQUESTS: usize = 4096;
     38 const LISTING_COORDINATE_FRAMING_BYTES: usize = "30402:".len() + 64 + 1;
     39 
     40 /// A bounded, exact source reference and local observation time.
     41 ///
     42 /// The source records provenance only. Pure shared URL validation neither
     43 /// normalizes endpoint aliases nor authorizes a destination or connection.
     44 #[derive(Clone, Eq, PartialEq)]
     45 pub struct AvailabilityObservation {
     46     source: RelayUrl,
     47     observed_at: UnixTimestamp,
     48 }
     49 
     50 impl AvailabilityObservation {
     51     /// Parses a pure source reference after checking its UTF-8 byte bound.
     52     ///
     53     /// # Errors
     54     ///
     55     /// Returns a static safe error for an oversized or invalid source reference.
     56     pub fn parse(source: &str, observed_at: UnixTimestamp) -> Result<Self, SafeError> {
     57         if source.len() > MAX_OBSERVATION_SOURCE_BYTES {
     58             return Err(invalid_view());
     59         }
     60         let source = RelayUrl::parse(source).map_err(|_| invalid_view())?;
     61         Ok(Self {
     62             source,
     63             observed_at,
     64         })
     65     }
     66 
     67     #[must_use]
     68     pub const fn source(&self) -> &RelayUrl {
     69         &self.source
     70     }
     71 
     72     #[must_use]
     73     pub const fn observed_at(&self) -> UnixTimestamp {
     74         self.observed_at
     75     }
     76 }
     77 
     78 impl fmt::Debug for AvailabilityObservation {
     79     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     80         formatter
     81             .debug_struct("AvailabilityObservation")
     82             .field("source_bytes", &self.source.as_str().len())
     83             .field("observed_at", &self.observed_at)
     84             .finish()
     85     }
     86 }
     87 
     88 /// The shared reason a verified listing cannot provide focused Food data.
     89 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     90 pub enum AvailabilityUnsupportedReason {
     91     Excluded(ClassifiedListingPartition),
     92     ProjectionRejected(&'static str),
     93 }
     94 
     95 #[derive(Clone, Eq, PartialEq)]
     96 enum AvailabilityProjection {
     97     Focused(Box<RadrootsInboundFoodAvailabilityProjection>),
     98     Unsupported(AvailabilityUnsupportedReason),
     99 }
    100 
    101 /// One exact verified version, its original wire and tolerant admission result.
    102 ///
    103 /// The raw head coordinate survives independently of the narrower app reference.
    104 /// Profile metadata, when present, is caller-supplied author-associated display
    105 /// data; it carries no signature typestate or farm-ownership proof.
    106 #[derive(Clone, Eq, PartialEq)]
    107 pub struct AvailabilityVersionView {
    108     verified_event: RadrootsSignatureVerifiedEvent,
    109     original_json: String,
    110     raw_coordinate: EventHeadCoordinate,
    111     listing_coordinate: Option<AvailabilityListingCoordinate>,
    112     projection: AvailabilityProjection,
    113     observation: AvailabilityObservation,
    114     profile_metadata: Option<ProfileMetadata>,
    115 }
    116 
    117 impl AvailabilityVersionView {
    118     /// Binds shared signature evidence to the complete original wire envelope.
    119     ///
    120     /// Original formatting and unknown JSON extras are retained exactly. Extras
    121     /// are outside the signed envelope and do not gain authenticated meaning.
    122     /// Shared tolerant admission preserves valid exclusions and projection
    123     /// rejections as unsupported evidence of this version.
    124     ///
    125     /// # Errors
    126     ///
    127     /// Returns a static safe error for wire limits, structural errors, a wire
    128     /// mismatch, an unsupported kind or an unavailable shared admission result.
    129     pub fn from_verified(
    130         verified_event: RadrootsSignatureVerifiedEvent,
    131         original_json: &str,
    132         observation: AvailabilityObservation,
    133     ) -> Result<Self, SafeError> {
    134         if original_json.len() > DEFAULT_RAW_JSON_MAX_BYTES
    135             || verified_event.event().kind_u32() != KIND_CLASSIFIED_LISTING
    136         {
    137             return Err(invalid_view());
    138         }
    139         let envelope = Nip01EventWire::parse_json_unverified(original_json)
    140             .map_err(|_| invalid_view())?
    141             .into_unverified_envelope()
    142             .map_err(|_| invalid_view())?;
    143         if &envelope != verified_event.event() {
    144             return Err(invalid_view());
    145         }
    146         let raw_coordinate = match event_head_candidate_for_nip01_event(verified_event.event()) {
    147             EventHeadCandidateResult::Candidate(candidate) => candidate.coordinate,
    148             _ => return Err(invalid_view()),
    149         };
    150         let listing_coordinate = app_listing_coordinate(&raw_coordinate);
    151         // Full default-limit wire equality precedes this bounded evidence copy.
    152         let projection = match admit_verified_food_availability_event(verified_event.clone()) {
    153             Ok(RadrootsFoodAvailabilityAdmissionOutcome::Admitted(admitted)) => {
    154                 let (_, projection) = (*admitted).into_parts();
    155                 AvailabilityProjection::Focused(Box::new(projection))
    156             }
    157             Ok(RadrootsFoodAvailabilityAdmissionOutcome::Excluded(excluded)) => {
    158                 AvailabilityProjection::Unsupported(AvailabilityUnsupportedReason::Excluded(
    159                     excluded.partition(),
    160                 ))
    161             }
    162             Err(RadrootsFoodAvailabilityAdmissionError::Projection(error)) => {
    163                 AvailabilityProjection::Unsupported(
    164                     AvailabilityUnsupportedReason::ProjectionRejected(error.code()),
    165                 )
    166             }
    167             _ => return Err(invalid_view()),
    168         };
    169         Ok(Self {
    170             verified_event,
    171             original_json: original_json.to_owned(),
    172             raw_coordinate,
    173             listing_coordinate,
    174             projection,
    175             observation,
    176             profile_metadata: None,
    177         })
    178     }
    179 
    180     #[must_use]
    181     pub fn version(&self) -> AvailabilityEventVersion {
    182         AvailabilityEventVersion::from_canonical(*self.verified_event.event().id())
    183     }
    184 
    185     #[must_use]
    186     pub fn publisher(&self) -> PublicPublisher {
    187         PublicPublisher::from_public_key(PublicKey::from_canonical(
    188             *self.verified_event.event().author(),
    189         ))
    190     }
    191 
    192     #[must_use]
    193     pub fn created_at(&self) -> EventTimestamp {
    194         self.verified_event.event().created_at()
    195     }
    196 
    197     #[must_use]
    198     pub fn original_json(&self) -> &str {
    199         &self.original_json
    200     }
    201 
    202     #[must_use]
    203     pub const fn observation(&self) -> &AvailabilityObservation {
    204         &self.observation
    205     }
    206 
    207     #[must_use]
    208     pub const fn raw_coordinate(&self) -> &EventHeadCoordinate {
    209         &self.raw_coordinate
    210     }
    211 
    212     #[must_use]
    213     pub fn listing_coordinate(&self) -> Option<&AvailabilityListingCoordinate> {
    214         self.listing_coordinate.as_ref()
    215     }
    216 
    217     #[must_use]
    218     pub fn focused(&self) -> Option<&RadrootsInboundFoodAvailabilityProjection> {
    219         match &self.projection {
    220             AvailabilityProjection::Focused(projection) => Some(projection),
    221             AvailabilityProjection::Unsupported(_) => None,
    222         }
    223     }
    224 
    225     #[must_use]
    226     pub const fn unsupported_reason(&self) -> Option<&AvailabilityUnsupportedReason> {
    227         match &self.projection {
    228             AvailabilityProjection::Focused(_) => None,
    229             AvailabilityProjection::Unsupported(reason) => Some(reason),
    230         }
    231     }
    232 
    233     #[must_use]
    234     pub fn profile_metadata(&self) -> Option<&ProfileMetadata> {
    235         self.profile_metadata.as_ref()
    236     }
    237 
    238     /// Returns a copy with optional caller-supplied, author-associated metadata.
    239     ///
    240     /// Matching authors do not establish a verified profile or ownership.
    241     /// Rejection leaves this immutable listing view usable.
    242     ///
    243     /// # Errors
    244     ///
    245     /// Returns a static safe error when the supplied candidate author differs.
    246     pub fn with_profile(&self, profile: Option<&Kind0ProfileCandidate>) -> Result<Self, SafeError> {
    247         if profile.is_some_and(|candidate| candidate.author() != self.publisher().public_key()) {
    248             return Err(invalid_view());
    249         }
    250         let mut view = self.clone();
    251         view.profile_metadata = profile.map(|candidate| candidate.metadata().clone());
    252         Ok(view)
    253     }
    254 }
    255 
    256 impl fmt::Debug for AvailabilityVersionView {
    257     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    258         formatter
    259             .debug_struct("AvailabilityVersionView")
    260             .field("created_at", &self.created_at())
    261             .field("wire_bytes", &self.original_json.len())
    262             .field("focused", &self.focused().is_some())
    263             .field("unsupported_reason", &self.unsupported_reason())
    264             .field("profile_present", &self.profile_metadata.is_some())
    265             .field("observation", &self.observation)
    266             .finish()
    267     }
    268 }
    269 
    270 /// The retained selected head's state, without a physical-supply claim.
    271 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    272 pub enum AvailabilityHeadState {
    273     Missing,
    274     Focused,
    275     Unsupported,
    276     Deleted,
    277 }
    278 
    279 #[derive(Clone, Eq, PartialEq)]
    280 enum AvailabilityHeadEvidence {
    281     Missing(AvailabilityListingCoordinate),
    282     Selected {
    283         version: Box<AvailabilityVersionView>,
    284         suppression: RadrootsNip09SuppressionDecision,
    285     },
    286 }
    287 
    288 /// Absence of retained knowledge or an exact selected version and shared decision.
    289 ///
    290 /// Deleted versions remain available as historical evidence. Neither missing
    291 /// knowledge nor listing query completion establishes exhaustive deletion data.
    292 #[derive(Clone, Eq, PartialEq)]
    293 pub struct AvailabilityHeadView {
    294     evidence: AvailabilityHeadEvidence,
    295 }
    296 
    297 impl AvailabilityHeadView {
    298     #[must_use]
    299     pub const fn missing(coordinate: AvailabilityListingCoordinate) -> Self {
    300         Self {
    301             evidence: AvailabilityHeadEvidence::Missing(coordinate),
    302         }
    303     }
    304 
    305     /// Validates exact correspondence to the supplied shared selected head and
    306     /// delegates suppression to the shared evaluator over borrowed requests.
    307     ///
    308     /// # Errors
    309     ///
    310     /// Returns a static safe error when coordinate, event ID or signed time
    311     /// differs, or more than4096 admitted deletion requests are supplied.
    312     pub fn from_selected(
    313         selected: CurrentEventHead,
    314         version: AvailabilityVersionView,
    315         requests: &[RadrootsAdmittedNip09DeletionRequestEvent],
    316     ) -> Result<Self, SafeError> {
    317         if requests.len() > MAX_DELETION_REQUESTS
    318             || &selected.coordinate != version.raw_coordinate()
    319             || selected.event_id != version.version().event_id()
    320             || selected.created_at != version.created_at().as_u64()
    321         {
    322             return Err(invalid_view());
    323         }
    324         let suppression = evaluate_nip09_suppression_from_borrowed_requests_v1(
    325             &version.verified_event,
    326             requests.iter(),
    327         );
    328         Ok(Self {
    329             evidence: AvailabilityHeadEvidence::Selected {
    330                 version: Box::new(version),
    331                 suppression,
    332             },
    333         })
    334     }
    335 
    336     #[must_use]
    337     pub fn state(&self) -> AvailabilityHeadState {
    338         match &self.evidence {
    339             AvailabilityHeadEvidence::Missing(_) => AvailabilityHeadState::Missing,
    340             AvailabilityHeadEvidence::Selected {
    341                 version,
    342                 suppression,
    343             } => {
    344                 if suppression.outcome() == RadrootsNip09SuppressionOutcome::Suppressed {
    345                     AvailabilityHeadState::Deleted
    346                 } else if version.focused().is_some() {
    347                     AvailabilityHeadState::Focused
    348                 } else {
    349                     AvailabilityHeadState::Unsupported
    350                 }
    351             }
    352         }
    353     }
    354 
    355     /// Returns the retained logical coordinate even when no head is known.
    356     ///
    357     /// Selected raw empty or overlong identifiers remain broader evidence and
    358     /// have no bounded application coordinate. Suppression is unchanged.
    359     #[must_use]
    360     pub fn listing_coordinate(&self) -> Option<&AvailabilityListingCoordinate> {
    361         match &self.evidence {
    362             AvailabilityHeadEvidence::Missing(coordinate) => Some(coordinate),
    363             AvailabilityHeadEvidence::Selected { version, .. } => version.listing_coordinate(),
    364         }
    365     }
    366 
    367     /// Returns the retained version, including historical deleted evidence.
    368     #[must_use]
    369     pub fn version(&self) -> Option<&AvailabilityVersionView> {
    370         match &self.evidence {
    371             AvailabilityHeadEvidence::Missing(_) => None,
    372             AvailabilityHeadEvidence::Selected { version, .. } => Some(version),
    373         }
    374     }
    375 
    376     #[must_use]
    377     pub const fn suppression(&self) -> Option<&RadrootsNip09SuppressionDecision> {
    378         match &self.evidence {
    379             AvailabilityHeadEvidence::Missing(_) => None,
    380             AvailabilityHeadEvidence::Selected { suppression, .. } => Some(suppression),
    381         }
    382     }
    383 
    384     /// Returns focused data only when the selected version remains visible.
    385     #[must_use]
    386     pub fn focused(&self) -> Option<&RadrootsInboundFoodAvailabilityProjection> {
    387         if self.state() == AvailabilityHeadState::Focused {
    388             self.version().and_then(AvailabilityVersionView::focused)
    389         } else {
    390             None
    391         }
    392     }
    393 }
    394 
    395 impl fmt::Debug for AvailabilityHeadView {
    396     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    397         let mut summary = formatter.debug_struct("AvailabilityHeadView");
    398         summary.field("state", &self.state());
    399         match &self.evidence {
    400             AvailabilityHeadEvidence::Missing(coordinate) => {
    401                 summary.field("coordinate_bytes", &coordinate.as_str().len());
    402             }
    403             AvailabilityHeadEvidence::Selected {
    404                 version,
    405                 suppression,
    406             } => {
    407                 summary
    408                     .field("version", version)
    409                     .field("suppression_reason", &suppression.reason());
    410             }
    411         }
    412         summary.finish()
    413     }
    414 }
    415 
    416 fn app_listing_coordinate(raw: &EventHeadCoordinate) -> Option<AvailabilityListingCoordinate> {
    417     let EventHeadCoordinate::Addressable {
    418         kind,
    419         pubkey,
    420         d_tag,
    421     } = raw
    422     else {
    423         return None;
    424     };
    425     if *kind != KIND_CLASSIFIED_LISTING
    426         || d_tag.is_empty()
    427         || d_tag.len() > RADROOTS_NIP01_COORDINATE_MAX_BYTES - LISTING_COORDINATE_FRAMING_BYTES
    428     {
    429         return None;
    430     }
    431     // Borrowed framing bounds precede author encoding and coordinate allocation.
    432     AvailabilityListingCoordinate::parse(&format!("30402:{}:{d_tag}", pubkey.to_hex())).ok()
    433 }
    434 
    435 const fn invalid_view() -> SafeError {
    436     SafeError::new(
    437         SafeErrorCode::InvalidProfileMetadata,
    438         SafeMessage::new("The public availability view is invalid."),
    439     )
    440 }