Expand description
Persistence for Community protocol local state (GROUP_PROTOCOL.md).
Stores the secrets this account holds: the server-root key and per-channel keys
(epoch-tagged). The DB is already account-scoped (account_dir(npub)/vector.db), so
there is no npub column — a row belongs to whichever account’s DB it lives in.
At-rest encryption: when Local Encryption is on, every secret BLOB and every identifying
metadata field (names, relays, roles, banlist, owner attestation, invite material) is wrapped
with the account’s ENCRYPTION_KEY before it touches disk and unwrapped on read, via the
enc_*/dec_* helpers below. A raw DB then reveals no WHO/WHERE/WHAT. The discriminators
(32-byte raw key vs 60-byte ciphertext; looks_encrypted for text) let a half-migrated DB
read back correctly, so the toggle/PIN-rekey flows and the one-time backfill are safe to re-run.
Structs§
- Invite
Link SetRow - A folded per-creator public-invite-link set: the creator’s pubkey (hex) and their active link locators. Used to surface “X has N active invite links” in the UI.
- Pending
Channel Key - One parked key vend awaiting the control fold that proves its grant.
- Pending
Community Invite - A parked invite awaiting the user’s accept/decline decision.
- Public
Invite Record - A minted public-invite link the owner retains (to list + revoke).
Functions§
- advance_
channel_ epoch - Apply a received channel rekey’s new key — the atomic archive+head dual-write: in ONE transaction,
ARCHIVE
(channel, new_epoch) -> new_keyincommunity_epoch_keysAND advance the channel’s read-head (community_channels.epoch+channel_key) iffnew_epochexceeds the current head. A caught-up OLDER epoch is archived (its history stays decryptable) but never regresses the head. Atomic so a crash can’t leave the archive ahead of the head or the reverse. Returns whether the head advanced. Epoch comparison is done in RUST (the u64-as-i64 ≥2^63 SQL mis-order trap). - advance_
server_ root_ epoch - banned_
set - The banned-pubkey set for a community, lazily decrypted+parsed from DB on first use and held (no further DB/decrypt) until a fold or delete invalidates it.
- channel_
rekeyed_ at_ server_ epoch - The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel
progress marker that lets a resumed re-founding skip channels already cut.
0if unknown. - clear_
banlist_ cache - Drop every cached banlist. The stored sets belong to the previous account’s DB.
- clear_
channel_ community_ cache - Drop every cached channel→community mapping (account swap).
- community_
created_ at_ ms - Local first-save time of a community (≈ when this account joined or created it), in ms.
created_atis set on the first save and preserved across metadata re-saves, so it tracks the join moment. Used to sort a not-yet-active community by join time.Noneif unknown. - community_
exists - Whether a Community with this id is already stored locally (joined). Cheaper than
load_communitywhen only existence matters (e.g. inbound-invite dedup). - community_
id_ for_ channel - community_
invite_ join_ counts - Per-link join counts for the owner’s public invites:
label -> distinct joinerswho joined via a link minted byinviter_npub(bech32). Reads theinvited-by/invited-labeltags on MemberJoined system events; distinct by joiner npub so a rejoin isn’t double-counted. Labels are unique per creator (random fallback ensures it), so (inviter, label) keys a single link. - community_
member_ activity - Observed participants: the best-effort member list of a Community, newest-active first.
Membership is NOT authoritative (a lurker who never posts and never announced won’t appear).
A member is included when they have real activity — a posted message/reaction/edit, OR a
join presence (kind 3306) — UNLESS that is superseded by a more-recent leave, OR they are
banned. So a “leave” actually removes a member, and a leave-then-rejoin/post re-adds them.
created_atis in seconds. Result is capped (anti-flood); see [COMMUNITY_MEMBER_CAP]. - community_
member_ activity_ capped community_member_activitywith the anti-flood display cap OPTIONAL. The migration roster seed passescapped = falseso a >500-member v1 community seeds EVERY member — a silent truncation there would permanently strand the dropped members (absent from the snapshot → absent frommemberlist()→ excluded from every future v2 rotation). Every other caller keeps the cap.- community_
protocol - The protocol a stored community runs, or
Noneif it isn’t held locally. - converge_
channel_ epoch - SAME-EPOCH convergence for a channel key (concurrent re-founding heal) — the channel counterpart to
converge_server_root_epoch. Adopts the winning re-founding’s channel key atepoch(the rekey addressed under the converged server root), replacing the one we minted in our own losing fork. Switches only while the channel is still ATepoch. Returns whether it switched. - converge_
edition_ head - Converge the head to a same-version fork winner (concurrent-edit resolution). Unlike
set_edition_head(which only ADVANCES the version), this resolves a fork AT the current version: two authorized editors editing concurrently from the same base both produceversion, and every client must adopt the SAME one. The winner is the lower deterministicinner_id, so this update fires only when the incoming edition ties the stored version AND carries a strictly lowerinner_id— monotonic toward the global minimum, so it can never flip-flop (a relay can’t churn the head by reordering, and a held row with a NULLinner_id, pre-migration, is treated as “always replaceable” so it heals to a ranked id). The version-advance path is unchanged and still handled byset_edition_head_with_id; callers run BOTH (advance covers v+1, converge covers a same-v fork). - converge_
edition_ head_ at_ epoch - As
converge_edition_head, scoped to an EXPLICIT epoch (the epoch the caller’s fold ran under) rather than the community row’s write-time value — same TOCTOU rationale asset_edition_head_at_epoch. - converge_
server_ root_ epoch - SAME-EPOCH convergence for the server root (concurrent re-founding heal): two BAN-holders who
re-founded at the same time each sit on their OWN root at the SAME epoch.
advance_server_root_epochrefuses to switch (its guard is strictly monotonic), so this is the sibling that REPLACES the head root atepochwith the deterministic winner (lowest root bytes — the caller decides). Archives the new root - delete_
community - Remove a Community and all its local state (channels, retained message keys, parked invites, minted public-invite tokens). Used when the user leaves a Community — there is no protocol “leave” (membership is key possession), so leaving is purely local: drop the keys + stop subscribing.
- delete_
community_ retain_ keys - self-removal teardown: drop all local community state EXCEPT the held epoch keys
(
community_epoch_keys). Read access to future epochs is already gone (the post-removal keys are never delivered); retaining the OLD keys only preserves the ability to author a3305self-delete of one’s own past messages, each sealed under the epoch key it was sent at. Used by every self-removal trigger (voluntary leave, kick of me, ban-rekey exclusion). - delete_
message_ key - Remove a retained message key (after a successful deletion publish).
- delete_
pending_ invite - Drop a parked invite without joining (the user declined).
- delete_
public_ invite - Forget a minted public-invite token (after revoking it on relays).
- drop_
pending_ channel_ key - Discharge ONE candidate (refused, superseded, expired, or undecodable).
- drop_
pending_ channel_ keys_ for - Discharge every candidate for a channel — a key landed, so the rest are moot.
- edition_
head_ entity_ ids - The set of control-entity ids (hex) this account tracks a head for. A base rotation gates its head-advance on re-anchoring covering EVERY one of these (not just a matching count), so a relay that withholds one entity’s editions while over-serving another’s can’t slip a thinned control plane past the rotator.
- get_
all_ edition_ heads - Every tracked control entity’s persisted head
(entity_id hex → (version, self_hash)). This is the per-entity refuse-downgrade FLOOR: the fold seeds each entity’s chain from its held head, so a withholding relay serving editions BELOW what we already hold can’t roll an authority chain back (e.g. resurrecting a since-revoked admin’s old grant). An empty map = a bootstrapping joiner (folds from genesis, floor 0). - get_
all_ edition_ heads_ epoched - get_
all_ edition_ heads_ full - Every tracked head as
entity_hex → (epoch, version, self_hash)— the epoch-primary floor. The caller seeds the fold with ONLY the entities at the community’s CURRENT epoch (a head recorded at a PRIOR epoch belongs to a superseded founding, so its entity folds fresh from the new epoch’s v1 genesis). This is what lets a re-founding’s compacted v1 plane land without a version-only downgrade. Every tracked head asentity_hex → (epoch, version, self_hash, inner_id)— the epoch-primary floor INCLUDING the deterministic tiebreak key, so a fold can resolve a same-version fork at the floor (converge to the lower inner id) instead of wedging on it. - get_
community_ ban_ marks - Per-npub ban marks: lowercase-hex npub →
created_at(secs) of the newest AUTHORIZED banlist edition that named them. Retained past an un-ban on purpose — it is what stops a pre-ban Join resurrecting a phantom member (CORD-02 §5 counts observation forward of the latest Leave, Kick or Ban). - get_
community_ banlist - A Community’s current banlist (hex pubkeys). Empty for an unknown community or empty list.
- get_
community_ banlist_ at - The
created_at(secs) of the banlist edition currently stored, or 0 if none. The version floor the rollback guard compares against. - get_
community_ dissolved - Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone.
falsefor an unknown community. - get_
community_ invite_ registry - A Community’s current invite-link registry (active link locators, hex). Empty for an unknown
community or a Private one.
is_public= this is non-empty (computed mode). - get_
community_ pins - A channel’s stored Pin List head:
(raw content, version),Nonewhen no edition has ever folded for it. - get_
community_ roles - A Community’s cached role graph. Empty (default) for an unknown community or none stored.
- get_
community_ roles_ at - The
created_at(secs) of the role-graph edition currently stored, or 0 if none. - get_
edition_ head - The current head
(version, self_hash)of a control entity’s edition chain, orNoneif no edition is held yet (so the next edition is the genesis, version 1, no prev_hash). - get_
edition_ head_ inner_ id - The held head’s tiebreak key (
inner_id), orNoneif unheld or pre-migration (NULL). The consumer uses this to decide a same-version convergence exactly asconverge_edition_head’s SQL does (a NULL/None held id is “always replaceable”) — so it never applies a display edit the head write would then refuse. - get_
guestbook - Load a community’s persisted Guestbook: the raw membership events + the
newest-seen cursor (relay seconds).
([], 0)when never synced; unparseable stored JSON degrades the same way (the next sync re-seeds from zero). - get_
invite_ link_ sets - Every creator’s active invite-link set for a Community (creator hex + locators). Empty for a Private community (or one not yet re-folded since this table was added).
- get_
message_ key - Read (WITHOUT removing) the retained key for a message by its INNER message id (what
the UI holds). Returns the ephemeral signing
Keys, the OUTER event id to NIP-09-delete, and the relay set — orNoneif not retained (someone else’s message, or already deleted). Peek-only so the key survives a failed deletion publish; the caller removes it withdelete_message_keyonly after the publish succeeds. - get_
migrated_ to - The flip fence readout — every v1 write path checks this first.
None= not migrated. - get_
migration_ ledger - Read the wizard ledger row:
(v2_community_id, phase, twin_json). - get_
migration_ pointer - The persisted migration payload JSON, if any.
Nonefor unknown/pointer-less communities. - get_
pending_ channel_ keys - Every parked vend for a community, for the post-fold re-judge.
- get_
pending_ invite - Read a parked invite’s bundle WITHOUT removing it. Accept is fallible (caps,
owner/authority collision), so the row must survive a rejected accept — peek here,
then
delete_pending_inviteonly after the join succeeds. - get_
read_ cut_ pending - Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban
flow retries the re-seal whenever this is set.
falsefor an unknown community. - get_
read_ cut_ target_ epoch - The base epoch a pending read-cut must reach (see
set_read_cut_target_epoch).0for an unknown community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63. - get_
server_ root_ epoch - Apply a received SERVER-ROOT (base) rekey’s new root — the base counterpart to
advance_channel_epoch, atomic: in ONE transaction, ARCHIVE(server-root scope, new_epoch) -> new_rootincommunity_epoch_keysAND advance the base head (communities.server_root_epoch+server_root_key) iffnew_epochexceeds the current base epoch (monotonic, compared in RUST). A caught-up OLDER base epoch is archived (its control/base history stays decryptable) but never regresses the head. Returns whether the head advanced. The community row’s CURRENT base epoch — the cheap freshness probe a root-derived write compares its in-hand struct against. - held_
epoch_ key - The held key for one specific
(scope, epoch), orNoneif not held. The open path uses this to select the decryption key by the inbound event’sepochtag. - held_
epoch_ keys - Every held
(epoch, key)for a scope, ascending by epoch. The read paths derive a pseudonym per returned epoch (#zOR-set) so cross-epoch history isn’t stranded. Sorted in Rust (not SQL): epoch is a u64 stored as i64, so a SQLORDER BYwould mis-order epochs >= 2^63. - is_
author_ banned - Whether
authoris banned in this community. The per-event hot check: a HashSet lookup once warm, no DB / decrypt / parse. - list_
all_ public_ invites - All minted public-invite links across ALL communities (backfill source for the synced Invite List).
- list_
community_ ids - Ids of every locally-stored Community.
- list_
pending_ invites - All parked invites, newest first.
- list_
public_ invites - All minted public-invite links for a Community, newest first.
- load_
community - Load a Community and its channels by id. Returns
Noneif not stored locally. - load_
community_ v2 - Load a v2 community by id, or
Noneif absent / not a v2 community. - mark_
channel_ rekeyed_ at_ server_ epoch - Record that a channel’s key has been rotated to cover base epoch
server_epoch(a read-cut step). Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting. - merge_
community_ ban_ marks - MERGE fresh ban marks into the stored set, keeping the LATER time per npub and never dropping an npub. A fold only sees the editions still in its window, so replacing wholesale would forget every ban that has since aged out — precisely the history the suppression depends on.
- migration_
flip_ candidates - Communities whose flip is UNFINISHED: a pointer is held but
migrated_tonever landed (crash between the v2 join and the flip txn, an unopenable-mretry, or a stale-root walk that can now advance). The boot maintenance re-drives each. - migration_
sweep_ candidates - v1 communities the boot sweep must probe for a migration signpost: never flipped and not yet migration-checked, SEALED OR NOT. Sealed ones sort first (the common case); bounded per sweep so a long v1 tail can’t storm the relays on every boot.
- park_
channel_ key - Park a vended channel key as a CANDIDATE.
- pending_
invite_ exists - pending_
invite_ received_ at - Whether an invite for this id is already parked (inbound dedup).
When a parked invite ARRIVED (unix secs), or
Noneif none is parked. The supersession key for the purge: an invite that arrived AFTER a removal is a genuine re-invite, not residue of the leave. - purge_
expired_ pending_ invites - Drop parked invites past their sender-declared NIP-40 expiry OR past the recipient-enforced 24h lifetime (measured from park time — conservative, since parking postdates sending). The lifetime leg is what clears rows parked by builds that predate the ingest-time rule: a machine dormant for a month otherwise boots into a page of fossil invites no other culler can touch (no declared expiry, never held, never tombstoned). Returns the count purged.
- purge_
pending_ invites_ for_ held_ communities - Drop every parked invite for a community we ALREADY hold — once joined on any device, the
invite must never resurface. Ordering-independent: covers the cross-device case where the
historical gift-wrapped invites are ingested BEFORE the synced membership list rehydrates
those communities (so the ingest-time
community_existsguard saw nothing yet). Returns the count purged. - reparent_
channels_ and_ fence - Re-parent every stitched channel row from the v1 community to the v2 twin, in ONE
transaction, AND stamp the terminal fence (
migrated_to+dissolved) on the v1 row. The dedicated migration transaction the v2 hijack guard (save_community_v2) forces: the generic v2 save SKIPS foreign-owned channel rows, so nothing but this may adopt them. Callers must NOTsave_community_v2after this: every in-memory v2 view at flip time is CHANNEL-LESS (its channel ids were v1-owned, so the pre-flip saves skipped them), and the v2 save PRUNES channel rows absent from the passed struct — a post-flip re-save DELETES the just-re-parented rows. Public channels fold from the control plane; nothing needs a re-save. Idempotent: a crash re-run re-parents zero rows and the fence writes are no-ops. - replace_
invite_ link_ sets - Replace ALL of a Community’s per-creator invite-link sets with the freshly-folded set (latest-wins). Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator view stays in lockstep with the flat registry computed in the same fold.
- save_
community - Persist a Community and all its channels (upsert). Secrets are stored as raw blobs in the account-scoped DB.
- save_
community_ v2 - Persist a v2 community + its channels atomically. UPSERT so a metadata re-save preserves banlist/roles (managed by the fold, not here).
- save_
pending_ invite - Park an inbound invite bundle for explicit user consent (the carrier never
auto-joins). First-invite-wins:
INSERT OR IGNOREmeans a later invite for the samecommunity_idcan’t silently rewrite a parked bundle. Returns whether a new row was inserted (false= already pending, caller should not re-notify). - save_
public_ invite - Retain a minted public-invite token so the owner can later list + revoke it.
- seat_
channel_ key - Seat a channel key on a channel that currently holds NONE, without the monotonic epoch guard.
- set_
community_ banlist - set_
community_ dissolved - Set the owner-dissolution SEAL on a community — PERMANENT + irreversible (no clear path; there is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once set, the control fold stops advancing and the inbound path drops every subsequent event. Seal a community as dissolved. Returns whether this call TRANSITIONED it (a live→dissolved flip) so the caller can fire the one-time death notification exactly once — a re-wrapped tombstone (fresh outer id, same owner seal) then can’t spam the handler.
- set_
community_ invite_ registry - Replace a Community’s cached invite-link registry (active link locators, hex), folded from the
owner-signed vsk=5 edition. Empty = Private. The version floor lives in
community_edition_heads(the registry’s own entity), so this is just the content cache (mirrorsset_community_banlist). - set_
community_ pins - Persist a channel’s folded Pin List head — the RAW carried content (either self-describing form), never a re-serialization: republishing must carry the exact bytes, and the byte cap judges what the wire carried. Encrypted at rest like the banlist — a public-form list carries disclosed message keys.
- set_
community_ roles - Replace a Community’s cached role graph (the aggregated
CommunityRoles) + thecreated_at(secs) of the newest per-entity edition it was built from.atis the version floor: the fetch path only calls this with a strictly-newer aggregate, so the role graph can’t roll backwards (same guard as the banlist). - set_
edition_ head - Record the current head (version + self_hash) of a control entity’s edition chain (keyless model).
The send side reads this to emit the next edition as
version+1citingself_hashasprev_hash; the fold uses it as the per-entity refuse-downgrade floor + anchor. Upserts per (community, entity).inner_idis the head edition’s deterministic tiebreak key (used only byconverge_edition_head). - set_
edition_ head_ at_ epoch - As
set_edition_head_with_id, stamping an EXPLICIT epoch — the epoch the caller’s fold actually ran under — instead of reading the community row at write time. Closes the TOCTOU where a concurrent re-founding bumpsserver_root_epochbetween a fold and its head persist, which would stamp an old-plane version as the new epoch’s floor and wedge the new epoch’s genuine head. - set_
edition_ head_ with_ id - As
set_edition_head, but also records the head edition’sinner_id(the deterministic tiebreak key), so a later same-version convergence can rank against it. A plain advance carries it through. - set_
guestbook - Persist a community’s Guestbook events + cursor (encrypted at rest, like the community row itself). The caller owns dedup/merge — this is a plain replace.
- set_
migrated_ to - Terminal flip fence: the v2 community id this v1 community migrated to. Set ONLY inside the flip transaction. One-way (no clear path) — mirrors the dissolved seal’s discipline.
- set_
migration_ checked - Mark a dissolved community’s tombstone as migration-checked (found to be a plain
{}dissolution) so the boot sweep stops re-probing it. Set implicitly byset_migration_pointertoo — either outcome converges the sweep. - set_
migration_ ledger - Upsert the wizard’s ledger row (phase reached + serialized twin state).
- set_
migration_ pointer - Persist the extracted migration payload. Overwrite-idempotent (pointer selection is total: the newest payload-carrying owner tombstone wins, so re-persisting is harmless).
- set_
read_ cut_ pending - Mark (or clear) that a PRIVATE-community ban’s base re-seal (read-cut) is OUTSTANDING — set when the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later instead of silently leaving a banned member with read access.
- set_
read_ cut_ target_ epoch - Set the base epoch a pending read-cut (re-founding) must reach. The re-seal rotates the base only
while
server_root_epoch < target, so a retry never double-rotates a base that already advanced. Set toserver_root_epoch + 1on a fresh exclusion delta (ban add / privatize); left untouched on a pure resume so the in-flight target is preserved. - store_
epoch_ key - Store one held epoch key in the multi-held archive.
scope_idis a channel_id hex orcrate::community::SERVER_ROOT_SCOPE_HEX. The(community, scope, epoch)PK makes a write for one epoch unable to disturb another epoch’s key — so retained history survives a rekey. Uses REPLACE on the exact coordinate so the fork-resolution apply path can commit the winning key for a contested epoch over a previously-stored loser (the only legitimate same-coordinate overwrite; an epoch key is otherwise immutable). - store_
message_ key - Retain the ephemeral signing key of a message I published, so I can later
NIP-09-delete it.
relaysis where the deletion must be sent. - take_
message_ key - Peek + remove in one call. Prefer
get_message_key+delete_message_keywhen a fallible step sits between, so a failure doesn’t strand the key. - upsert_
invite_ link_ set - Upsert ONE creator’s invite-link set (optimistic local update after the local user mints/revokes their own links, mirroring the flat-registry merge). An empty set removes the row.
- upsert_
public_ invite - Insert a public-invite row only if its (decrypted) token isn’t already present — idempotent hydration
from the synced Invite List, PRESERVING the original
created_at(unlikesave_public_invite, which stamps now). Returns true if a row was inserted. Tokens are stored encrypted with a random nonce, so SQL equality can’t dedup; scan + decrypt (few rows per community, owner-only).