add chat plane
This commit is contained in:
@@ -424,37 +424,80 @@ pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
|
||||
- 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.
|
||||
|
||||
### 8.4 Chat plane (`chat.rs`)
|
||||
### 8.4 Chat plane (`chat.rs`) — implemented in M4
|
||||
|
||||
Kinds (CORD-02 Appendix B): `9` message, `1111` NIP-22 comment, `7` NIP-25 reaction,
|
||||
`5` NIP-09 delete, `3302` edit, `3310` WebXDC peer signal, `23311` ephemeral typing.
|
||||
|
||||
```rust
|
||||
pub struct ChatRumor { id, author, kind, channel, epoch, at_ms, content,
|
||||
expiration: Option<Timestamp>, action: ChatAction }
|
||||
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> },
|
||||
Typing,
|
||||
Opaque,
|
||||
}
|
||||
pub struct ReplyRef { id: EventId, author: Option<PublicKey> }
|
||||
pub struct Target { reply: ReplyRef, kind: u16 } // the wire commits the target's kind
|
||||
|
||||
pub fn build_message(author, channel, epoch, content, quote: Option<&ReplyRef>, at_ms) -> UnsignedEvent;
|
||||
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_typing(author, channel, epoch, at_ms) -> UnsignedEvent;
|
||||
|
||||
pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool)
|
||||
-> Result<(Event, Keys), ChatError>;
|
||||
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 struct ChatMessage {
|
||||
pub id: EventId, // recomputed rumor id
|
||||
pub id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub channel: ChannelId,
|
||||
pub epoch: Epoch,
|
||||
pub kind: Kind, // 9 | 1111 | 3302 | 1740 | 15
|
||||
pub kind: Kind,
|
||||
pub content: String,
|
||||
pub media: Vec<SharedUri>,
|
||||
pub mentions: Vec<Mention>,
|
||||
pub reply_to: Option<EventId>, // lowercase `e`/`q`
|
||||
pub thread_root: Option<EventId>, // uppercase `E` for 1111
|
||||
pub reply_to: Option<EventId>, // a kind 9's `q`, or a comment's lowercase `e`
|
||||
pub thread_root: Option<EventId>, // a comment's uppercase `E`
|
||||
pub at_ms: u64,
|
||||
pub expiration: Option<Timestamp>,
|
||||
pub edited_at: Option<u64>, // folded from 3302
|
||||
pub deleted: bool, // folded from 5
|
||||
pub edited_at: Option<u64>,
|
||||
pub deleted: bool,
|
||||
pub reactions: BTreeMap<PublicKey, String>,
|
||||
}
|
||||
```
|
||||
|
||||
Sends funnel through one function so the rules cannot drift:
|
||||
|
||||
```rust
|
||||
fn publish_chat(store, client, community, channel, epoch, group, rumor, at_ms, ephemeral) -> Task<Result<Output<EventId, EventSendStatus>, Error>>;
|
||||
```
|
||||
|
||||
It builds the seal + wrap, mirrors the NIP-40 tag onto the wrap for durable kinds, publishes via `send_event(..).to(relays)`, retains the ephemeral wrap key for later NIP-09 scrubbing, and locally echoes its own wrap through the same ingest path so send-then-read works without waiting on a relay round-trip.
|
||||
|
||||
Disappearing messages (CORD-08) live here: `message_expiration` is read from the folded metadata, `["expiration", created_at + t]` is attached to every durable Chat rumor and to the wrap, kinds 5 and 1740 are exempt, ingest refuses an already-expired rumor, a periodic sweep purges stored ones, and the kind 1740 timer notice renders only when its author holds `MANAGE_METADATA`.
|
||||
- `open` returns the `OpenedStream` next to the typed rumor because the two halves go
|
||||
different ways: the caller caches the envelope, and folds the rumor.
|
||||
- Ordering is `(at_ms, id)` everywhere, ties on the lower inner rumor id. The fold emits
|
||||
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.
|
||||
- `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.
|
||||
- A reference's author slot is a SHOULD on the wire, so it is optional. NIP-25 nonetheless
|
||||
makes `p` a requirement, so a builder must be handed a `Target` built from the message it
|
||||
acts on and never one whose author is empty, or a peer that requires the tag drops the result.
|
||||
- `ms` orders a page but cannot page within one: a relay's `until` filter is second-granular,
|
||||
so the cursor step below is what has to cope with a boundary second.
|
||||
- `seal_rumor` gates the kind at publish and mirrors a NIP-40 `expiration` onto the wrap
|
||||
(CORD-08 §2). The timer's policy — ingest refusal, the sweep, kind 1740 — is M8.
|
||||
- **`media` and `mentions` are deliberately absent.** Both are pure post-processing of
|
||||
`content` by `common` (`extract_and_remove_media_urls`, `NostrParser`) and both return a
|
||||
gpui type, and a protocol crate does not take a UI dependency for a derived field. They
|
||||
land with the first consumer that renders them.
|
||||
|
||||
### 8.5 Invites (`invite.rs`)
|
||||
|
||||
@@ -514,11 +557,21 @@ Three layers, no new storage engine:
|
||||
```rust
|
||||
pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<()>;
|
||||
pub async fn query_rumors(database: &dyn NostrDatabase, channel: &ChannelId, until: Option<Timestamp>, limit: usize) -> Result<Vec<UnsignedEvent>>;
|
||||
pub async fn backfill(client: &Client, database: &dyn NostrDatabase, channel: &ChannelId,
|
||||
held: &[(Epoch, [u8; 32])], until: Option<Timestamp>, limit: usize)
|
||||
-> Result<Vec<ChatRumor>>;
|
||||
```
|
||||
|
||||
`query_rumors` returns `UnsignedEvent`, not `Event`: the cached payload *is* a rumor, which is also what `OpenedStream` carries, so the caller never has to re-parse.
|
||||
|
||||
**Deferred to M4:** the relay-paging `backfill`. It is network history paging whose "step past the same-second wall" policy belongs with the sync engine, and M1 has no subscription to test it against.
|
||||
**Landed in M4:** `backfill` — newest-first relay paging across every held epoch. It derives
|
||||
every held epoch's plane key once, fetches `kinds [1059, 21059]` by all of those addresses in
|
||||
one filter with an inclusive `until`, opens each wrap against the plane whose address it
|
||||
carries, caches it, and pages until the page is short of the limit, adds nothing new, or the
|
||||
cursor cannot advance. That last case is real: `until` has second granularity, so a page that
|
||||
begins and ends inside one boundary second has nowhere left to step and its remainder stays
|
||||
unreachable until a relay serves it. Capped at `MAX_PAGES` so a relay that only ever repeats
|
||||
itself cannot loop a client forever.
|
||||
|
||||
3. **Community state** — one local document per community, `Kind::ApplicationSpecificData` with `["d", "concord/<community_id>"]`:
|
||||
|
||||
@@ -673,6 +726,11 @@ pub fn pin(&self, id: EventId, cx: &App) -> Task<Result<(), Error>>; // vsk 11,
|
||||
|
||||
`CommunityEvent` and `ChannelEvent` mirror `ChatEvent`: one variant per thing the UI has to react to (`Updated`, `Members`, `Added`, `Removed`, `Dissolved`, `Error`, plus channel-level `Incoming`, `Reload`).
|
||||
|
||||
Every send funnels through one function so the rules cannot drift: it seals and wraps the
|
||||
rumor, mirrors any NIP-40 `expiration` onto the wrap, publishes via `send_event(..).to(relays)`,
|
||||
retains the ephemeral wrap key for a later NIP-09 scrub, and echoes its own wrap through the
|
||||
same ingest path so send-then-read never waits on a relay round-trip.
|
||||
|
||||
## 11. Integration with existing crates
|
||||
|
||||
1. **`crates/chat/src/lib.rs` — required fix, moved from M2 to the milestone that first subscribes.** `handle_notifications` currently treats *every* kind 1059 event as a NIP-59 gift wrap for the current user and pushes an unwrap failure into the trash. Concord wraps are kind 1059 with an ephemeral `p` tag, so they would flood the trash and leak error toasts. Route by `subscription_id` from `RelayMessage::Event` against `sub_id1`/`sub_id2`, and drop the `if rumor.tags.is_empty()` recipient heuristic. **M2 and M3 did not apply it**: the crate has no subscription and no `ConcordRegistry` yet, so no concord wrap can reach that handler and the change would be untestable. It lands with the sync engine (§10), as does the `concord::init` wiring in `desktop` and `web`.
|
||||
@@ -697,7 +755,9 @@ Each of these has burned a real implementation, or is a documented cross-client
|
||||
- Refuse to write a Pin List from a list the writer could not read.
|
||||
- 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. Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts.
|
||||
- **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.
|
||||
|
||||
## 13. Milestones
|
||||
|
||||
@@ -706,23 +766,27 @@ Each of these has burned a real implementation, or is a documented cross-client
|
||||
| M0 | Crate skeleton, `derive.rs`, golden vectors, workspace wiring | ✅ `cargo test -p concord` pins every derivation; all labels match Appendix A.6 |
|
||||
| M1 | `stream.rs` + `store.rs` | ✅ seal/wrap/open round-trips for both seal forms; hostile wraps rejected in the documented order; the local cache reads back with the group key gone |
|
||||
| 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` (15 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 | send/receive/edit/delete/reaction across two identities; history pages backwards across an epoch boundary (relay `backfill` lands here); binding checks reject a foreign channel/epoch |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone.
|
||||
Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone. The sync engine and its GPUI wiring (§10, §11) have no row of their own because they are cross-cutting: every plane they consume has to exist first, so they follow M8, and they are the milestone that applies §11's `chat::handle_notifications` routing fix.
|
||||
|
||||
M1 closed with `cargo test -p concord` (5 tests), `cargo clippy -p concord --all-targets`, and `cargo fmt -p concord --check` all clean. `rand` was added to the workspace pinned to the same `0.10.2` instance `nostr` already builds, so `Cargo.lock` gained no package.
|
||||
|
||||
M2 closed the same way at 7 tests, with `serde` added to the crate's dependencies (`serde_json` promoted from dev to main for the metadata content types) — `Cargo.lock` gained no package again, only the `concord → serde` edge.
|
||||
|
||||
M3 closed at 15 tests with no dependency change at all, and `Cargo.lock` untouched. New: `src/roles.rs` (permissions, Role/Grant/banlist content, `CommunityRoles`, the delegation fixpoint) and, in `src/control.rs`, `ControlFold` / `fold_control`, the metadata-and-channel fold, `ControlWriter` and its `Edition` input. `EntityHead` and `Floors` moved from `store.rs` into `edition.rs`, where `fold_head` now composes `fold` and `bootstrap_head` for the floor-aware case.
|
||||
M3 closed at 14 tests with no dependency change at all, and `Cargo.lock` untouched. New: `src/roles.rs` (permissions, Role/Grant/banlist content, `CommunityRoles`, the delegation fixpoint) and, in `src/control.rs`, `ControlFold` / `fold_control`, the metadata-and-channel fold, `ControlWriter` and its `Edition` input. `EntityHead` and `Floors` moved from `store.rs` into `edition.rs`, where `fold_head` now composes `fold` and `bootstrap_head` for the floor-aware case.
|
||||
|
||||
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 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).
|
||||
|
||||
**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.
|
||||
|
||||
## 14. Open questions and risks
|
||||
|
||||
Reference in New Issue
Block a user