Skip to content

feat(dpp): unify JSON/Value conversion traits + comprehensive round-trip tests#3573

Draft
shumkov wants to merge 129 commits intov3.1-devfrom
feat/json-convertible-address-transitions
Draft

feat(dpp): unify JSON/Value conversion traits + comprehensive round-trip tests#3573
shumkov wants to merge 129 commits intov3.1-devfrom
feat/json-convertible-address-transitions

Conversation

@shumkov
Copy link
Copy Markdown
Collaborator

@shumkov shumkov commented Apr 30, 2026

Summary

Unification work for the canonical JsonConvertible / ValueConvertible traits across packages/rs-dpp. Two passes:

Pass 1 — added trait impls to ~80 domain types (commit 9f23d675af). All types now expose the canonical conversion surface.

Pass 2 — added 197 round-trip tests using non-default fixtures + per-property assertions per the new test convention. Surfaced 3 platform_value bugs that were previously silent.

Test results

  • 3619 dpp lib tests pass, 18 ignored, 0 failed (no regressions vs. base branch).
  • 197 dedicated json_convertible_tests across ~95 types.
  • 12 of the 197 are #[ignore]d — each documents either a real bug found or a structural issue (BLS keys, untagged-enum ambiguity, known-broken serde).

Bugs surfaced (logged in docs/json-value-unification-plan.md §10b)

  1. OutPoint round-trip via platform_value::Value::Map fails — affects ChainAssetLockProof, AddressFundingFromAssetLockTransition, ShieldFromAssetLockTransition. JSON works.
  2. [u8; N] fixed-array fields with custom serializers fail platform_value round-trip ("Invalid symbol 17, offset 0") — affects ExtendedBlockInfo signature and all 5 shielded transitions' Orchard fields. JSON works.
  3. DataContract document_schemas lose sized integer types via JSON round-trip (U32(63)U64(63), I32(0)U64(0)) — Critical-1 manifestation; affects every state transition embedding a DataContract. Value round-trip works.

Convention added

Every J/V test follows docs/json-value-unification-plan.md §8:

  1. Round-trip via to_jsonfrom_json (and same for value).
  2. Non-default fixture with distinguishable values per field.
  3. Per-property assertions on the recovered struct — catches silent field drops, type narrowing, custom-PartialEq quirks.
  4. Tagged-enum $formatVersion preservation test where applicable.

Fixtures source priority: hand-built struct literals > random_* constructors > from_* factories > crate::tests::fixtures::* helpers > Default::default() only for unit-only enums.

Code improvements

  • Derived PartialEq on StateTransitionProofResult + StoredAssetLockInfo (was missing, blocked round-trip assert_eq).

Out of scope (genuinely residual — needs upstream work)

  • ValidatorSet — needs real BLS public key construction (crypto setup).
  • ExtendedDocument — Critical-3 known-broken serde (writes version, reads $version).
  • StateTransition umbrella — serde(untagged), deserialize ambiguity (logged #[ignore]).

Docs

  • docs/json-value-conversion-inventory.md — pre-pass-1 structural inventory.
  • docs/json-value-unification-plan.md — full plan with phases, bug log, lessons learned, fixture conventions.

Test plan

  • Reviewer runs cargo test -p dpp --features=json-conversion,value-conversion,serde-conversion and sees 3619 pass / 0 fail.
  • Reviewer skims docs/json-value-unification-plan.md §10b for the 3 surfaced bugs and decides priority for follow-up fix.
  • Reviewer confirms the test convention in §8 is appropriate for the project's testing style.

🤖 Generated with Claude Code

shumkov and others added 30 commits April 30, 2026 15:29
Adds JsonConvertible / ValueConvertible impls (canonical traits in
packages/rs-dpp/src/serialization/serialization_traits.rs) to the
domain types catalogued in docs/json-value-conversion-inventory.md.

This is the unification first pass — round-trip correctness, tagged-
enum tag preservation, and integer-precision tests are deferred to the
second pass per the plan. Some impls may produce broken JSON or fail
round-trip until pass 2 fixes them; that's expected.

Coverage:
- Symmetrize V-only and J-only types (15+1).
- Add J+V to types missing both: top priorities (DataContract,
  StateTransition, BatchTransition, Document, AssetLockProof,
  AddressCreditWithdrawalTransition, Pooling) plus 22 batch transitions
  and 19 leaf serde types.

Skipped: types without serde derives, lifetime-param refs, and the
wasm-dpp legacy crate per minimum-touch policy.

Approach: derive(JsonConvertible/ValueConvertible) where the type
already opts into the json_safe_fields macro ecosystem; empty manual
impl X {} (§6 escape hatch) elsewhere to bypass the JsonSafeFields
cascade. Both paths use the trait's default serde-delegating methods.

Adds planning docs:
- docs/json-value-conversion-inventory.md — structural inventory.
- docs/json-value-unification-plan.md — phased plan with critical
  findings and per-mechanism deprecation decisions.

cargo check -p dpp passes with --features=json-conversion,value-conversion,serde-conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the unification plan with:
- Progress table tracking the 5 passes (1 done, 2 in progress).
- Phase B/C status updated: ~80 types now have canonical impls.
- Skip-list rationale for types we deliberately did NOT migrate
  (no serde derives, lifetime params, internal indirection).
- Section 11 "Lessons learned from pass 1" — the JsonSafeFields
  cascade, BTreeMap-of-enum-keys serde helpers, what shipped in the
  481 commits we pulled, test-fixture pattern, sandbox/sccache/gpg
  gotchas.
- Reference to pass-1 commit 9f23d67.

Companion doc gets a status banner pointing back to the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ity)

Adding empty impl JsonConvertible/ValueConvertible for DataContract in
pass 1 collided with the existing DataContractJsonConversionMethodsV0::
to_json(&self, &PlatformVersion) at every call site that passes a
PlatformVersion — Rust E0034 (multiple applicable items in scope).

Per the unification plan §3.11 step 10, DataContract is KEEP-AS-EXCEPTION
(version-aware serde via DataContractInSerializationFormat). The proper
unification path renames the legacy methods to *_versioned first, then
the canonical traits can layer on. That's a follow-up.

For now, leave a comment in data_contract/mod.rs explaining the absence
and pointing readers at DataContractInSerializationFormat (which DOES
have the canonical traits) when they need a JSON shape.

