Contract Moderation

An application that stores user content needs a way to keep an abusive identity out. Before protocol version 14 nothing in consensus state could do that: the contract owner could delete nothing the user wrote, could stop nothing the user would write next, and a client-side blocklist bound nobody but the client that kept it. Every other user's node still accepted the identity's documents.

Contract moderation is the answer. A data contract may declare, in its config, that it keeps a banlist, a suspension list and/or a warning list of identities, and who may edit them. An identity on the banlist, or on the suspension list with a suspension that has not lapsed, cannot act on the contract at the document level: every document transition it signs against the contract is refused, paid, in the mempool and in a block. Token transitions are not affected. A warning bars nothing: it is a record, with a reason and a block time, of a step short of a suspension, which the warned identity and everyone else can read and which accumulates until a moderator clears it. The lists live under the contract's own subtree in Drive, are edited by one new state transition, and are readable with one GroveDB proof.

The Model

Four facts define a contract's moderation:

  1. The contract declares it. DataContractConfigV2::moderation is an optional ContractModerationConfig { banlist, suspensions, warnings, moderators }. At least one list must be kept, unless a document type lets the moderators delete its documents (see Deleting Documents below) or keeps fields only they write (see Changing Document Fields below), in which case the declaration may keep none. Which lists a contract keeps is decided when it is created and never changes: an update can not make an unmoderated contract moderated, turn a second list on, or turn a list off (validate_config_update 2, DataContractConfigUpdateError). Whoever writes documents under a contract knows from its first version whether and how they can be barred from it, which matters most where documents are assets: a ban also stops transfers and sales. And a list that is on may hold entries. Only the moderators may be changed by an update.
  2. The owner moderates, alone or with a fixed set, or an elected team does. ContractModerators is ContractOwner, AppointedModerators(set) or Elected(declaration); the third is its own section below. With the first two, a set is at most SystemLimits::max_contract_moderators (16) identities. The owner may always moderate and need not be named; it may be named, and then counts toward the 16. Naming it changes nothing about authority. Every identity named must exist: the contract create, and the contract update for the identities it adds, look each one up in state and refuse, paid, with ContractModeratorIdentityNotFoundError (41110). A moderator that does not exist can never sign, so naming one is a mistake, and catching it once at the declaration is cheaper than guarding every later reader of the set. Moderators act alone: there is no threshold and no vote. Neither the owner nor a moderator can be banned or suspended. An entry one of them already carries can still be lifted: a contract update may name as moderator an identity that is banned or suspended, the entry keeps binding it, and the owner or another moderator unbans or unsuspends it without demoting it first (never the identity itself: a moderation cannot target its own signer).
  3. A ban lasts until an unban; a suspension lasts until a block time. A suspension names the block time, in milliseconds, at which it lapses. A lapsed suspension is not deleted by the clock: the first document transition of the identity that runs at or after that time executes normally and, in the same execution, sweeps the stale entry. An explicit unsuspend deletes it too. A ban supersedes a suspension: banning a suspended identity removes the suspension, and suspending a banned identity is refused.
  4. A warning is a record, not a bar. A warn adds one warning, the block time and a reason, to the identity's entry on the warning list; the entry holds every warning the identity carries, oldest first, up to SystemLimits::max_contract_warnings_per_identity (16), past which a warn is refused until a clearWarnings deletes the entry whole. The document gate does not read the warning list, only the banlist and the suspension list (the warning list is read by a warn and a clearing alone); a ban leaves the warnings alone (they say how it came to that), and a banned or suspended identity may still be warned. What a warning is for is the client: an application shows the identity its warnings, and the moderators the count, before a suspension or a ban.

The Types

The declaration and the status live in packages/rs-dpp/src/data_contract/config/moderation/mod.rs:

#![allow(unused)]
fn main() {
pub struct ContractModerationConfig {
    pub banlist: bool,
    pub suspensions: bool,
    pub moderators: ContractModerators,
    pub warnings: bool,   // last: a declaration stored before the warning list existed does not decode
}

pub enum ContractModerators {
    ContractOwner,
    AppointedModerators(BTreeSet<Identifier>),
    Elected(Box<ElectedModerators>),      // see Elected Moderation below
}

pub enum ContractModerationList { Banlist, Suspensions, Warnings }

pub struct ContractModerationReason {
    pub code: Option<u16>,
    pub text: String,
    pub documents: Vec<ContractModerationDocument>,   // { document_type_name, document_id }
    pub reason_document_id: Option<Identifier>,       // a `reason` of the moderation charters contract
}

pub struct ContractBan { pub reason: ContractModerationReason }
pub struct ContractSuspension { pub until: TimestampMillis, pub reason: ContractModerationReason }
pub struct ContractWarning { pub warned_at: TimestampMillis, pub reason: ContractModerationReason }

pub struct ContractModerationStatus {
    pub ban: Option<ContractBan>,
    pub suspension: Option<ContractSuspension>,
    pub warnings: Vec<ContractWarning>,   // oldest first, empty when none
}
}

ContractModerationConfig::lists names the lists a contract keeps, in tree key order (banlist, suspension list, warning list); barring_lists the ones whose entries bar, which is every list but the warning list: what the document gate reads and what a ban's proof covers.

Every ban, every suspension and every warning carries a reason, stored with the entry so that whoever reads the list reads why. The text is free: at most SystemLimits::max_contract_moderation_reason_length (1024) bytes of UTF-8, possibly empty. The code is reserved for the ban codes a contract may declare in a later protocol version. No contract declares any today, so it is expected to be None; a moderator may still write any u16 there, and nothing checks it against anything. The reasonDocumentId names a reason document of the moderation charters contract, the ground the action is taken on: a seated elected team's ban, suspension, warning or deletion must name one its proposal lists (see The seated team below); for every other moderator it is stored as written, looked up nowhere. A reason may also cite the documents it is about, the posts a warning or a ban is for, each by its document type name and its id: at most SystemLimits::max_contract_moderation_reason_documents (16), none twice, with a type name a contract could admit (InvalidContractModerationReasonDocumentsError, 10904, unpaid). Nothing looks them up: a cited document may have been deleted since, by its author or by a moderator whose deletion left a record, or may never have existed, and a client that wants to show it fetches it or its removal record. The moderator pays the storage of the reason, documents included, byte for byte, and gets it back when the entry is removed.

The config is DataContractConfig::V2, a new variant of the config's own bincode enum inside the contract. The config version follows the platform version, as V1 did from protocol version 9: from protocol version 14 every new contract carries a V2 config, moderated or not (CONTRACT_VERSIONS_V6 sets both max_version and default_current_version to 2), and an existing V1 contract moves to V2 with its next update. config_valid_for_platform_version lowers a V2 only where the platform version does not admit it, never because of what it declares. Lowering would drop a moderation declaration, and moderation can never be turned on later, so that case is refused rather than dropped: serializing a contract whose config declares moderation at a platform version below 14 (ensure_admitted_by_platform_version), and parsing a config value with a moderation key at such a version, both fail with ProtocolError::NotSupported. For the same reason the declaration refuses an unknown key instead of skipping it (deny_unknown_fields, and the moderators' $type map likewise): a misspelled suspensions would otherwise leave the contract without the list for good. A contract create or update carrying a V2 config is active from protocol version 14 only (StateTransition::active_version_range): before that a node rejects it at decoding, unpaid, exactly as a binary that cannot decode the V2 discriminant does, so upgraded and older nodes agree on every block before activation. The JSON shape of the moderators is a flat {"$type": "contractOwner"}, {"$type": "appointedModerators", "identities": [...]} or {"$type": "elected", ...} with the declaration's keys beside its $type, the style of AuthorizedActionTakers.

The Transition

ContractUserModerationTransition (type 24) carries one action:

#![allow(unused)]
fn main() {
pub struct ContractUserModerationTransitionV0 {
    pub owner_id: Identifier,          // the moderator that signs
    pub data_contract_id: Identifier,
    pub identity_contract_nonce: IdentityNonce,
    pub action: ContractUserModerationAction,
    pub user_fee_increase: UserFeeIncrease,
    pub signature_public_key_id: KeyID,
    pub signature: BinaryData,
}

pub enum ContractUserModerationAction {
    Ban { identity_id, reason: ContractModerationReason },
    Unban { identity_id },
    Suspend { identity_id, until: TimestampMillis, reason: ContractModerationReason },
    Unsuspend { identity_id },
    Warn { identity_id, reason: ContractModerationReason },
    ClearWarnings { identity_id },
    DeleteDocument { document_type_name, document_id, reason: ContractModerationReason },
    RestoreDocument { document_type_name, document: BinaryData },
}
}

