add guestbook and moderation
This commit is contained in:
@@ -389,40 +389,91 @@ pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str,
|
||||
pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey }
|
||||
impl ControlWriter {
|
||||
pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_community_metadata(&self, keys, community_id, metadata, head,
|
||||
citation: Option<AuthorityCitation>, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_channel_metadata(&self, keys, channel, metadata, head, citation, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_role(&self, keys, role: &Role, head, citation, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_grant(&self, keys, community_id, grant: &Grant, head, citation, at_secs) -> Result<(Event, EntityHead)>;
|
||||
pub fn set_banlist(&self, keys, community_id, banned: &BTreeSet<PublicKey>, head, citation, at_secs)
|
||||
-> Result<(Event, EntityHead)>;
|
||||
}
|
||||
```
|
||||
|
||||
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`.
|
||||
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. **Every wrapper takes the citation** (M5): a delegated admin edits metadata, roles, grants and the banlist only under a `vac`, so a wrapper that hardcoded `None` would be an owner-only API. Only `publish` is usable without one. A remote signer (NIP-46) is not yet plumbed — every entry point takes `&Keys`, not a `NostrSigner`.
|
||||
|
||||
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key.
|
||||
|
||||
### 8.3 Guestbook and member list (`guestbook.rs`)
|
||||
### 8.3 Guestbook and member list (`guestbook.rs`) — implemented in M5
|
||||
|
||||
```rust
|
||||
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||
pub const KIND_KICK: u16 = 3309;
|
||||
pub const KIND_SNAPSHOT: u16 = 3312;
|
||||
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
|
||||
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
|
||||
|
||||
pub enum GuestbookEntry {
|
||||
Join { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> },
|
||||
Leave { member: PublicKey, at_ms: u64 },
|
||||
Kick { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option<AuthorityCitation> },
|
||||
Snapshot { refounder: PublicKey, members: Vec<PublicKey>, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 },
|
||||
}
|
||||
pub struct GuestbookRumor { pub id: EventId, pub author: PublicKey, pub kind: Kind, pub at_ms: u64,
|
||||
pub entry: GuestbookEntry }
|
||||
pub enum MemberState {
|
||||
Joined { at_ms: u64, invited_by: Option<(String, String)> },
|
||||
Left { at_ms: u64 },
|
||||
Kicked { at_ms: u64, actor: PublicKey },
|
||||
}
|
||||
|
||||
pub fn coalesce(events: &[GuestbookEvent], now_ms: u64, snapshot_authority: Option<&PublicKey>,
|
||||
pub fn build_join(member: PublicKey, invited_by: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent;
|
||||
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent;
|
||||
pub fn build_kick(actor, target: &PublicKey, citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent;
|
||||
pub fn build_snapshot_chunks(refounder, members: &[PublicKey], snapshot_id: [u8; 32], at_ms)
|
||||
-> Vec<UnsignedEvent>;
|
||||
pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys)
|
||||
-> Result<(Event, Keys), GuestbookError>;
|
||||
pub fn open(wrap: &Event, group: &GroupKey)
|
||||
-> Result<(OpenedStream, GuestbookRumor), GuestbookError>;
|
||||
|
||||
pub fn coalesce(rumors: &[GuestbookRumor], now_ms: u64, snapshot_authority: Option<&PublicKey>,
|
||||
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool)
|
||||
-> BTreeMap<PublicKey, MemberState>;
|
||||
|
||||
pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
|
||||
observed: &BTreeMap<PublicKey, u64>, // author → newest ms published
|
||||
banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>,
|
||||
refound: Option<&Refound>) -> BTreeSet<PublicKey>;
|
||||
granted: &BTreeSet<PublicKey>,
|
||||
banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>)
|
||||
-> BTreeSet<PublicKey>;
|
||||
```
|
||||
|
||||
- Entries dated more than an hour ahead of local time are dropped. An `ms` outside `0..999` drops the entry rather than being interpreted.
|
||||
- The plane is community-wide, and its key already carries the epoch, so a guestbook rumor binds no
|
||||
`channel`/`epoch` tags and `GuestbookRumor` carries neither. Coalescing spans every held epoch, which
|
||||
is what lets a snapshot bridge a Refounding.
|
||||
- Entries dated more than an hour ahead of local time are dropped. That is a coalesce-time check (`now_ms`
|
||||
is an argument, so it is testable without a clock); the `ms` range is not — `open` inherits
|
||||
`open_wrap`'s strict resolution, so an `ms` outside `0..999` drops the entry at parse instead.
|
||||
- Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id.
|
||||
- A Kick counts only when its signer holds `KICK` and outranks the target.
|
||||
- A Snapshot counts only from the npub whose Refounding minted the epoch. There is deliberately no owner fallback.
|
||||
- The member list is `coalesced Joined ∪ observed authors − banlist`, and observation counts **forward only** (an author re-enters on activity newer than their latest Leave/Kick/Ban). A Grant holder with `ms == 0` is present by construction.
|
||||
- A Join or Leave is self-signed by construction: `member` comes from the rumor's own author, so there is
|
||||
no wire field that could name somebody else.
|
||||
- A Kick counts only where `can_kick(actor, target, citation)` admits it — the roster's `KICK` plus a
|
||||
strictly higher rank plus a resolvable citation, composed by the caller. A Snapshot counts only from the
|
||||
npub whose Refounding minted the epoch. There is deliberately no owner fallback.
|
||||
- `invited_by` is an echo of the optional `invite` tag, never authority, so a malformed or repeated tag
|
||||
costs the label and not the member's own word.
|
||||
- `build_snapshot_chunks` is the only snapshot builder: 400 members per event, every chunk sharing one id
|
||||
and one timestamp. A chunk is independently useful, so `coalesce` seeds each chunk's members at that
|
||||
chunk's own time and never waits for its siblings.
|
||||
- The member list is `coalesced Joined ∪ observed authors ∪ Grant holders − banlist`. `granted` is the one
|
||||
addition to the spec's literal formula: a Grant recipient holds keys, so they are a member even with no
|
||||
Join and no published activity. It counts as an *unjdated* positive, so any dated Leave, Kick or Ban
|
||||
beats it. Observation counts forward only: an author re-enters on activity newer than their latest
|
||||
Leave, Kick or Ban.
|
||||
- `banned_at` is caller-supplied, and an entry missing from it excludes its npub outright — an empty map
|
||||
therefore means "every ban is terminal", which is the safe reading. The fold does not yet expose the
|
||||
banlist head's timestamp, so that plumbing belongs with the registry rather than here.
|
||||
- The earlier sketch's `refound: Option<&Refound>` argument is dropped: nothing mints a Refound until M7,
|
||||
and a parameter no caller can fill is a guess. It returns with the refounding that produces one.
|
||||
|
||||
### 8.4 Chat plane (`chat.rs`) — implemented in M4
|
||||
|
||||
@@ -436,7 +487,7 @@ pub enum ChatAction {
|
||||
Message { reply_to: Option<ReplyRef>, thread_root: Option<ReplyRef> },
|
||||
Reaction { target: EventId, emoji: String },
|
||||
Edit { target: EventId, content: String },
|
||||
Delete { target: EventId, target_kind: Option<u16> },
|
||||
Delete { target: EventId, target_kind: Option<u16>, citation: Option<AuthorityCitation> },
|
||||
Typing,
|
||||
Opaque,
|
||||
}
|
||||
@@ -447,7 +498,8 @@ pub fn build_message(author, channel, epoch, content, quote: Option<&ReplyRef>,
|
||||
pub fn build_comment(author, channel, epoch, content, parent: &Target, root: Option<&Target>, at_ms) -> UnsignedEvent;
|
||||
pub fn build_reaction(author, channel, epoch, target: &Target, emoji: &str, at_ms) -> UnsignedEvent;
|
||||
pub fn build_edit(author, channel, epoch, target: EventId, content: &str, at_ms) -> UnsignedEvent;
|
||||
pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option<u16>, at_ms) -> UnsignedEvent;
|
||||
pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option<u16>,
|
||||
citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent;
|
||||
pub fn build_typing(author, channel, epoch, at_ms) -> UnsignedEvent;
|
||||
|
||||
pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool)
|
||||
@@ -455,7 +507,9 @@ pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epheme
|
||||
pub fn open(wrap: &Event, group: &GroupKey, channel: &ChannelId, epoch: Epoch)
|
||||
-> Result<(OpenedStream, ChatRumor), ChatError>;
|
||||
pub fn plane_keys(held: &[(Epoch, [u8; 32])], channel: &ChannelId) -> Result<Vec<(Epoch, GroupKey)>>;
|
||||
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage>;
|
||||
pub fn fold(rumors: &[ChatRumor],
|
||||
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool)
|
||||
-> Vec<ChatMessage>;
|
||||
|
||||
pub struct ChatMessage {
|
||||
pub id: EventId,
|
||||
@@ -480,10 +534,12 @@ pub struct ChatMessage {
|
||||
newest first; mutations replay ascending on `(at_ms, Reverse(id))` so the last one
|
||||
applied wins — the highest `at_ms` and, between equal ones, the lower id. A deletion is
|
||||
terminal: a later edit never revives it.
|
||||
- **M4 honors a delete only from the message's own author.** A moderator delete (a `vac`
|
||||
citation under `MANAGE_MESSAGES`) needs the roster, so the builder's `citation`, the
|
||||
fold's `can_delete` predicate and its tests land with M5. Failing closed here loses a
|
||||
moderator's reach, never a member's authorship.
|
||||
- **Since M5 a delete is honored from the message's own author unconditionally, or from anyone
|
||||
`can_delete` admits.** That predicate is `(actor, citation, target_author)`, composed by the caller
|
||||
from the roster: a resolvable `vac` citation, `MANAGE_MESSAGES`, and a strictly higher rank than the
|
||||
author — the delete is the one Chat-plane authority action, which is why the builder takes a citation
|
||||
and the fold takes a gate. A self-delete never consults the predicate, matching CORD-02 §9's carve-out
|
||||
that a member's erasure of their own words survives even Dissolution.
|
||||
- `kind 15` is coop's own file-message convention, outside the CORD registry — it is
|
||||
accepted on the read side so a second coop device's files are not dropped, and
|
||||
`send_file` lands with the registry.
|
||||
@@ -543,7 +599,7 @@ pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // re-wrap h
|
||||
|
||||
Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored.
|
||||
|
||||
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3
|
||||
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3, banlist in M5
|
||||
|
||||
Three layers, no new storage engine:
|
||||
|
||||
@@ -587,11 +643,12 @@ pub struct CommunityState {
|
||||
pub channels: Vec<ChannelKeyRef>, // id, name, private, epoch
|
||||
pub relays: Vec<RelayUrl>,
|
||||
pub heads: Vec<EntityHead>, // entity, version, self_hash, inner id
|
||||
pub banned: BTreeSet<PublicKey>, // the held banlist, fed back into the next fold
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
```
|
||||
|
||||
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key.
|
||||
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), `dissolved` (needs the tombstone, M7), and `observed`/`guestbook` (need the guestbook ingest of §10). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. `banned` is a `BTreeSet` — serde has no such problem with a `Vec`-like sequence.
|
||||
|
||||
M3 added the two bridges between this document and the fold:
|
||||
|
||||
@@ -602,7 +659,7 @@ impl CommunityState {
|
||||
}
|
||||
```
|
||||
|
||||
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it.
|
||||
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit, and **assigns `banned` wholesale** (M5): the fold already retains a withheld list, so its output is the authority and a caller must not merge it by hand. `floors()` and `banned` are the two inputs the next `fold_control` call needs, which makes the state document a fold cache rather than a second source of truth.
|
||||
|
||||
Writes are debounced (a fold head changes on every edition); reads load once at init.
|
||||
|
||||
@@ -756,8 +813,9 @@ Each of these has burned a real implementation, or is a documented cross-client
|
||||
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points.
|
||||
- Lowercase hex only; x-only pubkeys only; no version tag anywhere.
|
||||
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate.
|
||||
- **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete honored only from the message's own author.
|
||||
- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, the guestbook's future-clock and Snapshot rules, and the write-side counterparts.
|
||||
- **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete from anybody other than the message's own author refused by the caller's gate.
|
||||
- **Enforced in M5:** a Kick counts only where the roster admits its actor under `KICK` with a strictly higher rank and a resolvable citation; a Snapshot counts only from the epoch's refounder, with no owner fallback; a guestbook entry more than an hour ahead is dropped, and an out-of-range `ms` or a non-verb `3306` entry is malformed, not interpreted; a duplicated `vac` is rejected outright; and a delete is honored from its target's author unconditionally, or from another actor only where `can_delete` admits them.
|
||||
- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts of the caps the folds already apply.
|
||||
|
||||
## 13. Milestones
|
||||
|
||||
@@ -768,7 +826,7 @@ Each of these has burned a real implementation, or is a documented cross-client
|
||||
| M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 |
|
||||
| M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (14 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
|
||||
| M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order |
|
||||
| M5 | Guestbook + member list + moderation | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test |
|
||||
| M5 | Guestbook + member list + moderation | ✅ `cargo test -p concord` (23 tests): a second holder folds joins, leaves, a cited kick and a chunked snapshot into one memberlist in either arrival order, with a ban and a Grant deciding the edges; an uncited, unranked or owner-directed kick and a foreign snapshot are dropped; a future-dated entry, a malformed `ms`, a non-verb `3306`, a duplicated `vac` and a bad snapshot chunk are each refused; and a moderator delete lands only under a citation the roster admits, while a self-delete never asks |
|
||||
| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 |
|
||||
| M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused |
|
||||
| M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet |
|
||||
@@ -783,9 +841,13 @@ M3 closed at 14 tests with no dependency change at all, and `Cargo.lock` untouch
|
||||
|
||||
M4 closed at 19 tests, again with no dependency change and `Cargo.lock` untouched — relay paging is `Client::fetch_events` over the same `NostrDatabase` the cache already used, so nothing new was needed. New: `src/chat.rs` (the whole channel plane) and, in `src/store.rs`, `backfill` plus the pure `advance` page step it is built from, which is what the paging test drives instead of a socket. `edition::canonical_decimal` became `pub(crate)` so the chat tag grammar shares one decimal check.
|
||||
|
||||
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`.
|
||||
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); and the **NIP-46 remote signer**, since every writer takes `&Keys` rather than a `NostrSigner`. Its persisted banlist and its role/grant/banlist write wrappers both landed in M5.
|
||||
|
||||
What M4 still defers, and to what: **moderator deletes and the `can_delete` predicate** (M5 — M4 honors a delete only from the message's own author, so a moderator's reach is missing rather than forged); **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8).
|
||||
What M4 still defers, and to what: **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8). Its moderator delete landed in M5.
|
||||
|
||||
M5 closed at 23 tests, again with no dependency change and `Cargo.lock` untouched. New: `src/guestbook.rs` (the whole membership plane — three rumor codecs, the coalesce, the memberlist), a `vac` citation on a kind-5 delete plus the fold's `can_delete` gate, `ControlWriter::set_role`/`set_grant`/`set_banlist`, and `CommunityState.banned`. Two shared helpers moved into `edition.rs` — `citation_tag` and `citation_from` — so the control, chat and guestbook grammars parse one `vac`. Every metadata/role/grant/banlist wrapper now takes its citation, which is a fix rather than an addition: M3's wrappers hardcoded `None` and so were owner-only, which the delegated-metadata test had been working around with `publish`.
|
||||
|
||||
What M5 still defers, and to what: the **guestbook's fetch and ingest path** — `coalesce` is fed wraps by its caller, so the plane has no `backfill` twin of `chat::backfill` until §10's sync engine needs one, and no `CommunityState.observed` to persist what it would learn; the **banlist head's timestamp** that `complete_memberlist`'s `banned_at` wants (the fold does not surface it, so an empty map means "every ban is terminal" until the registry plumbs it); and the **`Refound` seed** argument to `complete_memberlist`, which waits for M7 to mint one.
|
||||
|
||||
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user