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