Skip to main content

ahri_tre_protocol/
dictionary.rs

1use crate::{
2    ProtocolVersion, PublicName,
3    domain::{DomainSelector, DomainSummary},
4    pagination::PageRequest,
5    public_error::{ProtocolError, ValidationIssue, ValidationIssueCode},
6    refs::{DomainRef, SessionSelector, TagSummary, VariableRef, VocabularyRef},
7    search::{PredicateSet, SearchCursorBinding, TagPredicate, TextPredicate},
8};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13pub enum VariableSelector {
14    Id {
15        variable: VariableRef,
16        #[serde(default, skip_serializing_if = "Option::is_none")]
17        domain: Option<DomainSelector>,
18    },
19    Name {
20        domain: DomainSelector,
21        name: PublicName,
22    },
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct VocabularySummary {
27    pub vocabulary: VocabularyRef,
28    pub domain: DomainRef,
29    pub name: String,
30    /// Available for proven schema declarations or admitted content.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub item_count: Option<usize>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub description: Option<String>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub tags: Vec<TagSummary>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct VocabularyItemSummary {
41    pub value: i32,
42    pub code: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub description: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct VocabularyDetail {
49    #[serde(default)]
50    pub items_withheld: bool,
51    pub summary: VocabularySummary,
52    #[serde(default)]
53    pub items: Vec<VocabularyItemSummary>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct VariableSummary {
58    pub variable: VariableRef,
59    pub domain: DomainRef,
60    pub name: String,
61    pub value_type: String,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub vocabulary: Option<VocabularySummary>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub key_role: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub ontology_class: Option<String>,
70    #[serde(default)]
71    pub tags: Vec<TagSummary>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct VariableDetail {
76    #[serde(default)]
77    pub vocabulary_items_withheld: bool,
78    pub summary: VariableSummary,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub value_format: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub note: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub ontology_namespace: Option<String>,
85    #[serde(default)]
86    pub vocabulary_items: Vec<VocabularyItemSummary>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub domain_detail: Option<DomainSummary>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct DictionaryWarnings {
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub warnings: Vec<String>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub validation_errors: Vec<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct DictionaryMutationIntent {
101    #[serde(default)]
102    pub force_update: bool,
103    #[serde(default)]
104    pub check_dependencies: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ListVariablesRequest {
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub session: Option<SessionSelector>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub domain: Option<DomainSelector>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub study: Option<crate::study::StudySelector>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct GetVariableRequest {
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub session: Option<SessionSelector>,
121    pub variable: VariableSelector,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct AddVariableRequest {
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub session: Option<SessionSelector>,
128    pub domain: DomainSelector,
129    pub name: PublicName,
130    pub value_type: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub value_format: Option<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub key_role: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub description: Option<String>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub ontology_namespace: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub ontology_class: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub vocabulary: Option<PublicName>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub vocabulary_items: Option<String>,
145    pub intent: DictionaryMutationIntent,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct UpdateVariableRequest {
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub session: Option<SessionSelector>,
152    pub variable: VariableSelector,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub value_type: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub value_format: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub key_role: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub description: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub ontology_namespace: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub ontology_class: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub vocabulary: Option<PublicName>,
167    pub intent: DictionaryMutationIntent,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct VariableListResponse {
172    pub variables: Vec<VariableSummary>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub warnings: Option<DictionaryWarnings>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct VariableDetailResponse {
179    pub variable: VariableDetail,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub warnings: Option<DictionaryWarnings>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(tag = "kind", rename_all = "snake_case")]
186pub enum VocabularySelector {
187    Id {
188        vocabulary: VocabularyRef,
189        #[serde(default, skip_serializing_if = "Option::is_none")]
190        domain: Option<DomainSelector>,
191    },
192    Name {
193        domain: DomainSelector,
194        name: PublicName,
195    },
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct ListVocabulariesRequest {
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub session: Option<SessionSelector>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub domain: Option<DomainSelector>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub study: Option<crate::study::StudySelector>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209pub struct GetVocabularyRequest {
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub session: Option<SessionSelector>,
212    pub vocabulary: VocabularySelector,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct AddVocabularyRequest {
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub session: Option<SessionSelector>,
219    pub domain: DomainSelector,
220    pub name: PublicName,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub description: Option<String>,
223    pub items: String,
224    pub intent: DictionaryMutationIntent,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct VocabularyListResponse {
229    pub vocabularies: Vec<VocabularySummary>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub warnings: Option<DictionaryWarnings>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct VocabularyDetailResponse {
236    pub vocabulary: VocabularyDetail,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub warnings: Option<DictionaryWarnings>,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct SearchVariablesRequest {
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub session: Option<SessionSelector>,
245    pub predicates: PredicateSet<VariablePredicate>,
246    pub page: PageRequest,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct BrowserVariableSearchRequest {
251    pub datastore_id: crate::PublicUuid,
252    pub search: SearchVariablesRequest,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct VariableSearchCursorAnchor {
257    pub domain: DomainRef,
258    pub variable: VariableRef,
259    pub name: String,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct VariableSearchCursorQuery {
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub session: Option<SessionSelector>,
266    pub predicates: PredicateSet<VariablePredicate>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct VariableSearchCursor {
271    pub binding: SearchCursorBinding<VariableSearchCursorQuery>,
272    pub after: VariableSearchCursorAnchor,
273}
274
275/// Browser-safe response for a datastore-scoped dictionary-variable search.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct BrowserVariableSearchResponse {
278    pub protocol_version: ProtocolVersion,
279    pub datastore_id: crate::PublicUuid,
280    pub family: String,
281    pub predicates: PredicateSet<VariablePredicate>,
282    #[serde(default)]
283    pub variables: Vec<crate::dictionary_discovery::BrowserDictionaryVariableDiscoveryRecord>,
284    pub returned_count: u16,
285    pub requested_limit: u16,
286    pub ordering: crate::study::BrowserSearchOrdering,
287    pub has_more: bool,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub next_cursor: Option<String>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub empty_state: Option<BrowserVariableSearchEmptyState>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct BrowserVariableSearchEmptyState {
296    pub code: String,
297    pub message: String,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct BrowserVariableSearchCursorQuery {
302    pub datastore_id: crate::PublicUuid,
303    pub family: String,
304    pub predicates: PredicateSet<VariablePredicate>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct BrowserVariableSearchCursor {
309    pub binding: SearchCursorBinding<BrowserVariableSearchCursorQuery>,
310    pub after: VariableSearchCursorAnchor,
311}
312
313#[allow(clippy::result_large_err)]
314impl SearchVariablesRequest {
315    pub fn validate(&mut self, protocol_version: &ProtocolVersion) -> Result<(), ProtocolError> {
316        let mut issues = Vec::new();
317        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
318        collect_variable_predicate_issues(&mut self.predicates, &mut issues);
319        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
320        collect_cursor_issues(self, protocol_version, &mut issues);
321
322        if issues.is_empty() {
323            Ok(())
324        } else {
325            Err(ProtocolError::validation(
326                "variable search request is invalid",
327                issues,
328            ))
329        }
330    }
331
332    pub fn decoded_cursor(
333        &self,
334        protocol_version: &ProtocolVersion,
335    ) -> Result<Option<VariableSearchCursorAnchor>, ProtocolError> {
336        match self.page.cursor.as_deref() {
337            None => Ok(None),
338            Some(raw_cursor) => {
339                let cursor: VariableSearchCursor =
340                    serde_json::from_str(raw_cursor).map_err(|error| {
341                        ProtocolError::validation(
342                            "variable search request is invalid",
343                            vec![ValidationIssue::new(
344                                "body.page.cursor",
345                                ValidationIssueCode::InvalidFormat,
346                                format!("variable search cursor is invalid: {error}"),
347                            )],
348                        )
349                    })?;
350                let expected = self.cursor_binding(protocol_version);
351                if cursor.binding != expected {
352                    return Err(ProtocolError::validation(
353                        "variable search request is invalid",
354                        vec![ValidationIssue::new(
355                            "body.page.cursor",
356                            ValidationIssueCode::Conflict,
357                            "variable search cursor does not match the request predicates, page limit, or protocol version",
358                        )],
359                    ));
360                }
361                Ok(Some(cursor.after))
362            }
363        }
364    }
365
366    pub fn encode_cursor(
367        &self,
368        protocol_version: &ProtocolVersion,
369        after: VariableSearchCursorAnchor,
370    ) -> Result<String, ProtocolError> {
371        serde_json::to_string(&VariableSearchCursor {
372            binding: self.cursor_binding(protocol_version),
373            after,
374        })
375        .map_err(|error| {
376            ProtocolError::new(
377                crate::public_error::ProtocolErrorCode::Internal,
378                format!("failed to encode variable search cursor: {error}"),
379            )
380        })
381    }
382
383    fn cursor_binding(
384        &self,
385        protocol_version: &ProtocolVersion,
386    ) -> SearchCursorBinding<VariableSearchCursorQuery> {
387        SearchCursorBinding {
388            query: VariableSearchCursorQuery {
389                session: self.session.clone(),
390                predicates: self.predicates.clone(),
391            },
392            page_limit: self.page.limit,
393            protocol_version: protocol_version.to_string(),
394        }
395    }
396
397    #[allow(clippy::result_large_err)]
398    pub fn validate_for_browser(
399        &mut self,
400        datastore_id: crate::PublicUuid,
401        protocol_version: &ProtocolVersion,
402    ) -> Result<(), ProtocolError> {
403        let mut issues = Vec::new();
404        if self.session.is_some() {
405            issues.push(ValidationIssue::new(
406                "body.session",
407                ValidationIssueCode::Unsupported,
408                "daemon session is not accepted for browser dictionary-variable search",
409            ));
410        }
411        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
412        collect_variable_predicate_issues(&mut self.predicates, &mut issues);
413        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
414        if let Some(raw_cursor) = self.page.cursor.as_deref() {
415            match serde_json::from_str::<BrowserVariableSearchCursor>(raw_cursor) {
416                Ok(cursor)
417                    if cursor.binding
418                        == self.browser_cursor_binding(datastore_id, protocol_version) => {}
419                Ok(_) => issues.push(ValidationIssue::new(
420                    "body.page.cursor",
421                    ValidationIssueCode::Conflict,
422                    "browser dictionary-variable search cursor does not match the datastore, search family, predicates, page limit, or protocol version",
423                )),
424                Err(_) => issues.push(ValidationIssue::new(
425                    "body.page.cursor",
426                    ValidationIssueCode::InvalidFormat,
427                    "browser dictionary-variable search cursor is invalid",
428                )),
429            }
430        }
431        if issues.is_empty() {
432            Ok(())
433        } else {
434            Err(ProtocolError::validation(
435                "browser dictionary-variable search request is invalid",
436                issues,
437            ))
438        }
439    }
440
441    #[allow(clippy::result_large_err)]
442    pub fn browser_decoded_cursor(
443        &self,
444        datastore_id: crate::PublicUuid,
445        protocol_version: &ProtocolVersion,
446    ) -> Result<Option<VariableSearchCursorAnchor>, ProtocolError> {
447        let Some(raw_cursor) = self.page.cursor.as_deref() else {
448            return Ok(None);
449        };
450        let cursor: BrowserVariableSearchCursor =
451            serde_json::from_str(raw_cursor).map_err(|_| {
452                ProtocolError::validation(
453                    "browser dictionary-variable search request is invalid",
454                    vec![ValidationIssue::new(
455                        "body.page.cursor",
456                        ValidationIssueCode::InvalidFormat,
457                        "browser dictionary-variable search cursor is invalid",
458                    )],
459                )
460            })?;
461        if cursor.binding != self.browser_cursor_binding(datastore_id, protocol_version) {
462            return Err(ProtocolError::validation(
463                "browser dictionary-variable search request is invalid",
464                vec![ValidationIssue::new(
465                    "body.page.cursor",
466                    ValidationIssueCode::Conflict,
467                    "browser dictionary-variable search cursor does not match the datastore, search family, predicates, page limit, or protocol version",
468                )],
469            ));
470        }
471        Ok(Some(cursor.after))
472    }
473
474    #[allow(clippy::result_large_err)]
475    pub fn encode_browser_cursor(
476        &self,
477        datastore_id: crate::PublicUuid,
478        protocol_version: &ProtocolVersion,
479        after: VariableSearchCursorAnchor,
480    ) -> Result<String, ProtocolError> {
481        serde_json::to_string(&BrowserVariableSearchCursor {
482            binding: self.browser_cursor_binding(datastore_id, protocol_version),
483            after,
484        })
485        .map_err(|_| {
486            ProtocolError::new(
487                crate::public_error::ProtocolErrorCode::Internal,
488                "failed to encode browser dictionary-variable search cursor",
489            )
490        })
491    }
492
493    fn browser_cursor_binding(
494        &self,
495        datastore_id: crate::PublicUuid,
496        protocol_version: &ProtocolVersion,
497    ) -> SearchCursorBinding<BrowserVariableSearchCursorQuery> {
498        SearchCursorBinding {
499            query: BrowserVariableSearchCursorQuery {
500                datastore_id,
501                family: "dictionary_variables".to_string(),
502                predicates: self.predicates.clone(),
503            },
504            page_limit: self.page.limit,
505            protocol_version: protocol_version.to_string(),
506        }
507    }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(tag = "kind", rename_all = "snake_case")]
512pub enum VariablePredicate {
513    Text(TextPredicate<VariableTextField>),
514    Domain { domain: DomainSelector },
515    ValueType { value_type: String },
516    Vocabulary { vocabulary: VocabularyRef },
517    KeyRole { key_role: String },
518    Tag(TagPredicate),
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(rename_all = "snake_case")]
523pub enum VariableTextField {
524    Name,
525    Description,
526    ValueType,
527    VocabularyName,
528    OntologyClass,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532pub struct VariableSearchResponse {
533    pub session: crate::session::SessionStatusPayload,
534    pub variables: Vec<VariableSummary>,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub next_cursor: Option<String>,
537}
538
539fn collect_predicate_set_issues<P>(
540    path: &str,
541    predicates: &PredicateSet<P>,
542    issues: &mut Vec<ValidationIssue>,
543) {
544    if let Err(message) = predicates.validate() {
545        issues.push(ValidationIssue::new(
546            path,
547            ValidationIssueCode::InvalidValue,
548            message,
549        ));
550    }
551}
552
553fn collect_variable_predicate_issues(
554    predicates: &mut PredicateSet<VariablePredicate>,
555    issues: &mut Vec<ValidationIssue>,
556) {
557    for (index, predicate) in predicates.predicates_mut().iter_mut().enumerate() {
558        match predicate {
559            VariablePredicate::Text(text) => {
560                if let Err(message) = text.validate() {
561                    issues.push(ValidationIssue::new(
562                        format!("body.predicates.predicates[{index}].value"),
563                        ValidationIssueCode::InvalidValue,
564                        message,
565                    ));
566                }
567            }
568            VariablePredicate::ValueType { value_type } => {
569                collect_bounded_string_issue(
570                    value_type,
571                    format!("body.predicates.predicates[{index}].value_type"),
572                    "variable value type",
573                    issues,
574                );
575            }
576            VariablePredicate::KeyRole { key_role } => {
577                collect_bounded_string_issue(
578                    key_role,
579                    format!("body.predicates.predicates[{index}].key_role"),
580                    "variable key role",
581                    issues,
582                );
583            }
584            VariablePredicate::Domain { .. }
585            | VariablePredicate::Vocabulary { .. }
586            | VariablePredicate::Tag(_) => {}
587        }
588    }
589}
590
591fn collect_bounded_string_issue(
592    value: &mut String,
593    path: String,
594    label: &str,
595    issues: &mut Vec<ValidationIssue>,
596) {
597    *value = value.trim().to_string();
598    if value.is_empty() {
599        issues.push(ValidationIssue::new(
600            path,
601            ValidationIssueCode::TooShort,
602            format!("{label} must not be empty"),
603        ));
604    } else if value.len() > crate::search::MAX_TEXT_VALUE_LENGTH {
605        issues.push(ValidationIssue::new(
606            path,
607            ValidationIssueCode::TooLong,
608            format!(
609                "{label} must be at most {} characters",
610                crate::search::MAX_TEXT_VALUE_LENGTH
611            ),
612        ));
613    }
614}
615
616fn collect_page_issues(path: &str, limit: u16, issues: &mut Vec<ValidationIssue>) {
617    if limit == 0 || limit > crate::search::MAX_PAGE_LIMIT {
618        issues.push(ValidationIssue::new(
619            path,
620            ValidationIssueCode::OutOfRange,
621            if limit == 0 {
622                "page limit must be greater than zero".to_string()
623            } else {
624                format!(
625                    "page limit must be at most {}",
626                    crate::search::MAX_PAGE_LIMIT
627                )
628            },
629        ));
630    }
631}
632
633fn collect_cursor_issues(
634    request: &SearchVariablesRequest,
635    protocol_version: &ProtocolVersion,
636    issues: &mut Vec<ValidationIssue>,
637) {
638    if let Some(raw_cursor) = request.page.cursor.as_deref() {
639        let cursor: VariableSearchCursor = match serde_json::from_str(raw_cursor) {
640            Ok(cursor) => cursor,
641            Err(error) => {
642                issues.push(ValidationIssue::new(
643                    "body.page.cursor",
644                    ValidationIssueCode::InvalidFormat,
645                    format!("variable search cursor is invalid: {error}"),
646                ));
647                return;
648            }
649        };
650
651        if cursor.binding != request.cursor_binding(protocol_version) {
652            issues.push(ValidationIssue::new(
653                "body.page.cursor",
654                ValidationIssueCode::Conflict,
655                "variable search cursor does not match the request predicates, page limit, or protocol version",
656            ));
657        }
658    }
659}