Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ABI document versioning

Pina’s ABI document is the checked-in migrations/manifest.json and the publication ledger at migrations/publications.json. Both carry an abiVersion that belongs to Pina itself and has nothing to do with any user contract’s on-chain version.

This page covers how that version is chosen, how older documents are normalized, what the document stores versus derives, and how its JSON Schemas are generated and published. For the runtime decision tree that governs on-chain migrations, read How ABI migrations flow. For the decision record behind this design, read ADR 0009.

Three version axes

A Pina program carries three unrelated kinds of version, and confusing them is the most common source of migration bugs.

AxisExample valueWho owns itChanges when
On-chain contract version3 (integer in the envelope)Pina, allocated by createan enveloped contract’s published schema changes
ABI document abiVersion"0.21"pina_abi’s own release linea breaking pina_abi release — and nothing else
pina_abi package version0.21.3the release plannerany pina_abi release, including patches

Only the first one is written into account bytes. An ABI document upgrade never consumes an on-chain migration version, and an on-chain migration never changes abiVersion.

pina_abi is not part of the core release group. It releases on its own single-member group, so a CLI fix or a renderer tweak in core moves nothing here, while any pina_abi release cascades into a core release because pina_cli and pina_macros consume it. The contract moves deliberately and consumers follow; core releases never move the contract.

The version value

abiVersion is a committed value, one line of text in crates/pina_abi/ABI_VERSION, embedded with include_str!:

pub const ABI_VERSION: &str = include_str!("../ABI_VERSION").trim_ascii_end();

The file is pinned to pina_abi’s major.minor and is allowed to lead the crate. A shape-changing pull request advances the file while Cargo.toml still reads the old version — the pre-release window — and the release planner’s bump catches the crate up. This is the monochange model: a committed SCHEMA_VERSION that can sit ahead of its crate (verified at 0.7 against a crate at 0.6.3), which a compile-time derivation of CARGO_PKG_VERSION cannot express. monochange tried derivation twice and reverted both times; ADR 0009 carries the history and the reasoning.

Pre-1.0 bump semantics

While the major is 0, the release planner shifts bump severity: a breaking changeset advances the minor, a feat or fix advances only the patch. Since abiVersion keeps major.minor, only a breaking changeset moves it:

changeset on pina_abicrate versionabiVersion
breaking0.20.0 → 0.21.0"0.21"
feat0.20.0 → 0.20.1"0.20"
fix0.20.0 → 0.20.1"0.20"

The version moves if and only if the contract moved. This property is why pina_abi stays pre-1.0: at 1.0.0 the shift switches off, a feat would advance major.minor with no wire change, and 1.0.0 is not reachable through the planner anyway. Every 0.N version also keeps a permanent schema URL under the publishing policy, and every ABI version is a real contract worth one.

Reading a document

Every reader goes through the decode path rather than deserializing straight into the typed model:

  1. parse the JSON value;
  2. read abiVersion and reject a document stamped above the reader’s own version, naming the supported version in the error;
  3. apply each adjacent converter in order, from the document’s version up to the current one;
  4. deserialize into the current typed model;
  5. validate every content-addressed invariant, re-deriving everything the document no longer stores.

Step 2 is a capability marker, not a compatibility promise, and behaves like Cargo.lock’s version field: a repository’s documents are read by the same Pina the repository builds with, so the rejection only reaches a human who downgraded, and the remedy is one line — upgrade.

Conversions run in memory only. No command rewrites a checked-in document as a side effect of reading it.

The converter contract

The reader carries an ordered table of adjacent converters — one edge per ABI version step — keyed by the version itself. There is no separate epoch table: the version advances exactly at contract changes, so it is the only axis. Both documents share one abiVersion, so each step carries a converter for each document, and walk_document(AbiDocument::Manifest | AbiDocument::Publications, from, value) applies the matching one and stamps the step’s to version on the result:

