Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9455218
feat(platform)!: required document fields via contract updates (requi…
QuantumExplorer Aug 13, 2026
fb5d97e
fix(platform): address requiredSince review findings
QuantumExplorer Aug 13, 2026
c57720d
test(drive-abci): update PV14 fee baselines for contract-version stamp
QuantumExplorer Aug 13, 2026
72278c6
test(dpp): cover document serialization format 3 across all property …
QuantumExplorer Aug 13, 2026
5c255b8
docs(dpp): clarify which property types are not schema-reachable in f…
QuantumExplorer Aug 13, 2026
9f5f792
Merge remote-tracking branch 'origin/v4.2-dev' into claude/contract-v…
QuantumExplorer Aug 25, 2026
b837928
fix(dpp): reattach stray fixture doc comment tripping clippy doc_lazy…
QuantumExplorer Aug 25, 2026
7429545
refactor(dpp): version the contract-level requiredSince update valida…
QuantumExplorer Aug 25, 2026
879d21b
test(drive): cover contract-version stamping in create/replace action…
QuantumExplorer Aug 25, 2026
3e1d8ec
fix(dpp): classify requiredSince contract-version invariant failures …
QuantumExplorer Aug 25, 2026
ccb60da
refactor(dpp): name the contract-version stamp size in estimated_size v1
QuantumExplorer Aug 26, 2026
9908e80
refactor(dpp): move apply_required_since into its own versioned module
QuantumExplorer Aug 26, 2026
fc852d6
refactor(dpp): extract shared validate_update generation logic into c…
QuantumExplorer Aug 26, 2026
c4bfc3f
style(dpp): import requiredSince helpers instead of spelling out crat…
QuantumExplorer Aug 26, 2026
fc47637
refactor(dpp): move validate_required_since_within_contract_version t…
QuantumExplorer Aug 26, 2026
dfd20d7
refactor(dpp): validate_update_v1 delegates to the frozen generation 0
QuantumExplorer Aug 26, 2026
9e76e50
refactor(dpp): name the unconditional-requiredness check always_required
QuantumExplorer Aug 26, 2026
f534bba
test(drive-abci): pin protocol v13 fees on every path the stamp re-ba…
QuantumExplorer Aug 26, 2026
3b42f7f
docs(drive-abci): name the version gate behind the v13 fee deltas
QuantumExplorer Aug 26, 2026
820d73e
test(dpp): cover requiredSince layouts across property types and anno…
QuantumExplorer Aug 26, 2026
a26cea6
test(drive-abci): end-to-end grandfathering flow for requiredSince
QuantumExplorer Aug 26, 2026
5bbdc5d
test(drive-abci): cover the v13-to-v14 upgrade boundary for requiredS…
QuantumExplorer Aug 26, 2026
499ce4c
test(drive-abci): strategy run with a mid-chain requiredSince contrac…
QuantumExplorer Aug 26, 2026
1500e5e
docs(book): document requiredSince, the contract version stamp, and f…
QuantumExplorer Aug 26, 2026
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json",
"$comment": "EDITABLE UNTIL THE RELEASE CARRYING PROTOCOL V14 SHIPS — FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable) and the refersTo reference keyword on identifier properties, and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once the release carrying protocol v14 ships, mutating it would change historical validation results and break consensus replay. After release, any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.",
"$comment": "EDITABLE UNTIL THE RELEASE CARRYING PROTOCOL V14 SHIPS — FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties, and the requiredSince property keyword (the contract version a property is required from), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once the release carrying protocol v14 ships, mutating it would change historical validation results and break consensus replay. After release, any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.",
"type": "object",
"$defs": {
"documentProperties": {
Expand Down Expand Up @@ -224,6 +224,11 @@
"position": {
"type": "integer",
"minimum": 0
},
"requiredSince": {
"type": "integer",
"minimum": 1,
"maximum": 4294967295
}
},
"dependentSchemas": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ fn parse_document_properties(
&mut document_properties,
&required_fields,
&transient_fields,
true,
property_key,
property_value,
root_schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ fn insert_values(
vec![(prefix, property_key, property_value)];

while let Some((prefix, property_key, property_value)) = to_visit.pop() {
let is_top_level = prefix.is_none();
let prefixed_property_key = match prefix {
None => property_key,
Some(prefix) => [prefix, property_key].join(".").to_owned(),
Expand All @@ -143,6 +144,12 @@ fn insert_values(

let is_required = known_required.contains(&prefixed_property_key);
let is_transient = known_transient.contains(&prefixed_property_key);
let required_since = apply_required_since(
&inner_properties,
is_required,
is_top_level,
platform_version,
)?;

match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? {
DocumentPropertyType::Object(_) => {
Expand Down Expand Up @@ -179,6 +186,7 @@ fn insert_values(
property_type,
required: is_required,
transient: is_transient,
required_since,
},
);
}
Expand All @@ -194,6 +202,7 @@ fn insert_values_nested(
document_properties: &mut IndexMap<String, DocumentProperty>,
known_required: &BTreeSet<String>,
known_transient: &BTreeSet<String>,
is_top_level: bool,
property_key: String,
property_value: &Value,
root_schema: &Value,
Expand All @@ -212,6 +221,13 @@ fn insert_values_nested(

let is_transient = known_transient.contains(&property_key);

let required_since = apply_required_since(
&inner_properties,
is_required,
is_top_level,
platform_version,
)?;

let property_type =
match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? {
DocumentPropertyType::Object(_) => {
Expand Down Expand Up @@ -271,6 +287,7 @@ fn insert_values_nested(
&mut nested_properties,
&stripped_required,
&stripped_transient,
false,
object_property_string,
object_property_value,
root_schema,
Expand All @@ -294,12 +311,79 @@ fn insert_values_nested(
property_type,
required: is_required,
transient: is_transient,
required_since,
},
);

Ok(())
}

/// Parses the `requiredSince` keyword: the contract version from which the
/// property is required. Only meaningful on top-level required properties —
/// the document wire format encodes a required property without a presence
/// flag, so requiredness that varies by contract version must be resolvable
/// per property from the current schema alone (see the per-document contract
/// version stamp in document serialization format 3).
///
/// Versioned on `apply_required_since` in the platform version's document
/// type schema versions. `None` selects the behavior of the versions that
/// predate the keyword: it is ignored entirely, so their parses stay
/// byte-for-byte identical to what they always produced.
fn apply_required_since(
inner_properties: &BTreeMap<String, &Value>,
is_required: bool,
is_top_level: bool,
platform_version: &PlatformVersion,
) -> Result<Option<u32>, DataContractError> {
match platform_version
.dpp
.contract_versions
.document_type_versions
.schema
.apply_required_since
{
None => Ok(None),
Some(0) => apply_required_since_v0(inner_properties, is_required, is_top_level),
Some(version) => Err(DataContractError::Unsupported(format!(
"apply_required_since version {version} is not supported"
))),
}
}

fn apply_required_since_v0(
inner_properties: &BTreeMap<String, &Value>,
is_required: bool,
is_top_level: bool,
) -> Result<Option<u32>, DataContractError> {
let Some(required_since_value) = inner_properties.get(property_names::REQUIRED_SINCE) else {
return Ok(None);
};

if !is_top_level {
return Err(DataContractError::InvalidContractStructure(
"requiredSince is only allowed on top-level properties".to_string(),
));
}

if !is_required {
return Err(DataContractError::InvalidContractStructure(
"requiredSince is only allowed on properties listed in required".to_string(),
));
}

let required_since: u32 = required_since_value
.to_integer()
.map_err(|e| DataContractError::ValueWrongType(e.to_string()))?;

if required_since == 0 {
return Err(DataContractError::InvalidContractStructure(
"requiredSince must be a contract version of at least 1".to_string(),
));
}

Ok(Some(required_since))
}

/// Folds a `refersTo` declaration into the property type: an identifier property
/// with `refersTo` becomes `IdentifierWithReference(target)`. Non-identifier
/// properties cannot carry `refersTo`.
Expand Down Expand Up @@ -804,4 +888,207 @@ mod tests {
)
.expect("a parse predating refersTo should ignore the keyword entirely");
}

// ================================================================
// requiredSince
// ================================================================

#[test]
fn should_parse_required_since_on_top_level_required_property() {
let document_type = try_document_type_from_schema(json!({
"type": "object",
"properties": {
"a": {"type": "string", "position": 0, "maxLength": 60},
"b": {"type": "string", "position": 1, "maxLength": 60, "requiredSince": 3},
},
"required": ["a", "b"],
"additionalProperties": false
}))
.expect("should parse");

let properties = document_type.as_ref().flattened_properties().clone();
assert_eq!(properties.get("a").unwrap().required_since, None);
assert_eq!(properties.get("b").unwrap().required_since, Some(3));
assert!(properties.get("b").unwrap().required);
}

#[test]
fn should_reject_required_since_on_optional_property() {
let result = try_document_type_from_schema(json!({
"type": "object",
"properties": {
"a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 2},
},
"required": [],
"additionalProperties": false
}));

assert!(
result.is_err(),
"requiredSince on a property not listed in required must be rejected"
);
}

#[test]
fn should_reject_required_since_on_nested_property() {
let result = try_document_type_from_schema(json!({
"type": "object",
"properties": {
"outer": {
"type": "object",
"position": 0,
"properties": {
"inner": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 2},
},
"required": ["inner"],
"additionalProperties": false
},
},
"required": [],
"additionalProperties": false
}));

assert!(
result.is_err(),
"requiredSince on a nested property must be rejected"
);
}

#[test]
fn should_reject_required_since_above_u32_max() {
// The meta-schema caps the value at u32::MAX too; this pins the
// parser-side rejection so it does not depend on meta-schema
// coverage (parses without full validation skip the meta-schema)
let result = try_document_type_from_schema(json!({
"type": "object",
"properties": {
"a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 4_294_967_296_u64},
},
"required": ["a"],
"additionalProperties": false
}));

assert!(
result.is_err(),
"requiredSince above u32::MAX must be rejected"
);
}

#[test]
fn should_reject_required_since_of_zero() {
let result = try_document_type_from_schema(json!({
"type": "object",
"properties": {
"a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 0},
},
"required": ["a"],
"additionalProperties": false
}));

assert!(
result.is_err(),
"requiredSince of 0 must be rejected (contract versions start at 1)"
);
}

#[test]
fn should_parse_required_since_reached_through_a_ref() {
// A `$ref`'d property resolves to its `$defs` entry before keywords
// are read, so an annotation hidden behind a reference is parsed
// exactly like a direct one — any validation that only scans raw
// property JSON would miss it, which is why the
// `requiredSince <= contract version` invariant is enforced on
// parsed properties (validate_required_since_within_contract_version)
let platform_version = PlatformVersion::latest();
let config =
DataContractConfig::default_for_version(platform_version).expect("config should build");

let schema_defs: BTreeMap<String, Value> = [(
"annotated".to_string(),
platform_value::to_value(json!({
"type": "string", "maxLength": 60, "requiredSince": 2
}))
.expect("defs should convert"),
)]
.into_iter()
.collect();

let schema = platform_value::to_value(json!({
"type": "object",
"properties": {
"a": {"type": "string", "position": 0, "maxLength": 60},
"b": {"$ref": "#/$defs/annotated", "position": 1},
},
"required": ["a", "b"],
"additionalProperties": false
}))
.expect("schema should convert");

let document_type = DocumentType::try_from_schema(
Identifier::random(),
0,
config.version(),
"msg",
schema,
Some(&schema_defs),
&BTreeMap::new(),
&config,
false,
&mut vec![],
platform_version,
)
.expect("should parse");

let properties = document_type.as_ref().flattened_properties().clone();
assert_eq!(properties.get("b").unwrap().required_since, Some(2));

// The parsed-property invariant check sees the annotation the raw
// JSON hides: version 1 (too old for requiredSince 2) rejects,
// version 2 accepts
let mut document_types = BTreeMap::new();
document_types.insert("msg".to_string(), document_type);

assert!(
crate::data_contract::document_type::validate_required_since_within_contract_version(
&document_types,
1
)
.is_err(),
"requiredSince 2 must be rejected on a version 1 contract even through $ref"
);
assert!(
crate::data_contract::document_type::validate_required_since_within_contract_version(
&document_types,
2
)
.is_ok()
);
}

#[test]
fn should_ignore_required_since_on_platform_versions_predating_it() {
// Platform versions whose tables carry `apply_required_since: None`
// predate the keyword: even if it appears in a schema they parse
// (only possible without full validation — their meta-schemas reject
// it), they must ignore it and keep producing the plain required
// property they always produced.
let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist");

let document_type = try_document_type_from_schema_on_version(
json!({
"type": "object",
"properties": {
"a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 3},
},
"required": ["a"],
"additionalProperties": false
}),
platform_version,
)
.expect("a parse predating requiredSince should ignore the keyword entirely");

let properties = document_type.as_ref().flattened_properties().clone();
assert_eq!(properties.get("a").unwrap().required_since, None);
assert!(properties.get("a").unwrap().required);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ impl DocumentTypeV0 {
&mut document_properties,
&required_fields,
&transient_fields,
true,
property_key,
property_value,
&root_schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,10 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe
.estimated_size
{
0 => self.estimated_size_v0(platform_version),
1 => self.estimated_size_v1(platform_version),
version => Err(ProtocolError::UnknownVersionMismatch {
method: "estimated_size".to_string(),
known_versions: vec![0],
known_versions: vec![0, 1],
received: version,
}),
}
Expand Down
Loading
Loading