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
20 changes: 20 additions & 0 deletions book/src/data-model/documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,26 @@ Enforcement lives in the replace action's state validation (generation 1). The a

In Rust the lists are `DocumentTypeV2Getters::immutable_fields()` and `immutable_fields_allow_setting()`. Earlier document type generations return empty sets.

## Transient Properties

The doctype-level `transient` keyword lists top-level properties whose values are validated on the transition but never stored. DPNS uses it for the `domain`'s `preorderSalt`: the write proves the salted preorder, and the salt is then dropped.

```json
"transient": ["preorderSalt"]
```

A create drops the listed values before its document is built. Before protocol version 14 a replace stored whatever it carried, so a replaced document kept values its create had dropped; from protocol version 14 a replace drops them the same way (`document_from_replace_transition_action` 1). Values are dropped by top-level name, so a leaf of a transient object goes with the object.

Because no stored document carries a transient value, a rule that reads a stored value refuses a transient one, judged by the property's path and every object around it (`is_transient`). From protocol version 14, at registration:

- Every `transient` entry names a top-level property. A nested path, a system property or an undeclared name would mark a property transient in the parsed type while its value was still stored.
- No index reads a transient property. Every document would sit in the index's null branch, so a query by the value would find nothing and a unique index would enforce nothing.
- A `refersTo` lookup reads no transient property to assemble its key, and its index keys documents by none. A `propertyAgreement` names none on its referenced side. The referring side may be transient: it is judged on the transition, a write gate like the writer's `$ownerId`.
- A key reference does not store its key id with a transient identity, whichever side declares it (`identityProperty` on the key id, `keyIdProperty` on the identity): the key id alone names no key.
- `encryptedFor` names no transient recipient or key id.

The list cannot change on contract update: it decides which values stored documents carry and how every property is encoded (a transient property takes a presence byte even when required). From protocol version 14 any change to the names it lists is an incompatible schema change (the list is compared as a set, so reordering or repeating a name is no change); before it, the schema compatibility check failed on the keyword as unsupported, an internal error.

## Typed Arrays