pub const ABI_STEPS: &[AbiStep] = &[AbiStep {
	from: "0.20",
	to: "0.21",
	manifest: convert::manifest_0_20_to_0_21,
	publications: convert::publications_0_20_to_0_21,
}];

Four rules make the chain trustworthy.

Every step has an edge, including no-ops. The version follows every breaking pina_abi release, but the document shape changes only some of those times. A breaking change with an unchanged document registers a validating no-op edge that decodes, validates, and returns the document unchanged. monochange’s v0.6 → v0.7 edge is the precedent — payloads unchanged, version advanced because a config contract changed — and the edge exists so the 0.6 contract stays frozen while the walk stays gapless.

Forward only. Writers always emit the current version, and a document that must reach an older tool is regenerated from source by that tool. A downgrade entry point does not exist, so no best-effort write can lose information.

Never deleted. Once an edge ships, it is part of published history. Fixing a defective converter means a new version, never editing the old one — the same immutability rule the on-chain migration system applies to its transitions.

Gapless by construction and by test. The table is an ordered walk over adjacent versions, and a test decodes every frozen fixture to the current model, so a missing or mis-wired edge is a CI failure rather than a runtime error on a user’s machine.

Stored versus derived

The 0.20 baseline stored facts and re-derived everything else at load, and 0.21 removed the remaining stored copies. What the document used to carry, and where it comes from now:

Removed fieldWhere it comes from now
ContractHistory.identity (0.21)the contracts key kind:width:hex, parsed on load
DataSchema.codec (0.21)implied by abiVersion (pina_abi::SCHEMA_CODEC)
event Transition entries (0.21)none — events are decoded per version, never converted
"transition": null (0.21)an absent transition key
ProcessAccount fields at their default (0.21)an absent writable, signer, or optional reads as false, and an absent defaultValue or pda as none
receipt sequence (0.21)the receipt’s position in receipts
receipt and pending programId (0.21)the manifest’s programId: the ledger belongs to the manifest beside it
receipt and pending manifestSha256 (0.21)not recorded — it hashed a manifest later versions rewrite, and nothing compared it
receipt and pending previousReceiptSha256 (0.21)not recorded — version control keeps the ledger append-only
per-contract version in a receipt or pending (0.21)the position of the contract’s last pin
DataSchema.physicalderived from layout and fields by the frozen grammar
SchemaVersion.versionthe version’s position in the versions array
SchemaVersion.schemaSha256computed from the decoded schema
SchemaVersion.processSha256computed from the decoded process
Transition.from / toadjacency — the transition sits on version index from index-1
Transition schema/process hashesthe neighbouring versions’ computed hashes
Transition.process proofre-derived by classify_process_transition
ProcessAccount.constraintsnot recorded — validation rules live in the IDL clients use
receipt clusternot recorded — rpc_url keeps the credential-free endpoint

What stays is what cannot be derived: rust_name (macro lookup), the auto policy, envelope (written only as "envelope": false, on an instruction recorded without one), identity validation including the path-traversal proofs, mode, renames, implementation_sha256 (the hash of an external transition file), and the publication ledger: each receipt’s credential-free rpcUrl, its executableSha256, the pinned schema history of every contract it made live, and its abandoned flag, plus the pending record’s cluster. Publication receipts pin schema hashes by computing them from the decoded manifest at pin time.

A field’s rustType is stored as the source spelled it when the version was recorded. Drift is decided with DataSchema::same_wire, which compares the layout, the field names, and each type after pina_abi::wire_type collapses spellings that store the same bytes under the same reading (PodU64 and u64, Address and [u8; 32], PodString<N> and String<N>, PodVec<T, N> and Vec<T, N>). A later respelling therefore neither rewrites the recorded spelling nor changes a pinned hash.

The wire codec left the stored schema but not the schema hash. schemaSha256 still hashes layout, fields, and "codec": "pinaPodV2" in the order 0.20 serialized them, so every published pin keeps its value. A PinaPod release that changes wire bytes changes SCHEMA_CODEC through a breaking pina_abi release, and the converter for that release records the old format on every historical schema it carries forward.

