Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Platform uses data contracts to define application data schemas:
Use LF, UTF‑8, and a final newline.
- JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages.
- Rust: Follow rustfmt defaults; keep code clippy-clean. Modules `snake_case`, types `PascalCase`, constants `SCREAMING_SNAKE_CASE`.
- Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: shipped `vN` modules are frozen and new behaviour is a new `vN` selected only by the unreleased protocol version's tables; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`.
- Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: new behaviour is a new `vN` selected only by the unreleased protocol version's tables, and a shipped `vN` is edited in place only when the edit provably cannot modify consensus there, with the reason in the PR description's "In-place changes to shipped generations" section; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`.

## Testing Guidelines
- Unit/integration tests live alongside each package (e.g., `packages/<name>/tests`). E2E lives in `packages/platform-test-suite`.
Expand Down
58 changes: 41 additions & 17 deletions book/src/contributing/coding-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,25 +72,38 @@ The mechanics of `PlatformVersion`, feature version tables, and the dispatcher
shape are covered in the [Versioning](../versioning/platform-version.md)
chapters. The rules here are about what to do with those mechanics.

### Shipped generations are frozen
### Shipped generations are frozen unless the change cannot modify consensus

A behaviour change to a versioned method means a new `vN` module selected only
by the tables of the unreleased protocol version. It never means editing a
shipped `vN`, and that includes "harmless" edits: threading a new parameter
through it, adding a version-table check inside it, or computing a gate that is
always false for old versions. Inside the new generation the capability is a
constant fact (`Index::try_from_value_map(map, true)`), not a runtime check.

Why: replay safety becomes structural instead of something a reviewer has to
prove about a diff. A dead version check inside `v1` misleads the next reader
into thinking `v1` can take that path. Shipped files should stay byte-identical
to what shipped.

How: copy the previous generation into the new module, make the change there,
move the tests that exercise the new behaviour into the new module, and bump
the method's number in the new protocol version's tables only. Duplication
between generations is the accepted cost; it is cheaper than a drift-prone
flag.
by the tables of the unreleased protocol version. A shipped `vN` may be edited
in place only when we are sure the edit cannot modify consensus at any protocol
version that selects it: the new code is unreachable there by construction
(the data it acts on cannot exist under those versions, such as a keyword every
one of their meta-schemas refuses and their parser ignores, judged through a
dpp method whose own gate is `None` there), or the edit is a pure refactor with
identical output. "Probably inert" is not enough. If the argument takes more
than a sentence, or rests on a runtime check inside the shipped module, add a
generation instead. Inside a new generation the capability is a constant fact
(`Index::try_from_value_map(map, true)`), not a runtime check.

Why: replay safety is structural when a shipped file stays byte-identical, and
becomes a proof the reviewer has to check the moment it does not. An in-place
edit is acceptable when that proof is short and written down; a dead version
check inside `v1` that misleads the next reader into thinking `v1` can take
that path is not.

How, new generation: copy the previous generation into the new module, make
the change there, move the tests that exercise the new behaviour into the new
module, and bump the method's number in the new protocol version's tables
only. Duplication between generations is the accepted cost; it is cheaper than
a drift-prone flag.

How, in place: make the edit, leave a comment at the edited lines naming why
they are inert for every protocol version that selects the module, and give
the pull request description an "In-place changes to shipped generations"
section that lists each edited generation, the protocol versions that select
it, and the reason consensus cannot change there. Reviewers read that section
first.

### Table versions follow protocol-version boundaries, not PRs

Expand Down Expand Up @@ -472,6 +485,17 @@ now does.
`PlatformVersion::latest()`; pin `vN/`'s tests to `PlatformVersion::get(n)`.
5. Add a test that runs both versions through the dispatcher.

**Editing a shipped generation in place**

1. Only when the edit cannot modify consensus at any protocol version that
selects the module: unreachable by construction there, or output-identical.
2. Comment the edited lines with why they are inert for those versions.
3. Add a test that runs the module at the last shipped protocol version and
shows the outcome unchanged.
4. Add an "In-place changes to shipped generations" section to the pull
request description: each edited generation, the versions that select it,
and the reason consensus cannot change there.

**Changing a limit or a fee**

1. A number: add or update the `SystemLimits` (or `*_constants`) field, backfill
Expand Down
32 changes: 32 additions & 0 deletions book/src/data-model/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,38 @@ The array is stored inline in the document, like any other property: a varint el

In Rust a typed array parses to `DocumentPropertyType::TypedArray(TypedArrayProperty)`, whose `item_type` is the `DocumentPropertyType` the `items` schema parses to as a property schema (`try_from_value_map` with the contract's parsing options). The parse is the versioned `parse_typed_array` (`None` before protocol version 14, where an array that is not a byte array is refused as it always was). The older `DocumentPropertyType::Array` variant, whose elements are an `ArrayItemType` in their own length-prefixed encoding, is never produced by the parser.

## Distinct Identifier Properties

Protocol version 14 adds the property-level `distinctFrom` keyword, a pure structure rule on identifier properties: the property's value must differ from the value of a named property of the same document, or from the document's `$ownerId`. It sits next to the reference keywords (`refersTo` and its `propertyAgreement`, which bind a property to another document's values) but reads nothing beyond the transition being written.

```json
"delegateId": {
"type": "array", "byteArray": true, "minItems": 32, "maxItems": 32,
"contentMediaType": "application/x.dash.dpp.identifier",
"distinctFrom": "$ownerId",
"position": 0
},
"backupId": {
"type": "array", "byteArray": true, "minItems": 32, "maxItems": 32,
"contentMediaType": "application/x.dash.dpp.identifier",
"distinctFrom": "delegateId",
"position": 1
}
```

The value is `"$ownerId"` or the dotted path of another property of the document type (`"meta.reviewerId"` for a nested one). The parser (generation 3, meta-schema v3) checks the declaration when a contract enters the chain, on registration and on update:

- The keyword is only allowed on identifier properties, enforced by the same dependent schema shape that restricts `refersTo`.
- A named property must exist on the document type, must itself be an identifier (the only kind the value can be compared with), and must not be the declaring property. `$ownerId` needs no check; no other system property is accepted.
- On contract update a changed, added or removed `distinctFrom` is an incompatible schema change, like a changed `refersTo`.
- A typed array of identifiers declares it on its `items`, and every element must then differ from the named value; the declaration is refused on the array itself and on elements of any other type.

Enforcement lives in the structure validation of the document create and replace actions (create structure generation 1, introduced at protocol version 14, and replace structure generation 0, extended in place: the call is inert before 14, where no property can carry the keyword), after the schema validation of the document's properties, so every value compared is already a 32-byte identifier. The check reads the transition's data and the owner id it carries and never touches Drive; the declaring properties come from a list the parser built (`distinct_from_fields`), so a type without declarations costs nothing. An equal pair fails the write with `DocumentPropertyNotDistinctError` (basic code 10419), which names the document type, the property and what it collided with. When the named property is absent from the document there is nothing to differ from, so the rule passes.

A transfer or purchase changes `$ownerId` without touching the data, so the transfer and purchase structure validations (generation 0, extended in place: the call is inert before protocol version 14, where no property can carry the keyword) judge the stored document against its new owner: a transfer to, or a purchase by, the identity a `$ownerId`-distinct property names is refused with the same error. Price updates change neither owner nor data and are not judged.

In Rust the declaration is `DocumentProperty::distinct_from` (`Option<DistinctFrom>`, absent on every property parsed before protocol version 14), the document check is `DocumentTypeV0Methods::validate_distinct_from_properties`, and `DistinctFrom::violation` judges one value on its own, which is how the elements of a typed array are judged one by one.

## Rules and Guidelines

**Do:**
Expand Down
2 changes: 1 addition & 1 deletion book/src/error-handling/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Error codes are organized into ranges that correspond to error categories and su
| 10200-10277 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276), `PreProgrammedDistributionAmountOverLimitError` (10277) |
| 10350-10359 | Groups | `GroupPositionDoesNotExistError` (10350), `GroupExceedsMaxMembersError` (10354) |
| 10360-10367 | Contract Groups | `ContractGroupMembershipsOverLimitError` (10360), `InvalidContractGroupAdminsError` (10364), `InvalidContractGroupDescriptionLengthError` (10367); 10365 unassigned |
| 10400-10418 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401) |
| 10400-10419 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419) |
| 10450-10460 | Tokens | `InvalidTokenIdError` (10450), `TokenTransferToOurselfError` (10456) |
| 10500-10535 | Identity | `DuplicatedIdentityPublicKeyBasicError` (10500), `InvalidIdentityPublicKeyDataError` (10511) |
| 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) |
Expand Down
18 changes: 10 additions & 8 deletions book/src/versioning/versioned-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,16 @@ method, never the versioned implementation directly.
The layout is the versioning contract made physical, and three rules follow
from it:

- **One directory per generation, always.** A behaviour change to a versioned
method is a new `v1/` (or `v2/`, ...) directory with its own `mod.rs`, plus
a new match arm. It is never an edit inside `v0/`. That includes edits that
look harmless: threading a new parameter through `v0`, adding an
`if platform_version.protocol_version >= 14` inside it, or computing a
version-table gate that is always false for old versions. A shipped `vN/`
stays byte-identical to what shipped, so a reviewer never has to prove that
an in-place diff is inert for old blocks.
- **One directory per generation.** A behaviour change to a versioned method
is a new `v1/` (or `v2/`, ...) directory with its own `mod.rs`, plus a new
match arm. An edit inside a shipped `v0/` is allowed only when it cannot
modify consensus at any protocol version that selects `v0/`, because the
code it adds is unreachable there by construction or its output is
identical; the edited lines say why, and the pull request description
carries an "In-place changes to shipped generations" section (see the
[coding conventions](../contributing/coding-conventions.md)). An
`if platform_version.protocol_version >= 14` inside `v0/` is not that: it is
a runtime check the reader has to trust, so it gets a generation.
- **Inside a generation, a capability is a constant fact, not a check.** If
`v1` admits a new keyword, `v1` admits it unconditionally
(`Index::try_from_value_map(map, true)`). The decision of whether the
Expand Down
66 changes: 66 additions & 0 deletions packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@
"type": "integer",
"minimum": 0
},
"distinctFrom": {
"description": "Only on identifier properties: the value must differ from the value of the named property of the same document (a dotted path for a nested one) or, as \"$ownerId\", from the document's owner. Checked on document create and replace against the transition alone; an equal pair is refused (DocumentPropertyNotDistinctError, 10419). When the named property is absent from the document there is nothing to differ from, so the rule passes. A transfer to, or a purchase by, the identity a $ownerId declaration names is refused the same way, judged against the stored document. The named property must exist on the document type, be an identifier, and not be the declaring property itself. On a typed array of identifiers the keyword goes on the items and applies to every element. Available from protocol version 14.",
"type": "string",
"minLength": 1,
"maxLength": 256
},
"requiredSince": {
"type": "integer",
"minimum": 1,
Expand Down Expand Up @@ -385,6 +391,33 @@
"maxLength"
]
},
"distinctFrom": {
"description": "distinctFrom is only allowed on identifier properties",
"properties": {
"type": {
"const": "array"
},
"byteArray": {
"const": true
},
"contentMediaType": {
"const": "application/x.dash.dpp.identifier"
},
"minItems": {
"const": 32
},
"maxItems": {
"const": 32
}
},
"required": [
"type",
"byteArray",
"contentMediaType",
"minItems",
"maxItems"
]
},
"refersTo": {
"description": "refersTo is only allowed on identifier properties",
"properties": {
Expand Down Expand Up @@ -588,13 +621,46 @@
"byteArray": {
"type": "boolean",
"const": true
},
"distinctFrom": {
"description": "Only on identifier elements: every element must differ from the value of the named property of the same document (a dotted path for a nested one) or, as \"$ownerId\", from the document's owner, exactly as distinctFrom on an identifier property. The declaration belongs on the items, not on the array. Available from protocol version 14.",
"type": "string",
"minLength": 1,
"maxLength": 256
}
},
"required": [
"type"
],
"additionalProperties": false,
"dependentSchemas": {
"distinctFrom": {
"description": "distinctFrom is only allowed on identifier elements",
"properties": {
"type": {
"const": "array"
},
"byteArray": {
"const": true
},
"contentMediaType": {
"const": "application/x.dash.dpp.identifier"
},
"minItems": {
"const": 32
},
"maxItems": {
"const": 32
}
},
"required": [
"type",
"byteArray",
"contentMediaType",
"minItems",
"maxItems"
]
},
"byteArray": {
"description": "should be used only with array type",
"properties": {
Expand Down
24 changes: 24 additions & 0 deletions packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,14 @@ impl DocumentTypeV2Getters for DocumentType {
}
}

fn distinct_from_fields(&self) -> &[String] {
match self {
DocumentType::V0(_) => &[],
DocumentType::V1(_) => &[],
DocumentType::V2(v2) => v2.distinct_from_fields(),
}
}

fn immutable_fields(&self) -> &BTreeSet<String> {
match self {
DocumentType::V0(_) => &NO_IMMUTABLE_FIELDS,
Expand Down Expand Up @@ -1138,6 +1146,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> {
}
}

fn distinct_from_fields(&self) -> &[String] {
match self {
DocumentTypeRef::V0(_) => &[],
DocumentTypeRef::V1(_) => &[],
DocumentTypeRef::V2(v2) => v2.distinct_from_fields(),
}
}

fn immutable_fields(&self) -> &BTreeSet<String> {
match self {
DocumentTypeRef::V0(_) => &NO_IMMUTABLE_FIELDS,
Expand Down Expand Up @@ -1229,6 +1245,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> {
}
}

fn distinct_from_fields(&self) -> &[String] {
match self {
DocumentTypeMutRef::V0(_) => &[],
DocumentTypeMutRef::V1(_) => &[],
DocumentTypeMutRef::V2(v2) => v2.distinct_from_fields(),
}
}

fn immutable_fields(&self) -> &BTreeSet<String> {
match self {
DocumentTypeMutRef::V0(_) => &NO_IMMUTABLE_FIELDS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ pub trait DocumentTypeV2Getters {
/// mutable, where every property is already immutable.
fn immutable_fields(&self) -> &BTreeSet<String>;

/// The dotted paths of the properties that declare `distinctFrom`
/// (protocol version 14), in schema order. Empty on generations that
/// predate the keyword.
fn distinct_from_fields(&self) -> &[String];

/// The subset of [`Self::immutable_fields`] a replace may still set while
/// the stored document has no value for them (the
/// `immutableAllowSetting` keyword, protocol version 14). Once present
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,16 @@ fn parse_document_properties(
.map_err(consensus_or_protocol_data_contract_error)?;
}

// Every property is in the flattened map now, so a `distinctFrom` target
// can be resolved against its siblings. Gated on the same version that
// parsed the declarations, so the two halves of the rule move together.
super::validate_distinct_from_targets(
&flattened_document_properties,
ctx.name,
ctx.platform_version,
)
.map_err(consensus_or_protocol_data_contract_error)?;

Ok(ParsedProperties {
flattened_document_properties,
document_properties,
Expand Down
Loading
Loading