Skip to content

fix(server): version the persisted session blob and decode the older layouts - #647

Merged
TarikGul merged 3 commits into
mainfrom
tg/version-persisted-session
Sep 8, 2026
Merged

fix(server): version the persisted session blob and decode the older layouts#647
TarikGul merged 3 commits into
mainfrom
tg/version-persisted-session

Conversation

@TarikGul

@TarikGul TarikGul commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

The persisted session blob carries a leading version byte, so a future SessionInfo field can be added by minting a version rather than by invalidating every session already on disk. Decoding accepts the tagged layout
and 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

SessionInfo is written and never decoded. Every arm, including the tagged one, reads a frozen layout struct describing bytes on disk. A field added to SessionInfo therefore cannot change how any stored blob is read; it makes a_written_session_matches_the_eight_field_layout fail 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 the
untagged 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

…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.
@TarikGul
TarikGul requested a review from a team September 8, 2026 13:30
@TarikGul

TarikGul commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@lore-bot-app review

@lore-bot-app

lore-bot-app Bot commented Sep 8, 2026

Copy link
Copy Markdown

Reading the diff and checking what the record says. Back in a few minutes.

@lore-bot-app lore-bot-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.0 and @parity/truapi@0.9.0 carry the six-field layout, and everything from @parity/ios-host@0.7.0 through @parity/truapi@0.13.1 carries 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 AuthSession restore 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_knows returned only polkadot-sdk names. By authorship, johnthecat wrote #403 and #441, and pgherveou owns most recent truapi-server session work in #501, #585, #618 and #628.

Concerns

  1. The untagged arm decodes the live type. session.rs:215-226 probes SessionInfo::decode for 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 at session.rs:403-420 also encodes the live type, so it would keep passing. Freeze the eight-field layout as its own Decode struct now, and let only the tagged arm use SessionInfo. A #[codec(index = 1)] enum wrapper would give the same wire byte and make the per-version struct the natural shape.
  2. The early "trailing bytes" return can skip the last fallback. session.rs:223 returns as soon as any candidate decodes with bytes left over, and the comment at session.rs:219-222 claims 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 with 0x01. The error path at pairing_host.rs:548-553 calls clear_disconnected_session(true), which deletes the slot at pairing_host.rs:890-895, so a false negative is destructive. Record the trailing-bytes result and report it only after every layout has failed.
  3. The final error names the wrong layout. session.rs:227-229 reports 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.
  4. The external-session path never upgrades the blob. install_external_session at pairing_host.rs:482-497 decodes but does not hand the tagged form back to the host. A host that owns the blob itself and calls activateExternalSession will 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.
  5. Doc comments narrate history, against CLAUDE.md. session.rs:149-155, 173-175 and 206-209 describe 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

  1. Does this close #441? The commit message does not reference it.
  2. Did any release before 0.8.0 ship a SessionInfo layout 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.
  3. Does dotli restore through activateStoredSession, where the slot is rewritten, or activateExternalSession, 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

Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
…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.
@TarikGul

TarikGul commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@lore-bot-app review

@lore-bot-app

lore-bot-app Bot commented Sep 8, 2026

Copy link
Copy Markdown

Reading the diff and checking what the record says. Back in a few minutes.

@lore-bot-app lore-bot-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since last review

  • Resolved: the eight-field untagged layout is now a frozen Decode struct 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.rs changed.
  • New in this revision: the shared SsoSessionInfo type 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.0 and @parity/truapi@0.9.0 all carry exactly the six-field layout the frozen struct describes. @parity/truapi@0.10.0 through 0.13.1 carry the eight-field layout. SsoSessionInfo has 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 Disconnected rather 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

  1. SsoSessionInfo is shared by the frozen decoders and the live struct. session.rs:157 and session.rs:173 embed the live type. A future field in it changes the bytes both frozen structs expect, and the canary at session.rs:475 still passes because both sides use the same type. Its fixture also has sso: 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.
  2. The V1 arm decodes the live struct. session.rs:245 uses SessionInfo, 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 at session.rs:488 only says to mint a new version. Decoding V1 through the existing frozen struct now would make SessionInfo encode-only and turn the canary into the real invariant: encode of the live struct equals the V1 layout.
  3. Trailing bytes override the per-arm failures. At session.rs:262 any 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, at session.rs:763 and tests.rs:3007.
  4. Misplaced and historical test doc comments. session.rs:463-467 is one merged doc comment: the first paragraph belongs to the test at session.rs:495, which has none. That paragraph and session.rs:435-437 describe what "shipped" and what happens "on upgrade", which CLAUDE.md asks comments to avoid.
  5. External sessions never upgrade. pairing_host.rs:484 decodes 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

  1. Does dotli restore through activateStoredSession or activateExternalSession? The submodule is not checked out and not indexed, and the answer decides whether the untagged arms can ever be removed.
  2. 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

Comment thread rust/crates/truapi-server/src/host_logic/session.rs
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/session.rs Outdated
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.
@TarikGul
TarikGul added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 727b793 Sep 8, 2026
20 checks passed
@TarikGul
TarikGul deleted the tg/version-persisted-session branch September 8, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Persisted session blobs from before #403 fail to decode, dropping every paired session on upgrade

2 participants