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
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 - 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. - 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. - 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 - The hex id of the Community that owns
channel_id, if any is stored locally. Used to resolve a channel-addressed chat back to its Community for sending. - 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_
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).
- 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_ 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_ 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_
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. - 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. - 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. - pending_
invite_ exists - Whether an invite for this id is already parked (inbound dedup).
- 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. - 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.
- set_
community_ banlist - Replace a Community’s stored banlist (JSON array of hex pubkeys) + the
created_at(secs) of the edition it came from.atis the version: the owner’s own ban/unban writes its freshly built event time, andfetch_and_apply_banlistonly calls this with a strictly-newer edition, so the stored banlist can never roll backwards. - 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_ 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_
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).