fix(server): version the persisted session blob and decode the older layouts - #647
Conversation
…layouts The blob was a bare SCALE struct, so its layout was positional: inserting a field moved every later field and invalidated everything already on disk. Sessions written before the identity material was added fail to decode against the current order, and the pairing host drops them, so the user has to re-pair. Written blobs now carry a leading version byte, so a future field can be added by minting a version instead of by breaking readers. Decoding takes the first layout that consumes the blob exactly: the tagged one, the untagged current one written between the identity-material change and this tag, then the untagged one that predates it, whose two absent fields read as None. Exact consumption is what makes probing sound, and a whole session followed by more bytes is reported as corruption rather than as an unrecognized layout, because the older layout carries fewer fields and can only ever fail by running out of data. The older arm is removable once no untagged blob remains in the field, which the version byte makes checkable.
|
@lore-bot-app review |
|
Reading the diff and checking what the record says. Back in a few minutes. |
There was a problem hiding this comment.
TL;DR
Adds a leading version byte to the persisted pairing-host session blob and decodes the two untagged layouts that shipped before it, so existing sessions survive the upgrade instead of being wiped. 0 blocking, 5 minor. This closes the gap reported in issue #441.
Summary
encode_persisted_session now writes 0x01 followed by the SCALE-encoded SessionInfo. decode_persisted_session tries the tagged body first, then the whole blob as the current eight-field struct, then the whole blob as the six-field struct that predates the two identity-material fields, and accepts the first exact decode. Six-field blobs come back with identity_chat_private_key and device_enc_public_key set to None, which the host-facing accessors already model as optional at host_core.rs:369 and runtime.rs:718. On the core-storage path the re-encoded tagged blob is written back at pairing_host.rs:587, so the slot self-upgrades on first read. I verified the tests by reading them. Cargo is not permitted in this review environment, so I did not run them.
What the record says
- PR #403 inserted the two identity fields mid-struct and stated at the time that it "is a breaking change for persisted sessions, requiring users to re-pair once." #403
- Issue #441 by the same author reports the consequence: every paired session is dropped on upgrade because the decoder has no version tag. It is still open and asks how to handle breaking SCALE changes without versioning. This PR is the answer. The record has no discussion of tag-byte versus enum wrapper, and no removal timeline. #441
- Release timing checked against the tags in this checkout:
@parity/ios-host@0.5.0and@parity/truapi@0.9.0carry the six-field layout, and everything from@parity/ios-host@0.7.0through@parity/truapi@0.13.1carries the untagged eight-field layout. Both shapes are in the field, which matches the PR's premise. The clone is shallow and has no tag before 2026-08-11. - PR #585 removed three CLI on-disk state layouts as unreachable legacy fallbacks. The team prunes this kind of code once it is provably dead, which bears on how the removal condition for the new fallback should be written. #585
- PR #601 release notes: a failed
AuthSessionrestore is now reported through the auth callbacks rather than inferred from silence, so a decoder false negative is user-visible as a disconnect. #601 - Ownership:
who_knowsreturned only polkadot-sdk names. By authorship, johnthecat wrote #403 and #441, and pgherveou owns most recenttruapi-serversession work in #501, #585, #618 and #628.
Concerns
- The untagged arm decodes the live type.
session.rs:215-226probesSessionInfo::decodefor the pre-tag eight-field layout. That layout is now fixed on disk, but the struct will change again, which is the reason the tag exists. When it does, this arm silently starts probing a layout that never shipped and the real pre-tag blobs stop decoding, recreating #441 for everyone who paired between 0.10.0 and the tag release. The test atsession.rs:403-420also encodes the live type, so it would keep passing. Freeze the eight-field layout as its ownDecodestruct now, and let only the tagged arm useSessionInfo. A#[codec(index = 1)]enum wrapper would give the same wire byte and make the per-version struct the natural shape. - The early "trailing bytes" return can skip the last fallback.
session.rs:223returns as soon as any candidate decodes with bytes left over, and the comment atsession.rs:219-222claims the six-field layout "can only ever fail by running out of data". That holds for real usernames but is not an invariant, and it does not address the shifted case where the tagged candidate is a six-field blob whose key starts with0x01. The error path atpairing_host.rs:548-553callsclear_disconnected_session(true), which deletes the slot atpairing_host.rs:890-895, so a false negative is destructive. Record the trailing-bytes result and report it only after every layout has failed. - The final error names the wrong layout.
session.rs:227-229reports only the six-field decoder's error. A corrupt tagged blob reads as "no known layout decodes it: ", and since the slot is wiped on this path the log line is the only evidence left. Include the tagged arm's error or say which arms were tried. - The external-session path never upgrades the blob.
install_external_sessionatpairing_host.rs:482-497decodes but does not hand the tagged form back to the host. A host that owns the blob itself and callsactivateExternalSessionwill present untagged bytes on every launch, so the fallback cannot be retired on the strength of core-side migration alone. The dotli submodule is not checked out here, so I could not see which path it uses. - Doc comments narrate history, against CLAUDE.md.
session.rs:149-155,173-175and206-209describe what "shipped before" and when the arm "can go". CLAUDE.md asks that comments describe only the current code and not migrations. Describe the layout itself, for example "SessionInfo without the two identity fields, accepted on read only", and replace "once no untagged blob remains in the field", which nobody can observe, with a concrete condition such as a release version or a minimum supported host core.
Questions for the author
- Does this close #441? The commit message does not reference it.
- Did any release before 0.8.0 ship a
SessionInfolayout other than the six-field one? The 0.7.0 and 0.8.0 entries for the RFC-0022 cutover in #327 and #329 look like candidates, and I could not check them from this clone. If one did, those users are still wiped. - Does dotli restore through
activateStoredSession, where the slot is rewritten, oractivateExternalSession, where it is not? The answer decides whether the fallback can ever be removed without a forced re-pair.
🤖 Reviewed by Lore (Parity knowledge base) · 58 agent turns · 567.5s · knowledge as of 2026-09-08
…fore failing Each untagged layout is its own read-only decoder describing bytes on disk, so a later change to SessionInfo cannot follow them and stop decoding the blobs they exist to read. Only the tagged arm reads the live struct, and a test pins the two shapes together so adding a field fails loudly with what to do instead. Decoding runs every layout before reporting anything. A blob that parses with bytes left over no longer ends the search, because the caller deletes the stored session when decoding fails and a false negative is therefore unrecoverable. The final error names each layout tried and its own failure, so the log left behind identifies which shapes were rejected.
|
@lore-bot-app review |
|
Reading the diff and checking what the record says. Back in a few minutes. |
There was a problem hiding this comment.
Since last review
- Resolved: the eight-field untagged layout is now a frozen
Decodestruct with a canary test, every layout is tried before any failure is reported, and the final error names each arm's failure. Doc comments on the decoders now describe the layout rather than release history. - Remaining: the external-session path still decodes without handing the tagged form back to the host. Nothing in
pairing_host.rschanged. - New in this revision: the shared
SsoSessionInfotype is not frozen, the V1 arm still decodes the live struct, the trailing-bytes result hides per-arm failures, and one test doc comment is misplaced. - Earlier questions: the PR now references #441. I verified from the release tags that the six-field layout is the only pre-#403 shape in this checkout. The dotli question stays open.
TL;DR
Adds a version byte to the persisted pairing-host session blob and reads both untagged layouts that shipped before it. 0 blocking, 5 minor.
Summary
encode_persisted_session writes 0x01 followed by SCALE SessionInfo. decode_persisted_session tries the tagged body, then the whole blob as a frozen eight-field struct, then as a frozen six-field struct, and returns the first exact decode. Six-field blobs come back with the two identity fields set to None. On the core-storage restore path the tagged re-encoding is written back at pairing_host.rs:587, so the slot self-upgrades. I verified the tests by reading them. Cargo is not permitted in this environment, so I did not run them.
What the record says
- PR #403 inserted the two identity fields mid-struct and called it "a breaking change for persisted sessions, requiring users to re-pair once." #403
- Issue #441 reports the resulting session loss on upgrade and is still open. This PR is the fix. #441
- Release tags in this checkout:
@parity/ios-host@0.5.0,@parity/truapi-host@0.6.0and@parity/truapi@0.9.0all carry exactly the six-field layout the frozen struct describes.@parity/truapi@0.10.0through0.13.1carry the eight-field layout.SsoSessionInfohas the same ten fixed-size fields at every tag. No tag predates 0.5.0 here, so older shapes are unverifiable. - PR #571: the boot restore now announces its outcome, so a decode failure surfaces to the host as
Disconnectedrather than silence. #571 - PR #585 removed on-disk fallbacks once they were provably unreachable, which is the precedent for retiring the untagged arms. #585
- Review activity: Lore indexes only the author's "fixed" replies on this PR. No human review comments are indexed yet. Ownership by authorship is johnthecat for #403 and #441, pgherveou for recent session work.
Concerns
SsoSessionInfois shared by the frozen decoders and the live struct.session.rs:157andsession.rs:173embed the live type. A future field in it changes the bytes both frozen structs expect, and the canary atsession.rs:475still passes because both sides use the same type. Its fixture also hassso: None, so the SSO bytes are never exercised. Freeze an SSO layout struct too, or pin its encoded length of 352 bytes in a test with a populated fixture.- The V1 arm decodes the live struct.
session.rs:245usesSessionInfo, whose body is byte-identical to the frozen eight-field layout. When a field is added, V1 must be frozen as well, and the canary message atsession.rs:488only says to mint a new version. Decoding V1 through the existing frozen struct now would makeSessionInfoencode-only and turn the canary into the real invariant: encode of the live struct equals the V1 layout. - Trailing bytes override the per-arm failures. At
session.rs:262any arm that parsed a prefix wins the error message. An eight-field blob with a corrupt username length fails its own arm, then the six-field arm parses a prefix and leaves the username bytes over, so the log says "trailing bytes" and the real cause is lost. The slot is wiped on this path, so that log line is the only evidence. Record trailing as a per-arm failure instead. Two tests assert the exact string, atsession.rs:763andtests.rs:3007. - Misplaced and historical test doc comments.
session.rs:463-467is one merged doc comment: the first paragraph belongs to the test atsession.rs:495, which has none. That paragraph andsession.rs:435-437describe what "shipped" and what happens "on upgrade", which CLAUDE.md asks comments to avoid. - External sessions never upgrade.
pairing_host.rs:484decodes but returns nothing the host could persist, so a host that owns its blob presents untagged bytes on every launch. Unchanged from the previous review.
Questions for the author
- Does dotli restore through
activateStoredSessionoractivateExternalSession? The submodule is not checked out and not indexed, and the answer decides whether the untagged arms can ever be removed. - Is there a planned removal condition for the untagged arms, such as a minimum host-core version?
🤖 Reviewed by Lore (Parity knowledge base) · 52 agent turns · 363.0s · knowledge as of 2026-09-08 · re-review
The tagged arm reads the eight-field layout rather than the live struct, so SessionInfo is written and never decoded and a field added to it cannot change how any stored blob is read. One test holds the written form and that layout together, and reports what to do when they part. Trailing bytes are recorded per layout instead of as one verdict, so a blob that fails its own layout and then leaves bytes over in another still reports why each was rejected. A tagged blob keeps its own trailing-bytes diagnosis, which the callers assert. The SSO block is embedded by type rather than frozen field by field, so its encoded length is pinned and a populated fixture exercises those bytes. Activating an untagged stored session rewrites the slot in the written form, which is now covered end to end: the pairing host upgrades its stored session by activating once.
Summary
The persisted session blob carries a leading version byte, so a future
SessionInfofield can be added by minting a version rather than by invalidating every session already on disk. Decoding accepts the tagged layoutand each untagged layout that exists on disk, so a session paired before the tag keeps working, and activating it once rewrites the slot in the written form.
Without this, the blob's layout is positional (a bare SCALE struct with no tag) so a field inserted mid-struct moves every later field, the pairing host fails to decode, and the stored session is deleted, leaving the user to pair
again.
Four properties worth knowing
SessionInfois written and never decoded. Every arm, including the tagged one, reads a frozen layout struct describing bytes on disk. A field added toSessionInfotherefore cannot change how any stored blob is read; it makesa_written_session_matches_the_eight_field_layoutfail with what to do instead. The SSO block is embedded by type rather than frozen field by field, so its encoded length is pinned separately.The tag byte is not decisive.
public_key: [u8; 32]leads an untagged blob, so any first byte is legal and a real session may begin with the version byte. A blob that looks tagged but fails to decode falls through to theuntagged layouts.
Every layout is tried before any failure is reported. A decode failure deletes the stored session, so a false negative is unrecoverable. No arm ends the search, and each layout's own rejection reason is reported, since that log
line is the only evidence left. A tagged blob keeps its own trailing-bytes diagnosis.
An untagged slot upgrades itself. Activation re-encodes the session and writes it back when the bytes differ, so the first successful activation replaces an untagged blob with the written form.
Retiring the untagged layouts
Because activation rewrites the slot, the untagged population drains as hosts
activate. The layouts can be deleted once every supported host has shipped a
release containing this change and activated once, rather than on a judgement
call about what remains on disk.
closes: #441