diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index c986d0ffe51..7b55287b574 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -51,6 +51,7 @@ - **Name order, first failure.** Rules are checked in the order of their names, and the first rule the document breaks refuses the transition with `DocumentPropertyConstraintViolatedError` (10422). The error names the document type, the rule, and why it failed (below). - **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId`, `$transferredAt…` or a total that depends on the owner are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. - **Price updates** change only the update's time and heights, so the rules that read `$updatedAt…` are judged again the same way; other rules are not. +- **Moderator restores.** A document a [moderator restores](../data-model/contract-moderation.md#restoring-documents) is judged as a create: every rule, with its owner and the times and heights it comes back with, and every total as it will be once the document is back. Other documents may have filled a cap or a budget while it was gone; a restore that would break a rule is refused with 10422. - **Immutable properties.** The same grammar is the condition of an [`immutable`](mutability.md#immutable) entry, which freezes a property while it holds. Only such a condition may read the stored document, through `$old.`; a rule judges creates too, which have none. - **Deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete carries neither the owner nor any time or height, which is why an index-only type may not have a rule reading `$ownerId` or a system time or height. What may be deleted is the job of [`deleteConstraints`](deletion.md#deleteconstraints), rules in this grammar the stored document must meet for its owner to delete it. - **State and fees.** A rule reads the document, its owner and its times and heights, and a `countOf` or `sumOf` reads a total from state. Each such total is a state read billed with the write; nothing else a rule does adds a fee, and it changes nothing stored. The limits below bound its cost. SDKs that validate a document before sending it apply the same rules, except those reading a total, which they cannot read. @@ -199,7 +200,7 @@ A filter maps each key, a property of the counted type or `$ownerId`, to the val ``` - **As it will be after the write.** The total is the stored one with the write applied. When the counted type is the rule's own, a create adds the document, a replace swaps its stored version for the new one, and a transfer or purchase moves it to its new owner. So `atMostTenListings`, declared on `listing`, keeps every owner at ten or fewer, and a replace of one of ten is allowed. -- **Judged when the rule's own type is written.** A rule is never judged on writes of the type it counts. On its own type it holds for good, since every write that could raise the total is judged; a type with a contested index cannot total its own documents, since a document a contest awards is stored without any rule judged. On another type it is only checked when its own type is written, and can go stale later: deleting a `profile` does not undo a `post` that needed one. Deletes are not judged, so a lower bound can be broken by deleting documents, unless the counted type's [`deleteConstraints`](deletion.md#deleteconstraints) hold it. +- **Judged when the rule's own type is written.** A rule is never judged on writes of the type it counts. On its own type it holds for good, since every write that could raise the total is judged, a moderator's restore included; a type with a contested index cannot total its own documents, since a document a contest awards is stored without any rule judged. On another type it is only checked when its own type is written, and can go stale later: deleting a `profile` does not undo a `post` that needed one. Deletes are not judged, so a lower bound can be broken by deleting documents, unless the counted type's [`deleteConstraints`](deletion.md#deleteconstraints) hold it. - **Transfers, purchases and price updates.** A total that depends on the owner (a filter value of `$ownerId`, or a `$ownerId` key on the rule's own type) is read again for a transfer or purchase, the document counted toward its new owner. A rule a price update judges, one reading `$updatedAt…`, reads its totals too. - **Billed.** Each total is a state read billed with the write. A total two rules read alike is read once. - **Every earlier write counts.** A document batch carries one transition, and each state transition of a block is applied before the next is validated, so a total includes every write before it. diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 01158e7297a..b996bb38383 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -178,7 +178,7 @@ ContractUserModerationAction::RestoreDocument { } ``` -It names no identity and no id: the document is inside the bytes, and its id and owner with it. The transform checks, in order and each refusal paid: the document type exists (10406) and carries `moderatorAbilities.delete` (41115, only such a type keeps records), the signer is the owner or a moderator (41101, whoever deleted), the bytes decode under the type (a basic decoding error, never an execution error: the bytes are the signer's), the document has a removal record (`ContractDocumentRemovalNotFoundError`, 41119) that is not marked restored (`ContractDocumentAlreadyRestoredError`, 41122: the document is live), block time is within `SystemLimits::contract_document_restore_window_ms` of the removal, a week, the last millisecond included (`DocumentRestoreWindowElapsedError`, 41120), the bytes hash to what the record holds (`DocumentRestoreHashMismatchError`, 41121: the document as it was, not an edit of it), and no other document of the type holds a value of one of its unique indexes (`DuplicateUniqueIndexError`, 40105: the value was free while the document was gone, and someone may have taken it). The record read is billed; the hash pins everything else, so the owner, the revision, the timestamps, the references and the schema are not checked again. Whether the document's owner is banned is not asked either: a ban stops writes, it never removed documents. +It names no identity and no id: the document is inside the bytes, and its id and owner with it. The transform checks, in order and each refusal paid: the document type exists (10406) and carries `moderatorAbilities.delete` (41115, only such a type keeps records), the signer is the owner or a moderator (41101, whoever deleted), the bytes decode under the type (a basic decoding error, never an execution error: the bytes are the signer's), the document has a removal record (`ContractDocumentRemovalNotFoundError`, 41119) that is not marked restored (`ContractDocumentAlreadyRestoredError`, 41122: the document is live), block time is within `SystemLimits::contract_document_restore_window_ms` of the removal, a week, the last millisecond included (`DocumentRestoreWindowElapsedError`, 41120), the bytes hash to what the record holds (`DocumentRestoreHashMismatchError`, 41121: the document as it was, not an edit of it), the document meets every rule of the type's [`propertyConstraints`](../contract-keywords/property-constraints.md) as a create would, its `countOf` and `sumOf` totals read as they will be once it is back (`DocumentPropertyConstraintViolatedError`, 10422: the document re-enters every count and sum its deletion took it out of, and other documents may have filled a cap or a budget meanwhile), and no other document of the type holds a value of one of its unique indexes (`DuplicateUniqueIndexError`, 40105: the value was free while the document was gone, and someone may have taken it). The record read and the totals read are billed; the hash pins everything else, so the owner, the revision, the timestamps, the references and the schema are not checked again. Whether the document's owner is banned is not asked either: a ban stops writes, it never removed documents. The action carries the contract, the decoded document and the record marked restored by the signer at the block's time, so Drive puts the document back through the ordinary insert (`DocumentOperationType::AddDocument`, every index and aggregate of the type included) and replaces the record in place, without reading again. The restored document's storage flags name its owner, as they did before the deletion: the signer pays for the bytes, and the refund of a later deletion is the owner's, as it always was. Nothing the document type prices is charged, neither its creation token cost nor its `actionFees` creation fee, and no fee agreement is asked: a moderator undoes a moderation, it does not create content. The restore is proved by the record, now marked restored and holding the hash of the bytes the transition carried; the verifier reads the document's id out of those bytes under the contract's document type, so it needs the contract, which the SDKs register with their context provider before broadcasting. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index 00b6d84426a..9f4e6c6808c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -10,8 +10,12 @@ mod transformer; // document's own deletion does, billed the same. pub(in crate::execution::validation::state_transition) use state::v0::fetch_documents::fetch_document_with_id; // A moderator's field change judges the changed document by its type's `propertyConstraints` -// as a replace does, with the totals they read. -pub(in crate::execution::validation::state_transition) use transformer::v0::property_constraint_aggregates::read_property_constraint_aggregates_for_moderator_change; +// as a replace does, and a moderator's restore judges the restored document as a create does, +// with the totals they read. +pub(in crate::execution::validation::state_transition) use transformer::v0::property_constraint_aggregates::{ + read_property_constraint_aggregates_for_moderator_change, + read_property_constraint_aggregates_for_restore, +}; #[cfg(test)] mod tests; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs index a27be4d1256..f54a71512b9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs @@ -169,6 +169,55 @@ pub(crate) fn read_property_constraint_aggregates_for_moderator_change( ) } +/// Reads the `countOf` and `sumOf` totals the `propertyConstraints` rules of the document type +/// `document_type_name` read for a moderator's restore of `restored`, whose properties are +/// `restored_data` (one map value, which the caller judges the rules on too), each as it will +/// be once the document is back: the total the count or sum tree keeps now, plus what +/// `restored` adds to it, as for a create under its own owner. A filter reads its values (its +/// id, its owner, its properties) from `restored`. The reads are billed to +/// `execution_context`. +/// +/// Only the restore transform of `ContractUserModeration` calls it (protocol version 14); a +/// type whose rules read no total reads nothing. Every state transition of a block is applied +/// before the next is validated, so no earlier write of the block is missing from the totals. +#[allow(clippy::too_many_arguments)] +pub(crate) fn read_property_constraint_aggregates_for_restore( + drive: &Drive, + contract: &DataContract, + document_type_name: &str, + restored: &Document, + restored_data: &Value, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + let Some(document_type) = contract.document_type_optional_for_name(document_type_name) else { + // The restore refuses a document type the contract lacks + return Ok(BTreeMap::new()); + }; + let reads = document_type + .property_constraints() + .values() + .flat_map(|rule| rule.aggregate_reads()) + .collect::>(); + read_aggregates( + drive, + contract, + reads, + Some(VersionData { + id: restored.id(), + data: restored_data, + owner_id: restored.owner_id(), + }), + None, + block_info, + execution_context, + transaction, + platform_version, + ) +} + /// Reads from state the `countOf` and `sumOf` totals the `propertyConstraints` rules of /// the document type `document_type_name` read, for a write storing `written` in place of /// `stored` (`None` for a create), each as it will be once the write is done: the total the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index c376241bbfd..f60bbbdff17 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -14,6 +14,7 @@ use crate::execution::validation::state_transition::common::validate_document_no use crate::execution::validation::state_transition::common::validate_identity_exists::validate_identity_exists; use crate::execution::validation::state_transition::state_transitions::batch::{ fetch_document_with_id, read_property_constraint_aggregates_for_moderator_change, + read_property_constraint_aggregates_for_restore, }; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -1665,8 +1666,11 @@ fn transform_document_fields_change_v0( /// under the type, the type keeps removal records and the document has one that is not yet /// restored, block time is within the restore window after the removal, the bytes hash to what /// the record holds, the removal is no deletion the seated team approved together (those -/// stand), and no other document holds a value of one of the type's unique indexes. Every refusal is paid -/// for by bumping the signer's contract nonce. +/// stand), the document meets the type's `propertyConstraints` as a create would, with the +/// `countOf` and `sumOf` totals as they will be once it is back +/// (`DocumentPropertyConstraintViolatedError`, 10422), and no other document holds a value of +/// one of the type's unique indexes. Every refusal is paid for by bumping the signer's contract +/// nonce. /// /// The action carries the contract, the decoded document and the record marked restored, so /// Drive puts the document back and marks the record without reading again. Nothing the @@ -1902,7 +1906,43 @@ fn transform_document_restore_v0( return refuse(error); } - // What the hash does not pin: another document may have taken a value of one of the + // The hash does not pin the `countOf` and `sumOf` totals the type's `propertyConstraints` read: + // the restore puts the document back into every count and sum its deletion took it out + // of, and other documents may have taken the room it left meanwhile. So the document is + // judged as a create judges one, by every rule, under its own owner and with the times and + // heights it comes back with, the totals read, billed, as they will be once it is back. + // The rules not reading a total held when the document was last written and still do: it + // comes back byte for byte, and the rules are fixed with the type. + if !document_type.property_constraints().is_empty() { + let data = Value::from(document.properties().clone()); + let aggregates = read_property_constraint_aggregates_for_restore( + platform.drive, + contract, + document_type_name, + &document, + &data, + block_info, + execution_context, + tx, + platform_version, + )?; + let result = document_type.validate_property_constraints( + &data, + &DocumentSystemValues { + aggregates: Some(aggregates), + ..DocumentSystemValues::of_document(&document) + }, + platform_version, + )?; + if !result.is_valid() { + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action(), + result.errors, + )); + } + } + + // What the hash does not pin either: another document may have taken a value of one of the // type's unique indexes while the document was gone, and would clash with it. if document_type.indexes().values().any(|index| index.unique) { let uniqueness = platform.drive.validate_moderated_document_uniqueness( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 642c0d58afa..06d2f4a8893 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -81,6 +81,7 @@ mod duplicate_keys; mod moderated_document_reference; mod moderator_fields; mod preallocated_through_moderated_reference; +mod restore_property_constraints; mod retraction; mod seated_team; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_property_constraints.rs new file mode 100644 index 00000000000..261c1a63423 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_property_constraints.rs @@ -0,0 +1,200 @@ +//! A moderator's restore judged by its document type's `propertyConstraints` (protocol version +//! 14): the restore puts the document back into every count and sum its deletion took it out +//! of, so a `countOf` cap or a `sumOf` budget that other documents filled while it was gone +//! refuses it, as it would refuse a create. + +use super::*; +use dpp::consensus::basic::document::PropertyConstraintViolation; +use dpp::consensus::basic::BasicError; + +const DOCUMENT_PROPERTY_CONSTRAINT_VIOLATED: u32 = 10422; + +/// A post each author holds one of at a time: `byOwner` counts each author's posts. +fn one_post_per_author_schema() -> Value { + post_schema_with(platform_value!({ + "indices": [ + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }], "countable": "countable" }, + ], + "propertyConstraints": { + "onePostPerAuthor": { + "lessThanOrEqual": [{ "countOf": ["post", { "$ownerId": "$ownerId" }] }, 1] + }, + }, + })) +} + +/// A post spending part of a budget of 100 every post shares: the type sums `amount`. +fn budgeted_post_schema() -> Value { + post_schema_with(platform_value!({ + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + "amount": { "type": "integer", "minimum": 0, "maximum": 1000, "position": 1 }, + }, + "required": ["text", "amount"], + "documentsSummable": "amount", + "propertyConstraints": { + "budget": { "lessThanOrEqual": [{ "sumOf": ["post", "amount"] }, 100] }, + }, + })) +} + +/// A contract whose `post` type has `schema`, moderated by a named moderator +async fn setup_with_posts(schema: Value) -> Setup { + Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| add_document_type(contract, POST, schema), + ) + .await +} + +/// Sets a post's `amount` +fn spending(amount: u64) -> impl FnOnce(&mut Document) { + move |document: &mut Document| document.set("amount", Value::U64(amount)) +} + +/// The `post` rule `rule` broke, refusing the transition, paid +fn assert_rule_broken(execution: &StateTransitionExecutionResult, rule: &str) { + assert_paid_with_code(execution, DOCUMENT_PROPERTY_CONSTRAINT_VIOLATED); + let StateTransitionExecutionResult::PaidConsensusError { + error: + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(error)), + .. + } = execution + else { + panic!("expected DocumentPropertyConstraintViolatedError, got {execution:?}"); + }; + assert_eq!(error.document_type_name(), POST); + assert_eq!(error.constraint(), rule); + assert_eq!(error.violation(), PropertyConstraintViolation::NotMet); +} + +/// The post `post_id` is gone and its record is not marked restored +fn assert_not_restored(setup: &Setup, post_id: Identifier, transaction: &Transaction) { + assert_eq!( + setup.stored_document(POST, post_id, Some(transaction)), + None + ); + assert_eq!( + setup + .post_removal(post_id, Some(transaction)) + .map(|removal| removal.restoration), + Some(None) + ); +} + +#[tokio::test] +async fn should_refuse_a_restore_that_would_exceed_a_count_of_cap() { + let setup = setup_with_posts(one_post_per_author_schema()).await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + let stored = setup + .stored_document(POST, post.id(), Some(&transaction)) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + + // The deletion left the author no post, and the author wrote another. + let (other, create_other) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_other, &transaction)); + setup.commit(transaction); + + // Back beside it the author would hold two. The total is the author's, not the + // moderator's, who holds none: refused by the mempool and by a block, paid. + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes.clone())) + .await; + let mempool_errors = setup.check_tx(&restore); + assert_eq!(mempool_errors.len(), 1); + assert_eq!( + mempool_errors[0].code(), + DOCUMENT_PROPERTY_CONSTRAINT_VIOLATED + ); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_rule_broken( + &setup.process_at(&restore, BLOCK_TIME_MS + 1, &transaction), + "onePostPerAuthor", + ); + assert_not_restored(&setup, post.id(), &transaction); + + // Once the author deletes the other post there is room again, and the post comes back as + // it was, still the author's one post. + let other = setup + .stored_document(POST, other.id(), Some(&transaction)) + .expect("expected the other post to be stored"); + let own_delete = own_post_deletion(&setup, &setup.user, other).await; + assert_success(&setup.process_at(&own_delete, BLOCK_TIME_MS + 2, &transaction)); + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes)) + .await; + assert_success(&setup.process_at(&restore, BLOCK_TIME_MS + 3, &transaction)); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + Some(stored) + ); + let (_, one_too_many) = setup.create_document_of_type(&setup.user, POST).await; + assert_rule_broken( + &setup.process_at(&one_too_many, BLOCK_TIME_MS + 4, &transaction), + "onePostPerAuthor", + ); +} + +#[tokio::test] +async fn should_refuse_a_restore_that_would_exceed_a_sum_of_budget() { + let setup = setup_with_posts(budgeted_post_schema()).await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup + .create_document_of_type_with(&setup.user, POST, spending(20)) + .await; + assert_success(&setup.process(&create, &transaction)); + let stored = setup + .stored_document(POST, post.id(), Some(&transaction)) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + + // The deletion freed the 20 the post spent, and a stranger spent the whole budget. + let (other, create_other) = setup + .create_document_of_type_with(&setup.stranger, POST, spending(100)) + .await; + assert_success(&setup.process(&create_other, &transaction)); + + // 100 + 20 is above 100: refused, paid, nothing restored. + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes.clone())) + .await; + assert_rule_broken( + &setup.process_at(&restore, BLOCK_TIME_MS + 1, &transaction), + "budget", + ); + assert_not_restored(&setup, post.id(), &transaction); + + // The stranger takes back its post and spends 80 instead: 80 + 20 is exactly the budget, + // and the post comes back. + let other = setup + .stored_document(POST, other.id(), Some(&transaction)) + .expect("expected the other post to be stored"); + let own_delete = own_post_deletion(&setup, &setup.stranger, other).await; + assert_success(&setup.process_at(&own_delete, BLOCK_TIME_MS + 2, &transaction)); + let (_, create_smaller) = setup + .create_document_of_type_with(&setup.stranger, POST, spending(80)) + .await; + assert_success(&setup.process_at(&create_smaller, BLOCK_TIME_MS + 3, &transaction)); + let restore = setup + .moderate(&setup.moderator, restore_action(POST, bytes)) + .await; + assert_success(&setup.process_at(&restore, BLOCK_TIME_MS + 4, &transaction)); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + Some(stored) + ); +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7ed82ee63a7..b096eab07e6 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -494,7 +494,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// record holds; refused otherwise, or without a record (41119), past the /// window (41120), on a hash mismatch (41121), once restored (41122), or /// when another document took a value of one of the type's unique indexes -/// meanwhile (40105). The document goes back through the ordinary insert, +/// meanwhile (40105) or the document breaks one of the type's +/// `propertyConstraints` rules, its totals as they will be once it is back +/// (10422, 87). The document goes back through the ordinary insert, /// its storage flags naming its owner (the signer pays, the owner keeps /// the refund of a later deletion), and the record is marked restored in /// place (`ContractDocumentRemoval::restoration`: who, when) rather than @@ -2212,6 +2214,21 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// schemas without a scalar `$ref` target; earlier versions replay /// through generation 0. /// +/// 87. **A moderator's restore keeps the type's totals within its rules**: +/// the restore transform (21), in place, judges the restored document by +/// every rule of its type's `propertyConstraints`, as a create judges one: +/// under its own owner, with the times and heights it comes back with, and +/// the `countOf` and `sumOf` totals read, billed, as they will be once it is +/// back (`read_property_constraint_aggregates_for_restore`, the shared batch +/// transformer's read with no stored version). Before, the restore checked +/// the type's unique indexes but none of its rules, so a cap or a budget +/// that other documents filled while the document was gone was exceeded +/// once it came back, and the owner's later replaces were refused. A broken +/// rule refuses the restore with `DocumentPropertyConstraintViolatedError` +/// (10422), paid by bumping the signer's contract nonce like every restore +/// refusal, in the mempool too. Inert before this version: no earlier +/// version admits a `ContractUserModeration` transition. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's