Skip to main content

keldra_api/
lib.rs

1//! Generated Rust types for the Keldra 0.18 v1 gRPC API.
2
3pub mod v1 {
4    tonic::include_proto!("keldra.v1");
5}
6
7pub mod typed_json;
8
9/// Generated descriptor authority for protocol-contract tests and reflection.
10pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("keldra_descriptor");
11
12/// A Boolean predicate expression rejected before it is sent to Keldra.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum PredicateExpressionError {
15    EmptyConjunction,
16    EmptyDisjunction,
17}
18
19impl std::fmt::Display for PredicateExpressionError {
20    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::EmptyConjunction => {
23                formatter.write_str("a predicate conjunction requires at least one child")
24            }
25            Self::EmptyDisjunction => {
26                formatter.write_str("a predicate disjunction requires at least one child")
27            }
28        }
29    }
30}
31
32impl std::error::Error for PredicateExpressionError {}
33
34impl v1::IndexPredicateExpression {
35    /// Construct one leaf expression.
36    pub fn leaf(predicate: v1::IndexPredicate) -> Self {
37        Self {
38            expression: Some(v1::index_predicate_expression::Expression::Predicate(
39                predicate,
40            )),
41        }
42    }
43
44    /// Construct a non-empty conjunction.
45    pub fn all(
46        expressions: impl IntoIterator<Item = Self>,
47    ) -> Result<Self, PredicateExpressionError> {
48        let expressions = expressions.into_iter().collect::<Vec<_>>();
49        if expressions.is_empty() {
50            return Err(PredicateExpressionError::EmptyConjunction);
51        }
52        Ok(Self {
53            expression: Some(v1::index_predicate_expression::Expression::Conjunction(
54                v1::IndexPredicateConjunction { expressions },
55            )),
56        })
57    }
58
59    /// Construct a non-empty disjunction.
60    pub fn any(
61        expressions: impl IntoIterator<Item = Self>,
62    ) -> Result<Self, PredicateExpressionError> {
63        let expressions = expressions.into_iter().collect::<Vec<_>>();
64        if expressions.is_empty() {
65            return Err(PredicateExpressionError::EmptyDisjunction);
66        }
67        Ok(Self {
68            expression: Some(v1::index_predicate_expression::Expression::Disjunction(
69                v1::IndexPredicateDisjunction { expressions },
70            )),
71        })
72    }
73
74    /// Negate this complete expression.
75    pub fn negated(self) -> Self {
76        Self {
77            expression: Some(v1::index_predicate_expression::Expression::Negation(
78                Box::new(v1::IndexPredicateNegation {
79                    expression: Some(Box::new(self)),
80                }),
81            )),
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use prost::Message;
89    use prost_types::{DescriptorProto, FileDescriptorSet, ServiceDescriptorProto};
90
91    use super::v1::{
92        CloneObjectRequest, DeletedObject, Durability, LinkObjectRequest, NeverExisted,
93        ObjectAddress, ObjectHead, PresentObject, PutIfVersionOperation, UnlinkObjectRequest,
94        clone_object_request, object_head,
95    };
96
97    fn descriptors() -> FileDescriptorSet {
98        FileDescriptorSet::decode(super::FILE_DESCRIPTOR_SET).unwrap()
99    }
100
101    fn service<'a>(set: &'a FileDescriptorSet, name: &str) -> &'a ServiceDescriptorProto {
102        set.file
103            .iter()
104            .flat_map(|file| &file.service)
105            .find(|service| service.name.as_deref() == Some(name))
106            .unwrap_or_else(|| panic!("missing generated service descriptor {name}"))
107    }
108
109    fn message<'a>(set: &'a FileDescriptorSet, name: &str) -> &'a DescriptorProto {
110        set.file
111            .iter()
112            .flat_map(|file| &file.message_type)
113            .find(|message| message.name.as_deref() == Some(name))
114            .unwrap_or_else(|| panic!("missing generated message descriptor {name}"))
115    }
116
117    fn field_number(set: &FileDescriptorSet, message_name: &str, field_name: &str) -> i32 {
118        message(set, message_name)
119            .field
120            .iter()
121            .find(|field| field.name.as_deref() == Some(field_name))
122            .and_then(|field| field.number)
123            .unwrap_or_else(|| panic!("missing generated field {message_name}.{field_name}"))
124    }
125
126    #[test]
127    fn exact_path_states_are_distinct() {
128        let states = [
129            ObjectHead {
130                state: Some(object_head::State::Present(PresentObject {
131                    version: 7,
132                    content_hash: vec![7; 32],
133                    content_length: 99,
134                    content_type: String::new(),
135                })),
136            },
137            ObjectHead {
138                state: Some(object_head::State::Deleted(DeletedObject { version: 8 })),
139            },
140            ObjectHead {
141                state: Some(object_head::State::NeverExisted(NeverExisted {})),
142            },
143        ];
144
145        assert!(matches!(
146            &states[0].state,
147            Some(object_head::State::Present(_))
148        ));
149        assert!(matches!(
150            &states[1].state,
151            Some(object_head::State::Deleted(_))
152        ));
153        assert!(matches!(
154            &states[2].state,
155            Some(object_head::State::NeverExisted(_))
156        ));
157    }
158
159    #[test]
160    fn clone_object_wire_round_trip_preserves_both_identities_and_exact_cas() {
161        let request = CloneObjectRequest {
162            source: Some(ObjectAddress {
163                tenant: "tenant".into(),
164                bucket: "bucket".into(),
165                path: "source".into(),
166            }),
167            source_version: 17,
168            destination: Some(ObjectAddress {
169                tenant: "tenant".into(),
170                bucket: "bucket".into(),
171                path: "destination".into(),
172            }),
173            command_id: "clone-17".into(),
174            durability: Durability::Replicated as i32,
175            operation: Some(clone_object_request::Operation::PutIfVersion(
176                PutIfVersionOperation {
177                    expected_version: 11,
178                },
179            )),
180        };
181
182        let decoded = CloneObjectRequest::decode(request.encode_to_vec().as_slice()).unwrap();
183        assert_eq!(decoded, request);
184    }
185
186    #[test]
187    fn object_link_wire_contract_keeps_link_and_target_distinct() {
188        let link = ObjectAddress {
189            tenant: "tenant".into(),
190            bucket: "bucket".into(),
191            path: "alias".into(),
192        };
193        let request = LinkObjectRequest {
194            link: Some(link.clone()),
195            target: Some(ObjectAddress {
196                tenant: "tenant".into(),
197                bucket: "bucket".into(),
198                path: "target".into(),
199            }),
200            command_id: "link-1".into(),
201            durability: Durability::Replicated as i32,
202        };
203        assert_eq!(
204            LinkObjectRequest::decode(request.encode_to_vec().as_slice()).unwrap(),
205            request
206        );
207        let unlink = UnlinkObjectRequest {
208            link: Some(link),
209            command_id: "unlink-1".into(),
210            durability: Durability::Local as i32,
211        };
212        assert_eq!(
213            UnlinkObjectRequest::decode(unlink.encode_to_vec().as_slice()).unwrap(),
214            unlink
215        );
216    }
217
218    #[test]
219    fn schema_keeps_removed_capabilities_out() {
220        let set = descriptors();
221        let object_methods = service(&set, "ObjectService")
222            .method
223            .iter()
224            .filter_map(|method| method.name.as_deref())
225            .collect::<Vec<_>>();
226        for required in [
227            "StartPut",
228            "Put",
229            "PutEnd",
230            "WatchPrefix",
231            "ListObjects",
232            "CloneObject",
233            "DeleteVersion",
234            "ListObjectVersions",
235        ] {
236            assert!(object_methods.contains(&required));
237        }
238        let start_put = service(&set, "ObjectService")
239            .method
240            .iter()
241            .find(|method| method.name.as_deref() == Some("StartPut"))
242            .unwrap();
243        assert_eq!(
244            start_put.input_type.as_deref(),
245            Some(".keldra.v1.PutHeader")
246        );
247        assert_eq!(
248            start_put.output_type.as_deref(),
249            Some(".keldra.v1.PutToken")
250        );
251        let put = service(&set, "ObjectService")
252            .method
253            .iter()
254            .find(|method| method.name.as_deref() == Some("Put"))
255            .unwrap();
256        assert_eq!(put.client_streaming, Some(true));
257        for removed in [
258            "UploadBlob",
259            "PublishObject",
260            "PutObject",
261            "ListPrefix",
262            "BeginTransaction",
263            "CommitTransaction",
264            "RegisterProgram",
265        ] {
266            assert!(!object_methods.contains(&removed));
267        }
268        let index_methods = service(&set, "IndexService")
269            .method
270            .iter()
271            .filter_map(|method| method.name.as_deref())
272            .collect::<Vec<_>>();
273        for required in [
274            "CreateIndex",
275            "UpdateIndex",
276            "GetIndex",
277            "ListIndices",
278            "DeleteIndex",
279            "QueryIndex",
280        ] {
281            assert!(index_methods.contains(&required));
282        }
283        assert_eq!(field_number(&set, "InvokeProgramRequest", "program"), 1);
284        assert_eq!(
285            field_number(
286                &set,
287                "InvokeProgramResponse",
288                "executor_nomination_log_index"
289            ),
290            4
291        );
292        assert_eq!(
293            field_number(&set, "InvokeProgramResponse", "commit_log_index"),
294            5
295        );
296        assert_eq!(
297            field_number(&set, "BucketPolicy", "immutable_path_prefixes"),
298            1
299        );
300        assert_eq!(
301            field_number(&set, "BucketPolicy", "program_only_path_prefixes"),
302            2
303        );
304        let put_header = message(&set, "PutHeader");
305        assert!(
306            put_header
307                .oneof_decl
308                .iter()
309                .any(|oneof| oneof.name.as_deref() == Some("operation"))
310        );
311        for operation in ["put", "put_if_absent", "put_if_version", "put_immutable"] {
312            assert_eq!(
313                put_header
314                    .field
315                    .iter()
316                    .find(|field| field.name.as_deref() == Some(operation))
317                    .and_then(|field| field.oneof_index),
318                Some(0)
319            );
320        }
321        assert!(
322            !set.file
323                .iter()
324                .flat_map(|file| &file.message_type)
325                .any(|message| message.name.as_deref() == Some("BlobRef"))
326        );
327    }
328
329    #[test]
330    fn accounting_schema_separates_logical_and_replica_inclusive_physical_usage() {
331        let set = descriptors();
332        for (message_name, field) in [
333            ("AccountingLogicalUsage", "billable_logical_bytes"),
334            (
335                "AccountingLogicalUsage",
336                "retained_non_billable_logical_bytes",
337            ),
338            ("AccountingLogicalUsage", "visible_file_count"),
339            ("ClusterPhysicalStorage", "live_payload_blob_bytes"),
340            ("ClusterPhysicalStorage", "garbage_payload_blob_bytes"),
341            ("ClusterPhysicalStorage", "payload_sst_bytes"),
342            ("ClusterPhysicalStorage", "metadata_index_sst_bytes"),
343            ("ClusterPhysicalStorage", "wal_bytes"),
344            ("ClusterPhysicalStorage", "active_node_count"),
345            ("ClusterPhysicalStorage", "reported_node_count"),
346            ("ClusterPhysicalStorage", "reported_storage_replica_count"),
347            ("ClusterLogicalFileCounts", "visible_file_count"),
348            ("ClusterLogicalFileCounts", "active_source_node_count"),
349            ("ClusterLogicalFileCounts", "reported_source_node_count"),
350            ("ClusterCapabilities", "logical_file_counts"),
351            ("AccountingSnapshot", "tenant_physical_bytes"),
352        ] {
353            assert!(
354                message(&set, message_name)
355                    .field
356                    .iter()
357                    .any(|candidate| candidate.name.as_deref() == Some(field)),
358                "missing descriptor field {message_name}.{field}"
359            );
360        }
361        assert!(
362            !set.file
363                .iter()
364                .flat_map(|file| &file.message_type)
365                .flat_map(|message| &message.field)
366                .any(|field| matches!(
367                    field.name.as_deref(),
368                    Some("logical_stored_bytes" | "object_count")
369                ))
370        );
371    }
372
373    #[test]
374    fn generated_index_client_is_publicly_exposed() {
375        let _: Option<
376            super::v1::index_service_client::IndexServiceClient<tonic::transport::Channel>,
377        > = None;
378    }
379
380    #[test]
381    fn typed_json_fields_have_explicit_type_cardinality_and_capabilities() {
382        use super::v1::{
383            IndexField, IndexFieldCapability, IndexFieldCardinality, KeywordIndexField, index_field,
384        };
385
386        let field = IndexField {
387            name: "document_id".into(),
388            json_pointer: "/id".into(),
389            cardinality: IndexFieldCardinality::Single as i32,
390            capabilities: vec![IndexFieldCapability::Exact as i32],
391            field_type: Some(index_field::FieldType::Keyword(KeywordIndexField {})),
392        };
393
394        assert!(matches!(
395            field.field_type,
396            Some(index_field::FieldType::Keyword(_))
397        ));
398        assert_eq!(field.capabilities, [IndexFieldCapability::Exact as i32]);
399
400        let date = super::v1::DateIndexField {
401            strftime_pattern: "%Y-%m-%d".into(),
402        };
403        assert!(matches!(
404            index_field::FieldType::Date(date),
405            index_field::FieldType::Date(value) if value.strftime_pattern == "%Y-%m-%d"
406        ));
407
408        let set = descriptors();
409        assert!(
410            service(&set, "IndexService")
411                .method
412                .iter()
413                .any(|method| method.name.as_deref() == Some("ListIndices"))
414        );
415        assert_eq!(field_number(&set, "ListIndicesResponse", "indices"), 1);
416        assert!(
417            !service(&set, "IndexService")
418                .method
419                .iter()
420                .any(|method| method.name.as_deref() == Some("ListIndexes"))
421        );
422        assert!(
423            !set.file
424                .iter()
425                .flat_map(|file| &file.message_type)
426                .flat_map(|message| &message.field)
427                .any(|field| matches!(field.name.as_deref(), Some("fields_json" | "multi_valued")))
428        );
429    }
430
431    #[test]
432    fn typed_json_queries_expose_fielded_text_facets_and_aggregates() {
433        use super::v1::index_predicate_expression::Expression;
434        use super::v1::{
435            IndexAggregateOperation, IndexAggregateRequest, IndexAggregateResult, IndexFacetBucket,
436            IndexFacetRequest, IndexFacetResult, IndexPredicate, IndexPredicateConjunction,
437            IndexPredicateExpression, IndexPredicateOperator, IndexQueryHit, QueryIndexResponse,
438            TypedJsonIndexQuery,
439        };
440
441        let query = TypedJsonIndexQuery {
442            predicate: Some(IndexPredicateExpression {
443                expression: Some(Expression::Conjunction(IndexPredicateConjunction {
444                    expressions: [
445                        IndexPredicateOperator::FullText,
446                        IndexPredicateOperator::Phrase,
447                    ]
448                    .into_iter()
449                    .map(|operator| IndexPredicateExpression {
450                        expression: Some(Expression::Predicate(IndexPredicate {
451                            field: "summary".into(),
452                            operator: operator as i32,
453                            values_json: vec![br#""memory safety""#.to_vec()],
454                        })),
455                    })
456                    .collect(),
457                })),
458            }),
459            order: Vec::new(),
460            facets: vec![IndexFacetRequest {
461                field: "ecosystem".into(),
462                limit: 10,
463            }],
464            aggregates: vec![IndexAggregateRequest {
465                field: "severity".into(),
466                operation: IndexAggregateOperation::Average as i32,
467            }],
468        };
469        let response = QueryIndexResponse {
470            hits: vec![IndexQueryHit {
471                address: None,
472                object_version: 7,
473                score: Some(0.75),
474            }],
475            next_page_token: Vec::new(),
476            freshness: None,
477            facet_results: vec![IndexFacetResult {
478                field: "ecosystem".into(),
479                buckets: vec![IndexFacetBucket {
480                    value_json: br#""cargo""#.to_vec(),
481                    count: 4,
482                }],
483            }],
484            aggregate_results: vec![IndexAggregateResult {
485                field: "severity".into(),
486                operation: IndexAggregateOperation::Average as i32,
487                value_json: Some(b"7.5".to_vec()),
488                contributing_count: 4,
489            }],
490        };
491
492        assert_eq!(query.facets[0].limit, 10);
493        assert!(matches!(
494            query.predicate.unwrap().expression,
495            Some(Expression::Conjunction(_))
496        ));
497        assert_eq!(response.hits[0].object_version, 7);
498        assert_eq!(response.aggregate_results[0].contributing_count, 4);
499
500        let set = descriptors();
501        assert_eq!(field_number(&set, "TypedJsonIndexQuery", "predicate"), 5);
502        assert_eq!(
503            field_number(&set, "MetadataFilterIndexQuery", "predicate"),
504            2
505        );
506    }
507
508    #[test]
509    fn predicate_expression_helpers_reject_empty_boolean_operators() {
510        use super::PredicateExpressionError;
511        use super::v1::index_predicate_expression::Expression;
512        use super::v1::{IndexPredicate, IndexPredicateExpression, IndexPredicateOperator};
513
514        let leaf = IndexPredicateExpression::leaf(IndexPredicate {
515            field: "status".into(),
516            operator: IndexPredicateOperator::Exists as i32,
517            values_json: Vec::new(),
518        });
519        assert!(matches!(
520            IndexPredicateExpression::all([leaf.clone()])
521                .unwrap()
522                .expression,
523            Some(Expression::Conjunction(_))
524        ));
525        assert!(matches!(
526            IndexPredicateExpression::any([leaf.clone()])
527                .unwrap()
528                .expression,
529            Some(Expression::Disjunction(_))
530        ));
531        assert!(matches!(
532            leaf.negated().expression,
533            Some(Expression::Negation(_))
534        ));
535        assert_eq!(
536            IndexPredicateExpression::all(Vec::new()).unwrap_err(),
537            PredicateExpressionError::EmptyConjunction
538        );
539        assert_eq!(
540            IndexPredicateExpression::any(Vec::new()).unwrap_err(),
541            PredicateExpressionError::EmptyDisjunction
542        );
543    }
544
545    #[test]
546    fn generated_personaldb_client_is_publicly_exposed() {
547        let _: Option<
548            super::v1::personal_db_service_client::PersonalDbServiceClient<
549                tonic::transport::Channel,
550            >,
551        > = None;
552
553        let set = descriptors();
554        let methods = service(&set, "PersonalDbService")
555            .method
556            .iter()
557            .filter_map(|method| method.name.as_deref())
558            .collect::<Vec<_>>();
559        for required in [
560            "CreateGroup",
561            "DescribeGroup",
562            "ListGroups",
563            "GrantGroupRole",
564            "RevokeGroupRole",
565            "AppendEntry",
566            "MaterializeProjection",
567            "CatchUp",
568            "RegisterSnapshot",
569            "GetSnapshot",
570        ] {
571            assert!(
572                methods.contains(&required),
573                "missing generated RPC descriptor {required}"
574            );
575        }
576    }
577
578    #[test]
579    fn object_surface_has_only_explicit_typed_mutations() {
580        use super::v1::{
581            BulkOperation, BulkPutIfVersionRequest, CreateBucketRequest, DeleteIfVersionRequest,
582            DeleteRequest, DeleteVersionRequest, DeleteVersionResponse, Durability,
583            ListObjectsRequest, ListObjectsResponse, ObjectAddress, ObjectVersioning, PutHeader,
584            PutIfVersionOperation, PutRequest, PutToken, bulk_operation, put_header,
585        };
586
587        let address = Some(ObjectAddress {
588            tenant: "acme".into(),
589            bucket: "objects".into(),
590            path: "one".into(),
591        });
592        let header = PutHeader {
593            address: address.clone(),
594            content_type: "application/json".into(),
595            command_id: "command-1".into(),
596            durability: Durability::Local as i32,
597            operation: Some(put_header::Operation::PutIfVersion(PutIfVersionOperation {
598                expected_version: 8,
599            })),
600        };
601        let frame = PutRequest {
602            token: Some(PutToken {
603                value: b"opaque".to_vec(),
604                expires_at: None,
605            }),
606            chunk: Vec::new(),
607        };
608        assert!(matches!(
609            header.operation,
610            Some(put_header::Operation::PutIfVersion(_))
611        ));
612        assert!(frame.chunk.is_empty());
613
614        let operations = [
615            bulk_operation::Operation::Put(Default::default()),
616            bulk_operation::Operation::PutIfAbsent(Default::default()),
617            bulk_operation::Operation::PutIfVersion(BulkPutIfVersionRequest::default()),
618            bulk_operation::Operation::PutImmutable(Default::default()),
619            bulk_operation::Operation::Delete(DeleteRequest {
620                address: address.clone(),
621                ..Default::default()
622            }),
623            bulk_operation::Operation::DeleteIfVersion(DeleteIfVersionRequest {
624                address: address.clone(),
625                expected_version: 8,
626                ..Default::default()
627            }),
628        ];
629        assert_eq!(
630            operations
631                .into_iter()
632                .map(|operation| BulkOperation {
633                    operation: Some(operation),
634                })
635                .count(),
636            6
637        );
638
639        let current_head_delete = DeleteIfVersionRequest {
640            address: address.clone(),
641            expected_version: 8,
642            ..Default::default()
643        };
644        let retained_version_delete = DeleteVersionRequest {
645            address,
646            version: 7,
647            ..Default::default()
648        };
649        assert_eq!(current_head_delete.expected_version, 8);
650        assert_eq!(retained_version_delete.version, 7);
651        let replaced_current = DeleteVersionResponse {
652            deleted: true,
653            replacement_tombstone_version: Some(9),
654        };
655        assert_eq!(replaced_current.replacement_tombstone_version, Some(9));
656        assert_eq!(
657            CreateBucketRequest::default().versioning,
658            ObjectVersioning::Unversioned as i32
659        );
660
661        let list_request = ListObjectsRequest {
662            tenant: "acme".into(),
663            bucket: "objects".into(),
664            prefix: "reports/".into(),
665            start_after: Some("reports/2025.json".into()),
666            limit: 100,
667        };
668        let list_response = ListObjectsResponse {
669            paths: vec!["reports/2026.json".into()],
670            has_more: false,
671        };
672        assert_eq!(
673            list_request.start_after.as_deref(),
674            Some("reports/2025.json")
675        );
676        assert_eq!(list_response.paths, vec!["reports/2026.json".to_owned()]);
677    }
678
679    #[test]
680    fn authorization_wire_types_preserve_scope_and_typed_unions() {
681        use super::v1::{
682            AnyUsersetSelector, AtLeastRevision, AuthzConsistency, AuthzScope, DirectRelation,
683            InheritRule, NamespaceDefinition, ObjectRef, Permission, PermissionRule,
684            PutSchemaRequest, RelationDefinition, SubjectSelector, Userset, authz_consistency,
685            object_ref, permission_rule, relation_definition, subject, subject_selector,
686        };
687
688        let account = ObjectRef {
689            namespace: "account".into(),
690            id: Some(object_ref::Id::OpaqueId("acme".into())),
691        };
692        let members = Userset {
693            object: Some(account),
694            relation: "member".into(),
695        };
696        let schema = NamespaceDefinition {
697            name: "ledger".into(),
698            relations: vec![
699                RelationDefinition {
700                    name: "reader".into(),
701                    kind: Some(relation_definition::Kind::Direct(DirectRelation {
702                        allowed_subjects: vec![SubjectSelector {
703                            selector: Some(subject_selector::Selector::AnyUserset(
704                                AnyUsersetSelector {
705                                    namespace: "account".into(),
706                                    relation: "member".into(),
707                                },
708                            )),
709                        }],
710                    })),
711                },
712                RelationDefinition {
713                    name: "read".into(),
714                    kind: Some(relation_definition::Kind::Permission(Permission {
715                        rules: vec![PermissionRule {
716                            rule: Some(permission_rule::Rule::Inherit(InheritRule {
717                                relation: "reader".into(),
718                            })),
719                        }],
720                    })),
721                },
722            ],
723        };
724        let subject = super::v1::Subject {
725            kind: Some(subject::Kind::Userset(members)),
726        };
727        let publication = PutSchemaRequest {
728            schema_id: "acme".into(),
729            namespaces: vec![schema],
730        };
731        let consistency = AuthzConsistency {
732            requirement: Some(authz_consistency::Requirement::AtLeast(AtLeastRevision {
733                revision: 42,
734            })),
735        };
736        let system_scope = AuthzScope {
737            storage_tenant: "keldra-internal".into(),
738            realm: "_keldra/system".into(),
739        };
740
741        assert_eq!(publication.namespaces[0].relations.len(), 2);
742        assert!(matches!(subject.kind, Some(subject::Kind::Userset(_))));
743        assert!(matches!(
744            consistency.requirement,
745            Some(authz_consistency::Requirement::AtLeast(AtLeastRevision {
746                revision: 42
747            }))
748        ));
749        assert_eq!(system_scope.realm, "_keldra/system");
750    }
751
752    #[test]
753    fn tuple_mutation_and_batch_checks_share_request_scope() {
754        use super::v1::{
755            AuthzScope, CheckPermissionsRequest, MutateTuplesRequest, ObjectRef, PermissionCheck,
756            RelationTuple, Subject, TupleMutation, authz_consistency, object_ref, subject,
757            tuple_mutation,
758        };
759
760        let scope = AuthzScope {
761            storage_tenant: "acme".into(),
762            realm: "default".into(),
763        };
764        let ledger = ObjectRef {
765            namespace: "ledger".into(),
766            id: Some(object_ref::Id::OpaqueId("main".into())),
767        };
768        let alice = Subject {
769            kind: Some(subject::Kind::Object(ObjectRef {
770                namespace: "user".into(),
771                id: Some(object_ref::Id::OpaqueId("alice".into())),
772            })),
773        };
774        let tuple = RelationTuple {
775            object: Some(ledger.clone()),
776            relation: "reader".into(),
777            subject: Some(alice.clone()),
778        };
779        let mutation = MutateTuplesRequest {
780            scope: Some(scope.clone()),
781            operation_id: "grant-alice".into(),
782            expected_revision: Some(41),
783            mutations: vec![TupleMutation {
784                operation: Some(tuple_mutation::Operation::Add(tuple)),
785            }],
786        };
787        let checks = CheckPermissionsRequest {
788            scope: Some(scope),
789            checks: vec![PermissionCheck {
790                subject: Some(alice),
791                object: Some(ledger),
792                relation: "read".into(),
793            }],
794            consistency: Some(super::v1::AuthzConsistency {
795                requirement: Some(authz_consistency::Requirement::Exact(
796                    super::v1::ExactRevision { revision: 42 },
797                )),
798            }),
799        };
800
801        assert_eq!(mutation.expected_revision, Some(41));
802        assert_eq!(mutation.mutations.len(), 1);
803        assert_eq!(checks.checks.len(), 1);
804        assert!(matches!(
805            checks
806                .consistency
807                .and_then(|consistency| consistency.requirement),
808            Some(authz_consistency::Requirement::Exact(_))
809        ));
810    }
811}