cargo test -p dpp --features=json-conversion,value-conversion,serde-conversion
--lib json_convertible_tests now passes (10/10 — the 5 address-transition
round-trip + tag-preservation tests from pass 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds json_round_trip + value_round_trip tests for 11 types covered
by the pass-1 unification commit (9f23d67). All 28 tests in the
new modules pass; no regressions in the existing 3432 dpp lib tests.

Types covered:
- Identity, IdentityV0, IdentityPublicKey
- AddressCreditWithdrawalTransition
- TokenContractInfo, TokenPaymentInfo
- Document
- Pooling
- GroupStateTransitionInfo

Types skipped with TODO (V0 inner lacks Default):
- AssetLockValue (AssetLockValueV0)
- GroupAction (GroupActionV0 has GroupActionEvent field with no Default)

Pass-2 work continues: more types to follow, then bug discovery
(StateTransition untagged, ExtendedDocument bug, Critical-1 / -2 / -4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds round-trip tests for TokenEmergencyAction, GasFeesPaidBy, and
YesNoAbstainVoteChoice — all flat enums with derive(Default).
Also marks TokenMarketplaceRules and other types whose V0 lacks Default
with TODO(unification pass 2) comments — they need explicit fixtures.

34 json_convertible_tests pass, no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…DistributionType (pass 2)

DocumentPatch has Default and J+V impls — round-trips cleanly.
TokenDistributionType has Default but the J+V impls are on its variants
(TokenDistributionTypeWithResolvedRecipient, TokenDistributionInfo),
neither of which has Default — left as TODO for explicit fixture.

36/36 json_convertible_tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…perty assertions

Per user direction, every J/V test must:
1. Use a NON-DEFAULT fixture (distinguishable values per field).
2. Round-trip via to_json/from_json (and to_object/from_object).
3. Assert each field of the recovered value individually — catches
   silent field drops, type narrowing, and PartialEq quirks that
   whole-struct equality can miss.

IdentityCreateFromAddressesTransition is the canonical example —
fixture has 6 non-default fields including a 2-entry inputs map
with both P2PKH+P2SH addresses, a populated public key, two
witness types, custom fee strategy, and non-zero user_fee_increase.
All three tests pass (json_round_trip, value_round_trip,
format_version_tag).

Plan §8 updated with the new mandatory convention and rationale.
Existing tests with Default fixtures are now legacy and will be
upgraded as we revisit each type in pass 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sferToAddresses tests

Apply the new mandatory convention (non-default fixture + per-property
assertions + round-trip) to two more address transitions. Both fixtures
use distinguishable values for every field (identity_id, recipient_addresses,
nonce, signature, fee strategy, witnesses, etc.) so the per-property
assertions actually exercise data preservation.

3/5 address transitions now on the new convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upgrade AddressFundingFromAssetLockTransition, AddressFundsTransferTransition,
and AddressCreditWithdrawalTransition tests to non-default fixture +
per-property assertions per the new convention.

Bug surfaced: AddressFundingFromAssetLockTransition.value_round_trip
fails — `OutPoint` inside `ChainAssetLockProof` cannot deserialize from
`platform_value::Value::Map` ("invalid type: map, expected an OutPoint").
JSON round-trip works fine. Marked the value test #[ignore] with the
reason and logged in plan §10b for pass-3 fix.

5/5 address transitions now on the new convention. 46 json_convertible_tests
pass, 3 ignored (1 OutPoint bug + 2 StateTransition untagged-enum known
failures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erty assertions

Replaces the legacy Identity::default() fixture with one that has:
- id: Identifier::new([0x42; 32])
- balance: 1_000_000
- revision: 7
- public_keys: BTreeMap with 2 distinct entries

Per-property assertions check id, balance, revision, and public_keys count.
Removes the duplicate empty-fixture test module that was leftover.

401 dpp lib tests pass (filtered to identity::identity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… tests

Apply non-default fixture + per-property assertion convention to:
- IdentityPublicKey (8 distinguishable fields incl. disabled_at, contract_bounds)
- TokenContractInfo (contract_id + token_contract_position; note: untagged enum)
- Pooling (test all 3 variants — Never/IfAvailable/Standard)

48 json_convertible_tests pass, 3 ignored (1 OutPoint bug, 2 StateTransition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces single-Default-fixture tests for unit enums with
each_variant() pattern that exercises all variants in turn. This is
the per-property-assertion equivalent for unit-only enums where each
discriminant is the only "field".

Upgrades:
- TokenEmergencyAction (Pause, Resume)
- GasFeesPaidBy (DocumentOwner, ContractOwner, PreferContractOwner)
- YesNoAbstainVoteChoice (YES, NO, ABSTAIN)

48 json_convertible_tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply non-default fixture + per-property assertion convention to:
- GroupStateTransitionInfo (group_contract_position=5, action_id=[0x33;32], action_is_proposer=true)
- DocumentPatch (id=[0x77;32], 2 properties, revision=3, updated_at=1.7T)

48 json_convertible_tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…per-property

5-field fixture with all Option fields populated and gas_fees_paid_by
set to a non-default variant. Per-property assertion verifies each field
preserves through round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er-property

5-field fixture (owner_id, transitions, user_fee_increase,
signature_public_key_id, signature) with distinguishable values.
transitions vec is empty since DocumentTransition sub-types are tested
in their own modules. Per-property assertion verifies each field
preserves through round-trip.

49 json_convertible_tests pass, 3 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rk list

Updates the plan with:
- Pass-2 status table — 17/~80 types upgraded, 1 bug surfaced.
- Explicit list of types still on Default fixtures or without tests.
- Cost estimate: ~10-15 hours of focused work to finish pass 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds basic round-trip + format version tag tests for:
- IdentityCreateTransition (json/value tests #[ignore]: V0::default()
  has structurally invalid asset_lock_proof — needs explicit fixture)
- IdentityTopUpTransition
- IdentityCreditTransferTransition
- MasternodeVoteTransition
- IdentityPublicKeyInCreation
- IdentityUpdateTransition
- IdentityCreditWithdrawalTransition

DataContractCreateTransition and DataContractUpdateTransition skipped:
their V0 inners lack Default — needs explicit fixtures (TODO).

68 json_convertible_tests pass, 5 ignored (3 prior + 2 new
IdentityCreateTransition pending real fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds basic round-trip tests using Default fixture for:
- BlockInfo (struct with Default)
- Vote (manual Default impl)
- VotePoll (manual Default impl)
- ResourceVoteChoice (derived Default with #[default] variant)
- InstantAssetLockProof (manual Default impl)

Marks 6 types as TODO (no Default — needs explicit fixture):
- ContractBoundSpecification, ChainAssetLockProof,
- ExtendedBlockInfo, ExtendedEpochInfo, FinalizedEpochInfo,
- IdentityTokenInfo, TokenStatus.

78 json_convertible_tests pass, 5 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces TODOs with hand-built fixtures for:
- IdentityTokenInfo (frozen=true)
- TokenStatus (paused=true)
- ExtendedEpochInfo (6 fields, distinguishable values)
- FinalizedEpochInfo (12 fields incl. block_proposers map)
- ExtendedBlockInfo (8 fields incl. signature [u8;96])

Bug surfaced: ExtendedBlockInfo value_round_trip fails on signature
field round-trip via platform_value::Value ("Invalid symbol 17"). JSON
works. Marked #[ignore] and logged in plan §10b.

87 conversion tests pass, 6 ignored (3 prior + 1 new bug + 2 needs-fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AssetLockValue uses AssetLockValue::new() factory (V0 fields are
pub(super), can't be set directly).
ChainAssetLockProof uses OutPoint::from_str factory; value test
ignored due to known OutPoint round-trip bug.

90 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ourceVotePoll + ContestedDocumentVotePollWinnerInfo

102 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ansition

Both use fully-qualified trait syntax to disambiguate from legacy
StateTransitionValueConvert::to_object/to_json methods on the same
type — known E0034 ambiguity per plan §3.11.

106 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocumentReplaceTransition, DocumentTransferTransition,
DocumentPurchaseTransition, DocumentUpdatePriceTransition — all use
fully-qualified trait syntax to disambiguate from legacy methods.

116 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nMint

122 conversion tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…troyFrozenFunds

128 tests pass, 7 ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y/Claim/DirectPurchase/SetPrice)

136 conversion tests pass, 7 ignored. All 17 of 19 batch sub-transitions
now tested (only TokenConfigUpdate remaining — needs TokenConfigurationChangeItem fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shumkov and others added 30 commits May 7, 2026 18:01
…entity

Identity and PartialIdentity wasm wrappers had to_json / from_json /
to_object methods that went through generic serde via the wasm-side
helper (\`serialization::to_json\` etc.). These calls produce the same
wire shape as the canonical \`JsonConvertible\` / \`ValueConvertible\`
trait methods on dpp::Identity / dpp::PartialIdentity, but bypass the
trait — so any future custom impl on dpp wouldn't propagate to the
wasm boundary.

Switch to canonical traits where possible:

IdentityWasm:
- to_object: already used canonical \`self.0.to_object()\` (no change)
- to_json:   \`serialization::to_json\` -> \`self.0.to_json()\` + json_to_js
- from_json: \`serialization::from_json\` -> \`Identity::from_json(json)\`
- from_object: KEPT — uses \`Identity::try_from_platform_versioned\`
  because the wasm API dispatches on the \`platform_version\` arg, not
  on the value's \`\$formatVersion\` tag (intentional SDK convention).

PartialIdentityWasm:
- to_object: \`platform_value::to_value\` -> \`self.0.to_object()\` (canonical)
- to_json:   \`platform_value::to_value\` -> \`self.0.to_object()\` then
             \`platform_value_to_json\`
- from_object / from_json: KEPT — manual field-by-field with
  \`platform_version\` for inner IdentityPublicKey deserialization.

For IdentityPublicKey: the wasm \`to_object\` calls \`to_cleaned_object\`
(which strips disabledAt: None) — different semantic from canonical
to_object, so kept as-is to avoid changing the JS wire shape.

Disambiguation: PartialIdentity now imports \`ValueConvertible\`, which
collides with \`IdentityPublicKeyPlatformValueConversionMethodsV0::from_object\`
on IdentityPublicKey (both have a \`from_object\` method). Used the
fully-qualified \`<IdentityPublicKey as IdentityPublicKeyPlatformValueConversionMethodsV0>::from_object\`
form at the one collision site.

Test results: 1120 passing, 0 failing (unchanged).
cargo check -p wasm-dpp2: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dpp traits

Two more callers of the wasm-side generic-serde helper that wrap a
versioned dpp type with the canonical traits available — switched to
delegate via JsonConvertible / ValueConvertible:

- \`DataContractWasm::config()\` getter (data_contract/model.rs:397):
  was \`serialization::to_object(self.0.config())\` (generic serde).
  Now \`config().to_object()\` -> platform_value -> JS. DataContractConfig
  is a versioned enum with \`#[derive(JsonConvertible, ValueConvertible)]\`.

- \`TokenConfigurationLocalizationWasm::TryFrom<&JsValue>\` fallback path
  (tokens/configuration/localization.rs:121): was
  \`serialization::from_object(...)\`. Now goes through
  \`platform_value_from_object\` then
  \`TokenConfigurationLocalization::from_object\` (the canonical
  ValueConvertible trait method).

After these and the prior commits in this branch, the only remaining
callers of wasm-dpp2's generic-serde helpers (\`serialization::to_object\`
/ \`from_object\`) are over leaf types that aren't versioned dpp
structures — \`BTreeMap<String, JsonSchema>\` (document_schemas) and
\`BTreeMap<String, Value>\` (document data). Those are fine to keep on
generic serde.

Manual audit summary across all wasm-dpp2 wrappers of dpp domain types:

✅ Already routed through dpp methods (no change):
  IdentityPublicKeyWasm    - to_cleaned_object / to_json_object /
                              from_object / from_json_object
  DocumentWasm             - to_map_value / from_platform_value /
                              Document::to_json / from_json_value
  DataContractWasm         - to_value / from_value / from_json /
                              from_bytes / to_bytes (all dpp methods)

✅ Migrated in this branch:
  IdentityWasm             - to_object / to_json / from_json now via
                              JsonConvertible / ValueConvertible (was
                              generic serde via wasm helper)
  PartialIdentityWasm      - to_object / to_json now via
                              ValueConvertible (was direct
                              platform_value::to_value)
  BatchTransitionWasm      - now via _inner! macro (delegates to
                              JsonConvertible / ValueConvertible)
  GroupWasm                - same
  TokenConfigurationLocalizationWasm - same
  PoolingWasm Deserialize  - delegates to dpp::pooling_serde

❌ Cannot migrate (legitimate context-aware extensions):
  IdentityWasm.from_object         - wasm SDK convention: dispatch on
                                     platform_version arg, not value's
                                     \$formatVersion tag
  PartialIdentityWasm.from_*       - manual field-by-field deserialization
                                     with platform_version for inner keys
  IdentityPublicKeyWasm.{to,from}_*- already context-aware via dpp methods
  DocumentWasm.from_*              - composite wrapper (Document + metadata)
  DataContractWasm.{to,from}_*     - take \`platform_version\` + \`full_validation\`

❌ Cannot migrate (semantic divergence):
  IdentityPublicKeyWasm.to_object  - to_cleaned_object strips disabledAt:None

❌ Cannot migrate (JS interop adapters):
  IdentifierWasm / PlatformAddressWasm Serialize/Deserialize -
    visit_seq for Uint8Array, visit_map for {type,data} JS quirks

Test results: 1120 passing, 0 failing (unchanged).
cargo check -p wasm-dpp2: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous Phase E note dismissed wasm-dpp2's manual Serialize/Deserialize
impls on \`IdentifierWasm\` and \`PlatformAddressWasm\` as "JS-interop
quirks." Tracing through actual production usage shows that's
under-described — the adapters back the public TS \`IdentifierLike =
Identifier | Uint8Array | string\` contract and the wasm-sdk's
\`address_infos_to_js_map\` returning \`Map<hex_string, ...>\` keyed on
\`PlatformAddressWasm::to_hex()\`. Both flows pass non-canonical strings
(hex / bech32m) through the deserialize path in production.

Replace the dismissive note with a side-by-side comparison table
(canonical wire shapes dpp provides vs the lenient extras wasm adds),
the production usage reasons (IdentifierLike API contract, hex Map
keys, bech32m UI input), and a verdict that the current factoring is
correct — pushing more into dpp would weaken the canonical contract or
add dpp surface for one consumer. Includes "Don't re-litigate this"
note so a future agent doesn't try the obvious-looking
\`#[serde(transparent)]\` simplification.

Also expanded the manual to_*/from_* methods audit table to enumerate
every wrapper of an rs-dpp domain type (Identity, PartialIdentity,
IdentityPublicKey, Document, DataContract) and exactly which dpp
methods each delegates to — so the question "are we calling dpp
identity methods?" is answered definitively in the doc.

Final note rewrite: the "small follow-up" section that listed
bytes_b64 cleanup as a future PR now records what actually shipped on
this branch (bytes_b64 deletion + serde_bytes::option backport, plus
the 4 + 2 + 2 wrapper migrations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…page

Phase F of the json-value unification: lock in what's been built so
nobody re-introduces the parallel inherent-method pattern, and give
contributors a single canonical reference to point to.

## docs/json-value-conversion-canonical-pattern.md

A how-to-do-it-correctly-today reference (vs the long-form
unification plan, which captures rationale + history). Covers:

- The two canonical traits and their default impls
- When to derive vs hand-roll the impls (decision table)
- Tag-key conventions (\$formatVersion / \$baseFormatVersion /
  \$extendedFormatVersion / \$type / \$transition / \$action / kind /
  type) with the \$-prefix-iff-neighbor-\$-fields rule
- Round-trip + per-property test template (non-default fixture, both
  JSON and platform_value path, wire-shape lock)
- Escape hatches: tuple-variant enums needing internal tagging,
  field-level shaping helpers (\`serde_bytes\`, \`serde_bytes::option\`,
  \`serde_bytes_var\`, \`json_safe_u64\`, \`pooling_serde\`,
  \`json_safe_fields\` proc macro)
- Critical-1 through Critical-5 awareness (is_human_readable
  divergence, JSON array→bytes coercion, ExtendedDocument
  Critical-3, DataContract impure serde, to_canonical_object
  signature dependence)
- wasm-dpp2 wrapper patterns (\`_inner!\` for rs-dpp domain types,
  \`_serde!\` for wasm-only DTOs, manual context-aware methods)
- Anti-patterns to avoid

## scripts/lint/check_no_new_inherent_conversions.sh + allowlist

Lints \`packages/rs-dpp/src/**/*.rs\` for module-scope
\`pub fn (to_json|from_json|to_object|from_object|into_object)\`
methods. Compares against a snapshot allowlist of currently-tolerated
exceptions (7 entries — context-aware methods like
\`Document::to_json(&self, &PlatformVersion)\` and tuple-variant
asset-lock-proof helpers).

- Strips line numbers from grep output so the allowlist is stable
  across unrelated edits.
- Emits paths relative to repo root so the allowlist is portable
  across machines / CI runners.
- \`--update\` flag regenerates the allowlist (only when intentionally
  removing a tolerated exception).
- Adds a step "No new inherent JSON/Value conversions" to
  .github/workflows/tests-rs-workspace.yml between formatting and
  clippy. Runs on macOS self-hosted alongside the existing lints.

Verified: passes on clean tree, fails when a new violation is
planted in a temp file inside packages/rs-dpp/src.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI lint and canonical-pattern reference doc both shipped in
5caa138. Update Phase F bullets in docs/json-value-unification-plan.md
from ⬜ to ✅ with pointers to the lint script and the new reference doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on AssetLockProof variants

Phase D step 2 of the json-value unification plan: removing inherent
methods that are pure 1:1 delegations to canonical traits.

Both \`InstantAssetLockProof\` and \`ChainAssetLockProof\` had:

  pub fn to_object(&self) -> Result<Value, ProtocolError> {
      platform_value::to_value(self).map_err(ProtocolError::ValueError)
  }

  pub fn to_cleaned_object(&self) -> Result<Value, ProtocolError> {
      self.to_object()
  }

The first is byte-for-byte equivalent to the default
\`ValueConvertible::to_object\` impl. The second is just an alias for
the first (these types have nothing to "clean" — neither carries an
\`Option<T>\` field that would null out). Both types already derive
\`ValueConvertible\`, so deleting the inherent methods leaves callers
on the canonical default.

Updates:

- rs-dpp: delete the 4 inherent methods. Update internal tests to
  bring \`ValueConvertible\` into scope. Drop the redundant
  \`test_to_cleaned_object_succeeds\` test (was an exact alias of
  \`test_to_object_succeeds\` after the inherent method aliasing).

- wasm-dpp (legacy, minimum-touch): patch the 2 call sites that used
  \`self.0.to_cleaned_object()\` — add \`use dpp::serialization::
  ValueConvertible\` and switch to \`self.0.to_object()\`. Identical
  behavior. Per the plan's minimum-touch policy for wasm-dpp legacy.

Test results:
- rs-dpp: 3718 lib tests passing (was 3716 — 1 removed test,
  feature-gating changes raised the visible count).
- wasm-dpp: cargo check clean.
- wasm-dpp2: 1120 / 0 unchanged.

Net: -23 lines in rs-dpp inherent surface, identical wire shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_in_creation

Phase D step 3 of the json-value unification plan: dead code removal.

## CBOR module — entirely commented-out, deleted

Three files in \`identity/identity_public_key/conversion/cbor/\` and
\`identity/identity_public_key/v0/conversion/cbor.rs\` consisted of
nothing but commented-out lines (139 total, every line a \`// \`-prefix).
The files defined \`IdentityPublicKeyCborConversionMethodsV0\` —
nothing in the workspace references the trait or any of its methods,
even when building with \`identity-cbor-conversion\` enabled. Just dead.

- Deleted: \`identity/identity_public_key/conversion/cbor/\` directory
  (mod.rs + v0/mod.rs).
- Deleted: \`identity/identity_public_key/v0/conversion/cbor.rs\`.
- Removed the corresponding \`#[cfg(feature = "identity-cbor-conversion")]
  mod cbor;\` lines from both \`conversion/mod.rs\` files.

The \`identity-cbor-conversion\` feature flag stays in Cargo.toml since
it composes with downstream features; removing it is a breaking change
for consumers who reference it. The flag now just brings in the
\`cbor\` dep + enables \`value-conversion\` — pure carrier, no module
gating. Cleanup of the feature itself can be a separate decision.

## public_key_in_creation/v0/mod.rs — 119 lines of commented code

A 119-line block (lines 148-266) of commented-out methods on
\`IdentityPublicKeyInCreationV0\` — \`from_object\`,
\`from_raw_json_object\`, \`from_json_object\`, \`to_raw_object\`,
\`to_raw_cleaned_object\`, \`to_raw_json_object\`, \`to_ecdsa_array\`,
plus two \`#[cfg(feature = "state-transition-cbor-conversion")]\`
methods (\`from_cbor_value\` / \`to_cbor_value\`).

All replaced by either canonical traits (\`JsonConvertible\` /
\`ValueConvertible\` derives on the type) or by the \`IdentityPublicKey\`
canonical conversion methods that work via the \`From\` impl into
\`IdentityPublicKey\`. The closing \`// }\` at line 266 is what gave it
away — the block had been a method block on the impl, kept around as
a hand-rolled reference for the canonical migration. Now stale.

Verified:
- cargo check -p dpp (default + identity-cbor-conversion features): clean
- cargo test -p dpp --lib: 3718 passing, 0 failed
- wasm-dpp2: 1120 / 0 unchanged

Net: -260 lines of dead code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both steps shipped in earlier commits on this branch (\`30b43dc87b\` and
\`bde42eb320\`). Update the plan doc to reflect what landed and capture
two side notes:

1. The \`identity-cbor-conversion\` feature flag in rs-dpp's Cargo.toml
   is now a pure dep-carrier after the CBOR module deletion — no
   module-gating remains. Left in place to avoid a breaking change for
   downstream consumers.

2. The plan's earlier reference to commented blocks at
   \`asset_lock_proof/mod.rs:62-133\` was stale — that area was cleaned
   up in this branch's earlier asset-lock-proof tagged-enum work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase D step 4 of the json-value unification plan: replace explicit
\`value.remove("disabledAt")\` cleanup with
\`#[serde(skip_serializing_if = "Option::is_none")]\` on
\`IdentityPublicKeyV0::disabled_at\`. Eliminates the parallel
to_object/to_cleaned_object wire-shape divergence.

## Audit summary (per the plan's pre-merge requirement)

The "anything hashing serializations sees different bytes" risk is
ZERO for consensus paths. All hashing/signing/proof generation goes
through bincode (PlatformSerializable::serialize_to_bytes), which
is independent of serde-skip attributes:

- Identity::hash() — bincode
- Drive identity-key storage (replace_key_in_storage,
  insert_key_to_storage) — bincode
- IdentityPublicKey::public_key_hash() — hashes only the raw \`data\`
  bytes
- State transitions adding/updating keys use IdentityPublicKeyInCreation
  which has no \`disabled_at\` field at all
- to_canonical_object / to_canonical_cleaned_object exist only on
  state transitions, never on standalone IdentityPublicKey

## Wire-shape change

Visible only on the **serde-driven JSON / platform_value** path. For
the common case of a non-disabled key:

  Before: { id, type, purpose, ..., data, disabledAt: null }
  After:  { id, type, purpose, ..., data }

\`disabledAt\` with a real timestamp (Some) emits unchanged. Round-trip
works because the field also has \`#[serde(default)]\` — deserializing
an object without a \`disabledAt\` key produces \`None\`.

## Cleanups enabled

- IdentityPublicKeyV0::to_cleaned_object → pure delegation to to_object
  (was 8 lines with explicit remove("disabledAt"))
- IdentityV0::to_cleaned_object → pure delegation to to_object (was 9
  lines iterating publicKeys array, calling
  remove_optional_value_if_null)
- Both methods will be deletable in step 5 along with the
  IdentityPlatformValueConversionMethodsV0 trait surface that wraps
  them.

## Test fixture updates

- rs-dpp Identity round-trip tests (json + value paths) — drop the
  \`disabledAt: null\` literal from the expected wire shape.
- wasm-dpp2 Identity.spec.ts (3 fixtures: expectedJSONOutput,
  expectedJSONInput, expectedObject) — drop \`disabledAt: null\` /
  \`disabledAt: undefined\` for the same reason.
- The existing \`tagged_raw_value()\` deserialization input fixture in
  packages/rs-dpp/src/identity/conversion/platform_value/mod.rs
  keeps \`disabledAt: Value::Null\` to verify legacy-shape input is
  still accepted on deserialize.

Test results:
- rs-dpp: 3718 lib tests passing (2 wire-shape assertion updates).
- wasm-dpp2: 1120 / 0 passing (3 fixture updates).
- cargo check on wasm-dpp / wasm-dpp2 / wasm-sdk: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shipped in commit 7bed945. Captures the consensus audit findings
inline so the next contributor knows the path was investigated and
why bincode-based hashing/signing is unaffected by the serde-skip
attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase D step 5 of the json-value unification plan: now that
\`disabled_at\` carries \`#[serde(skip_serializing_if = "Option::is_none")]\`
(step 4), \`to_cleaned_object\` is byte-for-byte identical to
\`to_object\`. Delete the redundant trait surface.

## Deleted

- \`IdentityPlatformValueConversionMethodsV0\` (entire 1-method trait)
  - file: \`identity/conversion/platform_value/v0/mod.rs\` (deleted)
  - file: \`identity/v0/conversion/platform_value.rs\` (deleted, V0 impl)
  - outer impl on \`Identity\` in \`identity/conversion/platform_value/mod.rs\`
  - mod declaration in \`identity/v0/conversion/mod.rs\`
  - The trait's only method (\`to_cleaned_object\`) had a default body
    \`self.to_object()\` and the V0 impl was already pure delegation
    after step 4. Canonical \`ValueConvertible::to_object\` covers the
    same surface.

- \`to_cleaned_object\` method on
  \`IdentityPublicKeyPlatformValueConversionMethodsV0\`
  - removed from trait def (\`identity_public_key/conversion/platform_value/v0/mod.rs\`)
  - removed from V0 impl (\`identity_public_key/v0/conversion/platform_value.rs\`)
  - removed from outer \`IdentityPublicKey\` impl
    (\`identity_public_key/conversion/platform_value/mod.rs\`)
  - The trait itself stays (its \`from_object(value, &platform_version)\`
    method has legitimate version-dispatch semantics that
    \`ValueConvertible::from_object\` doesn't provide).

## Migrated callers

- \`IdentityV0::to_json\` / \`to_json_object\`: switched from
  \`self.to_cleaned_object()\` to canonical
  \`ValueConvertible::to_object\` (via the same trait import).
- \`IdentityPublicKeyV0::to_json\` / \`to_json_object\`: same.
- \`IdentityPublicKeyWasm::to_object\` (wasm-dpp2): switched from
  \`self.0.to_cleaned_object()\` to fully-qualified
  \`ValueConvertible::to_object(&self.0)\` (disambiguates between
  canonical \`ValueConvertible\` and legacy \`*PlatformValueConversionMethodsV0\`,
  both of which are in scope at the call site).

## Test fixtures updated

- \`identity_public_key/v0/conversion/platform_value.rs\`:
  renamed \`to_cleaned_object_*\` tests → \`to_object_*\` (assertions
  unchanged — same wire-shape behavior, just exercising the
  canonical method now).
- \`identity_public_key/conversion/platform_value/mod.rs\`:
  renamed \`to_cleaned_object_removes_disabled_at_when_none\` →
  \`to_object_strips_disabled_at_when_none\`.
- \`identity/conversion/platform_value/mod.rs\`:
  renamed \`identity_wrapper_to_cleaned_object_includes_format_version_tag\` →
  \`identity_wrapper_to_object_includes_format_version_tag\`.

## Test results

- rs-dpp: 3713 lib tests passing (was 3718 — lost 5 tests: 2 deleted
  with the v0/conversion/platform_value.rs file + 3 inline tests that
  exercised the now-redundant \`to_cleaned_object\` method specifically).
- wasm-dpp2: 1120 / 0 unchanged.
- cargo check on rs-dpp / wasm-dpp / wasm-dpp2: clean.

Net: −2 trait files, −1 trait method, ~−110 lines of redundant code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures what shipped in commit 18034d6 (to_cleaned_object cleanup
across Identity / IdentityPublicKey trait surfaces) and what's still
deferred for a larger follow-up — the rest of the legacy trait method
migration and the from_json_object binary-field replacement extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…K trait

Phase D step 5 (continued): \`IdentityPublicKeyPlatformValueConversionMethodsV0\`
no longer carries \`to_object\` / \`into_object\` — both methods were
byte-identical to canonical \`ValueConvertible::to_object\` /
\`into_object\` (which the outer \`IdentityPublicKey\` enum already
derives). The trait now exists solely as the wrapper for its
version-aware \`from_object(value, &platform_version)\` method,
which dispatches by \`identity_key_structure_version\` (distinct from
canonical \`from_object\` that dispatches on the value's own
\`\$formatVersion\` tag).

## Concrete changes

- Trait def: dropped 2 method signatures, kept \`from_object\` with
  expanded doc explaining its raison d'etre.
- V0 impl: dropped \`to_object\` / \`into_object\` bodies (each was a
  one-liner around \`platform_value::to_value\`).
- Outer \`IdentityPublicKey\` impl: dropped 2 dispatch arms.
- \`identity_public_key/v0/conversion/json.rs\`: \`to_json\` /
  \`to_json_object\` previously called \`self.to_object()\` from the
  legacy trait. Inlined \`platform_value::to_value(self)\` (the body
  was identical) since \`IdentityPublicKeyV0\` doesn't derive
  canonical \`ValueConvertible\` directly (only the outer enum does).

## Test fixture updates

- Several inner-V0 tests called \`key.to_object()\` /
  \`.into_object()\`. Switched to direct \`platform_value::to_value\`
  (V0 is a leaf struct without the canonical trait).
- Outer-wrapper tests in \`conversion/platform_value/mod.rs\` now
  import \`ValueConvertible\` for canonical \`to_object\` /
  \`into_object\` calls. \`from_object\` calls are explicitly
  fully-qualified through the legacy trait
  (\`<IdentityPublicKey as IdentityPublicKeyPlatformValueConversionMethodsV0>::from_object\`)
  because both canonical (no platform_version) and legacy (with
  platform_version) signatures coexist on the type.
- Reworked \`to_object_delegates_to_v0_serde_shape\` (was wrong —
  outer's \`to_object\` includes \`\$formatVersion\` while inner V0's
  serde shape doesn't): the new test
  \`to_object_includes_format_version_tag\` asserts the outer wrapper
  surfaces \`\$formatVersion: "0"\` from canonical \`to_object\`.

## Test results

- rs-dpp: 3712 lib tests passing (was 3713 — 1 less, dropped redundant
  delegation test).
- wasm-dpp2: 1120 / 0 unchanged.
- cargo check on rs-dpp / wasm-dpp / wasm-dpp2: clean.

The remaining \`IdentityPublicKeyJsonConversionMethodsV0\` and
\`IdentityJsonConversionMethodsV0\` traits stay intact — their
\`to_json_object\` (validating-JSON shape) and \`from_json_object\`
(binary-field replacement + version dispatch) methods provide
distinct semantics from canonical \`JsonConvertible\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…odsV0 entirely

After Phase D step 5 trimmed the trait down to a single method
(\`from_object(value, &platform_version)\`), examination shows the
platform_version arg is dead scaffolding for V1+ that doesn't exist:

- The V0 inner impl explicitly ignores it (\`_platform_version\`).
- The outer dispatch only ever maps to V0 (the only structure version).
- For V0 the result is byte-identical to canonical
  \`ValueConvertible::from_object(value)\` — both produce the same
  \`IdentityPublicKey\` because the outer enum is internally tagged
  with \`\$formatVersion\` and all V0 values carry that tag.

Future-V1 migration scenarios — where "platform decides version" might
diverge from "value decides version" — are speculative; if/when V1
ships, canonical's tag-driven dispatch is the correct semantic
(deserialize the value as it claims to be). The legacy
"override-the-tag-by-arg" form is unused everywhere it's currently
called.

## Changes

- Deleted \`identity_public_key/conversion/platform_value/v0/mod.rs\`
  (trait def, was a 1-method trait).
- Deleted \`identity_public_key/v0/conversion/platform_value.rs\` (V0
  impl + the \`TryFrom<&IdentityPublicKeyV0> for Value\` and
  \`TryFrom<Value> for IdentityPublicKeyV0\` helpers that lived
  alongside it — they were leftover scaffolding).
- Outer impl on \`IdentityPublicKey\` removed; the file now contains
  only the test module exercising canonical \`ValueConvertible\` round
  trips.
- Removed \`mod platform_value\` from
  \`identity_public_key/v0/conversion/mod.rs\`.

## Caller migration

- wasm-dpp2 \`partial_identity.rs:387\`: switched from
  \`<IdentityPublicKey as IdentityPublicKeyPlatformValueConversionMethodsV0>::from_object(value, platform_version)\`
  to \`<IdentityPublicKey as ValueConvertible>::from_object(value)\`.
- wasm-dpp2 \`public_key.rs:412\`: \`fromObject\` keeps the JS API
  signature \`(value, platformVersion)\` for SDK consistency, but
  routes through canonical \`ValueConvertible::from_object\`. The
  \`platform_version\` arg is now \`_platform_version\` — reserved for
  future use, not load-bearing.
- rs-dpp \`identity_public_key/v0/conversion/json.rs\`:
  \`from_json_object\` switched from \`Self::from_object(value,
  platform_version)\` to \`platform_value::from_value(value)\` (the
  TryFrom impls that previously routed through serde lived in the
  deleted file).

## Test results

- rs-dpp: 3704 lib tests passing (was 3712 — 8 fewer: dropped
  delegation tests that exercised the now-deleted trait surface).
- wasm-dpp2: 1120 / 0 unchanged.
- cargo check: clean across rs-dpp / wasm-dpp / wasm-dpp2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bject

Same dead-scaffolding pattern as the value-path cleanup
(\`IdentityPublicKeyPlatformValueConversionMethodsV0::from_object\`):
\`from_json_object\` took a \`&PlatformVersion\` arg, the V0 inner impl
ignored it (\`_platform_version\`), and the outer dispatcher always
mapped to V0 (the only structure version that exists). For V0 only,
the result is byte-identical regardless of the arg.

Trim the signature to \`from_json_object(raw_object) -> ProtocolError\`.
The trait still keeps \`to_json_object\` (validating-JSON shape) and
\`from_json_object\` (binary-field replacement) since both have real
semantic content distinct from canonical \`JsonConvertible\`.

Updated wasm-dpp2 callers (\`IdentityPublicKey.fromJSON\`,
\`PartialIdentity.fromJSON\`) to drop the unused arg. The wasm wrapper
JS API still accepts \`platformVersion\` for SDK consistency, prefixed
\`_\` since it's reserved for future use.

Test results:
- rs-dpp: 3704 lib tests passing.
- wasm-dpp2: 1120 / 0 unchanged.

Also expanded the trait doc comment so its remaining purpose
(validating-JSON shape support, complementary to canonical
JsonConvertible) is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cy traits

The wasm-dpp2 \`IdentityPublicKey.toJSON\` / \`fromJSON\` JS API was
exposing a deviant wire shape: validating-JSON form with binary fields
as JSON arrays of u8 values. Every other rs-dpp type's canonical JSON
shape uses base64 strings — including the embedded public keys inside
\`IdentityWasm.toJSON\`. The two paths produced *different* shapes for
the same data depending on whether you serialized the wrapping
Identity or the standalone IdentityPublicKey.

Switch \`IdentityPublicKeyWasm.toJSON\` / \`fromJSON\` and
\`PartialIdentityWasm\`'s inner-key deserialization to canonical
\`JsonConvertible\`. Now: same shape everywhere, base64 strings for
binary, base58 strings for identifiers.

That eliminates the only production reason to keep the legacy JSON
conversion traits. Audit confirmed:

- \`IdentityJsonConversionMethodsV0\` (Identity, not IPK): zero
  non-test callers anywhere in the workspace. Was already dead.
- \`IdentityPublicKeyJsonConversionMethodsV0\` (IPK): only callers
  were the wasm-dpp2 sites we just migrated.

## Deletions

Six files removed:

- \`identity/conversion/json/v0/mod.rs\` (\`IdentityJsonConversionMethodsV0\` trait def)
- \`identity/conversion/json/mod.rs\` (outer wrapper, was \`pub use v0::*;\`)
- \`identity/v0/conversion/json.rs\` (V0 impl + tests, ~165 lines)
- \`identity_public_key/conversion/json/v0/mod.rs\` (\`IdentityPublicKeyJsonConversionMethodsV0\` trait def)
- \`identity_public_key/conversion/json/mod.rs\` (outer impl + tests, ~85 lines)
- \`identity_public_key/v0/conversion/json.rs\` (V0 impl + \`TryFrom<&str>\` + tests, ~170 lines)

Module declarations removed from:

- \`identity/conversion/mod.rs\` (\`pub mod json\`)
- \`identity/v0/conversion/mod.rs\` (\`pub mod json\`)
- \`identity_public_key/conversion/mod.rs\` (\`pub mod json\`)
- \`identity_public_key/v0/conversion/mod.rs\` (\`mod json\`)

## Test fixture updates

- \`wasm-dpp2/tests/unit/IdentityPublicKey.spec.ts\`:
  \`toJSON\` test was asserting \`Array.from(json.data).deep.equal(...)\`
  against byte-array form. Switched to
  \`expect(json.data).to.equal('A2o5...=')\` for the canonical base64
  string. The \`fromJSON\` test was already passing a base64 string
  fixture (\`data: 'A2o5...'\`) — it kept working through the legacy
  \`from_json_object\` because \`replace_at_paths\` accepts both
  shapes; now it goes through canonical \`JsonConvertible::from_json\`
  which expects base64 directly.

## Test results

- rs-dpp: 3686 lib tests passing (was 3704 — lost 18 from the deleted
  trait test modules, all of which were exercising methods that no
  longer exist).
- wasm-dpp2: 1120 / 0 passing (1 fixture updated, full green).
- cargo check on rs-dpp / wasm-dpp / wasm-dpp2: clean.

Net: −425 lines of legacy trait code, single canonical JSON wire shape
across the entire SDK surface for identity-family types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…yInto impls

Phase D step 6 cleanup. The Critical-2 fix from earlier on this branch
already gave \`AssetLockProof\` a tagged-enum representation
(\`#[serde(tag = "type")]\`) and a manual \`Deserialize\` that routes
through \`RawAssetLockProof\` for the instant-lock raw-bytes shape.

What remained was three asymmetric helpers that produced *untagged*
\`platform_value::Value\` (dropping the variant tag entirely), so their
output couldn't round-trip through the manual \`Deserialize\`:

- \`AssetLockProof::to_raw_object\` (inherent method)
- \`TryInto<Value> for AssetLockProof\`
- \`TryInto<Value> for &AssetLockProof\`

All three had **zero production callers** workspace-wide (verified
across rs-dpp, rs-drive, rs-drive-abci, rs-sdk, wasm-dpp, wasm-dpp2,
wasm-sdk). The only callers were tests in the same module exercising
the dead helpers.

Deleted them. Use canonical \`ValueConvertible::to_object\` instead —
it produces the correctly-tagged shape (\`{type: "instant" | "chain",
...fields}\`) that round-trips through \`Deserialize\`.

The lingering \`TryFrom<&Value>\` / \`TryFrom<Value> for AssetLockProof\`
hacks (which accept legacy integer-tag and externally-tagged shapes)
are KEPT for now — they have 2 production callers in
state-transition value-conversion paths, and migrating them requires
auditing every upstream that produces a state transition's raw Value.
A TODO comment in the source already flagged this. Follow-up work.

Test fixture updates:

- \`chain_proof_to_raw_object\` test renamed to
  \`chain_proof_to_object_canonical\`, switched to
  \`ValueConvertible::to_object\`.
- \`chain_proof_value_round_trip\` rewritten to exercise the canonical
  \`to_object\` -> \`from_object\` round trip with a wire-shape
  assertion that the result has \`type: "chain"\` (not the legacy
  integer tag form).
- \`try_into_value\` test module deleted (exercised the now-deleted
  \`TryInto<Value>\` impls).

Test results:
- rs-dpp: 3684 lib tests passing (was 3686 — lost 2 tests that
  exercised the deleted impls).
- wasm-dpp2: 1120 / 0 unchanged.
- cargo check: clean across rs-dpp, wasm-dpp, wasm-dpp2.

Net: ~50 lines of asymmetric dead code removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…from_value

Phase D step 6, completing what the in-source TODO comment promised:

  // todo: replace with
  //   from_value(value.clone()).map_err(ProtocolError::ValueError)

The hack accepted two pre-Critical-2 wire shapes that no longer flow:

1. Integer-tagged: \`{type: 0|1, ...fields}\` (used \`AssetLockProofType\`
   enum's u8 discriminant)
2. Externally-tagged: \`{Instant: {...fields}}\` / \`{Chain: {...}}\`
   (variant name as map key)

After the Critical-2 fix earlier in this branch made AssetLockProof
internally tagged with \`#[serde(tag = "type")]\`, the canonical
serialization produces \`{type: "instant" | "chain", ...fields}\` —
incompatible with both legacy shapes. The hack was simultaneously
broken for canonical input (its \`get_optional_integer("type")\` call
errors when \`type\` is a string) AND unreachable for legacy input
(nothing in the workspace produces those shapes anymore).

Audit:

- Production callers of \`AssetLockProof::try_from(&Value)\` /
  \`try_from(Value)\`: only 2 — \`IdentityTopUpTransitionV0::from_object\`
  and \`IdentityCreateTransitionV0::from_object\`, both legacy
  \`StateTransitionValueConvert\` (A1) trait methods.
- External callers of those legacy A1 \`from_object\` methods:
  zero in rs-drive / rs-drive-abci / rs-sdk / wasm-dpp / wasm-dpp2 /
  wasm-sdk for these specific transitions. (wasm-dpp uses A1 for
  DataContract create/update only.)
- All 3684 rs-dpp lib tests still pass with the hack replaced —
  confirming nothing was relying on the legacy-shape acceptance.

Replaced both \`TryFrom\` impls with one-line \`platform_value::from_value\`
calls. The \`Deserialize\` impl handles the routing through
\`RawAssetLockProof\` for the instant-lock raw-bytes shape.

Test results:
- rs-dpp: 3684 / 0
- wasm-dpp / wasm-dpp2 / wasm-sdk: cargo check clean
- wasm-dpp2: 1120 / 0

Net: −56 lines of dead-on-arrival code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…notes

## Step 5 — Identity family canonical migration ✅ DONE

Updates the plan doc to reflect that ALL FOUR legacy identity-family
conversion traits have been deleted entirely (across multiple commits
on this branch):

- \`IdentityPlatformValueConversionMethodsV0\` (1-method, redundant
  after step 4 \`skip_serializing_if\`)
- \`IdentityPublicKeyPlatformValueConversionMethodsV0\` (4 methods,
  including the formerly-defended \`from_object(value, &platform_version)\`
  whose platform_version dispatch turned out to be dead scaffolding —
  V0 ignored the arg, only V0 exists, canonical tag-driven dispatch
  is byte-identical)
- \`IdentityJsonConversionMethodsV0\` (3 methods, zero non-test callers)
- \`IdentityPublicKeyJsonConversionMethodsV0\` (3 methods including
  validating-JSON shape — wasm-dpp2 IdentityPublicKey JS API switched
  to canonical base64 strings, matching every other rs-dpp type)

Documents what shipped + what didn't (the validating-JSON byte-array
shape was deliberately dropped — every other type's canonical JSON
output uses base64 strings, the IdentityPublicKey deviation was an
SDK API inconsistency).

## Step 6 — AssetLockProof tagged-enum + dead helpers ✅ DONE

Documents the two-part work: the original Critical-2 fix (tagged-enum
representation, manual Deserialize via RawAssetLockProof, canonical
traits) plus the asymmetric-helper deletion (\`to_raw_object\`,
\`TryInto<Value>\`, the \`TryFrom\` hack that accepted pre-Critical-2
legacy shapes which no longer flow anywhere).

## Step 8 — Document family ⬜ DEFERRED

Captures the audit findings for the follow-up PR: production callers
in rs-drive's document-update consensus path, the genuine semantic
divergence of \`to_map_value\` (returns BTreeMap, not Value),
\`from_json_value\` (manual ingest), and \`to_json_with_identifiers_using_bytes\`
(validating-JSON shape). Lists the specific call sites and the
sequence of steps a follow-up PR should take, including the
hash-equivalence audit needed for rs-drive.

No code changes — pure documentation update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ferent methods

Phase D step 8 slice A. The two legacy Document conversion traits had
a mix of redundant methods (1:1 canonical equivalents) and methods with
genuine semantic content. Trim the redundant ones, keep the rest with
clearer documentation of why they exist.

## DocumentPlatformValueMethodsV0 (A11)

Deleted from trait + V0 impl + outer impl + ExtendedDocumentV0 impl:

- \`to_object\` — 1:1 equivalent of canonical \`ValueConvertible::to_object\`.
- \`into_value\` — 1:1 equivalent of canonical \`ValueConvertible::into_object\`.

Kept (with expanded doc comments explaining why):

- \`to_map_value\` / \`into_map_value\` — return \`BTreeMap<String, Value>\`,
  used by ExtendedDocument and DocumentWasm to compose Document plus
  metadata fields. Canonical returns \`Value::Map(Vec<...>)\`, not the
  map directly.
- \`from_platform_value(value, &platform_version)\` — **legacy-shape
  ingest**. Accepts un-tagged Document values (no \`\$formatVersion\`).
  Symmetric with \`from_json_value\` on the JSON side. Used to ingest
  DPNS / DashPay legacy JSON fixtures and older stored shapes that
  predate \`#[serde(tag = "\$formatVersion")]\`. Initially audited as
  "dead scaffolding for V1+" — actually load-bearing for legacy
  ingest. ExtendedDocument's \`from_trusted_platform_value\` /
  \`from_untrusted_platform_value\` and the
  \`json_should_generate_human_readable_binaries\` test rely on it.

## DocumentJsonMethodsV0 (A10)

Deleted from trait + V0 impl + outer impl + ExtendedDocumentV0 impl:

- \`to_json\` — 1:1 equivalent of canonical \`JsonConvertible::to_json\`.
  Its body was \`self.to_object()?.try_into()\` — same as canonical.

Kept:

- \`to_json_with_identifiers_using_bytes\` — validating-JSON wire shape
  (bs58 string identifiers + binary fields as JSON arrays of u8).
  Used by JSON Schema validators.
- \`from_json_value<S, E>\` — generic over identifier deserialization
  type, accepts JSON without \`\$formatVersion\`. Legacy-shape ingest.

## Internal call-site updates

- \`document/v0/cbor_conversion.rs\`: \`self.to_object()\` (deleted V0
  trait method) -> inlined \`platform_value::to_value(self)\`. CBOR
  conversion goes through the same path.
- \`document/v0/json_conversion.rs\` tests: \`doc.to_json(...)\` ->
  \`serde_json::to_value(&doc)\` (DocumentV0 doesn't derive
  JsonConvertible directly).
- \`extended_document/v0/mod.rs\`: \`pub fn to_pretty_json\` inlined
  what legacy \`to_json\` body did (\`to_object()?.try_into()\`) — goes
  through platform_value as an intermediate, which is what the test
  fixture asserts (binary as base64, identifiers as bs58). Canonical
  \`JsonConvertible::to_json\` would go directly via serde_json
  (one-step), producing a subtly different shape due to Critical-1
  (is_human_readable divergence).
- \`extended_document/mod.rs\`: \`pub fn to_json\` (outer inherent,
  takes &PlatformVersion) routes through canonical
  \`JsonConvertible::to_json\` — platform_version arg kept for API
  compatibility but isn't load-bearing.

## Test results

- rs-dpp: 3677 lib tests passing (was 3684 — lost 7 tests that
  exercised the deleted methods; round-trip coverage moved to
  canonical-trait round-trip tests where applicable).
- wasm-dpp2: 1120 / 0 unchanged.
- rs-drive: cargo check clean.

Net: ~−170 lines of redundant code; clearer trait surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 8 slice A shipped in commit 678121a. Update the plan doc with:

1. What landed: deleted the 1:1 canonical-equivalent methods from both
   Document family legacy traits (\`to_object\`, \`into_value\` from A11;
   \`to_json\` from A10), kept the rest with expanded doc comments.
2. Audit course-correction on \`from_platform_value\`: my initial pass
   dismissed it as "dead scaffolding for V1+" — wrong. The method
   accepts un-tagged Document values that canonical
   \`ValueConvertible::from_object\` errors on (DPNS legacy fixtures,
   ExtendedDocument's \`from_trusted_platform_value\`). Reverted that
   part of the migration; kept the method as legacy-shape ingest
   (symmetric with \`from_json_value\`).
3. Slice B deferred work: wasm-dpp2 DocumentWasm.fromObject migration,
   rs-drive test-fixture migration, \`to_map_value\` API decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase D step 8 slice B finishes the Document-family canonical migration
by removing both legacy ingest methods and routing every caller through
canonical ValueConvertible / JsonConvertible.

Trait deletions (rs-dpp):
  - DocumentPlatformValueMethodsV0::from_platform_value
  - DocumentJsonMethodsV0::from_json_value<S, E>
Traits now hold only the methods with no canonical equivalent:
  A11 -> to_map_value / into_map_value (BTreeMap<String,Value> shape)
  A10 -> to_json_with_identifiers_using_bytes (validating-JSON shape)

Caller migrations:
  - wasm-dpp2 DocumentWasm.toObject emits \$formatVersion: \"0\";
    fromObject/fromJSON use canonical from_object / from_json.
  - ExtendedDocumentV0 from_trusted_platform_value /
    from_untrusted_platform_value insert \$formatVersion via the
    new ensure_document_format_version helper, then delegate to
    canonical Document::from_object. Public API unchanged.
  - wasm-dpp legacy DocumentWasm.fromObject inserts \$formatVersion
    inline before canonical (minimum-touch, JS surface unchanged).
  - rs-drive 4 sites in drive/document/update/mod.rs and 7 sites in
    tests/query_tests.rs migrated to local document_from_legacy_value
    helpers using the same insert-tag-then-canonical pattern.
  - Removed obsolete tests in document/v0/json_conversion.rs that
    targeted the deleted from_json_value method; canonical round-trip
    is covered in serialization_traits and v0/serialize.rs.

Verified:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3670 passed, 0 failed, 8 ignored
  cargo test -p drive --lib drive::document::update::tests
    -> 39 passed
  cargo check -p dpp -p drive -p wasm-dpp -p wasm-dpp2 --tests
    clean (only pre-existing warnings)

Net for Phase D step 8 (slice A + B): legacy A10 / A11 traits reduced
from 6 methods to 3, with the 3 survivors documenting why they stay.
Phase D step 9 removes both legacy state-transition trait families
entirely. The audit reframed the work after three findings:

1. Signing is bincode (PlatformSignable -> signable_bytes()), not
   JSON canonical. The to_canonical_object / to_canonical_cleaned_object
   machinery on A1 was vestigial JS-DPP-era scaffolding with zero
   production callers - only its own tautological tests called it.
2. Outer enums already had canonical JsonConvertible / ValueConvertible
   derives from Phase C; A1 / A2 were running in parallel doing the
   same work.
3. Cross-package use was tiny: 2 wasm-dpp legacy files used
   to_cleaned_object. wasm-dpp2 had zero A1 / A2 callers.

What landed (4 slices in one commit, after the audit collapsed
"step 9 long pole" to a deletion):

Slice 1 + 3: deleted both traits and 68 impl files
  - traits/state_transition_value_convert.rs
  - traits/state_transition_json_convert.rs
  - 34 value_conversion.rs + 34 json_conversion.rs (per transition x
    {inner, outer} x {V0, V1})
  - removed mod declarations from 34 parent mod.rs files
  - removed re-exports from traits/mod.rs

Slice 2: migrated 2 wasm-dpp legacy callers to canonical
  - DataContractCreateTransitionWasm / DataContractUpdateTransitionWasm
    constructor + toObject use canonical ValueConvertible::to_object()
    plus manual signature path stripping for skip_signature
  - constructors use insert-$formatVersion-then-canonical pattern
    matching the Document migration
  - state_transition_facade.rs is dead (module not exposed); no fix
    needed there

Slice 4 (subset): added #[json_safe_fields] to 5 V0 inners that lacked it
  - AddressCreditWithdrawalTransitionV0
  - AddressFundingFromAssetLockTransitionV0
  - AddressFundsTransferTransitionV0
  - IdentityCreateFromAddressesTransitionV0
  - IdentityTopUpFromAddressesTransitionV0

Slice 4 (deferred): BatchTransitionV0 / V1 cannot get #[json_safe_fields]
yet - the attribute generates compile-time JsonSafeFields assertions
on field types and DocumentTransition / BatchedTransition (and their
sub-transitions) need their own JsonSafeFields impls first. Follow-up
for the BatchTransition family migration.

Test cleanup: subagent deleted 76 tautology tests across 13 test
modules that were exercising the removed methods. Canonical round-trip
on outer enums via json_convertible_tests / value_convertible_tests
covers correctness.

Verification:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3594 passed, 0 failed, 8 ignored (was 3670; -76 tautology)
  cargo check -p drive -p wasm-dpp -p wasm-dpp2 -p dash-sdk -p drive-abci --tests
    clean (only pre-existing warnings)

Net: 219 lines added, 5051 lines deleted across 108 files.
…lpers

Plan-doc refresh:
- Updated progress table (now 2026-05-09): Phase D 1-9 done, steps 10-11
  remain. Pass 4 (wasm-dpp2 _serde! migration) reframed: step 9 audit
  showed wasm-dpp2 had no actual A1/A2 blockers, so the remaining
  _serde! sites need re-survey to identify actual blockers.
- Critical-5 (sorted-keys-for-signing) marked FALSIFIED — signing is
  bincode, the canonical-object machinery had zero production callers.
- A1/A2 rows in §3.1 trait table struck out with deletion commit ref.
- §3.5 catalogue: state_transition trait + impl files marked deleted.
- §3.10 affected-type total: pre-Phase D ~90 non-canonical types
  collapsed to ~10-15 remaining (DataContract KEEP-AS-EXCEPTION,
  AddressWitness/ContestedIndexFieldMatch step 11, wasm-dpp legacy).
- §3.11 step 7 (ExtendedDocument C1) marked DONE with commit ref.
- Phase D summary section now lists step-by-step status.

Code: deleted abstract_state_transition.rs - the state_transition_helpers
module had become dead after step 9 removed all callers (the trait
methods were the only consumers of to_object/to_cleaned_object/to_json
helpers). Verified zero remaining users via grep, then removed the
mod declaration and pub use re-export from state_transition/mod.rs.

Verification: 3594/3594 dpp lib tests pass; workspace --tests check
clean.
Step 9 follow-up: completes the JS-safe integer protection for the
BatchTransition tree, deferred at step 9 because it required a deeper
walk through the document/token transition sub-tree.

Attribute applied to V0 inners that were missing it (8 leaves):
  - DocumentDeleteTransitionV0
  - TokenFreezeTransitionV0
  - TokenUnfreezeTransitionV0
  - TokenDestroyFrozenFundsTransitionV0
  - TokenClaimTransitionV0
  - TokenEmergencyActionTransitionV0
  - TokenConfigUpdateTransitionV0
  - TokenSetPriceForDirectPurchaseTransitionV0

Plus BatchTransitionV0 / BatchTransitionV1 themselves (deferred at
step 9 with explanatory comments; comments removed, attribute applied).

Manual JsonSafeFields impls added in safe_fields.rs for 7 types where
the `#[json_safe_fields]` macro doesn't reach (variant-internal u64s
or wrapper enums with manual `impl JsonConvertible`):
  - DocumentTransition, TokenTransition, BatchedTransition (wrapper
    enums - their inner V0 leaves are all json_safe_fields-annotated,
    so safe by induction)
  - TokenEmergencyAction (unit-variant enum)
  - TokenDistributionType (unit-variant enum)
  - TokenPricingSchedule (escape-hatch pattern - tuple variants hold
    Credits / BTreeMap<TokenAmount, Credits>; matches existing
    TokenEvent pattern)
  - TokenConfigurationChangeItem (escape-hatch - tuple variants hold
    Option<TokenAmount> / Option<GroupContractPosition>)

Also: made DocumentTransition / TokenTransition enum re-exports public
in batch_transition/batched_transition/mod.rs (were `use`, now `pub use`)
so safe_fields.rs can name them via `crate::...::batched_transition::X`.

Verification:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3594 passed, 0 failed, 8 ignored
  cargo check -p drive -p wasm-dpp -p wasm-dpp2 -p dash-sdk -p drive-abci --tests
    clean (only pre-existing warnings)

Plan / memory updated to mark step 9 follow-up done. Phase D steps
1-9 + follow-up are now complete; remaining: step 10 (DataContract
KEEP-AS-EXCEPTION rename pass) and step 11 (AddressWitness /
ContestedIndexFieldMatch manual-impl refactor).
…dexFieldMatch

Phase D step 11 — replace custom Serialize/Deserialize impls with serde
attributes, gated on round-trip + wire-shape parity tests.

AddressWitness (address_funds/witness.rs):
  - Replaced ~115 lines of manual serde with `#[serde(tag = "type")]`
    internal tagging.
  - Explicit `rename = "p2pkh"` / `rename = "p2sh"` on variants
    (camelCase rule is ambiguous for `P2pkh` / `P2sh`).
  - `redeem_script` field gets explicit `rename = "redeemScript"`.
  - Behavior change: `MAX_P2SH_SIGNATURES` no longer enforced on the
    JSON/Value deserialize path. The bincode `Decode` impl still
    enforces it (the load-bearing wire format). Documented in the
    type's doc comment. JSON/Value is dev/SDK-facing; downstream
    consumers must validate signature counts before re-serializing.
  - Wire shape unchanged. The existing 4 round-trip tests in
    `json_convertible_tests` (P2PKH / P2SH x JSON / Value) keep
    passing — byte-for-byte parity confirmed.

ContestedIndexFieldMatch (data_contract/document_type/index/mod.rs):
  - Replaced ~95 lines of manual serde with
    `#[serde(rename_all = "snake_case")]` externally-tagged enum.
  - LazyRegex gets `serde(from = "String", into = "String")` so it
    round-trips as a bare string (the `regex: OnceLock<Regex>` field
    is reconstructed lazily on use).
  - Bug fix: previous custom Serialize emitted `{"Regex": ...}`
    (PascalCase) while custom Deserialize expected `{"regex": ...}`
    (snake_case) — the type was non-round-trippable through serde.
    New impl is consistently snake_case in both directions.
  - No production callers identified — production data-contract
    loading uses the unrelated Value-walking `regexPattern` path.
  - Added 4 round-trip + wire-shape parity tests:
    `json_round_trip_contested_index_field_match_regex`
    `json_round_trip_contested_index_field_match_positive_integer`
    `value_round_trip_contested_index_field_match_regex`
    `value_round_trip_contested_index_field_match_positive_integer`
    First two assert exact JSON shape `{"regex": "..."}` /
    `{"positive_integer_match": N}`.

Verification:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3598 passed (was 3594; +4 new), 0 failed, 8 ignored
  cargo check -p drive -p wasm-dpp -p wasm-dpp2 -p dash-sdk -p drive-abci --tests
    clean (only pre-existing warnings)

Net: ~-210 lines, both types now go through pure serde derive. Phase D
steps 4-9 + 11 all complete; only step 10 (DataContract — KEEP-AS-EXCEPTION
rename pass) remains in the deprecation order.
Step 11 follow-up: my initial migration picked snake_case for the
ContestedIndexFieldMatch wire shape, justified as "preserve what the
existing Deserialize accepted." That was the wrong call:

1. The previous serde impl was already non-round-trippable (Serialize
   emitted PascalCase, Deserialize expected snake_case), so there's no
   real wire shape to preserve.
2. There are zero production callers — data-contract loading uses an
   unrelated Value-walking `regexPattern` path.
3. The codebase convention is camelCase for JSON wire shapes
   (`$formatVersion`, `redeemScript`, etc.). snake_case sticks out.

Changed `serde(rename_all = "snake_case")` -> `serde(rename_all =
"camelCase")` on the enum. Updated the parity test fixture from
`{"positive_integer_match": 42}` to `{"positiveIntegerMatch": 42}`.
The `Regex` variant is unchanged (single-word lowercase identical
between snake_case and camelCase).

Verified: 3598/3598 dpp lib tests pass.
…al-4

Phase D step 10 — three pieces landing together:

1. Critical-4 pinned in regression tests
   (data_contract/conversion/serde/mod.rs::data_contract_serde_pins_critical_4):
   - data_contract_round_trips_through_serde_json: serde_json round-trip
     works at the active platform version.
   - data_contract_serialize_matches_serialization_format_at_current_version:
     DataContract::serialize is byte-equivalent to
     DataContractInSerializationFormat::serialize at LATEST_PLATFORM_VERSION
     — proves the manual impl is a thin format-routing wrapper, not a
     custom shape.
   - data_contract_deserialize_rejects_invalid_schema_via_full_validation:
     DataContract::deserialize rejects a contract with an indices entry
     referencing a nonexistent property — proves the hardcoded
     full_validation=true runs on the JSON/Value/CBOR ingest path.
   Module-level doc explains the impurity rationale (Critical-4 in the
   unification plan) so future readers / refactors understand the
   constraint.

2. Method rename pass to disambiguate from canonical traits:
     DataContractJsonConversionMethodsV0::
       from_json   -> from_json_versioned
       to_json     -> to_json_versioned
       to_validating_json (unchanged — no clash)
     DataContractValueConversionMethodsV0::
       from_value  -> from_value_versioned
       to_value    -> to_value_versioned
       into_value  -> into_value_versioned
   The `_versioned` suffix makes it visually obvious that these are NOT
   canonical JsonConvertible::to_json (zero args) but the version-aware
   path that takes &PlatformVersion and a full_validation flag. ~26 call
   sites updated across rs-dpp / rs-drive / rs-drive-abci / wasm-dpp /
   wasm-dpp2 / dash-sdk / rs-sdk-ffi.

3. KEEP-AS-EXCEPTION rationale documented at three sites:
   - conversion/json/v0/mod.rs (trait def)
   - conversion/value/v0/mod.rs (trait def)
   - data_contract/mod.rs:112-120 (outer enum, already had a comment;
     updated to reference the new method names)
   The traits stay because DataContract is a versioned enum routed
   through DataContractInSerializationFormat. Both the platform version
   and full_validation flag are inputs to the conversion that canonical
   `JsonConvertible` / `ValueConvertible` (with their parameter-free
   signatures) cannot express. The rename UNBLOCKS adding canonical
   traits in the future if we choose to (no longer ambiguous), but
   doesn't do so now.

Drive-by: rs-sdk-ffi/src/document/queries/search.rs needed an explicit
`use ValueConvertible;` import — pre-existing E0599 surfaced during the
verification cargo check across the workspace, fixed inline since it
was a one-line block on getting the workspace clean.

Verification:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3601 passed, 0 failed, 8 ignored (was 3598; +3 from new
       Critical-4 pin tests)
  cargo check -p dpp -p drive -p drive-abci -p wasm-dpp -p wasm-dpp2 -p dash-sdk -p rs-sdk-ffi --tests
    clean (only pre-existing warnings)

Phase D unification plan steps 1-11 are now complete. The plan's
"deprecate non-canonical mechanisms" pass is done for all non-DataContract
types; DataContract intentionally retains its version-aware traits as
the documented exception.
Step 10 follow-up. Flips the hardcoded full_validation=true in the
manual `Deserialize` impl on DataContract to false, making schema
validation opt-in instead of a hidden default.

Why:
- Most production callsites load already-validated contracts from
  storage and were paying validation cost twice.
- Hidden behavior in serde Deserialize doesn't match the rest of the
  codebase's serde semantics (where Deserialize means "structurally
  well-formed").
- Trust-but-verify boundaries (SDK ingest, JSON fixtures, gRPC
  handlers) were already plumbing the `full_validation` flag
  explicitly through `from_*_versioned` and don't depend on the
  implicit canonical-Deserialize default.

Audit confirmed canonical `Deserialize` had zero production callers
depending on its implicit validation: only ExtendedDocument's nested
round-trip and the Critical-4 pin tests themselves exercised it.
Production callers using `from_*_versioned(_, true, _)` keep working
unchanged (they explicitly opt in); those using `from_*_versioned(_,
false, _)` keep working unchanged (they explicitly opt out); only the
canonical path semantics change, and they change to match the rest of
the codebase.

Critical-4 pin test renamed
`data_contract_deserialize_does_not_validate_by_default` and now
asserts both halves of the new contract:
- canonical `serde_json::from_value::<DataContract>(...)` ACCEPTS a
  contract with an invalid `indices` schema entry (no validation).
- explicit `DataContract::from_json_versioned(_, true, _)` REJECTS
  the same payload (opt-in validation runs).

Module-level doc on conversion/serde/mod.rs updated:
- "Validation policy: opt-in, not default" section explains the new
  contract.
- Critical-4 framing updated to focus on the platform-version coupling
  (which stays) rather than the hardcoded validation (which is gone).

Verification:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3601 passed, 0 failed, 8 ignored (no count change)
  cargo check -p drive -p drive-abci -p wasm-dpp -p wasm-dpp2 -p dash-sdk -p rs-sdk-ffi --tests
    clean (only pre-existing warnings)

Plan + memory updated to reflect the four-piece Step 10 landing
(Critical-4 pin + rename + KEEP-AS-EXCEPTION docs + validation flip).
User-directed cleanup of step 10's earlier rename pass. The `_versioned`
naming was misleading — every conversion path uses the platform version,
including canonical (via `get_current()`). The actual semantic split is
**validation: yes / no**.

Final API:
  WITHOUT validation (the cheap path):
    serde_json::from_value::<DataContract>(...)
    platform_value::from_value::<DataContract>(...)
    serde_json::to_value(&dc)
    platform_value::to_value(&dc)
  WITH validation (opt-in, trust-boundary path):
    DataContract::from_json_validated(json, &pv)
    DataContract::from_value_validated(value, &pv)
  Kept (different concept):
    to_validating_json(&pv) — JSON Schema-compatible output with
    binary fields as u8 arrays

Deleted entirely:
  - DataContractJsonConversionMethodsV0::from_json_versioned (had a
    bool full_validation that's now baked into the method name choice)
  - DataContractJsonConversionMethodsV0::to_json_versioned (canonical
    serde_json::to_value does the same via get_current())
  - DataContractValueConversionMethodsV0::from_value_versioned
  - DataContractValueConversionMethodsV0::to_value_versioned
  - DataContractValueConversionMethodsV0::into_value_versioned

Caller migration (~30 sites across rs-dpp / rs-drive / rs-drive-abci /
wasm-dpp / wasm-dpp2 / dash-sdk / rs-sdk-ffi):
  - from_*_versioned(value, true, &pv)  -> from_*_validated(value, &pv)
  - from_*_versioned(value, false, &pv) -> canonical serde/platform_value
                                            from_value (caller's pv is
                                            usually current; for the few
                                            sites that need pv to control
                                            variant dispatch, deserialize
                                            via DataContractInSerializationFormat
                                            then try_from_platform_versioned
                                            — same internal shape the old
                                            method had)
  - from_*_versioned(value, dynamic_bool, &pv) -> if/else bridge between
                                                   validated and canonical
                                                   (preserves dynamism)
  - to_*_versioned(&pv) / into_value_versioned(&pv) -> canonical to_value /
                                                       serde_json::to_value
                                                       (DataContract doesn't
                                                       implement canonical
                                                       JsonConvertible/
                                                       ValueConvertible directly
                                                       per its doc comment;
                                                       use the function form)

Notable judgment call: tests/json_document.rs test-fixture loader needs
caller-provided &pv to control which DataContract variant comes back
(history-replay scenarios). Bridge's else-branch deserializes through
DataContractInSerializationFormat (serde(tag = "$formatVersion") enum
dispatches by tag), then calls try_from_platform_versioned(format,
false, &mut vec![], pv) for caller-pv variant dispatch. This preserves
the exact internal shape the old from_json_versioned(value, false, pv)
had.

Verification:
  cargo test -p dpp --features all_features_without_client --lib
    -> 3601 passed, 0 failed, 8 ignored (no count change)
  cargo check -p dpp -p drive -p drive-abci -p wasm-dpp -p wasm-dpp2 -p dash-sdk -p rs-sdk-ffi --tests
    clean

Plan + memory updated. Phase D unification work complete; DataContract
no longer has the misleading `_versioned` ceremony — validation is the
real axis, and it's expressed in the type system via method name.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant