pub const API_MINOR: u32 = 59;Expand description
The integer MINOR of API_VERSION — see there. Bumped from 0 to 1 when params validation
became strict (#34); to 2 with the set_nickname verb + StatusResult.self_nickname (#37);
to 3 when allow/grant strings became STABLE principals — b64u:/eid:/roster names,
never nicknames (#38); to 4 with the set_app_metadata verb + PresencePeer.meta (#39);
to 5 with PeerReachability.meta — pairing-mode app metadata on the probe pong (#40);
to 6 with PeerInfo.principal — the peer’s eid: device principal on status (#41);
to 7 with PeerReachability.principal — the same on reachability rows (#42); to 8 with the
service_allow_grant/service_allow_revoke per-peer access verbs (#44); to 9 covering the
unregister_service (#50) / peer_services (#52) / Run env+cwd (#51) surface that shipped
in 0.10.1 without a bump, PLUS the set_relays live relay-set verb (#53); to 10 when
service_allow_revoke/peer_remove became IMMEDIATE — no verb shape changed, but their
observable contract did: a revoked principal’s next session is refused even on a connection it
already holds, and its live connections are severed. Previously both waited for the peer to
disconnect on its own, which is unbounded (#54). A consumer can guard on
api_minor >= 10 before telling a user that revocation has taken effect; to 11 when
service_allow_grant/service_allow_revoke gained EPHEMERAL-service support and became strict
about an unknown service name — a name in neither the config nor the ephemeral registry now
answers ERR_NO_SUCH_SERVICE instead of a silent {} (#55, #69); to 12 with the pushed
StreamFrame::Reachability liveness transition frame (#58); to 13 with
PeerReachability::path — direct-vs-relay attribution on every reachability row (#64); to 14
with the run-backend MCPMESH_PEER_EID identity var — the caller’s stable device principal,
unconditionally present, so a run server can scope per caller without keying on a nickname
(#60); to 15 with the blob_revoke / blob_unpublish verbs — per-scope withdrawal of a grant
and of a published hash, so un-sharing a file no longer requires unpairing the person (#62); to
16 when the app-blob provider became available in PAIRING mode — the blob verbs previously
errored on any daemon without an org root key, though their scope gate never needed one (#61);
to 17 when the service answer began coming from the LIVE registry rather than config + overlay,
so a grant the accept path would refuse is no longer advertised. Three surfaces share that
resolver and all changed together: status‘s services[].allow, peer_services’ name list,
and the mcpmesh/ping/1 probe’s services. No wire shape changed, only the source of truth —
exactly the class of change a downstream cannot see in a type diff (#100); to 18 with blob_republish, so a fetched blob can
be re-served and every recipient becomes a source (#83); to 19 with durable blob revocation — an
unpublish now survives a later republish via a per-scope withdrawal set, and
ERR_BLOB_WITHDRAWN distinguishes “deliberately withdrawn” from “never had it” (#107); to 20
with blob_list filters + paging AND a DEFAULT limit of 256 scopes (the clamp is 4096) — a
daemon with more scopes than that previously answered with
everything, and past the 16 MiB frame cap the CLIENT rejected the response as malformed, leaving
the caller an opaque failure with no way to page. The connection survived: the control surface
carries no strike bound. This is a behaviour change for existing callers, detectable via the new
total/truncated (#84b); to 21 when a
PATH change became a reachability transition — StreamFrame::Reachability stopped being an
up/down toggle and same-verdict frames became possible (#92); to 22 with a SECOND producer for
that frame: a live per-session watcher that pushes when a session’s selected path changes,
rather than waiting for a probe, at a cadence probes never had (#92); to 23 when
PeerReachability::rtt_ms stopped including the path-settle window — a relayed peer could
previously never report under 600ms, so “relayed AND fast” was unreachable by construction
(#123); to 24 when reachable stopped sharing a deadline with path classification — a relayed
peer whose pong arrived after ~2.4s was reported OFFLINE while it was answering (#128); to 25
with ActiveSession::principal — the live-session view was keyed on a display nickname, so
two devices under one nickname were indistinguishable and any UI acting on a session (revoke,
disconnect, inspect) keyed on a collidable string (#73); to 26 when a
rate-limited inbound NOTIFICATION stopped being silently dropped and became a recorded audit
event — no type changed; the observable audit stream did (#76, #139); to 27 with the audit_prune /
audit_list verbs, StatusResult::storage, and the opt-in [limits].audit_retain_months
boot retention — the audit log stopped being a permanent, unbounded, unreadable record (#88);
to 28 with StatusResult::self_network / StreamFrame::SelfNetwork / the snapshot’s copy —
the node’s OWN reachability posture, previously unanswerable from either side of the API
(#90); to 29 with AuditRecord::principal — stable identity on the event stream and the
on-disk log, resolving #57’s parked docs conflict in favour of the #41/#42/#73 line (the
audit surface bans secrets and raw hex, not the prefixed principal rendering); to 30 with
StreamFrame::Reachability’s source — the frame has had TWO producers since 22 with no way
to tell them apart, so an embedder could not distinguish “a throwaway dial went via a relay”
from “the link this call is on just degraded”, and had to hedge every message down to the
weaker claim. rtt_ms: None was never the discriminator the doc implied (#150); to 31 with
ERR_NICKNAME_TAKEN — the nickname-collision pair refusal is branchable instead of
-32000, so an embedder writes its own recovery copy rather than substring-matching ours. The
prose changed with it: it named the set_nickname CONTROL VERB as the remedy, which a GUI user
cannot type, and the refusal is generated inviter-side so the embedder displaying it could not
rewrite it (#147); to 32 with SelfNetwork::identity_conflict_epoch — two nodes booted from
COPIES of one mesh root share an endpoint id, and the displaced one’s peers went unreachable
with nothing saying why. The relay reports it and iroh only warn!s it, so the fact existed
and was unreadable (#134); to 33 with the peer_diagnostics verb — a long-lived pairing that
cannot hole-punch while a fresh identity on the same hardware can differs only in DURABLE
per-peer state, and none of it was readable from outside the daemon (#140); to 34 when
outstanding invites became DURABLE — invite.expires_at_epoch changed meaning from an upper
bound on the daemon’s process lifetime to the real lifetime, and invite gained an error where
it previously always succeeded. No shape changed, which is exactly the class minor 10 records:
guard on api_minor >= 34 before telling a user their invite will still be good tomorrow
(#87b); to 35 with InviteParams.max_uses + InviteResult.uses_remaining — a bounded
multi-use invite, so onboarding a team is one link rather than one ceremony per person. Each
redemption still runs its own SAS and writes its own peer rows; it is N pairings sharing a
secret, never a group identity (#87); to 36 with branchable codes for the rest of the ONBOARDING
refusals — expired line, no live invite, inviter unreachable, id mismatch, name conflict, and
the deliberately-opaque refusal. ERR_NICKNAME_TAKEN had been the only coded pairing failure,
so every other one arrived as -32000 and an embedder could either forward our prose to end
users or substring-match it (#159); to 59 with Request::PeerHintClear — FORGET one peer’s
persisted dial hint (#140). An experiment tool and a workaround, not a policy change: nothing
clears a hint automatically. PeerEntry.last_addr is the only durable per-peer state on a node’s
disk that the dial path reads and the only thing a long-lived pairing carries that a freshly
paired identity does not, so clearing it makes the pairing ADDRESSING-EQUIVALENT to a fresh one —
which is exactly the difference #140 is about. Advisory, never authorization: the row, its
user_id, its services and its pairing stamp are untouched, and an absent hint is a supported
state. Guard on >= 59; to 58 with the REVERSE-DNS _meta key spellings
tech.counterpunch.mcpmesh/{service,peer} alongside the legacy mcpmesh/{service,peer} (#49,
SEP-1788’s SHOULD). No shape changed, and no peer or backend has to change what it READS:
both spellings are WRITTEN with identical values and EITHER is accepted, which is why this did
not need the coordinated wire change #49 assumed. A backend reading mcpmesh/peer keeps working.
It is not a no-op for every backend, though. A handler that rejects unknown _meta keys —
deny_unknown_fields, additionalProperties: false — now sees a second one and will refuse
requests it accepted at 57, the same class of break that made 0.50.0 a MINOR. Both prefixes are stripped from caller frames, because a
prefix mcpmesh writes but does not strip is one a caller can forge. A caller sending the two
service spellings with DIFFERENT values is REFUSED rather than reconciled. The reserved-key
enumeration in the 2026-07-28 grammar is progressToken, io.modelcontextprotocol/*, and bare
traceparent/tracestate/baggage; a prefixed key is reserved only by its SECOND label, so
neither of our spellings ever collided. The legacy
mcpmesh/* spellings are deprecated as of 0.51.0 and will be removed at 1.0 — migrate reads to
the reverse-DNS form. Guard on >= 58 only if you need the new spelling to be present; to 57
when _meta["mcpmesh/peer"] began being injected on
EVERY proxied request rather than only the handshake (#45 ask 2). No shape changed — this is
the same class as 37, on the same field: what changed is WHEN a backend can rely on the value
being there. Before 57 a served backend learned its caller on the session’s first frame and on
any later initialize, and saw nothing on frames 2..N; from 57 every request carrying a method
is attributed. A backend that authorizes per-request rather than binding at session start must
guard on >= 57, exactly as 37’s note says to guard before trusting the value at all. Positional
(array) params are the one exception and are deliberately left un-attributed — see
docs/local-protocol.md. Guard on >= 57; to 56 with PeerDiagnosticsResult::known_addrs and its two
set-difference companions — IROH’s own view of a peer’s addresses alongside the hint we stored
(#140). The verb dumped this node’s disk and nothing about what iroh made of it, which is the
half the standing hypothesis lives in: iroh skips address lookup while a path is selected, so a
pair holding a relayed connection never re-discovers and the stale hint is the only addressing
the dial contributes. Read-only (a point read of the remote map), so it stays safe to run on a
live reproduction — though it does reset iroh’s ~60s per-remote idle timer, so polling it keeps
state alive that would otherwise be reaped. known_addrs ABSENT means iroh holds no entry right
now, which is neither an empty list nor “never heard of”: iroh reaps a remote ~60s after its last
connection closes. Guard on >= 56; to 55 with StreamFrame::Resumed — a SUSPEND/RESUME
signal on subscribe (#167 ask 2). A suspended machine sends nothing, so the peer’s idle timer
tears the connection down before the lid reopens and keep_alive_secs cannot help; the frame is
what lets an embedder re-dial deliberately instead of discovering it on the next send. Detected
as wall-clock/monotonic skew, so a starved runtime (both clocks run long) does NOT emit. It names
no session — the daemon cannot know which survived. Guard on >= 55; to 54 with
OpenSessionParams::idle_timeout_secs — a
PER-CONNECTION QUIC idle timeout on the dial path (#166). The node-wide [network] knob made a
chat session, a bulk blob transfer and a media flow share one compromise. Only LOWERING is
unilateral (QUIC negotiates the minimum of both peers), which is also why the absence of an
accept-side twin is a missing symmetry rather than a missing half: the direction that can work
one-sidedly is available, and the direction that cannot never worked anywhere. Ignored on a
RACING dial, which abandons connections (logged at warn!, since a caller cannot know how many
devices a peer has). No per-connection keepalive — iroh caps the per-path
interval at 5s, so one could only make pings more frequent. Guard on >= 54; to 53 with org_rotate and the roster’s
successor_root_pk/successor_sig — ORG ROOT ROTATION (#93 ask c). The schema pinned exactly
one signature slot against one key, so an operator laptop that died took the org with it 90 days
later when the roster expired, and the delay is what made it undiagnosable; recovery was O(N)
fresh ceremonies with every member. A roster now carries a successor root cross-signed by its
predecessor, and the bridge rides EVERY subsequent roster — so a member offline for the
announcing publication still catches up, which the obvious one-shot design does not give you. A
successor is adopted only when the pinned key no longer signs directly, only on a statement that
key signed, and never resets rollback protection. A rotated roster declares
mcpmesh-roster/2, so EVERY member must be on 0.47.0+ before an org rotates — an older binary
refuses the closed-schema document and stops receiving membership changes entirely; an org that
never rotates keeps producing /1 byte-identically. NOT escrow: a LOST root cannot sign a bridge —
copy org-root.key to a second operator machine for that. Guard on >= 53; to 52 with attest_offer and the DEVICE ATTESTATION
ceremony (#85 ask 3) — a peer that already holds your b64u: admits a replacement device on the
strength of a user-key binding, with no fresh SAS ceremony with everyone you ever paired with.
PeerEntry.user_id was written once at pairing and never refreshed, so a machine restored from
a recovery phrase (ask 2) was still a complete stranger. OFF by default
([identity].admit_attested_devices): it changes what a pairing MEANS, from admitting a device
to admitting a person and their future devices, and that should be chosen rather than inherited
on upgrade. An attestation cannot admit a stranger — the receiver must already hold a row for
that user_id — cannot resurrect a REVOKED endpoint OR a revoked IDENTITY (ask 4, which is why
it shipped first; peer_revoke on a b64u: now revokes the person, because a thief holding the
disk holds the user key and can mint a fresh endpoint id at will), and
grants the INTERSECTION of that person’s existing services, never the union. Guard on >= 52;
to 51 with the PAIRING-MODE REVOCATION verbs —
peer_revoke / peer_unrevoke / device_revoke / device_revocation_import, plus
StatusResult::revoked (#85 ask 4). revoked_endpoints was roster-only, so in pairing mode
the only remedy for a stolen device was every peer independently running peer_remove, with
nothing telling them they should — until the last one did, whoever held the disk authenticated
as its owner and every message they sent was cryptographically indistinguishable. Two
directions, deliberately not one verb: peer_revoke is MY local decision about YOUR device;
device_revoke signs a portable statement about MY OWN, which is the half my peers cannot
discover for themselves. An import is honoured only from a user_id this node already pairs
with, and only for that person’s OWN devices — a signature proves who asked, not that the
endpoint was ever theirs. Revocation is IMMEDIATE (live sessions severed, #54) and outlives the
pair row, so it cannot be undone by re-pairing. Guard on >= 51; to 50 with SelfNetwork::local_discovery — LOCAL (mDNS)
peer discovery, off unless [network].local_discovery asks for it (#68). Peer resolution
otherwise needs external infrastructure, so two machines on one LAN with no uplink could not
find each other though the path between them was fine. Three modes: "off" (default), "on"
(resolve AND announce), "resolve" (resolve without publishing this node’s identity — it still
QUERIES about once a second, so it is quieter, not silent). Reported on status because the
setting was
otherwise unobservable and because "on" means this node multicasts its endpoint id and
addresses to every device on the link — a product backing a privacy switch has to be able to
show that. Deliberately NOT on by default, against what #68 asked for: pkarr publishes a signed
record you must already know the endpoint id to look up, while mDNS announces to strangers on a
café or hotel network, and a multicast packet cannot be un-sent. Guard on >= 50; to 49 with StorageInfo::blobs_gc — app-blob GARBAGE
COLLECTION, off unless [blobs].gc_interval is set (#80). blob_unpublish and blob_revoke
closed the AUTHORIZATION half at 15; neither reclaimed a byte, so <data_dir>/blobs/ grew
monotonically for the life of the node and an embedder that had told a user “this file is
deleted” could not deliver that. Opt-in, because a sweep also reclaims blobs this node FETCHED
and never republished — reclaimable in themselves (the fetch already wrote the caller’s
dest_path) but it means blob_republish of a hash fetched more than one interval ago fails.
blobs_gc is None when collection is not configured, Some with runs: 0 when it is
configured and has not swept yet — a distinction worth reading, because the collector sleeps a
full interval before its first run. WATCH runs: iroh-blobs ends collection for the process on
its first sweep error, so a counter that stops advancing is the only signal. Guard on >= 49;
to 48 with user_key_export / user_key_import — a
RECOVERY PHRASE for the user key, so a person’s b64u: survives the hardware (#85 ask 2). It
lived in one file on one machine with no export, import or escrow verb anywhere, so replacing a
laptop destroyed the identity peers pin, kb audiences key on, and a roster names — recovery was
an in-person SAS ceremony with everyone you had ever paired with. The export response carries
a PRIVATE KEY: it is deliberately absent from the audit log, from status, and from every
other surface. Import refuses to overwrite an existing key unless asked, because doing so
discards a live identity irreversibly. What it does NOT do: get a device admitted. Peers
authorize per DEVICE, and a restored user key puts this endpoint in nobody’s allowlist — that is
#85 ask 3, unshipped, and the reason a recovered person still pairs. Guard on >= 48; to 47 with BlobFetchParams::from — ADDITIONAL sources a
fetch falls back to when the ticket’s publisher does not answer (#83). Content addressing makes
every recipient a potential source, and a one-address ticket made that unusable: a file shared
with a room became unfetchable the moment the sender closed their laptop, though others in the
room already held the identical verified bytes. Additive and absent-tolerant — an older caller’s
payload reads as empty, which is the single-source behaviour — so guard on >= 47 only before
SENDING the field (deny_unknown_fields rejects the whole request below it). The bytes stay
BLAKE3-verified against the ticket’s hash whoever serves them, so an alternate can refuse but
never substitute; it must have republished the hash into a scope granting the caller
(blob_republish, api_minor >= 18). What did NOT land: multi-source PARALLEL fetch — sources
are tried in order, so an offline publisher costs one dial timeout; to 46 with the roster-mode embedding surface (#66, #93):
the org_create / org_approve / org_revoke AUTHORING verbs, the roster_members read,
PresencePeer::display_name + groups, RosterStatus::groups, and
OrgJoinResult::restart_required. Two gaps close together. Authoring existed only as CLI
porcelain, so an embedded node could CONSUME a roster and never author one — no “approve this
person” button without shelling out to a second binary. And the roster’s own contents never
crossed the seam: an embedder had managed group membership it could not display, and the only
route to a member list was hand-parsing the daemon-owned roster.json. roster_members is a
different question from status.presence — that lists reachable DEVICES and omits a person
whose devices are all down. restart_required closes a silent partial success: roster_mode is
a BOOT decision fixing the bound ALPNs and whether gossip/presence/blobs exist at all, so a
pairing-mode node that ran org_join got working MCP sessions with permanently empty presence
and no way to detect it. Guard on >= 46 before offering org authoring in a UI; the read fields
are additive and degrade to empty. What did NOT land: org root ROTATION (#93c) — an operator
laptop that dies still takes the org with it once the roster expires; to 45 with PairParams::allow_self_enroll +
ERR_SELF_ENROLL_NOT_OFFERED — pair now REFUSES a mcpmesh-enroll: line unless the caller
asked for that ceremony. A behaviour change for existing callers, deliberately: at 43-44 a caller
whose UI only ever offered “add a contact” completed a self-enrollment and learned which
ceremony it had run from enrolled_as_self afterwards — by which point the device→user binding
was written and irrevocable short of rotating the user key (#178). The refusal is decided from
the line before any dial, so the invite survives it and the same line works once the ceremony is
actually offered. Guard on >= 45 before sending the field — below it deny_unknown_fields
rejects the whole request. Note what the guard means: a daemon BELOW 45 gives a caller no way to
decline, so a UI that does not offer device enrollment should require >= 45 rather than pair
without it; to 44 when control responses stopped arriving in REQUEST
order and the blob_fetch_cancel verb landed (#172). The daemon now dispatches each request
CONCURRENTLY on its connection, so a blob_fetch no longer stalls every other verb behind it —
and responses arrive in COMPLETION order. JSON-RPC ids make that legal and the in-tree
ControlClient cannot observe it (one request at a time, by construction), but a hand-rolled
client that pipelines and matches responses POSITIONALLY breaks. A connection also caps
in-flight requests and refuses over it with ERR_TOO_MANY_INFLIGHT, and closing a control
connection now genuinely ABORTS its in-flight work rather than letting it run to completion
unread. Guard on >= 44 before pipelining, before sending blob_fetch_cancel, and before
treating ERR_CANCELLED as unexpected; to 43 with InviteParams::as_self — SELF-ENROLLMENT, so one
person’s devices share a user_id instead of appearing as unrelated strangers (#86). The
ceremony is ordinary pairing; the outcome is a device→user binding rather than a peer row, and
the private key never moves. Guard on >= 43. What this entry did NOT say, and 45 fixed: the
distinct scheme closes the version-SKEW hazard (a pre-43 redeemer silently over-granting) and
closes nothing for a CURRENT redeemer, which had no way to decline a ceremony it never offered
(#178); to 42 with the peer_introduce + peer_endorse
verbs — install a peer from a
SIGNED endorsement by someone you are already paired with, so a small group onboards in O(N)
instead of O(N²) two-human ceremonies (#65). It installs IDENTITY only and grants nothing, which
is what bounds it. Guard on >= 42; to 41 with StreamFrame::BlobTransfer — live app-blob
transfer progress on both the serving and fetching side (#82 ask 2), so an embedder can draw a
real progress bar instead of an indeterminate spinner. Guard on >= 41 before expecting the
frame. NOTE what it did NOT bring, and 44 did: at 41 blob_fetch still blocked its whole
control connection for the transfer and nothing could cancel it (#172) — progress arrived on the
SUBSCRIBE connection, which is a different one; to 40 with [services.<name>].rate_limit_per_min +
RegisterServiceParams::rate_limit_per_min — proxied-request buckets became per
(service, endpoint) instead of one shared per-endpoint bucket, so a noisy service can no
longer starve a quiet one (#63). -32053 changes meaning with it: it is now per-service, so a
consumer that backs off globally on one is backing off further than it needs to. Guard on
>= 40 before sending the field or narrowing a back-off; to 39 with PairParams::as_nickname +
InviteParams::peer_nickname — LOCAL aliases for the other party, so a nickname collision is
resolvable by the person who hit it instead of requiring the other human to rename a machine or
re-mint. #147 made the collision diagnosable; this makes it fixable. Guard on >= 39 before
offering an alias field in a UI: below it deny_unknown_fields rejects the whole request
(#87); to 38 with [network].presence_mode + SelfNetwork. presence_mode — reachable: false gained a new meaning (“up, paired, and deliberately not
answering”), and peer_services flips from “reachable, empty list” to “unreachable” for a
caller holding no grant. A consumer must guard on api_minor >= 38 before telling a user their
peer is offline, since below it that verdict could not mean this (#89); to 37 when the reserved
mcpmesh/* _meta namespace began
being enforced on EVERY proxied frame rather than the session’s first. run_session treats
frame 1 as the initialize whatever its method is, so a caller could send any other method
first and put its real initialize — with a forged mcpmesh/peer naming another principal,
forged groups and all — in frame 2, where nothing stripped or injected. No shape changed;
what changed is whether _meta["mcpmesh/peer"] can be trusted, which is the entire reason a
backend reads it. Guard on api_minor >= 37 before keying authorization on that value (#164).
Not every semantic change gets a minor, and that is the gap to watch (#122). A minor marks a change to this surface. A change to behaviour BEHIND the surface — same fields, same shapes, different meaning — may not bump it, and is invisible to a type diff. 17 and 24 above happen to be that class and did bump; do not infer from them that every such change will. When bumping several minors at once, read this block end to end AND the release notes, not the diff.
That class is bigger than it looks: 10, 17, 21, 22, 23, 24 and 37 all shipped with no change
to any type in this file — they moved meaning, not shape. Seven of the forty, and 37 is
a SECURITY fix, which is the case where a consumer most needs the guard. 38 adds a field, but
its REAL content is a meaning change to reachable — the field exists so the new meaning is
observable at all. A downstream
that diffs types across a multi-minor bump sees nothing for any of them.