Description
Twice during the protocol version 14 betas, a keyword rename made data contracts that were already registered fail to parse everywhere: in Drive's own contract fetch and in the SDKs, with or without full validation. The first was refersTo.lookup / propertyAgreement in 4.2.0-beta.7 (#5197); the second was immutableAllowSetting in 5.0.0-beta.1 (#5217). Could a retired keyword be refused only when a contract is registered or updated, and still be read when a stored contract is loaded (or the stored bytes rewritten when the change activates)? And could that become a written rule before protocol version 14 reaches testnet and mainnet?
We hit this building Dash Forge, a git forge that runs on Platform.
We know protocol version 14 is unreleased and its tables are still editable in place (system_limits/v4.rs#L3-L9). Both PRs said what would happen, and both release notes told devnets to re-cut. This is not a complaint about the betas. We are asking for the discipline that will matter once a contract cannot be re-cut.
Expected Behavior
- If a node or SDK of a protocol version accepted a contract, later builds of that protocol version still load it. Once a version has shipped, later protocol versions load it too.
- Retiring a keyword is a registration and update rule: new contracts and updates using it are refused, and stored contracts that use it are read back as before. feat(platform)!: only members added before a document approve its settled deletion (PV14) #5260 already handles a new rule this way ("A stored contract is read back as before"). The book states the same principle for the
immutablelints: "Stored contracts skip these lints, so a later tightening can never make a committed contract unreadable" (documents.md#L615). - If an old spelling truly cannot be read any more, the protocol upgrade rewrites the stored contracts. It does not refuse them on load.
transition_to_version_12already did this withstrip_unknown_document_schema_properties(perform_events_on_first_block_of_protocol_change/v0/mod.rs#L650-L657, strip_unknown_document_schema_properties.rs#L14-L26).
Current Behavior
Both refusals run on every parse:
refuse_replaced_reference_keywordsrefuseslookup,propertyAgreementandtype: "listElement""on every parse, so a contract written with them never loads with another meaning" (try_from_schema/mod.rs#L1039-L1075).parse_immutable_keywordrefuses any document type that carriesimmutableAllowSetting(common/mod.rs#L3106-L3133).- The generation 3 parser states this as policy: "its shape is enforced on both the validating and the stored path. No keyword gets stored-path leniency" (v3/mod.rs#L311-L327).
- Drive loads a stored contract through that parser (
versioned_deserialize_trusted(bytes, false, …), fetch_contract/v0/mod.rs#L78-L87). So every transition on such a contract ends as an internal error, as the comment at v3/mod.rs#L324-L327 says. - The owner cannot repair the contract with an update. The update's state validation first fetches the stored contract (data_contract_update/state/v0/mod.rs#L114-L120), and then validates the update against it (#L150-L154).
Reproduction. We kept snapshots of our contracts as fetched from two devnets. Each was fetched with one proved getDataContracts request and serialized with toBytes at protocol version 14:
- devnet-moutai.json @ 1cc5792a: registered while that devnet ran 4.2.0-beta.6.
- devnet-bonsia.json @ 4d6bd0aa: registered on 4.2.0-beta.7.
We parsed each one with three published SDKs:
// node repro.mjs <dir with node_modules/@dashevo/wasm-sdk> <snapshot.json>
import fs from 'node:fs';
const [dir, snapFile] = process.argv.slice(2);
const mod = await import(dir + '/node_modules/@dashevo/wasm-sdk/dist/sdk.js');
await mod.default();
for (const [id, v] of Object.entries(JSON.parse(fs.readFileSync(snapFile, 'utf8')))) {
for (const full of [false, true]) {
try { mod.DataContract.fromBase64(v.bytes, full, v.platformVersion); console.log(id, full, 'OK'); }
catch (e) { console.log(id, full, 'ERROR:', String(e?.message ?? e)); }
}
}| Contract (all at protocol version 14) | wasm-sdk 4.2.0-beta.5 | 4.2.0-beta.7 | 5.0.0-beta.1 |
|---|---|---|---|
A2KL77ngVM1ft1t1em2XKt1rWCBZANdAJMyfWrDGCcd1 (registered on beta.6, uses lookup) |
OK | ERROR (a) | ERROR (a) |
C1zHeeG7EUudXdB5ZyDQnXVU35hrCfvd1fRCybXEqaPS (registered on beta.6, uses propertyAgreement) |
OK | ERROR (b) | ERROR (b) |
6ktYsH3cpxC7FbazwtVWGiNuVb4TNE5YrHD1hNY8XqNx (registered on beta.7, uses immutableAllowSetting) |
(c) | OK | ERROR (d) |
Each result is the same with full_validation false and true. The other contracts in both snapshots, DPNS among them, load in every SDK from the release they were registered on onward. In (c), beta.5 cannot read grammar added after it (skipIfAbsent value must be a boolean). An older client failing on newer grammar is expected, and we are not asking about that. The errors are:
(a) invalid contract structure: refersTo lookup was replaced by findBy: map every property of the unique index to its source, { "<index property>": <source> }, and leave the index name out; a propertyAgreement function goes in findBy too
(b) invalid contract structure: refersTo propertyAgreement was replaced by where, keyed by the referenced document's property: { "<referenced property>": "<referring property or $ownerId>" }
(d) invalid contract structure: document type "checkRun": `immutableAllowSetting` is replaced by a conditional `immutable` entry: { "property": "p", "when": { "present": "$old.p" } } freezes "p" once the stored document holds it
What it cost us:
- On 2026-09-29, one of our devnets was upgraded in place from 4.2.0-beta.6 to 4.2.0-beta.7. After that, our two main contracts could not be loaded. We had to register new contracts, with new ids, on a new devnet.
- Our next devnet was re-cut for 5.0.0-beta.1. Had it been upgraded in place, our third contract (12 document types) would have become unreadable, along with every document in it. Everything we had stored there went with the re-cut, including a repository mirror import that stood at 5,609 of 7,742 items.
- No client version reads both forms. 4.2.0-beta.7 refuses the conditional
immutableentry (every immutable entry must be a property name (a string)), and 5.0.0-beta.1 refusesimmutableAllowSetting. So the SDK upgrade, the new contract and its new id had to ship as one release. - On mainnet there is no re-cut. The same change would strand every document in the affected contracts.
Possible Solution
Suggestions, roughly from smallest to largest:
- For pure renames, read the old spelling into the same declaration when loading. Keep refusing it at registration and update. The book says the meta-schema already refuses the old
refersTospellings there (10101, refers-to.md#L319). feat(dpp)!: refersTo finds a document with findBy and checks it with where #5197 notes that "the parsed declarations, and so validation, fees and execution, are the same". feat(platform)!: immutable properties frozen under a condition (PV14) #5217 gives the exact equivalent ofimmutableAllowSetting: ["p"], which is{ "property": "p", "when": { "present": "$old.p" } }. Since the meaning is identical, a contract written the old way would not load "with another meaning". We may be missing a path that skips the meta-schema. The rationale at v3/mod.rs#L321-L323 mentionscheck_txand client parsing. If such a path exists, a parser flag that only Drive's stored-contract fetch sets would keep the change narrow. - When a keyword has to go, migrate rather than refuse. In the first block of the protocol version that retires a keyword, rewrite the stored contracts that use it, as
transition_to_version_12did. Rewrite index entries earlier versions laid out otherwise, then drop the stored-layout reads (PV15+) #5196 proposes the same approach for index entries. - Write the rule down next to "Shipped generations are frozen" in coding-conventions.md. For example: "a parser change must load every contract the previous build admitted; retiring a keyword is a registration rule, plus a migration if needed". A "Stored contracts affected" section in the PR description, like "In-place changes to shipped generations", would make reviewers check for this.
- Check it in CI. Run a corpus of real registered contracts on every PR. We filed that separately as CI: load a corpus of real registered data contracts on every PR, and flag changes that break stored contracts #5271.
Alternatives Considered
- Re-registering under the new grammar is what we did. The contract gets a new id, and its documents stay behind under the old contract, which can no longer be read. Every client has to change ids at the same moment.
- Updating the contract to the new grammar is not possible, because the update path loads the stored contract first (above).
- Pinning the older SDK works only until the network upgrades. After that, the old SDK cannot read anything registered in the new form.
Additional Context
- The team already holds contracts from released versions to this standard. The census behind generation 3 loaded every mainnet and testnet contract version (meta_schema_v0_stray_keyword_tests.rs#L15-L26, test(dpp): guard doctype keyword names against stray keys of contracts admitted under meta-schema v0 #4855). fix(dpp)!: refuse a dotted property path in sum and average keywords (PV14) #5104 pins that a contract registered at protocol version 13 still loads at 14 (dotted_aggregate_name_tests.rs#L145). We are asking for the same treatment of contracts registered on the version under test. Devnets are where app teams find out whether they can build on Platform.
- Both refusals are still on
v5.0-devtoday (bc321362). - Related: CI: load a corpus of real registered data contracts on every PR, and flag changes that break stored contracts #5271 (contract corpus in CI), feat(dpp)!: refersTo finds a document with findBy and checks it with where #5197, feat(platform)!: immutable properties frozen under a condition (PV14) #5217, test(dpp): guard doctype keyword names against stray keys of contracts admitted under meta-schema v0 #4855, fix(dpp)!: refuse a dotted property path in sum and average keywords (PV14) #5104, feat(platform)!: only members added before a document approve its settled deletion (PV14) #5260, Rewrite index entries earlier versions laid out otherwise, then drop the stored-layout reads (PV15+) #5196.
- Environment: Platform 5.0.0-beta.1 on a devnet (sakura); earlier devnets ran 4.2.0-beta.5 to beta.7. Our app uses
@dashevo/evo-sdkand@dashevo/wasm-sdk5.0.0-beta.1 from npm, and rs-sdk / rs-dpp at tagv5.0.0-beta.1. The reproduction used@dashevo/wasm-sdk4.2.0-beta.5, 4.2.0-beta.7 and 5.0.0-beta.1 from npm on Node.
Activity