Repository navigation
Concord v2: an already-expired message is stored at ingest #93
Description
A Concord v2 message that is already expired when it arrives is saved and delivered to the message handler. CORD-08 §3 says a reader must refuse it.
What happens
The Message arm of apply_chat_to_state does not check the rumor's NIP-40 expiration:
Vector/crates/vector-core/src/community/v2/inbound.rs
Lines 137 to 150 in 9525cf5
| ChatEvent::Message { opened, reply_to, emoji } => { | |
| let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey); | |
| // DB dedup: a known inner id is already stored — don't re-ingest/re-emit (a | |
| // catch-up sweep re-fetches the whole page; in-memory STATE holds only a window). | |
| if crate::db::events::event_exists(&msg.id).unwrap_or(false) { | |
| return None; | |
| } | |
| state.ensure_community_chat(channel_id); | |
| // Persist regardless of the STATE-add result: `event_exists` already proved | |
| // it's not in the DB, so a `false` here means only that another writer put it | |
| // in STATE first — the row must still be saved, or it's lost until re-fetch. | |
| state.add_message_to_chat(channel_id, &msg); | |
| Some(ChatPersist::New(msg)) | |
| } |
So an expired message is saved to the database, and on_community_message fires for it on the live path:
Vector/crates/vector-core/src/community/v2/realtime.rs
Lines 592 to 600 in 9525cf5
| inbound::DispatchedV2::Chat { channel_id, event } => { | |
| match inbound::persist_chat_event(&event, &channel_id, &my_pk).await { | |
| // "New" = first ingest, which a relay-backfill replay of a month-old | |
| // event also satisfies — is_new comes from the inner send time so | |
| // stale replays persist + surface without ringing anything. | |
| Some(inbound::ChatPersist::New(message)) => { | |
| let fresh = crate::community::is_realtime_fresh(message.at); | |
| handler.on_community_message(&channel_id, &message, fresh); | |
| } |
Backfill goes through the same apply_chat_to_state.
The DM path already drops this case on receipt:
Vector/crates/vector-core/src/rumor.rs
Lines 316 to 321 in 9525cf5
| let expiration = extract_nip40_expiration(&rumor); | |
| // NIP-40: an event arriving already expired is dropped on receipt — it | |
| // must never render or persist. | |
| if already_expired(expiration) { | |
| return Ok(RumorProcessingResult::Ignored); | |
| } |
When it happens
nostr-sdk drops an event whose outer expiration tag is in the past, so a normal expired wrap does not reach apply_chat_to_state. But CORD-08 §2 says the wrap tag is for relays, and "readers judge by the rumor's copy alone". Any member of the channel can re-wrap the signed seal without the outer tag, or with a later one. That wrap reaches the client, and the expired message is stored.
In the app, the self-destruct sweeper removes the row a few seconds later. SDK and CLI hosts do not run the sweeper (self_destruct::start_sweeper has no caller), so a bot keeps it.
Spec
CORD-08 §3: "MUST refuse an already-expired rumor at ingest — never store it".
Fix
I have a fix with a test. The Message arm returns None when the rumor's expiration is already in the past. It uses the same already_expired check as the DM path. I will open a PR against widescreen.
Activity
fix(concord): an already-expired v2 message is refused at ingest
JSKitty commentedon Oct 2, 2026
Closed by #94 💚