Skip to content

multi: rebuild refresh output template against current operator key (#520) - #525

Merged
Roasbeef merged 1 commit into
mainfrom
ellemouton/issue-520-refresh-after-rotation-repro
May 23, 2026
Merged

multi: rebuild refresh output template against current operator key (#520)#525
Roasbeef merged 1 commit into
mainfrom
ellemouton/issue-520-refresh-after-rotation-repro

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Summary

Fixes #520. After an operator key rotation, the client's refresh path
silently constructed a JoinRoundRequest whose new VTXO output template
still carried the operator's pre-rotation key (K1), and the server
rejected the round with ErrOperatorKeyMismatch. This PR rebuilds the
new output's standard policy template against the operator's current
long-term key (K2) while leaving spend-time material for the old VTXO
untouched (it still has to commit to K1, because the on-chain output's
taproot tree was built against K1).

The cause sat in two places, both of which cloned the descriptor's stored
PolicyTemplate bytes verbatim into the new output:

  • vtxo/actor.go — auto-refresh emission on expiry (processOutbox /
    ForfeitRequest).
  • wallet/wallet.go — explicit RefreshVTXOs RPC handler
    (handleRefreshVTXOs).

Both now consult a func() *btcec.PublicKey provider wired in by
darepod from the cached OperatorTerms snapshot, and rebuild the new
output's template against the returned key. When the provider is unset
or the descriptor's stored policy is not the standard shape (vHTLC,
custom), the code falls back to the descriptor's stored bytes so legacy
paths and non-standard policies keep their current behavior.

What's in the diff

  • vtxo/policy.go, wallet/policy.go — new RefreshOutputTemplate
    helper on each descriptor type. Standard shape only; non-standard
    shapes return ErrRefreshOperatorKeyUnsupported so callers can fall
    back or surface rotation-specific UX explicitly.
  • vtxo/actor.go, vtxo/manager.go — new CurrentOperatorKey func()
    field on VTXOActorConfig and ManagerConfig (manager forwards to
    each spawned actor). The auto-refresh emission goes through a new
    refreshOutputTemplate helper that prefers the rebuilt template and
    falls back to the stored bytes on non-standard shapes.
  • wallet/wallet.go — new WithCurrentOperatorKey ArkOption and a
    parallel refreshOutputTemplate helper used by handleRefreshVTXOs.
  • darepod/server.go — small currentOperatorPubKey() accessor that
    reads the cached OperatorTerms, wired into both wallet.NewArk and
    the vtxo.ManagerConfig at construction time.
  • vtxo/refresh_operator_rotation_test.go — regression test with two
    subtests:
    • rebuilds against rotated key when provider wired — pins the fix:
      a VTXOActor configured with a CurrentOperatorKey returning K2
      emits a RefreshVTXORequest whose PolicyTemplate decodes to K2,
      not the K1 baked into the descriptor.
    • falls back to stored template when provider unset — pins the
      legacy path so harness tests and non-standard policy holders keep
      working unchanged.

Design notes for review

  1. Forfeit and unilateral-exit material is intentionally untouched.
    Only the new output's template is rewritten. The forfeit witness,
    control block, and unilateral exit script for the input VTXO still
    commit to K1 because that is what the on-chain output's taproot tree
    was built against. This matches the acceptance criteria in client: refresh/join round can fail after operator key rotation #520.

  2. Non-standard policies keep the legacy fallback. vHTLC and other
    custom shapes still ship the stored bytes verbatim. A user holding a
    vHTLC VTXO under K1 across a rotation will see the same failure as
    before — out of scope for client: refresh/join round can fail after operator key rotation #520, but worth a follow-up if it matters.

  3. A nil provider is treated as 'not wired yet'. The actor and wallet
    helpers fall back to EffectivePolicyTemplate so harnesses that don't
    mock OperatorTerms keep their current behavior.

Test plan

  • go test ./vtxo/ ./wallet/ ./darepod/ -count=1 — green
    (full unit suites for the three touched packages)
  • New regression test TestRefreshEmissionUsesCurrentOperatorKey
    passes both subtests
  • Verify CI is green
  • Recommended follow-up before bumping the submodule pointer in
    darepo: re-run the existing TestRefreshIntegrationSingleVTXOLifecycle
    itest against this branch to confirm the seal-time fee handshake and
    forfeit signing still work end-to-end through the new emission path
    (this PR's tests don't drive a real round).

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 2m 43s —— View job


Code Review

  • Gather context and understand the request
  • Read changed files
  • Analyze diff against origin/main
  • Review vtxo/policy.go and wallet/policy.go
  • Review vtxo/actor.go and vtxo/manager.go
  • Review wallet/wallet.go
  • Review darepod/server.go
  • Review regression test
  • Post comprehensive review feedback

Overall the fix is well-scoped and the design is sound: split the "rebuild the new output's template" concern cleanly from spend-time material, add a nil-safe fallback seam, wire it through the config chain, and cover it with a targeted regression test. A few findings below.


wallet/policy.go: asymmetric error wrapping vs. vtxo/policy.go

vtxo/policy.go:109 calls d.DecodeStandardPolicyTemplate() which wraps errors from both the inner DecodePolicyTemplate and DecodeStandardVTXOParams steps together, so any failure is wrapped with ErrRefreshOperatorKeyUnsupported and the actor falls back gracefully.

wallet/policy.go:66-74 splits the two steps:

// DecodePolicyTemplate failure → plain error, NOT wrapped
template, err := arkscript.DecodePolicyTemplate(d.PolicyTemplate)
if err != nil {
    return nil, fmt.Errorf("decode stored policy template: %w", err)
}
// Only DecodeStandardVTXOParams failure is wrapped
params, err := arkscript.DecodeStandardVTXOParams(template)
if err != nil {
    return nil, fmt.Errorf("%w: %w", ErrRefreshOperatorKeyUnsupported, err)
}

If DecodePolicyTemplate itself fails (e.g., a malformed or non-TLV-formatted descriptor), wallet.refreshOutputTemplate will not fall back to the stored bytes — it will propagate the error and cause the VTXO to be skipped in handleRefreshVTXOs. The vtxo actor would have fallen back to stored bytes for the same input.

In practice this only matters if arkscript.DecodePolicyTemplate can fail for non-standard policy shapes; if it always succeeds and only DecodeStandardVTXOParams fails for non-standard shapes, the asymmetry is benign. But it's worth aligning the two: either make wallet/policy.go wrap the DecodePolicyTemplate error too, or introduce a DecodeStandardPolicyTemplate() helper on VTXODescriptor (mirroring the vtxo side) so the wrapping logic lives in one place.

Fix this →


Duplicate ErrRefreshOperatorKeyUnsupported sentinels

vtxo/policy.go:20 and wallet/policy.go:19 define two independent sentinels with identical messages. Each package checks its own, so errors.Is works correctly in both cases today. However, if any cross-package caller ever needs to test for this condition (or if wallet.VTXODescriptor is replaced with vtxo.Descriptor down the line), the two sentinels cannot be unified with errors.Is without changes. Worth a short comment on each noting the parallel, or a follow-up issue.


Missing wallet-side unit test

vtxo/refresh_operator_rotation_test.go pins the vtxo auto-refresh path in both the fix and fallback directions. The analogous path through wallet.Ark.refreshOutputTemplatewallet.VTXODescriptor.RefreshOutputTemplate is not covered. A parallel test that feeds a VTXODescriptor through refreshOutputTemplate with K2 wired would complete the coverage and pin the wallet side against future regressions.


xOnlyEqual: prefer bytes.Equal over string() cast

refresh_operator_rotation_test.go:23-24:

return string(schnorr.SerializePubKey(a)) ==
    string(schnorr.SerializePubKey(b))

bytes.Equal(schnorr.SerializePubKey(a), schnorr.SerializePubKey(b)) is idiomatic and avoids an implicit string allocation. Minor nit.


Test doesn't cover provider() == nil separately

The fallback test covers provider == nil (the function itself is nil). The case where the provider is set but returns nil (e.g., OperatorTerms cache not yet populated) is exercised only implicitly through production code paths; an extra subtest with func() *btcec.PublicKey { return nil } would make the contract explicit and guard against regressions in that branch.


Cache staleness window is an accepted limitation (no action needed)

darepod/server.go:596-601 notes the cache is only refreshed during bootstrap today. currentOperatorPubKey() could return K1 if the daemon was started before the rotation and hasn't reconnected. The PR description and comments call this out clearly and it's out of scope for #520. The fix is still a strict improvement over always emitting K1. No action needed here, just confirming the design note is understood.


Summary

The core fix is correct and well-contained. The main actionable items are:

  1. Align wallet/policy.go's error wrapping with vtxo/policy.go so a DecodePolicyTemplate failure triggers the same fallback on both sides.
  2. Add a wallet-side unit test covering refreshOutputTemplate with a rotated key.
  3. Minor nits: bytes.Equal in xOnlyEqual, test for the provider() == nil branch.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request addresses issue #520 by ensuring that VTXO refresh operations utilize the operator's current long-term public key rather than stale keys persisted in descriptors. Changes include introducing a CurrentOperatorKey lookup mechanism across the server, VTXO manager, and wallet subsystems, along with logic to rebuild policy templates for standard VTXO shapes. Feedback was provided regarding wallet/policy.go to improve error wrapping for better fallback behavior and to maintain implementation consistency with the vtxo package.

Comment thread wallet/policy.go
Comment on lines +52 to +69
if d == nil {
return nil, fmt.Errorf("wallet VTXO descriptor must be " +
"provided")
}

if currentOperatorKey == nil {
return nil, fmt.Errorf("current operator key must be provided")
}

if len(d.PolicyTemplate) == 0 {
return nil, fmt.Errorf("wallet VTXO descriptor policy " +
"template must be provided")
}

template, err := arkscript.DecodePolicyTemplate(d.PolicyTemplate)
if err != nil {
return nil, fmt.Errorf("decode stored policy template: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The error returned when arkscript.DecodePolicyTemplate fails should be wrapped with ErrRefreshOperatorKeyUnsupported. This ensures that if the stored template is unparseable (and thus not a standard Ark VTXO shape), the logic falls back to the raw bytes instead of failing the refresh operation entirely. Additionally, the manual nil and empty checks can be simplified by calling EffectivePolicyTemplate directly, matching the pattern used in the vtxo package.

References
  1. Maintain consistency with the vtxo package implementation of RefreshOutputTemplate. (link)
  2. Ensure consistent error handling and fallback logic across similar subsystems (wallet vs vtxo).

@ellemouton
ellemouton force-pushed the ellemouton/issue-520-refresh-after-rotation-repro branch 2 times, most recently from cba20bf to d848946 Compare May 22, 2026 20:54
@ellemouton

Copy link
Copy Markdown
Member Author

Reworked per review feedback. The previous iteration read the operator key from the daemon-startup OperatorTerms cache, which is exactly the source darepo#335 calls out as the wrong one. This version resolves the operator key at refresh-join time via a fresh GetInfo round-trip, so the new VTXO output commits to whatever key the operator declares at that moment — and then stays bound to it for that VTXO's life (consistent with the per-VTXO-stability model).

Key changes vs the previous push:

  • Callback signature is now func(context.Context) (*btcec.PublicKey, error) (fetch, not cache read). On the darepod side it calls fetchOperatorTerms(ctx) and also refreshes the cache via storeOperatorTerms.
  • Wallet's handleRefreshVTXOs hoists the fetch to a single call at the top of the handler so one GetInfo covers the whole batch instead of N per-outpoint round-trips.
  • VTXO actor's auto-refresh fetches per emission. Fetch errors propagate so the refresh fails rather than silently emitting against a stale key; auto-refresh retries on the next expiry tick.
  • Added 4-subtest coverage (vtxo) including error-propagation and nil-key paths, plus a parallel wallet-side test for composeRefreshTemplate.
  • Fixed the wallet/policy.go error-wrap asymmetry that the earlier review caught (both decode failure modes now wrap with ErrRefreshOperatorKeyUnsupported).

@claude review this

@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 3m 30s —— View job


Claude finished @ellemouton's task ——


  • Gather context and understand the request
  • Read changed files (darepod/server.go, vtxo/actor.go, vtxo/manager.go, vtxo/policy.go)
  • Read changed files (wallet/wallet.go, wallet/policy.go)
  • Read regression tests (vtxo/refresh_operator_rotation_test.go, wallet/refresh_operator_rotation_test.go)
  • Analyze diff against origin/main
  • Post comprehensive review feedback

Code Review

The rework is a clear improvement over the cached-key approach. Using a live GetInfo fetch at join time correctly binds each new VTXO to whatever operator key the server declares at that moment, and the per-batch hoisting in the wallet path avoids N redundant round-trips. All previous feedback items are addressed. A few findings below.


vtxo/actor.goVTXOActorConfig.FetchOperatorKey doc comment is wrong about nil

Lines 106–108:

// A nil callback or a callback that errors causes refreshOutputTemplate
// to return the error to its caller, which surfaces it as a per-VTXO
// refresh failure rather than silently emitting against a stale key.

This is inaccurate for the nil case. The code at vtxo/actor.go:171-173 shows:

if a.cfg.FetchOperatorKey == nil {
    return vtxo.EffectivePolicyTemplate()
}

A nil callback falls back to stored bytes — it does not return an error. Only a non-nil callback that errors propagates. The wallet-side parallel comment (wallet.go) gets this right: "Nil leaves the handler falling back to the descriptor's stored bytes for harness paths...". The vtxo-side comment should be corrected.

Fix this →


vtxo/actor.goErrorS used for an external failure (violates CLAUDE.md rule)

vtxo/actor.go:418-426:

a.logger(ctx).ErrorS(
    ctx,
    "Failed to encode refresh policy",
    err,
    ...
)

With this rework, refreshOutputTemplate can fail because the FetchOperatorKey callback returned an error — e.g., the operator is unreachable. That is an external trigger, not an internal bug. CLAUDE.md is explicit: "error log level is only for internal bugs, never external triggers." The wallet side correctly uses WarnS for the same class of failure. This should be WarnS.

Also, the message "Failed to encode refresh policy" is now stale: the function can fail for reasons beyond encoding. Something like "Failed to build refresh output template" would cover both the fetch and encoding failure surfaces.

Fix this →


wallet/wallet.go — broken identifier in composeRefreshTemplate godoc

Lines 367–368:

// surrounding batch (see handleRefreshVTXOs). Mirrors VTXOActor.refresh
// OutputTemplate but takes the resolved key as a parameter so the caller

Go's godoc joins adjacent non-indented lines with a space, so this renders as "Mirrors VTXOActor.refresh OutputTemplate" — splitting the identifier at the line break. It should stay on one line or be rephrased.

Fix this →


Minor nit: WarnS call formatting inconsistency in handleRefreshVTXOs

Two adjacent WarnS calls inside handleRefreshVTXOs use different argument layout — ctx on the same line in the fetch-error case, on its own indented line in the nil-key case. Minor style inconsistency within the same function.


What's landed well

  • wallet/policy.go error-wrap symmetry is fixed: both DecodePolicyTemplate and DecodeStandardVTXOParams failures are now wrapped with ErrRefreshOperatorKeyUnsupported. The asymmetry from the previous round is gone. ✓
  • Wallet-side unit test (wallet/refresh_operator_rotation_test.go) now covers composeRefreshTemplate with rebuild, stored-bytes fallback, and parameter-preservation checks. ✓
  • Four vtxo subtests cover the fix path, legacy fallback, error propagation, and nil-key rejection — the full contract is pinned. ✓
  • Batch-hoisted fetch in the wallet (handleRefreshVTXOs) is the right design: one GetInfo covers all outpoints in the request, avoids N round-trips, and prevents concurrent-rotation disagreement between per-outpoint fetches. ✓
  • fetchCurrentOperatorPubKey in darepod refreshes the daemon-local cache as a side effect so GetInfo and other cache readers stay coherent with the key the refresh path resolved at. ✓

Summary

Three actionable items: fix the inaccurate nil-callback doc comment in VTXOActorConfig.FetchOperatorKey, downgrade the ErrorS to WarnS and update the stale log message in processOutbox, and fix the broken godoc identifier in composeRefreshTemplate. None of these affect correctness of the fix itself. The core change is sound.

@ellemouton
ellemouton force-pushed the ellemouton/issue-520-refresh-after-rotation-repro branch from d848946 to 493fd2a Compare May 22, 2026 21:12
@ellemouton

Copy link
Copy Markdown
Member Author

Round 3 — all of @claude's actionable items + the two CI failures addressed:

  • Commit msg L4 length: rewrapped the body so every line ≤72.
  • ll 80-col: aliased the callback type in the test (fetchOperatorKeyFn) so newRefreshTestActor's signature fits.
  • VTXOActorConfig.FetchOperatorKey doc: fixed — nil callback falls back, non-nil-that-errors propagates. The comment now matches the wallet-side parallel.
  • processOutbox log level: downgraded ErrorSWarnS with a brief comment citing the CLAUDE.md rule (error is for internal bugs, not external triggers like an unreachable operator). Stale message updated to "Failed to build refresh output template" since the helper can now fail for fetch reasons too.
  • composeRefreshTemplate godoc identifier break: rephrased so the refreshOutputTemplate reference stays on one line.
  • WarnS formatting nit in handleRefreshVTXOs: normalized to multi-line style for consistency with adjacent calls.

@ellemouton

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 4m 16s —— View job


Code Review (Round 4)

  • Gather context and understand request
  • Read changed files (current state on branch)
  • Verify previous feedback is addressed
  • Check for any new issues
  • Post comprehensive review feedback

All three actionable items from Round 3 are addressed. The code is in good shape. One remaining finding below.


Issue number references in code comments violate CLAUDE.md

CLAUDE.md is explicit: "Do not reference the current task, fix, or callers ("used by X", "added for the Y flow", "handles the case from issue #123"), since those belong in the PR description and rot as the codebase evolves."

There are eight places in the new code where issue numbers appear in comments or godoc:

File Location Reference
darepod/server.go fetchCurrentOperatorPubKey godoc (darepo-client issue #520, darepo #335)
vtxo/actor.go VTXOActorConfig.FetchOperatorKey field doc darepo-client issue #520 and the per-VTXO operator-key direction in darepo #335
vtxo/actor.go refreshOutputTemplate method godoc darepo-client issue #520 and the per-VTXO operator-key direction in darepo #335
vtxo/policy.go RefreshOutputTemplate method godoc darepo-client issue #520
wallet/wallet.go fetchOperatorKey field doc darepo-client issue #520 and darepo #335
wallet/wallet.go WithFetchOperatorKey godoc darepo-client issue #520 and darepo #335
wallet/wallet.go inline comment in handleRefreshVTXOs (issue #520)
test files TestRefreshEmissionUsesJoinTimeOperatorKey, TestComposeRefreshTemplate darepo-client issue #520

In each case the design principle is already expressed in plain English — "VTXOs commit to their operator key for life, the new output's key is chosen at join time" — so the issue number adds no semantic content and will become a dead reference once the issue is closed. The same applies to the darepo #335 cross-repo references. Strip the parenthetical issue references; the explanatory prose that surrounds them stands on its own.

Fix this →


Minor: processOutbox comment self-references CLAUDE.md

vtxo/actor.go lines 420–426:

// WarnS, not ErrorS: refreshOutputTemplate
// can fail because the FetchOperatorKey
// callback returned an error (operator
// unreachable, fresh GetInfo timed out) —
// those are external triggers, not internal
// bugs, per CLAUDE.md's logging policy. …

The WHY ("external trigger, not internal bug") is the right content for this comment. "per CLAUDE.md's logging policy" is self-referential filler — a future reader who disagrees with the level doesn't need to know where the rule came from; they need to understand why the distinction applies here. Drop the "per CLAUDE.md" attribution and let the reason stand on its own.


What's landed well (confirmation from prior rounds)

  • VTXOActorConfig.FetchOperatorKey nil-callback doc: correctly says nil → fallback, not error.
  • processOutbox log level: WarnS with updated message "Failed to build refresh output template".
  • composeRefreshTemplate godoc: identifier no longer split across lines ("Mirrors the vtxo-side refreshOutputTemplate helper…").
  • WarnS formatting in handleRefreshVTXOs: both branches use consistent multi-line style.
  • wallet/policy.go error-wrap symmetry: both DecodePolicyTemplate and DecodeStandardVTXOParams failures wrapped with ErrRefreshOperatorKeyUnsupported.
  • ✓ Four vtxo subtests + wallet-side TestComposeRefreshTemplate covering rebuild, fallback, error propagation, and nil-key rejection.
  • xOnlyEqual uses bytes.Equal (not string cast).

Summary

Two items: strip the issue-number citations from godoc/comments (CLAUDE.md rule, eight places), and drop the "per CLAUDE.md" self-reference from the processOutbox comment. The core fix is correct and all prior feedback is addressed.

Fixes darepo-client issue #520. The client now resolves the operator's
current long-term key at refresh-join time via a fresh GetInfo
round-trip and rebuilds the NEW VTXO output's policy template against
that key. The input VTXO's stored operator key is still respected for
spend-time material — it has to be, the on-chain output's taproot
tree commits to it — but the new output is a freshly-minted VTXO
whose operator key is chosen at join time and is then stable on that
new VTXO for life.

This replaces the earlier iteration of this PR that read from the
daemon-startup OperatorTerms cache. As noted in darepo#335, that
cache is the wrong source of truth across a rotation because it only
hydrates at bootstrap; the right source is a join-time fetch.

Touched surfaces:

- vtxo/policy.go, wallet/policy.go: RefreshOutputTemplate helper
  rebuilds the standard template against a caller-supplied current
  key, preserving the owner key and exit delay. Non-standard shapes
  (vHTLC, custom) return ErrRefreshOperatorKeyUnsupported. The
  wallet helper also wraps DecodePolicyTemplate failures with the
  sentinel so callers can branch on a single errors.Is check,
  mirroring the vtxo side's DecodeStandardPolicyTemplate path.
- vtxo/actor.go, vtxo/manager.go: new FetchOperatorKey func field on
  VTXOActorConfig and ManagerConfig (manager forwards to spawned
  actors). The auto-refresh emission calls FetchOperatorKey at the
  ForfeitRequest branch; fetch errors propagate so the refresh fails
  rather than silently emitting against a stale key. Auto-refresh
  retries naturally on the next expiry tick. The build failure is
  logged at warn level (external trigger, not an internal bug).
- wallet/wallet.go: new WithFetchOperatorKey ArkOption, plus a
  single fresh fetch hoisted to the top of handleRefreshVTXOs so
  one GetInfo covers the whole batch (every new VTXO in one RPC
  commits to the same key). composeRefreshTemplate is a free helper
  the loop uses with the pre-fetched key.
- darepod/server.go: new fetchCurrentOperatorPubKey accessor that
  calls fetchOperatorTerms over the wire, refreshes the cache via
  storeOperatorTerms so unrelated readers see the same snapshot,
  and returns the pubkey. Wired into wallet.NewArk and
  vtxo.NewManager at construction time.
- vtxo/refresh_operator_rotation_test.go: regression test with four
  subtests — rebuild against fetched key, fall back when fetch
  unset, propagate fetch errors, reject nil fetched keys.
- wallet/refresh_operator_rotation_test.go: parallel regression
  test for composeRefreshTemplate, completing the coverage the vtxo
  side pins.
@ellemouton
ellemouton force-pushed the ellemouton/issue-520-refresh-after-rotation-repro branch from 493fd2a to 520c32d Compare May 22, 2026 22:33
@ellemouton

Copy link
Copy Markdown
Member Author

Round 4 review addressed:

  • Issue-number citations stripped from code comments (8 places across vtxo/actor.go, vtxo/manager.go, vtxo/policy.go, wallet/wallet.go, wallet/policy.go, darepod/server.go, and the two test files). The surrounding explanatory prose stands on its own — the design principle ("VTXOs commit to their operator key for life, the new output's key is chosen at join time") was already spelled out around the citations. Per CLAUDE.md, that context belongs in the PR description, not in the code where it rots once the issue closes.
  • "per CLAUDE.md's logging policy" self-reference removed from the WarnS comment in processOutbox; the reasoning ("external trigger, not internal bug") stands on its own.

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM 🏏

@Roasbeef
Roasbeef merged commit 80e0fa6 into main May 23, 2026
18 checks passed
@ellemouton
ellemouton deleted the ellemouton/issue-520-refresh-after-rotation-repro branch May 23, 2026 00:17
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.

client: refresh/join round can fail after operator key rotation

2 participants