It is signed like a contract update: a CRITICAL authentication key without contract bounds, under the signer's contract-scoped nonce, and its minimum fee is the contract update floor. It activates with CONTRACT_USER_MODERATION_INITIAL_PROTOCOL_VERSION (14).

Validation

TierWhatCodes
Basic structure (unpaid)the target is not the signer; a suspension ends at or before SystemLimits::max_contract_suspension_until (2^53 - 1 ms, the largest value JSON clients read exactly); the text of a ban's, a suspension's or a warning's reason is at most SystemLimits::max_contract_moderation_reason_length bytes (its code is not checked) and the documents it cites are at most max_contract_moderation_reason_documents, well named and distinct10901, 10700, 10903, 10904
Signature and nonceCRITICAL key, contract nonceexisting
Transform (state, paid)the contract exists; it keeps the list the action edits; the signer is the owner or a moderator; the target of a ban, a suspend or a warn is neither; the target exists; the action fits the target's status41100-41106, 41109, 41117, 41118

The transform reads the contract and the target's status and refuses, paid, by bumping the signer's contract nonce. The action carries the status as read (and, for a warn, the warnings the target carries and the block time the new one is stamped with), so Drive edits the lists without reading them again, and the mempool, which transforms without a state validation stage, refuses with the same codes as a block. A suspend must end after the block time (41106). Suspending an identity that carries a suspension replaces it, longer or shorter. A warn is refused once the target carries max_contract_warnings_per_identity warnings (ContractUserWarningLimitReachedError, 41118), and a clearWarnings of an identity that carries none with ContractUserNotWarnedError (41117). A ban and a suspend read the barring lists the contract keeps; a warn and a clearing read the warning list alone.

The Document Gate

