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.
| Axis | Example value | Who owns it | Changes when |
|---|---|---|---|
| On-chain contract version | 3 (integer in the envelope) | Pina, allocated by create | an enveloped contract’s published schema changes |
ABI document abiVersion | "0.21" | pina_abi’s own release line | a breaking pina_abi release — and nothing else |
pina_abi package version | 0.21.3 | the release planner | any 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_abi | crate version | abiVersion |
|---|---|---|
breaking | 0.20.0 → 0.21.0 | "0.21" |
feat | 0.20.0 → 0.20.1 | "0.20" |
fix | 0.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:
- parse the JSON value;
- read
abiVersionand reject a document stamped above the reader’s own version, naming the supported version in the error; - apply each adjacent converter in order, from the document’s version up to the current one;
- deserialize into the current typed model;
- 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 field | Where 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.physical | derived from layout and fields by the frozen grammar |
SchemaVersion.version | the version’s position in the versions array |
SchemaVersion.schemaSha256 | computed from the decoded schema |
SchemaVersion.processSha256 | computed from the decoded process |
Transition.from / to | adjacency — the transition sits on version index from index-1 |
Transition schema/process hashes | the neighbouring versions’ computed hashes |
Transition.process proof | re-derived by classify_process_transition |
ProcessAccount.constraints | not recorded — validation rules live in the IDL clients use |
receipt cluster | not 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 unlessABI_VERSIONadvanced. 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 activepina_abichangeset 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.jsonandpublications.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$idset to that URL. Every0.Nversion 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
identityobject 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.20instruction history was.
The publication ledger step drops every stored copy of a fact the ledger or its manifest already records:
- drops each receipt’s
sequenceafter proving it equals the receipt’s position; - drops
programIdfrom every receipt and the pending record after proving they all name the same program; - drops each contract’s
versionafter proving it is the position of its last pin, leaving the entry as the bare list of pins; - drops
manifestSha256and thepreviousReceiptSha256chain link unchecked, because nothing compared the one and anyone able to edit the file could recompute the other; - drops the pinned
transitionSha256of everyevent:*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:
- Instructions recorded through
auto. An instruction that0.20recorded only becauseautocovered instructions stays enveloped after conversion, but under0.21anautopolicy alone records an instruction without one, so the build fails: “the migration manifest recordsUpdateInstructionwith a version envelope, but it no longer opts in with themigrationstoken”. If the instruction is published, addmigrationsto its#[instruction]attribute to keep its wire format;pina migrations createrejects the other direction withEnvelopeRemoval. If nothing is published, you may instead runpina migrations create, which records the instruction as a snapshot without a version byte; delete itsmigrations/transitions/instruction_*directory afterwards, because nothing reads it. - Event transitions.
migrations/transitions/event_*directories are no longer read or verified. Delete them. Code that callednormalize_event_data,with_current_event_data,MigratableEvent, orCurrentEventDatamust decode a historical record with the generated client’s<Event>V<n>event instead. - Instruction handlers.
#[discriminator(entrypoint)]now normalizes an enveloped instruction’s payload before the handler runs, through the generatedprocess_versionedandProcessAccountInfos::process_from_version. Remove handler-side calls towith_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. pina.tomlmigration keys.[migrations].version_type(orversion-type) and[migrations].autoare retired, because the manifest already records both. Every command that readspina.tomlfails until they are removed, and the error names thepina migrations create --version-typeor--autovalue that records the same setting.[migrations.answers]stays.- TypeScript event decoders. The generated per-event decoder is
decode<Event>Event(for exampledecodeValueChangedEventEventanddecodeValueChangedEventV0Event), matching the Dart clients. It decodes one version and converts nothing; rename calls to the oldnormalize<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:
- run
pina migrations sync(orpina migrations create) in the program directory — every checked-in draft is regenerated at the currentabiVersion; - commit the rewritten
manifest.jsonandpublications.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
- Change the document shape, or the
pina_abicontract.deny_unknown_fieldsmeans every document change is breaking; there are no additive-only changes. - Advance
crates/pina_abi/ABI_VERSIONto the next minor. The value leads the crate until the release bump catches up. - Add the adjacent converter edge — a real one when the bytes changed, a validating no-op when they did not.
- Add the
breakingchangeset onpina_abi. The ahead-requires-changeset guard fails without it. - 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. - Run
pina migrations syncso every checked-in example is rewritten at the current version, and confirm thecurrent/fixtures changed — or, for a no-op edge, did not. - Run
pina migrations checkand thepina_abisuite. The not-lag, ahead-requires-changeset, fixture-drift, and gapless-walk tests are the four gates that must pass.