Skip to content
Payvol

Operate

Versioning and deprecation

Three version numbers are in play and they version different things. Reading one as another is the most common misunderstanding about this project.

Three numbers

NumberVersionsMoves when
The npm version The package: its API, exports and behaviour. Any release. Semver, and while below 1.0 a breaking change bumps the minor.
The data profile The artifact format itself: which fields exist and what conforming means. Rarely, and deliberately. A shipped profile is not changed retroactively.
SPEC.md's revision The normative text. Whenever the wording is clarified, with neither of the above moving.

A package at 0.4.x writing an artifact that declares 0.1 is normal, not a mismatch. What conforms is decided by the profile inside the artifact, never by the version of the library that produced it.

Seen from the packages

// Three version numbers, and what each one versions.
import { createRequire } from 'node:module';

import { SPEC_VERSION, validate } from '@payvol/core';
import { DRAFT_VERSION, validateArtifact } from '@payvol/core/draft02';

// The third number: the npm version of the package these came from. It is read
// from the package's own manifest, which every Payvol package exports as
// `./package.json`. That export was missing before 0.3.2.
const require_ = createRequire(import.meta.url);
console.log('npm package   ', require_('@payvol/core/package.json').version, '(the code)');
console.log('shipped profile', SPEC_VERSION, '(the artifact format)');
console.log('draft profile ', DRAFT_VERSION, '(under development, may change)');

// A package at 0.4.x writing a 0.1 artifact is normal, not a mismatch: the
// three numbers above version three different things.
const v01 = {
  version: SPEC_VERSION,
  type: 'transfer',
  recipient: 'payee::1220aabbcc',
  instrument: 'DSO::1220be58/Amulet',
};
console.log('0.1 accepted  ', validate(v01).valid);

// Each profile refuses the other by name rather than half understanding it.
const asDraft = validateArtifact({ payload: v01, envelope: { security: 'unsignedAllowed' } });
console.log('0.1 as draft  ', asDraft.valid, asDraft.valid ? '' : asDraft.code);
console.log(
  '0.2 as v0.1   ',
  validate({ ...v01, version: DRAFT_VERSION }).valid,
  validate({ ...v01, version: DRAFT_VERSION }).code ?? '',
);
Output, captured from a real run
npm package    0.4.0 (the code)
shipped profile 0.1 (the artifact format)
draft profile  0.2-draft (under development, may change)
0.1 accepted   true
0.1 as draft   false UNKNOWN_VERSION
0.2 as v0.1    false UNKNOWN_VERSION

Output from @payvol/core version 0.4.0 and its sibling packages, installed from npm, on 2026-10-18.

The shipped profile, 0.1

Shipped and deprecated. Its 62 vectors still run and must still pass. Deprecated means it is no longer where new work goes, not that it stopped being defined: an artifact that declares it is read exactly as it always was.

It accepts Unicode control and format characters in field values. Those are invisible and change the identifier, so two requests that look identical to a payer can carry different ones. The draft refuses them. 0.1 is not changed, because adding a rejection to a shipped profile retroactively is a breaking change for everything already written against it.

The draft profile, 0.2-draft

Not frozen, and its 86 vectors can change with it. It carries its own vector set so that a change to the draft cannot quietly alter what the shipped profile means. Build against it knowing it will move.

How the package version moves

While below 1.0, a breaking change bumps the minor, never the patch. That rule was written down after 0.1.1 shipped a changed function signature as a patch, which put a breaking change in front of anyone using a caret range without asking them. 0.1.1 is deprecated and points at 0.2.0.

A release that only reports malformed input differently, where a correct caller sees no change, is a patch. That is what 0.3.1 was.