Because deny_unknown_fields applies, any field change is a breaking change for older readers, and every document change therefore advances the ABI version through a breaking changeset. The converse does not hold — a breaking crate change with an unchanged document is the no-op edge above.

The frozen fixture matrix

crates/pina_abi/fixtures/0.20/manifest.json
crates/pina_abi/fixtures/0.20/publications.json
crates/pina_abi/fixtures/0.20/manifest.schema.json
crates/pina_abi/fixtures/0.20/publications.schema.json
crates/pina_abi/fixtures/0.21/…
crates/pina_abi/fixtures/current/…
  • A <version>/ directory is frozen when that release ships and is never regenerated. They are the only artifacts that record what an older release actually wrote; a fixture regenerated from current code would encode today’s shape under yesterday’s version and prove nothing.
  • current/ is regenerated by the schema and fixture task whenever the model changes.
  • Fixtures are generated deterministically from fixed seeds, the way the monochange schema assets are, so the frozen bytes are reproducible on every machine rather than hand-maintained.
  • The fixtures prove the shape changed; the committed value only names it. The fixture-drift guard pairs the two: regenerating current/ must reproduce the frozen bytes unless ABI_VERSION advanced. A serialization change without a version change fails CI; a version change with unchanged bytes is the deliberate no-op edge, which registers a new frozen directory with identical bytes.
  • A test decodes every frozen fixture through the current reader and asserts it reaches the current model — the gapless-walk guard.
  • The not-lag guard asserts the committed value never lags the crate’s major.minor, and the ahead-requires-changeset guard asserts that a value ahead of the crate — the pre-release window — has an active pina_abi changeset behind it. Any other ahead state fails.

JSON Schemas

The document types derive schemars::JsonSchema, so the schema is generated from the same types that serialize and deserialize the document — deny_unknown_fields becomes additionalProperties: false, and the schema can never drift from the code that enforces it.

  • Canonical artifacts are checked in at crates/pina_abi/schemas/manifest.schema.json and publications.schema.json, regenerated and drift-checked in CI the way Codama IDL fixtures are.
  • Each version’s schema is frozen inside its fixture directory, so the schema for any historical shape stays printable.
  • Every version’s schema is hosted at a permanent URL under this book: https://pina-rs.github.io/pina/abi/schemas/<version>/manifest.schema.json, with $id set to that URL. Every 0.N version keeps its URL permanently.
  • pina abi schema [--document manifest|publications] [--version <major.minor>] prints the JSON with stable output, so editors, CI jobs, and non-Rust toolchains can validate a document without trusting the bytes:
pina abi schema --document manifest > manifest.schema.json

Upgrading from ABI 0.20

A 0.20 document still opens: the reader converts it in memory, and the next pina migrations create writes it at 0.21. The one exception is a ledger entry that names a published version without pinning it, covered below. The manifest step:

  • drops each history’s identity object after proving that its kind, width, and hex spell the contract key;
  • drops each schema’s "codec": "pinaPodV2" after proving it is that codec;
  • drops every event transition, because an event is decoded with the schema of the version that emitted it;
  • keeps every instruction enveloped, because every 0.20 instruction history was.

The publication ledger step drops every stored copy of a fact the ledger or its manifest already records:

  • drops each receipt’s sequence after proving it equals the receipt’s position;
  • drops programId from every receipt and the pending record after proving they all name the same program;
  • drops each contract’s version after proving it is the position of its last pin, leaving the entry as the bare list of pins;
  • drops manifestSha256 and the previousReceiptSha256 chain link unchecked, because nothing compared the one and anyone able to edit the file could recompute the other;
  • drops the pinned transitionSha256 of every event:* contract.

Every schemaSha256 pin keeps its value. A 0.21 entry must pin every version it made live, so a 0.20 entry that pinned nothing cannot convert, and reading it fails with an error naming pina migrations reconcile --pin-legacy. Confirm from version control that migrations/manifest.json still records exactly what those receipts shipped, then run that command once: it pins each such entry from the manifest (trust on first use) and writes the ledger in the 0.21 shape.

