Skip to content

Commit a235c72

Browse files
JSKittyclaude
andcommitted
fix(concord): v1 accepted writes into a dissolved community
CORD-02 §9 seals a dissolved community: no peer accepts another event, ever. v2 enforces that in `chat_send_context`, which every v2 send routes through. v1 has no shared send gate, so message, file, typing and hide each let a write through — it would sit pending forever with no reason given. Guarded on the write paths, deliberately NOT on `resolve_channel`: reads share it and a sealed community must stay browsable. The file check moves ahead of the encrypt + upload, matching that path's existing rule of never spending an upload on an unroutable send. `dissolved` is also surfaced now — in the core listing, as `Community:: is_dissolved()`, and in the agent tool description. Without it a bot cannot tell a sealed room from a live one and retries into a tombstone forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8120c11 commit a235c72

4 files changed

Lines changed: 92 additions & 1 deletion

File tree

crates/vector-agent/src/tools.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ impl VectorAgent {
452452

453453
// === Communities ===
454454

455-
#[tool(description = "List all Vector Communities held locally (owned or joined), each with its channels and channel ids. Use a channel id as the chat_id for get_messages.")]
455+
#[tool(description = "List all Vector Communities held locally (owned or joined), each with its channels and channel ids. Use a channel id as the chat_id for get_messages. `dissolved: true` means the community is permanently sealed — its history still reads, but no message or change will EVER be accepted again, so do not try to post there.")]
456456
async fn list_communities(&self) -> Result<CallToolResult, McpError> {
457457
let communities = self.core.list_communities().await;
458458
let json = serde_json::to_string_pretty(&communities).unwrap_or_else(|_| "[]".into());

crates/vector-core/src/community/v2/service.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7764,6 +7764,34 @@ mod tests {
77647764
assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
77657765
}
77667766

7767+
#[tokio::test]
7768+
async fn dissolution_seals_writes_but_not_reads() {
7769+
// CORD-02 §9: sealed means NO further activity, ever. Reads must survive —
7770+
// the history stays browsable, and only explicit user intent deletes it.
7771+
let (bed, owner, _member) = TestBed::new();
7772+
bed.swap_to(&owner);
7773+
let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
7774+
let general = community.channels[0].id;
7775+
send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
7776+
7777+
dissolve_community(&bed.relay, &community).await.unwrap();
7778+
let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7779+
7780+
for err in [
7781+
send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
7782+
send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
7783+
.await
7784+
.unwrap_err(),
7785+
send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
7786+
] {
7787+
assert!(err.contains("dissolved"), "every write is refused, got: {err}");
7788+
}
7789+
assert!(
7790+
texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
7791+
"but the history still reads"
7792+
);
7793+
}
7794+
77677795
#[tokio::test]
77687796
async fn only_the_owner_can_dissolve() {
77697797
let (bed, owner, member) = TestBed::new();

crates/vector-core/src/lib.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,11 @@ impl VectorCore {
10771077
"name": c.name,
10781078
"description": c.description,
10791079
"is_owner": is_owner,
1080+
// A dissolved community is SEALED: the row survives (history
1081+
// is never auto-deleted) but no write is ever accepted again.
1082+
// Without this a bot cannot tell it from a live one and
1083+
// retries sends into a tombstone forever.
1084+
"dissolved": c.dissolved,
10801085
// `readable` is the field a bot needs and could never
10811086
// compute: a private channel we've been told about but
10821087
// hold no key for is enumerable-but-unreadable, which
@@ -1102,6 +1107,7 @@ impl VectorCore {
11021107
"name": c.name,
11031108
"description": c.description,
11041109
"is_owner": crate::community::service::is_proven_owner(&c),
1110+
"dissolved": c.dissolved,
11051111
"channels": c.channels.iter()
11061112
.map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
11071113
.collect::<Vec<_>>(),
@@ -1871,6 +1877,7 @@ impl VectorCore {
18711877
.map_err(VectorError::Other);
18721878
}
18731879
let (community, channel) = self.resolve_channel(channel_id)?;
1880+
Self::ensure_v1_writable(&community)?;
18741881
let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
18751882
let reply = replied_to.filter(|r| !r.is_empty());
18761883
let ms = std::time::SystemTime::now()
@@ -1941,6 +1948,20 @@ impl VectorCore {
19411948
Some(_) => None,
19421949
None => Some(self.resolve_channel(channel_id)?),
19431950
};
1951+
// Same fail-fast rationale as the routing check above: a sealed community
1952+
// (CORD-02 §9) accepts nothing, so refuse before the encrypt + upload rather
1953+
// than burning a Blossom round-trip on a send that can never land. v2's own
1954+
// gate is inside the send, which is too late to save the upload.
1955+
match (&v2_target, &v1_target) {
1956+
(Some(c), _) => {
1957+
let cid = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1958+
if crate::db::community::get_community_dissolved(&cid).unwrap_or(false) {
1959+
return Err(VectorError::Other("this community has been dissolved".into()));
1960+
}
1961+
}
1962+
(None, Some((c, _))) => Self::ensure_v1_writable(c)?,
1963+
_ => {}
1964+
}
19441965
let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
19451966

19461967
let file_hash = crate::crypto::sha256_hex(&bytes);
@@ -2045,6 +2066,7 @@ impl VectorCore {
20452066
.map_err(VectorError::Other);
20462067
}
20472068
let (community, channel) = self.resolve_channel(channel_id)?;
2069+
Self::ensure_v1_writable(&community)?;
20482070
let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
20492071
service::publish_typing_signal(&transport, &community, &channel)
20502072
.await
@@ -2299,6 +2321,7 @@ impl VectorCore {
22992321
) -> Result<()> {
23002322
use crate::community::{envelope, inbound, service, transport::LiveTransport};
23012323
let (community, channel) = self.resolve_channel(channel_id)?;
2324+
Self::ensure_v1_writable(&community)?;
23022325
let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
23032326
let ms = std::time::SystemTime::now()
23042327
.duration_since(std::time::UNIX_EPOCH)
@@ -3218,6 +3241,19 @@ impl VectorCore {
32183241
}
32193242

32203243
/// Resolve a channel id to its owning Community + the Channel (with its secret key).
3244+
/// Refuse a WRITE into a sealed community (CORD-02 §9). Once dissolved, no honest
3245+
/// peer accepts another event, so a send would sit pending forever with no reason
3246+
/// given. v2 enforces this inside `chat_send_context`; v1 has no shared send gate,
3247+
/// so each write path calls this.
3248+
///
3249+
/// Reads are deliberately untouched — a dissolved community stays browsable.
3250+
fn ensure_v1_writable(community: &crate::community::Community) -> Result<()> {
3251+
if crate::db::community::get_community_dissolved(&community.id.to_hex()).unwrap_or(false) {
3252+
return Err(VectorError::Other("this community has been dissolved".into()));
3253+
}
3254+
Ok(())
3255+
}
3256+
32213257
fn resolve_channel(
32223258
&self,
32233259
channel_id: &str,

crates/vector-sdk/src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,33 @@ impl Community {
10421042
.unwrap_or_default()
10431043
}
10441044

1045+
/// Whether this community has been DISSOLVED (permanently sealed by its owner).
1046+
///
1047+
/// The local history survives — it is never auto-deleted — but the tombstone means
1048+
/// no relay will ever accept another message or control edit, by anyone. A bot that
1049+
/// does not check this retries sends into a sealed room forever, so gate any
1050+
/// unattended posting loop on it.
1051+
///
1052+
/// ```no_run
1053+
/// # async fn f(bot: &vector_sdk::VectorBot) -> Result<(), Box<dyn std::error::Error>> {
1054+
/// for community in bot.communities().await {
1055+
/// if community.is_dissolved().await { continue; }
1056+
/// // ...safe to post
1057+
/// }
1058+
/// # Ok(()) }
1059+
/// ```
1060+
pub async fn is_dissolved(&self) -> bool {
1061+
self.core
1062+
.list_communities()
1063+
.await
1064+
.into_iter()
1065+
.find(|v| {
1066+
v.get("community_id").or_else(|| v.get("id")).and_then(|i| i.as_str()) == Some(self.id.as_str())
1067+
})
1068+
.and_then(|v| v.get("dissolved").and_then(|d| d.as_bool()))
1069+
.unwrap_or(false)
1070+
}
1071+
10451072
/// A handle to one channel of this community by id.
10461073
pub fn channel(&self, channel_id: impl Into<String>) -> Channel {
10471074
Channel { core: self.core, id: channel_id.into(), kind: ChannelKind::Community }

0 commit comments

Comments
 (0)