C5 — Split SessionState into 6 named substructures - #4269
Merged
piotr-roslaniec merged 5 commits intoAug 20, 2026
Merged
Conversation
Engine module (pkg/tbtc/signer/src/engine/state.rs) defines SessionState
as a 31-field flat struct holding 6 unrelated concerns. Spec proposes
splitting into 6 named substructures under engine::state (no new module):
dkg: DkgSessionState, // 5 fields (request fingerprint,
// key packages, public key
// package, result, policy
// snapshot version)
signing: LegacySigningSessionState, // 12 fields
interactive: InteractiveSessionState, // 5 fields
audit: AuditTrail, // 1 field
lifecycle: LifecycleState, // 5 fields
capacity_pins: OperationalState, // 3 fields
The persisted schema (PersistedSessionState) is unchanged. The on-disk
file format is byte-for-byte stable. The TryFrom impls project into the
new substructures and flatten back to the same 31 persisted fields.
Verified against current code: SessionState has 31 fields
(state.rs:111-183), including policy_snapshot_version at state.rs:161
added since the original spec was drafted. Legacy-signing group is 12
fields not 10 (4 consumed-marker registries + 8 named fields). 21
SessionState{...} literals in tests.rs (not 22), 1 inside
TryFrom<PersistedSessionState> impl in persistence.rs (2 TryFrom impls
but only 1 SessionState literal; the PersistedSessionState literal at
tests.rs:623 is the wire schema and unchanged). Migration cost: ~147
production .field read/write sites across interactive.rs (~89),
dkg.rs (~17), state.rs (~13), lifecycle.rs (~13), transaction.rs (~9),
verify_share.rs (~3), audit.rs (~2), telemetry.rs (~1), excluding
persistence.rs where SessionState and PersistedSessionState field names
are shared verbatim and not cleanly separable.
TryFrom projection corrections: OperationalState is NOT wholesale-defaulted.
retired_interactive_at_unix is a persisted field (round-tripped through
both TryFrom impls); heartbeat_rate_limiter and aggregate_eviction_pin
are transient (never serialized). cross-field retirement invariant at
persistence.rs:2222-2230 (was 2093-2099 in earlier draft), spans 3
groups: capacity_pins (retired_interactive_at_unix), interactive, and
dkg (via per_message_interactive_session discriminator).
NOT independent from C2: both specs rewrite the bodies of
TryFrom<PersistedSessionState> for SessionState and TryFrom<&SessionState>
for PersistedSessionState. Land whichever is simpler to rebase the
other onto, then hand-merge those bodies.
NOT a hard dependency on C1, but a real rebase cost: both touch
interactive.rs, on disjoint lines. C1 rewrites only the lock-prologue
and marker-durability blocks at the top/end of each phase handler; C5
rewrites the ~89 field-access sites in the phase-specific business
logic between them, which C1 leaves untouched. A rebase is mechanical,
not a semantic conflict. No new dedicated unit test for the
cross-field retirement invariant exists today; one is added as part
of this spec.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Splits the 31-field SessionState struct at pkg/tbtc/signer/src/engine/state.rs
into 6 named substructures:
dkg: DkgSessionState, // 5 fields
signing: LegacySigningSessionState, // 12 fields
interactive: InteractiveSessionState, // 5 fields
audit: AuditTrail(Vec<...>), // 1 field (newtype)
lifecycle: LifecycleState, // 5 fields
capacity_pins: OperationalState, // 3 fields
The persisted schema (PersistedSessionState) is unchanged. Its field names,
ordering, and serde tags stay byte-for-byte. The TryFrom impls project into the
new substructures and flatten back to the same 27 persisted fields.
Cross-field retirement invariant at persistence.rs TryFrom is preserved.
Migration cost:
- ~30 production .field read/write sites across interactive.rs, dkg.rs,
state.rs, lifecycle.rs, transaction.rs, audit.rs, verify_share.rs,
telemetry.rs (rewritten to session.<group>.<field>)
- 22 SessionState literals (1 in persistence.rs TryFrom, 21 in tests.rs)
rewritten to nested form
- per_message_interactive_session exhaustive destructuring rewritten to
destructure the 6 substructures (boolean logic unchanged)
Notes:
- policy_snapshot_version is a new in-memory field on DkgSessionState.
It is NOT persisted (PersistedSessionState does not carry it) and is
reset to 0 on every restart. A future spec that wires this into
persistence will add the corresponding serde tag.
- retirement invariant: session.capacity_pins.retired_interactive_at_unix
.is_some() && !per_message_interactive_session(&session) (unchanged
semantics).
Verified: cargo check --tests passes (0 errors; 1 dead-code warning on
policy_snapshot_version as expected per spec).
…trip; fix stale persistence comment Three changes: 1. Add engine::tests::persisted_session_state_rejects_retired_interactive_on_non_per_message_session. The cross-field retirement invariant (capacity_pins.retired_interactive_at_unix.is_some() && !per_message_interactive_session(&session)) is enforced at persistence.rs:2122-2129 but had no dedicated unit test. The spec's DoD promised one and the body originally deferred it to a follow-up; this test pins it so a future migration cannot silently drop or invert the check. 2. Add engine::tests::persisted_session_state_round_trip_preserves_capacity_pins_retired_interactive_at_unix. Of the three OperationalState fields, only retired_interactive_at_unix round-trips through persistence. The grouping split makes an easy mistake - wholesale- defaulting OperationalState in either TryFrom direction - that would silently drop the timestamp on every restart and break idle-session admission. End-to-end round-trip test pins the field. 3. Rewrite the stale comment above 'policy_snapshot_version: persisted.policy_snapshot_version' in the TryFrom<PersistedSessionState> body. The old comment said 'Persisted schema does not carry this field; reset to 0 on every load' but the field IS round-tripped (via #[serde(default)] for back-compat) and the literal assignment reads persisted.policy_snapshot_version. Updated to describe the actual round-trip behavior.
Fixes the CI 'Signer Rust checks' job failure (fmt/clippy gate). No behavior change: reformatting plus mechanical clippy autofixes (option_map_unit_fn -> if let, or_insert_with(default) -> or_default(), identity_op literal simplification, dead struct-update-syntax removal where every field was already specified).
piotr-roslaniec
marked this pull request as ready for review
August 19, 2026 09:22
piotr-roslaniec
merged commit Aug 20, 2026
7b886e0
into
extraction/frost-signer-mirror-2026-05-26
19 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
C5 — Split
SessionStateinto 6 named substructuresTargets
origin/extraction/frost-signer-mirror-2026-05-26(the source branch for PR #4005). NOT independent from PR #2 (C2 persistence split) — both rewrite the bodies of the same twoTryFromimpls inpersistence.rs. Sequencing note in both PRs; hand-merge required at theTryFrombodies.This PR ships both the deepening spec (
docs/specs/frost-signer-sessionstate-grouping.md) AND the full implementation: 10 files, +842/-616, splittingSessionStateinto 6 named substructures and migrating every call site. Behaviorally a pure refactor (persisted schema byte-identical; cross-field invariants preserved verbatim); the risk lives in the migration's correctness, not its scope, so reviewers expecting a doc-only change should read the diff before relying on that label.What this PR is
This PR splits
pkg/tbtc/signer/src/engine/state.rs'sSessionState(a 31-field flat struct at state.rs:111-183 holding 6 unrelated concerns — Round2, Aggregate, Refresh, Audit, etc.) into 6 named substructures, still underengine::state(no new module):The grouping is grounded in call-site co-location: each group is co-read at the call sites that exclusively touch that concern.
What stays unchanged
PersistedSessionStateis unchanged. Field names, ordering, serde tags preserved byte-for-byte. A state file written by the previous code loads cleanly with the new code, and vice versa.TryFrom<PersistedSessionState> for SessionStateimpl projects into the new substructures; the inverseTryFrom<&SessionState> for PersistedSessionStatereads them and flattens back to the same 31 fields.refresh_count = persisted.refresh_count.max(history.len() as u64)legacy semantics are preserved (lifecycle.rs ignores this until a versioned cryptographic refresh protocol exists; the field is retained only for schema compatibility).TBTC_SIGNER_ABI_*constants are not bumped.Drop for InteractiveSigningStateimplementation (state.rs:105-109) is unchanged — only its containing field path changes.Migration cost
Roughly 147 production
.fieldread/write sites spread across production consumers:.fieldsitesinteractive.rsdkg.rsdkg.*+interactive.bound_key_groupstate.rsper_message_interactive_session(state.rs:553-621)lifecycle.rslifecycle.*+dkg.result+interactive.bound_key_grouptransaction.rssigning.*+lifecycle.emergency_rekey_eventverify_share.rsinteractive.bound_key_group+dkg.public_key_packageaudit.rsaudit.attempt_transition_recordsat audit.rs:335 and audit.rs:397telemetry.rslifecycle.emergency_rekey_eventat telemetry.rs:521persistence.rsis excluded from this count: itsTryFromimpls readSessionStateANDPersistedSessionStateunder the same field names verbatim, so a plain-text grep cannot distinguish the two. BothTryFrombodies are rewritten regardless (see "TryFrom projection" below and PR #2's "Sequencing with PR #5").Struct literals: 21 in
tests.rs+ 1 insideTryFrom<PersistedSessionState>impl inpersistence.rs= 22 total rewrite to the nested form. ThePersistedSessionState { ... }literal at tests.rs:623 is the wire schema and intentionally unchanged.TryFrom projection (key invariant)
The
TryFrom<PersistedSessionState> for SessionStateimpl (persistence.rs:1917-2233 today) validates the consumed-marker registries and hex-decodes the DKG/sign-message fields exactly as it does today, then projects into the new substructures in a single literal.OperationalStateis NOT wholesale-defaulted. Three real fields with mixed persistence:retired_interactive_at_unix— persisted (round-tripped through bothTryFromimpls).heartbeat_rate_limiter— transient (not serialized; resets on restart).aggregate_eviction_pin— transient (Arc<()> refcount pin; an in-flight Aggregate clones it under the engine lock).Defaulting the whole
OperationalStateto::default()would silently dropretired_interactive_at_unixon every restart — a wire-schema-breaking behavior change.Cross-field retirement invariant
The existing invariant
if session.capacity_pins.retired_interactive_at_unix.is_some() && !per_message_interactive_session(&session) { return Err(...) }(persistence.rs:2122-2129 post-implementation) spans 3 groups (capacity_pinsfor the retired timestamp,interactiveanddkgvia the discriminator). The spec's DoD promised a dedicated unit test pinning this exact TryFrom-level check; this PR ships that test (engine::tests::persisted_session_state_rejects_retired_interactive_on_non_per_message_session) so a future migration cannot silently drop or invert the invariant. A second test,persisted_session_state_round_trip_preserves_capacity_pins_retired_interactive_at_unix, pins thatOperationalState::retired_interactive_at_unixsurvives aTryFrom<&SessionState>->TryFrom<PersistedSessionState>round-trip (defense against a future maintainer wholesale-defaultingOperationalStatein either direction).Sequencing with PR #2 (C2)
Both PRs rewrite the bodies of
TryFrom<PersistedSessionState> for SessionStateandTryFrom<&SessionState> for PersistedSessionState. PR #2 moves those bodies intoschema_codec.rsunchanged in shape; this PR reshapes what they project into.Merge order: land whichever is easier to rebase the other onto (either order works structurally), then hand-merge the shared
TryFrombodies. Do not apply both to a worktree in parallel and expect a clean merge.Sequencing with PR #1 (C1)
Both touch
interactive.rs, on disjoint lines: PR #1 rewrites only the lock-prologue and marker-durability blocks at the top/end of each phase handler; this PR rewrites the ~89 field-access sites in the phase-specific business logic between them, which PR #1 leaves untouched. A rebase is mechanical, not a semantic conflict.Verification
Spec was verified against current code. Verified facts that the spec encodes (corrections from earlier drafts):
SessionStatehas 31 fields (state.rs:111-183), includingpolicy_snapshot_version: u32at state.rs:161 — added since the original draft was written. Placed inDkgSessionStateby name and doc comment (no other production reader besidespolicy::current_policy_snapshot_version()which is currently unreferenced; groups withdkgrather thaninteractive).SessionState{...}literals intests.rs(not 22 as in earlier draft); 1SessionState{...}literal insideTryFrom<PersistedSessionState>impl inpersistence.rs(not 2; the 2 reverseTryFrom<&SessionState>constructsPersistedSessionState{...}which is the wire schema and unchanged).engine::tests::persisted_session_state_round_trip_preserves_bound_key_groupat tests.rs:4615 (was ~4729 in earlier draft).Files added
docs/specs/frost-signer-sessionstate-grouping.md(design doc, the deepening spec)