Skip to main content

domain/
source.rs

1//! Source-system provenance and record references for app-owned operational facts.
2//!
3//! Provenance travels with facts so an agent draft can cite the app-owned source evidence it used:
4//!
5//! ```
6//! use domain::source;
7//!
8//! let provenance = source::Provenance::builder()
9//!     .system(source::System::Gingr)
10//!     .endpoint(source::Endpoint::try_new("/reservations").unwrap())
11//!     .record_id(source::record::Id::try_new("reservation-123").unwrap())
12//!     .extraction_batch(source::ExtractionBatchId::try_new("batch-2026-06-18").unwrap())
13//!     .pulled_at(source::Timestamp::try_new("2026-06-18T13:00:00Z").unwrap())
14//!     .request_scope(source::RequestScope::try_new("manager-daily-brief:loc-1").unwrap())
15//!     .schema_version(source::SchemaVersion::try_new("gingr-reservations-v1").unwrap())
16//!     .payload_hash(source::PayloadHash::try_new("sha256:fixture").unwrap())
17//!     .raw_payload_ref(source::RawPayloadRef::try_new("minio://fixtures/reservation-123.json").unwrap())
18//!     .build();
19//!
20//! let record_ref = source::RecordRef::from_provenance(&provenance);
21//! assert_eq!(record_ref.system(), source::System::Gingr);
22//! assert_eq!(record_ref.record_id().as_str(), "reservation-123");
23//! ```
24
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29/// Upstream systems that can supply operational, POS, labor, or import data.
30pub enum System {
31    /// Gingr reservation and pet-care operating system.
32    Gingr,
33    /// Reporting or BI data source.
34    BusinessIntelligence,
35    /// Labor scheduling source for staffing plans.
36    LaborScheduling,
37    /// Timeclock source for worked-hour data.
38    Timeclock,
39    /// Payroll source for labor-cost reconciliation.
40    Payroll,
41    /// Capacity inventory source for available accommodation counts.
42    CapacityInventory,
43    /// Point-of-sale source for retail and payment activity.
44    PointOfSale,
45    /// Manually supplied import data.
46    ManualImport,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50/// UTC instant reported by an upstream system for source-data lineage.
51pub struct Timestamp(DateTime<Utc>);
52
53impl Timestamp {
54    /// Promotes non-empty provider or import text into a source-lineage value.
55    pub fn try_new(value: impl AsRef<str>) -> Result<Self> {
56        let value = value.as_ref().trim();
57        if value.is_empty() {
58            return Err(Error::EmptyTimestamp);
59        }
60        let parsed = value
61            .parse::<DateTime<Utc>>()
62            .map_err(|_| Error::InvalidTimestamp)?;
63        Ok(Self(parsed))
64    }
65
66    /// Exposes the validated scalar for serialization and adapter boundaries.
67    pub const fn get(&self) -> &DateTime<Utc> {
68        &self.0
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
73/// Provider API endpoint or import route that produced source data.
74pub struct Endpoint(String);
75
76impl Endpoint {
77    /// Promotes non-empty provider or import text into a source-lineage value.
78    pub fn try_new(value: impl Into<String>) -> Result<Self> {
79        trimmed_non_empty(value, Error::EmptyEndpoint).map(Self)
80    }
81
82    /// Returns the provider or domain identifier as a string slice.
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
89/// Identifier that groups records from the same provider extraction run.
90pub struct ExtractionBatchId(String);
91
92impl ExtractionBatchId {
93    /// Promotes non-empty provider or import text into a source-lineage value.
94    pub fn try_new(value: impl Into<String>) -> Result<Self> {
95        trimmed_non_empty(value, Error::EmptyExtractionBatch).map(Self)
96    }
97
98    /// Returns the provider or domain identifier as a string slice.
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
105/// Import or API scope requested from the provider during extraction.
106pub struct RequestScope(String);
107
108impl RequestScope {
109    /// Promotes non-empty provider or import text into a source-lineage value.
110    pub fn try_new(value: impl Into<String>) -> Result<Self> {
111        trimmed_non_empty(value, Error::EmptyRequestScope).map(Self)
112    }
113
114    /// Returns the provider or domain identifier as a string slice.
115    pub fn as_str(&self) -> &str {
116        &self.0
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
121/// Version tag for the source payload schema used during mapping.
122pub struct SchemaVersion(String);
123
124impl SchemaVersion {
125    /// Promotes non-empty provider or import text into a source-lineage value.
126    pub fn try_new(value: impl Into<String>) -> Result<Self> {
127        trimmed_non_empty(value, Error::EmptySchemaVersion).map(Self)
128    }
129
130    /// Returns the provider or domain identifier as a string slice.
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
137/// Hash of the provider payload used for idempotency and drift checks.
138pub struct PayloadHash(String);
139
140impl PayloadHash {
141    /// Promotes non-empty provider or import text into a source-lineage value.
142    pub fn try_new(value: impl Into<String>) -> Result<Self> {
143        trimmed_non_empty(value, Error::EmptyPayloadHash).map(Self)
144    }
145
146    /// Returns the provider or domain identifier as a string slice.
147    pub fn as_str(&self) -> &str {
148        &self.0
149    }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
153/// Storage reference for the unnormalized provider payload.
154pub struct RawPayloadRef(String);
155
156impl RawPayloadRef {
157    /// Promotes non-empty provider or import text into a source-lineage value.
158    pub fn try_new(value: impl Into<String>) -> Result<Self> {
159        trimmed_non_empty(value, Error::EmptyRawPayloadRef).map(Self)
160    }
161
162    /// Returns the provider or domain identifier as a string slice.
163    pub fn as_str(&self) -> &str {
164        &self.0
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
169/// Status text observed directly from the provider before normalization.
170pub struct ObservedStatus(String);
171
172impl ObservedStatus {
173    /// Promotes non-empty provider or import text into a source-lineage value.
174    pub fn try_new(value: impl Into<String>) -> Result<Self> {
175        trimmed_non_empty(value, Error::EmptyObservedStatus).map(Self)
176    }
177
178    /// Returns the provider or domain identifier as a string slice.
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182}
183
184/// Source-record identity and relationship vocabulary used for provenance joins.
185pub mod record {
186    use serde::{Deserialize, Serialize};
187
188    use crate::source;
189
190    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
191    /// Provider or source identifier retained as the stable join key.
192    pub struct Id(String);
193
194    impl Id {
195        /// Promotes non-empty provider or import text into a source-lineage value.
196        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
197            source::trimmed_non_empty(value, source::Error::EmptyRecordId).map(Self)
198        }
199
200        /// Returns the provider or domain identifier as a string slice.
201        pub fn as_str(&self) -> &str {
202            &self.0
203        }
204    }
205
206    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207    /// Kinds of related records that may be attached to source-data lineage.
208    pub enum Role {
209        /// Customer record participating in the workflow.
210        Customer,
211        /// Pet record participating in the workflow.
212        Pet,
213        /// Resort location record participating in the workflow.
214        Location,
215        /// Reservation type source-data role, provider status, or explicit normalization assumption.
216        ReservationType,
217        /// Invoice source-data role, provider status, or explicit normalization assumption.
218        Invoice,
219        /// Payment source-data role, provider status, or explicit normalization assumption.
220        Payment,
221        /// Service source-data role, provider status, or explicit normalization assumption.
222        Service,
223        /// Staff source-data role, provider status, or explicit normalization assumption.
224        Staff,
225        /// Provider role or status could not be mapped confidently.
226        Unknown,
227    }
228
229    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230    /// Link from a source record to another related provider record.
231    pub struct RelatedId {
232        role: Role,
233        id: Id,
234    }
235
236    impl RelatedId {
237        /// Assembles source-lineage data from already validated domain parts without reinterpreting authority.
238        pub const fn new(role: Role, id: Id) -> Self {
239            Self { role, id }
240        }
241
242        /// Returns the role evidence carried by this source-lineage value.
243        pub const fn role(&self) -> Role {
244            self.role
245        }
246
247        /// Returns the id evidence carried by this source-lineage value.
248        pub const fn id(&self) -> &Id {
249            &self.id
250        }
251    }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255/// Stable pointer to an upstream record and the system that owns it.
256pub struct RecordRef {
257    system: System,
258    record_id: record::Id,
259}
260
261impl RecordRef {
262    /// Assembles source-lineage data from already validated domain parts without reinterpreting authority.
263    pub const fn new(system: System, record_id: record::Id) -> Self {
264        Self { system, record_id }
265    }
266
267    /// Builds this source value from provenance data.
268    pub fn from_provenance(provenance: &Provenance) -> Self {
269        Self::new(provenance.system(), provenance.record_id().clone())
270    }
271
272    /// Returns the system evidence carried by this source-lineage value.
273    pub const fn system(&self) -> System {
274        self.system
275    }
276
277    /// Returns the record id evidence carried by this source-lineage value.
278    pub const fn record_id(&self) -> &record::Id {
279        &self.record_id
280    }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
284/// Lineage metadata that ties normalized data back to its provider record.
285pub struct Provenance {
286    system: System,
287    endpoint: Endpoint,
288    record_id: record::Id,
289    #[builder(default)]
290    related_record_ids: Vec<record::RelatedId>,
291    extraction_batch: ExtractionBatchId,
292    pulled_at: Timestamp,
293    request_scope: RequestScope,
294    schema_version: SchemaVersion,
295    payload_hash: PayloadHash,
296    raw_payload_ref: RawPayloadRef,
297}
298
299impl Provenance {
300    /// Returns the system evidence carried by this source-lineage value.
301    pub const fn system(&self) -> System {
302        self.system
303    }
304
305    /// Returns the source system evidence carried by this source-lineage value.
306    pub const fn source_system(&self) -> System {
307        self.system
308    }
309
310    /// Returns the endpoint evidence carried by this source-lineage value.
311    pub const fn endpoint(&self) -> &Endpoint {
312        &self.endpoint
313    }
314
315    /// Returns the record id evidence carried by this source-lineage value.
316    pub const fn record_id(&self) -> &record::Id {
317        &self.record_id
318    }
319
320    /// Returns the related record ids evidence carried by this source snapshot.
321    pub fn related_record_ids(&self) -> &[record::RelatedId] {
322        &self.related_record_ids
323    }
324
325    /// Returns the extraction batch evidence carried by this source-lineage value.
326    pub const fn extraction_batch(&self) -> &ExtractionBatchId {
327        &self.extraction_batch
328    }
329
330    /// Returns the pulled at evidence carried by this source-lineage value.
331    pub const fn pulled_at(&self) -> &Timestamp {
332        &self.pulled_at
333    }
334
335    /// Returns the request scope evidence carried by this source-lineage value.
336    pub const fn request_scope(&self) -> &RequestScope {
337        &self.request_scope
338    }
339
340    /// Returns the schema version evidence carried by this source-lineage value.
341    pub const fn schema_version(&self) -> &SchemaVersion {
342        &self.schema_version
343    }
344
345    /// Returns the payload hash evidence carried by this source-lineage value.
346    pub const fn payload_hash(&self) -> &PayloadHash {
347        &self.payload_hash
348    }
349
350    /// Returns the raw payload ref evidence carried by this source-lineage value.
351    pub const fn raw_payload_ref(&self) -> &RawPayloadRef {
352        &self.raw_payload_ref
353    }
354}
355
356/// Reservation boundary for source contracts.
357pub mod reservation {
358    use serde::{Deserialize, Serialize};
359
360    use crate::{data_quality, source};
361
362    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363    /// Confidence in the provider relationship between an owner and pet.
364    pub enum OwnerPetRelationship {
365        /// Owner-pet relationship was matched to a single confident record.
366        Resolved,
367        /// Number of provider records that could match this relationship.
368        Ambiguous {
369            /// Candidate count carried by this variant.
370            candidate_count: u16,
371        },
372    }
373
374    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375    /// Normalized lifecycle states used to reconcile source-system data with domain workflows.
376    pub enum Status {
377        /// Reservation has been requested but not yet confirmed.
378        Requested,
379        /// Reservation has been accepted by the resort.
380        Confirmed,
381        /// Pet has arrived and is in care.
382        CheckedIn,
383        /// Pet has left care and the stay is complete.
384        CheckedOut,
385        /// Reservation is no longer active.
386        Cancelled,
387        /// Provider status text retained before normalization.
388        Unknown {
389            /// Observed carried by this variant.
390            observed: source::ObservedStatus,
391        },
392    }
393
394    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395    /// Explicit ingestion assumptions made while normalizing provider data.
396    pub enum Assumption {
397        /// Grain treated as reservation source-data role, provider status, or explicit normalization assumption.
398        GrainTreatedAsReservation,
399        /// Customer record ID treated as stable join key source-data role, provider status, or explicit normalization assumption.
400        CustomerRecordIdTreatedAsStableJoinKey,
401        /// Pet record ID treated as stable join key source-data role, provider status, or explicit normalization assumption.
402        PetRecordIdTreatedAsStableJoinKey,
403        /// Provider status mapping is provisional source-data role, provider status, or explicit normalization assumption.
404        ProviderStatusMappingIsProvisional,
405        /// Raw payload retention unknown source-data role, provider status, or explicit normalization assumption.
406        RawPayloadRetentionUnknown,
407        /// Refresh mutation policy unknown source-data role, provider status, or explicit normalization assumption.
408        RefreshMutationPolicyUnknown,
409    }
410
411    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412    /// Point-in-time source-data view used before promotion into core domain records.
413    pub struct Snapshot {
414        provenance: source::Provenance,
415        customer_record_id: Option<source::record::Id>,
416        pet_record_id: Option<source::record::Id>,
417        location_record_id: Option<source::record::Id>,
418        service_type_record_id: Option<source::record::Id>,
419        status: Option<Status>,
420        relationship: OwnerPetRelationship,
421        assumptions: Vec<Assumption>,
422    }
423
424    impl Snapshot {
425        /// Returns the builder evidence carried by this source-lineage value.
426        pub const fn builder() -> SnapshotBuilder {
427            SnapshotBuilder::new()
428        }
429
430        /// Returns the provenance evidence carried by this source-lineage value.
431        pub const fn provenance(&self) -> &source::Provenance {
432            &self.provenance
433        }
434
435        /// Returns the customer record id evidence carried by this source-lineage value.
436        pub const fn customer_record_id(&self) -> Option<&source::record::Id> {
437            self.customer_record_id.as_ref()
438        }
439
440        /// Returns the pet record id evidence carried by this source-lineage value.
441        pub const fn pet_record_id(&self) -> Option<&source::record::Id> {
442            self.pet_record_id.as_ref()
443        }
444
445        /// Returns the location record id evidence carried by this source-lineage value.
446        pub const fn location_record_id(&self) -> Option<&source::record::Id> {
447            self.location_record_id.as_ref()
448        }
449
450        /// Returns the service type record id evidence carried by this source-lineage value.
451        pub const fn service_type_record_id(&self) -> Option<&source::record::Id> {
452            self.service_type_record_id.as_ref()
453        }
454
455        /// Returns the status evidence carried by this source snapshot.
456        pub fn status(&self) -> Option<Status> {
457            self.status.clone()
458        }
459
460        /// Returns the relationship evidence carried by this source-lineage value.
461        pub const fn relationship(&self) -> &OwnerPetRelationship {
462            &self.relationship
463        }
464
465        /// Returns the assumptions evidence carried by this source snapshot.
466        pub fn assumptions(&self) -> &[Assumption] {
467            &self.assumptions
468        }
469
470        /// Returns the data quality issues evidence carried by this source snapshot.
471        pub fn data_quality_issues(
472            &self,
473            detected_at: source::Timestamp,
474        ) -> Vec<data_quality::Issue> {
475            let mut issues = Vec::new();
476            self.push_missing_issue(
477                &mut issues,
478                self.customer_record_id.is_none(),
479                data_quality::FieldPath::reservation(
480                    data_quality::ReservationField::CustomerRecordId,
481                ),
482                detected_at.clone(),
483            );
484            self.push_missing_issue(
485                &mut issues,
486                self.pet_record_id.is_none(),
487                data_quality::FieldPath::reservation(data_quality::ReservationField::PetRecordId),
488                detected_at.clone(),
489            );
490            self.push_missing_issue(
491                &mut issues,
492                self.location_record_id.is_none(),
493                data_quality::FieldPath::reservation(
494                    data_quality::ReservationField::LocationRecordId,
495                ),
496                detected_at.clone(),
497            );
498            self.push_missing_issue(
499                &mut issues,
500                self.service_type_record_id.is_none(),
501                data_quality::FieldPath::reservation(
502                    data_quality::ReservationField::ServiceTypeRecordId,
503                ),
504                detected_at.clone(),
505            );
506            if self.status.is_none() {
507                self.push_missing_issue(
508                    &mut issues,
509                    true,
510                    data_quality::FieldPath::reservation(data_quality::ReservationField::Status),
511                    detected_at.clone(),
512                );
513                issues.push(data_quality::Issue::new(
514                    data_quality::Kind::AssumptionInForce {
515                        assumption: Assumption::RefreshMutationPolicyUnknown,
516                    },
517                    data_quality::Severity::Blocking,
518                    self.provenance.clone(),
519                    detected_at.clone(),
520                    true,
521                ));
522            }
523            if let Some(Status::Unknown { observed }) = &self.status {
524                issues.push(data_quality::Issue::new(
525                    data_quality::Kind::UnknownSourceStatus {
526                        observed: observed.clone(),
527                    },
528                    data_quality::Severity::Blocking,
529                    self.provenance.clone(),
530                    detected_at.clone(),
531                    true,
532                ));
533            }
534            if matches!(self.relationship, OwnerPetRelationship::Ambiguous { .. }) {
535                issues.push(data_quality::Issue::new(
536                    data_quality::Kind::AmbiguousOwnerPetRelationship,
537                    data_quality::Severity::Blocking,
538                    self.provenance.clone(),
539                    detected_at.clone(),
540                    true,
541                ));
542            }
543            for assumption in &self.assumptions {
544                if matches!(
545                    assumption,
546                    Assumption::RawPayloadRetentionUnknown
547                        | Assumption::RefreshMutationPolicyUnknown
548                ) {
549                    issues.push(data_quality::Issue::new(
550                        data_quality::Kind::AssumptionInForce {
551                            assumption: *assumption,
552                        },
553                        data_quality::Severity::Warning,
554                        self.provenance.clone(),
555                        detected_at.clone(),
556                        false,
557                    ));
558                }
559            }
560            issues
561        }
562
563        fn push_missing_issue(
564            &self,
565            issues: &mut Vec<data_quality::Issue>,
566            missing: bool,
567            field: data_quality::FieldPath,
568            detected_at: source::Timestamp,
569        ) {
570            if missing {
571                issues.push(data_quality::Issue::new(
572                    data_quality::Kind::MissingRequiredField { field },
573                    data_quality::Severity::Blocking,
574                    self.provenance.clone(),
575                    detected_at,
576                    true,
577                ));
578            }
579        }
580    }
581
582    #[derive(Debug, Clone)]
583    /// Builder for assembling a source snapshot with validated provider identifiers.
584    pub struct SnapshotBuilder {
585        provenance: Option<source::Provenance>,
586        customer_record_id: Option<source::record::Id>,
587        pet_record_id: Option<source::record::Id>,
588        location_record_id: Option<source::record::Id>,
589        service_type_record_id: Option<source::record::Id>,
590        status: Option<Status>,
591        relationship: Option<OwnerPetRelationship>,
592        assumptions: Vec<Assumption>,
593    }
594
595    impl Default for SnapshotBuilder {
596        fn default() -> Self {
597            Self::new()
598        }
599    }
600
601    impl SnapshotBuilder {
602        /// Assembles source-lineage data from already validated domain parts without reinterpreting authority.
603        pub const fn new() -> Self {
604            Self {
605                provenance: None,
606                customer_record_id: None,
607                pet_record_id: None,
608                location_record_id: None,
609                service_type_record_id: None,
610                status: None,
611                relationship: None,
612                assumptions: Vec::new(),
613            }
614        }
615
616        /// Returns the provenance evidence carried by this source snapshot.
617        pub fn provenance(mut self, provenance: source::Provenance) -> Self {
618            self.provenance = Some(provenance);
619            self
620        }
621
622        /// Returns the customer record id evidence carried by this source snapshot.
623        pub fn customer_record_id(mut self, id: impl Into<Option<source::record::Id>>) -> Self {
624            self.customer_record_id = id.into();
625            self
626        }
627
628        /// Returns the pet record id evidence carried by this source snapshot.
629        pub fn pet_record_id(mut self, id: impl Into<Option<source::record::Id>>) -> Self {
630            self.pet_record_id = id.into();
631            self
632        }
633
634        /// Returns the location record id evidence carried by this source snapshot.
635        pub fn location_record_id(mut self, id: impl Into<Option<source::record::Id>>) -> Self {
636            self.location_record_id = id.into();
637            self
638        }
639
640        /// Returns the service type record id evidence carried by this source snapshot.
641        pub fn service_type_record_id(mut self, id: impl Into<Option<source::record::Id>>) -> Self {
642            self.service_type_record_id = id.into();
643            self
644        }
645
646        /// Returns the status evidence carried by this source snapshot.
647        pub fn status(mut self, status: impl Into<Option<Status>>) -> Self {
648            self.status = status.into();
649            self
650        }
651
652        /// Returns the relationship evidence carried by this source snapshot.
653        pub fn relationship(mut self, relationship: OwnerPetRelationship) -> Self {
654            self.relationship = Some(relationship);
655            self
656        }
657
658        /// Returns the assumptions evidence carried by this source snapshot.
659        pub fn assumptions(mut self, assumptions: Vec<Assumption>) -> Self {
660            self.assumptions = assumptions;
661            self
662        }
663
664        /// Builds the source snapshot once required provenance and relationship evidence are present.
665        pub fn build(self) -> Snapshot {
666            Snapshot {
667                provenance: self.provenance.expect("snapshot provenance is required"),
668                customer_record_id: self.customer_record_id,
669                pet_record_id: self.pet_record_id,
670                location_record_id: self.location_record_id,
671                service_type_record_id: self.service_type_record_id,
672                status: self.status,
673                relationship: self
674                    .relationship
675                    .expect("snapshot relationship is required"),
676                assumptions: self.assumptions,
677            }
678        }
679    }
680}
681
682/// Gingr boundary for source contracts.
683pub mod gingr {
684    use bon::Builder;
685    use serde::{Deserialize, Serialize};
686
687    use crate::source;
688
689    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
690    /// Provider API endpoint or import route that produced source data.
691    pub struct Endpoint(String);
692
693    impl Endpoint {
694        /// Promotes non-empty provider or import text into a source-lineage value.
695        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
696            source::trimmed_non_empty(value, source::Error::EmptyGingrEndpoint).map(Self)
697        }
698
699        /// Returns the provider or domain identifier as a string slice.
700        pub fn as_str(&self) -> &str {
701            &self.0
702        }
703    }
704
705    impl From<Endpoint> for source::Endpoint {
706        fn from(value: Endpoint) -> Self {
707            source::Endpoint::try_new(value.0).expect("Gingr endpoint was already validated")
708        }
709    }
710
711    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
712    /// Provider-native identifier for a source record.
713    pub struct ProviderRecordId(String);
714
715    impl ProviderRecordId {
716        /// Promotes non-empty provider or import text into a source-lineage value.
717        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
718            source::trimmed_non_empty(value, source::Error::EmptyProviderRecordId).map(Self)
719        }
720
721        /// Returns the provider or domain identifier as a string slice.
722        pub fn as_str(&self) -> &str {
723            &self.0
724        }
725    }
726
727    impl From<ProviderRecordId> for source::record::Id {
728        fn from(value: ProviderRecordId) -> Self {
729            source::record::Id::try_new(value.0).expect("Gingr provider id was already validated")
730        }
731    }
732
733    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734    /// Domain vocabulary for related provider id decisions in source workflows.
735    pub enum RelatedProviderId {
736        /// Owner source-data role, provider status, or explicit normalization assumption.
737        Owner(ProviderRecordId),
738        /// Animal source-data role, provider status, or explicit normalization assumption.
739        Animal(ProviderRecordId),
740        /// Resort location record participating in the workflow.
741        Location(ProviderRecordId),
742        /// Reservation type source-data role, provider status, or explicit normalization assumption.
743        ReservationType(ProviderRecordId),
744        /// Invoice source-data role, provider status, or explicit normalization assumption.
745        Invoice(ProviderRecordId),
746        /// Payment source-data role, provider status, or explicit normalization assumption.
747        Payment(ProviderRecordId),
748        /// Service source-data role, provider status, or explicit normalization assumption.
749        Service(ProviderRecordId),
750    }
751
752    impl RelatedProviderId {
753        /// Returns the owner evidence carried by this source-lineage value.
754        pub const fn owner(id: ProviderRecordId) -> Self {
755            Self::Owner(id)
756        }
757
758        /// Returns the animal evidence carried by this source-lineage value.
759        pub const fn animal(id: ProviderRecordId) -> Self {
760            Self::Animal(id)
761        }
762
763        fn promote(self) -> source::record::RelatedId {
764            match self {
765                Self::Owner(id) => {
766                    source::record::RelatedId::new(source::record::Role::Customer, id.into())
767                }
768                Self::Animal(id) => {
769                    source::record::RelatedId::new(source::record::Role::Pet, id.into())
770                }
771                Self::Location(id) => {
772                    source::record::RelatedId::new(source::record::Role::Location, id.into())
773                }
774                Self::ReservationType(id) => {
775                    source::record::RelatedId::new(source::record::Role::ReservationType, id.into())
776                }
777                Self::Invoice(id) => {
778                    source::record::RelatedId::new(source::record::Role::Invoice, id.into())
779                }
780                Self::Payment(id) => {
781                    source::record::RelatedId::new(source::record::Role::Payment, id.into())
782                }
783                Self::Service(id) => {
784                    source::record::RelatedId::new(source::record::Role::Service, id.into())
785                }
786            }
787        }
788    }
789
790    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
791    /// Identifier that groups records from the same provider extraction run.
792    pub struct ExtractionBatchId(String);
793
794    impl ExtractionBatchId {
795        /// Promotes non-empty provider or import text into a source-lineage value.
796        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
797            source::trimmed_non_empty(value, source::Error::EmptyExtractionBatch).map(Self)
798        }
799
800        /// Returns the provider or domain identifier as a string slice.
801        pub fn as_str(&self) -> &str {
802            &self.0
803        }
804    }
805
806    impl From<ExtractionBatchId> for source::ExtractionBatchId {
807        fn from(value: ExtractionBatchId) -> Self {
808            source::ExtractionBatchId::try_new(value.0)
809                .expect("Gingr extraction batch id was already validated")
810        }
811    }
812
813    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
814    /// Import or API scope requested from the provider during extraction.
815    pub struct RequestScope(String);
816
817    impl RequestScope {
818        /// Promotes non-empty provider or import text into a source-lineage value.
819        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
820            source::trimmed_non_empty(value, source::Error::EmptyRequestScope).map(Self)
821        }
822
823        /// Returns the provider or domain identifier as a string slice.
824        pub fn as_str(&self) -> &str {
825            &self.0
826        }
827    }
828
829    impl From<RequestScope> for source::RequestScope {
830        fn from(value: RequestScope) -> Self {
831            source::RequestScope::try_new(value.0)
832                .expect("Gingr request scope was already validated")
833        }
834    }
835
836    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
837    /// Provider schema version observed for an imported payload.
838    pub struct ProviderSchemaVersion(String);
839
840    impl ProviderSchemaVersion {
841        /// Promotes non-empty provider or import text into a source-lineage value.
842        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
843            source::trimmed_non_empty(value, source::Error::EmptyProviderSchemaVersion).map(Self)
844        }
845
846        /// Returns the provider or domain identifier as a string slice.
847        pub fn as_str(&self) -> &str {
848            &self.0
849        }
850    }
851
852    impl From<ProviderSchemaVersion> for source::SchemaVersion {
853        fn from(value: ProviderSchemaVersion) -> Self {
854            source::SchemaVersion::try_new(value.0)
855                .expect("Gingr provider schema version was already validated")
856        }
857    }
858
859    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
860    /// Provider-native status before mapping to a reservation workflow state.
861    pub struct ProviderStatus(String);
862
863    impl ProviderStatus {
864        /// Promotes non-empty provider or import text into a source-lineage value.
865        pub fn try_new(value: impl Into<String>) -> source::Result<Self> {
866            source::trimmed_non_empty(value, source::Error::EmptyProviderStatus).map(Self)
867        }
868
869        /// Returns the provider or domain identifier as a string slice.
870        pub fn as_str(&self) -> &str {
871            &self.0
872        }
873
874        fn promote(self) -> source::reservation::Status {
875            match self.0.trim().to_ascii_lowercase().as_str() {
876                "requested" | "request" | "pending" => source::reservation::Status::Requested,
877                "confirmed" | "booked" => source::reservation::Status::Confirmed,
878                "checked_in" | "checked-in" | "in_house" => source::reservation::Status::CheckedIn,
879                "checked_out" | "checked-out" | "complete" => {
880                    source::reservation::Status::CheckedOut
881                }
882                "cancelled" | "canceled" => source::reservation::Status::Cancelled,
883                _ => source::reservation::Status::Unknown {
884                    observed: source::ObservedStatus::try_new(self.0)
885                        .expect("Gingr provider status was already validated"),
886                },
887            }
888        }
889    }
890
891    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
892    /// Lineage metadata that ties normalized data back to its provider record.
893    pub struct Provenance {
894        endpoint: Endpoint,
895        provider_record_id: ProviderRecordId,
896        #[builder(default)]
897        related_provider_ids: Vec<RelatedProviderId>,
898        extraction_batch: ExtractionBatchId,
899        pulled_at: source::Timestamp,
900        request_scope: RequestScope,
901        provider_schema_version: ProviderSchemaVersion,
902        source_payload_hash: source::PayloadHash,
903        raw_payload_ref: source::RawPayloadRef,
904    }
905
906    impl Provenance {
907        /// Returns the source system evidence carried by this source-lineage value.
908        pub const fn source_system(&self) -> source::System {
909            source::System::Gingr
910        }
911
912        /// Returns the endpoint evidence carried by this source-lineage value.
913        pub const fn endpoint(&self) -> &Endpoint {
914            &self.endpoint
915        }
916
917        /// Returns the provider record id evidence carried by this source-lineage value.
918        pub const fn provider_record_id(&self) -> &ProviderRecordId {
919            &self.provider_record_id
920        }
921
922        /// Returns the related provider ids evidence carried by this source snapshot.
923        pub fn related_provider_ids(&self) -> &[RelatedProviderId] {
924            &self.related_provider_ids
925        }
926
927        /// Returns the extraction batch evidence carried by this source-lineage value.
928        pub const fn extraction_batch(&self) -> &ExtractionBatchId {
929            &self.extraction_batch
930        }
931
932        /// Returns the pulled at evidence carried by this source-lineage value.
933        pub const fn pulled_at(&self) -> &source::Timestamp {
934            &self.pulled_at
935        }
936
937        /// Returns the raw payload ref evidence carried by this source-lineage value.
938        pub const fn raw_payload_ref(&self) -> &source::RawPayloadRef {
939            &self.raw_payload_ref
940        }
941
942        /// Promotes provider source data into the normalized domain snapshot.
943        pub fn promote(self) -> source::Provenance {
944            source::Provenance::builder()
945                .system(source::System::Gingr)
946                .endpoint(self.endpoint.into())
947                .record_id(self.provider_record_id.into())
948                .related_record_ids(
949                    self.related_provider_ids
950                        .into_iter()
951                        .map(RelatedProviderId::promote)
952                        .collect(),
953                )
954                .extraction_batch(self.extraction_batch.into())
955                .pulled_at(self.pulled_at)
956                .request_scope(self.request_scope.into())
957                .schema_version(self.provider_schema_version.into())
958                .payload_hash(self.source_payload_hash)
959                .raw_payload_ref(self.raw_payload_ref)
960                .build()
961        }
962    }
963
964    /// Reservation boundary for source contracts.
965    pub mod reservation {
966        use serde::{Deserialize, Serialize};
967
968        use super::{Provenance, ProviderRecordId, ProviderStatus};
969        use crate::source;
970
971        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
972        /// Confidence in the provider relationship between an owner and pet.
973        pub enum OwnerPetRelationship {
974            /// Owner-pet relationship was matched to a single confident record.
975            Resolved,
976            /// Number of provider records that could match this relationship.
977            Ambiguous {
978                /// Candidate count carried by this variant.
979                candidate_count: u16,
980            },
981        }
982
983        impl From<OwnerPetRelationship> for source::reservation::OwnerPetRelationship {
984            fn from(value: OwnerPetRelationship) -> Self {
985                match value {
986                    OwnerPetRelationship::Resolved => Self::Resolved,
987                    OwnerPetRelationship::Ambiguous { candidate_count } => {
988                        Self::Ambiguous { candidate_count }
989                    }
990                }
991            }
992        }
993
994        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
995        /// Point-in-time source-data view used before promotion into core domain records.
996        pub struct Snapshot {
997            provenance: Provenance,
998            owner_provider_id: Option<ProviderRecordId>,
999            animal_provider_id: Option<ProviderRecordId>,
1000            location_provider_id: Option<ProviderRecordId>,
1001            service_type_provider_id: Option<ProviderRecordId>,
1002            provider_status: Option<ProviderStatus>,
1003            relationship: OwnerPetRelationship,
1004        }
1005
1006        impl Snapshot {
1007            /// Returns the builder evidence carried by this source-lineage value.
1008            pub const fn builder() -> SnapshotBuilder {
1009                SnapshotBuilder::new()
1010            }
1011
1012            /// Returns the provenance evidence carried by this source-lineage value.
1013            pub const fn provenance(&self) -> &Provenance {
1014                &self.provenance
1015            }
1016
1017            /// Returns the owner provider id evidence carried by this source-lineage value.
1018            pub const fn owner_provider_id(&self) -> Option<&ProviderRecordId> {
1019                self.owner_provider_id.as_ref()
1020            }
1021
1022            /// Returns the animal provider id evidence carried by this source-lineage value.
1023            pub const fn animal_provider_id(&self) -> Option<&ProviderRecordId> {
1024                self.animal_provider_id.as_ref()
1025            }
1026
1027            /// Returns the location provider id evidence carried by this source-lineage value.
1028            pub const fn location_provider_id(&self) -> Option<&ProviderRecordId> {
1029                self.location_provider_id.as_ref()
1030            }
1031
1032            /// Returns the service type provider id evidence carried by this source-lineage value.
1033            pub const fn service_type_provider_id(&self) -> Option<&ProviderRecordId> {
1034                self.service_type_provider_id.as_ref()
1035            }
1036
1037            /// Returns the provider status evidence carried by this source-lineage value.
1038            pub const fn provider_status(&self) -> Option<&ProviderStatus> {
1039                self.provider_status.as_ref()
1040            }
1041
1042            /// Returns the relationship evidence carried by this source-lineage value.
1043            pub const fn relationship(&self) -> &OwnerPetRelationship {
1044                &self.relationship
1045            }
1046
1047            /// Promotes provider source data into the normalized domain snapshot.
1048            pub fn promote(self) -> source::reservation::Snapshot {
1049                let status = self.provider_status.map(ProviderStatus::promote);
1050                let mut assumptions = vec![
1051                    source::reservation::Assumption::GrainTreatedAsReservation,
1052                    source::reservation::Assumption::CustomerRecordIdTreatedAsStableJoinKey,
1053                    source::reservation::Assumption::PetRecordIdTreatedAsStableJoinKey,
1054                    source::reservation::Assumption::ProviderStatusMappingIsProvisional,
1055                ];
1056                if status.is_none() {
1057                    assumptions.push(source::reservation::Assumption::RefreshMutationPolicyUnknown);
1058                }
1059
1060                source::reservation::Snapshot::builder()
1061                    .provenance(self.provenance.promote())
1062                    .customer_record_id(self.owner_provider_id.map(Into::into))
1063                    .pet_record_id(self.animal_provider_id.map(Into::into))
1064                    .location_record_id(self.location_provider_id.map(Into::into))
1065                    .service_type_record_id(self.service_type_provider_id.map(Into::into))
1066                    .status(status)
1067                    .relationship(self.relationship.into())
1068                    .assumptions(assumptions)
1069                    .build()
1070            }
1071        }
1072
1073        #[derive(Debug, Clone)]
1074        /// Builder for assembling a source snapshot with validated provider identifiers.
1075        pub struct SnapshotBuilder {
1076            provenance: Option<Provenance>,
1077            owner_provider_id: Option<ProviderRecordId>,
1078            animal_provider_id: Option<ProviderRecordId>,
1079            location_provider_id: Option<ProviderRecordId>,
1080            service_type_provider_id: Option<ProviderRecordId>,
1081            provider_status: Option<ProviderStatus>,
1082            relationship: Option<OwnerPetRelationship>,
1083        }
1084
1085        impl Default for SnapshotBuilder {
1086            fn default() -> Self {
1087                Self::new()
1088            }
1089        }
1090
1091        impl SnapshotBuilder {
1092            /// Assembles source-lineage data from already validated domain parts without reinterpreting authority.
1093            pub const fn new() -> Self {
1094                Self {
1095                    provenance: None,
1096                    owner_provider_id: None,
1097                    animal_provider_id: None,
1098                    location_provider_id: None,
1099                    service_type_provider_id: None,
1100                    provider_status: None,
1101                    relationship: None,
1102                }
1103            }
1104
1105            /// Returns the provenance evidence carried by this source snapshot.
1106            pub fn provenance(mut self, provenance: Provenance) -> Self {
1107                self.provenance = Some(provenance);
1108                self
1109            }
1110
1111            /// Returns the owner provider id evidence carried by this source snapshot.
1112            pub fn owner_provider_id(mut self, id: impl Into<Option<ProviderRecordId>>) -> Self {
1113                self.owner_provider_id = id.into();
1114                self
1115            }
1116
1117            /// Returns the animal provider id evidence carried by this source snapshot.
1118            pub fn animal_provider_id(mut self, id: impl Into<Option<ProviderRecordId>>) -> Self {
1119                self.animal_provider_id = id.into();
1120                self
1121            }
1122
1123            /// Returns the location provider id evidence carried by this source snapshot.
1124            pub fn location_provider_id(mut self, id: impl Into<Option<ProviderRecordId>>) -> Self {
1125                self.location_provider_id = id.into();
1126                self
1127            }
1128
1129            /// Returns the service type provider id evidence carried by this source snapshot.
1130            pub fn service_type_provider_id(
1131                mut self,
1132                id: impl Into<Option<ProviderRecordId>>,
1133            ) -> Self {
1134                self.service_type_provider_id = id.into();
1135                self
1136            }
1137
1138            /// Returns the provider status evidence carried by this source snapshot.
1139            pub fn provider_status(mut self, status: impl Into<Option<ProviderStatus>>) -> Self {
1140                self.provider_status = status.into();
1141                self
1142            }
1143
1144            /// Returns the relationship evidence carried by this source snapshot.
1145            pub fn relationship(mut self, relationship: OwnerPetRelationship) -> Self {
1146                self.relationship = Some(relationship);
1147                self
1148            }
1149
1150            /// Builds the source snapshot once required provenance and relationship evidence are present.
1151            pub fn build(self) -> Snapshot {
1152                Snapshot {
1153                    provenance: self.provenance.expect("snapshot provenance is required"),
1154                    owner_provider_id: self.owner_provider_id,
1155                    animal_provider_id: self.animal_provider_id,
1156                    location_provider_id: self.location_provider_id,
1157                    service_type_provider_id: self.service_type_provider_id,
1158                    provider_status: self.provider_status,
1159                    relationship: self
1160                        .relationship
1161                        .expect("snapshot relationship is required"),
1162                }
1163            }
1164        }
1165    }
1166}
1167
1168#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1169/// Validation failures returned by source domain constructors.
1170pub enum Error {
1171    #[error("timestamp must not be empty")]
1172    /// Signals that timestamp was blank or missing during source validation.
1173    EmptyTimestamp,
1174    #[error("timestamp must be RFC3339 UTC-compatible text")]
1175    /// Signals that timestamp could not be parsed or accepted during source validation.
1176    InvalidTimestamp,
1177    #[error("source endpoint must not be empty")]
1178    /// Signals that endpoint was blank or missing during source validation.
1179    EmptyEndpoint,
1180    #[error("Gingr endpoint must not be empty")]
1181    /// Signals that gingr endpoint was blank or missing during source validation.
1182    EmptyGingrEndpoint,
1183    #[error("source record id must not be empty")]
1184    /// Signals that record id was blank or missing during source validation.
1185    EmptyRecordId,
1186    #[error("provider record id must not be empty")]
1187    /// Signals that provider record id was blank or missing during source validation.
1188    EmptyProviderRecordId,
1189    #[error("extraction batch id must not be empty")]
1190    /// Signals that extraction batch was blank or missing during source validation.
1191    EmptyExtractionBatch,
1192    #[error("request scope must not be empty")]
1193    /// Signals that request scope was blank or missing during source validation.
1194    EmptyRequestScope,
1195    #[error("schema version must not be empty")]
1196    /// Signals that schema version was blank or missing during source validation.
1197    EmptySchemaVersion,
1198    #[error("provider schema version must not be empty")]
1199    /// Signals that provider schema version was blank or missing during source validation.
1200    EmptyProviderSchemaVersion,
1201    #[error("source payload hash must not be empty")]
1202    /// Signals that payload hash was blank or missing during source validation.
1203    EmptyPayloadHash,
1204    #[error("raw payload reference must not be empty")]
1205    /// Signals that raw payload ref was blank or missing during source validation.
1206    EmptyRawPayloadRef,
1207    #[error("observed status must not be empty")]
1208    /// Signals that observed status was blank or missing during source validation.
1209    EmptyObservedStatus,
1210    #[error("provider status must not be empty")]
1211    /// Signals that provider status was blank or missing during source validation.
1212    EmptyProviderStatus,
1213}
1214
1215/// Result type returned by fallible source operations.
1216pub type Result<T> = std::result::Result<T, Error>;
1217
1218fn trimmed_non_empty(value: impl Into<String>, empty_error: Error) -> Result<String> {
1219    let value = value.into().trim().to_string();
1220    if value.is_empty() {
1221        return Err(empty_error);
1222    }
1223    Ok(value)
1224}