Five source changes can follow the conversion:

  1. Instructions recorded through auto. An instruction that 0.20 recorded only because auto covered instructions stays enveloped after conversion, but under 0.21 an auto policy alone records an instruction without one, so the build fails: “the migration manifest records UpdateInstruction with a version envelope, but it no longer opts in with the migrations token”. If the instruction is published, add migrations to its #[instruction] attribute to keep its wire format; pina migrations create rejects the other direction with EnvelopeRemoval. If nothing is published, you may instead run pina migrations create, which records the instruction as a snapshot without a version byte; delete its migrations/transitions/instruction_* directory afterwards, because nothing reads it.
  2. Event transitions. migrations/transitions/event_* directories are no longer read or verified. Delete them. Code that called normalize_event_data, with_current_event_data, MigratableEvent, or CurrentEventData must decode a historical record with the generated client’s <Event>V<n> event instead.
  3. Instruction handlers. #[discriminator(entrypoint)] now normalizes an enveloped instruction’s payload before the handler runs, through the generated process_versioned and ProcessAccountInfos::process_from_version. Remove handler-side calls to with_current_instruction_data: the payload is already current, and the helper’s closure now takes the source version as a second argument, so existing calls no longer compile.
  4. pina.toml migration keys. [migrations].version_type (or version-type) and [migrations].auto are retired, because the manifest already records both. Every command that reads pina.toml fails until they are removed, and the error names the pina migrations create --version-type or --auto value that records the same setting. [migrations.answers] stays.
  5. TypeScript event decoders. The generated per-event decoder is decode<Event>Event (for example decodeValueChangedEventEvent and decodeValueChangedEventV0Event), matching the Dart clients. It decodes one version and converts nothing; rename calls to the old normalize<Event>Event.

Upgrading from pre-0.20 integer formats

The integer formatVersion era is retired, not migrated. Migrate to the reset ABI document walks the conversion. After upgrading Pina:

  1. run pina migrations sync (or pina migrations create) in the program directory — every checked-in draft is regenerated at the current abiVersion;
  2. commit the rewritten manifest.json and publications.json.

A ledger that pinned a real deployment would have no upgrade path from the integer formats. None exists today — the only non-empty receipt in any example records the fixture cluster — and accepting that one-time stranding is recorded in ADR 0009.

What this does not cover

This mechanism governs the document. It is not the on-chain migration system:

  • it does not read or write account bytes;
  • it does not allocate or consume a contract version;
  • a broken converter cannot corrupt an account, because no account data passes through it.

The publication ledger records no manifest digest. A 0.20 receipt carried a manifestSha256, but it hashed a manifest that later versions rewrite and nothing compared it, so 0.21 drops it. What a receipt proves comes from its pins instead: the schema and transition hashes of every version it made live, which every later check compares with the checked-in manifest. ADR 0009 records the decision.

The on-chain rules — envelopes, adjacent transitions, rent, the publication ledger, and the four scenarios — are in How ABI migrations flow.

Checklist for an ABI-affecting release

  1. Change the document shape, or the pina_abi contract. deny_unknown_fields means every document change is breaking; there are no additive-only changes.
  2. Advance crates/pina_abi/ABI_VERSION to the next minor. The value leads the crate until the release bump catches up.
  3. Add the adjacent converter edge — a real one when the bytes changed, a validating no-op when they did not.
  4. Add the breaking changeset on pina_abi. The ahead-requires-changeset guard fails without it.
  5. Regenerate the canonical schemas and the current/ fixtures, freeze the outgoing version’s fixture directory, and copy the new version’s schemas into the hosted location.
  6. Run pina migrations sync so every checked-in example is rewritten at the current version, and confirm the current/ fixtures changed — or, for a no-op edge, did not.
  7. Run pina migrations check and the pina_abi suite. The not-lag, ahead-requires-changeset, fixture-drift, and gapless-walk tests are the four gates that must pass.