Skip to main content

ahri_tre_protocol/
ingest.rs

1use crate::{
2    PublicName,
3    asset::{AssetCatalog, AssetSelector},
4    datafile::DataFileSummary,
5    dataset::RegisteredDatasetVariablePayload,
6    domain::DomainSelector,
7    refs::{DataFileRef, SessionSelector, StudyRef, VersionRef},
8    study::StudySelector,
9};
10use ahri_tre_types::{AssetType, AssetVersionRecord, DatasetRecord, TransformationId};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct IngestFileRequest {
16    pub classification: ahri_tre_types::IngestClassification,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub session: Option<SessionSelector>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub study: Option<StudySelector>,
21    pub asset: PublicName,
22    pub source: IngestFileSource,
23    pub format: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26    #[serde(default)]
27    pub new_version: bool,
28    #[serde(default)]
29    pub bump_major: bool,
30    #[serde(default)]
31    pub bump_minor: bool,
32    #[serde(default)]
33    pub compress: bool,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub encrypt: Option<bool>,
36    #[serde(default)]
37    pub hash_source_local: bool,
38    #[serde(default)]
39    pub verify_copy: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct IngestDatasetFileRequest {
44    pub classification: ahri_tre_types::IngestClassification,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub session: Option<SessionSelector>,
47    pub study: StudySelector,
48    pub domain: DomainSelector,
49    pub dataset: PublicName,
50    pub source: IngestDatasetFileSource,
51    pub materialization: DatasetMaterializationOptions,
52    pub parse: DatasetFileParseOptions,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct DatasetCreateFromDatafileRequest {
58    #[serde(
59        default,
60        skip_serializing_if = "crate::mutation::MutationOptions::is_empty"
61    )]
62    pub mutation: crate::mutation::MutationOptions,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub session: Option<SessionSelector>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub study: Option<StudySelector>,
67    pub domain: DomainSelector,
68    pub dataset: PublicName,
69    pub source: ManagedDataFileSource,
70    pub materialization: DatasetMaterializationOptions,
71    pub parse: DatasetFileParseOptions,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct IngestSqlRequest {
76    pub classification: ahri_tre_types::IngestClassification,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub session: Option<SessionSelector>,
79    pub study: StudySelector,
80    pub domain: DomainSelector,
81    pub dataset: PublicName,
82    pub source: SqlSource,
83    pub sql: String,
84    pub materialization: SqlMaterializationOptions,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct IngestRedcapRequest {
89    pub classification: ahri_tre_types::IngestClassification,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub session: Option<SessionSelector>,
92    pub study: StudySelector,
93    pub domain: DomainSelector,
94    pub source: RedcapIngestSource,
95    pub materialization: RedcapMaterializationOptions,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RedcapMaterializationOptions {
100    #[serde(default)]
101    pub no_register_dictionary: bool,
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub forms: Vec<String>,
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub skip_forms: Vec<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub dataset_prefix: Option<PublicName>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub description: Option<String>,
110    #[serde(default)]
111    pub force_metadata_updates: bool,
112    #[serde(default = "default_true")]
113    pub compress: bool,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub encrypt: Option<bool>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub response_encoding: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub max_attempts: Option<u32>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub timeout_secs: Option<u64>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(tag = "kind", rename_all = "snake_case")]
126pub enum RedcapIngestSource {
127    /// Public source intent; its token travels only through sensitive acquisition.
128    LiveApi {
129        endpoint: crate::acquisition::HttpsEndpoint,
130    },
131    /// Three consecutive binary roles, each with its own length and digest.
132    /// Their concatenation is also verified by the enclosing upload.
133    Stream {
134        project_info: crate::upload::UploadMetadata,
135        metadata: crate::upload::UploadMetadata,
136        records_eav: crate::upload::UploadMetadata,
137    },
138    OfflineExport {
139        project_info_path: String,
140        metadata_path: String,
141        records_eav_path: String,
142        #[serde(default)]
143        request_only: bool,
144    },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(tag = "kind", rename_all = "snake_case")]
149pub enum IngestDatasetFileSource {
150    Stream,
151    RestrictedLocalPath { path: String, request_only: bool },
152    HttpsUri { uri: IngestRemoteUri },
153}
154
155/// Returns whether a caller-visible filename is safe for a bounded CSV upload.
156///
157/// This is a protocol-level lexical policy only. The Trusted runtime remains
158/// authoritative for media type, content length, digest, and CSV validation.
159pub fn is_safe_csv_upload_filename(value: &str) -> bool {
160    !value.is_empty()
161        && value.len() <= 255
162        && value.ends_with(".csv")
163        && !value.starts_with('.')
164        && !value
165            .chars()
166            .any(|character| character.is_control() || matches!(character, '/' | '\\' | ':' | '\0'))
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ManagedDataFileSource {
171    pub asset: AssetSelector,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub version: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct DatasetMaterializationOptions {
178    pub description: String,
179    #[serde(default)]
180    pub replace: bool,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub new_version: Option<String>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct SqlMaterializationOptions {
187    pub description: String,
188    #[serde(default)]
189    pub replace: bool,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub new_version: Option<String>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct DatasetFileParseOptions {
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub format: Option<String>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub sheet: Option<String>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub header: Option<bool>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub delimiter: Option<char>,
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub null_strings: Vec<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub json_format: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub sample_rows: Option<usize>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub vocabulary_threshold: Option<usize>,
212    #[serde(default)]
213    pub force_metadata_updates: bool,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
218pub enum SqlSource {
219    /// Only finite query-result bytes cross the Managed upload boundary.
220    Stream { engine: SqlEngine },
221    Remote {
222        endpoint: crate::acquisition::AcquisitionEndpoint,
223    },
224    Duckdb {
225        path: String,
226        #[serde(default)]
227        request_only: bool,
228    },
229    Sqlite {
230        path: String,
231        #[serde(default)]
232        request_only: bool,
233    },
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum SqlEngine {
239    Duckdb,
240    Sqlite,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
245pub enum IngestFileSource {
246    Stream,
247    RestrictedLocalPath {
248        path: String,
249    },
250    HttpsUri {
251        uri: IngestRemoteUri,
252    },
253    UploadedContent {
254        filename: String,
255        media_type: String,
256        content_length: u64,
257        digest: String,
258        content_base64: String,
259    },
260}
261
262#[derive(Clone, PartialEq, Eq)]
263pub struct IngestRemoteUri {
264    pub uri: String,
265    pub credential_bearing: bool,
266    pub request_only: bool,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct IngestFileResponse {
271    pub session: crate::session::SessionStatusPayload,
272    pub study: String,
273    pub asset: IngestedAssetSummary,
274    pub datafile: DataFileSummary,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub version: Option<crate::asset::SafeAssetVersionSummary>,
277    pub transformation_id: TransformationId,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub transformation: Option<crate::refs::TransformationRef>,
280    pub source_size_bytes: u64,
281    pub stored_size_bytes: u64,
282    pub source: IngestSourceSummary,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct DatasetMaterializationResponse {
287    pub session: crate::session::SessionStatusPayload,
288    pub study: String,
289    pub catalog: AssetCatalog,
290    pub dataset: DatasetRecord,
291    pub lineage: crate::transformation::TransformationSummary,
292    pub row_count: usize,
293    pub column_count: usize,
294    pub variables: Vec<RegisteredDatasetVariablePayload>,
295    #[serde(default)]
296    pub metadata_warnings: Vec<String>,
297    pub source: DatasetMaterializationSourceSummary,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub source_catalog: Option<AssetCatalog>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub source_version: Option<AssetVersionRecord>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub source_datafile: Option<DataFileSummary>,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct RedcapIngestResponse {
308    #[serde(default)]
309    pub form_outcomes: Vec<RedcapFormOutcome>,
310    pub session: crate::session::SessionStatusPayload,
311    pub study: String,
312    pub redcap_study: ahri_tre_types::StudyRecord,
313    #[serde(default)]
314    pub preserved_artifacts: Vec<RedcapPreservedArtifactSummary>,
315    pub ingest_lineage: crate::transformation::TransformationSummary,
316    #[serde(default)]
317    pub datasets: Vec<RedcapFormSummary>,
318    #[serde(default)]
319    pub warnings: Vec<RedcapIngestWarning>,
320    pub source: RedcapIngestSourceSummary,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct RedcapPreservedArtifactSummary {
325    pub catalog: crate::asset::SafeAssetCatalog,
326    pub datafile: DataFileSummary,
327    pub lineage: crate::transformation::TransformationSummary,
328    pub source_size_bytes: u64,
329    pub stored_size_bytes: u64,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct RedcapIngestSourceSummary {
334    pub kind: RedcapIngestSourceKind,
335    #[serde(default)]
336    pub request_only: bool,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "snake_case")]
341pub enum RedcapIngestSourceKind {
342    OfflineExport,
343    LiveApi,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct RedcapIngestWarning {
348    pub kind: RedcapIngestWarningKind,
349    pub message: String,
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum RedcapIngestWarningKind {
355    ExpectedNormalization,
356    TransportFallback,
357    SkippedMaterialization,
358    PartialMetadataLinkage,
359    Other,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct DatasetMaterializationSourceSummary {
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub upload: Option<UploadSummary>,
366    pub kind: DatasetMaterializationSourceKind,
367    #[serde(default)]
368    pub request_only: bool,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum DatasetMaterializationSourceKind {
374    Https,
375    LocalDatasetFile,
376    ManagedDataFile,
377    SqlSource,
378    RedcapForm,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct IngestedAssetSummary {
383    pub name: String,
384    pub asset_type: AssetType,
385    pub study: StudyRef,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub description: Option<String>,
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub version: Option<VersionRef>,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct IngestSourceSummary {
394    pub kind: IngestSourceKind,
395    #[serde(default)]
396    pub request_only: bool,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub upload: Option<UploadSummary>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct UploadSummary {
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub acquisition: Option<ahri_tre_types::AcquisitionPath>,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub budgets: Option<ahri_tre_types::DisclosureBudgets>,
407    pub filename: String,
408    pub media_type: String,
409    pub content_length: u64,
410    pub digest: String,
411    pub outcome: UploadOutcome,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "snake_case")]
416pub enum UploadOutcome {
417    Ingested,
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum IngestSourceKind {
423    RestrictedLocalPath,
424    HttpsUri,
425    UploadedContent,
426}
427
428impl IngestFileSource {
429    pub const fn source_kind(&self) -> IngestSourceKind {
430        match self {
431            Self::Stream => IngestSourceKind::UploadedContent,
432            Self::RestrictedLocalPath { .. } => IngestSourceKind::RestrictedLocalPath,
433            Self::HttpsUri { .. } => IngestSourceKind::HttpsUri,
434            Self::UploadedContent { .. } => IngestSourceKind::UploadedContent,
435        }
436    }
437
438    pub const fn request_only(&self) -> bool {
439        match self {
440            Self::RestrictedLocalPath { .. } => true,
441            Self::HttpsUri { uri } => uri.request_only || uri.credential_bearing,
442            Self::Stream | Self::UploadedContent { .. } => true,
443        }
444    }
445}
446
447impl std::fmt::Debug for IngestRemoteUri {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        f.write_str("IngestRemoteUri(<request-only>)")
450    }
451}
452impl Serialize for IngestRemoteUri {
453    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
454        let endpoint: crate::acquisition::HttpsEndpoint =
455            serde_json::from_value(serde_json::Value::String(self.uri.clone())).map_err(|_| {
456                serde::ser::Error::custom(
457                    "Source credentials require the sensitive acquisition API",
458                )
459            })?;
460        use serde::ser::SerializeStruct;
461        let mut state = serializer.serialize_struct("IngestRemoteUri", 3)?;
462        state.serialize_field("uri", endpoint.as_str())?;
463        state.serialize_field("credential_bearing", &false)?;
464        state.serialize_field("request_only", &self.request_only)?;
465        state.end()
466    }
467}
468impl<'de> Deserialize<'de> for IngestRemoteUri {
469    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
470        #[derive(Deserialize)]
471        #[serde(deny_unknown_fields)]
472        struct PublicUri {
473            uri: crate::acquisition::HttpsEndpoint,
474            #[serde(default)]
475            credential_bearing: bool,
476            #[serde(default)]
477            request_only: bool,
478        }
479        let value = PublicUri::deserialize(deserializer)?;
480        if value.credential_bearing {
481            return Err(serde::de::Error::custom(
482                "Source credentials require the sensitive acquisition API",
483            ));
484        }
485        Ok(Self {
486            uri: value.uri.as_str().into(),
487            credential_bearing: false,
488            request_only: value.request_only,
489        })
490    }
491}
492
493impl IngestRemoteUri {
494    pub fn new(uri: impl Into<String>) -> Self {
495        let uri = uri.into();
496        let credential_bearing = remote_uri_is_credential_bearing(&uri);
497        Self {
498            credential_bearing,
499            request_only: credential_bearing,
500            uri,
501        }
502    }
503}
504
505impl RedcapIngestSource {
506    pub const fn source_kind(&self) -> RedcapIngestSourceKind {
507        match self {
508            Self::LiveApi { .. } => RedcapIngestSourceKind::LiveApi,
509            _ => RedcapIngestSourceKind::OfflineExport,
510        }
511    }
512
513    pub const fn request_only(&self) -> bool {
514        match self {
515            Self::Stream { .. } | Self::LiveApi { .. } => true,
516            Self::OfflineExport { request_only, .. } => *request_only,
517        }
518    }
519}
520
521pub fn classify_redcap_warning(message: &str) -> RedcapIngestWarningKind {
522    let lowered = message.to_ascii_lowercase();
523    if lowered.contains("normaliz") || lowered.contains("metadata") {
524        RedcapIngestWarningKind::ExpectedNormalization
525    } else if lowered.contains("fallback")
526        || lowered.contains("retry")
527        || lowered.contains("transport")
528    {
529        RedcapIngestWarningKind::TransportFallback
530    } else if lowered.contains("skip") || lowered.contains("not materializ") {
531        RedcapIngestWarningKind::SkippedMaterialization
532    } else if lowered.contains("link") || lowered.contains("partial") {
533        RedcapIngestWarningKind::PartialMetadataLinkage
534    } else {
535        RedcapIngestWarningKind::Other
536    }
537}
538
539fn remote_uri_is_credential_bearing(uri: &str) -> bool {
540    let Some(rest) = uri.strip_prefix("https://") else {
541        return false;
542    };
543    rest.split_once('/')
544        .map(|(authority, _)| authority.contains('@'))
545        .unwrap_or_else(|| rest.contains('@'))
546        || uri.contains('?')
547        || uri.contains('#')
548}
549
550const fn default_true() -> bool {
551    true
552}
553
554pub fn datafile_ref(
555    datastore_id: crate::PublicUuid,
556    datafile_id: ahri_tre_types::AssetId,
557) -> DataFileRef {
558    DataFileRef {
559        datastore_id,
560        kind: crate::refs::ObjectKind::Asset,
561        id: crate::PublicUuid::from_uuid(datafile_id.0),
562    }
563}
564
565pub fn version_ref(
566    datastore_id: crate::PublicUuid,
567    version_id: ahri_tre_types::VersionId,
568) -> VersionRef {
569    VersionRef {
570        datastore_id,
571        kind: crate::refs::ObjectKind::AssetVersion,
572        id: crate::PublicUuid::from_uuid(version_id.0),
573    }
574}
575
576impl IngestDatasetFileSource {
577    pub fn restricted_local_path(path: impl Into<String>) -> Self {
578        Self::RestrictedLocalPath {
579            path: path.into(),
580            request_only: true,
581        }
582    }
583
584    pub fn https_uri(uri: impl Into<String>) -> Self {
585        Self::HttpsUri {
586            uri: IngestRemoteUri::new(uri),
587        }
588    }
589
590    pub const fn source_kind(&self) -> IngestSourceKind {
591        match self {
592            Self::Stream => IngestSourceKind::UploadedContent,
593            Self::RestrictedLocalPath { .. } => IngestSourceKind::RestrictedLocalPath,
594            Self::HttpsUri { .. } => IngestSourceKind::HttpsUri,
595        }
596    }
597
598    pub const fn request_only(&self) -> bool {
599        match self {
600            Self::Stream => true,
601            Self::RestrictedLocalPath { request_only, .. } => *request_only,
602            Self::HttpsUri { uri } => uri.request_only || uri.credential_bearing,
603        }
604    }
605}
606
607impl SqlSource {
608    pub const fn request_only(&self) -> bool {
609        match self {
610            Self::Stream { .. } | Self::Remote { .. } => true,
611            Self::Duckdb { request_only, .. } | Self::Sqlite { request_only, .. } => *request_only,
612        }
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn uploaded_csv_source_round_trips_explicit_bounded_content_metadata() {
622        let source = IngestFileSource::UploadedContent {
623            filename: "visits.csv".to_string(),
624            media_type: "text/csv".to_string(),
625            content_length: 18,
626            digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
627                .to_string(),
628            content_base64: "aWQsdmFsdWUKMSxvbmUK".to_string(),
629        };
630
631        let encoded = serde_json::to_value(&source).expect("upload source should serialize");
632        assert_eq!(encoded["kind"], "uploaded_content");
633        assert_eq!(encoded["filename"], "visits.csv");
634        assert_eq!(encoded["media_type"], "text/csv");
635        assert_eq!(encoded["content_length"], 18);
636        assert_eq!(
637            serde_json::from_value::<IngestFileSource>(encoded)
638                .expect("upload source should deserialize"),
639            source
640        );
641        assert_eq!(source.source_kind(), IngestSourceKind::UploadedContent);
642        assert!(source.request_only());
643    }
644}
645
646/// Derived High form metadata. Counts, values and dictionary content require disclosure admission.
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648pub struct RedcapFormSummary {
649    pub catalog: crate::asset::SafeAssetCatalog,
650    pub lineage: crate::transformation::TransformationSummary,
651    pub source: DatasetMaterializationSourceSummary,
652}
653
654/// A synchronous form result. An inspection-required outcome is never safe to replay.
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
656pub struct RedcapFormOutcome {
657    pub dataset: String,
658    pub outcome: RedcapFormOutcomeKind,
659}
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case")]
662pub enum RedcapFormOutcomeKind {
663    Complete,
664    NotMaterialized,
665    InspectionRequired,
666}