From c26e3bba832d8e1f2f2afe8e43d3cae9a9a2bbc3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 12:14:34 +0700 Subject: [PATCH 1/2] feat(platform)!: deletable charter team changes and lookups on deletableDocument references (PV14) addedModerator and removedModerator are deletable: deleting an addition takes the member off and frees its maxAddedModerators slot, deleting a removal puts the elected member back. A removal names an elected member only (a listElement into the charter's members), so the seat check is one point read and the cap counts the additions that exist. refersTo takes a lookup on a deletableDocument reference (DeletableDocumentLookup, appended variant 10): the document exists now, every replace re-validates it, an immutable property may not hold it, and it is an expression operand and an ownerRefersTo target (never creatorRefersTo). The resignation request's added-member operand uses it. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/contract-moderation.md | 6 +- book/src/data-model/documents.md | 32 ++--- docs/protocol/moderation-charters.md | 51 ++++--- packages/js-evo-sdk/README.md | 2 +- .../moderation-charters-contract/README.md | 18 +-- ...oderation-charters-contract-documents.json | 19 ++- .../unit/moderationChartersContract.spec.js | 21 ++- .../document/v3/document-meta.json | 40 ++++-- .../config/moderation/elected.rs | 4 +- .../v1/mod.rs | 19 ++- .../class_methods/try_from_schema/mod.rs | 83 ++++++++---- .../class_methods/try_from_schema/v3/mod.rs | 39 ++++-- .../v3/owner_reference_tests.rs | 72 +++++++++- .../v3/reference_expression_tests.rs | 48 ++++++- .../v3/reference_lookup_tests.rs | 84 +++++++++--- .../document_type/index/preallocation.rs | 4 +- .../document_type/property/mod.rs | 79 ++++++++++- ...ter_added_moderator_limit_reached_error.rs | 2 +- .../src/errors/consensus/state/state_error.rs | 13 ++ packages/rs-dpp/src/moderation_charter/mod.rs | 19 +-- .../rs-dpp/src/moderation_charter/tests.rs | 2 +- packages/rs-dpp/src/system_data_contracts.rs | 42 +++++- .../common/seated_moderation_charter/mod.rs | 60 ++++---- .../document_reference_validation/v0/mod.rs | 11 +- .../batch/state/v0/added_moderator_cap.rs | 5 +- .../batch/tests/document/owner_reference.rs | 118 +++++++++++++++- .../tests/seated_team.rs | 128 +++++++++++++++--- .../v0/mod.rs | 2 +- ...e-validation-contract-owner-refers-to.json | 76 +++++++++++ .../src/query/chained_document_query/mod.rs | 3 +- .../src/query/composite_document_query/mod.rs | 3 +- .../rs-platform-version/src/version/v14.rs | 56 +++++--- .../src/platform/moderation_charters/mod.rs | 2 +- .../platform/moderation_charters/readers.rs | 49 +++++-- .../src/platform/moderation_charters/team.rs | 13 +- .../data_contract/document_type_reference.rs | 49 +++++-- .../unit/DocumentPropertyReference.spec.ts | 27 ++++ packages/wasm-sdk/src/moderation_charters.rs | 5 +- 38 files changed, 1031 insertions(+), 275 deletions(-) diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 35486131ac2..30efbb97ee3 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -294,12 +294,12 @@ The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/el **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). 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`, and the `memberId` of every `addedModerator` for it, less the `memberId` of every `removedModerator` for it (`ElectedCharter::active_members`). A `resignationRequest` changes nothing by itself; the leader acts on it with a removal. +**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). 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 a point read of its addition and, when there is one, of its removal (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. +- **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; 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`, counting the additions ever filed for the charter, so a removal frees no slot. 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. +- **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. - **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](../fees/overview.md#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. How the seated team claims it, split by its proposal's `rewardSplit`, is not built yet. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index cbb6dd57aa7..4eb5c30bc0c 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -313,7 +313,7 @@ A `permanentDocument` reference normally holds the referenced document's id. It reads: every member must be the owner of a `joinRequest` whose `submittedCharterId` equals this document's `submittedCharterId`. Without `lookup` the list would have to hold the join requests' ids, which the writer would have to find first, and which say nothing about who asked to join. The same form works on a scalar identifier property, where `"."` is the property's own value. -A `deletableDocument` reference takes no `lookup`. Once the document a key found is deleted, a new document with the same key would make the reference resolve again, to different content, where an id is produced at most once and a dead id reference stays dead. +A `deletableDocument` reference may carry a `lookup` too, and then promises less than its id form. Once the document a key found is deleted, a new document with the same key makes the reference resolve again, to different content, where an id is produced at most once and a dead id reference stays dead. So a deletable lookup means "a document with this key exists now", which is what a membership gate needs: the moderation charters' `resignationRequest` requires its writer to have an `addedModerator` for the charter now, and the leader takes an added member off by deleting that document. Every replace re-validates it, as it does a `deletableDocument` reference by id; an immutable property may not hold one, since the clearing a dead id reference allows reads an id, which a key is not; and it may be an operand of a reference expression and the target of an `ownerRefersTo` (see below). `index` names an index of the referenced document type. `keys` maps every property of that index, by its name on the referenced side (system ones such as `$ownerId` included), in any order, to where its value comes from on the referring side: @@ -323,17 +323,17 @@ A `deletableDocument` reference takes no `lookup`. Once the document a key found What is checked when the contract enters the chain, on registration and on update: -- `lookup` is only allowed on `permanentDocument` references (meta-schema v3 and the parser, `apply_property_reference` 0), on the property or on the `items` of a typed array. +- `lookup` is only allowed on `permanentDocument` and `deletableDocument` references (meta-schema v3 and the parser, `apply_property_reference` 0), on the property or on the `items` of a typed array. - Each property a key reads must exist on the referring type, be required (and so must every object around it), not be transient nor sit inside a transient object, and hold a single value, so a lookup never runs with a missing key part and a reader can assemble the same key from the stored document. A key that reads `"$ownerId"` needs a referring type whose documents can be neither transferred nor traded: the reference is judged when the document is written, and a transfer or purchase would move the writer part of its key without a write. These are properties of the referring type alone and are checked on every parse (generation 3). - The index must exist and be unique, so the key finds at most one document; it may not bucket a timestamp with `timeRange`, and the referenced type may not be `indexOnly`. `keys` must cover each property of the index exactly once and nothing else, and each source must hold the same kind of value as the index property it fills (the rule of `propertyAgreement`, `DocumentPropertyType::value_kind`). - The key must stay with the document it found, or the reference could dangle without the document being deleted: every schema property of the index must be fixed once written (the referenced type is immutable, or the property, or the top-level object holding it, is listed under `immutable`), `$ownerId` is only a key part on a type whose documents can be neither transferred nor traded, and the update and transfer times are refused where a replace, transfer or purchase moves them. `$id`, `$creatorId` and the creation times are always fixed. - A changed, added or removed `lookup` is an incompatible schema change on update, like the rest of a `refersTo`. -The checks on the referenced type run where that type is in hand. For a document type of the same contract the contract parse runs them under full validation (`create_document_types_from_document_schemas` 1, next to the `keyRequirements.boundTo` check), once every document type is parsed; a deletable target is left to registration, which refuses it for the `permanentDocument` reference (40122). For a type of another contract (`contractId`) the registration state validation runs them against that contract, where the other `refersTo` checks into another contract run, and refuses a declaration that cannot resolve with `ReferencedDocumentLookupInvalidError` (state code 40137). Index definitions cannot change on a contract update from protocol version 14 (`validate_update` 1 compares them by name), and neither can the flags the permanence rule reads, so the answer holds. +The checks on the referenced type run where that type is in hand. For a document type of the same contract the contract parse runs them under full validation (`create_document_types_from_document_schemas` 1, next to the `keyRequirements.boundTo` check), once every document type is parsed; a target of the wrong kind is left to registration, which refuses a deletable type for a `permanentDocument` lookup (40122) and one that forbids deletion for a `deletableDocument` lookup (40131). For a type of another contract (`contractId`) the registration state validation runs them against that contract, where the other `refersTo` checks into another contract run, and refuses a declaration that cannot resolve with `ReferencedDocumentLookupInvalidError` (state code 40137). Index definitions cannot change on a contract update from protocol version 14 (`validate_update` 1 compares them by name), and neither can the flags the permanence rule reads, so the answer holds. -When the referring document is created or replaced, the document reference validation assembles the key for each value and queries the index for at most one document, billed as a document fetch of the same kind as the id lookup (`fetch_document_through_lookup`). No document, or a key it cannot assemble, refuses the write, paid, with `ReferencedEntityNotFoundError` (40120) naming the property, or the element by its list path (`members[1]`); its target reads "found through unique index ``". A `propertyAgreement` beside the `lookup` is checked against the document the index found, exactly as for an id reference. A replace re-validates the reference when the property itself changed (for a list, the elements the stored list did not hold), and every value, every element included, when a property a key reads changed. Nothing else can move a key part: the writer is fixed on a type allowed to read it, and the referenced side's key is fixed by the rule above, so a validated lookup reference never dangles. +When the referring document is created or replaced, the document reference validation assembles the key for each value and queries the index for at most one document, billed as a document fetch of the same kind as the id lookup (`fetch_document_through_lookup`). No document, or a key it cannot assemble, refuses the write, paid, with `ReferencedEntityNotFoundError` (40120) naming the property, or the element by its list path (`members[1]`); its target reads "found through unique index ``". A `propertyAgreement` beside the `lookup` is checked against the document the index found, exactly as for an id reference. A replace re-validates a `permanentDocument` lookup when the property itself changed (for a list, the elements the stored list did not hold), and every value, every element included, when a property a key reads changed. Nothing else can move a key part: the writer is fixed on a type allowed to read it, and the referenced side's key is fixed by the rule above, so a validated permanent lookup never dangles. A `deletableDocument` lookup is re-validated on every replace, since the document it found may be gone. -Joins cannot go through a lookup reference: a chained query or a composite by-id join needs the join property's values to be the outer documents' ids, so both refuse such a property, and a `preallocated` index cannot be bound through one. In Rust the declaration is its own variant, `DocumentPropertyReferenceTarget::PermanentDocumentLookup`, appended to the enum rather than a field of `PermanentDocument`: the enum is embedded in the reference errors, so an id reference keeps its encoding, and code matching `PermanentDocument` as "the value is a document id" cannot mistake a lookup for one. The rules are on `DocumentReferenceLookup`. `as_document_reference` returns only references whose value is a document id, the accessor for joins; the validators use `as_any_document_reference`, whose declaration carries the lookup. +Joins cannot go through a lookup reference: a chained query or a composite by-id join needs the join property's values to be the outer documents' ids, so both refuse such a property, and a `preallocated` index cannot be bound through one. In Rust the declaration is its own variant, `DocumentPropertyReferenceTarget::PermanentDocumentLookup` (and `DeletableDocumentLookup` for the deletable form, appended after it), rather than a field of `PermanentDocument`: the enum is embedded in the reference errors, so an id reference keeps its encoding, and code matching `PermanentDocument` as "the value is a document id" cannot mistake a lookup for one. The rules are on `DocumentReferenceLookup`. `as_document_reference` returns only references whose value is a document id, the accessor for joins; the validators use `as_any_document_reference`, whose declaration carries the lookup. ### Reference expressions (`anyOf`, `allOf`) @@ -369,7 +369,7 @@ reads: the member was added to the charter, or it is an identity that asked to j What is checked when the contract enters the chain: - On every parse (meta-schema v3 and the parser, `apply_property_reference` 0): a combinator is the declaration's one key (a `propertyAgreement` or a `lookup` belongs to a leaf, inside it), a list names at least two operands (a single one is declared on its own), and an `anyOf` directly inside an `anyOf` (or an `allOf` inside an `allOf`) is refused, since it says what one flat list says. -- Every leaf is an `identity` or a `permanentDocument` (by id or with a `lookup`). Both are existence checks against entities that are never deleted, so an expression of them holds for good once it holds, as a single one of them does, and a replace re-validates it only when its value or a property one of its leaves binds changed. The other types do not compose with other operands and are refused, as is the key id form (`identityProperty`): `deletableDocument` is re-validated on every replace and may be cleared once its document is deleted (the immutable-property exception), which assumes the property refers to that one target; `identityPublicKey` pairs the value with a key id property no other operand reads; a `contract` target's requirements are gates judged against the block time and the writer rather than an existence check, and a contract or token id is never also an identity or document id. Admitting one later takes a new `apply_property_reference` generation. +- Every leaf is an `identity`, a `permanentDocument` (by id or with a `lookup`), a `listElement` or a `deletableDocument` with a `lookup`. The first three are existence checks against entities that are never deleted, so an expression of only them holds for good once it holds, as a single one of them does, and a replace re-validates it only when its value or a property one of its leaves binds changed. A deletable lookup leaf may find nothing later, so an expression holding one is re-validated on every replace, and an immutable property may not hold it. The other types do not compose with other operands and are refused, as is the key id form (`identityProperty`): `deletableDocument` by id is re-validated on every replace and may be cleared once its document is deleted (the immutable-property exception), which assumes the property refers to that one target; `identityPublicKey` pairs the value with a key id property no other operand reads; a `contract` target's requirements are gates judged against the block time and the writer rather than an existence check, and a contract or token id is never also an identity or document id. Admitting one later takes a new `apply_property_reference` generation. - Under full validation (registration): at most `SystemLimits::max_reference_operands` operands in one list and at most `max_reference_expression_depth` combinators on any path from the declaration to a leaf (4 and 4 at protocol version 14; the example above is 2 deep), no two alike operands in one list (a leaf naming the declaring contract explicitly is the same as one omitting it), and every leaf counted against `max_references_per_document`: an `anyOf` of two on a typed array of `maxItems` 15 counts 30, since each leaf may be read for each element. - Every leaf is checked exactly as the same target declared alone: the referenced document type, its permanence, the `propertyAgreement` sides and the `lookup` rules, at the same places (the contract parse for a type of the same contract, the registration state validation for another contract's). Every leaf must pass, since each has to be a declaration that could hold. An error names the failing leaf by where it sits, `refersTo anyOf[1].allOf[1] lookup: ...` from the parse and `resignation.memberId.anyOf[1].allOf[1]` from registration. - A changed expression (an operand added, removed, changed or moved, `anyOf` swapped for `allOf`, a single target turned into an expression or back) is an incompatible schema change on update, like the rest of a `refersTo`. Inside `refersTo`, `anyOf` and `allOf` are the declaration's data; the schema compatibility rules never read them as JSON Schema keywords. @@ -440,7 +440,7 @@ A property's reference constrains a value the writer chose. Some rules constrain "resignationRequest": { "type": "object", "ownerRefersTo": { - "type": "permanentDocument", + "type": "deletableDocument", "documentType": "addedModerator", "lookup": { "index": "byElectedCharterMember", @@ -451,9 +451,9 @@ A property's reference constrains a value the writer chose. Some rules constrain } ``` -reads: the writer must be the `memberId` of an `addedModerator` for this document's `electedCharterId`. +reads: the writer must be the `memberId` of an `addedModerator` for this document's `electedCharterId`, one that exists when the request is written (the leader takes an added member off by deleting it). -- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), but only three targets can hold a writer: `identity`, a `permanentDocument` found through a `lookup`, and a `listElement` (the writer an element of the list, see [An element of a list](#an-element-of-a-list-listelement)), alone or as the leaves of a reference expression (above). The rest are refused, as a leaf of an expression too. `contract`, `token` and a document by id would need the writer's identity id to be a contract, token or document id, which it never is, so a document type declaring one could never be written; `identityPublicKey` pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and admits only those forms; the parser (generation 3, `parse_owner_reference`, which reads the stored schema once the core parse has run the meta-schema) refuses the others on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. +- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), but only these targets can hold a writer: `identity`, a `permanentDocument` or a `deletableDocument` found through a `lookup`, and a `listElement` (the writer an element of the list, see [An element of a list](#an-element-of-a-list-listelement)), alone or as the leaves of a reference expression (above). The rest are refused, as a leaf of an expression too. `contract`, `token` and a document by id would need the writer's identity id to be a contract, token or document id, which it never is, so a document type declaring one could never be written; `identityPublicKey` pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and admits only those forms; the parser (generation 3, `parse_owner_reference`, which reads the stored schema once the core parse has run the meta-schema) refuses the others on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. - Only a document type whose documents can be neither transferred nor traded may declare it, checked on every parse. A transfer or a purchase is not a write, so it would hand the document to an owner the declaration never checked; with neither possible, the owner of every document is the writer that was checked. - In a `lookup`, `"."` is the writer, and a `"$ownerId"` key part is the writer as well. Every referring-side rule of a property's lookup applies unchanged (its `"$ownerId"` rule holds by the point above), and so does every referenced-side rule, for a type of the same contract at contract level and for one of another contract at registration. - A `propertyAgreement` works as on a property reference; its referring side may name `$ownerId`, which is then the same writer as the reference's value. @@ -462,25 +462,23 @@ reads: the writer must be the `memberId` of an `addedModerator` for this documen - Adding, removing or changing it is an incompatible schema change on update (`validate_schema_compatibility` 1 freezes the keyword as the shared rule set freezes `refersTo`). - Every validator, the reference bound and the client bindings enumerate a type's references through `DocumentTypeRef::reference_declarations`, which yields the owner or creator reference first, as `ReferenceHolder::Owner` or `ReferenceHolder::Creator`, then each property's, so none can skip it. -A document type whose documents can be transferred or traded declares `creatorRefersTo` instead: the same declaration, whose value is the document's `$creatorId`, its creator, which a transfer or a purchase never changes. A marketplace item that only a seated moderator may mint, and anyone may then own, reads: +A document type whose documents can be transferred or traded declares `creatorRefersTo` instead: the same declaration, whose value is the document's `$creatorId`, its creator, which a transfer or a purchase never changes. A marketplace item that only an elected moderator may mint, and anyone may then own, reads: ```json "moderatorBadge": { "type": "object", "transferable": 1, "creatorRefersTo": { - "type": "permanentDocument", - "documentType": "addedModerator", - "lookup": { - "index": "byElectedCharterMember", - "keys": { "electedCharterId": "electedCharterId", "memberId": "." } - } + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" }, "properties": { "electedCharterId": { "...": "..." } } } ``` -- It takes the same three targets, with `"."` the creator in a lookup, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` key part in its lookup is refused, as in a property's lookup on such a type, since the owner moves. +- It takes the same targets but a `deletableDocument` (the creator never changes, and a document a transfer handed on could not be replaced once the one a lookup found is deleted), with `"."` the creator in a lookup, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` key part in its lookup is refused, as in a property's lookup on such a type, since the owner moves. - When a document is created its creator is the writer; on a replace, the value is the stored creator, whoever writes, and the replace rules are those of its target, as for the owner reference. A transfer or a purchase needs no check. A failure is the target's error at the path `$creatorId`, and registration names the declaration `.$creatorId`. An `identity` target reads nothing: the creator existed when it wrote the document, and an identity is never removed. It counts one against `max_references_per_document`, and a change to it is an incompatible schema change on update. ## Immutable Properties on Mutable Document Types diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index c8aa0fa901d..c3116865fe7 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -22,9 +22,12 @@ for that target, so the charter seated on a contract is the one - Document types: `reason`, `submittedCharter`, `joinRequest`, `electedCharter`, `addedModerator`, `removedModerator`, `resignationRequest` -Every type is immutable, and every type but `resignationRequest` is -undeletable, so each document another one refers to stays exactly as it was -when it was referred to. Additional properties are rejected on every type. +Every type is immutable. `reason`, `submittedCharter`, `joinRequest` and +`electedCharter` are undeletable, so each document another one refers to +permanently stays exactly as it was when it was referred to. The three team +changes are deletable: `addedModerator` and `removedModerator`, which the +leader takes back by deleting them, and `resignationRequest`, which its writer +withdraws. Additional properties are rejected on every type. ## The flow @@ -39,9 +42,10 @@ when it was referred to. Additional properties are rejected on every type. asked to join. That create opens or joins the contest for the target. 5. After the election the leader may add members from the same join requests, - up to the target's `maxAddedModerators`, and remove members. A member asks - to leave with a resignation request, which the leader acts on with a - removal. + at most the target's `maxAddedModerators` at a time, and take them off again + by deleting the addition; an elected member is taken off with a removal, + and put back by deleting it. A member asks to leave with a resignation + request, which the leader acts on. The seated team acts with the target contract's whole elected moderation declaration: every document type and ability it lists. A team narrows what it @@ -49,10 +53,13 @@ acts on only through the reasons its proposal lists, since every action names one. There are no powers: any one member acts alone. The team that acts is: ``` -leader + (electedCharter.members + addedModerator.memberId) - - removedModerator.memberId +leader + (electedCharter.members - removedModerator.memberId) + + addedModerator.memberId ``` +where both lists are the documents that exist now, and a removal can only name +one of the charter's `members`. + ## `reason` A ground for a moderation action. @@ -136,20 +143,22 @@ the contest until it is awarded, so this index lists seated charters only. All three types refer to an `electedCharter`. A reference finds a document in the type's own storage, and only a winner is ever written there (contenders live in the contest), so these documents can only name a seated charter. Each is -unique on the charter and the member, so it is written at most once per member -at a time, and a removal is final. +unique on the charter and the member, so a member has at most one of each at a +time, and each can be deleted: an addition to take the member off, a removal to +put an elected member back, a request to withdraw it. | Type | Properties | Rules | | --- | --- | --- | | `addedModerator` | `electedCharterId`, `submittedCharterId`, `memberId` | `electedCharterId` carries `propertyAgreement: { "$ownerId": "$ownerId", "submittedCharterId": "submittedCharterId" }`: only the leader adds, and `submittedCharterId` is the charter's proposal. `memberId` refers to a `joinRequest` through the same `lookup` as `members` and is `distinctFrom: "$ownerId"`: an addition needs the member's consent, disclosed on the proposal | -| `removedModerator` | `electedCharterId`, `memberId` | Only the leader removes (`propertyAgreement: { "$ownerId": "$ownerId" }`); no resignation is needed; `memberId` is `distinctFrom: "$ownerId"` | -| `resignationRequest` | `electedCharterId`, `recipientId`, `recipientKeyId`, `senderKeyId`, `encryptedMessage` | The owner is the member asking to leave, and must be on the team: `ownerRefersTo: { "anyOf": [...] }` requires the writer to be an element of the elected charter's `members` (`listElement`, the charter found by `electedCharterId` through `propertyAgreement: { "electedCharterId": "$id" }`) or the `memberId` of an `addedModerator` for that charter (a `lookup` through `byElectedCharterMember`, `"."` the writer). The leader is in neither list, so it cannot file one. The message is encrypted to the leader (`propertyAgreement: { "recipientId": "$ownerId" }` on `electedCharterId`) with the leader's decryption key bound to `submittedCharter` and the member's encryption key bound to `joinRequest`, the keys join requests use. A request changes nothing by itself: the leader acts on it with a `removedModerator`. It is the only deletable type: deleting it withdraws the request, and nothing refers to it | +| `removedModerator` | `electedCharterId`, `memberId` | Only the leader removes (`propertyAgreement: { "$ownerId": "$ownerId" }`); no resignation is needed; `memberId` must be one of the charter's elected `members` (`refersTo: { "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "members" }`, 40120 otherwise): an added member is taken off by deleting its addition. Deleting a removal puts the member back | +| `resignationRequest` | `electedCharterId`, `recipientId`, `recipientKeyId`, `senderKeyId`, `encryptedMessage` | The owner is the member asking to leave, and must be on the team: `ownerRefersTo: { "anyOf": [...] }` requires the writer to be an element of the elected charter's `members` (`listElement`, the charter found by `electedCharterId` through `propertyAgreement: { "electedCharterId": "$id" }`) or the `memberId` of an `addedModerator` for that charter (a `deletableDocument` reference with a `lookup` through `byElectedCharterMember`, `"."` the writer: the addition must exist when the request is filed). The leader is in neither list, so it cannot file one. The message is encrypted to the leader (`propertyAgreement: { "recipientId": "$ownerId" }` on `electedCharterId`) with the leader's decryption key bound to `submittedCharter` and the member's encryption key bound to `joinRequest`, the keys join requests use. A request changes nothing by itself: the leader acts on it by deleting the member's addition, or with a `removedModerator` for an elected member. Deleting it withdraws the request, and nothing refers to it | **The cap on additions.** The target contract's elected declaration carries -`maxAddedModerators`: how many members a seated team's leader may add, 0 when -left out and at most `SystemLimits::max_contract_moderation_added_moderators` -(15). It counts additions ever filed against a charter, so a removal frees no -slot. The schema cannot count documents, so a consensus rule refuses an +`maxAddedModerators`: how many members a seated team's leader may have added +at a time, 0 when left out and at most +`SystemLimits::max_contract_moderation_added_moderators` (15). It counts the +charter's additions that exist now, so deleting one frees its slot. The schema +cannot count documents, so a consensus rule refuses an addition over the cap, paid, with `ModerationCharterAddedModeratorLimitReachedError` (41202): the batch's state validation reads the charter, its target and at most the cap's number of additions, all billed, once the addition's own references @@ -193,9 +202,8 @@ replaced). The moderation paths of the target read it: (`IdentityNotContractModeratorError`, 41101); before a charter is seated the interim rules apply as they did. The signer check lists no team: the leader is the charter's owner, an elected member costs a point read of - `removedModerator`, anyone else a point read of `addedModerator` and, when - there is one, of `removedModerator` (both unique on `electedCharterId` and - `memberId`). + `removedModerator`, anyone else a point read of `addedModerator` (both + unique on `electedCharterId` and `memberId`). - **Abilities.** The team holds the abilities the target's declaration gives it: a deletion or a restore needs `deleteDocuments` on the type, a list action the ability on some moderated type @@ -215,7 +223,8 @@ replaced). The moderation paths of the target read it: a charter is seated (41113); the pot waits for the seated team, whose claim comes in a later pull request. - **Resignations.** A `resignationRequest` changes nothing by itself: the - leader acts on it with a `removedModerator`. + leader acts on it by deleting the member's `addedModerator`, or with a + `removedModerator` for an elected member. ## Validation beyond the schema @@ -251,7 +260,7 @@ the indexes above; no endpoint is specific to charters. The Rust SDK | The team | the seated charter, then `addedModerator` and `removedModerator` by `byElectedCharterMember`, combined as `ElectedCharter::active_members` does | `Sdk::fetch_moderation_team` | `team` | | The proposals for a contract | `submittedCharter.byTargetContract`, in filing order, paged | `Sdk::fetch_submitted_charters` | `submittedCharters` | | The join requests for a proposal | `joinRequest.bySubmittedCharter`, paged | `Sdk::fetch_join_requests` | `joinRequests` | -| A charter's pending resignation requests | `resignationRequest.byElectedCharterOwner`, less the writers the charter has a `removedModerator` for | `Sdk::fetch_pending_resignation_requests` | `pendingResignationRequests` | +| A charter's pending resignation requests | `resignationRequest.byElectedCharterOwner` whose writer is still on the team | `Sdk::fetch_pending_resignation_requests` | `pendingResignationRequests` | `Sdk::build_join_request` and `Sdk::build_resignation_request` (`buildJoinRequest` and `buildResignationRequest` in JavaScript) build the two diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index b22b93bbd58..0d58f661fa0 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -339,7 +339,7 @@ team.leaderId; team.members; team.contains(identityId); const proposals = await sdk.moderationCharters.submittedCharters({ targetContractId: contractId, limit: 20 }); const requests = await sdk.moderationCharters.joinRequests({ submittedCharterId: proposalId }); -// Resignation requests the leader has not acted on with a removal yet +// Resignation requests whose writer is still on the team (the leader has not acted on them) const pending = await sdk.moderationCharters.pendingResignationRequests(charter.id); ``` diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 9a16b2d5dd2..a2d4e8e1bbe 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -6,8 +6,10 @@ moderation team. It activates at protocol version 14, registered at genesis by chains born at 14 and inserted by the upgrade to 14, and has the same ID on every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. -It has seven document types. All are immutable, and all but `resignationRequest` are undeletable, so -everything a charter points at, and the charter itself, is a fixed text. +It has seven document types. All are immutable. The four a charter is made of +(`reason`, `submittedCharter`, `joinRequest`, `electedCharter`) are +undeletable, so everything a charter points at, and the charter itself, is a +fixed text; the three team changes are deletable. The schema carries almost every rule through its keywords: typed arrays with a reference per element, a reference resolved through a unique index @@ -94,13 +96,13 @@ Once an elected charter is seated, its team can change without a new vote: | Type | Written by | Properties | Rules | | --- | --- | --- | --- | -| `addedModerator` | the leader | `electedCharterId`, `submittedCharterId`, `memberId` | `memberId` owns a `joinRequest` for the charter's proposal (`lookup`) and is not the leader; at most the target's `maxAddedModerators` additions per charter, ever filed, a consensus rule of the batch's state validation (41202) | -| `removedModerator` | the leader | `electedCharterId`, `memberId` | Needs no resignation; `memberId` is not the leader | -| `resignationRequest` | a member of the team | `electedCharterId`, `recipientId`, `recipientKeyId`, `senderKeyId`, `encryptedMessage` | The writer is in the charter's `members` or was added (`ownerRefersTo` with `anyOf`); a message only the leader can read; deletable, which withdraws it; the leader acts on it with a removal | +| `addedModerator` | the leader | `electedCharterId`, `submittedCharterId`, `memberId` | `memberId` owns a `joinRequest` for the charter's proposal (`lookup`) and is not the leader; at most the target's `maxAddedModerators` additions per charter at a time, a consensus rule of the batch's state validation (41202); deleting it takes the member off and frees its slot | +| `removedModerator` | the leader | `electedCharterId`, `memberId` | Needs no resignation; `memberId` is one of the charter's elected `members` (`listElement`); deleting it puts the member back | +| `resignationRequest` | a member of the team | `electedCharterId`, `recipientId`, `recipientKeyId`, `senderKeyId`, `encryptedMessage` | The writer is in the charter's `members` or has an addition now (`ownerRefersTo` with `anyOf`, the addition a `deletableDocument` lookup); a message only the leader can read; deletable, which withdraws it; the leader acts on it by deleting the addition or removing an elected member | -Each is written once per member and charter (unique indexes), and a removal is -final. The team that acts is the leader plus the elected members and the -additions, less the removals (`ElectedCharter::active_members` in `rs-dpp`). +Each exists at most once per member and charter (unique indexes). The team +that acts is the leader plus the elected members less the removals, plus the +additions (`ElectedCharter::active_members` in `rs-dpp`). See [the protocol guide](../../docs/protocol/moderation-charters.md) for details. diff --git a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json index 6e6985ecb7b..c66b06a93a9 100644 --- a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -384,7 +384,7 @@ "addedModerator": { "type": "object", "documentsMutable": false, - "canBeDeleted": false, + "canBeDeleted": true, "indices": [ { "name": "byElectedCharterMember", @@ -454,12 +454,12 @@ "memberId" ], "additionalProperties": false, - "description": "A member the leader adds after the election, up to the target's maxAddedModerators" + "description": "A member the leader adds after the election, at most the target's maxAddedModerators at a time; deleting it takes the member off the team" }, "removedModerator": { "type": "object", "documentsMutable": false, - "canBeDeleted": false, + "canBeDeleted": true, "indices": [ { "name": "byElectedCharterMember", @@ -498,6 +498,15 @@ "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier", "distinctFrom": "$ownerId", + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + "description": "An elected member of the charter; an added one is taken off by deleting its addition", "position": 1 } }, @@ -507,7 +516,7 @@ "memberId" ], "additionalProperties": false, - "description": "A member the leader removes; final" + "description": "An elected member the leader takes off the team; deleting it puts them back" }, "resignationRequest": { "type": "object", @@ -524,7 +533,7 @@ "inList": "members" }, { - "type": "permanentDocument", + "type": "deletableDocument", "documentType": "addedModerator", "lookup": { "index": "byElectedCharterMember", diff --git a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js index 9d4f92c1dee..84014a562d5 100644 --- a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js +++ b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js @@ -386,6 +386,10 @@ describe('Moderation Charters Contract', () => { expect(validate('addedModerator', await rawAddition()).isValid()).to.be.true(); }); + it('should be deletable, which takes the member off the team', () => { + expect(moderationChartersContractDocumentsSchema.addedModerator.canBeDeleted).to.be.true(); + }); + expectRequired('addedModerator', rawAddition, ['electedCharterId', 'submittedCharterId', 'memberId']); expectNoAdditionalProperties('addedModerator', rawAddition, 'power'); }); @@ -400,6 +404,21 @@ describe('Moderation Charters Contract', () => { expect(validate('removedModerator', await rawRemoval()).isValid()).to.be.true(); }); + it('should be deletable, which puts the member back on the team', () => { + expect(moderationChartersContractDocumentsSchema.removedModerator.canBeDeleted).to.be.true(); + }); + + it('should remove only an elected member', () => { + const { refersTo } = moderationChartersContractDocumentsSchema.removedModerator.properties.memberId; + + expect(refersTo).to.deep.equal({ + type: 'listElement', + documentType: 'electedCharter', + propertyAgreement: { electedCharterId: '$id' }, + inList: 'members', + }); + }); + expectRequired('removedModerator', rawRemoval, ['electedCharterId', 'memberId']); expectNoAdditionalProperties('removedModerator', rawRemoval, 'resignationRequestId'); }); @@ -427,7 +446,7 @@ describe('Moderation Charters Contract', () => { expect(anyOf.map(({ type, documentType }) => [type, documentType])).to.deep.equal([ ['listElement', 'electedCharter'], - ['permanentDocument', 'addedModerator'], + ['deletableDocument', 'addedModerator'], ]); }); diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index aedff92fcb9..8884e090a6e 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -5,7 +5,7 @@ "type": "object", "$defs": { "referenceOperands": { - "description": "The operands of a refersTo anyOf or allOf: two or more, no two alike, each a leaf or an expression of the other combinator. At most SystemLimits max_reference_operands of them in one list, and at most SystemLimits max_reference_expression_depth combinators on any path from the declaration to a leaf (4 and 4 from protocol version 14), checked at contract registration; every leaf counts against max_references_per_document, since each may be read for each value. A leaf is an identity, permanentDocument (by id or through a lookup) or listElement target: all are existence checks against entities that are never deleted (a list element reads a list that never changes on a document that is never deleted), so an expression of them holds for good once it holds and is re-validated on replace only when its value or a property bound to one of its leaves changes, as a single target is. The other types do not compose with other operands: deletableDocument is re-validated on every replace and may be cleared once its document is deleted, which assumes the property refers to that one target; identityPublicKey pairs the value with a key id property no other operand reads; a contract target's requirements are gates judged against the block time and the writer rather than an existence check, and a contract or token id is never also an identity or document id. A propertyAgreement belongs to its leaf and is checked only against that leaf's document. Allowed on identifier properties and on the items of a typed array of identifiers; a changed expression is an incompatible schema change on update", + "description": "The operands of a refersTo anyOf or allOf: two or more, no two alike, each a leaf or an expression of the other combinator. At most SystemLimits max_reference_operands of them in one list, and at most SystemLimits max_reference_expression_depth combinators on any path from the declaration to a leaf (4 and 4 from protocol version 14), checked at contract registration; every leaf counts against max_references_per_document, since each may be read for each value. A leaf is an identity, permanentDocument (by id or through a lookup), listElement or deletableDocument-through-a-lookup target. The first three are existence checks against entities that are never deleted (a list element reads a list that never changes on a document that is never deleted), so an expression of only them holds for good once it holds and is re-validated on replace only when its value or a property bound to one of its leaves changes, as a single target is; a deletableDocument leaf found through a lookup is an existence check against a document that may be deleted, so an expression holding one is re-validated on every replace, and an immutable property may not hold it. The other types do not compose with other operands: deletableDocument by id is re-validated on every replace and may be cleared once its document is deleted, which assumes the property refers to that one target; identityPublicKey pairs the value with a key id property no other operand reads; a contract target's requirements are gates judged against the block time and the writer rather than an existence check, and a contract or token id is never also an identity or document id. A propertyAgreement belongs to its leaf and is checked only against that leaf's document. Allowed on identifier properties and on the items of a typed array of identifiers; a changed expression is an incompatible schema change on update", "type": "array", "minItems": 2, "uniqueItems": true, @@ -16,10 +16,26 @@ "enum": [ "identity", "permanentDocument", - "listElement" + "listElement", + "deletableDocument" ] }, "identityProperty": false + }, + "if": { + "properties": { + "type": { + "const": "deletableDocument" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "lookup" + ] } } }, @@ -267,7 +283,7 @@ ] }, "anyOf": { - "description": "A reference expression holding if at least one operand holds: two or more operands, each a leaf (an ordinary identity or permanentDocument target, by id or through a lookup, with its own keys) or an allOf (an anyOf directly inside an anyOf says what one flat list says). When the referring document is created or replaced, consensus checks the operands in declared order and stops at the first that holds; every read is billed, including those of the operands that failed, and when none holds the write is refused with the error of the last operand, so the order of the list decides which failure a writer sees. See referenceOperands for the limits and the leaf types. Available from protocol version 14.", + "description": "A reference expression holding if at least one operand holds: two or more operands, each a leaf (an ordinary identity, permanentDocument (by id or through a lookup), listElement or deletableDocument-through-a-lookup target, with its own keys) or an allOf (an anyOf directly inside an anyOf says what one flat list says). When the referring document is created or replaced, consensus checks the operands in declared order and stops at the first that holds; every read is billed, including those of the operands that failed, and when none holds the write is refused with the error of the last operand, so the order of the list decides which failure a writer sees. See referenceOperands for the limits and the leaf types. Available from protocol version 14.", "$ref": "#/$defs/referenceOperands", "items": { "properties": { @@ -276,7 +292,7 @@ } }, "allOf": { - "description": "A reference expression holding if every operand holds for the same value: two or more operands, each a leaf (an ordinary identity or permanentDocument target, by id or through a lookup, with its own keys) or an anyOf (an allOf directly inside an allOf says what one flat list says). When the referring document is created or replaced, consensus checks the operands in declared order and stops at the first that fails, refusing the write with that operand's error; every read is billed. See referenceOperands for the limits and the leaf types. Available from protocol version 14.", + "description": "A reference expression holding if every operand holds for the same value: two or more operands, each a leaf (an ordinary identity, permanentDocument (by id or through a lookup), listElement or deletableDocument-through-a-lookup target, with its own keys) or an anyOf (an allOf directly inside an allOf says what one flat list says). When the referring document is created or replaced, consensus checks the operands in declared order and stops at the first that fails, refusing the write with that operand's error; every read is billed. See referenceOperands for the limits and the leaf types. Available from protocol version 14.", "$ref": "#/$defs/referenceOperands", "items": { "properties": { @@ -440,7 +456,7 @@ "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, "lookup": { - "description": "permanentDocument references only (a key into a deletable type could find a new document once the one it found is deleted, where an id is produced at most once): the property's value is not the referenced document's id. The referenced document is the one the named unique index of the referenced document type finds for a key assembled from the referring document, and the reference holds if that document exists. index names an index of the referenced document type that is unique, carries no timeRange and does not belong to an indexOnly type; its key must stay with the document it found (every schema property of the index fixed by an immutable document type or the immutable list, $ownerId only on a type whose documents can be neither transferred nor traded), so the reference never dangles; keys maps every property of that index (by its name on the referenced side, system ones such as $ownerId included), in any order, to where its value comes from: a property path of the referring document type, \"$ownerId\" for the referring document's owner, or \".\" for the value of the property carrying the reference (on an array element, the element), which must appear exactly once. A property a key reads must be required (and so must every object around it), not transient nor inside a transient object, and hold the same kind of value as its index property, so a lookup never runs with a missing key part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries the index for the assembled key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; propertyAgreement pairs are checked against the document found. A replace re-validates the reference when a property a key reads changed, and on every replace when a key reads $ownerId", + "description": "permanentDocument and deletableDocument references only: the property's value is not the referenced document's id. The referenced document is the one the named unique index of the referenced document type finds for a key assembled from the referring document, and the reference holds if that document exists. index names an index of the referenced document type that is unique, carries no timeRange and does not belong to an indexOnly type; its key must stay with the document it found (every schema property of the index fixed by an immutable document type or the immutable list, $ownerId only on a type whose documents can be neither transferred nor traded), so the reference never dangles; keys maps every property of that index (by its name on the referenced side, system ones such as $ownerId included), in any order, to where its value comes from: a property path of the referring document type, \"$ownerId\" for the referring document's owner, or \".\" for the value of the property carrying the reference (on an array element, the element), which must appear exactly once. A property a key reads must be required (and so must every object around it), not transient nor inside a transient object, and hold the same kind of value as its index property, so a lookup never runs with a missing key part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries the index for the assembled key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; propertyAgreement pairs are checked against the document found. A replace re-validates a permanentDocument lookup when a property a key reads changed, and on every replace when a key reads $ownerId. On a deletableDocument reference the lookup says less: once the document it found is deleted the same key may find another filed later, so the reference means a document with this key exists now; every replace re-validates it, as it does a deletableDocument reference by id, and an immutable property may not hold one (the clearing a dead reference by id allows reads an id, which a key is not)", "type": "object", "properties": { "index": { @@ -581,7 +597,7 @@ }, { "if": { - "properties": { "type": { "const": "permanentDocument" } }, + "properties": { "type": { "enum": ["permanentDocument", "deletableDocument"] } }, "required": ["type"] }, "then": {}, @@ -1909,21 +1925,25 @@ "description": "The subset of the immutable properties a replace may still set while the stored document has no value for them: a first-time set is accepted, after which the property is frozen like the rest of the immutable list (it can neither change nor be removed). Every entry must also appear in immutable; it is only meaningful for optional properties, since a required one always has a value from creation. On contract update an entry may be dropped (tightening) at any time, but may only be added for a property that becomes immutable in the same update: an already-immutable property cannot start allowing a set. Available from protocol version 14." }, "ownerRefersTo": { - "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the three targets a writer can be: identity, a permanentDocument found through a lookup, where \".\" is the writer, or a listElement, the writer an element of the list, or an anyOf or allOf expression whose every leaf is one of them and \"$ownerId\" names the writer as well. contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner; on a type whose documents can, creatorRefersTo checks the creator instead. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the targets a writer can be: identity, a permanentDocument or a deletableDocument found through a lookup, where \".\" is the writer, or a listElement, the writer an element of the list, or an anyOf or allOf expression whose every leaf is one of them and \"$ownerId\" names the writer as well. A deletableDocument leaf gates the writer on the document the lookup finds existing now: every replace checks it again, so a writer whose document is gone can no longer replace theirs (it may still delete it). contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner; on a type whose documents can, creatorRefersTo checks the creator instead. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", "properties": { "type": { "enum": [ "identity", "permanentDocument", - "listElement" + "listElement", + "deletableDocument" ] } }, "if": { "properties": { "type": { - "const": "permanentDocument" + "enum": [ + "permanentDocument", + "deletableDocument" + ] } }, "required": [ @@ -1937,7 +1957,7 @@ } }, "creatorRefersTo": { - "description": "A refersTo declaration whose value is the document's $creatorId, its creator, instead of a property's value: the counterpart of ownerRefersTo for a document type whose documents can be transferred or traded, since the creator never changes. The declaration of an identifier property, with the same keys and the same checks, for the three targets a creator can be: identity, a permanentDocument found through a lookup, where \".\" is the creator, or a listElement, the creator an element of the list, or an anyOf or allOf expression whose every leaf is one of them. contract, token and a document by id are refused, since the creator's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the creator does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the writer. Only on a document type that records creator ids: a transferable or tradeable document type of a format-1 contract; a type declares at most one of ownerRefersTo and creatorRefersTo. When a document is created (by its creator) and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the creator against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $creatorId as the path. A transfer or a purchase needs no check: it does not change the creator. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "description": "A refersTo declaration whose value is the document's $creatorId, its creator, instead of a property's value: the counterpart of ownerRefersTo for a document type whose documents can be transferred or traded, since the creator never changes. The declaration of an identifier property, with the same keys and the same checks, for the three targets a creator can be: identity, a permanentDocument found through a lookup, where \".\" is the creator, or a listElement, the creator an element of the list, or an anyOf or allOf expression whose every leaf is one of them. contract, token, a document by id and a deletableDocument (the creator never changes, and a document a transfer handed on could not be replaced once the one a lookup found is deleted) are refused, since the creator's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the creator does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the writer. Only on a document type that records creator ids: a transferable or tradeable document type of a format-1 contract; a type declares at most one of ownerRefersTo and creatorRefersTo. When a document is created (by its creator) and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the creator against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $creatorId as the path. A transfer or a purchase needs no check: it does not change the creator. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", "properties": { "type": { diff --git a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs index 64066223ff7..ebca95011e7 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -327,8 +327,8 @@ pub struct ElectedModerators { /// `contractRequirements: { "moderation": "electionOpen" }` is what reads it. pub election_delay: Option, /// 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: the additions ever filed against - /// a seated charter, so a removal or a resignation frees no slot. 0 when the declaration + /// an identity that asked to join the team's proposal: the additions a seated charter + /// holds at a time, the leader taking one back by deleting it. 0 when the declaration /// leaves it out, a team then being exactly what was elected; at most /// `SystemLimits::max_contract_moderation_added_moderators`. The moderation charters /// contract's `addedModerator` documents are what it counts. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index 582f1fe767f..d207a3b1d1b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -184,6 +184,7 @@ impl DocumentType { contract_id, document_type_name, lookup: Some(lookup), + permanent, .. }) = target.as_any_document_reference() else { @@ -197,14 +198,18 @@ impl DocumentType { else { continue; }; - // A lookup is only declared on a permanentDocument reference, and a - // deletable target fails that reference whatever its indexes say: - // registration reports it (ReferencedDocumentTypeDeletableError), so the - // lookup is not judged against a type it could never reference + // A permanentDocument lookup into a deletable type, or a + // deletableDocument lookup into one that forbids deletion, fails that + // reference whatever its indexes say: registration reports it + // (ReferencedDocumentTypeDeletableError or + // ReferencedDocumentTypeNotDeletableError), so the lookup is not judged + // against a type it could never reference. A deletableDocument lookup + // exists from the same protocol version 14 as every other lookup, so + // this stays inert before it let referenced = referenced_document_type.as_ref(); - if referenced.documents_can_be_deleted() - || referenced.documents_can_be_deleted_by_moderators() - { + let deletable = referenced.documents_can_be_deleted() + || referenced.documents_can_be_deleted_by_moderators(); + if permanent == deletable { continue; } if let Some(reason) = lookup.referenced_side_error(declaring, referenced) { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 1601b866464..4ee342c0f3c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -841,7 +841,16 @@ fn parse_reference_expression_leaf( let reference_type = declaration .get_str(property_names::TYPE) .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - if let Some(reason) = expression_leaf_refusal_reason(reference_type) { + // A deletableDocument leaf through a lookup is an existence check like + // the others; it only asks every replace to re-validate the expression + let deletable_lookup = + reference_type == "deletableDocument" && declaration.contains_key(property_names::LOOKUP); + let refusal = if deletable_lookup { + None + } else { + expression_leaf_refusal_reason(reference_type) + }; + if let Some(reason) = refusal { return Err(DataContractError::InvalidContractStructure(format!( "refersTo {path} is a reference of type {reference_type}, which a reference \ expression does not take: {reason}" @@ -867,8 +876,9 @@ fn expression_leaf_refusal_reason(reference_type: &str) -> Option<&'static str> match reference_type { _ if COMBINABLE_REFERENCE_TARGET_TYPES.contains(&reference_type) => None, "deletableDocument" => Some( - "it is re-validated on every replace and may be cleared once its document is \ - deleted, which assumes the property refers to that one target", + "by id it is re-validated on every replace and may be cleared once its document is \ + deleted, which assumes the property refers to that one target; declare it with a \ + lookup to combine it", ), "identityPublicKey" => { Some("it pairs the value with a key id property, which no other operand reads") @@ -933,14 +943,17 @@ fn validate_reference_target_keys( ))); } - // `lookup` finds a referenced DOCUMENT through an index of its type, and - // only a permanent one: a key into a deletable type could find a new - // document once the one it found is deleted, where an id is produced at - // most once. The other targets are found by the value itself - if refers_to_map.contains_key(property_names::LOOKUP) && reference_type != "permanentDocument" { + // `lookup` finds a referenced DOCUMENT through an index of its type, of + // either kind: a permanent one never dangles, and a deletable one is + // re-validated on every replace, since a key into a deletable type may + // find a new document once the one it found is deleted. The other targets + // are found by the value itself + if refers_to_map.contains_key(property_names::LOOKUP) + && !matches!(reference_type, "permanentDocument" | "deletableDocument") + { return Err(DataContractError::InvalidContractStructure(format!( "{reference_type} refersTo does not take lookup: it is only allowed on \ - permanentDocument references" + permanentDocument and deletableDocument references" ))); } @@ -1010,10 +1023,20 @@ fn parse_reference_target( }, } } - "deletableDocument" => DocumentPropertyReferenceTarget::DeletableDocument { - contract_id, - document_type_name, - property_agreement, + "deletableDocument" => match refers_to_map.get(property_names::LOOKUP) { + Some(lookup_value) => { + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup: parse_document_reference_lookup(lookup_value)?, + } + } + None => DocumentPropertyReferenceTarget::DeletableDocument { + contract_id, + document_type_name, + property_agreement, + }, }, _ => DocumentPropertyReferenceTarget::ListElement(parse_list_element_reference( refers_to_map, @@ -1394,11 +1417,6 @@ pub(super) fn parse_doctype_reference( "{keyword} does not take a token reference: its value, {value}'s identity id, is \ never a token id" )), - Some("deletableDocument") => Some(format!( - "{keyword} does not take a deletableDocument reference: its value, {value}'s \ - identity id, is never a document id, and only a permanentDocument reference takes \ - the lookup that could find one" - )), _ => None, }; if let Some(refusal) = refusal { @@ -1434,16 +1452,33 @@ pub(super) fn parse_doctype_reference( DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } | DocumentPropertyReferenceTarget::ListElement(_) => {} - DocumentPropertyReferenceTarget::PermanentDocument { .. } => { + // A deletable document found through a lookup gates the writer on + // it existing now, and every replace asks again: the writer never + // changes (ownerRefersTo is only on a type whose documents stay + // with it), so the gate is the writer's own. The creator's would + // outlive a transfer, leaving a new owner unable to replace the + // document once the creator's document is gone, so creatorRefersTo + // keeps to targets that hold for good + DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } + if keyword == property_names::OWNER_REFERS_TO => {} + DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => { + return Err(DataContractError::InvalidContractStructure(format!( + "{keyword}{at} does not take a deletableDocument reference: the creator \ + never changes, and a document a transfer handed on could not be replaced \ + once the one the lookup found is deleted" + ))) + } + DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::DeletableDocument { .. } => { return Err(DataContractError::InvalidContractStructure(format!( - "{keyword}{at} takes a permanentDocument reference only with a lookup: its \ - value, {value}'s identity id, is never a document id" + "{keyword}{at} takes a document reference only with a lookup: its value, \ + {value}'s identity id, is never a document id" ))) } _ => { return Err(DataContractError::InvalidContractStructure(format!( - "{keyword}{at} takes an identity reference, a permanentDocument reference \ - with a lookup or a listElement reference" + "{keyword}{at} takes an identity reference, a document reference with a \ + lookup or a listElement reference" ))) } } @@ -1451,7 +1486,7 @@ pub(super) fn parse_doctype_reference( Ok(Some(target)) } -/// The `lookup` of a `permanentDocument` reference: `index`, the name of an index of the +/// The `lookup` of a document reference: `index`, the name of an index of the /// referenced document type, and `keys`, every property of that index mapped to /// its referring-side source (`"."`, `"$ownerId"` or a property path), with `"."` /// exactly once. What the names resolve to is checked once the document types diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 67cb9d88668..c797c76b6a1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -748,6 +748,10 @@ fn with_own_contract_id_omitted( | DocumentPropertyReferenceTarget::DeletableDocument { contract_id: referenced, .. + } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id: referenced, + .. } => { if *referenced == Some(contract_id) { *referenced = None; @@ -815,8 +819,9 @@ fn validate_reference_count( /// An `immutable` property may not hold a `deletableDocument` reference the /// replace state validation could not clear: a typed array of them, at the -/// top level or inside an immutable object, or a single one inside an -/// immutable object. Every replace re-validates such a reference, so once a +/// top level or inside an immutable object, a single one inside an immutable +/// object, or one declared with a lookup, alone or as an operand of an +/// expression, anywhere. Every replace re-validates such a reference, so once a /// target is deleted the property would have to change, which an immutable /// property cannot: the document could never be replaced again. The one /// such reference that has a way out is a single one held by an immutable @@ -832,21 +837,37 @@ fn validate_no_immutable_deletable_element_references( let Some(reference) = property.property_type.reference() else { continue; }; - if !matches!( - reference.target(), - Some(DocumentPropertyReferenceTarget::DeletableDocument { .. }) - ) { + let Some(target) = reference.target() else { + continue; + }; + // A deletableDocument found through a lookup, alone or as an operand of + // an expression, is re-validated on every replace too, and the clearing + // exception reads a document id, which a lookup key is not + let deletable_lookup = target.leaves().into_iter().any(|leaf| { + matches!( + leaf, + DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } + ) + }); + if !deletable_lookup + && !matches!( + target, + DocumentPropertyReferenceTarget::DeletableDocument { .. } + ) + { continue; } let top_level = path.split('.').next().unwrap_or(path); let is_list = matches!(reference, PropertyReference::Elements { .. }); - // A single reference that is itself the immutable property can be + // A single reference by id that is itself the immutable property can be // cleared once its target is gone - if !is_list && top_level == path { + if !deletable_lookup && !is_list && top_level == path { continue; } if document_type.immutable_fields.contains(top_level) { - let held_as = if is_list { + let held_as = if deletable_lookup { + "a deletableDocument reference through a lookup" + } else if is_list { "a typed array of deletableDocument references" } else { "a deletableDocument reference inside an object" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs index 99855c7ae4c..cad896e55c3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs @@ -303,11 +303,11 @@ fn should_refuse_an_owner_or_creator_reference_to_a_target_its_identity_can_neve ), ( json!({ "type": "permanentDocument", "documentType": "addedModerator" }), - "takes a permanentDocument reference only with a lookup", + "takes a document reference only with a lookup", ), ( json!({ "type": "deletableDocument", "documentType": "post" }), - "does not take a deletableDocument reference", + "takes a document reference only with a lookup", ), ] { let schema = contract_declaring(declaration.clone()); @@ -792,7 +792,73 @@ fn should_parse_an_owner_or_creator_reference_expression_of_identity_capable_lea full_validation, PlatformVersion::latest(), ), - "ownerRefersTo anyOf[1] takes a permanentDocument reference only with a lookup", + "ownerRefersTo anyOf[1] takes a document reference only with a lookup", ); } } + +/// The moderation charters' resignation: an added moderator is taken off the team by +/// deleting its addition, so the writer's membership may be a deletable document that exists +/// now. `ownerRefersTo` takes a `deletableDocument` found through a lookup, alone or as an +/// operand; `creatorRefersTo` does not, since the creator's document could be deleted after +/// a transfer, leaving the new owner unable to replace theirs. +#[test] +fn should_parse_an_owner_reference_to_a_deletable_document_found_through_a_lookup() { + let deletable_added_moderator = json!({ + "type": "deletableDocument", + "documentType": "addedModerator", + "lookup": added_moderator_lookup() + }); + let with_deletable_additions = |mut schema: serde_json::Value| { + schema["documentSchemas"]["addedModerator"]["canBeDeleted"] = json!(true); + schema + }; + + for owner_refers_to in [ + deletable_added_moderator.clone(), + json!({ "anyOf": [{ "type": "identity" }, deletable_added_moderator.clone()] }), + ] { + for full_validation in [true, false] { + let parsed = contract_on( + with_deletable_additions(charter_contract(owner_refers_to.clone())), + full_validation, + PlatformVersion::latest(), + ) + .unwrap_or_else(|e| panic!("{owner_refers_to} should parse: {e}")); + let target = owner_reference(&parsed).expect("the owner reference"); + assert!( + target.leaves().into_iter().any(|leaf| matches!( + leaf, + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + document_type_name, + .. + } if document_type_name == "addedModerator" + )), + "{owner_refers_to}: {target:?}" + ); + } + } + + // The meta-schema refuses it on the creator at registration, and the parser on the + // stored path, alone or as an operand + let schema = with_deletable_additions(creator_contract(deletable_added_moderator.clone())); + let error = contract(schema.clone()).expect_err("the meta-schema should refuse it"); + assert!( + is_json_schema_error(&error), + "expected a meta-schema error, got {error}" + ); + assert_refused( + contract_on(schema, false, PlatformVersion::latest()), + "creatorRefersTo does not take a deletableDocument reference", + ); + assert_refused( + contract_on( + with_deletable_additions(creator_contract(json!({ + "anyOf": [{ "type": "identity" }, deletable_added_moderator] + }))), + false, + PlatformVersion::latest(), + ), + "creatorRefersTo anyOf[1] does not take a deletableDocument reference", + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs index 113656bfc24..524a6cb524c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs @@ -288,6 +288,52 @@ fn should_parse_an_all_of_of_a_lookup_and_an_identity() { ); } +/// A `deletableDocument` found through a lookup is an operand like a permanent one: the +/// leader takes an added moderator off by deleting the addition, and the expression is then +/// re-validated on every replace. An immutable property may not hold it, since once the +/// document is gone the property could never change to pass again. +#[test] +fn should_parse_a_deletable_document_lookup_operand_and_refuse_it_in_an_immutable_property() { + let mut deletable_added_moderator = added_moderator_lookup(); + deletable_added_moderator["type"] = json!("deletableDocument"); + let mut schema = charter_contract(any_of(vec![ + join_request_lookup(), + deletable_added_moderator, + ])); + schema["documentSchemas"]["addedModerator"]["canBeDeleted"] = json!(true); + + let parsed = contract(schema.clone()).expect("parses"); + let DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + } = expected_added_moderator_lookup() + else { + unreachable!("a permanent lookup") + }; + assert_eq!( + property_type(&parsed, "memberId"), + DocumentPropertyType::IdentifierWithReference(any_of_targets(vec![ + expected_join_request_lookup(), + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + }, + ])) + ); + + let mut immutable = schema; + immutable["documentSchemas"]["resignation"]["immutable"] = json!(["memberId"]); + assert_refused( + contract(immutable), + "lists \"memberId\" as immutable, but \"memberId\" is a deletableDocument reference \ + through a lookup", + ); +} + /// Each leaf is an ordinary declaration with its own keys: an agreement /// belongs to the leaf it is declared on. #[test] @@ -477,7 +523,7 @@ fn should_refuse_every_leaf_type_an_expression_does_not_take() { ( json!({ "type": "deletableDocument", "documentType": "note" }), "reference of type deletableDocument, which a reference expression does not take: \ - it is re-validated on every replace", + by id it is re-validated on every replace", ), ( json!({ "type": "identityPublicKey", "keyIdProperty": "keyId" }), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs index 39138937020..fceaa85eac3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs @@ -160,24 +160,13 @@ fn should_parse_a_lookup_beside_a_property_agreement() { } #[test] -fn should_refuse_a_lookup_on_any_reference_but_a_permanent_document_one() { - for reference_type in [ - "identity", - "contract", - "token", - "identityPublicKey", - "deletableDocument", - ] { +fn should_refuse_a_lookup_on_any_reference_but_a_document_one() { + for reference_type in ["identity", "contract", "token", "identityPublicKey"] { let mut refers_to = json!({ "type": reference_type, "lookup": members_lookup() }); if reference_type == "identityPublicKey" { refers_to["keyIdProperty"] = json!("title"); } - let mut schema = charter_contract(refers_to); - if reference_type == "deletableDocument" { - // A deletable target, so the reference itself is well formed: only - // the lookup is out of place - refers_to_deletable_join_requests(&mut schema); - } + let schema = charter_contract(refers_to); // The meta-schema refuses it under full validation, and the parser on // its own without it @@ -390,6 +379,51 @@ fn should_refuse_a_lookup_reading_the_writer_on_a_type_that_can_change_owner() { contract(transferable).expect("a key that does not read the writer holds"); } +/// A `deletableDocument` reference may find its document through a lookup too: a key into a +/// deletable type may find a later document once the one it found is deleted, so the reference +/// means a document with this key exists now, and every replace re-validates it. The +/// referenced side is checked as it is for a permanent one. +#[test] +fn should_parse_a_lookup_on_a_deletable_document_reference_and_check_its_referenced_side() { + let deletable_join_request = json!({ "type": "deletableDocument", "documentType": "joinRequest", "lookup": members_lookup() }); + let mut schema = charter_contract(deletable_join_request); + refers_to_deletable_join_requests(&mut schema); + + let parsed = contract(schema.clone()).expect("parses"); + assert_eq!( + member_id_type(&parsed), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + lookup: expected_lookup(&[ + ( + "submittedCharterId", + LookupKeySource::Property("submittedCharterId".to_string()), + ), + ("$ownerId", LookupKeySource::ReferenceValue), + ]), + } + ) + ); + + let mut moving = schema.clone(); + moving["documentSchemas"]["joinRequest"]["documentsMutable"] = json!(true); + assert_refused( + contract(moving), + "keys documents by \"submittedCharterId\", which a replace can change", + ); + + // Once its document is gone the property would have to change to pass again + let mut immutable = schema; + immutable["documentSchemas"]["electedCharter"]["immutable"] = json!(["memberId"]); + assert_refused( + contract(immutable), + "\"memberId\" is a deletableDocument reference through a lookup", + ); +} + /// Makes the fixture's `joinRequest` deletable, the target a /// `deletableDocument` reference needs. fn refers_to_deletable_join_requests(schema: &mut serde_json::Value) { @@ -469,18 +503,28 @@ fn should_check_an_element_lookup_as_a_single_one_is_checked() { ))), "index \"byMessage\" of \"joinRequest\" is not unique", ); - // And never on a deletableDocument reference + // And on a deletableDocument reference, whose elements every replace re-validates let mut deletable = with_members(json!({ "type": "deletableDocument", "documentType": "joinRequest", "lookup": members_lookup() })); refers_to_deletable_join_requests(&mut deletable); - contract(deletable.clone()).expect_err("the meta-schema should refuse it"); - assert_refused( - contract_on(deletable, false, PlatformVersion::latest()), - "deletableDocument refersTo does not take lookup", - ); + let parsed = contract(deletable).expect("parses"); + assert!(matches!( + parsed + .document_type_for_name("electedCharter") + .expect("the electedCharter document type") + .flattened_properties() + .get("members") + .expect("the members property") + .property_type + .reference(), + Some(PropertyReference::Elements { + target: DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. }, + .. + }) + )); } #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index 52eda7adf49..5c13466f2c8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -108,8 +108,8 @@ impl Index { }; // Only a scalar reference can bind: an index property is never a // typed array, so element references never reach an index. A - // lookup reference (`PermanentDocumentLookup`) never matches - // either: its value is not the referenced document's `$id`. Nor + // lookup reference of either kind never matches either: its value + // is not the referenced document's `$id`. Nor // does a reference expression (`anyOf` / `allOf`), even of // permanentDocument leaves only: an `anyOf` value may be the id of // a document of any of them, and binding an `allOf` would have to diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index cd6509fa52e..c4c8205ca71 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -807,9 +807,9 @@ pub enum DocumentPropertyReferenceTarget { /// expect the reference to resolve to nothing. It can not come back /// pointing at something else: a document id commits to the nonce of /// its create transition, so an id is produced at most once and a - /// reference means that one document or nothing (which is why there is - /// no lookup form of it: a key could find a new document once the one it - /// found is deleted). A WRITER may not leave + /// reference means that one document or nothing (its lookup form, + /// [`Self::DeletableDocumentLookup`], promises less: a key may find a new + /// document once the one it found is deleted). A WRITER may not leave /// it that way: every replace of the referring document re-validates /// the reference, so a dead one has to be repointed at a document that /// exists or cleared (on an `immutable` property, clearing is the only @@ -836,9 +836,7 @@ pub enum DocumentPropertyReferenceTarget { /// the agreement pairs are checked against the document found, and the /// key must stay with that document (its parts cannot be changed by a /// replace, a transfer or a purchase), so the reference can not dangle - /// either. There is no deletable form: a key into a deletable type could - /// find a new document once the one it found is deleted, where an id is - /// produced at most once. + /// either. Its deletable form is [`Self::DeletableDocumentLookup`]. /// /// A variant of its own rather than a field of /// [`Self::PermanentDocument`], appended as this enum's rule requires: an @@ -898,6 +896,35 @@ pub enum DocumentPropertyReferenceTarget { /// earlier variant keeps its consensus encoding. #[serde(rename = "listElement")] ListElement(ListElementReference), + /// A `deletableDocument` reference declared with a `lookup`: the value is + /// one part of a key, as for [`Self::PermanentDocumentLookup`], into a + /// document type whose documents CAN be deleted. The document the key + /// finds must exist, and the agreement pairs hold against it, when the + /// referring document is written, and every replace re-validates it, as a + /// [`Self::DeletableDocument`] reference is. It promises less than the id + /// form: once the document it found is deleted, the same key may find + /// another one filed later, so the reference says "a document with this + /// key exists now", not "this document". That is what a membership gate + /// needs, such as "the writer is currently an added moderator of this + /// charter" (`ownerRefersTo`, the one doctype reference that takes it). + /// An immutable property can not hold one (a replace could neither keep a + /// dead one nor clear it), and it may be an operand of a reference + /// expression, which is then re-validated on every replace as well. + /// + /// Appended, so every earlier variant keeps its consensus encoding. It + /// serializes under the `deletableDocument` tag, with a `lookup` field. + #[serde(rename = "deletableDocument")] + DeletableDocumentLookup { + /// The contract the referenced document type lives in; `None` means + /// the declaring contract itself + contract_id: Option, + document_type_name: String, + /// See [`Self::PermanentDocument`]'s `property_agreement`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + property_agreement: BTreeMap, + /// How the referenced document is found. + lookup: DocumentReferenceLookup, + }, } /// The declaration content every document reference target shares: @@ -923,7 +950,8 @@ pub struct DocumentReferenceDeclaration<'a> { /// or must allow it (`deletableDocument`) pub permanent: bool, /// How the referenced document is found when the value is not its id - /// ([`DocumentPropertyReferenceTarget::PermanentDocumentLookup`]); `None` + /// ([`DocumentPropertyReferenceTarget::PermanentDocumentLookup`] and + /// [`DocumentPropertyReferenceTarget::DeletableDocumentLookup`]); `None` /// when the value is the referenced document's id. Only /// [`DocumentPropertyReferenceTarget::as_any_document_reference`] ever /// returns a declaration carrying one. @@ -992,6 +1020,19 @@ impl DocumentPropertyReferenceTarget { lookup: None, in_list: None, }), + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + } => Some(DocumentReferenceDeclaration { + contract_id: *contract_id, + document_type_name, + property_agreement, + permanent: false, + lookup: Some(lookup), + in_list: None, + }), DocumentPropertyReferenceTarget::ListElement(reference) => { Some(DocumentReferenceDeclaration { contract_id: reference.contract_id, @@ -1316,6 +1357,18 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { document_type_name, Some(lookup), ), + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + lookup, + .. + } => write_document_reference( + f, + "deletable", + *contract_id, + document_type_name, + Some(lookup), + ), DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, key_requirements, @@ -10334,6 +10387,15 @@ mod tests { property_agreement: [("electedCharterId".to_string(), "$id".to_string())].into(), in_list: "members".to_string(), }), + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: Default::default(), + lookup: DocumentReferenceLookup { + index: "byOwner".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }, ]; for target in &targets { @@ -10348,6 +10410,9 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => { "permanentDocument" } + DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => { + "deletableDocument" + } // Not a `type`: the schema declares them under their own keys DocumentPropertyReferenceTarget::ListElement(_) => "listElement", DocumentPropertyReferenceTarget::AnyOf(_) => "anyOf", diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs index b7c99047220..e8e20247723 100644 --- a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs @@ -10,7 +10,7 @@ use thiserror::Error; /// An `addedModerator` of the moderation charters contract for a seated charter that already /// has as many additions as its target contract's elected declaration allows -/// (`maxAddedModerators`). Additions ever filed count, so a removal frees no slot. +/// (`maxAddedModerators`). The additions that exist count: deleting one frees its slot. #[derive( Error, Debug, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 792496ee8b6..eb4adfe5637 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -729,6 +729,19 @@ mod tests { )), 9 ); + // A deletable document found through a lookup is appended after it + assert_eq!( + target_variant(&DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: BTreeMap::new(), + lookup: DocumentReferenceLookup { + index: "byOwner".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }), + 10 + ); } #[test] diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 2186174f26c..95a42db919b 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -12,10 +12,11 @@ //! only the leader can read; //! - an `electedCharter` is a proposal put to the vote with its team, chosen from the identities //! that asked to join it. Creating one opens or joins the contest for the target contract; -//! - once a charter is seated, its leader may add members from the same join requests, up to -//! the target's `maxAddedModerators` (`addedModerator`), and remove members -//! (`removedModerator`); a member asks to leave with a `resignationRequest`, which the -//! leader acts on with a removal and the member withdraws by deleting it. +//! - once a charter is seated, its leader may add members from the same join requests, at most +//! the target's `maxAddedModerators` at a time (`addedModerator`, taken back by deleting it), +//! and remove elected members (`removedModerator`, undone by deleting it); a member asks to +//! leave with a `resignationRequest`, which the leader acts on and the member withdraws by +//! deleting it. //! //! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected //! members and the additions, less the removals. @@ -327,10 +328,12 @@ impl ElectedCharter { /// The members a seated team acts with besides its leader, `leader_id`: the elected /// members and those the leader added after the election, less those the leader removed. /// `added` and `removed` are the `memberId`s of the charter's `addedModerator` and - /// `removedModerator` documents. A removal is final, so the order the documents were - /// filed in does not matter. A `resignationRequest` changes nothing by itself: the leader - /// acts on it with a removal. The leader is never among the result: neither list may - /// name it. + /// `removedModerator` documents that exist now: the leader takes an addition back by + /// deleting it, and a removal, which only names an elected member, puts the member back + /// when it is deleted. A removal wins over an addition of the same member, so the order + /// the documents were filed in does not matter. A `resignationRequest` changes nothing by + /// itself: the leader acts on it by deleting the member's addition or removing an elected + /// member. The leader is never among the result: neither list may name it. pub fn active_members<'a>( &self, leader_id: Identifier, diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 97fa7cc7af4..c484e45a962 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -173,7 +173,7 @@ fn should_combine_the_elected_members_the_additions_and_the_removals() { [id(3), id(4), id(5)].into() ); - // A removal is final: an addition of a removed member does not bring it back + // A removal wins over an addition of the same elected member assert_eq!( charter.active_members(leader, &[id(2)], &[id(2)]), [id(3), id(4)].into() diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index b3a7a45eb3c..adfebe26917 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -441,11 +441,18 @@ mod moderation_charters_tests { MODERATION_CHARTERS_CONTRACT_ID ); assert!(!document_type.documents_mutable(), "{name} is immutable"); - // A resignation request is withdrawn by deleting it; nothing refers to it + // A team change is undone by deleting it: an addition takes the member off, a + // removal puts them back, a resignation request is withdrawn. What makes the + // charter is final. assert_eq!( document_type.documents_can_be_deleted(), - name == RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, - "{name}: only a resignation request can be deleted" + [ + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + ] + .contains(&name), + "{name}: only the team changes can be deleted" ); } } @@ -914,8 +921,8 @@ mod moderation_charters_tests { } /// After the election the leader adds members from the same join requests and removes - /// members, and a member leaves on its own: each change is written once per member, and - /// only by the one entitled to it. + /// elected members, and a member asks to leave: each change is written once per member, + /// and only by the one entitled to it. #[test] fn should_let_only_the_leader_change_the_team_and_only_a_member_resign() { let contract = contract(); @@ -973,6 +980,26 @@ mod moderation_charters_tests { } other => panic!("addedModerator.memberId: {other:?}"), } + // Only an elected member can be removed; an added one is taken off by deleting the + // addition + match reference( + &contract, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + property_names::MEMBER_ID, + ) { + PropertyReference::Value(DocumentPropertyReferenceTarget::ListElement(listed)) => { + assert_eq!( + listed.document_type_name, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME + ); + assert_eq!(listed.in_list, property_names::MEMBERS); + assert_eq!( + listed.document_id_property(), + Some(property_names::ELECTED_CHARTER_ID) + ); + } + other => panic!("removedModerator.memberId: {other:?}"), + } for type_name in [ ADDED_MODERATOR_DOCUMENT_TYPE_NAME, REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, @@ -1022,7 +1049,8 @@ mod moderation_charters_tests { } /// Only a member of the seated team may ask to leave: the writer is listed in the elected - /// charter's `members`, or the leader added it after the election. The request is + /// charter's `members`, or the leader added it after the election and has not deleted the + /// addition. The request is /// deletable, which withdraws it, and carries a message only the leader can read. #[test] fn should_let_only_a_team_member_ask_to_leave() { @@ -1038,7 +1066,7 @@ mod moderation_charters_tests { ); }; match operands.operands() { - [DocumentPropertyReferenceTarget::ListElement(listed), DocumentPropertyReferenceTarget::PermanentDocumentLookup { + [DocumentPropertyReferenceTarget::ListElement(listed), DocumentPropertyReferenceTarget::DeletableDocumentLookup { document_type_name, lookup, .. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs index f2105c837da..b27a78f8196 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs @@ -8,7 +8,9 @@ //! protocol version 14 a seat is never replaced. So the charter seated on a contract is the one //! `byTargetContract` finds, and its team is its owner, the leader, plus its `members` and the //! `memberId` of every `addedModerator` for it, less the `memberId` of every `removedModerator` -//! for it ([`ElectedCharter::active_members`]). The moderation paths of the target read it from +//! for it ([`ElectedCharter::active_members`]). An addition is taken back by deleting it and a +//! removal, which only names an elected member, by deleting it too, so both lists are the +//! documents that exist now. The moderation paths of the target read it from //! there: there is no block-end seating hook and no copy under the moderated contract. //! //! Every read is a query of the charter contract, served from the system contract cache (it @@ -94,8 +96,10 @@ pub(crate) fn fetch_seated_moderation_charter( impl SeatedModerationCharter { /// Whether `identity_id` is on the seated team: the leader, or an active member. The - /// leader costs nothing more; an elected member costs a point read of its removal, and - /// anyone else a point read of its addition and, when there is one, of its removal. Both + /// leader costs nothing more. An elected member (one of the charter's `members`) is on + /// it unless the leader filed a `removedModerator` for it, and anyone else only while the + /// leader's `addedModerator` for it exists: one point read either way, since a removal + /// can only name an elected member and an addition is taken back by deleting it. Both /// types are unique on the charter and the member, so the team is never listed whole. #[allow(clippy::too_many_arguments)] pub(crate) fn seats( @@ -110,28 +114,27 @@ impl SeatedModerationCharter { if identity_id == self.leader_id { return Ok(true); } - let member_document = |document_type_name: &str, - execution_context: &mut StateTransitionExecutionContext| - -> Result { - Ok(!query_charter_documents( - drive, - document_type_name, - [ - (property_names::ELECTED_CHARTER_ID, self.id), - (property_names::MEMBER_ID, identity_id), - ], - 1, - epoch, - execution_context, - transaction, - platform_version, - )? - .is_empty()) - }; - let joined = self.charter.members.contains(&identity_id) - || member_document(ADDED_MODERATOR_DOCUMENT_TYPE_NAME, execution_context)?; - // A removal is final: whoever it names is off the team for good. - Ok(joined && !member_document(REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, execution_context)?) + let (document_type_name, present_means_seated) = + if self.charter.members.contains(&identity_id) { + (REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, false) + } else { + (ADDED_MODERATOR_DOCUMENT_TYPE_NAME, true) + }; + let present = !query_charter_documents( + drive, + document_type_name, + [ + (property_names::ELECTED_CHARTER_ID, self.id), + (property_names::MEMBER_ID, identity_id), + ], + 1, + epoch, + execution_context, + transaction, + platform_version, + )? + .is_empty(); + Ok(present == present_means_seated) } /// The share of each moderated type's declared moderators fee the team takes: its @@ -181,9 +184,10 @@ impl SeatedModerationCharter { } /// How many `addedModerator` documents name the elected charter `elected_charter_id`, counted up -/// to `up_to`: the additions ever filed for it (the type is immutable and undeletable), which the -/// target's `maxAddedModerators` caps. One billed query of the `byElectedCharterMember` index, -/// limited to `up_to` documents, so the cost is bounded by the cap. +/// to `up_to`: the members added to it now (deleting an addition takes the member off and frees +/// its slot), which the target's `maxAddedModerators` caps. One billed query of the +/// `byElectedCharterMember` index, limited to `up_to` documents, so the cost is bounded by the +/// cap. pub(crate) fn count_added_moderators( drive: &Drive, elected_charter_id: Identifier, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 1d11f820b2f..0e798651723 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -603,8 +603,14 @@ fn binds_a_changed_property( // another missing document) fails the existence check. A writer gate // is therefore never evaluated against a missing document: it is // checked against the new target, or not at all once the reference - // is cleared. - DocumentPropertyReferenceTarget::DeletableDocument { .. } => true, + // is cleared. Through a lookup the same holds, the document the key + // finds now being the target: an expression holding one is + // re-validated on every replace through the walk below, and an + // `ownerRefersTo` one gates every replace on the writer's document + // still existing. Reached from protocol version 14 only, the version + // whose parser produces it. + DocumentPropertyReferenceTarget::DeletableDocument { .. } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => true, DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, .. } => is_changed_field(changed_fields, key_id_property), @@ -830,6 +836,7 @@ fn validate_reference_target_v0( DocumentPropertyReferenceTarget::PermanentDocument { .. } | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } | DocumentPropertyReferenceTarget::DeletableDocument { .. } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } | DocumentPropertyReferenceTarget::ListElement(_) => { let Some(DocumentReferenceDeclaration { contract_id: referenced_contract_id, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs index 8b2600a8f30..739fa06f568 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs @@ -2,8 +2,9 @@ //! //! An `addedModerator` of the moderation charters system contract names a seated //! `electedCharter` and a member the leader adds from the proposal's join requests. The target -//! contract's elected declaration caps them: at most `maxAddedModerators` per charter, counting -//! the additions ever filed (the type is immutable and undeletable), so a removal frees no slot. +//! contract's elected declaration caps them: at most `maxAddedModerators` per charter at a time, +//! counting the additions that exist now (the leader takes one back by deleting it, which frees +//! its slot). //! The schema can not count documents, so this is a consensus rule of the document create, //! judged here once the create's own state validation passed: its references then proved the //! charter is seated, that its leader is the writer, and that the member asked to join. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs index a09a0378232..f84225acdf3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs @@ -9,7 +9,9 @@ //! adds a `propertyAgreement` checked against the moderator the lookup finds, //! and `note` declares an identity target, which every writer meets. //! `stepDownNotice` composes with `anyOf`: its writer is an added moderator -//! or the charter's founder (`founderSeat`). +//! or the charter's founder (`founderSeat`). `seatNotice` may only be written +//! by the `memberId` of a `deletableSeat`, which the founder can delete to +//! take the seat back: a `deletableDocument` found through a lookup. //! `moderatorBadge` can be transferred, so it declares `creatorRefersTo` //! instead: only a seated moderator may mint one, and whoever holds it later, //! the check is against that creator. `creatorNote` declares an identity @@ -390,6 +392,32 @@ mod owner_reference_tests { ); } + /// Deletes `document`, a `type_name` document owned by `who`. + async fn delete( + &mut self, + who: Who, + type_name: &str, + document: &Document, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let (document_type, writer, _) = self.parts(who, type_name); + let nonce = writer.next_nonce(); + let transition = BatchTransition::new_document_deletion_transition_from_document( + document.clone(), + document_type, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + self.process(&transition) + } + /// A resignation request by `who` from the elected charter `charter`. async fn request_resignation( &mut self, @@ -801,4 +829,92 @@ mod owner_reference_tests { "expected the last operand's 40120 at $ownerId" ); } + + /// A writer met through a deletable document found by a lookup, as a moderation charter's + /// added moderator the leader can take off: the writer passes while its seat exists, and + /// every replace asks again, one leaving the lookup keys alone included, since the seat + /// can be deleted. Once it is, the writer can no longer replace the document. + #[tokio::test] + async fn should_check_a_deletable_owner_lookup_on_every_replace() { + let mut fixture = OwnerReferenceFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let (seat, result) = fixture + .create( + Who::Founder, + "deletableSeat", + &[ + ("electedCharterId", id_value(charter_id(1))), + ("memberId", id_value(member)), + ], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let notice_values = [ + ("electedCharterId", id_value(charter_id(1))), + ("text", Value::from("on duty")), + ]; + let (notice, result) = fixture + .create(Who::Member, "seatNotice", ¬ice_values) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let assert_seat_not_found = |result, writer: Identifier| { + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), + .. + } if e.path() == "$ownerId" + && *e.entity_id() == writer + && matches!( + e.entity_type(), + DocumentPropertyReferenceTarget::DeletableDocumentLookup { + document_type_name, .. + } if document_type_name == "deletableSeat" + ), + "expected 40120 at $ownerId for a deletable seat" + ); + }; + let (_, result) = fixture + .create(Who::Stranger, "seatNotice", ¬ice_values) + .await; + assert_seat_not_found(result, stranger); + + // A replace touching nothing the lookup reads still reads the seat + let (result, execution_context) = fixture.validate_directly( + Who::Member, + None, + "seatNotice", + notice_values + .iter() + .map(|(property, value)| (property.to_string(), value.clone())) + .collect(), + Some(BTreeSet::from(["text".to_string()])), + ); + assert!(result.is_valid(), "{:?}", result.errors); + assert_matches!( + execution_context.operations_slice(), + [ValidationOperation::PrecalculatedOperation(fee)] if fee.processing_fee > 0, + "the lookup query is billed on every replace" + ); + + // The founder takes the seat back, and the member's next replace is refused + let result = fixture.delete(Who::Founder, "deletableSeat", &seat).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let result = fixture + .replace(Who::Member, "seatNotice", ¬ice, |notice| { + notice.set("text", "off duty".into()); + }) + .await; + assert_seat_not_found(result, member); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs index 455d1cfeab2..17f0b53752c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs @@ -54,6 +54,7 @@ use drive::util::object_size_info::DocumentInfo::DocumentRefInfo; use drive::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; use std::sync::Arc; +const REFERENCED_ENTITY_NOT_FOUND: u32 = 40120; const CONTRACT_MODERATION_ABILITY_NOT_GRANTED: u32 = 41201; const MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED: u32 = 41202; const DOCUMENT_ACTION_FEE_MODERATORS_SHARE_MISMATCH: u32 = 40139; @@ -443,6 +444,11 @@ impl Team { /// The leader's addition of `actor` to the seated team async fn addition_of(&self, actor: &Actor) -> StateTransition { + self.added(actor).await.1 + } + + /// The leader's addition of `actor` to the seated team, and the `addedModerator` it creates + async fn added(&self, actor: &Actor) -> (Document, StateTransition) { let properties = BTreeMap::from([ ( "electedCharterId".to_string(), @@ -459,11 +465,16 @@ impl Team { ]); self.charter_document(&self.leader, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, properties) .await - .1 } /// The leader's removal of `actor` from the seated team async fn removal_of(&self, actor: &Actor) -> StateTransition { + self.removed(actor).await.1 + } + + /// The leader's removal of `actor` from the seated team, and the `removedModerator` it + /// creates + async fn removed(&self, actor: &Actor) -> (Document, StateTransition) { let properties = BTreeMap::from([ ( "electedCharterId".to_string(), @@ -480,7 +491,28 @@ impl Team { properties, ) .await - .1 + } + + /// The leader's deletion of its team change `document` of `document_type_name`, which + /// undoes it + async fn undoing(&self, document_type_name: &str, document: Document) -> StateTransition { + let document_type = self + .charters + .document_type_for_name(document_type_name) + .expect("expected the charter document type"); + BatchTransition::new_document_deletion_transition_from_document( + document, + document_type, + &self.leader.key, + self.leader.contract_nonce(), + 0, + None, + &self.leader.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected to build the charter document deletion") } /// A post by `actor`, agreeing to `agreement` (`None`: no agreement at all), and the post @@ -716,10 +748,11 @@ async fn should_seat_the_winner_of_the_contest_and_let_its_team_moderate_instead } } -/// The leader adds members from the join requests and removes members: an added member -/// moderates and is protected until it is removed, a removed elected member no longer -/// moderates and can be moderated, and the leader and the active members can be neither banned -/// nor have their documents deleted, while the interim moderators and the owner lost that. +/// The leader adds members from the join requests and removes elected members: an added member +/// moderates and is protected until the leader deletes its addition, a removed elected member +/// no longer moderates and can be moderated until the leader deletes the removal, and the +/// leader and the active members can be neither banned nor have their documents deleted, while +/// the interim moderators and the owner lost that. #[tokio::test] async fn should_follow_additions_and_removals_and_protect_the_team() { let team = Team::new(InterimModerators::AppointedModerators( @@ -739,7 +772,8 @@ async fn should_follow_additions_and_removals_and_protect_the_team() { &setup.process(&ban, &transaction), IDENTITY_NOT_CONTRACT_MODERATOR, ); - assert_success(&setup.process(&team.addition_of(added).await, &transaction)); + let (addition, adding) = team.added(added).await; + assert_success(&setup.process(&adding, &transaction)); let ban = setup.moderate(added, ban_action(setup.user.id())).await; assert_success(&setup.process(&ban, &transaction)); @@ -773,9 +807,15 @@ async fn should_follow_additions_and_removals_and_protect_the_team() { assert_success(&setup.process(&warn, &transaction)); } - // Removed, the added member and the elected member moderate no more, and are moderated. + // Taken off, the added member by deleting its addition and the elected member by a + // removal, they moderate no more, and are moderated. + let taking_off = team + .undoing(ADDED_MODERATOR_DOCUMENT_TYPE_NAME, addition) + .await; + assert_success(&setup.process(&taking_off, &transaction)); + let (removal, removing) = team.removed(&team.member).await; + assert_success(&setup.process(&removing, &transaction)); for removed in [added, &team.member] { - assert_success(&setup.process(&team.removal_of(removed).await, &transaction)); let unban = setup.moderate(removed, unban_action(setup.user.id())).await; assert_paid_with_code( &setup.process(&unban, &transaction), @@ -790,38 +830,84 @@ async fn should_follow_additions_and_removals_and_protect_the_team() { .moderate(&team.leader, delete_action(POST, added_post.id())) .await; assert_success(&setup.process(&delete, &transaction)); + + // Deleting the removal puts the elected member back. + let reinstating = team + .undoing(REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, removal) + .await; + assert_success(&setup.process(&reinstating, &transaction)); + let unban = setup + .moderate(&team.member, unban_action(setup.user.id())) + .await; + assert_success(&setup.process(&unban, &transaction)); + let ban = setup + .moderate(&team.leader, ban_action(team.member.id())) + .await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); +} + +/// A removal names an elected member of the charter and nobody else: an added member is taken +/// off by deleting its addition, and an identity never on the team has nothing to remove. +#[tokio::test] +async fn should_remove_only_an_elected_member() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + team.award(); + + let transaction = setup.platform.drive.grove.start_transaction(); + let added = &team.joiners[0]; + assert_success(&setup.process(&team.addition_of(added).await, &transaction)); + for never_elected in [added, &team.joiners[1]] { + assert_paid_with_code( + &setup.process(&team.removal_of(never_elected).await, &transaction), + REFERENCED_ENTITY_NOT_FOUND, + ); + } + // The added member is still on the team. + let ban = setup.moderate(added, ban_action(setup.user.id())).await; + assert_success(&setup.process(&ban, &transaction)); } -/// The target's `maxAddedModerators` caps the additions to a seated charter: the Nth passes, the -/// (N+1)th is refused, paid, and a removal frees no slot. +/// The target's `maxAddedModerators` caps the additions a seated charter holds: the Nth passes, +/// the (N+1)th is refused, paid, and deleting an addition frees its slot. #[tokio::test] -async fn should_cap_the_members_a_leader_adds_and_free_no_slot_on_a_removal() { +async fn should_cap_the_members_a_leader_adds_and_free_a_slot_when_an_addition_is_deleted() { let team = Team::new(InterimModerators::ContractOwner).await; let setup = &team.setup; team.award(); let transaction = setup.platform.drive.grove.start_transaction(); let [first, second, third] = &team.joiners; - assert_success(&setup.process(&team.addition_of(first).await, &transaction)); + let (first_addition, adding_first) = team.added(first).await; + assert_success(&setup.process(&adding_first, &transaction)); assert_success(&setup.process(&team.addition_of(second).await, &transaction)); let over_the_cap = team.addition_of(third).await; assert_paid_with_code( &setup.process(&over_the_cap, &transaction), MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED, ); - - assert_success(&setup.process(&team.removal_of(first).await, &transaction)); - let after_a_removal = team.addition_of(third).await; - assert_paid_with_code( - &setup.process(&after_a_removal, &transaction), - MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED, - ); - // The third joiner never made it onto the team. + // The third joiner did not make it onto the team. let ban = setup.moderate(third, ban_action(setup.user.id())).await; assert_paid_with_code( &setup.process(&ban, &transaction), IDENTITY_NOT_CONTRACT_MODERATOR, ); + + let taking_off_first = team + .undoing(ADDED_MODERATOR_DOCUMENT_TYPE_NAME, first_addition) + .await; + assert_success(&setup.process(&taking_off_first, &transaction)); + assert_success(&setup.process(&team.addition_of(third).await, &transaction)); + let ban = setup.moderate(third, ban_action(setup.user.id())).await; + assert_success(&setup.process(&ban, &transaction)); + // The team is full again. + assert_paid_with_code( + &setup.process(&team.addition_of(first).await, &transaction), + MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED, + ); } /// A seated team acts with the declaration's abilities and no others; the interim moderators diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index 84cd520a2d1..9b0516f2190 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -474,7 +474,7 @@ fn validate_reference_target_declaration_v0( )); } - // A lookup, only ever on a permanentDocument reference, must + // A lookup, on a document reference of either kind, must // resolve in the referenced document type: a unique index its keys // cover exactly, filled from sources of the right kinds, with a key // that stays with the document it found. The contract parse checks diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json index 40bfc01f867..cd35fa22bfa 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json @@ -278,6 +278,82 @@ "electedCharterId" ], "additionalProperties": false + }, + "deletableSeat": { + "type": "object", + "canBeDeleted": true, + "documentsMutable": false, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + } + }, + "indices": [ + { + "name": "byElectedCharterMember", + "properties": [ + { + "electedCharterId": "asc" + }, + { + "memberId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "electedCharterId", + "memberId" + ], + "additionalProperties": false + }, + "seatNotice": { + "type": "object", + "ownerRefersTo": { + "type": "deletableDocument", + "documentType": "deletableSeat", + "lookup": { + "index": "byElectedCharterMember", + "keys": { + "electedCharterId": "electedCharterId", + "memberId": "." + } + } + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "text": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [ + "electedCharterId", + "text" + ], + "additionalProperties": false } } } diff --git a/packages/rs-drive/src/query/chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs index f62c585e819..7aa60c1be44 100644 --- a/packages/rs-drive/src/query/chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -235,7 +235,8 @@ impl<'a> DriveDocumentQuery<'a> { // outer document's id, so `as_document_reference` leaves it out; it // is named here so the refusal says why if let DocumentPropertyType::IdentifierWithReference( - DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { lookup, .. }, ) = &join_document_property.property_type { return Err(unsupported(format!( diff --git a/packages/rs-drive/src/query/composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs index be6387a6f42..28d31eb9e00 100644 --- a/packages/rs-drive/src/query/composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -621,7 +621,8 @@ impl<'a> DriveDocumentQuery<'a> { // `document_reference_of` leaves it out; it is named here so // the refusal says why if let Some(DocumentPropertyType::IdentifierWithReference( - DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { lookup, .. }, )) = source_property_type { return Err(label(&format!( diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 12c73e885c1..3ccd9aa9fe8 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -735,8 +735,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// a key assembled from the referring document. `keys` maps every index /// property to a property path of the referring type, `$ownerId` or `.` /// (the value, or the element, exactly once). A `deletableDocument` -/// reference takes none: a key into a deletable type could find a new -/// document once the one it found is deleted. Generation 3 of the parser +/// reference may take one too (`DeletableDocumentLookup`, appended): it +/// then means a document with this key exists now, since the key may find +/// a later document once the one it found is deleted, so every replace +/// re-validates it, an immutable property may not hold it, and it is the +/// one deletable form a reference expression and `ownerRefersTo` (never +/// `creatorRefersTo`) take. Generation 3 of the parser /// checks on every parse that each property a key reads is a stored, /// required, single value of the referring type; /// `create_document_types_from_document_schemas` 1, edited in place like @@ -772,9 +776,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// target keeps its variant and its encoding; decoding refuses a nesting /// deeper than `MAX_REFERENCE_EXPRESSION_DECODE_DEPTH`, 16, so the bytes of /// a consensus error cannot recurse without bound). An operand is a leaf, -/// an `identity` or a `permanentDocument` (by id or with a `lookup`, item -/// 32), or an expression of the other combinator; a list names two or more -/// operands. `contract`, `token`, `deletableDocument` and +/// an `identity`, a `permanentDocument` (by id or with a `lookup`, item +/// 32), a `listElement`, a `deletableDocument` with a `lookup` (which +/// re-validates the expression on every replace), or an expression of the +/// other combinator; a list names two or more operands. `contract`, +/// `token`, `deletableDocument` by id and /// `identityPublicKey` leaves, the key id form, a combinator directly /// inside the same combinator and keys beside a combinator are refused on /// every parse. Registration caps a list at @@ -808,9 +814,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// keyword (meta-schema v3, which reuses the property declaration by /// `$ref`), whose value is the document's `$ownerId`, the writer, instead /// of a property's: a single target, or a reference expression (item 33) -/// whose every leaf is one of the two targets that can hold a writer: +/// whose every leaf is one of the targets that can hold a writer: /// `identity`, and a `permanentDocument` found through a `lookup`, where -/// `.` is the writer; `contract`, `token` and a document by id (which the +/// `.` is the writer (and, for the writer alone, a `deletableDocument` +/// found through one, item 32); `contract`, `token` and a document by id (which the /// writer's identity id never is) and `identityPublicKey` (which needs a /// key id) are refused, as a leaf too. Parser generation 3 reads it from /// the stored schema once the core parse has run the meta-schema, on @@ -930,7 +937,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 37. **The moderation charters system contract** /// (`SystemDataContract::ModerationCharters`, schema v1, the first piece of /// decentralized moderation teams) carries seven document types, all -/// immutable and all but `resignationRequest` undeletable. A `reason` is a ground for a moderation +/// immutable, the four a charter is made of undeletable and the three team +/// changes deletable. A `reason` is a ground for a moderation /// action, keyed by its owner and a three-letter `code` unique among the /// owner's reasons. A `submittedCharter` is a leader's proposal to /// moderate one contract on that contract's own terms: its @@ -951,16 +959,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// which filed a join request for that proposal (item 32, a lookup through /// the join request's unique index) and none of which is the leader /// (item 26). Once a charter is seated, its leader adds members from the -/// same join requests (`addedModerator`, the same lookup) and removes -/// members (`removedModerator`), each once per member and charter (unique -/// indexes), removals final, so the team that acts is the leader plus the -/// elected members and the additions less the removals -/// (`ElectedCharter::active_members`). A member asks to leave with a -/// deletable `resignationRequest`, which only a member may file -/// (`ownerRefersTo` with an `anyOf` of a `listElement` into the elected -/// charter's `members` and a lookup of an `addedModerator`, items 33 to -/// 35) and which carries a message encrypted to the leader; the leader acts -/// on it with a removal. The cap on +/// same join requests (`addedModerator`, the same lookup) and takes them +/// back by deleting the addition, and removes elected members +/// (`removedModerator`, whose `memberId` is a `listElement` of the +/// charter's `members`), putting one back by deleting the removal; each +/// exists at most once per member and charter (unique indexes), so the +/// team that acts is the leader plus the elected members less the +/// removals plus the additions (`ElectedCharter::active_members`). A +/// member asks to leave with a deletable `resignationRequest`, which only +/// a member may file (`ownerRefersTo` with an `anyOf` of a `listElement` +/// into the elected charter's `members` and a `deletableDocument` lookup +/// of an `addedModerator`, items 32 to 35) and which carries a message +/// encrypted to the leader; the leader acts on it by deleting the addition +/// or removing an elected member. The cap on /// additions, the target's `maxAddedModerators`, is a consensus rule of /// item 40. /// Its `byTargetContract` index is a contested unique index @@ -1036,9 +1047,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `byTargetContract` index finds, and the moderation paths read it, each /// read a billed document query of the system contract. Once one is seated, /// only its team moderates the contract: the leader (the charter's owner) -/// and the active members (its `members` and additions, less removals), -/// each alone, found by at most two point reads of the unique -/// `addedModerator` and `removedModerator` indexes; the interim moderators +/// and the active members (its `members` less removals, plus additions), +/// each alone, found by one point read of the unique `removedModerator` +/// index for an elected member or of `addedModerator` for anyone else; the +/// interim moderators /// are refused (41101). The team holds the abilities the declaration gives /// it: a deletion or restore needs `deleteDocuments` on the type, a list /// action the ability on some moderated type @@ -1047,7 +1059,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// declaration says so, and the interim moderators no longer are. A /// `notYetUsable` interim stops blocking the moderated types /// (`contract_moderation_gate` v0). An `addedModerator` past the target's -/// `maxAddedModerators` additions ever filed for the charter is refused, +/// `maxAddedModerators` additions the charter holds is refused, /// paid (`ModerationCharterAddedModeratorLimitReachedError`, 41202), by a /// hook in the batch's `validate_state` v0 that only a create of the /// charter contract reaches. A document action on a moderated type may diff --git a/packages/rs-sdk/src/platform/moderation_charters/mod.rs b/packages/rs-sdk/src/platform/moderation_charters/mod.rs index 3752525d6e9..d73e1283b6d 100644 --- a/packages/rs-sdk/src/platform/moderation_charters/mod.rs +++ b/packages/rs-sdk/src/platform/moderation_charters/mod.rs @@ -18,7 +18,7 @@ //! * [`Sdk::fetch_join_requests`]: the join requests for a proposal //! (`joinRequest.bySubmittedCharter`), one page at a time. //! * [`Sdk::fetch_pending_resignation_requests`]: the resignation requests for a charter -//! whose writer the leader has not removed yet. +//! whose writer is still on the team. //! //! [`Sdk::build_join_request`] and [`Sdk::build_resignation_request`] build the two documents //! whose message only the leader reads, encrypted with the generic diff --git a/packages/rs-sdk/src/platform/moderation_charters/readers.rs b/packages/rs-sdk/src/platform/moderation_charters/readers.rs index 51f269cc5e2..6f638228d0e 100644 --- a/packages/rs-sdk/src/platform/moderation_charters/readers.rs +++ b/packages/rs-sdk/src/platform/moderation_charters/readers.rs @@ -216,15 +216,15 @@ pub(super) fn join_requests_query( ) } -/// The resignation requests among `requests` whose writer is not among `removed`, the -/// `memberId`s of the charter's removals: the ones the leader has not acted on. +/// The resignation requests among `requests` whose writer is still on `team`: the ones the +/// leader has not acted on, by deleting the writer's addition or removing an elected writer. pub(super) fn pending_resignation_requests( requests: Vec, - removed: &BTreeSet, + team: &ModerationTeam, ) -> Vec { requests .into_iter() - .filter(|request| !removed.contains(&request.owner_id())) + .filter(|request| team.contains(&request.owner_id())) .collect() } @@ -382,19 +382,33 @@ impl Sdk { } /// The resignation requests for the charter `elected_charter_id` the leader has not acted - /// on: those whose writer the charter has no `removedModerator` for. A withdrawn request is - /// deleted, so it is not among them either. + /// on: those whose writer is still on the team (the leader takes an added member off by + /// deleting its `addedModerator`, and an elected one with a `removedModerator`). A + /// withdrawn request is deleted, so it is not among them either. Empty when there is no + /// such charter. pub async fn fetch_pending_resignation_requests( &self, elected_charter_id: Identifier, ) -> Result, Error> { let contract = self.fetch_moderation_charters_contract().await?; - let (requests, removed) = futures::try_join!( + let Some(seated) = self + .fetch_elected_charter_of(contract.clone(), elected_charter_id) + .await? + else { + return Ok(vec![]); + }; + let (requests, added, removed) = futures::try_join!( self.fetch_every_page(|page| resignation_requests_query( contract.clone(), elected_charter_id, page, )), + self.fetch_every_page(|page| team_change_query( + contract.clone(), + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + elected_charter_id, + page, + )), self.fetch_every_page(|page| team_change_query( contract.clone(), REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, @@ -402,10 +416,8 @@ impl Sdk { page, )), )?; - Ok(pending_resignation_requests( - requests, - &member_ids(&removed)?, - )) + let team = ModerationTeam::from_documents(&seated.document, &added, &removed)?; + Ok(pending_resignation_requests(requests, &team)) } /// Every document the query `query_for_page` builds matches, page after page, or an error @@ -629,11 +641,18 @@ mod tests { } #[test] - fn should_keep_only_the_resignation_requests_whose_writer_was_not_removed() { + fn should_keep_only_the_resignation_requests_whose_writer_is_still_on_the_team() { let requests = vec![document(1, 0xA1), document(2, 0xA2), document(3, 0xA3)]; - let removed = BTreeSet::from([Identifier::from([0xA2; 32]), Identifier::from([0xFF; 32])]); + // 0xA2 was taken off: an elected member removed, or an added one whose addition + // was deleted, is no longer among the members either way + let team = ModerationTeam { + elected_charter_id: Identifier::from([0xE1; 32]), + submitted_charter_id: Identifier::from([0xE2; 32]), + leader_id: Identifier::from([0xE3; 32]), + members: BTreeSet::from([Identifier::from([0xA1; 32]), Identifier::from([0xA3; 32])]), + }; - let pending = pending_resignation_requests(requests, &removed); + let pending = pending_resignation_requests(requests, &team); assert_eq!( pending @@ -642,7 +661,7 @@ mod tests { .collect::>(), vec![Identifier::from([0xA1; 32]), Identifier::from([0xA3; 32])] ); - assert!(pending_resignation_requests(vec![], &removed).is_empty()); + assert!(pending_resignation_requests(vec![], &team).is_empty()); } #[test] diff --git a/packages/rs-sdk/src/platform/moderation_charters/team.rs b/packages/rs-sdk/src/platform/moderation_charters/team.rs index 58028f00b18..7ce66326b48 100644 --- a/packages/rs-sdk/src/platform/moderation_charters/team.rs +++ b/packages/rs-sdk/src/platform/moderation_charters/team.rs @@ -116,20 +116,17 @@ mod tests { #[test] fn should_combine_members_additions_and_removals_as_active_members_does() { - // Elected 2, 3, 4; added 5, 6; removed 3 (elected) and 6 (added) and 9 (never on it) + // Elected 2, 3, 4; added 5 and 6; 3 removed. An addition the leader deleted, or a + // removal, is not read at all. let (document, charter) = elected_charter(&[2, 3, 4]); let added = [change(0x50, CHARTER, 5), change(0x51, CHARTER, 6)]; - let removed = [ - change(0x60, CHARTER, 3), - change(0x61, CHARTER, 6), - change(0x62, CHARTER, 9), - ]; + let removed = [change(0x60, CHARTER, 3)]; let team = ModerationTeam::from_documents(&document, &added, &removed).expect("reads"); - let expected = charter.active_members(id(LEADER), &[id(5), id(6)], &[id(3), id(6), id(9)]); + let expected = charter.active_members(id(LEADER), &[id(5), id(6)], &[id(3)]); assert_eq!(team.members, expected); - assert_eq!(team.members, BTreeSet::from([id(2), id(4), id(5)])); + assert_eq!(team.members, BTreeSet::from([id(2), id(4), id(5), id(6)])); assert_eq!(team.leader_id, id(LEADER)); assert_eq!(team.elected_charter_id, id(CHARTER)); assert_eq!(team.submitted_charter_id, id(0xBB)); diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index d59e5f973c0..6c8fd8cc596 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -194,6 +194,15 @@ export type DocumentPropertyReferenceTarget = * Absent — not `{}`-valued — when the declaration carries none. */ propertyAgreement?: Record; + /** + * How the referenced document is found when the property's value is + * not its id. See {@link DocumentReferenceLookup}. Absent when the + * value is the referenced document's `$id`. With a lookup the + * reference means "a document with this key exists now": once the one + * it found is deleted the key may find another, and every replace + * re-validates it. + */ + lookup?: DocumentReferenceLookup; } | { /** @@ -246,12 +255,16 @@ export type DocumentPropertyReferenceExpression = | { type: 'allOf'; allOf: Array }; /** - * One operand of a reference expression: a leaf, an `identity` or a - * `permanentDocument` (by id or with a `lookup`) with its own fields, a + * One operand of a reference expression: a leaf, an `identity`, a + * `permanentDocument` (by id or with a `lookup`), a `listElement` or a + * `deletableDocument` with a `lookup` with its own fields, a * `propertyAgreement` belonging to its own leaf, or a nested expression. */ export type DocumentPropertyReferenceOperand = - | Extract + | Extract< + DocumentPropertyReferenceTarget, + { type: 'identity' | 'permanentDocument' | 'listElement' | 'deletableDocument' } + > | DocumentPropertyReferenceExpression; /** @@ -265,9 +278,11 @@ export type DocumentPropertyReferenceOperand = * a property path of the referring document type, `'$ownerId'` for the * referring document's owner, or `'.'` for the value of the property that * carries the reference (exactly once). To resolve a reference yourself, - * query the index with those values: at most one document matches. Only a - * `permanentDocument` reference carries a lookup, and its key cannot move - * off the document it found, so it keeps resolving. + * query the index with those values: at most one document matches. On a + * `permanentDocument` reference the key cannot move off the document it + * found, so it keeps resolving; on a `deletableDocument` reference it may + * find nothing, or a later document with the same key, once the one it + * found is deleted. */ export type DocumentReferenceLookup = { index: string; @@ -291,10 +306,12 @@ export type DocumentPropertyReference = { * `"$ownerId"`, which is not a property path: the value it checks is the * document's owner. Consensus checks it with the writer's id when a * document is created, and when a replace changes a property its `lookup` - * or `propertyAgreement` reads; in its `lookup`, `'.'` is the writer. Its - * `type` is `identity` or a `permanentDocument` with a `lookup`, the only - * targets a writer can be, on a document type whose documents can be - * neither transferred nor traded. On a type whose documents can, a + * or `propertyAgreement` reads (every replace for a `deletableDocument` + * lookup, which gates the writer on its document still existing); in its + * `lookup`, `'.'` is the writer. Its `type` is `identity`, a + * `permanentDocument` or `deletableDocument` with a `lookup`, or a + * `listElement`, the targets a writer can be, on a document type whose + * documents can be neither transferred nor traded. On a type whose documents can, a * `creatorRefersTo` declaration takes its place, listed first with the * path `"$creatorId"`: the same, with the document's creator, who never * changes, as the value. @@ -468,7 +485,8 @@ fn set_reference_target_fields( DocumentPropertyReferenceTarget::PermanentDocument { .. } | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", - DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", + DocumentPropertyReferenceTarget::DeletableDocument { .. } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => "deletableDocument", DocumentPropertyReferenceTarget::ListElement(_) => "listElement", DocumentPropertyReferenceTarget::AnyOf(_) | DocumentPropertyReferenceTarget::AllOf(_) => { return Err(WasmDppError::generic(format!( @@ -543,6 +561,12 @@ fn set_reference_target_fields( contract_id, document_type_name, property_agreement, + } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { + contract_id, + document_type_name, + property_agreement, + .. } => { let effective = contract_id.unwrap_or(declaring_contract_id); set_field( @@ -561,7 +585,8 @@ fn set_reference_target_fields( // Present only on a lookup reference, absent when the value is // the referenced document's id, as the schema omits it; the // sources keep their schema spelling. - if let DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } = target + if let DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } + | DocumentPropertyReferenceTarget::DeletableDocumentLookup { lookup, .. } = target { let lookup_object = Object::new(); set_field( diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 33c96db7fe8..db76fe5ef80 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -615,6 +615,33 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(joinRequest).to.not.have.property('path'); }); + it('should carry a deletableDocument operand found through a lookup, with its lookup', () => { + // The leader takes an added moderator off by deleting the addition + const deletable = structuredClone(expressionSchemas); + deletable.addedModerator.canBeDeleted = true; + const memberId = deletable.resignation.properties.memberId as { refersTo: { anyOf: { type: string }[] } }; + memberId.refersTo.anyOf[1].type = 'deletableDocument'; + const contract = buildExpressionContract(deletable); + const [member] = contract.documentTypeReferences('resignation') as Reference[]; + + const [, addedModerator] = member.anyOf!; + expect(addedModerator.type).to.equal('deletableDocument'); + expect(addedModerator.documentType).to.equal('addedModerator'); + expect(addedModerator.lookup).to.deep.equal({ + index: 'byModerator', + keys: { moderatorId: '.', submittedCharterId: 'submittedCharterId' }, + }); + }); + + it('should refuse a deletableDocument operand found by id', () => { + const byId = structuredClone(expressionSchemas); + byId.addedModerator.canBeDeleted = true; + const memberId = byId.resignation.properties.memberId as { refersTo: { anyOf: object[] } }; + memberId.refersTo.anyOf[1] = { type: 'deletableDocument', documentType: 'addedModerator' }; + + expect(() => buildExpressionContract(byId)).to.throw(); + }); + it('should carry an anyOf the elements of a typed array declare', () => { const withMembers = structuredClone(expressionSchemas); const properties = withMembers.resignation.properties as Record; diff --git a/packages/wasm-sdk/src/moderation_charters.rs b/packages/wasm-sdk/src/moderation_charters.rs index 6971e22cc74..6183f32f45d 100644 --- a/packages/wasm-sdk/src/moderation_charters.rs +++ b/packages/wasm-sdk/src/moderation_charters.rs @@ -323,8 +323,9 @@ impl WasmSdk { } /// The resignation requests for a seated charter the leader has not acted on: those whose - /// writer the charter has no removal for. A withdrawn request is deleted, so it is not - /// among them either. + /// writer is still on the team (an added member is taken off by deleting its addition, an + /// elected one by a removal). A withdrawn request is deleted, so it is not among them + /// either. /// /// @param electedCharterId - The seated charter, an `electedCharter` document. #[wasm_bindgen( From 5c5967211c003fff71785d9a72a41a16cd9d1a2e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 12:40:16 +0700 Subject: [PATCH 2/2] test(dpp): the v3 meta-schema accepts a lookup on a deletableDocument reference Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/validation/meta_validators/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index b074c6b4c99..e31e0a8bb23 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -450,8 +450,8 @@ mod tests { } #[test] - fn should_accept_a_lookup_on_a_permanent_document_refers_to_in_v3_document_schema() { - for reference_type in ["permanentDocument"] { + fn should_accept_a_lookup_on_a_document_refers_to_in_v3_document_schema() { + for reference_type in ["permanentDocument", "deletableDocument"] { for keys in [ json!({ "submittedCharterId": "submittedCharterId", "$ownerId": "." }), json!({ "submittedCharterId": ".", "$ownerId": "$ownerId" }), @@ -479,8 +479,8 @@ mod tests { json!({ "type": "contract", "lookup": { "index": "byOwner", "keys": keys } }), json!({ "type": "token", "lookup": { "index": "byOwner", "keys": keys } }), json!({ "type": "identityPublicKey", "keyIdProperty": "keyId", "lookup": { "index": "byOwner", "keys": keys } }), - json!({ "type": "deletableDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": keys } }), json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "keys": keys } }), + json!({ "type": "deletableDocument", "documentType": "note", "lookup": { "index": "byOwner" } }), json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner" } }), json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "", "keys": keys } }), json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "x".repeat(33), "keys": keys } }),