Skip to content

C5 — Split SessionState into 6 named substructures - #4269

Merged
piotr-roslaniec merged 5 commits into
extraction/frost-signer-mirror-2026-05-26from
spec/c5-sessionstate-grouping
Aug 20, 2026
Merged

C5 — Split SessionState into 6 named substructures#4269
piotr-roslaniec merged 5 commits into
extraction/frost-signer-mirror-2026-05-26from
spec/c5-sessionstate-grouping

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

C5 — Split SessionState into 6 named substructures

Targets 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 two TryFrom impls in persistence.rs. Sequencing note in both PRs; hand-merge required at the TryFrom bodies.

This PR ships both the deepening spec (docs/specs/frost-signer-sessionstate-grouping.md) AND the full implementation: 10 files, +842/-616, splitting SessionState into 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's SessionState (a 31-field flat struct at state.rs:111-183 holding 6 unrelated concerns — Round2, Aggregate, Refresh, Audit, etc.) into 6 named substructures, still under engine::state (no new module):

SessionState {
    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 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

  • The persisted schema PersistedSessionState is 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.
  • The TryFrom<PersistedSessionState> for SessionState impl projects into the new substructures; the inverse TryFrom<&SessionState> for PersistedSessionState reads them and flattens back to the same 31 fields.
  • The 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).
  • The wire schema version is not bumped.
  • The TBTC_SIGNER_ABI_* constants are not bumped.
  • The Drop for InteractiveSigningState implementation (state.rs:105-109) is unchanged — only its containing field path changes.

Migration cost

Roughly 147 production .field read/write sites spread across production consumers:

File .field sites Notes
interactive.rs ~89 heaviest consumer; all 5 content groups touched
dkg.rs ~17 dkg.* + interactive.bound_key_group
state.rs ~13 mostly the exhaustive destructuring in per_message_interactive_session (state.rs:553-621)
lifecycle.rs ~13 lifecycle.* + dkg.result + interactive.bound_key_group
transaction.rs ~9 signing.* + lifecycle.emergency_rekey_event
verify_share.rs ~3 interactive.bound_key_group + dkg.public_key_package
audit.rs ~2 audit.attempt_transition_records at audit.rs:335 and audit.rs:397
telemetry.rs ~1 lifecycle.emergency_rekey_event at telemetry.rs:521
Total (excl. persistence.rs, tests.rs) ~147

persistence.rs is excluded from this count: its TryFrom impls read SessionState AND PersistedSessionState under the same field names verbatim, so a plain-text grep cannot distinguish the two. Both TryFrom bodies are rewritten regardless (see "TryFrom projection" below and PR #2's "Sequencing with PR #5").

Struct literals: 21 in tests.rs + 1 inside TryFrom<PersistedSessionState> impl in persistence.rs = 22 total rewrite to the nested form. The PersistedSessionState { ... } literal at tests.rs:623 is the wire schema and intentionally unchanged.

TryFrom projection (key invariant)

The TryFrom<PersistedSessionState> for SessionState impl (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.

OperationalState is NOT wholesale-defaulted. Three real fields with mixed persistence:

  • retired_interactive_at_unixpersisted (round-tripped through both TryFrom impls).
  • heartbeat_rate_limitertransient (not serialized; resets on restart).
  • aggregate_eviction_pintransient (Arc<()> refcount pin; an in-flight Aggregate clones it under the engine lock).

Defaulting the whole OperationalState to ::default() would silently drop retired_interactive_at_unix on 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_pins for the retired timestamp, interactive and dkg via 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 that OperationalState::retired_interactive_at_unix survives a TryFrom<&SessionState> -> TryFrom<PersistedSessionState> round-trip (defense against a future maintainer wholesale-defaulting OperationalState in either direction).

Sequencing with PR #2 (C2)

Both PRs rewrite the bodies of TryFrom<PersistedSessionState> for SessionState and TryFrom<&SessionState> for PersistedSessionState. PR #2 moves those bodies into schema_codec.rs unchanged 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 TryFrom bodies. 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):

  • SessionState has 31 fields (state.rs:111-183), including policy_snapshot_version: u32 at state.rs:161 — added since the original draft was written. Placed in DkgSessionState by name and doc comment (no other production reader besides policy::current_policy_snapshot_version() which is currently unreferenced; groups with dkg rather than interactive).
  • Legacy-signing group is 12 fields not 10 (4 consumed-marker registries + sign request fingerprint, message bytes, round state, active attempt context, finalize request fingerprint, signature result, build-tx request fingerprint, transaction result).
  • 21 SessionState{...} literals in tests.rs (not 22 as in earlier draft); 1 SessionState{...} literal inside TryFrom<PersistedSessionState> impl in persistence.rs (not 2; the 2 reverse TryFrom<&SessionState> constructs PersistedSessionState{...} which is the wire schema and unchanged).
  • Cross-field retirement invariant at persistence.rs:2222-2230 (was 2093-2099 in earlier draft).
  • engine::tests::persisted_session_state_round_trip_preserves_bound_key_group at tests.rs:4615 (was ~4729 in earlier draft).
  • Audit trail reads at audit.rs:335 and audit.rs:397 (was audit.rs:302 in earlier draft, which was wrong).
  • Various other line citations corrected.

Files added

  • docs/specs/frost-signer-sessionstate-grouping.md (design doc, the deepening spec)
  • 9 Rust source files updated by the implementation commit (10 files total including the spec): the new substructures plus all call-site migrations. See "Migration cost" above for the per-file site count and "Verification" below for the file:line citations.

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.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f009758-259e-46bb-a84c-82ecba547288

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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
piotr-roslaniec marked this pull request as ready for review August 19, 2026 09:22
…(rebase field-renames to session.dkg/.signing/.interactive/.audit/.lifecycle/.capacity_pins across state.rs, interactive.rs, persistence split modules, audit.rs, tests.rs)
@piotr-roslaniec
piotr-roslaniec merged commit 7b886e0 into extraction/frost-signer-mirror-2026-05-26 Aug 20, 2026
19 checks passed
@piotr-roslaniec
piotr-roslaniec deleted the spec/c5-sessionstate-grouping branch August 20, 2026 09:23
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.

1 participant