The gate sits in the batch transformer (a barred signer has every one of its transitions against the contract refused on its own, each with its nonce bump), transform_document_transitions_within_contract_v0, right after the contract is fetched, as its own versioned helper: contract_moderation_gate, selected by batch_state_transition.contract_moderation_gate (None up to protocol version 13, Some(0) from 14). That transformer is shared with every earlier protocol version, so the gate is a version-table fact there, not something inferred from contract data. A config that declares no moderation costs nothing: no read, no branch. Otherwise the transformer reads the owner's status on the lists the contract keeps, bills the read, and:

  • banned: every document transition of the batch on that contract except a deletion fails with ContractUserBannedError (41107), paid, each with its contract nonce bump;
  • suspended and not lapsed: the same with ContractUserSuspendedError (41108);
  • suspended and lapsed: the transitions go through and the batch action records the contract in lapsed_suspensions (the identity is always the batch owner, so it is not stored and nothing can queue another identity's); the batch converter (documents_batch_transition generation 1) appends one delete per contract.

Deletions (Delete and IndexOnlyDelete) are never refused: a barred identity can write nothing new, move nothing and sell nothing, but it may still take down what it wrote, under the document type's ordinary deletion rules. A batch of deletions alone carries on whole; in a mixed batch the deletions carry on next to the refusals. Because the transformer runs in check_tx, a barred identity's documents never enter the mempool. The other party of a document transition is checked too: a transfer to a banned or live-suspended recipient, and a purchase from a banned or live-suspended seller, are refused with ContractModerationCounterpartyBarredError (41114), paid by the signer, so a barred identity collects neither assets nor proceeds on the contract. That read is billed to the batch; a counterparty's lapsed suspension is left for the counterparty's own next transition to sweep. No contract could declare moderation before protocol version 14, so older blocks replay unchanged through the same code.

The Errors

Basic, in their own band (10900-10949): InvalidContractModerationConfigError (10900), ContractModerationSelfTargetError (10901), DocumentActionFeesWithoutModerationError (10902, see Fee Pots and the Claim), ContractModerationReasonTooLongError (10903), InvalidContractModerationReasonDocumentsError (10904), InvalidContractModerationDocumentFieldsError (10905, a field change naming no field or a system property). State, in their own sub-band: ContractModerationNotEnabledError (41100), IdentityNotContractModeratorError (41101), ContractModerationTargetNotAllowedError (41102), ContractUserAlreadyBannedError (41103), ContractUserNotBannedError (41104), ContractUserNotSuspendedError (41105), ContractSuspensionNotInFutureError (41106), ContractUserBannedError (41107), ContractUserSuspendedError (41108), ContractModerationTargetNotFoundError (41109), ContractModeratorIdentityNotFoundError (41110, from the contract create and update, not from the moderation transition), ContractModerationCounterpartyBarredError (41114, from the document gate; 41111 to 41113 are reserved), ContractUserNotWarnedError (41117), ContractUserWarningLimitReachedError (41118), DocumentFieldNotChangeableByModeratorsError (41123) and DocumentModeratorFieldNotWritableError (41124) (see Changing Document Fields). A contract update that turns a list on or off is refused with the existing DataContractConfigUpdateError (40002). Elected moderation has its own band (41200-41299): ContractModeratedDocumentTypeNotYetUsableError (41200), ContractModerationAbilityNotGrantedError (41201), ModerationCharterAddedModeratorLimitReachedError (41202) and ModerationReasonNotListedError (41203). A discounted action fee the seated charter does not give is refused with DocumentActionFeeModeratorsShareMismatchError (40139), beside the other fee agreement errors.

Deleting Documents

A banlist keeps an identity out; it does not take down what the identity already wrote. A document type may let the contract's moderators do that:

"post": {
  "type": "object",
  "moderatorAbilities": { "delete": true },
  "properties": { "text": { "type": "string", "maxLength": 280, "position": 0 } },
  "additionalProperties": false
}

moderatorAbilities is a document type keyword of meta-schema v3, parsed by apply_moderator_abilities; its delete key is DocumentTypeV2::documents_can_be_deleted_by_moderators and its deleteWithin key documents_can_be_deleted_by_moderators_for. Its rules for deletion:

  • The contract declares moderation. The keyword on a contract without a moderation block is refused (InvalidContractStructure, 10231): moderation can not be switched on later, so nobody could ever delete anything. In return a moderation block may keep no list at all when at least one document type carries the keyword (ContractModerationConfig::validate takes that fact from the contract): a contract can moderate content without moderating users.
  • It is fixed with the type. A contract update can not add the keyword to an existing document type or take it away (DocumentTypeUpdateError, 40212): authors keep the rules they wrote under. A document type an update adds may carry it.
  • It is independent of canBeDeleted, which rules what a document's own owner may do. canBeDeleted: false with moderatorAbilities.delete: true is a post its author can not retract and moderation can remove.
  • Some types can not carry it: one that keeps history (Drive refuses to delete such documents), an indexOnly one (there is no stored row to name by id), and one that restricts creation (its documents are the contract owner's, which no moderator may delete). Transferable and tradeable types may, and so may a type with a deletion token cost, which a moderator does not pay.
  • It may come with a window. moderatorAbilities.deleteWithin: 86400 lets the moderators delete a document for that many seconds after its last modification ($updatedAt), and no longer: once block time is past $updatedAt plus the window the document is settled, and no moderator deletes it any more, the contract owner included (DocumentModerationWindowElapsedError, 41116). At exactly $updatedAt plus the window the deletion still passes; the document's own owner still deletes it as canBeDeleted allows. Moderation acts on what was just written; it does not reach back into what has stood unchallenged. A replace or a price update moves $updatedAt, so new content opens the window again; a transfer or a purchase does not. The window needs the flag, at least one second, and the clock in the type's required, so that every document carries it: $updatedAt, or for a type with documentsMutable: false $createdAt instead, since nothing modifies such a document after its creation. A type whose documents can be replaced must require $updatedAt: measured from creation alone, an author could wait the window out and then rewrite a post into something no moderator can remove. The transform reads $updatedAt and falls back to $createdAt; like the flag it is fixed with the type, in both directions (a longer one would reopen documents that had settled). It is in seconds, as the other durations of a document type are, and it says nothing about a document's own owner, whose deletion canBeDeleted rules at any age.
  • For references it counts as deletable. A permanentDocument reference refuses such a type (ReferencedDocumentTypeDeletableError, 40122) whatever its canBeDeleted says, so the guarantee that a validated permanent reference never dangles holds; a deletableDocument reference accepts it, canBeDeleted: false included. Both checks, at contract registration and at document write, read "deletable" as deletable by anyone. A like or a reply that points at a post moderators can remove therefore declares refersTo: deletableDocument: the join reports a removed post as a missing id, and the removal record says why it is missing.
  • What a deletion leaves is the type's to say, and fixed with it. deleteKeepsRecord (default true, DocumentTypeV2::moderator_deletions_keep_records) says whether the deletion writes the removal record below, and deleteRefundsOwner (default false, moderator_deletions_refund_owner) whether the owner is refunded its storage. Both need delete: true and change with no update (40212). A type that keeps no record has no records subtree, is refused by getContractDocumentRemovals, and can never have a deletion restored (41119): its deletions are final.

The deletion is the seventh action of the same transition:

#![allow(unused)]
fn main() {
ContractUserModerationAction::DeleteDocument {
    document_type_name: String,
    document_id: Identifier,
    reason: ContractModerationReason,   // as on a ban: a code nothing checks, a text that may be empty
}
}

It names no identity (identity_id() is None): whose document it is is only known once the document is read. The transform checks, in order and each refusal paid: the document type exists (10406), it carries the keyword (DocumentTypeNotDeletableByModeratorsError, 41115), the signer is the owner or a moderator (41101), the document exists (DocumentNotFoundError), its owner is neither the contract owner nor a moderator (41102, the rule that protects them from a ban protects what they wrote), and block time is within the type's window after the document's last modification ($updatedAt, else $createdAt), when the type sets one (41116). The document is read the way a document's own deletion reads it, billed the same. The action carries the contract, the document's owner and the block time, so Drive reads nothing again. Nothing the document type prices is charged: neither its deletion token cost nor its actionFees deletion fee, both of which are what a document's own owner pays for deleting it.

Drive then runs DocumentOperationType::ForceDeleteDocument, the ordinary deletion (so every index and aggregate of the type stays right) without its canBeDeleted guard, which is the owner's rule and not the moderators', and writes a removal record:

#![allow(unused)]
fn main() {
pub struct ContractDocumentRemoval {
    pub document_owner_id: Identifier,
    pub moderator_id: Identifier,
    pub reason: ContractModerationReason,
    pub removed_at: TimestampMillis,   // the block time
    pub document_hash: [u8; 32],       // sha256d of the document as serialized under its type
    pub restoration: Option<ContractDocumentRestoration>, // { moderator_id, restored_at } once restored
}
}

On a type that keeps records, the record is what is left to say that a document was removed, not lost (a type that keeps none writes nothing below, reads no existing record, and is proved by the document's absence, VerifiedDocuments with the id and none): a client holding a dangling id (a reply whose parent is gone) can prove who removed it, whose it was, when, why and what it was. The moderator pays for it, reason included, and nothing ever deletes it. From protocol version 14 a document id commits to the nonce of its create transition and is produced at most once, so the removed id can not be created again by anyone: the only way the document comes back is a moderator's restore (see Restoring Documents below), which marks the record restored and leaves it in place. A record and a live document of the same id therefore coexist exactly when the record is marked restored, and a restored document deleted again gets a fresh record in place of the marked one.

The hash is of the document as Document::serialize writes it under its document type and the protocol version of the deleting block, computed from the document as read, not from the bytes as stored: a document stored under an earlier version of its type or of the serialization would never re-serialize to its stored bytes, and a client keeping the document rather than the bytes could never match them. A client that may have to undo a deletion keeps the document it fetched before deleting; serialized under the same contract it gives the same bytes.

The deleted document's owner gets no storage refund, unless its type sets deleteRefundsOwner, in which case the batch refunds it as the owner's own deletion would, the moderator still paying for the transition and the record. Otherwise the batch carries ContractModerationOperationType::ForfeitStorageRefunds, a marker that writes nothing, and apply_drive_operations generation 1 turns every removal such a batch attributes to an identity into a removal attributed to nobody: the bytes still leave the system (FeeResult::removed_bytes_from_system), no refund is computed, and the credits stay in the storage pools they were distributed to when the document was written. An estimate carries no refund to begin with (refunds come from the flags of what is really removed), so the mempool's fee check is the same with or without the forfeiture. Forfeiting the whole batch is exact: a moderator's deletion removes the document and nothing else, since its record is written once and the nonce it bumps keeps its size. An author who deletes the same document with an ordinary document transition is refunded as always.

Restoring Documents

A deletion can be undone. The eighth action of the same transition brings a deleted document back, as it was:

#![allow(unused)]
fn main() {
ContractUserModerationAction::RestoreDocument {
    document_type_name: String,
    document: BinaryData,   // the document serialized under its type, as it was when deleted
}
}

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.

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.

Two consequences follow from the document coming back byte for byte. Its $updatedAt does not move, so a type with moderatorAbilities.deleteWithin may have settled it while it was gone: the moderators can not delete it again until its author edits it, though the author still can. And a type with a contested index can not carry moderatorAbilities.delete at all (InvalidContractStructure, 10231): a contested index only takes a document through a vote, which no restore can go through, so such a deletion could never be undone. The bytes are decoded under the type as the contract holds it when the restore is processed, and the hash is of the document serialized under the type as it was when the document was deleted: a contract update that changes the type's layout inside the window (a property added, say) leaves the record unrestorable, since bytes that decode under the new layout cannot hash to what the record holds. A client therefore serializes under the contract's current version, as Drive does, and keeps the document rather than the bytes.

Changing Document Fields

A deletion takes a document down; some moderation only annotates one. A report is handled, a post flagged, a ticket assigned, and the document should stay, with the moderators' word on it. A document type may keep fields for its moderators:

"report": {
  "type": "object",
  "documentsMutable": false,
  "moderatorAbilities": { "changeFields": ["status", "resolution"] },
  "properties": {
    "reason": { "type": "integer", "minimum": 0, "maximum": 8, "position": 0 },
    "status": { "type": "integer", "minimum": 1, "maximum": 3, "position": 1 },
    "resolution": { "type": "string", "maxLength": 200, "position": 2 }
  },
  "required": ["reason"],
  "additionalProperties": false
}

moderatorAbilities.changeFields is DocumentTypeV2::moderator_changeable_fields, parsed by apply_moderator_abilities. The listed properties are the moderators' to write, and only theirs:

  • A document's owner writes them only as a moderator. The batch transformer refuses a create that sets one, and a replace that changes, adds or removes one, with DocumentModeratorFieldNotWritableError (41124), unless the signer moderates the contract (transformer::v0::moderator_fields::judge_moderator_field_write, through common::moderators::moderator_field_write_refusal, which reads who moderates only when such a field is written). When the signer does moderate, the action is stamped as a moderator's: the create action's moderated flag, the replace action's moderated_at and moderated_by (which otherwise carry the stored document's stamp over, each half as stored), so the document is written with $moderatedAt the block's time and $moderatedBy the signer. It runs in the transformer, beside the aggregate reads, so the mempool refuses such a write as a block does; it is inert before protocol version 14, whose types keep no such field. A seated team's member must also hold changeDocumentFields on the type. So a report starts with no status, and its author can never mark it handled.
  • The fields are fixed with the type, in both directions (40212), and a type an update adds may declare them: a field only moderators write starts absent on every document, and one its owner could have set would no longer be theirs.
  • The parser keeps them out of what a change could break. Each must be a declared, optional, stored top-level property, not immutable, neither a reference nor read by one (a propertyAgreement's referring side, a lookup source, a key id's identity property), neither generated nor a parameter of a generated property, and in no contested index; the type may not be indexOnly (10231). References are therefore never checked again when a moderator changes a document: what they read did not move. That matters for a report whose post a moderator already deleted: a replace re-checks every deletableDocument reference and would refuse it, while a moderator's change goes through. For the same reason a lookup key or a list element's list on another type may not read a field moderators write (schema_property_is_fixed_once_written counts it as moving).
  • A type that lists any keeps a revision (DocumentTypeBasicMethods::requires_revision, through has_moderator_changeable_fields), even with documentsMutable: false: Drive stores the change as an update, which needs one, and the revision is what refuses an owner's replace built before the change.
  • The contract's moderation may keep no list, as with deletion, when a type keeps fields for its moderators.

The change is the ninth action of the transition:

#![allow(unused)]
fn main() {
ContractUserModerationAction::ChangeDocumentFields {
    document_type_name: String,
    document_id: Identifier,
    fields: BTreeMap<String, Value>,   // each field's new value, `Null` removing it
    reason: ContractModerationReason,
}
}

Basic structure refuses one that names no field or a system property (InvalidContractModerationDocumentFieldsError, 10905, unpaid). The transform (transform_document_fields_change_v0) checks, in order and each refusal paid: the document type exists (10406), every field is one it keeps for its moderators (DocumentFieldNotChangeableByModeratorsError, 41123), the signer moderates the contract (41101) and a seated team holds changeDocumentFields on the type (41201) and names a listed reason (41203), the document exists (40101) and has not expired (40140). It then builds the changed document: the stored one with each field set or removed, compared as a replace compares (Value::equal_underlying_data, so an integer sent at another width than the stored one is no change), $revision one higher (OverflowError, 10700, at u64::MAX), $moderatedAt the block's time and $moderatedBy the signer, everything else as stored, $updatedAt and its heights included. That document is judged as a replace judges one: validate_document_properties (the schema and propertyConstraints, with the countOf and sumOf totals read as they will be once it is stored, read_property_constraint_aggregates_for_moderator_change), distinctFrom and the shapes of encryptedFor properties, then, when a changed field is in a unique index, Drive::validate_moderated_document_uniqueness, the restore's check generalized to a changed document (only the changed fields are checked, and the document's own entries do not clash). Whoever owns the document is no protection: the fields are the moderators'. Nothing the document type prices is charged.

The action carries the contract and the changed document, and Drive stores it with DocumentOperationType::UpdateDocument, the replace's update, every index and aggregate of the type included. The document's storage flags name its owner, as a replace's do: the moderator pays for the bytes the change adds, and what the change frees (a smaller value, an index entry that moved) is refunded to the owner, who paid for it. A seated team's member does not have the change counted toward the team's action share: the changes of one document have no bound, and counting them would let a member farm it. A change that changes nothing is refused (10905) rather than written. $updatedAt does not move, so a change never opens a moderatorAbilities.deleteWithin window again: a moderator can not keep a post deletable by touching it.

The stamp is two system properties of document serialization format 3 (bits 9 and 10 of its time field flags): absent on a document no moderator has written, set only here and by the batch transformer, which stamps a moderator's create or replace of such a field (an action it did not judge is written unstamped), carried over by a replace that leaves the fields alone and by every transfer, purchase and price update, and put back by a restore with the rest of the bytes. A type that keeps fields for its moderators may index either, never in a unique index (apply_moderator_abilities, 10231; the core index check admits the two names from parser generation 3, ParserGeneration::admit_moderation_stamp_indexes). See System Properties.

The change is proved by the document itself: the prover builds the single-document query a replace is proved by, and verify_contract_document_change_execution checks that the document exists and holds each value the change set (and none of the removed ones), returning VerifiedDocuments. The stamp is not compared: a later moderator's write to another field moves it without undoing this change. Its other properties are the owner's and are not compared; the verifier reads the document under the contract, so the SDKs register the contract before broadcasting.

Storage

[64] DataContractDocuments
└── <contract id>
    ├── [0] the contract (or its history subtree)
    ├── [1] documents
    └── [2] other
        ├── [16]  document removals -> <document type name> -> <document id>
        │                            -> Item(owner id ‖ moderator id ‖ removed at ‖ document hash ‖ restored? [‖ restored by ‖ restored at] ‖ reason)   (with such a document type)
        ├── [48]  moderation action counts -> <identity id> -> Item(count)        (elected contracts)
        ├── [64]  contract version item (every contract)
        ├── [128] banlist       -> <identity id> -> Item(reason)                 (when declared)
        ├── [192] suspensions   -> <identity id> -> Item(until ‖ reason)         (when declared)
        └── [224] warnings      -> <identity id> -> Item((warned at ‖ len ‖ reason)+)  (when declared)

until is a u64 of block time in milliseconds, big-endian. A reason is a tag byte (bit 0: a code follows, bit 1: documents follow, bit 2: a reason document follows), the code as a big-endian u16 when tagged, the 32-byte id of the reason document when tagged, then when tagged the documents it cites (their count in one byte, then each one's type name as a length byte and the name, and its 32-byte id), then the text as UTF-8 up to the end of the value, so an entry with an empty reason and no code costs one byte more than the bare entry would. A value without the tag byte is an entry written before entries carried a reason and reads as the empty reason; a tag without bit 1 is a reason from before documents could be cited, and one without bit 2 a reason naming no reason document. A warning list entry holds every warning the identity carries, oldest first, each warned at as a u64 of block time in milliseconds, big-endian, the length of its encoded reason as a big-endian u16 (the prefix that lets one value hold several reasons, each of which would otherwise run to the end), then the reason as above (types::encode_warnings). A warn rewrites the entry one warning longer; a clearWarnings deletes it, so a stored entry never holds fewer than one warning. A document removal is the document owner's id, the moderator's id, removed at as a u64 of block time in milliseconds, big-endian, the 32 bytes of the document hash, a tag byte (0: not restored, 1: restored) followed when restored by the restoring moderator's id and restored at as a u64, big-endian, then the reason the same way (types::encode_document_removal): 105 bytes before the reason, 145 once restored.

A removal record is replaced in place, as a suspension is, by the restore that marks it and by the deletion of a restored document, which writes a fresh record over the marked one; the deletion transform reads the record, billed, to know which of the two it writes, and a record that is not marked restored beside a live document is a state no transition produces. The replacement's flags follow GroveDB's flag merge like a suspension's: the restore adds forty bytes and passes the record, with the refund of its removal, to the restoring moderator, who pays for them; the fresh record of a second deletion is shorter and stays with whoever held it. A fee estimate prices a replacement as a fresh insert of the whole record, for the reason a suspension's does, and the records a write walks past are estimated as restored, the larger shape.

The document removals tree exists exactly when the contract has a document type whose moderators' deletions keep records (moderatorAbilities.delete without deleteKeepsRecord: false): a contract without one keeps the other tree, and the shape, it would have had. One subtree per such document type is created with the type, by insert_contract generation 2 or by update_contract generation 2 for a type an update adds, and the tree above them with the first: whether it is there is read off the stored contract, since an existing type never changes the keyword, so the update needs no read. Nothing is created lazily by the first removal. The key sorts below 128, as a key added later should: a contract that keeps both lists, the one whose other tree then holds four keys, still has the banlist on top. With fewer keys the version item is on top, and the list one level down.

The moderation action counts of an elected contract sit at 48: one item per member of the seated team who signed a counted action since the moderators pot was last settled, its identity id to its count as a big-endian u32 (see The seated team below). The tree is created with the contract, the only time elected moderation can be declared, so it is never made lazily. Each counted action reads its signer's count with one point read and writes it one higher: an insert of 36 bytes for the member's first action of a period, a replacement of the same size after. A settle reads the whole tree, at most one item per identity the team can hold (the leader, the elected members and the additions the target allows), and deletes every item. Per-member items keep the action, far more frequent than a settle, to a four-byte write; one packed item for the team would rewrite every member's count on every action. The items carry no storage flags: the member whose action writes one pays for it, the settle that deletes it refunds nobody, and a settle's fee result carries no refunds for anyone else. The key is below 64: created with two or three lists it leaves the banlist on top (with every list, where the version item and the suspension list alone had put the suspension list there), with or without the removal records tree; created with the banlist alone, or with the banlist and one other list beside removal records, it puts the version item on top and the banlist a level down. What sits there is read by the team's actions and by a settle, never by a document transition.

The contract's own subtree holds three keys whatever the contract keeps, so its Merk keeps 1, the documents, on top: every document proof and write goes through that key, and a fourth key beside it would have pushed it one level down (a Merk built from one sorted batch roots at the middle key). Everything else a contract keeps goes into 2, its other tree, which protocol version 14 introduces together with the version item. Inside, the keys are spread like the root tree's, so the tree stays balanced as it fills and the most read entry sits on top: the banlist at 128, read by every document transition on a moderated contract, the version item at 64, the suspension list at 192, the warning list at 224. A Merk built from one sorted batch roots at the middle key, the upper middle of an even count, so a key added later goes where it keeps 128 the median of the keys created together in the likely combinations: the warning list, which no document transition reads, sits above 192, which leaves the banlist on top for a contract keeping the banlist and a warning list, with or without the removal records tree at 16, and for one keeping all three lists with that tree; a contract keeping all three lists and no removal records has the suspension list on top and the banlist one level down.

The other tree is written by every contract insertion, and by the migration on the first block of protocol version 14 for the contracts stored before it. A contract update finds it there, so its fee estimate writes none; applied, the update reads key 2 once, billed, rather than fail inside a block: a tree is left alone (it may hold the lists), a missing one is written. The 4.2 betas kept the version item itself at key 2, before the other tree existed: an update of a contract that still holds that item puts the tree in its place and the item under it (add_contract_to_storage generation 1). Until such a contract is updated, the unproved getDataContractsLatestVersions reads its version from that item, and the proved form shows no version item for it.

The list trees are created by insert_contract generation 2 for a contract that declares them, and by nothing else: the lists are fixed at creation, so a contract update creates none and leaves the existing ones and their entries alone, and no tree is made lazily by the first ban. An entry's storage flags name the moderator that wrote it, so the storage refund of its deletion goes to that moderator whichever transition deletes it: the explicit unban or unsuspend, the ban over a suspension, or the document transition that sweeps a lapsed suspension. The sweep's processing fee is charged to the batch signer.

The writers, readers and provers live in packages/rs-drive/src/drive/contract/moderation/, versioned by DriveContractModerationMethodVersions. A suspend that replaces an entry is a batch_replace, because two operations on one key would fail the batch; so is a warn on an identity that already carries warnings, which rewrites the entry whole with the new warning last. The entry is then longer, so it passes, with the refund of its removal, to the moderator that warned last, who pays for the bytes the warning added; the same merge a longer suspension replacement goes through. The replacement brings its own reason, so the entry may change size: a longer replacement merges the flags as a document that changes hands does, the moderator that replaced it paying for the bytes it added and becoming the entry's owner, refunded when it is removed; a shorter or an equally long one stays the first moderator's, who is refunded the removed bytes at once and the rest on removal. A fee estimate prices a replacement as a fresh insert of the whole entry, because GroveDB's average-case replace assumes an item keeps its size and would price no storage for a longer reason; the entries a write walks past, and the one a delete removes, are estimated at a typical reason (128 bytes of text), not at the longest.

Reading and Proving

fetch_contract_moderation_status(contract, identity, lists) reads the identity's entry on each list named, the warning list included, and the verifier of its proof rebuilds the same merged path query from the same lists. The lists are the ones the contract's config declares; an undeclared list has no tree and cannot be queried, so a status query names the lists it wants and the node refuses one the contract does not keep. fetch_contract_moderation_entries pages one list in identity id order, bounded by the platform version's max_returned_elements (the default page size too, and the number the proof verifier assumes when a request names no limit), with the last identity as the cursor. A page shorter than its limit is the last one and carries no cursor.

The DAPI Queries

  • getContractModerationStatus(contract_id, identity_id, lists, prove): the identity's status on the lists named.
  • getContractModerationEntries(contract_id, list, start_after, limit, prove): one page of a list. An entry of the warning list carries every warning of the identity, and its reason is the latest warning's.
  • getContractDocumentRemovals(contract_id, document_type_name, document_ids | page, prove): the records of the documents moderators deleted, within one document type that carries moderatorAbilities.delete (no other keeps records, so the node refuses any other). By document ids, up to max_returned_elements of them and none twice: an id with no record is left out of the response, and proved absent by a proof. Or one page in document id order, with the last document id as the cursor. Each record carries the document hash and, once restored, who restored it and when. Drive::verify_contract_document_removals rebuilds the path query from the same request.

A status query answers for the lists it names and no others: Drive::verify_contract_moderation_status and the SDK result both return ContractModerationListStatuses, one ContractModerationListStatus per list queried, so a list that was not read is absent rather than reported as empty (banned() is None unless the banlist was queried). ContractModerationStatusQuery::for_contract names every list the contract keeps; the wasm-sdk does the same, fetching the contract, when the query names no list. Both have Fetch and FetchUnproved impls in the Rust SDK (platform::contract_moderation), wasm-sdk functions and contracts.moderationStatus / contracts.moderationEntries on the JavaScript SDK. The proof of a moderation transition's execution covers the lists the moderation touched and is classified as affected state: an earlier or later moderation leaving the same entries verifies just the same. A ban does two things, adds the ban and removes a suspension, so its proof covers every barring list the contract keeps (the banlist entry present, the suspension absent), which the prover and the verifier both read from the contract's config (so the SDKs fetch and cache the contract before broadcasting a ban, as they do for the contracts a document batch touches); an unban, a suspend, an unsuspend, a warn and a clearWarnings prove the one entry they edit. A warn's proof shows the entry with the transition's reason as its last warning (the block time is the block's, which the verifier does not know); a clearing's shows the entry absent. The result, VerifiedContractModerationListStatuses, holds one ContractModerationListStatus per list proved, never a full status: a list that was not proved is left unknown rather than reported as empty. An identity whose unsuspend was just proved may be banned; the status query answers that.

Fee Pots and the Claim

A moderation team can be paid. A document type may charge a fixed fee in credits for an action on its documents (the actionFees keyword, see Document action fees), split in two parts. The owner parts collect in the contract's owner pot, the moderators parts in its moderators pot.

[40] PreFundedSpecializedBalances (sum tree)
├── [64]  owner fee pots      (sum tree) -> <contract id> -> SumItem(credits)
├── [128] voting balances
└── [192] moderators fee pots (sum tree) -> <contract id> -> SumItem(credits)

[64] DataContractDocuments -> <contract id> -> [2] other
    ├── [32] last claim of the owner pot       Item(epoch u16 BE | time u64 BE | claimant id)   (after a claim)
    └── [96] last claim of the moderators pot  Item(epoch u16 BE | time u64 BE | claimant id)   (after a claim)

The pots are not under the contract. The per-block total credits check (calculate_total_credits_balance) sums a fixed set of root sum trees, and DataContractDocuments is a normal tree: credits parked under a contract would leave that sum and fail every block with CorruptedCreditsNotBalanced. PreFundedSpecializedBalances is one of the summed trees, so the pots live there, in two sum trees beside the voting balances, created at genesis (state structure 4) and by the upgrade to protocol version 14 through the same helper, one after the other, so that both node populations build the same Merk. A pot is created by the first fee it receives, and so is its tree on a chain that reached protocol version 14 on a build from before the pots: that first fee checks, with a billed read, that the tree is there. The estimation of a voting balance write moves to generation 1 with them, because the prefunded balances layer now holds three trees instead of one. The two last claims are plain items of the contract's other tree, below 128 so the banlist stays on top, written by the first claim and replaced by every later one. A last claim (ContractFeePotLastClaim) is 42 bytes: the epoch of the claim, which the next claim is judged against, the time of its block in milliseconds, and the id of the identity that signed it. The owner pot's claimant is always the owner; the moderators pot's is whichever member of the team claimed for all of them, so the team can see who paid them and when. Every last claim has the same size, so a replacement never changes the size of the item, and the item carries no storage flags: it is never removed, and no claim adds bytes for anyone to own.

The team that shares the moderators pot is the set of identities the contract appoints, the owner among them only when appointed, and the owner alone when nobody is appointed (ContractModerators::team). It is about earnings, not authority: an owner who is not appointed still may moderate. For an elected contract it is the interim's team until a charter is seated, and from then on the seated team, which shares the pot by its proposal's reward split (see The seated team below). ContractFeePot::recipients names who a payout of a pot goes to: the contract owner for the owner pot, the team for the moderators pot, nobody for the moderators pot of a contract that declares no moderation.

ContractFeeClaim (state transition type 25) names a contract and a pot and pays the pot out. It is signed with a CRITICAL authentication key under the signer's contract nonce, and the claimant pays its gas like any other transition.

StageCheckError
Transform (state, paid)the contract existsDataContractNotPresentError (10400)
the signer is a recipient of the pot: the owner for the owner pot, a member of the team for the moderators pot (the leader or an active member once a charter is seated)41113
the pot was not paid out in this epoch yet41111
every recipient gets at least a credit41112

The owner pot goes to the owner whole. The moderators pot is split equally between the team, and what the split leaves over, less than a credit per member, stays in the pot for the next claim, so no member is favoured by the order of the identity ids. A seated team's pot is split by its proposal's reward split instead, rounded down the same way. Each pot is paid out at most once per epoch and the two are independent: the owner's claim does not use up the team's, nor the reverse. A refused claim is paid for by a nonce bump and leaves the pot and its last claim alone. As for moderation, state validation is the transform, so the mempool refuses with the same codes as a block.

The team is read when the claim executes. An owner who changes the appointed set by a contract update and then claims pays the new set: that follows from the owner controlling the contract's config, and is not prevented. The claim credits every recipient's balance, which is why a named moderator must exist (41110): crediting a balance that is not there is an internal error.

The proof of a claim's execution shows the pot with its last claim and the balance of every recipient, which the prover and the verifier both read from the contract. When the claimant is not among them, it shows the claimant's balance alone: the claimant is then on a seated elected team, which the charter contract names and the contract does not, so neither side could list the other payees (ContractFeePot::claim_proof_identities). An interim team's claim, before a charter is seated, is proved with every recipient's balance as before. VerifiedContractFeeClaim carries the contract id, the pot, that last claim (epoch, block time, claimant), the credits left in the pot and the balances. A pot that was never claimed proves no claim; a later claim of the same pot verifies just the same, so the result is classified as affected state.

Reading the Pots

  • getContractFeePots(contract_id, prove): both pots of the contract, each with its credits and its last claim: the epoch and the block time it was paid out in, and the identity that claimed.

The query always reads both pots, so its proof is one fixed path query (Drive::contract_fee_pots_query) that the prover and Drive::verify_contract_fee_pots build alike, with nothing in the request to get wrong. A pot nothing was paid into yet has no element and reads as zero credits, and a pot never paid out has no last claim, which is not a claim in epoch 0: a pot can have been paid out in epoch 0, so the last claim is a message of its own on the wire, unset when there is none, and the JavaScript fields (lastClaimEpoch, lastClaimTimeMs, lastClaimantId) are absent together. The proof says nothing about the contract itself, only about what is stored under its id, so the node refuses the query for a contract it does not hold before it proves anything, and a client that needs to know the contract exists fetches it.

A recipient reads the pots to decide whether a claim is worth its gas: the credits are what it would pay, and a last claim epoch equal to the current epoch means the claim would be refused (41111). A member of the team also reads there which member last claimed for the team, and when. The Rust SDK has Fetch and FetchUnproved impls for ContractFeePots (platform::contract_fee_pots, queried by the contract id), the wasm-sdk getContractFeePots and contractClaimFees, and the JavaScript SDK contracts.feePots and contracts.claimFees.

The claim's proof is verified against the contract, which names who the pot pays, and the team can change by a contract update. So every client fetches the contract again before a claim instead of trusting a cached copy: ClaimContractFees in the Rust SDK, contractClaimFees in the wasm-sdk, and the wasm-sdk's generic broadcastAndWait for a ContractFeeClaim built by hand, which falls back to the cached copy when that fetch fails, because the transition is already broadcast by then.

Elected Moderation

A contract may hand the choice of its moderators to the network instead of keeping it: it declares that its moderators are a team elected by masternodes and evonodes. Teams apply with a charter in the moderation charters system contract (see docs/protocol/moderation-charters.md), masternodes elect one in a contest for the contract's seat, and the seated team moderates with the contract's declaration, charging at most what the contract declares. What the contract itself holds is the declaration, frozen at its creation, and the interim: how the contract is moderated until its first team is seated.

#![allow(unused)]
fn main() {
pub struct ElectedModerators {
    pub join_window: u32,                 // seconds; at most 4 weeks, at least 1 day on mainnet (0 elsewhere), 1 week by default
    pub vote_window: u32,                 // the same
    pub challenge_cool_down: Option<u32>, // Some = seat contestable, seconds, 2 weeks to 3 years; None = never contested again
    pub election_delay: Option<u32>,      // seconds after creation before the first charter; unbounded, none = at once
    pub max_added_moderators: u16,        // members the leader may add after the election; 0 to 15, 0 by default
    pub moderated_document_types: BTreeMap<DocumentName, BTreeSet<ModerationAbility>>, // per type: DeleteDocuments, Ban, Suspend, Warn, ChangeDocumentFields
    pub interim: InterimModerators,       // ContractOwner, AppointedModerators(set), NotYetUsable, NoModeration
    pub owner_protected: bool,            // false by default
}
}

The declaration lives in packages/rs-dpp/src/data_contract/config/moderation/elected.rs. On the wire it is the third $type of the moderators, flat: {"$type": "elected", "seatContestable": true, "challengeCoolDown": 1209600, "moderatedDocumentTypes": {"post": ["ban", "deleteDocuments"]}, "interim": {"$type": "notYetUsable"}}, or "seatContestable": false without a challengeCoolDown, with joinWindow, voteWindow, electionDelay, maxAddedModerators and ownerProtected optional. Its parts:

  • The election parameters are fixed once set (SystemLimits: max_contract_moderation_election_window_seconds caps both windows at four weeks, and on mainnet min_mainnet_contract_moderation_election_window_seconds keeps them at least a day; every other network takes a window of 0, so a test election can be run through in a block or two). The join window is how long applicants may join an election once the first one applied, the vote window how long masternodes then vote. The election delay is the one parameter the contract sets freely: how many seconds after its creation the first charter may be filed against it, the notice the contract gives before its first election can be called. It is optional and unbounded; left out, the election may be called at once. Because the declaration is made at the contract's creation and never changes, the creation is the declaration's own time. The charter contract's targetContractId reads it through the moderation: "electionOpen" requirement below. maxAddedModerators says how many members the leader of a seated team may add after the election, each one an identity that asked to join the team's proposal: additions ever filed, so a removal or a resignation frees no slot. It is 0 when left out, a team then being exactly what was elected, and at most SystemLimits::max_contract_moderation_added_moderators (15).
  • The seat is contestable or not, and the contract says which: seatContestable is required, with no default. A default of false would make every team permanent, leaving a contract nothing to do about a leader who lost its keys or went rogue, since the leader can not change and a challenge is the only remedy; a default of true would opt every contract into challenges without it asking. A contestable seat declares its challenge cool-down, how long a seated team is safe from a challenge after a seat change (min_contract_moderation_challenge_cool_down_seconds to max_contract_moderation_challenge_cool_down_seconds, two weeks to three years), and a seat that can not be contested declares none, since a cool-down means nothing there: a declaration missing the key, or seatContestable: true without the cool-down, or false with one, does not parse. In Rust the two are one field, challenge_cool_down: Option<u32> (ElectedModerators::seat_contestable is is_some), so a declaration can not disagree with itself however it is encoded. Nothing reads the seat yet: challenges come after protocol version 14, a challenge then being a new contest on the same byTargetContract index of the charter contract, allowed only when the target declares its seat contestable. The key is there now because the declaration is frozen at the contract's creation. Until challenges ship a seat is never contested again, whatever the key says.
  • The moderated set is the document types the team moderates, each with the abilities the seated team holds on it: non-empty, each type a document type of the contract, each ability set non-empty and backed by the contract (ban needs the banlist, suspend the suspension list, warn the warning list, deleteDocuments the type itself setting moderatorAbilities.delete, so deletions reach only such types, within their window, and changeDocumentFields the type listing moderatorAbilities.changeFields; and every type listing changeFields must be moderated with changeDocumentFields, since once a team is seated only it writes those fields). The charter of a team will say how those types are moderated, never which. The lists stay contract-wide: an ability on a type is what a team may do over the documents of that type. The set also bounds the interim block. A charter does not price the moderators part of an action: a type's own actionFees.moderators amount is the most a team may charge, a charter charges a share of it (the charter contract's business, not the declaration's), and the owner part stays what the type declares, immutable as before.
  • The interim says who moderates until a team is seated. ContractOwner and AppointedModerators(set) are the merged kinds, with their authority, their limit and their existence check (41110 at create): they moderate, they are protected, and they are the team that claims the moderators pot, all of it until a charter is seated and none of it after (see The seated team below). NotYetUsable names nobody: nobody moderates, nobody claims the pot (it accumulates for the team to come, ContractFeeClaimNotAllowedError for everyone), and the moderated document types can not be used. A contract that never attracts a team keeps those types unusable for good; the other types work as on an unmoderated contract. NoModeration names nobody too, with the moderated types usable meanwhile: nobody moderates and nobody claims the pot, and every type works as on an unmoderated contract until a team is seated.
  • The owner flag says whether the contract owner is protected from the team once one is seated, as the owner and the moderators of the merged kinds are (41102 on a ban, a suspension or a deletion of its documents). Not protected by default. During the interim the owner is protected whenever it moderates, flag or not: ContractModerationConfig::protects is what the moderation transition checks, and it is may_moderate or the flag for the owner.

Validation. validate_moderation_config v0 checks the declaration with the rest of the moderation config, from the raw document schemas of the create or update transition, and refuses with InvalidContractModerationConfigError (10900, unpaid): a window, or the cool-down of a contestable seat, outside its bounds, an empty moderated set, a moderated type the contract does not have, an empty ability set or an ability the type can not back, and an empty or oversized interim set. The create then checks that every interim moderator exists (41110), as it does for an appointed set.

The update. validate_config_update 2 refuses, with DataContractConfigUpdateError (40002), every change to the declaration, the interim included, and entering or leaving elected moderation. A contract that declares elected moderation is elected for good, and one that did not can not become so; the merged kinds still swap their moderators freely. An update may add document types; the declaration keeps naming the ones it named.

The interim block. The batch transformer's contract_moderation_gate v0 runs it before the lists: on an elected contract whose interim is NotYetUsable, every document transition of a moderated document type, deletions included (nothing of those types was ever written), is refused, paid, with ContractModeratedDocumentTypeNotYetUsableError (41200) and its contract nonce bump, in a block and in the mempool, until a charter is seated on the contract. Whether one is, is read (billed) only when a transition of the batch is on a type the block covers. The lists are read only for the transitions on the other types, and not at all when nothing is left. The interim moderators of the other two kinds moderate through the same transition, the same gate and the same claim as the merged kinds; a moderation transition against a NotYetUsable contract fails as by a non-moderator (41101).

The seated team. Seating writes nothing. A team applies with an electedCharter of the moderation charters contract, a create on its contested unique index byTargetContract, keyed by the target contract; awarding that contest writes the winner's document to the charter contract's storage, the only electedCharter ever written there for the target (contenders live in the contest, and in protocol version 14 a seat is never replaced, whether or not the declaration says it is contestable: another charter for a seated target is refused, paid, with DuplicateUniqueIndexError, 40105). So the charter seated on a contract is the one byTargetContract finds, and every moderation path reads it from there (execution/validation/state_transition/common/seated_moderation_charter in drive-abci): there is no block-end seating hook and no copy under the moderated contract. Its team is the charter's owner, the leader, plus the active members: its members less the memberId of every removedModerator for it, plus the memberId of every addedModerator for it (ElectedCharter::active_members), counting the documents that exist now, since the leader takes either back by deleting it. A resignationRequest changes nothing by itself; the leader acts on it by deleting the member's addition or removing an elected member.

  • Who moderates. Once a charter is seated, only its team moderates: the leader and the active members, each alone. The interim moderators, the owner among them, can no longer act (41101), whatever the interim was. Deciding whether the signer is on the team reads no list of it: the leader costs nothing beyond the charter lookup, an elected member one point read of its removal, anyone else one point read of its addition (both types are unique on the charter and the member). What the interim did stands: its bans, suspensions, warnings and removals, which the team may lift.
  • With what. The team holds the abilities the declaration gives it and no others. A deletion or a restore needs deleteDocuments on the document type, and a field change changeDocumentFields; a ban, a suspension or a warning, or lifting one, needs the ability on some moderated type, since the lists are contract-wide. Anything else is refused, paid, with ContractModerationAbilityNotGrantedError (41201).
  • Who is protected. The leader and the active members can be neither put on a list nor have their documents deleted (41102), and the owner too when the declaration sets ownerProtected. The interim moderators lose the protection they had.
  • How many join later. The leader adds members from the proposal's join requests, at most the target's maxAddedModerators at a time, counting the charter's additions that exist now: the leader takes an added member off by deleting its addition, which frees the slot. An elected member is taken off with a removedModerator, which may only name one of the charter's members and puts the member back when deleted. The schema cannot count documents, so the batch's state validation refuses the addition past the cap, paid, with ModerationCharterAddedModeratorLimitReachedError (41202), after reading the charter, its target and at most the cap's number of additions, all billed; additions an earlier create of the same batch was accepted for count too. Like a unique index conflict it is judged in the block, not in the mempool, which runs no state validation for a batch.
  • On what grounds. Every ban, suspension, warning, document deletion and field change of the team names, in its reason's reasonDocumentId, a reason document its proposal lists: the grounds it asked to be elected on. Any other is refused, paid, in a block and in the mempool (ModerationReasonNotListedError, 41203), a reason naming none included; a proposal listing no reason is a team that can take no such action. The proposal is read, billed, only when the reason names a document. Lifting a ban, a suspension or warnings and restoring a document carry no reason and are not checked, and the interim is not bound before the seating.
  • What it charges. An action on a moderated type may agree to the charter's moderatorsShare of the declared moderators part instead of the whole of it, and is then charged that (see Document action fees). An action agreeing to the declared amount reads no charter.
  • The pot. The interim team claims the moderators pot only until a charter is seated; its claim is refused after (41113), so the pot carries over to the seated team, unsettled. From then on the leader or an active member claims it for the team, at most once per epoch, and it is paid out by the proposal's rewardSplit: the leader share to the leader; the equal share in equal parts to the other active members, or to the leader when it has none; and the action share between the whole team, the leader included, in proportion to the bans, suspensions, warnings and document deletions each one signed since the last settle (not field changes, whose number on one document has no bound) (the counts of the other tree's key 48), or equally when nobody acted. Lifting and restoring do not count. Every share and every part rounds down to the credit; the few credits left stay in the pot for the next settle. A claim that would pay nobody a credit is refused (41112). The settle reads the team (the charter's removals and additions), the proposal and the counts, all billed, and deletes the counts.
  • Settled before every change. An addedModerator or removedModerator created or deleted pays the pot out to the team as it was first, the same way, and resets the counts: a removed member is paid for what it did, a new one shares nothing earned before it came, one coming back nothing earned while it was away. The settle ignores the once-per-epoch limit and is not a claim: it writes no last claim, and the team may still claim in the same epoch. It is an effect, never a refusal, judged by the batch's state validation once the change passed (the addition its cap too), which the mempool does not run; at most one per target contract per batch.

Referencing an elected contract. A document type that must point at a contract of this kind says so in its reference: "refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }. contractRequirements holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values or a bound: moderation: "elected", or moderation: "electionOpen", which also requires the contract's own election delay to have passed since its creation, or the contract to declare none (the delay between a contract's creation and the first charter against it, so a team cannot be seated before anyone has seen the contract, set by each contract for itself). Both have a user in the charter contract: a charter proposal only needs the target to be elected, so teams can form during the notice, and the charter that opens the contest needs its election electionOpen; minimumAgeSeconds, a number of seconds the reference fixes, which requires the contract's recorded creation time to be at least that far before the block time of the write; minimumSecondsSinceUpdate, the same of the later of the contract's creation and last update times (any update restarts the clock; an elected declaration can not be added by an update, so this one is for other uses than the charter); owner, "self" requiring the referenced contract to be owned by the writer of the referring document (its $ownerId, a write gate like the $ownerId property agreement of a document reference) and "other" by anyone else (so a charter may forbid an owner from chartering its own team); readonly: true, requiring the referenced contract's config to be read-only, one that can never be updated again (which makes minimumSecondsSinceUpdate moot for the same target); keepsHistory: true, requiring its config to keep history (only true is declarable for either flag); and ownerProtected, requiring the contract's elected moderation declaration to protect the owner from the team (true) or to leave it unprotected (false), which implies elected moderation without the schema having to say so, a contract without an elected declaration meeting neither value. A contract created before contracts recorded their creation time never meets a duration, its own election delay included. Consensus checks them when the referring document is written, against the contract it has already fetched for the existence check and the write itself (its owner and block time), so they cost no further read; a contract that exists but does not meet a requirement refuses the write, paid, with ReferencedContractRequirementNotMetError (40135) naming the requirement, where a contract that does not exist is still 40120. A replace re-checks them when it changes the reference. owner is the one requirement judged against the writer, and a transfer or a purchase changes the writer without any write, so on a document type whose documents can be transferred or traded a reference carrying owner is re-checked on every replace, touched or not, as the $ownerId writer gate is: after a transfer the new owner has to repoint it at a contract that meets the requirement for them, or clear it where it is optional. The reference is then checked whole, its other requirements included. Because the new owner must be able to repoint it, registration refuses such a reference held by an immutable property on a type whose documents can be transferred or traded (the property itself, inside an immutable object, or the elements of a typed array), with InvalidContractStructure. On a type whose documents stay with their owner the writer never changes and neither does a contract's owner, so nothing is re-checked. The other requirements are facts about the referenced contract, not the writer, and never bring a reference back on their own. A changed contractRequirements is an incompatible schema change on update, like the rest of a refersTo. The charter system contract's targetContractId is the first user.

Referencing an identity key with requirements. The same shape serves the key references the charter contract needs: "refersTo": { "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } }. keyRequirements holds what the referenced key must be beyond existing and not being disabled, each key an aspect of the key: purpose, the key's purpose by its wire name (authentication, encryption, decryption, transfer, voting or owner; never system), and boundTo, the name of a document type of the declaring contract, which requires the key's contract bounds to be exactly the declaring contract and that document type; a whole-contract bound or a contract group bound never meets it, even where the group holds the type, since the check reads nothing beyond the key. Registration (create_document_types_from_document_schemas 1, a post-pass edited in place since it is inert before protocol version 14, under full validation like the meta-schema) checks that boundTo names a document type the contract has, so the write-time check never needs a second contract fetch, and that a key meeting the pair can exist at all: only authentication, encryption and decryption keys carry a document type bound, and Drive registers an encryption or decryption key bound to a document type only when that type declares requiresIdentityEncryptionBoundedKey or requiresIdentityDecryptionBoundedKey, so a boundTo paired with transfer, voting or owner, or with an encryption purpose on a type without the matching keyword, is refused as a requirement no key could ever meet. Consensus checks the requirements when the referring document is written, against the key it has already fetched for the existence check, so they cost no further read; a key that exists and is enabled but does not meet one refuses the write, paid, with ReferencedIdentityKeyRequirementNotMetError (40136) naming the document type, the property, the requirement and what the key has, where a missing key is still 40123 and a disabled one 40124. A replace that repoints the reference at another key, through either the identity id or the key id, re-checks them. A changed keyRequirements is an incompatible schema change on update, like the rest of a refersTo. New requirements (a security level, say) are new keys of the same object, never a new reference type. The charter contract's joinRequest.recipientId (a decryption key bound to submittedCharter) is the first user.

What comes next. After protocol version 14, challenges of a contestable seat, amendments and threshold actions. Issue #4865 holds the design.

Versioning Touchpoints

All in place for protocol version 14: CONTRACT_VERSIONS_V6 makes config V2 the config of every new contract (max_version and default_current_version 2) and validate_config_update 2; STATE_TRANSITION_SERIALIZATION_VERSIONS_V3 and DRIVE_ABCI_VALIDATION_VERSIONS_V10 carry the transition's slots and batch_state_transition.contract_moderation_gate, and the contract update's basic structure moves to 2 to validate the declaration; DRIVE_CONTRACT_METHOD_VERSIONS_V4 bumps insert_contract to 2 and adds the moderation table (its update_contract 2 belongs to token distribution and does nothing for moderation); DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4 adds the converter slot and bumps documents_batch_transition to 1 for the sweep; DRIVE_VERIFY_METHOD_VERSIONS and DRIVE_ABCI_QUERY_VERSIONS gain their moderation tables; SYSTEM_LIMITS_V4 gains max_contract_moderators, max_contract_suspension_until, max_contract_moderation_reason_length and max_contract_warnings_per_identity. The warning list adds two slots to DriveContractModerationMethodVersions (add_contract_warning, remove_contract_warnings) and nothing else: the list, the two actions and the two errors join a feature no release contains.

The document deletion adds, all for protocol version 14 as well: the moderatorAbilities.delete keyword in meta-schema v3 (CONTRACT_VERSIONS_V6 already selects it); five slots in DriveContractModerationMethodVersions and one in the verify and query tables; and batch_operations.apply_drive_operations = 1 in DRIVE_VERSION_V9, the generation that forfeits the refund. The transition's own tables do not move: the action joins a transition no release contains.

The field change moves no table either: the action, the transform, the converter, the prover's and the verifier's arms sit in generations only protocol version 14 selects, and the batch transformer's gate on an owner's create or replace (v0, edited in place) reads nothing for a type that keeps no such field, which no earlier version can register. The uniqueness check a restore and a field change share is validate_moderated_document_uniqueness in DriveDocumentIndexUniquenessMethodVersions, still 0, and takes the changed fields.

Elected moderation moves no table: the declaration is a variant of the same config V2, validate_moderation_config v0 and validate_config_update 2 take it on in place while protocol version 14 is unreleased, contract_moderation_gate v0 runs the interim block, and SYSTEM_LIMITS_V4 gains the four bounds of the windows and the cool-down. The seated team moves none either: the moderation transition's state v0, the claim's, the gate v0 and the batch transformer's state v2 read the charter in place, all generations no release selects; the cap on additions is a hook in the batch's shipped validate_state v0 that only a create of the charter contract reaches, a contract absent from state before protocol version 14. The pot, the counts and the reasons add four slots to DriveContractModerationMethodVersions (set_contract_moderation_action_count, fetch_contract_moderation_action_counts, remove_contract_moderation_action_counts and their estimation), 0 at every version. The forced settle is a second hook in the same validate_state v0, reached only by a create or delete of the charter contract's team changes; the claim's action, the moderation transition's action and the batch action carry what they settled into converters no release selects (contract_fee_claim_transition 0, contract_user_moderation_transition 0, documents_batch_transition 1); and the claim's arm of the shipped prove_state_transition v0 and verify_state_transition_was_executed_with_proof v0 proves the claimant alone for an elected contract, a transition no earlier version admits.

What Is Not There Yet

Deleting indexOnly documents (the action would have to carry the owner and the values), deleting every document of an identity at once, action fees on token transitions, group-based moderators (AuthorizedActionTakers::Group through group actions), keys bound to the contract allowed to sign its moderation, ban codes declared by the contract (the reason's code is where they will go), the moderator's id on a ban or a suspension (a warning carries its block time but not who issued it), retracting one warning rather than all, a warning that expires by the clock, a contract-declared strike count that turns warnings into a suspension, a query of the moderation action counts (a client reads them with a raw GroveDB proof today), challenges and amendments of a seated charter, and the Swift and Kotlin SDKs. The refusal a barred identity receives (41107, 41108, 41114) does not repeat the reason: the status query does.

Tests

  • packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderator_abilities_tests.rs: the object's shape on both paths, delete's rules and the window's (it needs delete and a clock, $updatedAt or for documents that never change $createdAt), and every rule changeFields holds its properties to, the revision it keeps included; validate_update/v1: the abilities frozen across updates.
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/moderator_fields.rs: a moderator's change stored, proved and paid for, a null removing a field, a report resolved after its post was deleted, every refusal of the transform, the create and replace gate for owners who do not moderate, a replace built before the change refused on its revision, and the unique indexes over the fields; seated_team.rs: a seated team writing the fields the declaration gives it changeDocumentFields on, and its members, not the off-team owner, writing them in their own documents.
  • packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs: the trees created with the contract and with a document type an update adds, records written, replaced, read by ids and by page with proofs that verify to the same, the bounds of a read, estimate against applied cost, and a moderator's deletion refunding nobody where the author's own refunds the author.
  • packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals: the query by ids and by page, its proof read back by the verifier, and every request it refuses.
  • packages/rs-dpp/src/data_contract/config/moderation/mod.rs and config/methods/validate_update/v2: the declaration's rules and the update rules, the elected declaration's among them (every bound, the seat and its cool-down, the moderated set, the envelope, the maximums, the interim set, the wire shape, and an update refused for each field and for entering or leaving); moderation/elected.rs: what each interim kind allows.
  • packages/rs-drive/src/drive/contract/moderation/tests.rs: tree creation on insert, the trees and their entries surviving a contract update, every writer with estimation, status and page proofs, paging, the refund going to the first moderator after another one replaces its suspension, a status proof over one list saying nothing about the other, the warning list tree created only when declared, warnings accumulating under the moderator that warned last and cleared with a refund to it, a warn never estimated below its cost up to the fullest entry, and the banlist on top of the other tree with every combination of lists.
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs: the gate is silent before protocol version 14 and for an unmoderated contract, refuses each barred operation of one batch on its own while keeping the deletions, and blocks the moderated types of an elected contract in its interim without reading the lists for them.
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team/pot.rs: a seated team's claim split by its reward split with every part rounded down and the remainder left in the pot, the equal split of the action share when nobody acted, the counts per signer (counted actions only, not the interim's) reset by a claim and by a change of the team, the settle before an addition, before a removal and before either is undone (in an epoch already claimed, and leaving the epoch's claim to the team), and the claim's proof with the claimant's balance; seated_team/reasons.rs: a seated team's bound action refused without a listed reason in a block and in the mempool, a proposal with no reason, reversals and the interim unbound; packages/rs-dpp/src/moderation_charter/reward_split.rs: the split's arithmetic; packages/rs-drive/src/drive/contract/moderation/action_count_tests.rs: the counts tree created with an elected contract only, written, read, bounded and reset, and the banlist on top of an elected contract's other tree.
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs: a contest for an elected contract's seat awarded, then the leader and the members moderating instead of the interim, another charter for the seated target refused whether or not its seat is contestable, additions and removals, the protection of the team, the cap on additions, abilities the declaration does not give, the interim block ending, a discounted fee charged and read where the declared one reads nothing, every other discount refused in a block and on recheck, and the interim's claim refused once a charter is seated.
  • packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs: the whole pipeline, including a warned user carrying on with its warnings accumulating, proved and cleared, the warning limit and the clearing that lifts it, warnings kept through a ban and out of the gate and the ban's proof, every refusal of a warn, and the warning list fixed at creation; the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, measured from $createdAt on a type that never changes, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, inactivity of the transition and of a moderated contract create or update before protocol version 14, and elected moderation (each interim kind moderating or not, the block of a moderated type next to an unmoderated one in a block and in the mempool, a create refused outside a bound and for an unknown type, and an update refused for a changed field and for entering or leaving).