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
Original file line number Diff line number Diff line change
Expand Up @@ -1525,12 +1525,13 @@ pub(super) fn parse_doctype_aggregate_keywords(
//
// Note on pre-v12 contracts: contracts created before v12 used the
// generation-1 parser, which ignores these fields. After v12 upgrade,
// deserialization uses the generation-2 parser which will read them. This
// is safe because the contract update path runs through that parser with
// full_validation=true, and the primary key tree type is set correctly at
// contract creation time. Pre-v12 contracts can only have these flags if
// they were explicitly set in the schema — the meta-schema allows them as
// optional boolean properties.
// deserialization uses the generation-2 parser which will read them.
// Meta-schema v0 does not declare these fields: it admits them as unknown
// keys of any shape, so a pre-v12 contract carrying one was never
// validated against it, and reading it would assume a primary key tree
// type the contract was not created with. No such contract exists on
// mainnet or testnet; see `try_from_schema_generation_3` for the census
// and the rule that follows from it.
let schema_map_opt = schema.to_map().ok();

let documents_countable = schema_map_opt
Expand Down Expand Up @@ -1986,11 +1987,11 @@ pub(super) fn parse_index_only_keyword(schema: &Value) -> Result<bool, ProtocolE
/// doctype-level keyword they predate (their meta-schemas still reject them
/// under `full_validation`).
///
/// Every entry must be a string. The meta-schema enforces that under full
/// validation and a stored contract can only ever have passed it, so a
/// non-string entry is a malformed schema on either path and is refused
/// Every entry must be a string, on either path. A non-string entry is refused
/// rather than silently dropped: dropping it would record a smaller set than
/// the author declared.
/// the author declared. A contract admitted under meta-schema v0 was never
/// checked against this keyword; `try_from_schema_generation_3` states why the
/// stored path is strict all the same.
pub(super) fn parse_property_name_list_keyword(
schema: &Value,
name: &str,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Doctype-level keyword names that are already taken by stray keys.
//!
//! The document meta-schema v0 admitted every contract created at protocol
//! versions 1 to 11 and does not refuse unknown doctype-level keys, so such a
//! contract may carry a key no validator ever looked at. The rule for reading
//! keywords off those contracts is stated on `try_from_schema_generation_3`.
//! What stays open is the name of a future keyword: one named after a stray
//! key would change the meaning of the contracts that carry it, from the block
//! that activates the keyword.

use serde_json::Value as JsonValue;
use std::fs;
use std::path::Path;

/// Every doctype-level key that no closed meta-schema declares, carried by a
/// contract admitted under meta-schema v0. From a census of every contract
/// create and update transition on each network (2026-09-20), decoded from the
/// raw bytes. Mainnet: 54 admitted contract versions below height 398435,
/// where protocol version 12 activated. Testnet: 3347 below height 362782.
/// None of them carries a generation 3 keyword.
///
/// The lists are exhaustive and final. Neither network has a stray on a
/// contract admitted later, and the set can no longer grow: every create and
/// update since protocol version 12 is validated against a meta-schema that
/// refuses unknown keys. So a new doctype-level keyword is safe exactly when
/// its name is absent from these lists, and the census never needs repeating.
const MAINNET_STRAY_DOCTYPE_KEYS: &[&str] = &["mutable"];
const TESTNET_STRAY_DOCTYPE_KEYS: &[&str] = &[
"mutable",
"comment",
"position",
"tokenCosts",
"indexes",
"bls_public_key",
"keywords",
];

/// The meta-schema that leaves the doctype level open. It declares `keywords`,
/// which no later meta-schema does, so it is not checked against the lists.
const OPEN_DOCUMENT_META_SCHEMA_VERSION: &str = "v0";

/// Pick another name, or decide what happens to the contracts that carry the
/// stray key first.
#[test]
fn should_not_name_a_doctype_keyword_after_a_stray_key_of_a_meta_schema_v0_contract() {
let meta_schemas = Path::new(env!("CARGO_MANIFEST_DIR")).join("schema/meta_schemas/document");

let mut checked = 0;
for entry in fs::read_dir(&meta_schemas).expect("the document meta-schema directory exists") {
let directory = entry.expect("a readable directory entry").path();
let version = directory
.file_name()
.and_then(|name| name.to_str())
.expect("a meta-schema version directory name")
.to_owned();
if version == OPEN_DOCUMENT_META_SCHEMA_VERSION {
continue;
}

let source = fs::read_to_string(directory.join("document-meta.json"))
.unwrap_or_else(|e| panic!("document meta-schema {version} must be readable: {e}"));
let meta_schema: JsonValue =
serde_json::from_str(&source).expect("document meta-schema JSON must be valid");
let keywords = meta_schema["properties"]
.as_object()
.expect("the document meta-schema declares its doctype-level keywords");

for stray in MAINNET_STRAY_DOCTYPE_KEYS
.iter()
.chain(TESTNET_STRAY_DOCTYPE_KEYS)
{
assert!(
!keywords.contains_key(*stray),
"document meta-schema {version} declares `{stray}`, which contracts admitted \
under meta-schema v0 already carry as a stray key"
);
}
checked += 1;
}

assert!(
checked >= 3,
"expected the closed document meta-schemas v1 to v3 at least, found {checked}"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,40 @@ const RANKED_INDEX_KEY_LENGTH_CHECK: common::RankedIndexKeyLengthCheck =
/// Full validation rejects keep-history document types that allow deletion.
/// Stored contracts bypass this check so legacy contradictory schemas remain
/// readable and can be repaired by setting `canBeDeleted: false` on update.
///
/// # Doctype-level keywords on contracts that predate them
///
/// Not every stored contract was validated against the keywords read here.
/// The document meta-schema v0 admitted every contract created at protocol
/// versions 1 to 11 and leaves unknown doctype-level keys open, so such a
/// contract may carry a key named like one of these keywords, of any shape.
/// Meta-schemas v1 and later refuse unknown doctype-level keys, and the
/// property and index levels were closed from v0 on.
///
/// One rule covers every doctype-level keyword of this generation: it is read
/// wherever it appears, and its shape is enforced on both the validating and
/// the stored path. No keyword gets stored-path leniency.
///
/// * Nothing this parser receives records which meta-schema admitted the
/// contract, so a well-formed stray cannot be told apart from a validated
/// declaration. Leniency could therefore only ever cover the malformed
/// case, which fails loudly, and never the well-formed one, which would
/// silently change the meaning of a contract (for `indexOnly`, the storage
/// layout of documents already written).
/// * `full_validation: false` is not only the stored path. `check_tx` and
/// client-side parsing take it too, so leniency there widens what an
/// unvalidated schema may contain.
/// * Refusing is the safe failure. It is deterministic across nodes, confined
/// to the one contract (its transitions end as an internal error, which the
/// proposer leaves out of the block, so the chain does not halt), and
/// repairable in a later protocol version.
///
/// What makes the rule safe is evidence rather than code: a census of every
/// contract admitted under meta-schema v0 on mainnet and testnet (2026-09-20)
/// found none carrying any of these keywords, and that set closed for good
/// when protocol version 12 activated. `meta_schema_v0_stray_keyword_tests`
/// records the stray keys those contracts do carry, and fails if a document
/// meta-schema ever declares one of them as a keyword.
#[allow(clippy::too_many_arguments)]
fn try_from_schema_generation_3(
data_contract_id: Identifier,
Expand All @@ -247,17 +281,12 @@ fn try_from_schema_generation_3(
validation_operations: &mut impl Extend<ProtocolValidationOperation>,
platform_version: &PlatformVersion,
) -> Result<DocumentTypeV2, ProtocolError> {
// Read the aggregate and indexOnly keywords before the core parser
// consumes `schema`.
// Read the doctype-level keywords before the core parser consumes
// `schema`. Each is read wherever it appears, and its shape is enforced on
// both paths: see "Doctype-level keywords on contracts that predate them"
// above.
let aggregates = common::parse_doctype_aggregate_keywords(&schema, name)?;
let index_only = common::parse_index_only_keyword(&schema)?;
// Like every doctype-level keyword of this generation, `actionFees` is read wherever it
// appears and its shape is enforced on the validating and the stored path alike: no keyword
// is softened for a stored contract. The frozen v0 meta-schema (protocol versions 1 to 11)
// did not refuse unknown top-level keys, but a census of every contract create and update
// on mainnet and testnet (2026-09-20) found no contract it admitted carrying this key, and
// every create and update since is validated by a meta-schema that refuses it. So every
// declaration a node reads from state was validated.
let action_fees = DocumentActionFees::try_from_document_schema(&schema, name)?;
let immutable_fields =
common::parse_property_name_list_keyword(&schema, name, property_names::IMMUTABLE)?;
Expand Down Expand Up @@ -403,6 +432,8 @@ mod index_only_tests;

#[cfg(test)]
mod keep_history_tests;
#[cfg(test)]
mod meta_schema_v0_stray_keyword_tests;

#[cfg(test)]
mod tests {
Expand Down
Loading