Bug report — relay identity rotation orphans kind:39000/39001/39002, channels become permanently un-unarchivable
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
# Bug report — relay identity rotation orphans kind:39000/39001/39002, channels become permanently un-unarchivable
**Reporter:** @punjab
**Buzz Desktop:** 0.5.22
**Relay:** local, `crates/buzz-relay`, repo at commit `0b8ee8762`
**Observed:** 2026-09-04
## Summary
The relay signs group metadata (39000), admins (39001) and members (39002) with `state.relay_keypair`. Those are addressable events, replaced on `(kind, pubkey, d)`. **The pubkey is part of the replacement key.**
When the relay's signing identity changes, every previously-signed record stays live forever under the old pubkey. The relay then holds two "current" metadata records per channel — one per identity — and they can disagree. Nothing migrates or tombstones the old set.
The visible symptom: **a channel archived before the rotation and unarchived after it can never be unarchived.** The post-rotation record says unarchived, the pre-rotation record still says `["archived","true"]`, and the client resolves archived. Archive/unarchive cycling cannot fix it, because every new write lands under the new identity and never supersedes the old record.
Downstream, `AppShell.tsx:266-272` filters the sidebar on `channel.isMember && channel.archivedAt === null`, and `ChannelScreen.tsx:527` gates the composer on the same pair — so the channel silently disappears from the sidebar *and* becomes read-only, with no error and nothing in the UI to explain it.
## Evidence
On this relay the signing identity changed between **2026-08-25 18:44:46Z** (last event under the old key) and **2026-08-27 18:29:05Z** (first under the new one).
- old: `79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798`
- new: `a89645b88f68acadd3fa443462e1484c210bf03f46296afa6f99ffb2017d9f1a`
The old value is the secp256k1 generator point — the pubkey for private key `1`, i.e. the no-stable-key path, not a configured identity.
Both records were live simultaneously for channel `2e064824-8ac8-427c-8381-f44315985358`:
```
pubkey created_at tags
79be667e… 2026-08-19 02:43:40Z [… ["archived","true"]] <- pre-rotation, still authoritative
a89645b8… 2026-09-04 22:38:54Z [… no archived tag] <- post-rotation
```
The relay's own read path returns both. `channels search --include-archived --query curry` returned the same `channel_id` twice, once `archived: false` and once `archived: true`.
Split records existed on **7 channels** for kind:39000 and **7 each** for 39001 and 39002 — every channel whose metadata was written on both sides of the rotation. Membership contents happened to agree here, so only the archived flag produced a visible fault; a rotation that straddles a real membership change would split the member list too, and `isMember` drives the same two gates.
## Reproduction
1. Run a relay without `BUZZ_RELAY_PRIVATE_KEY` set, so it signs under the fallback identity.
2. Create a channel, then archive it.
3. Give the relay a stable key — `scripts/ensure-local-relay-key.sh` generates one when `.env` has the variable empty — and restart. Point it at the same Postgres.
4. Unarchive the channel through any client.
5. The channel stays hidden from the sidebar and its composer stays disabled. Querying kind:39000 for that `d` returns two current records disagreeing on `archived`.
## Impact
Silent and undiagnosable from the UI. The channel is not gone — messages, membership and history are all intact — but it is unreachable and no client surface says why. Every recovery action available to a user (re-archive, unarchive, restart, reinstall) writes under the new identity and cannot dislodge the stale record.
Scope is any deployment whose relay identity changes against a retained database: local and self-hosted relays that bootstrap a fresh key over an existing Postgres, or any key rotation. A relay that has always had one stable configured key is unaffected.
## Suggested fix
A migration is needed; a client-side tiebreak would only paper over it.
1. **On boot, detect a relay-identity change** — persist the last-used relay pubkey and compare.
2. **On change, re-sign or retire the previous identity's 39000/39001/39002 records.** Re-signing the latest record per `(kind, d)` under the new identity preserves state; deleting the old set works too, provided the new identity has already emitted a record for every affected `d`, or channels signed only under the old key would vanish.
3. **Fail loudly if neither has run** — a relay serving two identities' metadata for the same `d` is a corrupt state and should be reported at startup, not resolved silently by whichever record a client happens to pick.
Worth deciding separately whether the fallback identity should exist at all. A relay that signs addressable records under a well-known throwaway key produces exactly this class of unrecoverable state the moment it is given a real one.
## Workaround
Delete the pre-rotation records for the affected channel, keeping the post-rotation ones:
```sql
delete from events
where kind = 39000
and pubkey = decode('', 'hex')
and tags->0->>1 = '';
```
Verified on this relay: the duplicate disappeared, the channel returned to the sidebar, and all 52 messages plus the full member list were untouched. Back the rows up first — they are reinsertable verbatim, signatures included.
Contributor guide
Assessment
This issue has not been assessed yet.