Up to protocol version 13 a `type: "array"` property had to be a byte array (`byteArray: true`). Protocol version 14 adds typed arrays: a list whose `items` schema says what every element is.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1727,7 +1727,8 @@
"type": "array",
"items": {
"type": "string"
}
},
"description": "Names of top-level properties whose values are validated on the transition but never stored: a create, and from protocol version 14 a replace, drops them before the document is written. From protocol version 14 every entry must name a top-level property (list the object around a nested one); no index may read a transient property or one inside a transient object; a refersTo lookup may not read one on either side, a propertyAgreement may not name one on its referenced side, and a stored key id may not pair with a transient identity; encryptedFor may not name one. Adding, removing or changing an entry on contract update is an incompatible schema change."
},
"immutable": {
"type": "array",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ fn parse_with(
/// Parse through the real dispatcher, which picks the parser generation out
/// of the platform version's `try_from_schema` table value (generation 2 at
/// PV13, generation 3 at PV14).
fn parse_dispatched(
pub(super) fn parse_dispatched(
schema: Value,
platform_version: &PlatformVersion,
full_validation: bool,
Expand Down Expand Up @@ -108,7 +108,10 @@ fn names(entries: &[&str]) -> BTreeSet<String> {

/// The lints surface as `InvalidContractStructure` either directly or, with
/// the `validation` feature on, wrapped as the basic `ContractError`.
fn expect_structure_error<T: std::fmt::Debug>(result: Result<T, ProtocolError>, needle: &str) {
pub(super) fn expect_structure_error<T: std::fmt::Debug>(
result: Result<T, ProtocolError>,
needle: &str,
) {
let message = match result {
Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure(
message,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ use crate::data_contract::document_type::index::IndexGrammarAdmissions;
use crate::data_contract::document_type::property::DocumentPropertyType;
#[cfg(feature = "validation")]
use crate::data_contract::document_type::property::{
DocumentPropertyReferenceTarget, PropertyReference, ReferenceHolder, ReferenceOperands,
is_transient, DocumentPropertyReferenceTarget, PropertyReference, ReferenceHolder,
ReferenceOperands,
};
use crate::data_contract::document_type::property_names;
use crate::data_contract::document_type::reference_lookup::owner_can_change;
Expand Down Expand Up @@ -512,11 +513,84 @@ fn try_from_schema_generation_3(
validate_reference_expressions(&v2, data_contract_id, name, platform_version)?;
validate_reference_count(&v2, name, platform_version)?;
validate_no_immutable_deletable_element_references(&v2, name)?;
validate_transient_fields(&v2, name)?;
validate_no_transient_index_properties(&v2, name)?;
}

Ok(v2)
}

/// Every entry of the `transient` list names a top-level property of the
/// document type. Drive drops transient values by top-level name before a
/// document is stored, so an entry naming a nested path, a system property or
/// nothing at all would mark a property transient in the parsed type and
/// still leave its value stored: list the object around a nested property.
///
/// Full validation only: a contract registered before this version was never
/// held to it, and a stored contract must stay readable. An update re-parses
/// the whole contract under full validation, and neither the list nor an index
/// can change on update, so a contract registered earlier with such a shape
/// could no longer be updated; a census of every mainnet and testnet contract
/// (2026-09-23) found none, as for the word-character names rule.
#[cfg(feature = "validation")]
fn validate_transient_fields(
document_type: &DocumentTypeV2,
name: &str,
) -> Result<(), ProtocolError> {
match document_type
.transient_fields
.iter()
.find(|field| !document_type.properties.contains_key(*field))
{
Some(field) => {
let hint = if field.contains('.') && !field.starts_with('$') {
": transient values are dropped by top-level name, so list the object around \
a nested property"
} else {
""
};
Err(consensus_or_protocol_data_contract_error(
DataContractError::InvalidContractStructure(format!(
"document type \"{name}\" lists \"{field}\" as transient, but it is not a \
top-level property of the document type{hint}"
)),
))
}
None => Ok(()),
}
}

/// No index reads a transient property or a property inside a transient
/// object. Its value is never stored, so every document would sit in the
/// index's null branch: a query by the value finds nothing, and a unique index
/// enforces nothing, since a create is checked against stored entries, none
/// of which holds the value.
///
/// Full validation only, like [`validate_transient_fields`].
#[cfg(feature = "validation")]
fn validate_no_transient_index_properties(
document_type: &DocumentTypeV2,
name: &str,
) -> Result<(), ProtocolError> {
for index in document_type.indices.values() {
if let Some(property) = index
.properties
.iter()
.find(|property| is_transient(DocumentTypeRef::V2(document_type), &property.name))
{
return Err(consensus_or_protocol_data_contract_error(
DataContractError::InvalidContractStructure(format!(
"index \"{}\" of document type \"{name}\" reads \"{}\", which is transient \
or inside a transient object: its value is never stored, so the index \
would never hold it",
index.name, property.name
)),
));
}
}
Ok(())
}

/// Every typed array property's `maxItems` (which the parse requires) is at
/// most `SystemLimits::max_typed_array_items`, so its worst-case encoded
/// size stays small. Read off the flattened properties, which reach a typed
Expand Down Expand Up @@ -825,6 +899,8 @@ mod reference_lookup_tests;
#[cfg(all(test, feature = "validation"))]
mod reference_test_helpers;
#[cfg(all(test, feature = "validation"))]
mod transient_tests;
#[cfg(all(test, feature = "validation"))]
mod typed_array_reference_tests;
#[cfg(all(test, feature = "validation"))]
mod typed_array_test_helpers;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! The `transient` doctype keyword under generation 3: every entry names a
//! top-level property, and no index reads a transient value. Both are
//! registration lints (`full_validation` only), so a stored contract stays
//! readable, and generation 2 (protocol version 13) keeps accepting both.

use super::immutable_tests::{expect_structure_error, parse_dispatched};
use super::*;
use platform_value::platform_value;

/// A type (parsed as `post`) with a short `code`, a long `body` and a required `meta`
/// object around a required `tag`, with `extra` set on top.
fn note_schema_with(extra: Value) -> Value {
let mut schema = platform_value!({
"type": "object",
"properties": {
"code": { "type": "string", "maxLength": 32, "position": 0 },
"body": { "type": "string", "maxLength": 500, "position": 1 },
"meta": {
"type": "object",
"position": 2,
"properties": {
"tag": { "type": "string", "maxLength": 30, "position": 0 }
},
"required": ["tag"],
"additionalProperties": false
}
},
"required": ["code", "meta"],
"additionalProperties": false
});
if let Value::Map(entries) = extra {
for (key, value) in entries {
let key = key.to_text().expect("a text key");
schema.set_value(&key, value).expect("doctype key applies");
}
}
schema
}

#[test]
fn should_accept_transient_top_level_properties_and_objects() {
for transient in [
platform_value!(["body"]),
platform_value!(["meta"]),
platform_value!(["code", "body", "meta"]),
] {
let schema = note_schema_with(platform_value!({ "transient": transient.clone() }));
parse_dispatched(schema, PlatformVersion::latest(), true)
.unwrap_or_else(|e| panic!("{transient:?} should register: {e}"));
}
}

/// Drive drops transient values by top-level name, so an entry naming
/// anything else would be flagged transient and still stored.
#[test]
fn should_refuse_a_transient_entry_that_is_not_a_top_level_property() {
for entry in ["meta.tag", "ghost", "$ownerId"] {
let schema = note_schema_with(platform_value!({ "transient": [entry] }));
expect_structure_error(
parse_dispatched(schema.clone(), PlatformVersion::latest(), true),
&format!(
"document type \"post\" lists \"{entry}\" as transient, but it is not a \
top-level property of the document type"
),
);

// A stored contract is parsed without full validation and stays readable
parse_dispatched(schema.clone(), PlatformVersion::latest(), false)
.unwrap_or_else(|e| panic!("{entry}: the stored path should parse: {e}"));

// Generation 2 predates the rule
let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13");
parse_dispatched(schema, platform_version_13, true)
.unwrap_or_else(|e| panic!("{entry}: protocol version 13 should accept it: {e}"));
}
}

/// A transient value is never stored, so every document would sit in the
/// index's null branch and a unique index would enforce nothing.
#[test]
fn should_refuse_an_index_reading_a_transient_property_or_one_inside_a_transient_object() {
for (transient, index_property, unique) in [
("code", "code", true),
("code", "code", false),
("meta", "meta.tag", true),
] {
let index_properties = Value::Array(vec![Value::Map(vec![(
Value::Text(index_property.to_string()),
Value::Text("asc".to_string()),
)])]);
let schema = note_schema_with(platform_value!({
"transient": [transient],
"indices": [{
"name": "byValue",
"properties": index_properties,
"unique": unique
}]
}));
expect_structure_error(
parse_dispatched(schema.clone(), PlatformVersion::latest(), true),
&format!(
"index \"byValue\" of document type \"post\" reads \"{index_property}\", which \
is transient or inside a transient object"
),
);
parse_dispatched(schema.clone(), PlatformVersion::latest(), false)
.unwrap_or_else(|e| panic!("{index_property}: the stored path should parse: {e}"));
let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13");
parse_dispatched(schema, platform_version_13, true).unwrap_or_else(|e| {
panic!("{index_property}: protocol version 13 should accept it: {e}")
});
}

// The same index over a stored property, beside a transient one, registers
let schema = note_schema_with(platform_value!({
"transient": ["body"],
"indices": [{ "name": "byValue", "properties": [{ "meta.tag": "asc" }], "unique": true }]
}));
parse_dispatched(schema, PlatformVersion::latest(), true)
.expect("an index over a stored property registers");
}
Original file line number Diff line number Diff line change
Expand Up @@ -1195,7 +1195,7 @@ pub fn is_referring_system_agreement_property(name: &str) -> bool {
/// around it, is transient: either way its value is never stored.
/// `transient_fields()` holds the paths as declared, so a leaf of a transient
/// object is found only through the object's path, a prefix of its own.
pub(crate) fn is_transient(document_type: DocumentTypeRef, path: &str) -> bool {
pub fn is_transient(document_type: DocumentTypeRef, path: &str) -> bool {
let transient_fields = document_type.transient_fields();
path.match_indices('.')
.map(|(end, _)| &path[..end])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ impl DocumentReferenceLookup {
/// index must exist and be unique, so the key finds at most one document;
/// it may not bucket a timestamp (`timeRange`), since its first key part
/// is then a bucket start no referring value names; the referenced type
/// may not be `indexOnly`; `keys` must map every property of the index
/// may not be `indexOnly`; no index property may be transient, a value
/// no stored document holds; `keys` must map every 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, or no document could ever
/// match. The key must also stay with the document it found, see
Expand Down Expand Up @@ -261,6 +262,22 @@ impl DocumentReferenceLookup {
referenced.name()
));
}
// A transient value is never stored, so no document would ever sit in
// the index under a key naming it
if let Some(transient) = index
.properties
.iter()
.find(|property| is_transient(referenced, &property.name))
{
return Some(format!(
"index \"{}\" of \"{}\" keys documents by \"{}\", which is transient or inside \
a transient object: its value is never stored, so the lookup could never find \
a document",
self.index,
referenced.name(),
transient.name
));
}
if let Some(missing) = index
.properties
.iter()
Expand Down Expand Up @@ -695,6 +712,33 @@ mod tests {
);
}

/// A registration refuses an index over a transient property, but a type
/// parsed without full validation (a stored contract) still reaches the
/// lookup's own check, which must never find a document through it.
#[test]
fn should_refuse_a_lookup_into_an_index_reading_a_transient_property() {
let lookup = lookup(
"bySubmittedCharter",
&[
("submittedCharterId", "submittedCharterId"),
("$ownerId", "."),
],
);
let referenced = join_request_with(platform_value!({
"transient": ["submittedCharterId"]
}));
let error = lookup
.referenced_side_error(elected_charter().as_ref(), referenced.as_ref())
.expect("an index over a transient property should be refused");
assert!(
error.contains(
"index \"bySubmittedCharter\" of \"joinRequest\" keys documents by \
\"submittedCharterId\", which is transient or inside a transient object"
),
"{error}"
);
}

/// A key part read from the writer would move with a transfer or a
/// purchase of the referring document, which re-validates nothing, so
/// only a type that can do neither may read it.
Expand Down
Loading
Loading