1use crate::{
24 AtomicMutation, AtomicPrecondition, FeltDb, FlowError, PreconditionFailure, RecordPrecondition,
25 StoredRow,
26 application::{ApplicationManifest, ApplicationRevision, FieldDefinition},
27 policy_evaluation::{
28 Actor, AuthorizationState, PolicyEvaluator, PolicySubject, RecordAuthorizationContext,
29 },
30};
31use serde::{Deserialize, Serialize};
32use serde_json::{Map, Value};
33use sha2::{Digest, Sha256};
34use std::{
35 cmp::Ordering,
36 collections::{BTreeMap, BTreeSet},
37 sync::Arc,
38 time::Instant,
39};
40
41pub const STATE_CONTRACT_VERSION: u32 = 1;
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum PrimitiveType {
46 String,
47 Integer,
48 Number,
49 Boolean,
50 Timestamp,
51 Date,
52 Time,
53 Json,
54 Uuid,
55 Decimal,
56 Money,
57 Bigint,
58 Email,
59 Url,
60 Phone,
61 Binary,
62 File,
63 GeoPoint,
64 Object,
65}
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(tag = "kind", rename_all = "snake_case")]
68pub enum FieldType {
69 Primitive { primitive: PrimitiveType },
70 Enum { values: Vec<String> },
71 Reference { collection: String },
72 Array { items: Box<FieldType> },
73 Map { values: Box<FieldType> },
74 Vector { dimensions: Option<usize> },
75}
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct FieldConstraints {
78 #[serde(default)]
79 pub minimum: Option<f64>,
80 #[serde(default)]
81 pub maximum: Option<f64>,
82 #[serde(default)]
83 pub min_length: Option<usize>,
84 #[serde(default)]
85 pub max_length: Option<usize>,
86 #[serde(default)]
87 pub pattern: Option<String>,
88}
89impl Default for FieldConstraints {
90 fn default() -> Self {
91 Self {
92 minimum: None,
93 maximum: None,
94 min_length: None,
95 max_length: None,
96 pattern: None,
97 }
98 }
99}
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101pub struct FieldSchema {
102 pub name: String,
103 pub field_type: FieldType,
104 #[serde(default)]
105 pub nullable: bool,
106 #[serde(default)]
107 pub required: bool,
108 #[serde(default)]
109 pub default: Option<Value>,
110 #[serde(default)]
111 pub constraints: FieldConstraints,
112 #[serde(default)]
113 pub computed: Option<String>,
114}
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct SchemaIndex {
117 pub name: String,
118 pub fields: Vec<String>,
119 #[serde(default)]
120 pub unique: bool,
121 #[serde(default)]
122 pub sparse: bool,
123 #[serde(default = "one")]
124 pub version: u32,
125}
126fn one() -> u32 {
127 1
128}
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130pub struct CollectionSchema {
131 pub name: String,
132 #[serde(default = "one")]
133 pub version: u32,
134 #[serde(default)]
135 pub fields: Vec<FieldSchema>,
136 #[serde(default)]
137 pub indexes: Vec<SchemaIndex>,
138}
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct StateSchema {
141 pub contract_version: u32,
142 pub schema_version: u64,
143 pub application_id: String,
144 pub revision_id: String,
145 #[serde(default)]
146 pub collections: Vec<CollectionSchema>,
147}
148fn parse_manifest_type(value: &str) -> FieldType {
149 let value = value.trim();
150 if let Some(inner) = value
151 .strip_prefix("array<")
152 .and_then(|v| v.strip_suffix('>'))
153 {
154 return FieldType::Array {
155 items: Box::new(parse_manifest_type(inner)),
156 };
157 }
158 if let Some(inner) = value.strip_prefix("map<").and_then(|v| v.strip_suffix('>')) {
159 return FieldType::Map {
160 values: Box::new(parse_manifest_type(inner)),
161 };
162 }
163 if value == "vector" {
164 return FieldType::Vector { dimensions: None };
165 }
166 if let Some(inner) = value
167 .strip_prefix("vector<")
168 .and_then(|v| v.strip_suffix('>'))
169 {
170 return FieldType::Vector {
171 dimensions: inner.parse().ok(),
172 };
173 }
174 FieldType::Primitive {
175 primitive: match value {
176 "int" | "integer" => PrimitiveType::Integer,
177 "number" | "float" => PrimitiveType::Number,
178 "bool" | "boolean" => PrimitiveType::Boolean,
179 "timestamp" | "datetime" => PrimitiveType::Timestamp,
180 "date" => PrimitiveType::Date,
181 "time" => PrimitiveType::Time,
182 "json" => PrimitiveType::Json,
183 "uuid" => PrimitiveType::Uuid,
184 "decimal" => PrimitiveType::Decimal,
185 "money" => PrimitiveType::Money,
186 "bigint" => PrimitiveType::Bigint,
187 "email" => PrimitiveType::Email,
188 "url" => PrimitiveType::Url,
189 "phone" => PrimitiveType::Phone,
190 "binary" | "bytes" => PrimitiveType::Binary,
191 "file" => PrimitiveType::File,
192 "geo" | "geopoint" => PrimitiveType::GeoPoint,
193 "object" => PrimitiveType::Object,
194 _ => PrimitiveType::String,
195 },
196 }
197}
198pub fn valid_manifest_field_type(value: &str) -> bool {
199 let value = value.trim();
200 if matches!(
201 value,
202 "text"
203 | "string"
204 | "int"
205 | "integer"
206 | "number"
207 | "float"
208 | "bool"
209 | "boolean"
210 | "timestamp"
211 | "datetime"
212 | "date"
213 | "time"
214 | "json"
215 | "uuid"
216 | "decimal"
217 | "money"
218 | "bigint"
219 | "email"
220 | "url"
221 | "phone"
222 | "binary"
223 | "bytes"
224 | "file"
225 | "geo"
226 | "geopoint"
227 | "object"
228 | "enum"
229 | "reference"
230 | "vector"
231 ) {
232 return true;
233 }
234 if value
235 .strip_prefix("vector<")
236 .and_then(|v| v.strip_suffix('>'))
237 .is_some_and(|v| v.parse::<usize>().is_ok_and(|n| n > 0))
238 {
239 return true;
240 }
241 value
242 .strip_prefix("array<")
243 .and_then(|v| v.strip_suffix('>'))
244 .or_else(|| value.strip_prefix("map<").and_then(|v| v.strip_suffix('>')))
245 .is_some_and(valid_manifest_field_type)
246}
247fn manifest_field_type(field: &FieldDefinition) -> FieldType {
248 if let Some(collection) = &field.reference {
249 FieldType::Reference {
250 collection: collection.clone(),
251 }
252 } else if !field.enum_values.is_empty() {
253 FieldType::Enum {
254 values: field.enum_values.clone(),
255 }
256 } else {
257 parse_manifest_type(&field.field_type)
258 }
259}
260pub fn schema_from_manifest(
261 manifest: &ApplicationManifest,
262 revision_id: impl Into<String>,
263) -> StateSchema {
264 StateSchema {
265 contract_version: STATE_CONTRACT_VERSION,
266 schema_version: manifest.state_schema_version,
267 application_id: manifest.application_id.clone(),
268 revision_id: revision_id.into(),
269 collections: manifest
270 .collections
271 .iter()
272 .map(|collection| CollectionSchema {
273 name: collection.name.clone(),
274 version: manifest.state_schema_version as u32,
275 fields: collection
276 .fields
277 .iter()
278 .map(|field| FieldSchema {
279 name: field.name.clone(),
280 field_type: manifest_field_type(field),
281 nullable: field.nullable,
282 required: field.required,
283 default: field.default.clone(),
284 constraints: Default::default(),
285 computed: field.computed.clone(),
286 })
287 .collect(),
288 indexes: manifest
289 .indexes
290 .iter()
291 .filter(|index| index.collection == collection.name)
292 .map(|index| SchemaIndex {
293 name: index.name.clone(),
294 fields: index.fields.clone(),
295 unique: index.unique,
296 sparse: index.sparse,
297 version: index.version,
298 })
299 .collect(),
300 })
301 .collect(),
302 }
303}
304pub fn schema_from_revision(revision: &ApplicationRevision) -> StateSchema {
305 schema_from_manifest(&revision.manifest, revision.revision_id.clone())
306}
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308pub struct SchemaIssue {
309 pub path: String,
310 pub code: String,
311 pub message: String,
312}
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct SchemaValidation {
315 pub valid: bool,
316 pub schema_hash: Option<String>,
317 pub issues: Vec<SchemaIssue>,
318}
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
320#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
321pub enum SchemaCompatibility {
322 Safe,
323 RequiresMigration,
324 Destructive,
325 Incompatible,
326}
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct SchemaChange {
329 pub collection: String,
330 pub field: Option<String>,
331 pub classification: SchemaCompatibility,
332 pub description: String,
333}
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct SchemaCompatibilityReport {
336 pub from_version: u64,
337 pub to_version: u64,
338 pub classification: SchemaCompatibility,
339 pub changes: Vec<SchemaChange>,
340}
341
342fn sorted_schema(mut schema: StateSchema) -> StateSchema {
343 schema.collections.sort_by(|a, b| a.name.cmp(&b.name));
344 for collection in &mut schema.collections {
345 collection.fields.sort_by(|a, b| a.name.cmp(&b.name));
346 collection.indexes.sort_by(|a, b| a.name.cmp(&b.name));
347 for field in &mut collection.fields {
348 if let FieldType::Enum { values } = &mut field.field_type {
349 values.sort();
350 }
351 }
352 }
353 schema
354}
355pub fn canonical_schema_bytes(schema: &StateSchema) -> Result<Vec<u8>, String> {
356 serde_json::to_vec(&sorted_schema(schema.clone())).map_err(|e| e.to_string())
357}
358pub fn schema_hash(schema: &StateSchema) -> Result<String, String> {
359 Ok(format!(
360 "sha256:{:x}",
361 Sha256::digest(canonical_schema_bytes(schema)?)
362 ))
363}
364pub fn validate_schema(schema: &StateSchema) -> SchemaValidation {
365 let mut issues = vec![];
366 if schema.contract_version != STATE_CONTRACT_VERSION {
367 issues.push(SchemaIssue {
368 path: "contract_version".into(),
369 code: "UNSUPPORTED_CONTRACT".into(),
370 message: "unsupported state contract version".into(),
371 });
372 }
373 let mut collections = BTreeSet::new();
374 for collection in &schema.collections {
375 if collection.name.trim().is_empty() || !collections.insert(collection.name.clone()) {
376 issues.push(SchemaIssue {
377 path: format!("collections.{}", collection.name),
378 code: "INVALID_COLLECTION".into(),
379 message: "collection names must be non-empty and unique".into(),
380 });
381 }
382 let mut fields = BTreeSet::new();
383 for field in &collection.fields {
384 if field.name.trim().is_empty() || !fields.insert(field.name.clone()) {
385 issues.push(SchemaIssue {
386 path: format!("collections.{}.fields.{}", collection.name, field.name),
387 code: "INVALID_FIELD".into(),
388 message: "field names must be non-empty and unique".into(),
389 });
390 }
391 if field.required && field.nullable {
392 issues.push(SchemaIssue {
393 path: format!("collections.{}.fields.{}", collection.name, field.name),
394 code: "INVALID_NULLABILITY".into(),
395 message: "required fields cannot be nullable".into(),
396 });
397 }
398 if field.computed.is_some() && field.default.is_some() {
399 issues.push(SchemaIssue {
400 path: format!("collections.{}.fields.{}", collection.name, field.name),
401 code: "INVALID_COMPUTED_DEFAULT".into(),
402 message: "computed fields cannot declare defaults".into(),
403 });
404 }
405 if let Some(expression) = &field.computed {
406 let sources = expression
407 .strip_prefix("copy:")
408 .map(|v| vec![v])
409 .or_else(|| {
410 expression
411 .strip_prefix("concat:")
412 .map(|v| v.split(',').collect())
413 })
414 .unwrap_or_default();
415 if sources.is_empty()
416 || sources.iter().any(|source| {
417 !collection
418 .fields
419 .iter()
420 .any(|candidate| candidate.name == source.trim())
421 })
422 {
423 issues.push(SchemaIssue{path:format!("collections.{}.fields.{}.computed",collection.name,field.name),code:"INVALID_COMPUTED_FIELD".into(),message:"computed expressions must use copy:<field> or concat:<field>,<field> with known fields".into()});
424 }
425 }
426 if field
427 .default
428 .as_ref()
429 .is_some_and(|value| !valid_type(value, field))
430 {
431 issues.push(SchemaIssue {
432 path: format!(
433 "collections.{}.fields.{}.default",
434 collection.name, field.name
435 ),
436 code: "INVALID_DEFAULT".into(),
437 message: "default does not satisfy the field type".into(),
438 });
439 }
440 if let FieldType::Enum { values } = &field.field_type {
441 let unique = values.iter().collect::<BTreeSet<_>>();
442 if values.is_empty() || unique.len() != values.len() {
443 issues.push(SchemaIssue {
444 path: format!("collections.{}.fields.{}", collection.name, field.name),
445 code: "INVALID_ENUM".into(),
446 message: "enum values must be non-empty and unique".into(),
447 });
448 }
449 }
450 }
451 for index in &collection.indexes {
452 if index.fields.is_empty() || index.fields.iter().any(|f| !fields.contains(f)) {
453 issues.push(SchemaIssue {
454 path: format!("collections.{}.indexes.{}", collection.name, index.name),
455 code: "INVALID_INDEX".into(),
456 message: "indexes must reference existing fields".into(),
457 });
458 }
459 }
460 }
461 for collection in &schema.collections {
462 for field in &collection.fields {
463 if let FieldType::Reference { collection: target } = &field.field_type {
464 if !collections.contains(target) {
465 issues.push(SchemaIssue {
466 path: format!("collections.{}.fields.{}", collection.name, field.name),
467 code: "INVALID_REFERENCE".into(),
468 message: format!("unknown reference target {target}"),
469 });
470 }
471 }
472 }
473 }
474 let valid = issues.is_empty();
475 SchemaValidation {
476 valid,
477 schema_hash: valid.then(|| schema_hash(schema).ok()).flatten(),
478 issues,
479 }
480}
481pub fn compare_schemas(before: &StateSchema, after: &StateSchema) -> SchemaCompatibilityReport {
482 let mut changes = vec![];
483 let old: BTreeMap<_, _> = before.collections.iter().map(|v| (&v.name, v)).collect();
484 let new: BTreeMap<_, _> = after.collections.iter().map(|v| (&v.name, v)).collect();
485 for (name, collection) in &new {
486 if !old.contains_key(name) {
487 changes.push(SchemaChange {
488 collection: (*name).clone(),
489 field: None,
490 classification: SchemaCompatibility::Safe,
491 description: "collection added".into(),
492 });
493 continue;
494 }
495 let previous = old[name];
496 let old_fields: BTreeMap<_, _> = previous.fields.iter().map(|v| (&v.name, v)).collect();
497 let new_fields: BTreeMap<_, _> = collection.fields.iter().map(|v| (&v.name, v)).collect();
498 for (field, current) in &new_fields {
499 match old_fields.get(field) {
500 None => changes.push(SchemaChange {
501 collection: (*name).clone(),
502 field: Some((*field).clone()),
503 classification: if current.required && current.default.is_none() {
504 SchemaCompatibility::Destructive
505 } else {
506 SchemaCompatibility::Safe
507 },
508 description: "field added".into(),
509 }),
510 Some(prior) if prior.field_type != current.field_type => {
511 let classification = match (&prior.field_type, ¤t.field_type) {
512 (FieldType::Enum { values: a }, FieldType::Enum { values: b })
513 if a.iter().all(|v| b.contains(v)) =>
514 {
515 SchemaCompatibility::Safe
516 }
517 (FieldType::Enum { .. }, FieldType::Enum { .. }) => {
518 SchemaCompatibility::Destructive
519 }
520 _ => SchemaCompatibility::Incompatible,
521 };
522 changes.push(SchemaChange {
523 collection: (*name).clone(),
524 field: Some((*field).clone()),
525 classification,
526 description: "field type changed".into(),
527 });
528 }
529 Some(prior)
530 if prior.required != current.required || prior.nullable != current.nullable =>
531 {
532 changes.push(SchemaChange {
533 collection: (*name).clone(),
534 field: Some((*field).clone()),
535 classification: if current.required {
536 SchemaCompatibility::Destructive
537 } else {
538 SchemaCompatibility::Safe
539 },
540 description: "field requirement changed".into(),
541 })
542 }
543 _ => {}
544 }
545 }
546 for field in old_fields.keys().filter(|f| !new_fields.contains_key(*f)) {
547 changes.push(SchemaChange {
548 collection: (*name).clone(),
549 field: Some((*field).clone()),
550 classification: SchemaCompatibility::Destructive,
551 description: "field removed".into(),
552 });
553 }
554 }
555 for name in old.keys().filter(|n| !new.contains_key(*n)) {
556 changes.push(SchemaChange {
557 collection: (*name).clone(),
558 field: None,
559 classification: SchemaCompatibility::Destructive,
560 description: "collection removed".into(),
561 });
562 }
563 let classification = changes
564 .iter()
565 .map(|v| v.classification.clone())
566 .max()
567 .unwrap_or(SchemaCompatibility::Safe);
568 SchemaCompatibilityReport {
569 from_version: before.schema_version,
570 to_version: after.schema_version,
571 classification,
572 changes,
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct AuthorizationContext {
578 pub subject: String,
579 pub tenant_id: String,
580 pub application_id: String,
581 pub revision_id: String,
582 #[serde(default)]
583 pub capabilities: BTreeSet<String>,
584 #[serde(default)]
585 pub readable_collections: BTreeSet<String>,
586 #[serde(default)]
587 pub writable_collections: BTreeSet<String>,
588 #[serde(default)]
589 pub field_projections: BTreeMap<String, BTreeSet<String>>,
590}
591impl AuthorizationContext {
592 fn can(&self, cap: &str) -> bool {
593 self.capabilities.contains("*") || self.capabilities.contains(cap)
594 }
595}
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct ReadContext {
598 pub application_id: String,
599 pub revision_id: String,
600 pub schema_version: u64,
601 pub state_version: u64,
602 pub causal_cursor: BTreeMap<String, u64>,
603 pub state_namespace: String,
604 pub authorization: AuthorizationContext,
605 #[serde(skip, default)]
606 snapshot_rows: Vec<StoredRow>,
607 #[serde(skip, default)]
608 live_query: bool,
609}
610pub fn begin_read(
611 db: &FeltDb,
612 schema: &StateSchema,
613 state_namespace: &str,
614 authorization: AuthorizationContext,
615) -> Result<ReadContext, StateFailure> {
616 begin_read_internal(db, schema, state_namespace, authorization, true)
617}
618
619pub fn begin_query_read(
623 db: &FeltDb,
624 schema: &StateSchema,
625 state_namespace: &str,
626 authorization: AuthorizationContext,
627) -> Result<ReadContext, StateFailure> {
628 build_read_context(db, schema, state_namespace, authorization, Vec::new(), true)
629}
630
631fn begin_read_internal(
632 db: &FeltDb,
633 schema: &StateSchema,
634 state_namespace: &str,
635 authorization: AuthorizationContext,
636 capture_snapshot: bool,
637) -> Result<ReadContext, StateFailure> {
638 if authorization.application_id != schema.application_id
639 || authorization.revision_id != schema.revision_id
640 {
641 return Err(StateFailure::new(
642 "SCHEMA_MISMATCH",
643 "authorization context does not match schema",
644 ));
645 }
646 let snapshot_rows = if capture_snapshot {
647 db.state_rows_for_namespace(state_namespace)
648 .map_err(StateFailure::storage)?
649 } else {
650 Vec::new()
651 };
652 build_read_context(
653 db,
654 schema,
655 state_namespace,
656 authorization,
657 snapshot_rows,
658 !capture_snapshot,
659 )
660}
661
662fn build_read_context(
663 db: &FeltDb,
664 schema: &StateSchema,
665 state_namespace: &str,
666 authorization: AuthorizationContext,
667 snapshot_rows: Vec<StoredRow>,
668 live_query: bool,
669) -> Result<ReadContext, StateFailure> {
670 Ok(ReadContext {
671 application_id: schema.application_id.clone(),
672 revision_id: schema.revision_id.clone(),
673 schema_version: schema.schema_version,
674 state_version: db.sequence().map_err(StateFailure::storage)?,
675 causal_cursor: db
676 .operation_versions()
677 .map_err(StateFailure::storage)?
678 .into_iter()
679 .collect(),
680 state_namespace: state_namespace.into(),
681 authorization,
682 snapshot_rows,
683 live_query,
684 })
685}
686
687fn namespace_capability(state_namespace: &str, collection: &str) -> String {
688 format!("{state_namespace}:{collection}")
689}
690
691fn membership_collection_name(schema: &StateSchema) -> Option<String> {
692 AuthorizationState::new(Arc::new(schema.clone()), "", Arc::new(Vec::new()))
693 .find_membership_collection()
694}
695
696fn member_policy_snapshot_rows(
697 db: &FeltDb,
698 schema: &StateSchema,
699 state_namespace: &str,
700) -> Result<Vec<StoredRow>, StateFailure> {
701 let Some(collection) = membership_collection_name(schema) else {
702 return Ok(Vec::new());
703 };
704 db.list_collection(&namespace_capability(state_namespace, &collection))
705 .map_err(StateFailure::storage)
706}
707
708fn load_reference_rows(
709 db: &FeltDb,
710 state_namespace: &str,
711 values: &[Value],
712 references: &[QueryReference],
713) -> Result<BTreeMap<(String, String), Value>, StateFailure> {
714 let mut rows = BTreeMap::new();
715 let mut requested = BTreeMap::<String, BTreeSet<String>>::new();
716 for reference in references {
717 for id in values
718 .iter()
719 .filter_map(|value| value.get(&reference.field).and_then(Value::as_str))
720 {
721 requested
722 .entry(reference.collection.clone())
723 .or_default()
724 .insert(id.to_string());
725 }
726 }
727 for (collection, ids) in requested {
728 let capability = namespace_capability(state_namespace, &collection);
729 let loaded = db
730 .get_collection_records(&capability, ids.iter().map(String::as_str))
731 .map_err(StateFailure::storage)?;
732 let present = loaded
733 .into_iter()
734 .map(|row| (row.key, row.value))
735 .collect::<BTreeMap<_, _>>();
736 for id in ids {
737 rows.insert(
738 (collection.clone(), id.clone()),
739 present.get(&id).cloned().unwrap_or(Value::Null),
740 );
741 }
742 }
743 Ok(rows)
744}
745
746fn transaction_snapshot_rows(
747 db: &FeltDb,
748 schema: &StateSchema,
749 request: &TransactionRequest,
750 uniform_write_policy: Option<&PolicySubject>,
751 collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
752) -> Result<Vec<StoredRow>, StateFailure> {
753 let namespace = request
754 .state_namespace
755 .as_deref()
756 .unwrap_or(&request.application_id);
757 let mut full_collections = BTreeSet::new();
758 let mut records = BTreeSet::new();
759 for condition in &request.preconditions {
760 if let TransactionPrecondition::CollectionVersion { collection, .. } = condition {
761 full_collections.insert(collection.clone());
762 }
763 }
764 let membership_collection = membership_collection_name(schema);
765 for operation in &request.operations {
766 let substrate = is_substrate_collection(&operation.collection);
767 records.insert((operation.collection.clone(), operation.id.clone()));
768 let write_policy = collection_write_policies
769 .and_then(|policies| policies.get(&operation.collection))
770 .or(uniform_write_policy);
771 if matches!(write_policy, Some(&PolicySubject::Member)) {
772 if let Some(collection) = &membership_collection {
773 full_collections.insert(collection.clone());
774 }
775 }
776 if substrate || matches!(operation.kind, TransactionOperationKind::Delete) {
777 continue;
778 }
779 let definition = collection(schema, &operation.collection)?;
780 if definition.indexes.iter().any(|index| index.unique) {
781 full_collections.insert(operation.collection.clone());
782 }
783 }
784
785 let mut snapshot = Vec::new();
786 let mut materialized = BTreeSet::new();
787 for collection in &full_collections {
788 snapshot.extend(
789 db.list_collection(&namespace_capability(namespace, collection))
790 .map_err(StateFailure::storage)?,
791 );
792 materialized.insert(collection.clone());
793 }
794 for (collection, key) in records {
795 if materialized.contains(&collection) {
796 continue;
797 }
798 if let Some(row) = db
799 .get_collection_record(&namespace_capability(namespace, &collection), &key)
800 .map_err(StateFailure::storage)?
801 {
802 snapshot.push(row);
803 }
804 }
805 Ok(snapshot)
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize)]
809#[serde(tag = "operator", rename_all = "camelCase")]
810pub enum QueryFilter {
811 Eq { field: String, value: Value },
812 Neq { field: String, value: Value },
813 Lt { field: String, value: Value },
814 Lte { field: String, value: Value },
815 Gt { field: String, value: Value },
816 Gte { field: String, value: Value },
817 In { field: String, values: Vec<Value> },
818 Contains { field: String, value: Value },
819 StartsWith { field: String, value: String },
820 EndsWith { field: String, value: String },
821 Exists { field: String, exists: bool },
822 And { filters: Vec<QueryFilter> },
823 Or { filters: Vec<QueryFilter> },
824 Not { filter: Box<QueryFilter> },
825}
826#[derive(Debug, Clone, Serialize, Deserialize)]
827#[serde(rename_all = "snake_case")]
828pub enum SortDirection {
829 Asc,
830 Desc,
831}
832#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct QueryOrder {
834 pub field: String,
835 pub direction: SortDirection,
836}
837#[derive(Debug, Clone, Serialize, Deserialize)]
838#[serde(rename_all = "snake_case")]
839pub enum AggregateFunction {
840 Count,
841 Sum,
842 Avg,
843 Min,
844 Max,
845}
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct AggregateDefinition {
848 pub name: String,
849 pub function: AggregateFunction,
850 #[serde(default)]
851 pub field: Option<String>,
852}
853#[derive(Debug, Clone, Serialize, Deserialize)]
854pub struct QueryReference {
855 pub field: String,
856 pub collection: String,
857 #[serde(default)]
858 pub projection: Vec<String>,
859 pub as_name: String,
860}
861#[derive(Debug, Clone, Serialize, Deserialize)]
862pub struct CanonicalQuery {
863 pub collection: String,
864 #[serde(default)]
865 pub filter: Option<QueryFilter>,
866 #[serde(default)]
867 pub order_by: Vec<QueryOrder>,
868 #[serde(default)]
869 pub limit: Option<usize>,
870 #[serde(default)]
871 pub offset: usize,
872 #[serde(default)]
873 pub cursor: Option<String>,
874 #[serde(default)]
875 pub projection: Vec<String>,
876 #[serde(default)]
877 pub group_by: Vec<String>,
878 #[serde(default)]
879 pub aggregates: Vec<AggregateDefinition>,
880 #[serde(default)]
881 pub references: Vec<QueryReference>,
882 #[serde(default, skip_serializing_if = "Option::is_none")]
887 pub semantic: Option<Value>,
888}
889#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct ExecutionTiming {
894 #[serde(default)]
896 pub planning_ms: Option<f64>,
897 #[serde(default)]
899 pub index_lookup_ms: Option<f64>,
900 #[serde(default)]
902 pub filtering_ms: Option<f64>,
903 #[serde(default)]
905 pub serialization_ms: Option<f64>,
906 #[serde(default)]
908 pub total_ms: Option<f64>,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize)]
916pub struct IndexMetrics {
917 #[serde(default)]
919 pub index_used: Option<String>,
920 #[serde(default)]
922 pub index_hits: usize,
923 #[serde(default)]
925 pub index_misses: usize,
926 #[serde(default)]
928 pub fallback_used: bool,
929 #[serde(default)]
931 pub fallback_reason: Option<String>,
932}
933
934impl ExecutionTiming {
935 pub fn new() -> Self {
936 ExecutionTiming {
937 planning_ms: None,
938 index_lookup_ms: None,
939 filtering_ms: None,
940 serialization_ms: None,
941 total_ms: None,
942 }
943 }
944}
945
946impl Default for ExecutionTiming {
947 fn default() -> Self {
948 Self::new()
949 }
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
957pub struct QueryPlan {
958 pub implementation: String,
959 pub collection: String,
960 pub index: Option<String>,
961 pub scan_fallback: bool,
962 #[serde(default)]
965 pub access_method: Option<String>,
966 #[serde(default)]
967 pub estimated_rows: Option<usize>,
968 #[serde(default)]
970 pub fallback_reason: Option<String>,
971 #[serde(default)]
973 pub actual_rows_scanned: Option<usize>,
974 #[serde(default)]
976 pub actual_rows_returned: Option<usize>,
977 #[serde(default)]
979 pub matching_records: Option<usize>,
980 #[serde(default)]
982 pub unrelated_records_examined: Option<usize>,
983 #[serde(default)]
985 pub pagination_position: Option<usize>,
986 #[serde(default)]
988 pub execution_ms: Option<f64>,
989 #[serde(default)]
991 pub timing: Option<ExecutionTiming>,
992 #[serde(default)]
994 pub index_metrics: Option<IndexMetrics>,
995}
996#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
997#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
998pub enum OperationType {
999 Read,
1000 Query,
1001 Insert,
1002 Update,
1003 Delete,
1004 Transaction,
1005 Sync,
1006 Replication,
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub struct QueryResult {
1011 pub query_id: String,
1012 pub query_hash: String,
1013 pub application_revision: String,
1014 pub schema_version: u64,
1015 pub state_version: u64,
1016 pub causal_cursor: BTreeMap<String, u64>,
1017 pub records: Vec<Value>,
1018 pub aggregates: Vec<Value>,
1019 pub next_cursor: Option<String>,
1020 pub plan: QueryPlan,
1021 #[serde(default)]
1022 pub operation_type: Option<OperationType>,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct StateFailure {
1027 pub code: String,
1028 pub message: String,
1029 #[serde(default)]
1030 pub expected: Option<Value>,
1031 #[serde(default)]
1032 pub actual: Option<Value>,
1033 #[serde(default)]
1034 pub resource: Option<String>,
1035 #[serde(default)]
1036 pub transaction: Option<String>,
1037 #[serde(default)]
1038 pub causal_position: Option<u64>,
1039 #[serde(default, skip_serializing_if = "Option::is_none")]
1044 pub failure: Option<Value>,
1045}
1046impl StateFailure {
1047 fn new(code: &str, message: &str) -> Self {
1048 Self {
1049 code: code.into(),
1050 message: message.into(),
1051 expected: None,
1052 actual: None,
1053 resource: None,
1054 transaction: None,
1055 causal_position: None,
1056 failure: None,
1057 }
1058 }
1059 fn storage(error: impl std::fmt::Display) -> Self {
1060 Self::new("STORAGE_FAILURE", &error.to_string())
1061 }
1062}
1063
1064fn idempotency_conflict() -> StateFailure {
1065 StateFailure::new(
1066 "IDEMPOTENCY_CONFLICT",
1067 "transaction ID was already committed with a different canonical payload",
1068 )
1069}
1070
1071fn collection<'a>(
1072 schema: &'a StateSchema,
1073 name: &str,
1074) -> Result<&'a CollectionSchema, StateFailure> {
1075 schema
1076 .collections
1077 .iter()
1078 .find(|v| v.name == name)
1079 .ok_or_else(|| StateFailure::new("UNKNOWN_COLLECTION", name))
1080}
1081fn filter_fields(filter: &QueryFilter, out: &mut BTreeSet<String>) {
1082 match filter {
1083 QueryFilter::Eq { field, .. }
1084 | QueryFilter::Neq { field, .. }
1085 | QueryFilter::Lt { field, .. }
1086 | QueryFilter::Lte { field, .. }
1087 | QueryFilter::Gt { field, .. }
1088 | QueryFilter::Gte { field, .. }
1089 | QueryFilter::In { field, .. }
1090 | QueryFilter::Contains { field, .. }
1091 | QueryFilter::StartsWith { field, .. }
1092 | QueryFilter::EndsWith { field, .. }
1093 | QueryFilter::Exists { field, .. } => {
1094 out.insert(field.clone());
1095 }
1096 QueryFilter::And { filters } | QueryFilter::Or { filters } => {
1097 for f in filters {
1098 filter_fields(f, out)
1099 }
1100 }
1101 QueryFilter::Not { filter } => filter_fields(filter, out),
1102 }
1103}
1104fn compare_value(left: &Value, right: &Value) -> Option<Ordering> {
1105 match (left, right) {
1106 (Value::Number(a), Value::Number(b)) => a.as_f64()?.partial_cmp(&b.as_f64()?),
1107 (Value::String(a), Value::String(b)) => Some(a.cmp(b)),
1108 (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
1109 _ => None,
1110 }
1111}
1112fn matches(value: &Value, filter: &QueryFilter) -> bool {
1113 let get = |field: &str| value.get(field);
1114 match filter {
1115 QueryFilter::Eq { field, value: v } => get(field) == Some(v),
1116 QueryFilter::Neq { field, value: v } => get(field) != Some(v),
1117 QueryFilter::Lt { field, value: v } => {
1118 get(field).and_then(|x| compare_value(x, v)) == Some(Ordering::Less)
1119 }
1120 QueryFilter::Lte { field, value: v } => matches!(
1121 get(field).and_then(|x| compare_value(x, v)),
1122 Some(Ordering::Less | Ordering::Equal)
1123 ),
1124 QueryFilter::Gt { field, value: v } => {
1125 get(field).and_then(|x| compare_value(x, v)) == Some(Ordering::Greater)
1126 }
1127 QueryFilter::Gte { field, value: v } => matches!(
1128 get(field).and_then(|x| compare_value(x, v)),
1129 Some(Ordering::Greater | Ordering::Equal)
1130 ),
1131 QueryFilter::In { field, values } => get(field).is_some_and(|x| values.contains(x)),
1132 QueryFilter::Contains { field, value: v } => get(field).is_some_and(|x| {
1133 x.as_str()
1134 .zip(v.as_str())
1135 .is_some_and(|(a, b)| a.contains(b))
1136 || x.as_array().is_some_and(|a| a.contains(v))
1137 }),
1138 QueryFilter::StartsWith { field, value: v } => get(field)
1139 .and_then(Value::as_str)
1140 .is_some_and(|x| x.starts_with(v)),
1141 QueryFilter::EndsWith { field, value: v } => get(field)
1142 .and_then(Value::as_str)
1143 .is_some_and(|x| x.ends_with(v)),
1144 QueryFilter::Exists { field, exists } => get(field).is_some() == *exists,
1145 QueryFilter::And { filters } => filters.iter().all(|f| matches(value, f)),
1146 QueryFilter::Or { filters } => filters.iter().any(|f| matches(value, f)),
1147 QueryFilter::Not { filter } => !matches(value, filter),
1148 }
1149}
1150fn projected(value: &Value, fields: &[String]) -> Value {
1151 if fields.is_empty() {
1152 return value.clone();
1153 }
1154 let mut out = Map::new();
1155 for field in fields {
1156 if let Some(v) = value.get(field) {
1157 out.insert(field.clone(), v.clone());
1158 }
1159 }
1160 Value::Object(out)
1161}
1162pub fn query_hash(query: &CanonicalQuery) -> Result<String, StateFailure> {
1163 Ok(format!(
1164 "sha256:{:x}",
1165 Sha256::digest(serde_json::to_vec(query).map_err(StateFailure::storage)?)
1166 ))
1167}
1168
1169fn pagination_query_hash(query: &CanonicalQuery) -> Result<String, StateFailure> {
1173 let mut shape = query.clone();
1174 shape.cursor = None;
1175 shape.offset = 0;
1176 query_hash(&shape)
1177}
1178
1179fn encode_query_cursor(query_hash: &str, position: usize) -> String {
1180 format!("feltdb-query-v1.{position}.{}", &query_hash[7..])
1181}
1182
1183fn decode_query_cursor(cursor: &str, expected_hash: &str) -> Result<usize, StateFailure> {
1184 let mut parts = cursor.split('.');
1185 let version = parts.next();
1186 let position = parts.next();
1187 let hash = parts.next();
1188 if version != Some("feltdb-query-v1")
1189 || parts.next().is_some()
1190 || hash != Some(&expected_hash[7..])
1191 {
1192 return Err(StateFailure::new(
1193 "INVALID_CURSOR",
1194 "cursor does not match the query scope, collection, ordering, or limit",
1195 ));
1196 }
1197 position
1198 .and_then(|value| value.parse::<usize>().ok())
1199 .ok_or_else(|| StateFailure::new("INVALID_CURSOR", "cursor is malformed"))
1200}
1201
1202fn query_value(row: &crate::StoredRow) -> Value {
1203 let mut value = row.value.clone();
1204 if let Some(object) = value.as_object_mut() {
1205 object.insert("_id".into(), Value::String(row.key.clone()));
1206 object.insert(
1207 "_version".into(),
1208 Value::from(
1209 row.operation
1210 .as_ref()
1211 .map(|value| value.sequence)
1212 .unwrap_or(0),
1213 ),
1214 );
1215 }
1216 value
1217}
1218fn extract_equality_predicate(filter: &QueryFilter) -> Option<(String, Value)> {
1219 match filter {
1220 QueryFilter::Eq { field, value } => Some((field.clone(), value.clone())),
1221 QueryFilter::And { filters } => filters.iter().find_map(extract_equality_predicate),
1222 _ => None,
1223 }
1224}
1225fn extract_indexed_equality(
1226 filter: &QueryFilter,
1227 definition: &CollectionSchema,
1228) -> Option<(String, Value)> {
1229 match filter {
1230 QueryFilter::Eq { field, value } if can_use_index(definition, field) => {
1231 Some((field.clone(), value.clone()))
1232 }
1233 QueryFilter::And { filters } => filters
1234 .iter()
1235 .find_map(|filter| extract_indexed_equality(filter, definition)),
1236 _ => None,
1237 }
1238}
1239
1240fn contains_managed_scope(filter: &QueryFilter) -> bool {
1241 match filter {
1242 QueryFilter::Eq { field, .. } => matches!(
1243 field.as_str(),
1244 "tenant_id" | "tenantId" | "organization_id" | "organizationId" | "scope_prefix"
1245 ),
1246 QueryFilter::And { filters } => filters.iter().any(contains_managed_scope),
1247 _ => false,
1248 }
1249}
1250
1251fn extract_range_predicate(filter: &QueryFilter) -> Option<(String, Option<Value>, Option<Value>)> {
1252 match filter {
1253 QueryFilter::Gte { field, value } => Some((field.clone(), Some(value.clone()), None)),
1254 QueryFilter::Lte { field, value } => Some((field.clone(), None, Some(value.clone()))),
1255 QueryFilter::Gt { field, value } => Some((field.clone(), Some(value.clone()), None)),
1256 QueryFilter::Lt { field, value } => Some((field.clone(), None, Some(value.clone()))),
1257 _ => None,
1258 }
1259}
1260
1261fn can_use_index(definition: &CollectionSchema, field: &str) -> bool {
1262 definition
1263 .indexes
1264 .iter()
1265 .any(|idx| idx.fields.contains(&field.to_string()))
1266}
1267
1268fn can_use_index_for_range(definition: &CollectionSchema, field: &str) -> bool {
1269 definition
1270 .indexes
1271 .iter()
1272 .any(|idx| idx.fields.contains(&field.to_string()))
1273}
1274
1275pub fn explain_query(
1276 schema: &StateSchema,
1277 query: &CanonicalQuery,
1278) -> Result<QueryPlan, StateFailure> {
1279 let definition = collection(schema, &query.collection)?;
1280
1281 let mut predicate_fields = BTreeSet::new();
1282 if let Some(filter) = &query.filter {
1283 filter_fields(filter, &mut predicate_fields)
1284 }
1285
1286 let (used_index, index_name, _index_predicate_field_and_value) =
1287 if let Some(filter) = &query.filter {
1288 if let Some((field, _value)) = extract_equality_predicate(filter) {
1289 if can_use_index(definition, &field) {
1290 let index_name = definition
1291 .indexes
1292 .iter()
1293 .find(|idx| idx.fields.contains(&field))
1294 .map(|idx| idx.name.clone());
1295
1296 if index_name.is_some() {
1297 (true, index_name, Some((field, Value::Null)))
1298 } else {
1299 (false, None, None)
1300 }
1301 } else {
1302 (false, None, None)
1303 }
1304 } else {
1305 (false, None, None)
1306 }
1307 } else {
1308 (false, None, None)
1309 };
1310
1311 let (execution_strategy, scan_fallback_flag, access_method) = if used_index {
1312 ("index_lookup".into(), false, Some("index".to_string()))
1313 } else {
1314 (
1315 "full_table_scan".into(),
1316 true,
1317 Some("sequential_scan".to_string()),
1318 )
1319 };
1320
1321 Ok(QueryPlan {
1322 implementation: execution_strategy,
1323 collection: query.collection.clone(),
1324 index: index_name.clone(),
1325 scan_fallback: scan_fallback_flag,
1326 access_method,
1327 estimated_rows: if used_index { Some(1) } else { None },
1328 fallback_reason: if scan_fallback_flag {
1329 Some("no_applicable_index".to_string())
1330 } else {
1331 None
1332 },
1333 actual_rows_scanned: None,
1334 actual_rows_returned: None,
1335 matching_records: None,
1336 unrelated_records_examined: None,
1337 pagination_position: None,
1338 execution_ms: None,
1339 timing: None,
1340 index_metrics: None,
1341 })
1342}
1343
1344pub fn execute_query(
1345 _db: &FeltDb,
1346 schema: &StateSchema,
1347 context: &ReadContext,
1348 query: &CanonicalQuery,
1349 read_policy: Option<PolicySubject>,
1350) -> Result<QueryResult, StateFailure> {
1351 let query_start = Instant::now();
1352
1353 if let Some(clause) = &query.semantic {
1358 return Err(if clause.is_object() {
1359 StateFailure::new(
1360 "SEMANTIC_UNSUPPORTED",
1361 "canonical queries recognize the semantic clause but do not compose semantic decisions; use the bounded authority query",
1362 )
1363 } else {
1364 StateFailure::new("INVALID_QUERY", "semantic clause must be an object")
1365 });
1366 }
1367 if query.limit == Some(0) || query.limit.is_some_and(|limit| limit > 1000) {
1368 return Err(StateFailure::new(
1369 "INVALID_QUERY",
1370 "limit must be between 1 and 1000",
1371 ));
1372 }
1373 if context.schema_version != schema.schema_version || context.revision_id != schema.revision_id
1374 {
1375 return Err(StateFailure::new(
1376 "SCHEMA_MISMATCH",
1377 "read context does not match schema",
1378 ));
1379 }
1380 let substrate = is_substrate_collection(&query.collection);
1384 let substrate_definition;
1385 let definition = if substrate {
1386 if !query.references.is_empty() {
1387 return Err(StateFailure::new(
1388 "INVALID_QUERY",
1389 "substrate collections do not expand references",
1390 ));
1391 }
1392 substrate_definition = CollectionSchema {
1393 name: query.collection.clone(),
1394 version: 1,
1395 fields: vec![],
1396 indexes: vec![],
1397 };
1398 &substrate_definition
1399 } else {
1400 collection(schema, &query.collection)?
1401 };
1402 let readable = if substrate {
1403 context.authorization.can("composition:read")
1404 } else {
1405 context.authorization.can("state:read")
1406 && (context.authorization.readable_collections.is_empty()
1407 || context
1408 .authorization
1409 .readable_collections
1410 .contains(&query.collection))
1411 };
1412 if !readable {
1413 return Err(StateFailure::new(
1414 "AUTHORIZATION_DENIED",
1415 "collection is not readable",
1416 ));
1417 }
1418 let available: BTreeSet<_> = definition
1419 .fields
1420 .iter()
1421 .map(|v| v.name.clone())
1422 .chain(query.references.iter().map(|v| v.as_name.clone()))
1423 .chain(["_id".into(), "_version".into()])
1424 .collect();
1425 let mut requested = query.projection.iter().cloned().collect::<BTreeSet<_>>();
1426 let mut predicate_fields = BTreeSet::new();
1427 if let Some(filter) = &query.filter {
1428 filter_fields(filter, &mut requested);
1429 filter_fields(filter, &mut predicate_fields)
1430 }
1431 for order in &query.order_by {
1432 requested.insert(order.field.clone());
1433 }
1434 for group in &query.group_by {
1435 requested.insert(group.clone());
1436 }
1437 for agg in &query.aggregates {
1438 if let Some(field) = &agg.field {
1439 requested.insert(field.clone());
1440 }
1441 }
1442 for reference in &query.references {
1443 requested.insert(reference.field.clone());
1444 }
1445 if !substrate && requested.iter().any(|f| !available.contains(f)) {
1446 return Err(StateFailure::new(
1447 "UNKNOWN_FIELD",
1448 "query references an unknown field",
1449 ));
1450 }
1451 if let Some(allowed) = context
1452 .authorization
1453 .field_projections
1454 .get(&query.collection)
1455 {
1456 if requested
1457 .iter()
1458 .any(|f| !allowed.contains(f) && f != "_id" && f != "_version")
1459 {
1460 return Err(StateFailure::new(
1461 "UNAUTHORIZED_PROJECTION",
1462 "query references a protected field",
1463 ));
1464 }
1465 }
1466 let effective_projection = context
1467 .authorization
1468 .field_projections
1469 .get(&query.collection)
1470 .filter(|_| query.projection.is_empty())
1471 .map(|allowed| allowed.iter().cloned().collect::<Vec<_>>())
1472 .unwrap_or_else(|| query.projection.clone());
1473 let capability = format!("{}:{}", context.state_namespace, query.collection);
1474 for reference in &query.references {
1475 let target = collection(schema, &reference.collection)?;
1476 if !context.authorization.readable_collections.is_empty()
1477 && !context
1478 .authorization
1479 .readable_collections
1480 .contains(&reference.collection)
1481 {
1482 return Err(StateFailure::new(
1483 "AUTHORIZATION_DENIED",
1484 "referenced collection is not readable",
1485 ));
1486 }
1487 if reference
1488 .projection
1489 .iter()
1490 .any(|field| !target.fields.iter().any(|v| &v.name == field))
1491 {
1492 return Err(StateFailure::new(
1493 "UNKNOWN_FIELD",
1494 "reference projection contains an unknown field",
1495 ));
1496 }
1497 if let Some(allowed) = context
1498 .authorization
1499 .field_projections
1500 .get(&reference.collection)
1501 {
1502 if reference
1503 .projection
1504 .iter()
1505 .any(|field| !allowed.contains(field))
1506 {
1507 return Err(StateFailure::new(
1508 "UNAUTHORIZED_PROJECTION",
1509 "reference projection contains a protected field",
1510 ));
1511 }
1512 }
1513 }
1514
1515 let policy_snapshot = if matches!(read_policy, Some(PolicySubject::Member)) {
1516 member_policy_snapshot_rows(_db, schema, &context.state_namespace)?
1517 } else {
1518 Vec::new()
1519 };
1520
1521 let is_unfiltered_count = query.filter.is_none()
1523 && query.group_by.is_empty()
1524 && query
1525 .aggregates
1526 .iter()
1527 .any(|agg| matches!(agg.function, AggregateFunction::Count));
1528
1529 if is_unfiltered_count && read_policy.is_none() {
1533 let cardinality = _db.collection_cardinality(&capability).unwrap_or(0);
1534
1535 let count_result = serde_json::json!(cardinality);
1536 let query_hash = query_hash(query)?;
1537 let execution_ms = query_start.elapsed().as_secs_f64() * 1000.0;
1538
1539 return Ok(QueryResult {
1540 query_id: format!("query:{}:{}", context.state_version, &query_hash[7..23]),
1541 query_hash,
1542 application_revision: context.revision_id.clone(),
1543 schema_version: context.schema_version,
1544 state_version: context.state_version,
1545 causal_cursor: std::collections::BTreeMap::new(),
1546 records: vec![],
1547 aggregates: vec![count_result],
1548 next_cursor: None,
1549 plan: QueryPlan {
1550 implementation: "maintained_count".into(),
1551 collection: query.collection.clone(),
1552 index: None,
1553 scan_fallback: false,
1554 access_method: Some("maintained_count".to_string()),
1555 estimated_rows: Some(1),
1556 fallback_reason: None,
1557 actual_rows_scanned: Some(0),
1558 actual_rows_returned: Some(1),
1559 matching_records: Some(1),
1560 unrelated_records_examined: Some(0),
1561 pagination_position: Some(0),
1562 execution_ms: Some(execution_ms),
1563 timing: Some(ExecutionTiming {
1564 planning_ms: None,
1565 index_lookup_ms: None,
1566 filtering_ms: None,
1567 serialization_ms: None,
1568 total_ms: Some(execution_ms),
1569 }),
1570 index_metrics: Some(IndexMetrics {
1571 index_used: None,
1572 index_hits: 0,
1573 index_misses: 0,
1574 fallback_used: false,
1575 fallback_reason: None,
1576 }),
1577 },
1578 operation_type: Some(OperationType::Query),
1579 });
1580 }
1581
1582 let (planned_index, index_name, index_predicate_field_and_value) =
1583 if let Some(filter) = &query.filter {
1584 if let Some((field, value)) = extract_indexed_equality(filter, definition) {
1585 let index_name = definition
1586 .indexes
1587 .iter()
1588 .find(|idx| idx.fields.contains(&field))
1589 .map(|idx| idx.name.clone());
1590
1591 if index_name.is_some() {
1592 (true, index_name, Some((field, value)))
1593 } else {
1594 (false, None, None)
1595 }
1596 } else {
1597 (false, None, None)
1598 }
1599 } else {
1600 (false, None, None)
1601 };
1602 let used_index = planned_index && context.live_query;
1603 if context.live_query
1606 && !substrate
1607 && query.filter.as_ref().is_some_and(contains_managed_scope)
1608 && !used_index
1609 {
1610 return Err(StateFailure::new(
1611 "INDEX_UNAVAILABLE",
1612 "managed tenant scope requires a schema-declared equality index",
1613 ));
1614 }
1615
1616 let planning_ms = query_start.elapsed().as_secs_f64() * 1000.0;
1617 let filtering_start = Instant::now();
1618
1619 let execution_before = crate::query_execution_diagnostics::counters();
1623 let index_lookup_start = Instant::now();
1624 let selected_rows = if !context.live_query {
1625 context
1626 .snapshot_rows
1627 .iter()
1628 .filter(|row| {
1629 row.capability == capability
1630 && !row.deleted
1631 && query
1632 .filter
1633 .as_ref()
1634 .is_none_or(|filter| matches(&query_value(row), filter))
1635 })
1636 .cloned()
1637 .collect()
1638 } else if let Some((field, search_value)) = &index_predicate_field_and_value {
1639 _db.create_equality_index(&capability, field)
1640 .map_err(StateFailure::storage)?;
1641 _db.query_collection_by_equality(&capability, &[(field.as_str(), search_value)], |row| {
1642 !row.deleted
1643 && query
1644 .filter
1645 .as_ref()
1646 .is_none_or(|filter| matches(&query_value(row), filter))
1647 })
1648 .map_err(StateFailure::storage)?
1649 .ok_or_else(|| {
1650 StateFailure::new(
1651 "INDEX_UNAVAILABLE",
1652 "the schema-declared scoped equality index is unavailable",
1653 )
1654 })?
1655 } else {
1656 _db.query_collection(&capability, None, |row| {
1657 !row.deleted
1658 && query
1659 .filter
1660 .as_ref()
1661 .is_none_or(|filter| matches(&query_value(row), filter))
1662 })
1663 .map_err(StateFailure::storage)?
1664 };
1665 let index_lookup_ms = index_lookup_start.elapsed().as_secs_f64() * 1000.0;
1666 let execution_after = crate::query_execution_diagnostics::counters();
1667 let candidates_examined = if used_index {
1668 selected_rows.len()
1673 } else {
1674 execution_after
1675 .scan_records_visited
1676 .saturating_sub(execution_before.scan_records_visited) as usize
1677 };
1678 let _predicate_evaluated = execution_after
1679 .records_predicate_evaluated
1680 .saturating_sub(execution_before.records_predicate_evaluated)
1681 as usize;
1682 let mut values = selected_rows.iter().map(query_value).collect::<Vec<_>>();
1683
1684 if let Some(policy) = &read_policy {
1685 let actor =
1686 if context.authorization.subject.is_empty() || context.authorization.subject == ":" {
1687 None
1688 } else {
1689 context
1690 .authorization
1691 .subject
1692 .split(':')
1693 .nth(1)
1694 .map(|id| Actor::new(id))
1695 };
1696
1697 let auth_state = matches!(policy, &PolicySubject::Member).then(|| {
1698 AuthorizationState::new(
1699 Arc::new(schema.clone()),
1700 context.state_namespace.clone(),
1701 Arc::new(policy_snapshot.clone()),
1702 )
1703 });
1704
1705 values.retain(|record_value| {
1706 if let Some(record_id) = record_value.get("_id").and_then(Value::as_str) {
1707 let mut record_context = RecordAuthorizationContext {
1708 actor: actor.clone(),
1709 collection: query.collection.clone(),
1710 record_id: record_id.to_string(),
1711 record_value: record_value.clone(),
1712 authorization_state: None,
1713 };
1714 if let Some(state) = &auth_state {
1715 record_context.authorization_state = Some(state.clone());
1716 }
1717
1718 PolicyEvaluator::evaluate_record(policy.clone(), &record_context).is_ok()
1719 } else {
1720 true
1721 }
1722 });
1723 }
1724
1725 let reference_rows =
1726 load_reference_rows(_db, &context.state_namespace, &values, &query.references)?;
1727 for value in &mut values {
1728 for reference in &query.references {
1729 let id = value.get(&reference.field).and_then(Value::as_str);
1730 let related = id
1731 .and_then(|id| {
1732 reference_rows
1733 .get(&(reference.collection.clone(), id.to_string()))
1734 .filter(|value| !value.is_null())
1735 })
1736 .map(|value| projected(value, &reference.projection))
1737 .unwrap_or(Value::Null);
1738 if let Some(object) = value.as_object_mut() {
1739 object.insert(reference.as_name.clone(), related);
1740 }
1741 }
1742 }
1743 values.sort_by(|a, b| {
1744 for order in &query.order_by {
1745 let cmp = a
1746 .get(&order.field)
1747 .zip(b.get(&order.field))
1748 .and_then(|(x, y)| compare_value(x, y))
1749 .unwrap_or(Ordering::Equal);
1750 if cmp != Ordering::Equal {
1751 return if matches!(order.direction, SortDirection::Desc) {
1752 cmp.reverse()
1753 } else {
1754 cmp
1755 };
1756 }
1757 }
1758 compare_value(
1759 a.get("_id").unwrap_or(&Value::Null),
1760 b.get("_id").unwrap_or(&Value::Null),
1761 )
1762 .unwrap_or(Ordering::Equal)
1763 });
1764 let pagination_hash = pagination_query_hash(query)?;
1765 let start = if let Some(cursor) = &query.cursor {
1766 decode_query_cursor(cursor, &pagination_hash)?
1767 } else {
1768 query.offset
1769 };
1770 let limit = query.limit.unwrap_or(100).min(1000);
1771 let end = (start + limit).min(values.len());
1772 let page = if start <= values.len() {
1773 values[start..end].to_vec()
1774 } else {
1775 vec![]
1776 };
1777 let filtering_ms = filtering_start.elapsed().as_secs_f64() * 1000.0;
1778 let aggregates = aggregate(&values, &query.group_by, &query.aggregates)?;
1779 let hash = pagination_hash;
1780
1781 let (execution_strategy, scan_fallback_flag, access_method) = if used_index {
1782 ("index_lookup".into(), false, Some("index".to_string()))
1783 } else {
1784 (
1785 "full_table_scan".into(),
1786 true,
1787 Some("sequential_scan".to_string()),
1788 )
1789 };
1790
1791 let rows_scanned = candidates_examined;
1792
1793 let index_metrics = Some(IndexMetrics {
1794 index_used: index_name.clone(),
1795 index_hits: if used_index { candidates_examined } else { 0 },
1796 index_misses: 0,
1797 fallback_used: scan_fallback_flag,
1798 fallback_reason: if scan_fallback_flag {
1799 Some("no_applicable_index".to_string())
1800 } else {
1801 None
1802 },
1803 });
1804
1805 let serialization_start = Instant::now();
1806 let serialized_records: Vec<Value> = page
1807 .iter()
1808 .map(|v| projected(v, &effective_projection))
1809 .collect();
1810 let serialization_ms = serialization_start.elapsed().as_secs_f64() * 1000.0;
1811
1812 let total_ms = query_start.elapsed().as_secs_f64() * 1000.0;
1813 let returned_count = serialized_records.len();
1814 let next_cursor = (end < values.len()).then(|| encode_query_cursor(&hash, end));
1815
1816 let timing = ExecutionTiming {
1817 planning_ms: Some(planning_ms),
1818 index_lookup_ms: used_index.then_some(index_lookup_ms),
1819 filtering_ms: Some(filtering_ms),
1820 serialization_ms: Some(serialization_ms),
1821 total_ms: Some(total_ms),
1822 };
1823
1824 Ok(QueryResult {
1825 query_id: format!("query:{}:{}", context.state_version, &hash[7..23]),
1826 query_hash: hash,
1827 application_revision: context.revision_id.clone(),
1828 schema_version: context.schema_version,
1829 state_version: context.state_version,
1830 causal_cursor: context.causal_cursor.clone(),
1831 records: serialized_records,
1832 aggregates,
1833 next_cursor,
1834 plan: QueryPlan {
1835 implementation: execution_strategy,
1836 collection: query.collection.clone(),
1837 index: index_name,
1838 scan_fallback: scan_fallback_flag,
1839 access_method,
1840 estimated_rows: Some(values.len()),
1841 fallback_reason: if scan_fallback_flag {
1842 Some("no_applicable_index".to_string())
1843 } else {
1844 None
1845 },
1846 actual_rows_scanned: Some(rows_scanned),
1847 actual_rows_returned: Some(returned_count),
1848 matching_records: Some(values.len()),
1849 unrelated_records_examined: Some(0),
1850 pagination_position: Some(start),
1851 execution_ms: Some(total_ms),
1852 timing: Some(timing),
1853 index_metrics,
1854 },
1855 operation_type: Some(OperationType::Query),
1856 })
1857}
1858fn aggregate(
1859 values: &[Value],
1860 groups: &[String],
1861 defs: &[AggregateDefinition],
1862) -> Result<Vec<Value>, StateFailure> {
1863 if defs.is_empty() {
1864 return Ok(vec![]);
1865 }
1866 let mut buckets: BTreeMap<String, (Map<String, Value>, Vec<&Value>)> = BTreeMap::new();
1867 for value in values {
1868 let mut key = Map::new();
1869 for field in groups {
1870 key.insert(
1871 field.clone(),
1872 value.get(field).cloned().unwrap_or(Value::Null),
1873 );
1874 }
1875 let encoded = serde_json::to_string(&key).map_err(StateFailure::storage)?;
1876 buckets
1877 .entry(encoded)
1878 .or_insert((key, vec![]))
1879 .1
1880 .push(value);
1881 }
1882 let mut out = vec![];
1883 for (_, (group, rows)) in buckets {
1884 let mut result = group;
1885 for def in defs {
1886 let nums = def
1887 .field
1888 .as_ref()
1889 .map(|field| {
1890 rows.iter()
1891 .filter_map(|v| v.get(field).and_then(Value::as_f64))
1892 .collect::<Vec<_>>()
1893 })
1894 .unwrap_or_default();
1895 let value = match def.function {
1896 AggregateFunction::Count => Value::from(rows.len() as u64),
1897 AggregateFunction::Sum => Value::from(nums.iter().sum::<f64>()),
1898 AggregateFunction::Avg => {
1899 if nums.is_empty() {
1900 Value::Null
1901 } else {
1902 Value::from(nums.iter().sum::<f64>() / nums.len() as f64)
1903 }
1904 }
1905 AggregateFunction::Min => nums
1906 .into_iter()
1907 .reduce(f64::min)
1908 .map(Value::from)
1909 .unwrap_or(Value::Null),
1910 AggregateFunction::Max => nums
1911 .into_iter()
1912 .reduce(f64::max)
1913 .map(Value::from)
1914 .unwrap_or(Value::Null),
1915 };
1916 result.insert(def.name.clone(), value);
1917 }
1918 out.push(Value::Object(result));
1919 }
1920 Ok(out)
1921}
1922
1923#[derive(Debug, Clone, Serialize, Deserialize)]
1924#[serde(rename_all = "snake_case")]
1925pub enum TransactionOperationKind {
1926 Insert,
1927 Update,
1928 Delete,
1929}
1930#[derive(Debug, Clone, Serialize, Deserialize)]
1931pub struct TransactionOperation {
1932 pub kind: TransactionOperationKind,
1933 pub collection: String,
1934 pub id: String,
1935 #[serde(default)]
1936 pub value: Value,
1937 #[serde(default)]
1938 pub if_version: Option<u64>,
1939}
1940#[derive(Debug, Clone, Serialize, Deserialize)]
1941#[serde(tag = "kind", rename_all = "snake_case")]
1942pub enum TransactionPrecondition {
1943 CollectionVersion {
1944 collection: String,
1945 version: u64,
1946 },
1947 StateVersion {
1948 version: u64,
1949 },
1950 ApplicationRevision {
1951 revision_id: String,
1952 },
1953 CausalCursor {
1954 cursor: BTreeMap<String, u64>,
1955 },
1956 Record {
1973 collection: String,
1974 id: String,
1975 #[serde(default)]
1976 require_absent: bool,
1977 #[serde(default)]
1978 expected_version: Option<u64>,
1979 #[serde(default)]
1980 expected_epoch: Option<u64>,
1981 #[serde(default)]
1982 expected_lease_id: Option<String>,
1983 },
1984}
1985#[derive(Debug, Clone, Serialize, Deserialize)]
1986pub struct TransactionRequest {
1987 #[serde(default)]
1988 pub transaction_id: Option<String>,
1989 pub tenant_id: String,
1990 pub application_id: String,
1991 pub revision_id: String,
1992 pub schema_version: u64,
1993 #[serde(default)]
1994 pub state_namespace: Option<String>,
1995 #[serde(default)]
1996 #[serde(rename = "base_revision")]
1998 pub causal_parent: Option<u64>,
1999 pub authorization: AuthorizationContext,
2000 pub operations: Vec<TransactionOperation>,
2001 #[serde(default)]
2002 pub preconditions: Vec<TransactionPrecondition>,
2003 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2006 pub fences: Vec<crate::ReadBasis>,
2007}
2008
2009pub const SUBSTRATE_COLLECTIONS: &[&str] = &[
2015 "_feltdb.CompositionContract",
2016 "_feltdb.Composition",
2017 "_feltdb.CompositionVersion",
2018 "_feltdb.CompositionTopology",
2019 "_feltdb.StateOwnership",
2020 "_feltdb.StateDependency",
2021 "_feltdb.StateChange",
2022 "_feltdb.StateAttribution",
2023 "_feltdb.StateObservation",
2024 "_feltdb.StateProposal",
2025 "_feltdb.StateImpact",
2026 "_feltdb.Intent",
2027 "_feltdb.IntentEvent",
2028 "_feltdb.ProposalEvent",
2029];
2030
2031pub fn is_substrate_collection(name: &str) -> bool {
2032 SUBSTRATE_COLLECTIONS.contains(&name)
2033}
2034
2035pub fn capture_read_basis(
2044 db: &FeltDb,
2045 schema: &StateSchema,
2046 state_namespace: &str,
2047 reads: &crate::ReadSet,
2048 readable: &dyn Fn(&str) -> bool,
2049) -> Result<crate::ReadBasis, StateFailure> {
2050 let reads = reads
2051 .normalised()
2052 .map_err(|error| StateFailure::new("VALIDATION_FAILED", &error.to_string()))?;
2053 authorize_read_set(schema, &reads, readable)?;
2054 db.read_basis_in(
2055 &reads,
2056 &crate::ReadLayout::Namespace(state_namespace.to_string()),
2057 )
2058 .map_err(StateFailure::storage)
2059}
2060
2061pub fn read_basis_denied(collection: &str) -> StateFailure {
2063 let mut failure = StateFailure::new(
2064 "AUTHORIZATION_DENIED",
2065 "a read basis may only cover collections the caller can read in full",
2066 );
2067 failure.resource = Some(collection.to_string());
2068 failure
2069}
2070
2071fn authorize_read_set(
2072 schema: &StateSchema,
2073 reads: &crate::ReadSet,
2074 readable: &dyn Fn(&str) -> bool,
2075) -> Result<(), StateFailure> {
2076 let collections = reads
2077 .records
2078 .iter()
2079 .map(|record| record.collection.as_str())
2080 .chain(
2081 reads
2082 .predicates
2083 .iter()
2084 .map(|predicate| predicate.collection.as_str()),
2085 );
2086 for name in collections {
2087 if !is_substrate_collection(name) {
2088 collection(schema, name)?;
2089 }
2090 if !readable(name) {
2091 return Err(read_basis_denied(name));
2092 }
2093 }
2094 Ok(())
2095}
2096#[derive(Debug, Clone, Serialize, Deserialize)]
2097pub struct TransactionAudit {
2098 pub transaction_id: String,
2099 pub subject: String,
2100 pub tenant: String,
2101 pub application: String,
2102 pub revision: String,
2103 pub schema_version: u64,
2104 pub state_before: u64,
2105 pub state_after: u64,
2106 pub operations: usize,
2107 pub authorization_decisions: Vec<String>,
2108 pub causal_parent: Option<u64>,
2109 pub result: String,
2110}
2111#[derive(Debug, Clone, Serialize, Deserialize)]
2112pub struct TransactionResult {
2113 pub transaction_id: String,
2114 pub base_revision: u64,
2115 pub commit_revision: u64,
2116 pub status: crate::TransactionStatus,
2117 pub state_before: u64,
2118 pub state_after: u64,
2119 pub causal_cursor: BTreeMap<String, u64>,
2120 pub duplicate: bool,
2121 pub audit: TransactionAudit,
2122}
2123
2124fn valid_type(value: &Value, field: &FieldSchema) -> bool {
2125 if value.is_null() {
2126 return field.nullable;
2127 }
2128 match &field.field_type {
2129 FieldType::Primitive { primitive } => match primitive {
2130 PrimitiveType::String => value.is_string(),
2131 PrimitiveType::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
2132 PrimitiveType::Number => value.is_number(),
2133 PrimitiveType::Boolean => value.is_boolean(),
2134 PrimitiveType::Json => true,
2135 PrimitiveType::Timestamp => value.as_str().is_some_and(valid_timestamp),
2136 PrimitiveType::Date => value.as_str().is_some_and(valid_date),
2137 PrimitiveType::Time => value.as_str().is_some_and(valid_time),
2138 PrimitiveType::Uuid => value.as_str().is_some_and(valid_uuid),
2139 PrimitiveType::Decimal => value.as_str().is_some_and(valid_decimal),
2140 PrimitiveType::Bigint => value.as_str().is_some_and(valid_integer_string),
2141 PrimitiveType::Email => value.as_str().is_some_and(valid_email),
2142 PrimitiveType::Url => value.as_str().is_some_and(valid_url),
2143 PrimitiveType::Phone => value.as_str().is_some_and(valid_phone),
2144 PrimitiveType::Binary => value.as_str().is_some_and(valid_base64),
2145 PrimitiveType::Money => valid_money(value),
2146 PrimitiveType::File => valid_file(value),
2147 PrimitiveType::GeoPoint => valid_geo(value),
2148 PrimitiveType::Object => value.is_object(),
2149 },
2150 FieldType::Enum { values } => value
2151 .as_str()
2152 .is_some_and(|v| values.iter().any(|x| x == v)),
2153 FieldType::Reference { .. } => value.is_string(),
2154 FieldType::Array { items } => value.as_array().is_some_and(|values| {
2155 values.iter().all(|value| {
2156 valid_type(
2157 value,
2158 &FieldSchema {
2159 name: "item".into(),
2160 field_type: (**items).clone(),
2161 nullable: false,
2162 required: true,
2163 default: None,
2164 constraints: Default::default(),
2165 computed: None,
2166 },
2167 )
2168 })
2169 }),
2170 FieldType::Map { values } => value.as_object().is_some_and(|entries| {
2171 entries
2172 .values()
2173 .all(|value| valid_nested_type(value, values))
2174 }),
2175 FieldType::Vector { dimensions } => value.as_array().is_some_and(|values| {
2176 dimensions.is_none_or(|expected| values.len() == expected)
2177 && values.iter().all(Value::is_number)
2178 }),
2179 }
2180}
2181
2182fn valid_nested_type(value: &Value, field_type: &FieldType) -> bool {
2183 valid_type(
2184 value,
2185 &FieldSchema {
2186 name: "value".into(),
2187 field_type: field_type.clone(),
2188 nullable: false,
2189 required: true,
2190 default: None,
2191 constraints: Default::default(),
2192 computed: None,
2193 },
2194 )
2195}
2196fn valid_integer_string(value: &str) -> bool {
2197 !value.is_empty()
2198 && value
2199 .strip_prefix('-')
2200 .unwrap_or(value)
2201 .chars()
2202 .all(|c| c.is_ascii_digit())
2203}
2204fn valid_decimal(value: &str) -> bool {
2205 let unsigned = value.strip_prefix('-').unwrap_or(value);
2206 let mut parts = unsigned.split('.');
2207 parts
2208 .next()
2209 .is_some_and(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()))
2210 && parts
2211 .next()
2212 .is_none_or(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()))
2213 && parts.next().is_none()
2214}
2215fn valid_date(value: &str) -> bool {
2216 value.len() == 10
2217 && value.as_bytes()[4] == b'-'
2218 && value.as_bytes()[7] == b'-'
2219 && value[..4].parse::<u16>().is_ok()
2220 && value[5..7]
2221 .parse::<u8>()
2222 .is_ok_and(|v| (1..=12).contains(&v))
2223 && value[8..]
2224 .parse::<u8>()
2225 .is_ok_and(|v| (1..=31).contains(&v))
2226}
2227fn valid_time(value: &str) -> bool {
2228 value.len() >= 5
2229 && value.as_bytes().get(2) == Some(&b':')
2230 && value[..2].parse::<u8>().is_ok_and(|v| v < 24)
2231 && value[3..5].parse::<u8>().is_ok_and(|v| v < 60)
2232 && (value.len() == 5
2233 || value.as_bytes().get(5) == Some(&b':')
2234 && value
2235 .get(6..8)
2236 .is_some_and(|v| v.parse::<u8>().is_ok_and(|s| s < 60)))
2237}
2238fn valid_timestamp(value: &str) -> bool {
2239 value.get(..10).is_some_and(valid_date)
2240 && value
2241 .as_bytes()
2242 .get(10)
2243 .is_some_and(|v| *v == b'T' || *v == b' ')
2244 && value.get(11..).is_some_and(valid_time)
2245}
2246fn valid_uuid(value: &str) -> bool {
2247 value.len() == 36
2248 && value.chars().enumerate().all(|(i, c)| {
2249 matches!(i, 8 | 13 | 18 | 23) && c == '-'
2250 || !matches!(i, 8 | 13 | 18 | 23) && c.is_ascii_hexdigit()
2251 })
2252}
2253fn valid_email(value: &str) -> bool {
2254 let mut parts = value.split('@');
2255 parts.next().is_some_and(|v| !v.is_empty())
2256 && parts
2257 .next()
2258 .is_some_and(|v| v.contains('.') && !v.starts_with('.') && !v.ends_with('.'))
2259 && parts.next().is_none()
2260}
2261fn valid_url(value: &str) -> bool {
2262 (value.starts_with("https://") || value.starts_with("http://"))
2263 && value.split("//").nth(1).is_some_and(|v| !v.is_empty())
2264}
2265fn valid_phone(value: &str) -> bool {
2266 let digits = value.chars().filter(|c| c.is_ascii_digit()).count();
2267 (7..=15).contains(&digits)
2268 && value
2269 .chars()
2270 .all(|c| c.is_ascii_digit() || "+-(). ".contains(c))
2271}
2272fn valid_base64(value: &str) -> bool {
2273 !value.is_empty()
2274 && value.len() % 4 == 0
2275 && value
2276 .chars()
2277 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='))
2278}
2279fn valid_money(value: &Value) -> bool {
2280 value.as_object().is_some_and(|v| {
2281 v.get("amount")
2282 .and_then(Value::as_str)
2283 .is_some_and(valid_decimal)
2284 && v.get("currency")
2285 .and_then(Value::as_str)
2286 .is_some_and(|c| c.len() == 3 && c.chars().all(|x| x.is_ascii_uppercase()))
2287 })
2288}
2289fn valid_file(value: &Value) -> bool {
2290 value.as_object().is_some_and(|v| {
2291 v.get("name").is_some_and(Value::is_string)
2292 && v.get("type").is_some_and(Value::is_string)
2293 && v.get("size").and_then(Value::as_u64).is_some()
2294 && (v.get("url").is_some_and(Value::is_string)
2295 || v.get("ref").is_some_and(Value::is_string))
2296 })
2297}
2298fn valid_geo(value: &Value) -> bool {
2299 value.as_object().is_some_and(|v| {
2300 v.get("latitude")
2301 .and_then(Value::as_f64)
2302 .is_some_and(|x| (-90.0..=90.0).contains(&x))
2303 && v.get("longitude")
2304 .and_then(Value::as_f64)
2305 .is_some_and(|x| (-180.0..=180.0).contains(&x))
2306 })
2307}
2308fn validate_record(collection: &CollectionSchema, value: &mut Value) -> Result<(), StateFailure> {
2309 let object = value
2310 .as_object_mut()
2311 .ok_or_else(|| StateFailure::new("VALIDATION_FAILED", "record must be an object"))?;
2312 for field in &collection.fields {
2313 if let Some(expression) = &field.computed {
2314 let computed = if let Some(source) = expression.strip_prefix("copy:") {
2315 object.get(source.trim()).cloned().unwrap_or(Value::Null)
2316 } else if let Some(sources) = expression.strip_prefix("concat:") {
2317 Value::String(
2318 sources
2319 .split(',')
2320 .filter_map(|source| object.get(source.trim()).and_then(Value::as_str))
2321 .collect::<String>(),
2322 )
2323 } else {
2324 return Err(StateFailure::new(
2325 "VALIDATION_FAILED",
2326 "unsupported computed expression",
2327 ));
2328 };
2329 object.insert(field.name.clone(), computed);
2330 }
2331 }
2332 for field in &collection.fields {
2333 if !object.contains_key(&field.name) {
2334 if let Some(default) = &field.default {
2335 object.insert(field.name.clone(), default.clone());
2336 } else if field.required && field.computed.is_none() {
2337 return Err(StateFailure::new(
2338 "VALIDATION_FAILED",
2339 &format!("missing required field {}", field.name),
2340 ));
2341 }
2342 }
2343 if let Some(value) = object.get(&field.name) {
2344 if !valid_type(value, field) {
2345 return Err(StateFailure::new(
2346 "VALIDATION_FAILED",
2347 &format!("invalid value for {}", field.name),
2348 ));
2349 }
2350 if let Some(text) = value.as_str() {
2351 if field.constraints.min_length.is_some_and(|n| text.len() < n)
2352 || field.constraints.max_length.is_some_and(|n| text.len() > n)
2353 {
2354 return Err(StateFailure::new(
2355 "VALIDATION_FAILED",
2356 &format!("length constraint failed for {}", field.name),
2357 ));
2358 }
2359 }
2360 if let Some(number) = value.as_f64() {
2361 if field.constraints.minimum.is_some_and(|n| number < n)
2362 || field.constraints.maximum.is_some_and(|n| number > n)
2363 {
2364 return Err(StateFailure::new(
2365 "VALIDATION_FAILED",
2366 &format!("numeric constraint failed for {}", field.name),
2367 ));
2368 }
2369 }
2370 }
2371 }
2372 if object.keys().any(|name| {
2376 name != "__version" && name != "id" && !collection.fields.iter().any(|f| &f.name == name)
2377 }) {
2378 return Err(StateFailure::new(
2379 "VALIDATION_FAILED",
2380 "record contains an unknown field",
2381 ));
2382 }
2383 Ok(())
2384}
2385pub fn execute_transaction(
2386 db: &FeltDb,
2387 schema: &StateSchema,
2388 request: &TransactionRequest,
2389 write_policy: Option<PolicySubject>,
2390) -> Result<TransactionResult, StateFailure> {
2391 execute_transaction_with_policies(db, schema, request, write_policy.as_ref(), None)
2392}
2393pub fn execute_transaction_with_collection_policies(
2394 db: &FeltDb,
2395 schema: &StateSchema,
2396 request: &TransactionRequest,
2397 write_policies: &BTreeMap<String, PolicySubject>,
2398) -> Result<TransactionResult, StateFailure> {
2399 execute_transaction_with_policies(db, schema, request, None, Some(write_policies))
2400}
2401fn execute_transaction_with_policies(
2402 db: &FeltDb,
2403 schema: &StateSchema,
2404 request: &TransactionRequest,
2405 uniform_write_policy: Option<&PolicySubject>,
2406 collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
2407) -> Result<TransactionResult, StateFailure> {
2408 if request.application_id != schema.application_id
2409 || request.revision_id != schema.revision_id
2410 || request.schema_version != schema.schema_version
2411 {
2412 return Err(StateFailure::new(
2413 "SCHEMA_MISMATCH",
2414 "transaction does not match state schema",
2415 ));
2416 }
2417 if request.authorization.subject.is_empty()
2418 || request.authorization.tenant_id != request.tenant_id
2419 || request.authorization.application_id != request.application_id
2420 || request.authorization.revision_id != request.revision_id
2421 {
2422 return Err(StateFailure::new(
2423 "AUTHORIZATION_DENIED",
2424 "transaction authorization context is invalid",
2425 ));
2426 }
2427 if !request.authorization.can("state:write") {
2428 return Err(StateFailure::new(
2429 "AUTHORIZATION_DENIED",
2430 "state:write is required",
2431 ));
2432 }
2433 let canonical = if request.fences.is_empty() {
2434 serde_json::to_vec(&(
2435 request.tenant_id.as_str(),
2436 request.application_id.as_str(),
2437 request.revision_id.as_str(),
2438 request.schema_version,
2439 request.state_namespace.as_deref(),
2440 request.causal_parent,
2441 &request.authorization.subject,
2442 &request.preconditions,
2443 &request.operations,
2444 ))
2445 } else {
2446 serde_json::to_vec(&(
2449 request.tenant_id.as_str(),
2450 request.application_id.as_str(),
2451 request.revision_id.as_str(),
2452 request.schema_version,
2453 request.state_namespace.as_deref(),
2454 request.causal_parent,
2455 &request.authorization.subject,
2456 &request.preconditions,
2457 &request.operations,
2458 &request.fences,
2459 ))
2460 }
2461 .map_err(StateFailure::storage)?;
2462 let transaction_id = request
2463 .transaction_id
2464 .clone()
2465 .unwrap_or_else(|| format!("tx_{:x}", Sha256::digest(&canonical)));
2466 let storage_transaction_id = format!(
2472 "txscope_{:x}_{}",
2473 Sha256::digest(
2474 format!(
2475 "{}\0{}\0{}",
2476 request.tenant_id,
2477 request.application_id,
2478 request
2479 .state_namespace
2480 .as_deref()
2481 .unwrap_or(&request.application_id)
2482 )
2483 .as_bytes()
2484 ),
2485 transaction_id
2486 );
2487 let payload_hash = format!("sha256:{:x}", Sha256::digest(&canonical));
2488 if let Some(result) = committed_result(
2493 db,
2494 request,
2495 &transaction_id,
2496 &storage_transaction_id,
2497 &payload_hash,
2498 )? {
2499 return Ok(result);
2500 }
2501 match commit_new_transaction(
2502 db,
2503 schema,
2504 request,
2505 uniform_write_policy,
2506 collection_write_policies,
2507 &transaction_id,
2508 &storage_transaction_id,
2509 &payload_hash,
2510 ) {
2511 Ok(result) => Ok(result),
2512 Err(failure) => match committed_result(
2518 db,
2519 request,
2520 &transaction_id,
2521 &storage_transaction_id,
2522 &payload_hash,
2523 )? {
2524 Some(result) => Ok(result),
2525 None => Err(failure),
2526 },
2527 }
2528}
2529
2530fn committed_result(
2534 db: &FeltDb,
2535 request: &TransactionRequest,
2536 transaction_id: &str,
2537 storage_transaction_id: &str,
2538 payload_hash: &str,
2539) -> Result<Option<TransactionResult>, StateFailure> {
2540 let Some(original) = db
2541 .committed_transaction(storage_transaction_id, Some(payload_hash))
2542 .map_err(|error| match error {
2543 crate::FlowError::TransactionIdPayloadMismatch { .. } => idempotency_conflict(),
2544 other => StateFailure::storage(other),
2545 })?
2546 else {
2547 return Ok(None);
2548 };
2549 let cursor = db
2550 .operation_versions()
2551 .map_err(StateFailure::storage)?
2552 .into_iter()
2553 .collect();
2554 let audit = TransactionAudit {
2555 transaction_id: transaction_id.to_string(),
2556 subject: request.authorization.subject.clone(),
2557 tenant: request.tenant_id.clone(),
2558 application: request.application_id.clone(),
2559 revision: request.revision_id.clone(),
2560 schema_version: request.schema_version,
2561 state_before: original.state_before,
2562 state_after: original.state_after,
2563 operations: request.operations.len(),
2564 authorization_decisions: vec!["state:write allowed".into()],
2565 causal_parent: request.causal_parent,
2566 result: "duplicate".into(),
2567 };
2568 Ok(Some(TransactionResult {
2569 transaction_id: transaction_id.to_string(),
2570 base_revision: original.base_revision,
2571 commit_revision: original.commit_revision,
2572 status: original.status,
2573 state_before: original.state_before,
2574 state_after: original.state_after,
2575 causal_cursor: cursor,
2576 duplicate: true,
2577 audit,
2578 }))
2579}
2580
2581#[allow(clippy::too_many_arguments)]
2583fn commit_new_transaction(
2584 db: &FeltDb,
2585 schema: &StateSchema,
2586 request: &TransactionRequest,
2587 uniform_write_policy: Option<&PolicySubject>,
2588 collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
2589 transaction_id: &str,
2590 storage_transaction_id: &str,
2591 payload_hash: &str,
2592) -> Result<TransactionResult, StateFailure> {
2593 let transaction_id = transaction_id.to_string();
2594 let storage_transaction_id = storage_transaction_id.to_string();
2595 let payload_hash = payload_hash.to_string();
2596 let namespace = request
2597 .state_namespace
2598 .as_deref()
2599 .unwrap_or(&request.application_id)
2600 .to_string();
2601 let snapshot = transaction_snapshot_rows(
2602 db,
2603 schema,
2604 request,
2605 uniform_write_policy,
2606 collection_write_policies,
2607 )?;
2608 let mut record_preconditions: Vec<RecordPrecondition> = Vec::new();
2613 for condition in &request.preconditions {
2614 let (resource, expected, actual, passed) = match condition {
2615 TransactionPrecondition::Record {
2616 collection,
2617 id,
2618 require_absent,
2619 expected_version,
2620 expected_epoch,
2621 expected_lease_id,
2622 } => {
2623 let precondition = RecordPrecondition {
2624 capability: format!("{namespace}:{collection}"),
2625 key: id.clone(),
2626 require_absent: *require_absent,
2627 expected_version: *expected_version,
2628 expected_epoch: *expected_epoch,
2629 expected_lease_id: expected_lease_id.clone(),
2630 };
2631 if precondition.is_empty() {
2632 let mut failure = StateFailure::new(
2635 "VALIDATION_FAILED",
2636 &format!("precondition on {collection}:{id} constrains nothing"),
2637 );
2638 failure.resource = Some(format!("{collection}:{id}"));
2639 return Err(failure);
2640 }
2641 record_preconditions.push(precondition);
2642 continue;
2643 }
2644 TransactionPrecondition::CollectionVersion {
2645 collection,
2646 version,
2647 } => {
2648 let capability = format!(
2649 "{}:{}",
2650 request
2651 .state_namespace
2652 .as_deref()
2653 .unwrap_or(&request.application_id),
2654 collection
2655 );
2656 let actual = snapshot
2657 .iter()
2658 .filter(|row| row.capability == capability)
2659 .filter_map(|row| row.operation.as_ref().map(|op| op.sequence))
2660 .max()
2661 .unwrap_or(0);
2662 (
2663 collection.clone(),
2664 Value::from(*version),
2665 Value::from(actual),
2666 actual == *version,
2667 )
2668 }
2669 TransactionPrecondition::StateVersion { version } => {
2670 let actual = db.sequence().map_err(StateFailure::storage)?;
2671 (
2672 "state".into(),
2673 Value::from(*version),
2674 Value::from(actual),
2675 actual == *version,
2676 )
2677 }
2678 TransactionPrecondition::ApplicationRevision { revision_id } => (
2679 ("application_revision").into(),
2680 Value::String(revision_id.clone()),
2681 Value::String(schema.revision_id.clone()),
2682 revision_id == &schema.revision_id,
2683 ),
2684 TransactionPrecondition::CausalCursor { cursor } => {
2685 let actual: BTreeMap<_, _> = db
2686 .operation_versions()
2687 .map_err(StateFailure::storage)?
2688 .into_iter()
2689 .collect();
2690 (
2691 "causal_cursor".into(),
2692 serde_json::to_value(cursor).map_err(StateFailure::storage)?,
2693 serde_json::to_value(&actual).map_err(StateFailure::storage)?,
2694 cursor == &actual,
2695 )
2696 }
2697 };
2698 if !passed {
2699 let mut failure =
2700 StateFailure::new("PRECONDITION_FAILED", "transaction precondition failed");
2701 failure.resource = Some(resource);
2702 failure.expected = Some(expected);
2703 failure.actual = Some(actual);
2704 failure.transaction = Some(transaction_id.clone());
2705 failure.causal_position = db.sequence().ok();
2706 return Err(failure);
2707 }
2708 }
2709 let mut mutations = vec![];
2710 let mut preconditions = vec![];
2711 let mut overlay: BTreeMap<(String, String), Option<Value>> = BTreeMap::new();
2712 for operation in &request.operations {
2713 if !request.authorization.writable_collections.is_empty()
2714 && !request
2715 .authorization
2716 .writable_collections
2717 .contains(&operation.collection)
2718 {
2719 return Err(StateFailure::new(
2720 "AUTHORIZATION_DENIED",
2721 "collection is not writable",
2722 ));
2723 }
2724 let substrate = is_substrate_collection(&operation.collection);
2727 if substrate && !request.authorization.can("composition:write") {
2728 let mut failure = StateFailure::new(
2729 "AUTHORIZATION_DENIED",
2730 "composition:write is required for substrate collections",
2731 );
2732 failure.resource = Some(operation.collection.clone());
2733 return Err(failure);
2734 }
2735 let definition = if substrate {
2736 None
2737 } else {
2738 Some(collection(schema, &operation.collection)?)
2739 };
2740 let capability = format!(
2741 "{}:{}",
2742 request
2743 .state_namespace
2744 .as_deref()
2745 .unwrap_or(&request.application_id),
2746 operation.collection
2747 );
2748 let key = operation.id.clone();
2749 let persisted = snapshot
2750 .iter()
2751 .find(|row| row.capability == capability && row.key == key && !row.deleted);
2752 let existing_value = overlay
2753 .get(&(capability.clone(), key.clone()))
2754 .cloned()
2755 .unwrap_or_else(|| persisted.map(|row| Some(row.value.clone())).unwrap_or(None));
2756
2757 let write_policy = collection_write_policies
2759 .and_then(|policies| policies.get(&operation.collection))
2760 .or(uniform_write_policy)
2761 .filter(|_| !substrate);
2762 if let Some(policy) = write_policy {
2763 let actor = if request.authorization.subject.is_empty()
2764 || request.authorization.subject == ":"
2765 {
2766 None
2767 } else {
2768 request
2769 .authorization
2770 .subject
2771 .split(':')
2772 .nth(1)
2773 .map(|id| Actor::new(id))
2774 };
2775
2776 let auth_record_value = match operation.kind {
2778 TransactionOperationKind::Insert => {
2779 operation.value.clone()
2781 }
2782 TransactionOperationKind::Update | TransactionOperationKind::Delete => {
2783 existing_value
2786 .clone()
2787 .ok_or_else(|| StateFailure::new("CONFLICT", "record does not exist"))?
2788 }
2789 };
2790
2791 let auth_state = AuthorizationState::new(
2793 Arc::new(schema.clone()),
2794 request
2795 .state_namespace
2796 .as_deref()
2797 .unwrap_or(&request.application_id)
2798 .to_string(),
2799 Arc::new(snapshot.clone()),
2800 );
2801
2802 let record_context = RecordAuthorizationContext {
2803 actor,
2804 collection: operation.collection.clone(),
2805 record_id: key.clone(),
2806 record_value: auth_record_value,
2807 authorization_state: Some(auth_state),
2808 };
2809
2810 PolicyEvaluator::evaluate_record(policy.clone(), &record_context).map_err(|_| {
2811 StateFailure::new(
2812 "AUTHORIZATION_DENIED",
2813 &format!(
2814 "record authorization failed for operation on {}:{}",
2815 operation.collection, key
2816 ),
2817 )
2818 })?;
2819 }
2820
2821 let value = match operation.kind {
2822 TransactionOperationKind::Insert => {
2823 if existing_value.is_some() {
2824 let refusal = crate::PreconditionFailure::Present {
2827 collection: capability.clone(),
2828 key: key.clone(),
2829 };
2830 let mut failure =
2831 StateFailure::new("PRECONDITION_FAILED", &refusal.to_string());
2832 failure.transaction = Some(transaction_id.clone());
2833 failure.resource = Some(format!("{}:{}", operation.collection, key));
2834 failure.failure =
2835 Some(refusal.public_json(&crate::ReadLayout::Namespace(namespace.clone())));
2836 return Err(failure);
2837 }
2838 let mut value = operation.value.clone();
2839 match definition {
2840 Some(definition) => validate_record(definition, &mut value)?,
2841 None if value.is_object() => {}
2842 None => {
2843 return Err(StateFailure::new(
2844 "VALIDATION_FAILED",
2845 "record must be an object",
2846 ));
2847 }
2848 }
2849 Some(value)
2850 }
2851 TransactionOperationKind::Update => {
2852 let current = existing_value
2853 .ok_or_else(|| StateFailure::new("CONFLICT", "record does not exist"))?;
2854 let mut value = current;
2855 let patch = operation.value.as_object().ok_or_else(|| {
2856 StateFailure::new("VALIDATION_FAILED", "update must be an object")
2857 })?;
2858 for (k, v) in patch {
2859 value
2860 .as_object_mut()
2861 .expect("existing object")
2862 .insert(k.clone(), v.clone());
2863 }
2864 if let Some(definition) = definition {
2865 validate_record(definition, &mut value)?;
2866 }
2867 Some(value)
2868 }
2869 TransactionOperationKind::Delete => {
2870 if existing_value.is_none() {
2871 return Err(StateFailure::new("CONFLICT", "record does not exist"));
2872 }
2873 None
2874 }
2875 };
2876 preconditions.push(AtomicPrecondition {
2877 capability: capability.clone(),
2878 key: key.clone(),
2879 expected_version: operation.if_version.or_else(|| {
2880 persisted
2881 .and_then(|row| row.operation.as_ref())
2882 .map(|op| op.sequence)
2883 }),
2884 });
2885 overlay.insert((capability.clone(), key.clone()), value.clone());
2886 mutations.push(AtomicMutation {
2887 capability,
2888 key,
2889 value,
2890 });
2891 }
2892 for definition in &schema.collections {
2893 let capability = format!(
2894 "{}:{}",
2895 request
2896 .state_namespace
2897 .as_deref()
2898 .unwrap_or(&request.application_id),
2899 definition.name
2900 );
2901 let mut referenced_records = BTreeMap::new();
2902 for ((cap, key), value) in overlay
2903 .iter()
2904 .filter(|((cap, _), value)| cap == &capability && value.is_some())
2905 {
2906 let value = value.as_ref().expect("filtered");
2907 for field in &definition.fields {
2908 if let FieldType::Reference { collection: target } = &field.field_type {
2909 if let Some(reference) = value.get(&field.name).and_then(Value::as_str) {
2910 let target_capability = format!(
2911 "{}:{}",
2912 request
2913 .state_namespace
2914 .as_deref()
2915 .unwrap_or(&request.application_id),
2916 target
2917 );
2918 let present = if let Some(value) =
2919 overlay.get(&(target_capability.clone(), reference.into()))
2920 {
2921 value.is_some()
2922 } else if snapshot.iter().any(|row| {
2923 row.capability == target_capability
2924 && row.key == reference
2925 && !row.deleted
2926 }) {
2927 true
2928 } else {
2929 let cache_key = (target_capability.clone(), reference.to_string());
2930 if let Some(present) = referenced_records.get(&cache_key) {
2931 *present
2932 } else {
2933 let present = db
2934 .get_collection_record(&target_capability, reference)
2935 .map_err(StateFailure::storage)?
2936 .is_some();
2937 referenced_records.insert(cache_key, present);
2938 present
2939 }
2940 };
2941 if !present {
2942 let mut failure = StateFailure::new(
2943 "VALIDATION_FAILED",
2944 &format!(
2945 "reference {}.{} points to a missing record",
2946 definition.name, field.name
2947 ),
2948 );
2949 failure.resource = Some(format!("{cap}:{key}"));
2950 return Err(failure);
2951 }
2952 }
2953 }
2954 }
2955 }
2956 for index in definition.indexes.iter().filter(|index| index.unique) {
2957 let mut seen = BTreeSet::new();
2958 let existing = snapshot
2959 .iter()
2960 .filter(|row| row.capability == capability && !row.deleted)
2961 .filter(|row| !overlay.contains_key(&(capability.clone(), row.key.clone())))
2962 .map(|row| &row.value)
2963 .chain(
2964 overlay
2965 .iter()
2966 .filter(|((cap, _), _)| cap == &capability)
2967 .filter_map(|(_, value)| value.as_ref()),
2968 );
2969 for value in existing {
2970 let tuple = index
2971 .fields
2972 .iter()
2973 .map(|field| value.get(field).cloned().unwrap_or(Value::Null))
2974 .collect::<Vec<_>>();
2975 if index.sparse && tuple.iter().any(Value::is_null) {
2976 continue;
2977 }
2978 let encoded = serde_json::to_string(&tuple).map_err(StateFailure::storage)?;
2979 if !seen.insert(encoded) {
2980 return Err(StateFailure::new(
2981 "CONFLICT",
2982 &format!("unique index {} violated", index.name),
2983 ));
2984 }
2985 }
2986 }
2987 }
2988 let commit = db
2989 .apply_atomic_transaction_fenced_in(
2990 &storage_transaction_id,
2991 Some(&payload_hash),
2992 request.causal_parent,
2993 &preconditions,
2994 &record_preconditions,
2995 &crate::ReadLayout::Namespace(namespace.clone()),
2996 &request.fences,
2997 &mutations,
2998 Some(serde_json::json!({"transaction_id":transaction_id.clone(),"subject":request.authorization.subject.clone(),"tenant":request.tenant_id.clone(),"application":request.application_id.clone(),"revision":request.revision_id.clone(),"schema_version":request.schema_version,"operations":request.operations.len(),"authorization_decisions":["state:write allowed"],"causal_parent":request.causal_parent,"result":"committed"})),
2999 )
3000 .map_err(|error| {
3001 if let FlowError::PreconditionFailed(refusal) = &error {
3007 let layout = crate::ReadLayout::Namespace(namespace.clone());
3008 let public = refusal.public_json(&layout);
3009 let mut failure =
3010 StateFailure::new("PRECONDITION_FAILED", &error.to_string());
3011 failure.transaction = Some(transaction_id.clone());
3012 failure.causal_position = db.sequence().ok();
3013 failure.resource = public
3014 .get("key")
3015 .and_then(Value::as_str)
3016 .map(str::to_owned)
3017 .or_else(|| Some(refusal.key().to_string()));
3018 failure.failure = Some(public);
3019 if let PreconditionFailure::Version {
3020 expected, actual, ..
3021 } = refusal.as_ref()
3022 {
3023 failure.expected = Some(Value::from(*expected));
3024 failure.actual = Some(Value::from(*actual));
3025 }
3026 return failure;
3027 }
3028 if let FlowError::RevisionConflict { expected, actual } = &error {
3029 let mut failure =
3030 StateFailure::new("PRECONDITION_FAILED", &error.to_string());
3031 failure.transaction = Some(transaction_id.clone());
3032 failure.causal_position = db.sequence().ok();
3033 failure.resource = Some("authority_revision".into());
3034 failure.expected = Some(Value::from(*expected));
3035 failure.actual = Some(Value::from(*actual));
3036 return failure;
3037 }
3038 if let FlowError::TransactionIdPayloadMismatch { .. } = &error {
3039 let mut failure = idempotency_conflict();
3042 failure.transaction = Some(transaction_id.clone());
3043 return failure;
3044 }
3045 if let FlowError::Io(_) | FlowError::Serde(_) = &error {
3046 let mut failure = StateFailure::new("DURABILITY_FAILURE", &error.to_string());
3050 failure.transaction = Some(transaction_id.clone());
3051 return failure;
3052 }
3053 let text = error.to_string();
3054 if text.contains("INVALID_READ_SET:") {
3055 let mut failure = StateFailure::new("VALIDATION_FAILED", &text);
3057 failure.transaction = Some(transaction_id.clone());
3058 return failure;
3059 }
3060 if text.contains("PRECONDITION_FAILED") {
3061 let mut failure = StateFailure::new("PRECONDITION_FAILED", &text);
3062 failure.transaction = Some(transaction_id.clone());
3063 failure.causal_position = db.sequence().ok();
3064 if let Some(condition) = preconditions.first() {
3065 failure.resource = Some(format!("{}:{}", condition.capability, condition.key));
3066 failure.expected = Some(
3067 condition
3068 .expected_version
3069 .map(Value::from)
3070 .unwrap_or(Value::Null),
3071 );
3072 failure.actual = Some(
3073 snapshot
3074 .iter()
3075 .find(|row| {
3076 row.capability == condition.capability && row.key == condition.key
3077 })
3078 .and_then(|row| row.operation.as_ref())
3079 .map(|op| Value::from(op.sequence))
3080 .unwrap_or(Value::Null),
3081 );
3082 }
3083 failure
3084 } else {
3085 StateFailure::storage(error)
3086 }
3087 })?;
3088 let cursor = db
3089 .operation_versions()
3090 .map_err(StateFailure::storage)?
3091 .into_iter()
3092 .collect();
3093 let audit = TransactionAudit {
3094 transaction_id: transaction_id.clone(),
3095 subject: request.authorization.subject.clone(),
3096 tenant: request.tenant_id.clone(),
3097 application: request.application_id.clone(),
3098 revision: request.revision_id.clone(),
3099 schema_version: request.schema_version,
3100 state_before: commit.state_before,
3101 state_after: commit.state_after,
3102 operations: request.operations.len(),
3103 authorization_decisions: vec!["state:write allowed".into()],
3104 causal_parent: request.causal_parent,
3105 result: if commit.duplicate {
3106 "duplicate"
3107 } else {
3108 "committed"
3109 }
3110 .into(),
3111 };
3112 Ok(TransactionResult {
3113 transaction_id,
3114 base_revision: commit.base_revision,
3115 commit_revision: commit.commit_revision,
3116 status: commit.status,
3117 state_before: commit.state_before,
3118 state_after: commit.state_after,
3119 causal_cursor: cursor,
3120 duplicate: commit.duplicate,
3121 audit,
3122 })
3123}
3124
3125#[cfg(test)]
3126mod tests {
3127 use super::*;
3128 #[test]
3129 fn extended_field_types_have_canonical_validation() {
3130 let valid = [
3131 (
3132 "uuid",
3133 serde_json::json!("550e8400-e29b-41d4-a716-446655440000"),
3134 ),
3135 ("decimal", serde_json::json!("-1234567890.0123456789")),
3136 ("bigint", serde_json::json!("9223372036854775808123")),
3137 ("date", serde_json::json!("2026-08-21")),
3138 ("time", serde_json::json!("13:45:30")),
3139 ("datetime", serde_json::json!("2026-08-21T13:45:30Z")),
3140 ("email", serde_json::json!("person@example.com")),
3141 ("url", serde_json::json!("https://example.com/path")),
3142 ("phone", serde_json::json!("+1 (212) 555-0100")),
3143 ("binary", serde_json::json!("SGVsbG8=")),
3144 (
3145 "money",
3146 serde_json::json!({"amount":"19.99","currency":"USD"}),
3147 ),
3148 (
3149 "file",
3150 serde_json::json!({"name":"report.pdf","type":"application/pdf","size":42,"ref":"sha256:abc"}),
3151 ),
3152 (
3153 "geo",
3154 serde_json::json!({"latitude":40.7,"longitude":-74.0}),
3155 ),
3156 ("object", serde_json::json!({"enabled":true})),
3157 (
3158 "array<uuid>",
3159 serde_json::json!(["550e8400-e29b-41d4-a716-446655440000"]),
3160 ),
3161 ("map<integer>", serde_json::json!({"one":1,"two":2})),
3162 ("vector<3>", serde_json::json!([0.1, 0.2, 0.3])),
3163 ];
3164 for (kind, value) in valid {
3165 assert!(
3166 valid_nested_type(&value, &parse_manifest_type(kind)),
3167 "{kind} rejected {value}"
3168 );
3169 }
3170 assert!(!valid_nested_type(
3171 &serde_json::json!("12.3x"),
3172 &parse_manifest_type("decimal")
3173 ));
3174 assert!(!valid_nested_type(
3175 &serde_json::json!([1, 2]),
3176 &parse_manifest_type("vector<3>")
3177 ));
3178 assert!(!valid_nested_type(
3179 &serde_json::json!({"latitude":100,"longitude":0}),
3180 &parse_manifest_type("geo")
3181 ));
3182 }
3183 fn schema() -> StateSchema {
3184 StateSchema {
3185 contract_version: 1,
3186 schema_version: 1,
3187 application_id: "app".into(),
3188 revision_id: "rev".into(),
3189 collections: vec![CollectionSchema {
3190 name: "incidents".into(),
3191 version: 1,
3192 fields: vec![
3193 FieldSchema {
3194 name: "title".into(),
3195 field_type: FieldType::Primitive {
3196 primitive: PrimitiveType::String,
3197 },
3198 nullable: false,
3199 required: true,
3200 default: None,
3201 constraints: Default::default(),
3202 computed: None,
3203 },
3204 FieldSchema {
3205 name: "status".into(),
3206 field_type: FieldType::Enum {
3207 values: vec!["open".into(), "closed".into()],
3208 },
3209 nullable: false,
3210 required: false,
3211 default: Some(Value::String("open".into())),
3212 constraints: Default::default(),
3213 computed: None,
3214 },
3215 ],
3216 indexes: vec![SchemaIndex {
3217 name: "status".into(),
3218 fields: vec!["status".into()],
3219 unique: false,
3220 sparse: false,
3221 version: 1,
3222 }],
3223 }],
3224 }
3225 }
3226 fn scoped_schema() -> StateSchema {
3227 let mut schema = schema();
3228 schema.collections[0].fields[1].field_type = FieldType::Primitive {
3229 primitive: PrimitiveType::String,
3230 };
3231 schema
3232 }
3233 fn auth() -> AuthorizationContext {
3234 AuthorizationContext {
3235 subject: "user:1".into(),
3236 tenant_id: "tenant".into(),
3237 application_id: "app".into(),
3238 revision_id: "rev".into(),
3239 capabilities: ["state:read".into(), "state:write".into()].into(),
3240 readable_collections: Default::default(),
3241 writable_collections: Default::default(),
3242 field_projections: Default::default(),
3243 }
3244 }
3245 fn schema_with_owner() -> StateSchema {
3246 StateSchema {
3247 contract_version: 1,
3248 schema_version: 1,
3249 application_id: "app".into(),
3250 revision_id: "rev".into(),
3251 collections: vec![CollectionSchema {
3252 name: "incidents".into(),
3253 version: 1,
3254 fields: vec![
3255 FieldSchema {
3256 name: "title".into(),
3257 field_type: FieldType::Primitive {
3258 primitive: PrimitiveType::String,
3259 },
3260 nullable: false,
3261 required: true,
3262 default: None,
3263 constraints: Default::default(),
3264 computed: None,
3265 },
3266 FieldSchema {
3267 name: "owner_id".into(),
3268 field_type: FieldType::Primitive {
3269 primitive: PrimitiveType::String,
3270 },
3271 nullable: false,
3272 required: false,
3273 default: None,
3274 constraints: Default::default(),
3275 computed: None,
3276 },
3277 ],
3278 indexes: vec![],
3279 }],
3280 }
3281 }
3282
3283 fn db(name: &str) -> FeltDb {
3284 FeltDb::open(
3285 std::env::temp_dir().join(format!("state-contract-{name}-{}.log", crate::now_ms())),
3286 )
3287 .unwrap()
3288 }
3289 #[test]
3290 fn schema_hash_is_deterministic_and_invalid_schema_fails() {
3291 let a = schema();
3292 let mut b = a.clone();
3293 b.collections[0].fields.reverse();
3294 assert_eq!(schema_hash(&a).unwrap(), schema_hash(&b).unwrap());
3295 let mut invalid = a;
3296 invalid.collections[0].fields[0].required = true;
3297 invalid.collections[0].fields[0].nullable = true;
3298 assert!(!validate_schema(&invalid).valid);
3299 }
3300 #[test]
3301 fn compatibility_classifies_required_and_type_changes() {
3302 let a = schema();
3303 let mut b = a.clone();
3304 b.schema_version = 2;
3305 b.collections[0].fields.push(FieldSchema {
3306 name: "owner".into(),
3307 field_type: FieldType::Primitive {
3308 primitive: PrimitiveType::String,
3309 },
3310 nullable: false,
3311 required: true,
3312 default: None,
3313 constraints: Default::default(),
3314 computed: None,
3315 });
3316 assert_eq!(
3317 compare_schemas(&a, &b).classification,
3318 SchemaCompatibility::Destructive
3319 );
3320 b.collections[0].fields[0].field_type = FieldType::Primitive {
3321 primitive: PrimitiveType::Integer,
3322 };
3323 assert_eq!(
3324 compare_schemas(&a, &b).classification,
3325 SchemaCompatibility::Incompatible
3326 );
3327 }
3328 #[test]
3329 fn transaction_is_atomic_and_preconditions_are_structured() {
3330 let db = db("tx");
3331 let schema = schema();
3332 let request = TransactionRequest {
3333 transaction_id: None,
3334 tenant_id: "tenant".into(),
3335 application_id: "app".into(),
3336 revision_id: "rev".into(),
3337 schema_version: 1,
3338 state_namespace: None,
3339 causal_parent: Some(0),
3340 authorization: auth(),
3341 preconditions: vec![],
3342 operations: vec![
3343 TransactionOperation {
3344 kind: TransactionOperationKind::Insert,
3345 collection: "incidents".into(),
3346 id: "one".into(),
3347 value: serde_json::json!({"title":"First"}),
3348 if_version: None,
3349 },
3350 TransactionOperation {
3351 kind: TransactionOperationKind::Insert,
3352 collection: "incidents".into(),
3353 id: "two".into(),
3354 value: serde_json::json!({"title":"Second"}),
3355 if_version: None,
3356 },
3357 ],
3358 fences: vec![],
3359 };
3360 let result = execute_transaction(&db, &schema, &request, None).unwrap();
3361 assert_eq!(result.state_after, 2);
3362 assert_eq!(db.export_snapshot().unwrap().rows.len(), 2);
3363 let mut stale = request;
3364 stale.transaction_id = Some("stale".into());
3365 stale.causal_parent = Some(0);
3366 let refused = execute_transaction(&db, &schema, &stale, None).unwrap_err();
3369 assert_eq!(refused.code, "PRECONDITION_FAILED");
3370 let failure = refused.failure.expect("structured failure");
3371 assert_eq!(failure["predicate"], "present");
3372 assert_eq!(failure["collection"], "incidents");
3373 assert_eq!(failure["id"], "one");
3374 assert_eq!(failure["key"], "incidents:one");
3375 assert_eq!(
3376 db.export_snapshot().unwrap().rows.len(),
3377 2,
3378 "nothing written"
3379 );
3380 }
3381 #[test]
3390 fn a_record_precondition_fences_the_document_version() {
3391 let db = db("record-precondition");
3392 let schema = schema();
3393 let write = |id: &str,
3394 transaction: &str,
3395 title: &str,
3396 preconditions: Vec<TransactionPrecondition>,
3397 kind: TransactionOperationKind| {
3398 execute_transaction(
3399 &db,
3400 &schema,
3401 &TransactionRequest {
3402 transaction_id: Some(transaction.into()),
3403 tenant_id: "tenant".into(),
3404 application_id: "app".into(),
3405 revision_id: "rev".into(),
3406 schema_version: 1,
3407 state_namespace: None,
3408 causal_parent: None,
3409 authorization: auth(),
3410 preconditions,
3411 operations: vec![TransactionOperation {
3412 kind,
3413 collection: "incidents".into(),
3414 id: id.into(),
3415 value: serde_json::json!({ "title": title }),
3416 if_version: None,
3417 }],
3418 fences: vec![],
3419 },
3420 None,
3421 )
3422 };
3423 let fence = |version: Option<u64>, absent: bool| {
3424 vec![TransactionPrecondition::Record {
3425 collection: "incidents".into(),
3426 id: "i1".into(),
3427 require_absent: absent,
3428 expected_version: version,
3429 expected_epoch: None,
3430 expected_lease_id: None,
3431 }]
3432 };
3433 let version = || {
3434 db.state_rows()
3435 .unwrap()
3436 .into_iter()
3437 .find(|row| row.capability == "app:incidents" && row.key == "i1" && !row.deleted)
3438 .and_then(|row| row.value.get("__version").and_then(Value::as_u64))
3439 };
3440
3441 write(
3443 "i1",
3444 "create",
3445 "First",
3446 fence(None, true),
3447 TransactionOperationKind::Insert,
3448 )
3449 .expect("the record is absent, so the create commits");
3450 assert_eq!(version(), Some(1));
3451
3452 let refused = write(
3455 "i1",
3456 "create-again",
3457 "Second",
3458 fence(None, true),
3459 TransactionOperationKind::Insert,
3460 )
3461 .expect_err("the record now exists");
3462 assert!(
3463 refused.code == "CONFLICT" || refused.code == "PRECONDITION_FAILED",
3464 "a lost creation race is a conflict, got {}",
3465 refused.code,
3466 );
3467 assert_eq!(version(), Some(1), "a refused transaction writes nothing");
3468
3469 let guarded = write(
3472 "i2",
3473 "guarded",
3474 "Other",
3475 fence(None, true),
3476 TransactionOperationKind::Insert,
3477 )
3478 .expect_err("i1 exists, so the guard fails and i2 is never written");
3479 assert_eq!(guarded.code, "PRECONDITION_FAILED");
3480 assert_eq!(
3481 guarded
3482 .failure
3483 .as_ref()
3484 .and_then(|value| value.get("predicate").and_then(Value::as_str)),
3485 Some("present"),
3486 );
3487 assert!(
3488 db.state_rows()
3489 .unwrap()
3490 .iter()
3491 .all(|row| row.key != "i2" || row.deleted),
3492 "a refused guard leaves the staged write unwritten",
3493 );
3494
3495 let stale = write(
3497 "i1",
3498 "stale",
3499 "Third",
3500 fence(Some(7), false),
3501 TransactionOperationKind::Update,
3502 )
3503 .expect_err("version 7 is not the version on record");
3504 assert_eq!(stale.code, "PRECONDITION_FAILED");
3505 assert_eq!(stale.expected, Some(Value::from(7)));
3506 assert_eq!(stale.actual, Some(Value::from(1)));
3507 assert_eq!(version(), Some(1));
3508
3509 write(
3511 "i1",
3512 "fenced",
3513 "Fourth",
3514 fence(Some(1), false),
3515 TransactionOperationKind::Update,
3516 )
3517 .expect("version 1 is the version on record");
3518 assert_eq!(version(), Some(2));
3519
3520 write(
3523 "i1",
3524 "fenced",
3525 "Fourth",
3526 fence(Some(1), false),
3527 TransactionOperationKind::Update,
3528 )
3529 .expect("a duplicate transaction id is not a failure");
3530 assert_eq!(version(), Some(2));
3531 }
3532
3533 #[test]
3537 fn an_empty_record_precondition_is_refused() {
3538 let db = db("empty-precondition");
3539 let schema = schema();
3540 let failure = execute_transaction(
3541 &db,
3542 &schema,
3543 &TransactionRequest {
3544 transaction_id: Some("empty".into()),
3545 tenant_id: "tenant".into(),
3546 application_id: "app".into(),
3547 revision_id: "rev".into(),
3548 schema_version: 1,
3549 state_namespace: None,
3550 causal_parent: None,
3551 authorization: auth(),
3552 preconditions: vec![TransactionPrecondition::Record {
3553 collection: "incidents".into(),
3554 id: "i1".into(),
3555 require_absent: false,
3556 expected_version: None,
3557 expected_epoch: None,
3558 expected_lease_id: None,
3559 }],
3560 operations: vec![TransactionOperation {
3561 kind: TransactionOperationKind::Insert,
3562 collection: "incidents".into(),
3563 id: "i1".into(),
3564 value: serde_json::json!({ "title": "First" }),
3565 if_version: None,
3566 }],
3567 fences: vec![],
3568 },
3569 None,
3570 )
3571 .expect_err("a precondition that constrains nothing is a caller error");
3572 assert_eq!(failure.code, "VALIDATION_FAILED");
3573 assert!(db.state_rows().unwrap().iter().all(|row| row.deleted));
3574 }
3575
3576 #[test]
3577 fn validation_failure_rolls_back_every_operation() {
3578 let db = db("rollback");
3579 let schema = schema();
3580 let request = TransactionRequest {
3581 transaction_id: None,
3582 tenant_id: "tenant".into(),
3583 application_id: "app".into(),
3584 revision_id: "rev".into(),
3585 schema_version: 1,
3586 state_namespace: None,
3587 causal_parent: None,
3588 authorization: auth(),
3589 preconditions: vec![],
3590 operations: vec![
3591 TransactionOperation {
3592 kind: TransactionOperationKind::Insert,
3593 collection: "incidents".into(),
3594 id: "one".into(),
3595 value: serde_json::json!({"title":"ok"}),
3596 if_version: None,
3597 },
3598 TransactionOperation {
3599 kind: TransactionOperationKind::Insert,
3600 collection: "incidents".into(),
3601 id: "two".into(),
3602 value: serde_json::json!({"status":"open"}),
3603 if_version: None,
3604 },
3605 ],
3606 fences: vec![],
3607 };
3608 assert_eq!(
3609 execute_transaction(&db, &schema, &request, None)
3610 .unwrap_err()
3611 .code,
3612 "VALIDATION_FAILED"
3613 );
3614 assert!(db.export_snapshot().unwrap().rows.is_empty());
3615 }
3616 #[test]
3617 fn query_filters_orders_projects_and_aggregates() {
3618 let db = db("query");
3619 let schema = schema();
3620 for (id, title, status) in [("1", "B", "open"), ("2", "A", "open"), ("3", "C", "closed")] {
3621 execute_transaction(
3622 &db,
3623 &schema,
3624 &TransactionRequest {
3625 transaction_id: None,
3626 tenant_id: "tenant".into(),
3627 application_id: "app".into(),
3628 revision_id: "rev".into(),
3629 schema_version: 1,
3630 state_namespace: None,
3631 causal_parent: None,
3632 authorization: auth(),
3633 preconditions: vec![],
3634 operations: vec![TransactionOperation {
3635 kind: TransactionOperationKind::Insert,
3636 collection: "incidents".into(),
3637 id: id.into(),
3638 value: serde_json::json!({"title":title,"status":status}),
3639 if_version: None,
3640 }],
3641 fences: vec![],
3642 },
3643 None,
3644 )
3645 .unwrap();
3646 }
3647 let context = begin_read(&db, &schema, "app", auth()).unwrap();
3648 let result = execute_query(
3649 &db,
3650 &schema,
3651 &context,
3652 &CanonicalQuery {
3653 collection: "incidents".into(),
3654 filter: Some(QueryFilter::Eq {
3655 field: "status".into(),
3656 value: Value::String("open".into()),
3657 }),
3658 order_by: vec![QueryOrder {
3659 field: "title".into(),
3660 direction: SortDirection::Asc,
3661 }],
3662 limit: Some(1),
3663 offset: 0,
3664 cursor: None,
3665 projection: vec!["title".into()],
3666 group_by: vec!["status".into()],
3667 aggregates: vec![AggregateDefinition {
3668 name: "total".into(),
3669 function: AggregateFunction::Count,
3670 field: None,
3671 }],
3672 references: vec![],
3673 semantic: None,
3674 },
3675 None,
3676 )
3677 .unwrap();
3678 assert_eq!(result.records, vec![serde_json::json!({"title":"A"})]);
3679 assert_eq!(result.aggregates[0]["total"], 2);
3680 assert_eq!(result.state_version, 3);
3681 }
3682 #[test]
3683 fn authorization_prevents_projection_before_read() {
3684 let db = db("auth");
3685 let schema = schema();
3686 let mut auth = auth();
3687 auth.field_projections
3688 .insert("incidents".into(), ["status".into()].into());
3689 let context = begin_read(&db, &schema, "app", auth).unwrap();
3690 let error = execute_query(
3691 &db,
3692 &schema,
3693 &context,
3694 &CanonicalQuery {
3695 collection: "incidents".into(),
3696 filter: None,
3697 order_by: vec![],
3698 limit: None,
3699 offset: 0,
3700 cursor: None,
3701 projection: vec!["title".into()],
3702 group_by: vec![],
3703 aggregates: vec![],
3704 references: vec![],
3705 semantic: None,
3706 },
3707 None,
3708 )
3709 .unwrap_err();
3710 assert_eq!(error.code, "UNAUTHORIZED_PROJECTION");
3711 }
3712 #[test]
3713 fn stale_record_precondition_is_structured_and_identity_is_idempotent() {
3714 let db = db("precondition");
3715 let schema = schema();
3716 let mut request = TransactionRequest {
3717 transaction_id: None,
3718 tenant_id: "tenant".into(),
3719 application_id: "app".into(),
3720 revision_id: "rev".into(),
3721 schema_version: 1,
3722 state_namespace: None,
3723 causal_parent: None,
3724 authorization: auth(),
3725 preconditions: vec![],
3726 operations: vec![TransactionOperation {
3727 kind: TransactionOperationKind::Insert,
3728 collection: "incidents".into(),
3729 id: "one".into(),
3730 value: serde_json::json!({"title":"First"}),
3731 if_version: None,
3732 }],
3733 fences: vec![],
3734 };
3735 let first = execute_transaction(&db, &schema, &request, None).unwrap();
3736 let duplicate = execute_transaction(&db, &schema, &request, None).unwrap();
3737 assert_eq!(first.transaction_id, duplicate.transaction_id);
3738 assert!(duplicate.duplicate);
3739 request.transaction_id = Some("stale-update".into());
3740 request.operations = vec![TransactionOperation {
3741 kind: TransactionOperationKind::Update,
3742 collection: "incidents".into(),
3743 id: "one".into(),
3744 value: serde_json::json!({"title":"Changed"}),
3745 if_version: Some(99),
3746 }];
3747 let failure = execute_transaction(&db, &schema, &request, None).unwrap_err();
3748 assert_eq!(failure.code, "PRECONDITION_FAILED");
3749 assert_eq!(failure.expected, Some(Value::from(99)));
3750 assert_eq!(failure.actual, Some(Value::from(1)));
3751 }
3752 #[test]
3753 fn stale_authority_revision_is_a_structured_precondition_failure() {
3754 let db = db("authority-revision-precondition");
3755 let schema = schema();
3756 let mut request = TransactionRequest {
3757 transaction_id: Some("initial".into()),
3758 tenant_id: "tenant".into(),
3759 application_id: "app".into(),
3760 revision_id: "rev".into(),
3761 schema_version: 1,
3762 state_namespace: None,
3763 causal_parent: None,
3764 authorization: auth(),
3765 preconditions: vec![],
3766 operations: vec![TransactionOperation {
3767 kind: TransactionOperationKind::Insert,
3768 collection: "incidents".into(),
3769 id: "one".into(),
3770 value: serde_json::json!({"title":"First"}),
3771 if_version: None,
3772 }],
3773 fences: vec![],
3774 };
3775 execute_transaction(&db, &schema, &request, None).unwrap();
3776 request.transaction_id = Some("stale-parent".into());
3777 request.causal_parent = Some(0);
3778 request.operations[0].id = "two".into();
3779
3780 let failure = execute_transaction(&db, &schema, &request, None).unwrap_err();
3781 assert_eq!(failure.code, "PRECONDITION_FAILED");
3782 assert_eq!(failure.resource.as_deref(), Some("authority_revision"));
3783 assert_eq!(failure.expected, Some(Value::from(0)));
3784 assert_eq!(failure.actual, Some(Value::from(1)));
3785 }
3786 #[test]
3787 fn explicit_transaction_id_is_bound_to_canonical_payload_across_restart() {
3788 let path = std::env::temp_dir().join(format!(
3789 "feltdb-content-id-{}-{}.log",
3790 std::process::id(),
3791 crate::now_ms()
3792 ));
3793 let schema = schema();
3794 let request = TransactionRequest {
3795 transaction_id: Some("stable-client-id".into()),
3796 tenant_id: "tenant".into(),
3797 application_id: "app".into(),
3798 revision_id: "rev".into(),
3799 schema_version: 1,
3800 state_namespace: None,
3801 causal_parent: None,
3802 authorization: auth(),
3803 preconditions: vec![],
3804 operations: vec![TransactionOperation {
3805 kind: TransactionOperationKind::Insert,
3806 collection: "incidents".into(),
3807 id: "content-bound".into(),
3808 value: serde_json::json!({"title":"Original"}),
3809 if_version: None,
3810 }],
3811 fences: vec![],
3812 };
3813 {
3814 let db = crate::open(&path).unwrap();
3815 assert!(
3816 !execute_transaction(&db, &schema, &request, None)
3817 .unwrap()
3818 .duplicate
3819 );
3820 assert!(
3821 execute_transaction(&db, &schema, &request, None)
3822 .unwrap()
3823 .duplicate
3824 );
3825 }
3826 let reopened = crate::open(&path).unwrap();
3827 assert!(
3828 execute_transaction(&reopened, &schema, &request, None)
3829 .unwrap()
3830 .duplicate
3831 );
3832 let mut changed = request.clone();
3833 changed.operations[0].value = serde_json::json!({"title":"Changed"});
3834 let failure = execute_transaction(&reopened, &schema, &changed, None).unwrap_err();
3835 assert_eq!(failure.code, "IDEMPOTENCY_CONFLICT");
3836 assert!(failure.message.contains("different canonical payload"));
3837 std::fs::remove_file(path).ok();
3838 }
3839 #[test]
3840 fn explicit_transaction_id_is_isolated_between_applications() {
3841 fn application(
3842 app: &str,
3843 namespace: &str,
3844 transaction: &str,
3845 record: &str,
3846 ) -> (StateSchema, TransactionRequest) {
3847 let mut schema = schema();
3848 schema.application_id = app.into();
3849 let mut authorization = auth();
3850 authorization.application_id = app.into();
3851 let request = TransactionRequest {
3852 transaction_id: Some(transaction.into()),
3853 tenant_id: "tenant".into(),
3854 application_id: app.into(),
3855 revision_id: "rev".into(),
3856 schema_version: 1,
3857 state_namespace: Some(namespace.into()),
3858 causal_parent: None,
3859 authorization,
3860 preconditions: vec![],
3861 operations: vec![TransactionOperation {
3862 kind: TransactionOperationKind::Insert,
3863 collection: "incidents".into(),
3864 id: record.into(),
3865 value: serde_json::json!({"title":format!("{app}-{record}")}),
3866 if_version: None,
3867 }],
3868 fences: vec![],
3869 };
3870 (schema, request)
3871 }
3872 for (name, order) in [
3873 (
3874 "a-then-b",
3875 vec![("app-a", "shared-1", "one"), ("app-b", "shared-1", "one")],
3876 ),
3877 (
3878 "b-then-a",
3879 vec![("app-b", "shared-1", "one"), ("app-a", "shared-1", "one")],
3880 ),
3881 (
3882 "interleaved",
3883 vec![
3884 ("app-a", "shared-1", "one"),
3885 ("app-b", "shared-1", "one"),
3886 ("app-a", "shared-2", "two"),
3887 ("app-b", "shared-2", "two"),
3888 ],
3889 ),
3890 ] {
3891 let path = std::env::temp_dir().join(format!(
3892 "feltdb-app-isolation-{name}-{}-{}.log",
3893 std::process::id(),
3894 crate::now_ms()
3895 ));
3896 {
3897 let db = crate::open(&path).unwrap();
3898 for (app, transaction, record) in &order {
3899 let (schema, request) =
3900 application(app, &format!("{app}:production"), transaction, record);
3901 assert!(
3902 !execute_transaction(&db, &schema, &request, None)
3903 .unwrap()
3904 .duplicate
3905 );
3906 }
3907 }
3908 let reopened = crate::open(&path).unwrap();
3909 for (app, transaction, record) in &order {
3910 let (schema, request) =
3911 application(app, &format!("{app}:production"), transaction, record);
3912 assert!(
3913 execute_transaction(&reopened, &schema, &request, None)
3914 .unwrap()
3915 .duplicate
3916 );
3917 let rows = reopened.state_rows().unwrap();
3918 assert_eq!(
3919 rows.iter()
3920 .filter(
3921 |row| row.capability == format!("{app}:production:incidents")
3922 && row.key == *record
3923 && !row.deleted
3924 )
3925 .count(),
3926 1
3927 );
3928 }
3929 std::fs::remove_file(path).ok();
3930 }
3931 }
3932 #[test]
3933 fn read_context_remains_a_stable_snapshot() {
3934 let db = db("snapshot-read");
3935 let schema = schema();
3936 let insert = |id: &str| TransactionRequest {
3937 transaction_id: None,
3938 tenant_id: "tenant".into(),
3939 application_id: "app".into(),
3940 revision_id: "rev".into(),
3941 schema_version: 1,
3942 state_namespace: None,
3943 causal_parent: None,
3944 authorization: auth(),
3945 preconditions: vec![],
3946 operations: vec![TransactionOperation {
3947 kind: TransactionOperationKind::Insert,
3948 collection: "incidents".into(),
3949 id: id.into(),
3950 value: serde_json::json!({"title":id}),
3951 if_version: None,
3952 }],
3953 fences: vec![],
3954 };
3955 execute_transaction(&db, &schema, &insert("one"), None).unwrap();
3956 let context = begin_read(&db, &schema, "app", auth()).unwrap();
3957 execute_transaction(&db, &schema, &insert("two"), None).unwrap();
3958 let result = execute_query(
3959 &db,
3960 &schema,
3961 &context,
3962 &CanonicalQuery {
3963 collection: "incidents".into(),
3964 filter: None,
3965 order_by: vec![],
3966 limit: None,
3967 offset: 0,
3968 cursor: None,
3969 projection: vec![],
3970 group_by: vec![],
3971 aggregates: vec![],
3972 references: vec![],
3973 semantic: None,
3974 },
3975 None,
3976 )
3977 .unwrap();
3978 assert_eq!(result.records.len(), 1);
3979 assert_eq!(result.state_version, 1);
3980 }
3981 #[test]
3982 fn transaction_log_envelope_replays_all_rows() {
3983 let path =
3984 std::env::temp_dir().join(format!("state-contract-replay-{}.log", crate::now_ms()));
3985 let schema = schema();
3986 {
3987 let db = FeltDb::open(&path).unwrap();
3988 let request = TransactionRequest {
3989 transaction_id: Some("replay-tx".into()),
3990 tenant_id: "tenant".into(),
3991 application_id: "app".into(),
3992 revision_id: "rev".into(),
3993 schema_version: 1,
3994 state_namespace: None,
3995 causal_parent: None,
3996 authorization: auth(),
3997 preconditions: vec![],
3998 operations: vec![
3999 TransactionOperation {
4000 kind: TransactionOperationKind::Insert,
4001 collection: "incidents".into(),
4002 id: "one".into(),
4003 value: serde_json::json!({"title":"One"}),
4004 if_version: None,
4005 },
4006 TransactionOperation {
4007 kind: TransactionOperationKind::Insert,
4008 collection: "incidents".into(),
4009 id: "two".into(),
4010 value: serde_json::json!({"title":"Two"}),
4011 if_version: None,
4012 },
4013 ],
4014 fences: vec![],
4015 };
4016 execute_transaction(&db, &schema, &request, None).unwrap();
4017 }
4018 let reopened = FeltDb::open(path).unwrap();
4019 assert_eq!(reopened.export_snapshot().unwrap().rows.len(), 2);
4020 assert_eq!(reopened.current_revision().unwrap(), 1);
4021 }
4022 #[test]
4023 fn indexed_equality_query_uses_index_not_scan() {
4024 let db = db("regression-indexed-equality");
4025 let schema = schema();
4026 let request = TransactionRequest {
4027 transaction_id: None,
4028 tenant_id: "tenant".into(),
4029 application_id: "app".into(),
4030 revision_id: "rev".into(),
4031 schema_version: 1,
4032 state_namespace: None,
4033 causal_parent: None,
4034 authorization: auth(),
4035 preconditions: vec![],
4036 operations: (1..=100)
4037 .map(|i| TransactionOperation {
4038 kind: TransactionOperationKind::Insert,
4039 collection: "incidents".into(),
4040 id: format!("incident-{}", i),
4041 value: serde_json::json!({
4042 "title": format!("Incident {}", i),
4043 "status": if i % 2 == 0 { "open" } else { "closed" }
4044 }),
4045 if_version: None,
4046 })
4047 .collect(),
4048 fences: vec![],
4049 };
4050 execute_transaction(&db, &schema, &request, None).unwrap();
4051 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4052 let result = execute_query(
4053 &db,
4054 &schema,
4055 &context,
4056 &CanonicalQuery {
4057 collection: "incidents".into(),
4058 filter: Some(QueryFilter::Eq {
4059 field: "status".into(),
4060 value: Value::String("open".into()),
4061 }),
4062 order_by: vec![],
4063 limit: Some(100),
4064 offset: 0,
4065 cursor: None,
4066 projection: vec![],
4067 group_by: vec![],
4068 aggregates: vec![],
4069 references: vec![],
4070 semantic: None,
4071 },
4072 None,
4073 )
4074 .unwrap();
4075 assert_eq!(result.plan.access_method, Some("index".to_string()));
4076 assert_eq!(result.plan.index, Some("status".to_string()));
4077 assert!(!result.plan.scan_fallback);
4078 assert_eq!(result.plan.implementation, "index_lookup");
4079 assert!(result.plan.actual_rows_scanned.unwrap() <= 100);
4080 assert_eq!(
4081 result.plan.index_metrics.as_ref().unwrap().index_used,
4082 Some("status".to_string())
4083 );
4084 assert!(result.plan.index_metrics.as_ref().unwrap().index_hits > 0);
4085 }
4086 #[test]
4087 fn non_indexed_field_query_falls_back_to_scan() {
4088 let db = db("regression-non-indexed");
4089 let schema = schema();
4090 let request = TransactionRequest {
4091 transaction_id: None,
4092 tenant_id: "tenant".into(),
4093 application_id: "app".into(),
4094 revision_id: "rev".into(),
4095 schema_version: 1,
4096 state_namespace: None,
4097 causal_parent: None,
4098 authorization: auth(),
4099 preconditions: vec![],
4100 operations: (1..=50)
4101 .map(|i| TransactionOperation {
4102 kind: TransactionOperationKind::Insert,
4103 collection: "incidents".into(),
4104 id: format!("incident-{}", i),
4105 value: serde_json::json!({
4106 "title": format!("Incident {}", i),
4107 "status": "open"
4108 }),
4109 if_version: None,
4110 })
4111 .collect(),
4112 fences: vec![],
4113 };
4114 execute_transaction(&db, &schema, &request, None).unwrap();
4115 let context = begin_read(&db, &schema, "app", auth()).unwrap();
4116 let result = execute_query(
4117 &db,
4118 &schema,
4119 &context,
4120 &CanonicalQuery {
4121 collection: "incidents".into(),
4122 filter: Some(QueryFilter::Eq {
4123 field: "title".into(),
4124 value: Value::String("Incident 25".into()),
4125 }),
4126 order_by: vec![],
4127 limit: Some(100),
4128 offset: 0,
4129 cursor: None,
4130 projection: vec![],
4131 group_by: vec![],
4132 aggregates: vec![],
4133 references: vec![],
4134 semantic: None,
4135 },
4136 None,
4137 )
4138 .unwrap();
4139 assert_eq!(
4140 result.plan.access_method,
4141 Some("sequential_scan".to_string())
4142 );
4143 assert!(result.plan.scan_fallback);
4144 assert_eq!(result.plan.implementation, "full_table_scan");
4145 assert_eq!(
4146 result.plan.index_metrics.as_ref().unwrap().fallback_used,
4147 true
4148 );
4149 assert_eq!(
4150 result.plan.index_metrics.as_ref().unwrap().fallback_reason,
4151 Some("no_applicable_index".to_string())
4152 );
4153 }
4154 #[test]
4155 fn timing_measurements_are_populated() {
4156 let db = db("regression-timing");
4157 let schema = schema();
4158 let request = TransactionRequest {
4159 transaction_id: None,
4160 tenant_id: "tenant".into(),
4161 application_id: "app".into(),
4162 revision_id: "rev".into(),
4163 schema_version: 1,
4164 state_namespace: None,
4165 causal_parent: None,
4166 authorization: auth(),
4167 preconditions: vec![],
4168 operations: vec![TransactionOperation {
4169 kind: TransactionOperationKind::Insert,
4170 collection: "incidents".into(),
4171 id: "test-incident".into(),
4172 value: serde_json::json!({"title": "Test", "status": "open"}),
4173 if_version: None,
4174 }],
4175 fences: vec![],
4176 };
4177 execute_transaction(&db, &schema, &request, None).unwrap();
4178 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4179 let result = execute_query(
4180 &db,
4181 &schema,
4182 &context,
4183 &CanonicalQuery {
4184 collection: "incidents".into(),
4185 filter: Some(QueryFilter::Eq {
4186 field: "status".into(),
4187 value: Value::String("open".into()),
4188 }),
4189 order_by: vec![],
4190 limit: Some(100),
4191 offset: 0,
4192 cursor: None,
4193 projection: vec![],
4194 group_by: vec![],
4195 aggregates: vec![],
4196 references: vec![],
4197 semantic: None,
4198 },
4199 None,
4200 )
4201 .unwrap();
4202 assert!(result.plan.timing.is_some());
4203 let timing = result.plan.timing.unwrap();
4204 assert!(timing.planning_ms.is_some());
4205 assert!(timing.filtering_ms.is_some());
4206 assert!(timing.serialization_ms.is_some());
4207 assert!(timing.total_ms.is_some());
4208 assert!(timing.total_ms.unwrap() > 0.0);
4209 }
4210 #[test]
4211 fn index_metrics_track_hits_and_misses() {
4212 let db = db("regression-metrics");
4213 let schema = schema();
4214 let request = TransactionRequest {
4215 transaction_id: None,
4216 tenant_id: "tenant".into(),
4217 application_id: "app".into(),
4218 revision_id: "rev".into(),
4219 schema_version: 1,
4220 state_namespace: None,
4221 causal_parent: None,
4222 authorization: auth(),
4223 preconditions: vec![],
4224 operations: (1..=20)
4225 .map(|i| TransactionOperation {
4226 kind: TransactionOperationKind::Insert,
4227 collection: "incidents".into(),
4228 id: format!("incident-{}", i),
4229 value: serde_json::json!({
4230 "title": format!("Incident {}", i),
4231 "status": if i <= 10 { "open" } else { "closed" }
4232 }),
4233 if_version: None,
4234 })
4235 .collect(),
4236 fences: vec![],
4237 };
4238 execute_transaction(&db, &schema, &request, None).unwrap();
4239 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4240 let result = execute_query(
4241 &db,
4242 &schema,
4243 &context,
4244 &CanonicalQuery {
4245 collection: "incidents".into(),
4246 filter: Some(QueryFilter::Eq {
4247 field: "status".into(),
4248 value: Value::String("open".into()),
4249 }),
4250 order_by: vec![],
4251 limit: Some(100),
4252 offset: 0,
4253 cursor: None,
4254 projection: vec![],
4255 group_by: vec![],
4256 aggregates: vec![],
4257 references: vec![],
4258 semantic: None,
4259 },
4260 None,
4261 )
4262 .unwrap();
4263 let metrics = result.plan.index_metrics.as_ref().unwrap();
4264 assert_eq!(metrics.index_used, Some("status".to_string()));
4265 assert_eq!(metrics.index_hits, 10);
4266 assert_eq!(metrics.index_misses, 0);
4267 assert!(!metrics.fallback_used);
4268 }
4269
4270 #[test]
4271 fn scoped_cursor_is_complete_deterministic_and_bound_to_scope_and_collection() {
4272 let db = db("scoped-pagination");
4273 let schema = scoped_schema();
4274 let operations = (0..237)
4275 .map(|i| (format!("a-{i:03}"), "tenant-a"))
4276 .chain((0..1000).map(|i| (format!("b-{i:04}"), "tenant-b")))
4277 .map(|(id, scope)| TransactionOperation {
4278 kind: TransactionOperationKind::Insert,
4279 collection: "incidents".into(),
4280 id: id.clone(),
4281 value: serde_json::json!({"title": id, "status": scope}),
4282 if_version: None,
4283 })
4284 .collect();
4285 execute_transaction(
4286 &db,
4287 &schema,
4288 &TransactionRequest {
4289 transaction_id: Some("seed-scopes".into()),
4290 tenant_id: "tenant".into(),
4291 application_id: "app".into(),
4292 revision_id: "rev".into(),
4293 schema_version: 1,
4294 state_namespace: None,
4295 causal_parent: None,
4296 authorization: auth(),
4297 preconditions: vec![],
4298 operations,
4299 fences: vec![],
4300 },
4301 None,
4302 )
4303 .unwrap();
4304
4305 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4306 let mut query = CanonicalQuery {
4307 collection: "incidents".into(),
4308 filter: Some(QueryFilter::Eq {
4309 field: "status".into(),
4310 value: Value::String("tenant-a".into()),
4311 }),
4312 order_by: vec![],
4313 limit: Some(25),
4314 offset: 0,
4315 cursor: None,
4316 projection: vec![],
4317 group_by: vec![],
4318 aggregates: vec![],
4319 references: vec![],
4320 semantic: None,
4321 };
4322 let mut ids = Vec::new();
4323 let first_cursor = loop {
4324 let page = execute_query(&db, &schema, &context, &query, None).unwrap();
4325 assert!(page.records.len() <= 25);
4326 assert!(
4327 page.records
4328 .iter()
4329 .all(|record| record["status"] == "tenant-a")
4330 );
4331 ids.extend(
4332 page.records
4333 .iter()
4334 .map(|record| record["_id"].as_str().unwrap().to_owned()),
4335 );
4336 let Some(cursor) = page.next_cursor else {
4337 break query.cursor.clone();
4338 };
4339 query.cursor = Some(cursor);
4340 };
4341 assert_eq!(ids.len(), 237);
4342 let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
4343 assert_eq!(unique.len(), 237);
4344 assert!(first_cursor.is_some());
4345
4346 let cursor = encode_query_cursor(
4347 &pagination_query_hash(&CanonicalQuery {
4348 cursor: None,
4349 ..query.clone()
4350 })
4351 .unwrap(),
4352 25,
4353 );
4354 let mut wrong_scope = query.clone();
4355 wrong_scope.cursor = Some(cursor.clone());
4356 wrong_scope.filter = Some(QueryFilter::Eq {
4357 field: "status".into(),
4358 value: Value::String("tenant-b".into()),
4359 });
4360 assert_eq!(
4361 execute_query(&db, &schema, &context, &wrong_scope, None)
4362 .unwrap_err()
4363 .code,
4364 "INVALID_CURSOR"
4365 );
4366 let mut malformed = query;
4367 malformed.cursor = Some("not-a-cursor".into());
4368 assert_eq!(
4369 execute_query(&db, &schema, &context, &malformed, None)
4370 .unwrap_err()
4371 .code,
4372 "INVALID_CURSOR"
4373 );
4374 }
4375
4376 #[test]
4383 fn semantic_clause_is_recognized_and_refused_never_ignored() {
4384 let db = db("semantic-refusal");
4385 let schema = schema();
4386 execute_transaction(
4387 &db,
4388 &schema,
4389 &TransactionRequest {
4390 transaction_id: Some("seed".into()),
4391 tenant_id: "tenant".into(),
4392 application_id: "app".into(),
4393 revision_id: "rev".into(),
4394 schema_version: 1,
4395 state_namespace: None,
4396 causal_parent: None,
4397 authorization: auth(),
4398 preconditions: vec![],
4399 operations: vec![TransactionOperation {
4400 kind: TransactionOperationKind::Insert,
4401 collection: "incidents".into(),
4402 id: "i1".into(),
4403 value: serde_json::json!({ "title": "First", "status": "open" }),
4404 if_version: None,
4405 }],
4406 fences: vec![],
4407 },
4408 None,
4409 )
4410 .unwrap();
4411 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4412 let plain = CanonicalQuery {
4413 collection: "incidents".into(),
4414 filter: Some(QueryFilter::Eq {
4415 field: "status".into(),
4416 value: Value::String("open".into()),
4417 }),
4418 order_by: vec![],
4419 limit: Some(10),
4420 offset: 0,
4421 cursor: None,
4422 projection: vec![],
4423 group_by: vec![],
4424 aggregates: vec![],
4425 references: vec![],
4426 semantic: None,
4427 };
4428 let state_before = db.state_rows().unwrap().len();
4429
4430 for clause in [
4433 serde_json::json!({
4434 "decision": { "kind": "binary", "predicate": "needs a human" },
4435 "filter": { "is": true },
4436 }),
4437 serde_json::json!({ "decision": "needs_human" }),
4438 serde_json::json!({}),
4439 ] {
4440 let failure = execute_query(
4441 &db,
4442 &schema,
4443 &context,
4444 &CanonicalQuery {
4445 semantic: Some(clause),
4446 ..plain.clone()
4447 },
4448 None,
4449 )
4450 .expect_err("semantic intent is never silently dropped");
4451 assert_eq!(failure.code, "SEMANTIC_UNSUPPORTED");
4452 }
4453
4454 for malformed in [
4457 serde_json::json!("yes"),
4458 serde_json::json!(true),
4459 serde_json::json!(1),
4460 serde_json::json!(["needs_human"]),
4461 ] {
4462 let failure = execute_query(
4463 &db,
4464 &schema,
4465 &context,
4466 &CanonicalQuery {
4467 semantic: Some(malformed),
4468 ..plain.clone()
4469 },
4470 None,
4471 )
4472 .expect_err("a malformed semantic value is invalid");
4473 assert_eq!(failure.code, "INVALID_QUERY");
4474 }
4475
4476 let failure = execute_query(
4479 &db,
4480 &schema,
4481 &context,
4482 &CanonicalQuery {
4483 semantic: Some(serde_json::json!({ "decision": "needs_human" })),
4484 limit: Some(0),
4485 ..plain.clone()
4486 },
4487 None,
4488 )
4489 .unwrap_err();
4490 assert_eq!(failure.code, "SEMANTIC_UNSUPPORTED");
4491
4492 assert_eq!(db.state_rows().unwrap().len(), state_before);
4495 let judgments: Vec<Value> = db
4496 .capability(crate::semantic_decision::DEFAULT_DECISION_COLLECTION)
4497 .find(|_: &Value| true)
4498 .unwrap();
4499 assert!(judgments.is_empty());
4500
4501 let page = execute_query(&db, &schema, &context, &plain, None).unwrap();
4503 assert_eq!(page.records.len(), 1);
4504 }
4505
4506 #[test]
4511 fn semantic_clause_survives_deserialization_and_changes_query_identity() {
4512 let plain: CanonicalQuery = serde_json::from_value(serde_json::json!({
4513 "collection": "incidents",
4514 "limit": 10,
4515 }))
4516 .unwrap();
4517 assert!(plain.semantic.is_none());
4518 let with_clause: CanonicalQuery = serde_json::from_value(serde_json::json!({
4519 "collection": "incidents",
4520 "limit": 10,
4521 "semantic": { "decision": "needs_human" },
4522 }))
4523 .unwrap();
4524 assert_eq!(
4525 with_clause.semantic,
4526 Some(serde_json::json!({ "decision": "needs_human" }))
4527 );
4528 assert_ne!(
4529 query_hash(&plain).unwrap(),
4530 query_hash(&with_clause).unwrap(),
4531 "a semantic clause is part of what the query is"
4532 );
4533 let malformed: CanonicalQuery = serde_json::from_value(serde_json::json!({
4534 "collection": "incidents",
4535 "semantic": "yes",
4536 }))
4537 .unwrap();
4538 assert_eq!(malformed.semantic, Some(Value::String("yes".into())));
4539 let null: CanonicalQuery = serde_json::from_value(serde_json::json!({
4540 "collection": "incidents",
4541 "semantic": null,
4542 }))
4543 .unwrap();
4544 assert!(null.semantic.is_none());
4545 let unrelated: CanonicalQuery = serde_json::from_value(serde_json::json!({
4546 "collection": "incidents",
4547 "limit": 10,
4548 "refill": true,
4549 }))
4550 .unwrap();
4551 assert_eq!(query_hash(&unrelated).unwrap(), query_hash(&plain).unwrap());
4552 assert!(!serde_json::to_string(&plain).unwrap().contains("semantic"));
4555 }
4556
4557 #[test]
4558 fn scoped_index_work_is_independent_of_unrelated_growth() {
4559 fn examined(unrelated: usize) -> usize {
4560 let db = db(&format!("scope-growth-{unrelated}"));
4561 let schema = scoped_schema();
4562 let operations = (0..20)
4563 .map(|i| (format!("target-{i}"), "tenant-a"))
4564 .chain((0..unrelated).map(|i| (format!("other-{i}"), "tenant-b")))
4565 .map(|(id, status)| TransactionOperation {
4566 kind: TransactionOperationKind::Insert,
4567 collection: "incidents".into(),
4568 id: id.clone(),
4569 value: serde_json::json!({"title": id, "status": status}),
4570 if_version: None,
4571 })
4572 .collect();
4573 execute_transaction(
4574 &db,
4575 &schema,
4576 &TransactionRequest {
4577 transaction_id: None,
4578 tenant_id: "tenant".into(),
4579 application_id: "app".into(),
4580 revision_id: "rev".into(),
4581 schema_version: 1,
4582 state_namespace: None,
4583 causal_parent: None,
4584 authorization: auth(),
4585 preconditions: vec![],
4586 operations,
4587 fences: vec![],
4588 },
4589 None,
4590 )
4591 .unwrap();
4592 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4593 let result = execute_query(
4594 &db,
4595 &schema,
4596 &context,
4597 &CanonicalQuery {
4598 collection: "incidents".into(),
4599 filter: Some(QueryFilter::Eq {
4600 field: "status".into(),
4601 value: Value::String("tenant-a".into()),
4602 }),
4603 order_by: vec![],
4604 limit: Some(20),
4605 offset: 0,
4606 cursor: None,
4607 projection: vec![],
4608 group_by: vec![],
4609 aggregates: vec![],
4610 references: vec![],
4611 semantic: None,
4612 },
4613 None,
4614 )
4615 .unwrap();
4616 assert_eq!(result.records.len(), 20);
4617 result.plan.actual_rows_scanned.unwrap()
4618 }
4619 assert_eq!(
4620 [examined(100), examined(1000), examined(10_000)],
4621 [20, 20, 20]
4622 );
4623 }
4624
4625 #[test]
4626 fn managed_scope_without_schema_index_fails_closed() {
4627 let db = db("scope-missing-index");
4628 let mut schema = schema();
4629 schema.collections[0].fields[0].name = "tenant_id".into();
4630 execute_transaction(
4631 &db,
4632 &schema,
4633 &TransactionRequest {
4634 transaction_id: None,
4635 tenant_id: "tenant".into(),
4636 application_id: "app".into(),
4637 revision_id: "rev".into(),
4638 schema_version: 1,
4639 state_namespace: None,
4640 causal_parent: None,
4641 authorization: auth(),
4642 preconditions: vec![],
4643 operations: vec![TransactionOperation {
4644 kind: TransactionOperationKind::Insert,
4645 collection: "incidents".into(),
4646 id: "a".into(),
4647 value: serde_json::json!({"tenant_id":"tenant-a"}),
4648 if_version: None,
4649 }],
4650 fences: vec![],
4651 },
4652 None,
4653 )
4654 .unwrap();
4655 let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
4656 let failure = execute_query(
4657 &db,
4658 &schema,
4659 &context,
4660 &CanonicalQuery {
4661 collection: "incidents".into(),
4662 filter: Some(QueryFilter::Eq {
4663 field: "tenant_id".into(),
4664 value: Value::String("tenant-a".into()),
4665 }),
4666 order_by: vec![],
4667 limit: Some(20),
4668 offset: 0,
4669 cursor: None,
4670 projection: vec![],
4671 group_by: vec![],
4672 aggregates: vec![],
4673 references: vec![],
4674 semantic: None,
4675 },
4676 None,
4677 )
4678 .unwrap_err();
4679 assert_eq!(failure.code, "INDEX_UNAVAILABLE");
4680 }
4681 #[test]
4682 #[ignore]
4683 fn gate_a_46_collection_sherpa_workload_benchmark() {
4684 use std::time::Instant;
4685 fn sherpa_schema() -> StateSchema {
4686 let mut collections = vec![];
4687 for i in 1..=46 {
4688 collections.push(CollectionSchema {
4689 name: format!("collection_{}", i),
4690 version: 1,
4691 fields: vec![
4692 FieldSchema {
4693 name: "id".into(),
4694 field_type: FieldType::Primitive {
4695 primitive: PrimitiveType::String,
4696 },
4697 nullable: false,
4698 required: true,
4699 default: None,
4700 constraints: Default::default(),
4701 computed: None,
4702 },
4703 FieldSchema {
4704 name: "name".into(),
4705 field_type: FieldType::Primitive {
4706 primitive: PrimitiveType::String,
4707 },
4708 nullable: false,
4709 required: true,
4710 default: None,
4711 constraints: Default::default(),
4712 computed: None,
4713 },
4714 FieldSchema {
4715 name: "status".into(),
4716 field_type: FieldType::Enum {
4717 values: vec!["active".into(), "inactive".into()],
4718 },
4719 nullable: false,
4720 required: false,
4721 default: Some(Value::String("active".into())),
4722 constraints: Default::default(),
4723 computed: None,
4724 },
4725 ],
4726 indexes: vec![
4727 SchemaIndex {
4728 name: "id_idx".into(),
4729 fields: vec!["id".into()],
4730 unique: false,
4731 sparse: false,
4732 version: 1,
4733 },
4734 SchemaIndex {
4735 name: "status_idx".into(),
4736 fields: vec!["status".into()],
4737 unique: false,
4738 sparse: false,
4739 version: 1,
4740 },
4741 ],
4742 });
4743 }
4744 StateSchema {
4745 contract_version: 1,
4746 schema_version: 1,
4747 application_id: "app".into(),
4748 revision_id: "rev".into(),
4749 collections,
4750 }
4751 }
4752 let db = db("gate-a-sherpa-bench");
4753 let schema = sherpa_schema();
4754 let auth_ctx = auth();
4755 println!("Gate A Benchmark: Loading 46 collections...");
4756 let load_start = Instant::now();
4757 for col_idx in 1..=46 {
4758 let collection_name = format!("collection_{}", col_idx);
4759 let records_per_collection = 500;
4760 let mut operations = vec![];
4761 for record_idx in 1..=records_per_collection {
4762 operations.push(TransactionOperation {
4763 kind: TransactionOperationKind::Insert,
4764 collection: collection_name.clone(),
4765 id: format!("rec-{}", record_idx),
4766 value: serde_json::json!({
4767 "id": format!("id-{}", record_idx),
4768 "name": format!("Record {}", record_idx),
4769 "status": if record_idx % 2 == 0 { "active" } else { "inactive" }
4770 }),
4771 if_version: None,
4772 });
4773 }
4774 let request = TransactionRequest {
4775 transaction_id: None,
4776 tenant_id: "tenant".into(),
4777 application_id: "app".into(),
4778 revision_id: "rev".into(),
4779 schema_version: 1,
4780 state_namespace: None,
4781 causal_parent: None,
4782 authorization: auth_ctx.clone(),
4783 preconditions: vec![],
4784 operations,
4785 fences: vec![],
4786 };
4787 execute_transaction(&db, &schema, &request, None).unwrap();
4788 }
4789 let load_ms = load_start.elapsed().as_secs_f64() * 1000.0;
4790 println!(" Loaded ~23,000 records in {:.2}ms", load_ms);
4791 println!("Gate A Benchmark: Executing 46 indexed queries...");
4792 let query_start = Instant::now();
4793 let context = begin_read(&db, &schema, "app", auth_ctx).unwrap();
4794 let mut query_times = vec![];
4795 let mut index_used_count = 0;
4796 for col_idx in 1..=46 {
4797 let collection_name = format!("collection_{}", col_idx);
4798 let q_start = Instant::now();
4799 let result = execute_query(
4800 &db,
4801 &schema,
4802 &context,
4803 &CanonicalQuery {
4804 collection: collection_name,
4805 filter: Some(QueryFilter::Eq {
4806 field: "status".into(),
4807 value: Value::String("active".into()),
4808 }),
4809 order_by: vec![],
4810 limit: Some(1000),
4811 offset: 0,
4812 cursor: None,
4813 projection: vec![],
4814 group_by: vec![],
4815 aggregates: vec![],
4816 references: vec![],
4817 semantic: None,
4818 },
4819 None,
4820 )
4821 .unwrap();
4822 let q_ms = q_start.elapsed().as_secs_f64() * 1000.0;
4823 query_times.push(q_ms);
4824 assert_eq!(
4825 result.plan.access_method,
4826 Some("index".to_string()),
4827 "Query {} should use index",
4828 col_idx
4829 );
4830 if result
4831 .plan
4832 .index_metrics
4833 .as_ref()
4834 .unwrap()
4835 .index_used
4836 .is_some()
4837 {
4838 index_used_count += 1;
4839 }
4840 }
4841 let total_query_time_ms = query_start.elapsed().as_secs_f64() * 1000.0;
4842 query_times.sort_by(|a, b| a.partial_cmp(b).unwrap());
4843 let median_query_time = query_times[query_times.len() / 2];
4844 println!("\n=== Gate A Verification ===");
4845 println!("Indexes used: {}/46", index_used_count);
4846 println!(
4847 "Total query time: {:.2}ms (target: <10,000ms)",
4848 total_query_time_ms
4849 );
4850 println!(
4851 "Per-query median: {:.2}ms (target: <100ms)",
4852 median_query_time
4853 );
4854 assert_eq!(index_used_count, 46, "All queries must use indexes");
4855 assert!(
4856 total_query_time_ms < 10000.0,
4857 "Total time must be <10s (was {:.2}ms)",
4858 total_query_time_ms
4859 );
4860 }
4861
4862 #[test]
4863 fn authenticated_policy_allows_query_with_capability() {
4864 let db = db("auth_read_allow");
4865 let schema = schema();
4866
4867 let mut auth_context = auth();
4869 auth_context.subject = "user:alice".into();
4870
4871 let context = begin_read(&db, &schema, "app", auth_context).unwrap();
4872 let query = CanonicalQuery {
4873 collection: "incidents".into(),
4874 filter: None,
4875 order_by: vec![],
4876 limit: Some(100),
4877 offset: 0,
4878 cursor: None,
4879 projection: vec![],
4880 group_by: vec![],
4881 aggregates: vec![],
4882 references: vec![],
4883 semantic: None,
4884 };
4885
4886 let result = execute_query(&db, &schema, &context, &query, None);
4887 assert!(
4888 result.is_ok(),
4889 "Query should succeed with state:read capability"
4890 );
4891 }
4892
4893 #[test]
4894 fn authenticated_policy_denies_query_without_capability() {
4895 let db = db("auth_read_deny");
4896 let schema = schema();
4897
4898 let auth_context = AuthorizationContext {
4900 subject: "user:bob".into(),
4901 tenant_id: "tenant".into(),
4902 application_id: "app".into(),
4903 revision_id: "rev".into(),
4904 capabilities: BTreeSet::new(), readable_collections: Default::default(),
4906 writable_collections: Default::default(),
4907 field_projections: Default::default(),
4908 };
4909
4910 let context = begin_read(&db, &schema, "app", auth_context).unwrap();
4911 let query = CanonicalQuery {
4912 collection: "incidents".into(),
4913 filter: None,
4914 order_by: vec![],
4915 limit: Some(100),
4916 offset: 0,
4917 cursor: None,
4918 projection: vec![],
4919 group_by: vec![],
4920 aggregates: vec![],
4921 references: vec![],
4922 semantic: None,
4923 };
4924
4925 let result = execute_query(&db, &schema, &context, &query, None);
4926 assert!(
4927 result.is_err(),
4928 "Query should fail without state:read capability"
4929 );
4930 if let Err(e) = result {
4931 assert_eq!(e.code, "AUTHORIZATION_DENIED");
4932 }
4933 }
4934
4935 #[test]
4936 fn write_policy_requires_state_write_capability() {
4937 let db = db("auth_write_deny");
4938 let schema = schema();
4939
4940 let auth_context = AuthorizationContext {
4942 subject: "user:charlie".into(),
4943 tenant_id: "tenant".into(),
4944 application_id: "app".into(),
4945 revision_id: "rev".into(),
4946 capabilities: BTreeSet::new(), readable_collections: Default::default(),
4948 writable_collections: Default::default(),
4949 field_projections: Default::default(),
4950 };
4951
4952 let request = TransactionRequest {
4953 transaction_id: None,
4954 tenant_id: "tenant".into(),
4955 application_id: "app".into(),
4956 revision_id: "rev".into(),
4957 schema_version: 1,
4958 state_namespace: None,
4959 causal_parent: None,
4960 authorization: auth_context,
4961 operations: vec![TransactionOperation {
4962 kind: TransactionOperationKind::Insert,
4963 collection: "incidents".into(),
4964 id: "proj-1".into(),
4965 value: serde_json::json!({"title": "Test Project"}),
4966 if_version: None,
4967 }],
4968 preconditions: vec![],
4969 fences: vec![],
4970 };
4971
4972 let result = execute_transaction(&db, &schema, &request, None);
4973 assert!(
4974 result.is_err(),
4975 "Transaction should fail without state:write capability"
4976 );
4977 if let Err(e) = result {
4978 assert_eq!(e.code, "AUTHORIZATION_DENIED");
4979 }
4980 }
4981
4982 #[test]
4985 fn policy_enforcement_unauthenticated_read_denied() {
4986 let db = db("policy_unauth_read");
4988 let schema = schema();
4989
4990 let insert_request = TransactionRequest {
4992 transaction_id: None,
4993 tenant_id: "tenant".into(),
4994 application_id: "app".into(),
4995 revision_id: "rev".into(),
4996 schema_version: 1,
4997 state_namespace: None,
4998 causal_parent: None,
4999 authorization: auth(),
5000 operations: vec![TransactionOperation {
5001 kind: TransactionOperationKind::Insert,
5002 collection: "incidents".into(),
5003 id: "inc-1".into(),
5004 value: serde_json::json!({"title": "Test Incident"}),
5005 if_version: None,
5006 }],
5007 preconditions: vec![],
5008 fences: vec![],
5009 };
5010 execute_transaction(&db, &schema, &insert_request, None).unwrap();
5011
5012 let unauth_context = AuthorizationContext {
5014 subject: "anonymous".into(),
5015 tenant_id: "tenant".into(),
5016 application_id: "app".into(),
5017 revision_id: "rev".into(),
5018 capabilities: BTreeSet::new(), readable_collections: Default::default(),
5020 writable_collections: Default::default(),
5021 field_projections: Default::default(),
5022 };
5023
5024 let context = begin_read(&db, &schema, "app", unauth_context).unwrap();
5025 let query = CanonicalQuery {
5026 collection: "incidents".into(),
5027 filter: None,
5028 order_by: vec![],
5029 limit: Some(100),
5030 offset: 0,
5031 cursor: None,
5032 projection: vec![],
5033 group_by: vec![],
5034 aggregates: vec![],
5035 references: vec![],
5036 semantic: None,
5037 };
5038
5039 let result = execute_query(&db, &schema, &context, &query, None);
5040 assert!(result.is_err(), "Unauthenticated read must be denied");
5041 assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
5042 }
5043
5044 #[test]
5045 fn policy_enforcement_unauthenticated_write_denied() {
5046 let db = db("policy_unauth_write");
5048 let schema = schema();
5049
5050 let unauth_context = AuthorizationContext {
5051 subject: "anonymous".into(),
5052 tenant_id: "tenant".into(),
5053 application_id: "app".into(),
5054 revision_id: "rev".into(),
5055 capabilities: BTreeSet::new(), readable_collections: Default::default(),
5057 writable_collections: Default::default(),
5058 field_projections: Default::default(),
5059 };
5060
5061 let request = TransactionRequest {
5062 transaction_id: None,
5063 tenant_id: "tenant".into(),
5064 application_id: "app".into(),
5065 revision_id: "rev".into(),
5066 schema_version: 1,
5067 state_namespace: None,
5068 causal_parent: None,
5069 authorization: unauth_context,
5070 operations: vec![TransactionOperation {
5071 kind: TransactionOperationKind::Insert,
5072 collection: "incidents".into(),
5073 id: "inc-1".into(),
5074 value: serde_json::json!({"title": "Incident"}),
5075 if_version: None,
5076 }],
5077 preconditions: vec![],
5078 fences: vec![],
5079 };
5080
5081 let result = execute_transaction(&db, &schema, &request, None);
5082 assert!(result.is_err(), "Unauthenticated write must be denied");
5083 assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
5084 }
5085
5086 #[test]
5087 fn policy_enforcement_authenticated_read_allowed() {
5088 let db = db("policy_auth_read_ok");
5090 let schema = schema();
5091
5092 let insert_request = TransactionRequest {
5094 transaction_id: None,
5095 tenant_id: "tenant".into(),
5096 application_id: "app".into(),
5097 revision_id: "rev".into(),
5098 schema_version: 1,
5099 state_namespace: None,
5100 causal_parent: None,
5101 authorization: auth(),
5102 operations: vec![TransactionOperation {
5103 kind: TransactionOperationKind::Insert,
5104 collection: "incidents".into(),
5105 id: "inc-1".into(),
5106 value: serde_json::json!({"title": "Test Incident"}),
5107 if_version: None,
5108 }],
5109 preconditions: vec![],
5110 fences: vec![],
5111 };
5112 execute_transaction(&db, &schema, &insert_request, None).unwrap();
5113
5114 let auth_context = AuthorizationContext {
5116 subject: "user:alice".into(),
5117 tenant_id: "tenant".into(),
5118 application_id: "app".into(),
5119 revision_id: "rev".into(),
5120 capabilities: ["state:read".into()].into(),
5121 readable_collections: Default::default(),
5122 writable_collections: Default::default(),
5123 field_projections: Default::default(),
5124 };
5125
5126 let context = begin_read(&db, &schema, "app", auth_context).unwrap();
5127 let query = CanonicalQuery {
5128 collection: "incidents".into(),
5129 filter: None,
5130 order_by: vec![],
5131 limit: Some(100),
5132 offset: 0,
5133 cursor: None,
5134 projection: vec![],
5135 group_by: vec![],
5136 aggregates: vec![],
5137 references: vec![],
5138 semantic: None,
5139 };
5140
5141 let result = execute_query(&db, &schema, &context, &query, None);
5142 assert!(result.is_ok(), "Authenticated read must be allowed");
5143 let query_result = result.unwrap();
5144 assert!(
5145 query_result.records.len() >= 1,
5146 "Query should return the inserted record"
5147 );
5148 }
5149
5150 #[test]
5151 fn policy_enforcement_authenticated_write_allowed() {
5152 let db = db("policy_auth_write_ok");
5154 let schema = schema();
5155
5156 let auth_context = AuthorizationContext {
5157 subject: "user:bob".into(),
5158 tenant_id: "tenant".into(),
5159 application_id: "app".into(),
5160 revision_id: "rev".into(),
5161 capabilities: ["state:write".into()].into(),
5162 readable_collections: Default::default(),
5163 writable_collections: Default::default(),
5164 field_projections: Default::default(),
5165 };
5166
5167 let request = TransactionRequest {
5168 transaction_id: None,
5169 tenant_id: "tenant".into(),
5170 application_id: "app".into(),
5171 revision_id: "rev".into(),
5172 schema_version: 1,
5173 state_namespace: None,
5174 causal_parent: None,
5175 authorization: auth_context,
5176 operations: vec![TransactionOperation {
5177 kind: TransactionOperationKind::Insert,
5178 collection: "incidents".into(),
5179 id: "inc-1".into(),
5180 value: serde_json::json!({"title": "New Incident"}),
5181 if_version: None,
5182 }],
5183 preconditions: vec![],
5184 fences: vec![],
5185 };
5186
5187 let result = execute_transaction(&db, &schema, &request, None);
5188 assert!(result.is_ok(), "Authenticated write must be allowed");
5189 assert!(
5190 result.unwrap().state_after > 0,
5191 "Transaction should have succeeded"
5192 );
5193 }
5194
5195 #[test]
5196 fn policy_enforcement_transaction_atomicity_with_auth() {
5197 let db = db("policy_tx_atomic");
5199 let schema = schema();
5200
5201 let insert_initial = TransactionRequest {
5203 transaction_id: None,
5204 tenant_id: "tenant".into(),
5205 application_id: "app".into(),
5206 revision_id: "rev".into(),
5207 schema_version: 1,
5208 state_namespace: None,
5209 causal_parent: None,
5210 authorization: auth(),
5211 operations: vec![TransactionOperation {
5212 kind: TransactionOperationKind::Insert,
5213 collection: "incidents".into(),
5214 id: "initial".into(),
5215 value: serde_json::json!({"title": "Initial"}),
5216 if_version: None,
5217 }],
5218 preconditions: vec![],
5219 fences: vec![],
5220 };
5221 let initial_result = execute_transaction(&db, &schema, &insert_initial, None).unwrap();
5222 let state_before_denied_tx = initial_result.state_after;
5223
5224 let unauth_context = AuthorizationContext {
5226 subject: "anonymous".into(),
5227 tenant_id: "tenant".into(),
5228 application_id: "app".into(),
5229 revision_id: "rev".into(),
5230 capabilities: BTreeSet::new(), readable_collections: Default::default(),
5232 writable_collections: Default::default(),
5233 field_projections: Default::default(),
5234 };
5235
5236 let denied_request = TransactionRequest {
5237 transaction_id: None,
5238 tenant_id: "tenant".into(),
5239 application_id: "app".into(),
5240 revision_id: "rev".into(),
5241 schema_version: 1,
5242 state_namespace: None,
5243 causal_parent: None,
5244 authorization: unauth_context,
5245 operations: vec![TransactionOperation {
5246 kind: TransactionOperationKind::Insert,
5247 collection: "incidents".into(),
5248 id: "denied".into(),
5249 value: serde_json::json!({"title": "Should Not Commit"}),
5250 if_version: None,
5251 }],
5252 preconditions: vec![],
5253 fences: vec![],
5254 };
5255
5256 let denied_result = execute_transaction(&db, &schema, &denied_request, None);
5257 assert!(denied_result.is_err(), "Transaction should be denied");
5258
5259 let verify_auth = auth();
5261 let verify_context = begin_read(&db, &schema, "app", verify_auth).unwrap();
5262 let verify_query = CanonicalQuery {
5263 collection: "incidents".into(),
5264 filter: None,
5265 order_by: vec![],
5266 limit: Some(100),
5267 offset: 0,
5268 cursor: None,
5269 projection: vec![],
5270 group_by: vec![],
5271 aggregates: vec![],
5272 references: vec![],
5273 semantic: None,
5274 };
5275
5276 let verify_result =
5277 execute_query(&db, &schema, &verify_context, &verify_query, None).unwrap();
5278 assert_eq!(
5279 verify_result.records.len(),
5280 1,
5281 "Only initial record should exist; denied transaction must not partially commit"
5282 );
5283 }
5284
5285 #[test]
5286 fn policy_enforcement_capability_must_match_operation() {
5287 let db = db("policy_cap_mismatch");
5289 let schema = schema();
5290
5291 let read_only_context = AuthorizationContext {
5292 subject: "user:charlie".into(),
5293 tenant_id: "tenant".into(),
5294 application_id: "app".into(),
5295 revision_id: "rev".into(),
5296 capabilities: ["state:read".into()].into(), readable_collections: Default::default(),
5298 writable_collections: Default::default(),
5299 field_projections: Default::default(),
5300 };
5301
5302 let write_request = TransactionRequest {
5303 transaction_id: None,
5304 tenant_id: "tenant".into(),
5305 application_id: "app".into(),
5306 revision_id: "rev".into(),
5307 schema_version: 1,
5308 state_namespace: None,
5309 causal_parent: None,
5310 authorization: read_only_context,
5311 operations: vec![TransactionOperation {
5312 kind: TransactionOperationKind::Insert,
5313 collection: "incidents".into(),
5314 id: "inc-1".into(),
5315 value: serde_json::json!({"title": "Test"}),
5316 if_version: None,
5317 }],
5318 preconditions: vec![],
5319 fences: vec![],
5320 };
5321
5322 let result = execute_transaction(&db, &schema, &write_request, None);
5323 assert!(
5324 result.is_err(),
5325 "Write must be denied even with read capability"
5326 );
5327 assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
5328 }
5329
5330 #[test]
5331 fn member_policy_read_allowed_when_member() {
5332 let db = db("member_policy_read_allow");
5334 let schema = schema();
5335
5336 let insert_context = AuthorizationContext {
5338 subject: "user:admin".into(),
5339 tenant_id: "tenant".into(),
5340 application_id: "app".into(),
5341 revision_id: "rev".into(),
5342 capabilities: ["state:write".into()].into(),
5343 readable_collections: Default::default(),
5344 writable_collections: Default::default(),
5345 field_projections: Default::default(),
5346 };
5347
5348 let insert_request = TransactionRequest {
5349 transaction_id: None,
5350 tenant_id: "tenant".into(),
5351 application_id: "app".into(),
5352 revision_id: "rev".into(),
5353 schema_version: 1,
5354 state_namespace: None,
5355 causal_parent: None,
5356 authorization: insert_context,
5357 operations: vec![TransactionOperation {
5358 kind: TransactionOperationKind::Insert,
5359 collection: "incidents".into(),
5360 id: "proj-1".into(),
5361 value: serde_json::json!({"title": "Test Project"}),
5362 if_version: None,
5363 }],
5364 preconditions: vec![],
5365 fences: vec![],
5366 };
5367
5368 let _ = execute_transaction(&db, &schema, &insert_request, None);
5369
5370 let member_context = AuthorizationContext {
5372 subject: "user:alice".into(),
5373 tenant_id: "tenant".into(),
5374 application_id: "app".into(),
5375 revision_id: "rev".into(),
5376 capabilities: ["state:read".into()].into(),
5377 readable_collections: Default::default(),
5378 writable_collections: Default::default(),
5379 field_projections: Default::default(),
5380 };
5381
5382 let read_context = begin_read(&db, &schema, "app", member_context).unwrap();
5383 let query = CanonicalQuery {
5384 collection: "incidents".into(),
5385 filter: None,
5386 order_by: vec![],
5387 limit: Some(100),
5388 offset: 0,
5389 cursor: None,
5390 projection: vec![],
5391 group_by: vec![],
5392 aggregates: vec![],
5393 references: vec![],
5394 semantic: None,
5395 };
5396
5397 let result = execute_query(&db, &schema, &read_context, &query, None);
5398 assert!(
5399 result.is_ok(),
5400 "Member policy read should be allowed with state:read"
5401 );
5402 }
5403
5404 #[test]
5405 fn member_policy_read_denied_without_capability() {
5406 let db = db("member_policy_read_deny");
5408 let schema = schema();
5409
5410 let empty_context = AuthorizationContext {
5411 subject: "user:bob".into(),
5412 tenant_id: "tenant".into(),
5413 application_id: "app".into(),
5414 revision_id: "rev".into(),
5415 capabilities: BTreeSet::new(), readable_collections: Default::default(),
5417 writable_collections: Default::default(),
5418 field_projections: Default::default(),
5419 };
5420
5421 let read_context = begin_read(&db, &schema, "app", empty_context).unwrap();
5422 let query = CanonicalQuery {
5423 collection: "incidents".into(),
5424 filter: None,
5425 order_by: vec![],
5426 limit: Some(100),
5427 offset: 0,
5428 cursor: None,
5429 projection: vec![],
5430 group_by: vec![],
5431 aggregates: vec![],
5432 references: vec![],
5433 semantic: None,
5434 };
5435
5436 let result = execute_query(&db, &schema, &read_context, &query, None);
5437 assert!(
5438 result.is_err(),
5439 "Member policy read must be denied without state:read"
5440 );
5441 assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
5442 }
5443
5444 #[test]
5445 fn member_policy_write_allowed_when_member() {
5446 let db = db("member_policy_write_allow");
5448 let schema = schema();
5449
5450 let member_context = AuthorizationContext {
5451 subject: "user:alice".into(),
5452 tenant_id: "tenant".into(),
5453 application_id: "app".into(),
5454 revision_id: "rev".into(),
5455 capabilities: ["state:write".into()].into(),
5456 readable_collections: Default::default(),
5457 writable_collections: Default::default(),
5458 field_projections: Default::default(),
5459 };
5460
5461 let write_request = TransactionRequest {
5462 transaction_id: None,
5463 tenant_id: "tenant".into(),
5464 application_id: "app".into(),
5465 revision_id: "rev".into(),
5466 schema_version: 1,
5467 state_namespace: None,
5468 causal_parent: None,
5469 authorization: member_context,
5470 operations: vec![TransactionOperation {
5471 kind: TransactionOperationKind::Insert,
5472 collection: "incidents".into(),
5473 id: "inc-member-1".into(),
5474 value: serde_json::json!({"title": "Member Project"}),
5475 if_version: None,
5476 }],
5477 preconditions: vec![],
5478 fences: vec![],
5479 };
5480
5481 let result = execute_transaction(&db, &schema, &write_request, None);
5482 assert!(
5483 result.is_ok(),
5484 "Member policy write should be allowed with state:write"
5485 );
5486 }
5487
5488 #[test]
5489 fn member_policy_write_denied_without_capability() {
5490 let db = db("member_policy_write_deny");
5492 let schema = schema();
5493
5494 let empty_context = AuthorizationContext {
5495 subject: "user:bob".into(),
5496 tenant_id: "tenant".into(),
5497 application_id: "app".into(),
5498 revision_id: "rev".into(),
5499 capabilities: BTreeSet::new(), readable_collections: Default::default(),
5501 writable_collections: Default::default(),
5502 field_projections: Default::default(),
5503 };
5504
5505 let write_request = TransactionRequest {
5506 transaction_id: None,
5507 tenant_id: "tenant".into(),
5508 application_id: "app".into(),
5509 revision_id: "rev".into(),
5510 schema_version: 1,
5511 state_namespace: None,
5512 causal_parent: None,
5513 authorization: empty_context,
5514 operations: vec![TransactionOperation {
5515 kind: TransactionOperationKind::Insert,
5516 collection: "incidents".into(),
5517 id: "inc-member-2".into(),
5518 value: serde_json::json!({"title": "Denied Project"}),
5519 if_version: None,
5520 }],
5521 preconditions: vec![],
5522 fences: vec![],
5523 };
5524
5525 let result = execute_transaction(&db, &schema, &write_request, None);
5526 assert!(
5527 result.is_err(),
5528 "Member policy write must be denied without state:write"
5529 );
5530 assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
5531 }
5532
5533 #[test]
5534 fn member_policy_cross_org_mutation_prevented() {
5535 let db = db("member_policy_cross_org");
5539 let schema = schema();
5540
5541 let admin_context = AuthorizationContext {
5543 subject: "user:admin".into(),
5544 tenant_id: "tenant".into(),
5545 application_id: "app".into(),
5546 revision_id: "rev".into(),
5547 capabilities: ["state:write".into()].into(),
5548 readable_collections: Default::default(),
5549 writable_collections: Default::default(),
5550 field_projections: Default::default(),
5551 };
5552
5553 let insert_in_org2 = TransactionRequest {
5554 transaction_id: None,
5555 tenant_id: "tenant".into(),
5556 application_id: "app".into(),
5557 revision_id: "rev".into(),
5558 schema_version: 1,
5559 state_namespace: None,
5560 causal_parent: None,
5561 authorization: admin_context,
5562 operations: vec![TransactionOperation {
5563 kind: TransactionOperationKind::Insert,
5564 collection: "incidents".into(),
5565 id: "inc-org2-1".into(),
5566 value: serde_json::json!({"title": "Org2 Resource"}),
5567 if_version: None,
5568 }],
5569 preconditions: vec![],
5570 fences: vec![],
5571 };
5572
5573 let _ = execute_transaction(&db, &schema, &insert_in_org2, None);
5574
5575 let alice_context = AuthorizationContext {
5579 subject: "user:alice".into(),
5580 tenant_id: "tenant".into(),
5581 application_id: "app".into(),
5582 revision_id: "rev".into(),
5583 capabilities: BTreeSet::new(), readable_collections: Default::default(),
5585 writable_collections: Default::default(),
5586 field_projections: Default::default(),
5587 };
5588
5589 let cross_org_mutation = TransactionRequest {
5590 transaction_id: None,
5591 tenant_id: "tenant".into(),
5592 application_id: "app".into(),
5593 revision_id: "rev".into(),
5594 schema_version: 1,
5595 state_namespace: None,
5596 causal_parent: None,
5597 authorization: alice_context,
5598 operations: vec![TransactionOperation {
5599 kind: TransactionOperationKind::Update,
5600 collection: "incidents".into(),
5601 id: "inc-org2-1".into(),
5602 value: serde_json::json!({"title": "Hacked by alice"}),
5603 if_version: Some(0),
5604 }],
5605 preconditions: vec![],
5606 fences: vec![],
5607 };
5608
5609 let result = execute_transaction(&db, &schema, &cross_org_mutation, None);
5610 assert!(
5611 result.is_err(),
5612 "Cross-org mutation must be denied (member of org-1 only)"
5613 );
5614 assert_eq!(result.unwrap_err().code, "AUTHORIZATION_DENIED");
5615 }
5616
5617 #[test]
5618 fn record_authorization_owner_policy_filters_owned_records() {
5619 let db = db("record_auth_owner_filter");
5620 let schema = schema_with_owner();
5621
5622 let alice_context = AuthorizationContext {
5623 subject: "user:alice".into(),
5624 tenant_id: "tenant".into(),
5625 application_id: "app".into(),
5626 revision_id: "rev".into(),
5627 capabilities: ["state:read".into()].into(),
5628 readable_collections: Default::default(),
5629 writable_collections: Default::default(),
5630 field_projections: Default::default(),
5631 };
5632
5633 let bob_context = AuthorizationContext {
5634 subject: "user:bob".into(),
5635 tenant_id: "tenant".into(),
5636 application_id: "app".into(),
5637 revision_id: "rev".into(),
5638 capabilities: ["state:read".into()].into(),
5639 readable_collections: Default::default(),
5640 writable_collections: Default::default(),
5641 field_projections: Default::default(),
5642 };
5643
5644 let admin_context = AuthorizationContext {
5645 subject: "user:admin".into(),
5646 tenant_id: "tenant".into(),
5647 application_id: "app".into(),
5648 revision_id: "rev".into(),
5649 capabilities: ["state:write".into(), "state:read".into()].into(),
5650 readable_collections: Default::default(),
5651 writable_collections: Default::default(),
5652 field_projections: Default::default(),
5653 };
5654
5655 let insert_records = TransactionRequest {
5656 transaction_id: None,
5657 tenant_id: "tenant".into(),
5658 application_id: "app".into(),
5659 revision_id: "rev".into(),
5660 schema_version: 1,
5661 state_namespace: None,
5662 causal_parent: None,
5663 authorization: admin_context.clone(),
5664 operations: vec![
5665 TransactionOperation {
5666 kind: TransactionOperationKind::Insert,
5667 collection: "incidents".into(),
5668 id: "inc-alice-1".into(),
5669 value: serde_json::json!({"title": "Alice 1", "owner_id": "alice"}),
5670 if_version: None,
5671 },
5672 TransactionOperation {
5673 kind: TransactionOperationKind::Insert,
5674 collection: "incidents".into(),
5675 id: "inc-bob-1".into(),
5676 value: serde_json::json!({"title": "Bob 1", "owner_id": "bob"}),
5677 if_version: None,
5678 },
5679 TransactionOperation {
5680 kind: TransactionOperationKind::Insert,
5681 collection: "incidents".into(),
5682 id: "inc-alice-2".into(),
5683 value: serde_json::json!({"title": "Alice 2", "owner_id": "alice"}),
5684 if_version: None,
5685 },
5686 ],
5687 preconditions: vec![],
5688 fences: vec![],
5689 };
5690
5691 execute_transaction(&db, &schema, &insert_records, None).expect("insert should succeed");
5692
5693 let alice_read = begin_read(&db, &schema, "app", alice_context).unwrap();
5694 let query = CanonicalQuery {
5695 collection: "incidents".into(),
5696 filter: None,
5697 order_by: vec![],
5698 limit: None,
5699 offset: 0,
5700 cursor: None,
5701 projection: vec!["title".into(), "owner_id".into()],
5702 group_by: vec![],
5703 aggregates: vec![],
5704 references: vec![],
5705 semantic: None,
5706 };
5707
5708 let alice_result = execute_query(
5709 &db,
5710 &schema,
5711 &alice_read,
5712 &query,
5713 Some(PolicySubject::Owner),
5714 )
5715 .expect("query should succeed");
5716
5717 assert_eq!(
5718 alice_result.records.len(),
5719 2,
5720 "Alice should see only 2 incidents she owns"
5721 );
5722 for record in &alice_result.records {
5723 assert_eq!(record.get("owner_id").unwrap(), "alice");
5724 }
5725
5726 let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
5727 let bob_result = execute_query(&db, &schema, &bob_read, &query, Some(PolicySubject::Owner))
5728 .expect("query should succeed");
5729
5730 assert_eq!(
5731 bob_result.records.len(),
5732 1,
5733 "Bob should see only 1 incident he owns"
5734 );
5735 assert_eq!(bob_result.records[0].get("owner_id").unwrap(), "bob");
5736 }
5737
5738 #[test]
5739 fn record_authorization_owner_policy_denies_direct_access() {
5740 let db = db("record_auth_owner_direct");
5741 let schema = schema_with_owner();
5742
5743 let admin_context = AuthorizationContext {
5744 subject: "user:admin".into(),
5745 tenant_id: "tenant".into(),
5746 application_id: "app".into(),
5747 revision_id: "rev".into(),
5748 capabilities: ["state:write".into(), "state:read".into()].into(),
5749 readable_collections: Default::default(),
5750 writable_collections: Default::default(),
5751 field_projections: Default::default(),
5752 };
5753
5754 let bob_context = AuthorizationContext {
5755 subject: "user:bob".into(),
5756 tenant_id: "tenant".into(),
5757 application_id: "app".into(),
5758 revision_id: "rev".into(),
5759 capabilities: ["state:read".into()].into(),
5760 readable_collections: Default::default(),
5761 writable_collections: Default::default(),
5762 field_projections: Default::default(),
5763 };
5764
5765 let insert_request = TransactionRequest {
5766 transaction_id: None,
5767 tenant_id: "tenant".into(),
5768 application_id: "app".into(),
5769 revision_id: "rev".into(),
5770 schema_version: 1,
5771 state_namespace: None,
5772 causal_parent: None,
5773 authorization: admin_context,
5774 operations: vec![TransactionOperation {
5775 kind: TransactionOperationKind::Insert,
5776 collection: "incidents".into(),
5777 id: "inc-alice-secret".into(),
5778 value: serde_json::json!({"title": "Secret", "owner_id": "alice"}),
5779 if_version: None,
5780 }],
5781 preconditions: vec![],
5782 fences: vec![],
5783 };
5784
5785 execute_transaction(&db, &schema, &insert_request, None).expect("insert should succeed");
5786
5787 let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
5788 let direct_query = CanonicalQuery {
5789 collection: "incidents".into(),
5790 filter: Some(QueryFilter::Eq {
5791 field: "_id".into(),
5792 value: Value::String("inc-alice-secret".into()),
5793 }),
5794 order_by: vec![],
5795 limit: None,
5796 offset: 0,
5797 cursor: None,
5798 projection: vec!["title".into(), "owner_id".into()],
5799 group_by: vec![],
5800 aggregates: vec![],
5801 references: vec![],
5802 semantic: None,
5803 };
5804
5805 let result = execute_query(
5806 &db,
5807 &schema,
5808 &bob_read,
5809 &direct_query,
5810 Some(PolicySubject::Owner),
5811 )
5812 .expect("query should succeed");
5813
5814 assert_eq!(
5815 result.records.len(),
5816 0,
5817 "Bob should not see Alice's record even with direct _id filter"
5818 );
5819 }
5820
5821 #[test]
5822 fn record_authorization_authenticated_policy_allows_all() {
5823 let db = db("record_auth_authenticated");
5824 let schema = schema();
5825
5826 let alice_context = AuthorizationContext {
5827 subject: "user:alice".into(),
5828 tenant_id: "tenant".into(),
5829 application_id: "app".into(),
5830 revision_id: "rev".into(),
5831 capabilities: ["state:write".into(), "state:read".into()].into(),
5832 readable_collections: Default::default(),
5833 writable_collections: Default::default(),
5834 field_projections: Default::default(),
5835 };
5836
5837 let bob_context = AuthorizationContext {
5838 subject: "user:bob".into(),
5839 tenant_id: "tenant".into(),
5840 application_id: "app".into(),
5841 revision_id: "rev".into(),
5842 capabilities: ["state:read".into()].into(),
5843 readable_collections: Default::default(),
5844 writable_collections: Default::default(),
5845 field_projections: Default::default(),
5846 };
5847
5848 let insert_records = TransactionRequest {
5849 transaction_id: None,
5850 tenant_id: "tenant".into(),
5851 application_id: "app".into(),
5852 revision_id: "rev".into(),
5853 schema_version: 1,
5854 state_namespace: None,
5855 causal_parent: None,
5856 authorization: alice_context,
5857 operations: vec![
5858 TransactionOperation {
5859 kind: TransactionOperationKind::Insert,
5860 collection: "incidents".into(),
5861 id: "inc-alice-auth".into(),
5862 value: serde_json::json!({"title": "Alice's incident"}),
5863 if_version: None,
5864 },
5865 TransactionOperation {
5866 kind: TransactionOperationKind::Insert,
5867 collection: "incidents".into(),
5868 id: "inc-bob-auth".into(),
5869 value: serde_json::json!({"title": "Bob's incident"}),
5870 if_version: None,
5871 },
5872 ],
5873 preconditions: vec![],
5874 fences: vec![],
5875 };
5876
5877 execute_transaction(&db, &schema, &insert_records, None).expect("insert should succeed");
5878
5879 let bob_read = begin_read(&db, &schema, "app", bob_context).unwrap();
5880 let query = CanonicalQuery {
5881 collection: "incidents".into(),
5882 filter: None,
5883 order_by: vec![],
5884 limit: None,
5885 offset: 0,
5886 cursor: None,
5887 projection: vec!["title".into()],
5888 group_by: vec![],
5889 aggregates: vec![],
5890 references: vec![],
5891 semantic: None,
5892 };
5893
5894 let result = execute_query(
5895 &db,
5896 &schema,
5897 &bob_read,
5898 &query,
5899 Some(PolicySubject::Authenticated),
5900 )
5901 .expect("query should succeed");
5902
5903 assert_eq!(
5904 result.records.len(),
5905 2,
5906 "Authenticated policy should allow access to all records for authenticated actor"
5907 );
5908 }
5909
5910 #[test]
5911 fn record_authorization_pagination_respects_authorization() {
5912 let db = db("record_auth_pagination");
5913 let schema = schema_with_owner();
5914
5915 let admin_context = AuthorizationContext {
5916 subject: "user:admin".into(),
5917 tenant_id: "tenant".into(),
5918 application_id: "app".into(),
5919 revision_id: "rev".into(),
5920 capabilities: ["state:write".into(), "state:read".into()].into(),
5921 readable_collections: Default::default(),
5922 writable_collections: Default::default(),
5923 field_projections: Default::default(),
5924 };
5925
5926 let alice_context = AuthorizationContext {
5927 subject: "user:alice".into(),
5928 tenant_id: "tenant".into(),
5929 application_id: "app".into(),
5930 revision_id: "rev".into(),
5931 capabilities: ["state:read".into()].into(),
5932 readable_collections: Default::default(),
5933 writable_collections: Default::default(),
5934 field_projections: Default::default(),
5935 };
5936
5937 let mut ops = vec![];
5938 for i in 0..5 {
5939 ops.push(TransactionOperation {
5940 kind: TransactionOperationKind::Insert,
5941 collection: "incidents".into(),
5942 id: format!("incident-{}", i),
5943 value: serde_json::json!({
5944 "title": format!("Incident {}", i),
5945 "owner_id": if i % 2 == 0 { "alice" } else { "bob" }
5946 }),
5947 if_version: None,
5948 });
5949 }
5950
5951 let insert_request = TransactionRequest {
5952 transaction_id: None,
5953 tenant_id: "tenant".into(),
5954 application_id: "app".into(),
5955 revision_id: "rev".into(),
5956 schema_version: 1,
5957 state_namespace: None,
5958 causal_parent: None,
5959 authorization: admin_context,
5960 operations: ops,
5961 preconditions: vec![],
5962 fences: vec![],
5963 };
5964
5965 execute_transaction(&db, &schema, &insert_request, None).expect("insert should succeed");
5966
5967 let alice_read = begin_read(&db, &schema, "app", alice_context).unwrap();
5968 let query_no_limit = CanonicalQuery {
5969 collection: "incidents".into(),
5970 filter: None,
5971 order_by: vec![QueryOrder {
5972 field: "_id".into(),
5973 direction: SortDirection::Asc,
5974 }],
5975 limit: None,
5976 offset: 0,
5977 cursor: None,
5978 projection: vec!["title".into(), "owner_id".into()],
5979 group_by: vec![],
5980 aggregates: vec![],
5981 references: vec![],
5982 semantic: None,
5983 };
5984
5985 let result = execute_query(
5986 &db,
5987 &schema,
5988 &alice_read,
5989 &query_no_limit,
5990 Some(PolicySubject::Owner),
5991 )
5992 .expect("query should succeed");
5993
5994 assert_eq!(
5995 result.records.len(),
5996 3,
5997 "Alice should see 3 incidents she owns (0, 2, 4)"
5998 );
5999 for record in &result.records {
6000 assert_eq!(record.get("owner_id").unwrap(), "alice");
6001 }
6002 }
6003}