diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs index 43e48a2eaba..2df60ef9153 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs @@ -509,6 +509,7 @@ mod tests { //! `DocumentTypeV2::try_from_schema` rather than at the per-index //! `Index::try_from` boundary. use super::*; + use crate::data_contract::config::v0::DataContractConfigSettersV0; use platform_value::platform_value; /// Build a minimal v2-shaped document-type schema with @@ -582,6 +583,14 @@ mod tests { let platform_version = PlatformVersion::latest(); let config = DataContractConfig::default_for_version(platform_version) .expect("default config available on latest platform version"); + parse_with_config(schema, &config) + } + + fn parse_with_config( + schema: Value, + config: &DataContractConfig, + ) -> Result { + let platform_version = PlatformVersion::latest(); DocumentTypeV2::try_from_schema( Identifier::new([1; 32]), 1, @@ -597,6 +606,35 @@ mod tests { ) } + fn basic_message_schema(extra: Vec<(&'static str, Value)>) -> Value { + let mut schema_map = vec![ + ( + Value::Text("type".to_string()), + Value::Text("object".to_string()), + ), + ( + Value::Text("properties".to_string()), + platform_value!({ + "message": { + "type": "string", + "maxLength": 50, + "position": 0, + }, + }), + ), + ( + Value::Text("additionalProperties".to_string()), + Value::Bool(false), + ), + ]; + + for (key, value) in extra { + schema_map.push((Value::Text(key.to_string()), value)); + } + + Value::Map(schema_map) + } + /// `documentsAverageable: "score" + rangeAverageable: true + /// rangeCountable: false` — explicit-false on the count side /// contradicts the shorthand. Must reject. @@ -781,6 +819,162 @@ mod tests { ); } + /// `documentsKeepHistory: true` + `canBeDeleted: true` is a + /// self-contradictory document-type configuration that contract + /// validation MUST reject at creation time. + /// + /// The two flags are mutually exclusive in practice: when a + /// document type keeps history, rs-drive's delete path rejects + /// every deletion with `InvalidDeletionOfDocumentThatKeepsHistory` + /// (see `force_delete_document_for_contract_operations_v0` in + /// `rs-drive/src/drive/document/delete/.../v0/mod.rs`, which returns + /// the error unconditionally when `documents_keep_history()` is + /// true). So `canBeDeleted: true` on a keep-history type advertises + /// a capability the storage layer will always refuse. + /// + /// Today nothing catches this contradiction up front: the parser + /// accepts the contract, the delete state-transition structure + /// validator only checks `documents_can_be_deleted()` (not + /// `documents_keep_history()`), and the contradiction surfaces only + /// at execution — after the transition has already passed broadcast + /// validation. An SDK user can deploy such a contract (testnet + /// contract `5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC`'s `note` + /// type is a live example) and only discover the problem when a + /// delete fails deep in Drive. + /// + /// This test asserts the behavior we WANT — rejection at + /// contract-creation parse time, naming both offending flags so the + /// author can fix the schema immediately. It FAILS against current + /// code (the contract parses cleanly), pinning the missing guard to + /// the flag-parsing region of `try_from_schema` (mirror the existing + /// cross-flag `ContestedUniqueIndexOnMutableDocumentTypeError` + /// check). Once the guard lands, this test locks the behavior in. + #[test] + fn doctype_keep_history_with_can_be_deleted_rejected() { + let schema = basic_message_schema(vec![ + ("documentsKeepHistory", Value::Bool(true)), + ("canBeDeleted", Value::Bool(true)), + ]); + let result = parse(schema); + assert!( + result.is_err(), + "documentsKeepHistory: true + canBeDeleted: true must be rejected at \ + contract-creation parse time: a keep-history document type can never be \ + deleted (rs-drive returns InvalidDeletionOfDocumentThatKeepsHistory), so \ + advertising canBeDeleted: true is a self-contradiction that should be caught \ + here rather than at delete-execution time" + ); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"), + "rejection error must name both documentsKeepHistory and canBeDeleted so the \ + contract author knows which flags conflict; got {msg}" + ); + } + + /// Same contradiction as the explicit `documentsKeepHistory: true` + /// + `canBeDeleted: true` test, but both values are inherited from + /// contract defaults. The parser resolves defaults before storing + /// the document type, so the guard must inspect the resolved + /// booleans, not only the raw keys present in the document schema. + #[test] + fn doctype_keep_history_with_can_be_deleted_inherited_from_defaults_rejected() { + let mut config = DataContractConfig::default_for_version(PlatformVersion::latest()) + .expect("default config available on latest platform version"); + config.set_documents_keep_history_contract_default(true); + config.set_documents_can_be_deleted_contract_default(true); + + let result = parse_with_config(basic_message_schema(vec![]), &config); + + assert!( + result.is_err(), + "contract defaults resolving to documentsKeepHistory: true + canBeDeleted: true \ + must be rejected just like explicit document-type flags" + ); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"), + "error must name both resolved conflicting flags; got {msg}" + ); + } + + /// One-sided inheritance: keep-history comes from the contract + /// default, while `canBeDeleted: true` is explicitly set on the + /// document type. This is equally impossible to execute and must + /// not slip through a guard that only checks for both raw keys on + /// the same schema object. + #[test] + fn doctype_keep_history_default_with_explicit_can_be_deleted_rejected() { + let mut config = DataContractConfig::default_for_version(PlatformVersion::latest()) + .expect("default config available on latest platform version"); + config.set_documents_keep_history_contract_default(true); + config.set_documents_can_be_deleted_contract_default(false); + + let result = parse_with_config( + basic_message_schema(vec![("canBeDeleted", Value::Bool(true))]), + &config, + ); + + assert!( + result.is_err(), + "documentsKeepHistory inherited true + explicit canBeDeleted: true must be rejected" + ); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"), + "error must name both resolved conflicting flags; got {msg}" + ); + } + + /// Opposite one-sided inheritance: `canBeDeleted: true` comes from + /// the contract default, while the document type explicitly opts + /// into history. This is the shape most likely to surprise + /// contract authors because canBeDeleted defaults to true. + #[test] + fn doctype_explicit_keep_history_with_can_be_deleted_default_rejected() { + let mut config = DataContractConfig::default_for_version(PlatformVersion::latest()) + .expect("default config available on latest platform version"); + config.set_documents_keep_history_contract_default(false); + config.set_documents_can_be_deleted_contract_default(true); + + let result = parse_with_config( + basic_message_schema(vec![("documentsKeepHistory", Value::Bool(true))]), + &config, + ); + + assert!( + result.is_err(), + "explicit documentsKeepHistory: true + inherited canBeDeleted: true must be rejected" + ); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"), + "error must name both resolved conflicting flags; got {msg}" + ); + } + + /// Companion to the rejection test: `documentsKeepHistory: true` + /// with `canBeDeleted: false` (or absent) must keep parsing + /// cleanly. Guards the future guard against being over-broad — only + /// the `true + true` combination is contradictory; keep-history + /// types that are (correctly) non-deletable must remain valid. + #[test] + fn doctype_keep_history_with_can_be_deleted_false_accepted() { + let schema = basic_message_schema(vec![ + ("documentsKeepHistory", Value::Bool(true)), + ("canBeDeleted", Value::Bool(false)), + ]); + let v2 = parse(schema).expect("keep-history + canBeDeleted: false is a valid combination"); + assert!( + v2.documents_keep_history, + "documentsKeepHistory: true must be carried into v2" + ); + assert!( + !v2.documents_can_be_deleted, + "canBeDeleted: false must be carried into v2" + ); + } + /// Shorthand `documentsAverageable: "score"` with /// `rangeSummable: true` (no `rangeAverageable`, no /// `rangeCountable`) must desugar to the SAME diff --git a/packages/rs-dpp/src/document/document_factory/v0/mod.rs b/packages/rs-dpp/src/document/document_factory/v0/mod.rs index 269dc16c092..fb0b6bff83f 100644 --- a/packages/rs-dpp/src/document/document_factory/v0/mod.rs +++ b/packages/rs-dpp/src/document/document_factory/v0/mod.rs @@ -544,15 +544,19 @@ impl DocumentFactoryV0 { #[cfg(test)] mod test { + use crate::data_contract::schema::DataContractSchemaMethodsV0; use data_contracts::SystemDataContract; + use platform_value::platform_value; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::document::document_factory::DocumentFactoryV0; use crate::document::{Document, DocumentV0}; use crate::identifier::Identifier; use crate::system_data_contracts::load_system_data_contract; + use crate::tests::fixtures::get_data_contract_fixture; #[test] /// Create a delete transition in DocumentFactoryV0 for an immutable but deletable document type @@ -608,4 +612,72 @@ mod test { let transitions = result.unwrap(); assert_eq!(transitions.len(), 1, "There should be one transition"); } + + #[test] + /// A keep-history document type can never be deleted by Drive, even if the + /// contract advertises `canBeDeleted: true`. The generic DPP factory should + /// reject that delete locally instead of constructing a transition that will + /// be doomed at ABCI/Drive execution. + fn delete_keep_history_document_is_rejected_locally() { + let platform_version = PlatformVersion::latest(); + let mut data_contract = + get_data_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + data_contract + .set_document_schema( + "historyDocument", + platform_value!({ + "type": "object", + "documentsKeepHistory": true, + "canBeDeleted": true, + "properties": { + "name": { + "type": "string", + "position": 0, + }, + }, + "additionalProperties": false, + }), + false, + &mut vec![], + platform_version, + ) + .expect("current parser accepts the contradictory doctype"); + + let document_type = data_contract + .document_type_for_name("historyDocument") + .expect("expected historyDocument document type"); + assert!(document_type.documents_keep_history()); + assert!(document_type.documents_can_be_deleted()); + + let document = Document::V0(DocumentV0 { + id: Identifier::random(), + owner_id: Identifier::random(), + properties: BTreeMap::new(), + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let mut nonce_counter = BTreeMap::new(); + let result = DocumentFactoryV0::document_delete_transitions( + vec![(document, document_type, None)], + &mut nonce_counter, + platform_version, + ); + + assert!( + result.is_err(), + "DocumentFactoryV0 must reject delete transitions for keep-history document types \ + before they can be signed and broadcast" + ); + } } diff --git a/packages/rs-dpp/src/document/specialized_document_factory/v0/mod.rs b/packages/rs-dpp/src/document/specialized_document_factory/v0/mod.rs index 014c5d5afea..784e9c83b8a 100644 --- a/packages/rs-dpp/src/document/specialized_document_factory/v0/mod.rs +++ b/packages/rs-dpp/src/document/specialized_document_factory/v0/mod.rs @@ -554,6 +554,7 @@ impl SpecializedDocumentFactoryV0 { #[allow(clippy::type_complexity)] mod tests { use super::*; + use crate::data_contract::schema::DataContractSchemaMethodsV0; use crate::document::DocumentV0Getters; use crate::tests::fixtures::get_data_contract_fixture; use crate::util::entropy_generator::EntropyGenerator; @@ -580,6 +581,39 @@ mod tests { (factory, data_contract) } + fn setup_factory_with_keep_history_can_be_deleted_document( + ) -> (SpecializedDocumentFactoryV0, DataContract) { + let platform_version = PlatformVersion::latest(); + let created = get_data_contract_fixture(None, 0, platform_version.protocol_version); + let mut data_contract = created.data_contract_owned(); + data_contract + .set_document_schema( + "historyDocument", + platform_value!({ + "type": "object", + "documentsKeepHistory": true, + "canBeDeleted": true, + "properties": { + "name": { + "type": "string", + "position": 0, + }, + }, + "additionalProperties": false, + }), + false, + &mut vec![], + platform_version, + ) + .expect("current parser accepts the contradictory doctype"); + let factory = SpecializedDocumentFactoryV0::new_with_entropy_generator( + platform_version.protocol_version, + data_contract.clone(), + Box::new(TestEntropyGenerator), + ); + (factory, data_contract) + } + #[test] fn new_creates_factory_with_default_entropy() { let platform_version = PlatformVersion::latest(); @@ -1057,6 +1091,32 @@ mod tests { assert_eq!(*nonce_counter.get(&key).unwrap(), 1); } + #[test] + fn create_state_transition_delete_with_keep_history_document_returns_error() { + let (factory, data_contract) = + setup_factory_with_keep_history_can_be_deleted_document(); + let owner_id = Identifier::from([7u8; 32]); + let doc_type = data_contract + .document_type_for_name("historyDocument") + .unwrap(); + assert!(doc_type.documents_keep_history()); + assert!(doc_type.documents_can_be_deleted()); + + let doc = build_document(&factory, owner_id, "historyDocument"); + let mut nonce_counter = BTreeMap::new(); + let entries = vec![( + DocumentTransitionActionType::Delete, + vec![(doc, doc_type, Bytes32::default(), None)], + )]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + + assert!( + result.is_err(), + "SpecializedDocumentFactoryV0 must reject delete transitions for keep-history \ + document types before they can be signed and broadcast" + ); + } + #[test] fn create_state_transition_delete_without_revision_returns_error() { let (factory, data_contract) = setup_factory(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index beacc1093a3..a2c394059d7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -361,6 +361,220 @@ mod deletion_tests { assert_eq!(processing_result.aggregated_fees().processing_fee, 445700); } + /// A document type with `documentsKeepHistory: true` can never be deleted: + /// rs-drive's delete path returns `InvalidDeletionOfDocumentThatKeepsHistory` + /// unconditionally when `documents_keep_history()` is true (see + /// `force_delete_document_for_contract_operations_v0`). The + /// delete-transition STRUCTURE validator, however, only checks + /// `documents_can_be_deleted()` — NOT `documents_keep_history()` (see + /// `document_delete_transition_action/advanced_structure_v0/mod.rs`). So if a + /// contract sets BOTH `documentsKeepHistory: true` AND `canBeDeleted: true` + /// (a self-contradiction that contract validation does not currently + /// prevent — testnet contract `5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC`'s + /// `note` type is a live example), a delete sails through structure + /// validation and is only refused deep in Drive at execution. + /// + /// This test pins the behavior we WANT: the delete is refused as an invalid + /// (paid) state transition, leaving the document in place — the same outcome + /// the `can_not_be_deleted` sibling test above asserts. It is expected to + /// FAIL against current code, marking the missing guard in the delete + /// transition's structure validator (the keep-history check should sit next + /// to the existing `documents_can_be_deleted()` check there). Once that guard + /// lands, the doomed delete is rejected up front at broadcast time with a + /// clear consensus error, instead of an SDK user discovering the + /// contradiction only when the transition mysteriously fails to apply. + #[tokio::test] + async fn test_document_delete_on_document_type_that_keeps_history_is_rejected() { + run_document_delete_on_keep_history_can_be_deleted_contract_is_rejected( + "tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json", + ) + .await; + } + + /// Same delete-execution gap, but the contradictory document-type + /// flags are inherited from contract defaults rather than spelled + /// directly on the `note` schema. The structure validator sees only + /// the resolved `DocumentTypeRef`, so the future guard must reject + /// this path too. + #[tokio::test] + async fn test_document_delete_on_document_type_that_inherits_keep_history_and_can_be_deleted_is_rejected( + ) { + run_document_delete_on_keep_history_can_be_deleted_contract_is_rejected( + "tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted-by-default.json", + ) + .await; + } + + async fn run_document_delete_on_keep_history_can_be_deleted_contract_is_rejected( + contract_path: &str, + ) { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let platform_state = platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected to get current platform version"); + + let note_contract = json_document_to_contract(contract_path, true, platform_version) + .expect("expected to get data contract"); + platform + .drive + .apply_contract( + ¬e_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply contract successfully"); + + let mut rng = StdRng::seed_from_u64(437); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let note_document_type = note_contract + .document_type_for_name("note") + .expect("expected a note document type"); + + // Precondition: this is the contradictory flag combination — the type + // keeps history (so Drive will refuse deletion) yet advertises that its + // documents can be deleted. + assert!(note_document_type.documents_keep_history()); + assert!(note_document_type.documents_can_be_deleted()); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let document = note_document_type + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + let mut altered_document = document.clone(); + altered_document.set_revision(Some(1)); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + note_document_type, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_create_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_eq!(processing_result.valid_count(), 1); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let documents_batch_deletion_transition = + BatchTransition::new_document_deletion_transition_from_document( + altered_document, + note_document_type, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_deletion_serialized_transition = documents_batch_deletion_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_deletion_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Desired behavior: the delete targeting a keep-history document type is + // refused as an invalid (paid) state transition — exactly like the + // `can_not_be_deleted` sibling test. + // + // FAILS today, and the failure mode is the important part: the + // transition is classified as neither valid nor invalid. It passes + // structure validation (which never checks `documents_keep_history()`) + // and then, at execution, Drive's `InvalidDeletionOfDocumentThatKeepsHistory` + // — a `DriveError`, not a consensus error — surfaces as an + // `ExecutionResult::InternalError`, leaving valid_count == 0 AND + // invalid_paid_count == 0. An internal error is "the node failed to + // process this", not "this transition is invalid": there is no clean + // consensus rejection for the client to observe, which is the root of + // the "delete hangs on the network" symptom. The fix is to reject the + // delete in the structure validator so it becomes a normal invalid + // (paid) transition instead. + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "delete of a keep-history document type must be rejected up front as an \ + invalid state transition (the delete-transition structure validator should \ + refuse it, mirroring the can_not_be_deleted path), not be accepted here and \ + fail only at Drive execution" + ); + assert_eq!(processing_result.valid_count(), 0); + } + #[tokio::test] async fn test_document_delete_on_document_type_that_is_not_mutable_and_can_be_deleted() { run_document_delete_on_document_type_that_is_not_mutable_and_can_be_deleted_at_protocol_version( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted-by-default.json b/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted-by-default.json new file mode 100644 index 00000000000..7a863df7da3 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted-by-default.json @@ -0,0 +1,43 @@ +{ + "$formatVersion": "0", + "id": "8MjTnX7JUbGfYYswyuCtHU7ZqcYU9s1fUaNiqD9s5tEw", + "config": { + "$formatVersion": "1", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": true, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "sizedIntegerTypes": true + }, + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "indices": [ + { + "name": "ownerId", + "properties": [ + { + "$ownerId": "asc" + } + ] + } + ], + "properties": { + "message": { + "type": "string", + "maxLength": 140, + "position": 0 + } + }, + "required": [ + "$createdAt", + "$updatedAt" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json b/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json new file mode 100644 index 00000000000..cf10e5d9456 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json @@ -0,0 +1,36 @@ +{ + "$formatVersion": "0", + "id": "8MjTnX7JUbGfYYswyuCtHU7ZqcYU9s1fUaNiqD9s5tEw", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "documentsMutable": true, + "documentsKeepHistory": true, + "canBeDeleted": true, + "indices": [ + { + "name": "ownerId", + "properties": [ + { + "$ownerId": "asc" + } + ] + } + ], + "properties": { + "message": { + "type": "string", + "maxLength": 140, + "position": 0 + } + }, + "required": [ + "$createdAt", + "$updatedAt" + ], + "additionalProperties": false + } + } +}